- Run record
- loading
Loading this Run…
Loading the Revisions of this Run…
A published source file of Revision 0 of Canvely — Spring Menu 1.0.0, shown as text in the browser.
Source is browser-viewable only. There is no download or archive endpoint, and no additional license is granted beyond inspection and research.
import { useSyncExternalStore } from "react";
import {
type Doc,
type Rect,
existing,
getIndex,
selectionBounds,
unionRect,
boundsOf,
} from "./model";
export type Tool = "move" | "rect" | "ellipse" | "text";
export interface View {
x: number;
y: number;
zoom: number;
}
interface Snapshot {
doc: Doc;
selection: string[];
}
export interface Toast {
id: number;
text: string;
tone: "info" | "warn";
}
export interface EditorState {
doc: Doc;
selection: string[];
past: Snapshot[];
future: Snapshot[];
tool: Tool;
view: View;
viewport: { w: number; h: number };
editing: { id: string; isNew: boolean } | null;
toast: Toast | null;
spaceDown: boolean;
}
export const HISTORY_LIMIT = 200;
export const MIN_ZOOM = 0.1;
export const MAX_ZOOM = 8;
const STORAGE_KEY = "canvely:spring-menu:document:v1";
let state: EditorState;
let initialDoc: Doc;
let gestureBase: Snapshot | null = null;
const listeners = new Set<() => void>();
export function getState() {
return state;
}
export function setState(patch: Partial<EditorState>) {
state = { ...state, ...patch };
listeners.forEach((l) => l());
}
function subscribe(l: () => void) {
listeners.add(l);
return () => listeners.delete(l);
}
export function useEditor(): EditorState {
return useSyncExternalStore(subscribe, getState, getState);
}
export function loadStoredDoc(validate: (d: unknown) => d is Doc): Doc | null {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as unknown;
return validate(parsed) ? parsed : null;
} catch {
return null;
}
}
function persist() {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(state.doc));
} catch {
// Storage may be full or disabled; the editor keeps working in memory.
}
}
export function initStore(doc: Doc, original: Doc) {
initialDoc = original;
state = {
doc,
selection: [],
past: [],
future: [],
tool: "move",
view: { x: 0, y: 0, zoom: 1 },
viewport: { w: 0, h: 0 },
editing: null,
toast: null,
spaceDown: false,
};
}
// ---------------------------------------------------------------------------
// History
/** Commit a finished change as exactly one undo step. */
export function commit(doc: Doc, selection: string[] = state.selection) {
if (gestureBase) {
setState({ doc, selection });
endGesture();
return;
}
if (doc === state.doc) {
setState({ selection });
return;
}
setState({
doc,
selection,
past: [...state.past, { doc: state.doc, selection: state.selection }].slice(-HISTORY_LIMIT),
future: [],
});
persist();
}
export function gestureActive() {
return gestureBase !== null;
}
export function beginGesture() {
if (!gestureBase) gestureBase = { doc: state.doc, selection: state.selection };
}
/** Live update during a gesture; not recorded until endGesture. */
export function live(doc: Doc, selection?: string[]) {
setState(selection ? { doc, selection } : { doc });
}
export function endGesture(selection?: string[]) {
const base = gestureBase;
gestureBase = null;
const sel = selection ?? state.selection;
if (!base) {
if (selection) setState({ selection: sel });
return;
}
if (state.doc === base.doc) {
setState({ selection: sel });
return;
}
setState({ selection: sel, past: [...state.past, base].slice(-HISTORY_LIMIT), future: [] });
persist();
}
export function cancelGesture() {
const base = gestureBase;
gestureBase = null;
if (base) setState({ doc: base.doc, selection: base.selection });
}
export function undo() {
if (gestureBase || state.editing) return;
const prev = state.past[state.past.length - 1];
if (!prev) return;
setState({
doc: prev.doc,
selection: existing(prev.doc, prev.selection),
past: state.past.slice(0, -1),
future: [...state.future, { doc: state.doc, selection: state.selection }],
});
persist();
}
export function redo() {
if (gestureBase || state.editing) return;
const next = state.future[state.future.length - 1];
if (!next) return;
setState({
doc: next.doc,
selection: existing(next.doc, next.selection),
future: state.future.slice(0, -1),
past: [...state.past, { doc: state.doc, selection: state.selection }],
});
persist();
}
export function resetDemo() {
gestureBase = null;
setState({
doc: initialDoc,
selection: [],
past: [],
future: [],
editing: null,
tool: "move",
});
try {
localStorage.removeItem(STORAGE_KEY);
} catch {
// ignore
}
fitAll();
}
// ---------------------------------------------------------------------------
// Selection & tools
export function select(ids: string[]) {
setState({ selection: ids });
}
export function setTool(tool: Tool) {
setState({ tool });
}
let toastTimer: number | undefined;
let toastId = 0;
export function showToast(text: string, tone: Toast["tone"] = "info") {
toastId += 1;
setState({ toast: { id: toastId, text, tone } });
window.clearTimeout(toastTimer);
toastTimer = window.setTimeout(() => setState({ toast: null }), 4200);
}
// ---------------------------------------------------------------------------
// View
export const clampZoom = (z: number) => Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, z));
export function setView(view: View) {
setState({ view });
}
/** Zoom to z keeping the canvas-local screen point (sx, sy) fixed. */
export function zoomAt(sx: number, sy: number, z: number) {
const v = state.view;
const nz = clampZoom(z);
const dx = (sx - v.x) / v.zoom;
const dy = (sy - v.y) / v.zoom;
setView({ zoom: nz, x: sx - dx * nz, y: sy - dy * nz });
}
const STOPS = [0.1, 0.15, 0.25, 0.33, 0.5, 0.67, 0.75, 1, 1.25, 1.5, 2, 3, 4, 6, 8];
export function zoomStep(dir: 1 | -1) {
const z = state.view.zoom;
const next = dir > 0 ? STOPS.find((s) => s > z * 1.001) ?? MAX_ZOOM : [...STOPS].reverse().find((s) => s < z * 0.999) ?? MIN_ZOOM;
zoomAt(state.viewport.w / 2, state.viewport.h / 2, next);
}
export function zoomTo100() {
zoomAt(state.viewport.w / 2, state.viewport.h / 2, 1);
}
export function contentBounds(doc: Doc): Rect | null {
let r: Rect | null = null;
for (const n of doc.children) if (n.visible) r = unionRect(r, boundsOf(doc, n.id));
return r;
}
export function fitRect(r: Rect | null) {
const { w, h } = state.viewport;
if (!r || w <= 0 || h <= 0) return;
const pad = w < 600 ? 20 : 56;
const labelSpace = 22;
const z = clampZoom(Math.min((w - pad * 2) / Math.max(r.w, 1), (h - pad * 2 - labelSpace) / Math.max(r.h, 1)));
setView({
zoom: z,
x: (w - r.w * z) / 2 - r.x * z,
y: (h - r.h * z + labelSpace) / 2 - r.y * z,
});
}
export function fitAll() {
fitRect(contentBounds(state.doc));
}
export function fitSelection() {
const r = selectionBounds(state.doc, state.selection);
if (r) fitRect(r);
}
export function validSelection(doc: Doc, ids: string[]) {
const idx = getIndex(doc);
return ids.filter((id) => idx.has(id));
}