import type { Editor } from './editor.ts'; import type { Availability, Capability, ElementRecord, Geometry, Id, Json, Result, Target } from './types.ts'; import { geometryValid, targetElement } from './document.ts'; import { clone, equal, fault, freeze, readMap, readSet, reject, validJson } from './values.ts'; export type ViewState = { readonly id: Id; readonly canvasId: Id; readonly selection: ReturnType>; readonly viewport: { readonly x: number; readonly y: number; readonly zoom: number }; readonly tool: 'select' | 'pan'; readonly modes: ReadonlyMap }; export type GestureState = { readonly token: number; readonly viewId: Id; readonly targets: readonly Target[]; readonly preview: ReadonlyMap; readonly phase: 'active' | 'committing' }; export type ConnectionGesture = { readonly token: number; readonly viewId: Id; readonly target: Target; readonly path: Json; readonly phase: 'active' | 'committing' }; export type SessionSnapshot = { readonly views: ReadonlyMap; readonly gesture: GestureState | null; readonly connectionGesture: ConnectionGesture | null; readonly lastCancelReason: string | null }; /** Transient interaction state belongs to views; no pointer event enters the ledger. */ export function createSessions(editor: Editor) { let snapshot: SessionSnapshot = Object.freeze({ views: readMap(new Map()), gesture: null, connectionGesture: null, lastCancelReason: null }); let baseline = new Map(), connectionBaseline: ElementRecord | undefined, disposed = false, nextGestureToken = 0; const listeners = new Set<() => void>(); const notify = () => { for (const listener of [...listeners]) try { listener(); } catch { /* A renderer cannot invalidate model commits. */ } }; function setView(view: ViewState) { const views = new Map(snapshot.views); views.set(view.id, view); snapshot = Object.freeze({ ...snapshot, views: readMap(views) }); notify(); } function cancel(reason = 'canceled') { if (!snapshot.gesture && !snapshot.connectionGesture) return; baseline.clear(); connectionBaseline = undefined; snapshot = Object.freeze({ ...snapshot, gesture: null, connectionGesture: null, lastCancelReason: reason }); notify(); } const detachEditor = editor.subscribe(() => { const document = editor.getSnapshot().document; const views = new Map(snapshot.views); let changed = false; for (const [id, view] of views) { const canvasMissing = !document.canvases.has(view.canvasId); const selection = [...view.selection].filter(target => document.elements.has(target)), modes = new Map([...view.modes].filter(([target]) => document.elements.has(target))); if (canvasMissing || selection.length !== view.selection.size || modes.size !== view.modes.size) { views.set(id, freeze({ ...view, canvasId: canvasMissing ? [...document.canvases.values()].find(canvas => !canvas.parent)!.id : view.canvasId, selection: readSet(canvasMissing ? [] : selection), modes: readMap(canvasMissing ? new Map() : modes) })); changed = true; } } if (changed) { snapshot = Object.freeze({ ...snapshot, views: readMap(views) }); notify(); } if (snapshot.connectionGesture?.phase === 'active') { const current = document.elements.get(snapshot.connectionGesture.target.id), before = connectionBaseline; if (!current || current.structure !== 'connection' || before?.structure !== 'connection' || current.incarnation !== before.incarnation || current.ownerCanvasId !== before.ownerCanvasId || !equal(current.path, before.path)) cancel('target_changed'); else if (!editor.can({ type: 'connection.path', target: snapshot.connectionGesture.target, path: current.path }).enabled) cancel('permission_changed'); } if (snapshot.gesture?.phase !== 'active') return; for (const target of snapshot.gesture.targets) { const before = baseline.get(target.id), current = document.elements.get(target.id); if (!current || current.structure !== 'node' || before?.structure !== 'node' || current.incarnation !== before.incarnation || current.ownerCanvasId !== before.ownerCanvasId || !equal(current.geometry, before.geometry)) { cancel('target_changed'); return; } if (!editor.can({ type: 'element.geometry', target, patch: {} }).enabled) { cancel('permission_changed'); return; } } }); function view(id: Id) { const found = snapshot.views.get(id); if (!found || disposed) throw fault('view_detached', [id]); return found; } function update(viewId: Id, target: Target, patch: Partial): Availability { try { view(viewId); if (snapshot.gesture?.viewId !== viewId || !snapshot.gesture.targets.some(value => value.id === target.id && value.incarnation === target.incarnation)) throw fault('gesture_not_owned', [target.id]); const original = targetElement(editor.getSnapshot().document, target); if (original.structure !== 'node') throw fault('unsupported_capability', [target.id]); if (Object.keys(patch).some(key => !['x', 'y', 'width', 'height', 'rotation'].includes(key))) throw fault('invalid_geometry_patch', [target.id]); let element: ElementRecord = freeze({ ...original, geometry: snapshot.gesture.preview.get(target.id) || original.geometry }); const parts: [Capability, Partial][] = [ ['geometry.move', Object.fromEntries(Object.entries(patch).filter(([key]) => key === 'x' || key === 'y'))], ['geometry.resize', Object.fromEntries(Object.entries(patch).filter(([key]) => key === 'width' || key === 'height'))], ['geometry.rotate', Object.fromEntries(Object.entries(patch).filter(([key]) => key === 'rotation'))], ]; for (const [capability, value] of parts) if (Object.keys(value).length) { if (!editor.registry.supports(element, capability)) throw fault('unsupported_capability', [target.id]); const changes = editor.registry.handlers.get(element.componentId)!.get(capability)!({ element, input: freeze({ patch: value }) }); for (const change of changes) { if (change.field !== 'geometry' || change.target.id !== target.id || change.target.incarnation !== target.incarnation) throw fault('invalid_handler_change', [target.id]); const geometry = change.after as Geometry; if (!geometryValid(geometry)) throw fault('invalid_geometry', [target.id]); element = freeze({ ...element, geometry }); } } const preview = new Map(snapshot.gesture.preview); preview.set(target.id, (element as ElementRecord & { structure: 'node' }).geometry); snapshot = Object.freeze({ ...snapshot, gesture: freeze({ ...snapshot.gesture, preview: readMap(preview) }) }); notify(); return { enabled: true }; } catch (error) { const result = reject(error); return { enabled: false, code: result.code, targets: result.targets }; } } return Object.freeze({ getSnapshot: () => snapshot, attach(id: Id, canvasId = [...editor.getSnapshot().document.canvases.values()].find(canvas => !canvas.parent)!.id) { if (disposed || snapshot.views.has(id)) throw fault('duplicate_view', [id]); if (!editor.getSnapshot().document.canvases.has(canvasId)) throw fault('canvas_missing', [canvasId]); setView(freeze({ id, canvasId, selection: readSet([]), viewport: { x: 0, y: 0, zoom: 1 }, tool: 'select', modes: readMap(new Map()) })); return () => { if ((snapshot.gesture?.viewId === id || snapshot.connectionGesture?.viewId === id)) cancel('view_detached'); const views = new Map(snapshot.views); views.delete(id); snapshot = Object.freeze({ ...snapshot, views: readMap(views) }); notify(); }; }, canvas(id: Id, canvasId: Id) { const current = view(id); if (!editor.getSnapshot().document.canvases.has(canvasId)) throw fault('canvas_missing', [canvasId]); if ((snapshot.gesture?.viewId === id || snapshot.connectionGesture?.viewId === id)) cancel('canvas_changed'); setView(freeze({ ...current, canvasId, selection: readSet([]), modes: readMap(new Map()) })); }, select(id: Id, ids: readonly Id[]) { const current = view(id), document = editor.getSnapshot().document; for (const target of ids) if (!document.elements.has(target)) throw fault('element_missing', [target]); setView(freeze({ ...current, selection: readSet(ids) })); }, viewport(id: Id, viewport: ViewState['viewport']) { if (![viewport.x, viewport.y, viewport.zoom].every(Number.isFinite) || viewport.zoom <= 0) throw fault('invalid_viewport'); setView(freeze({ ...view(id), viewport: { ...viewport } })); }, tool(id: Id, tool: ViewState['tool']) { setView(freeze({ ...view(id), tool })); }, mode(id: Id, target: Id, mode: string | null) { const current = view(id), modes = new Map(current.modes); if (mode) modes.set(target, mode); else modes.delete(target); setView(freeze({ ...current, modes: readMap(modes) })); }, begin(viewId: Id, targets: readonly Target[]): Availability { try { view(viewId); if (snapshot.gesture || snapshot.connectionGesture || !targets.length || new Set(targets.map(target => target.id)).size !== targets.length) throw fault('gesture_busy'); baseline = new Map(targets.map(target => { const element = targetElement(editor.getSnapshot().document, target); if (element.structure !== 'node' || editor.registry.resolve(element) !== 'known') throw fault('unsupported_component', [target.id]); return [target.id, element]; })); for (const target of targets) { const allowed = editor.can({ type: 'element.geometry', target, patch: {} }); if (!allowed.enabled) throw fault(allowed.code, allowed.targets); } snapshot = Object.freeze({ ...snapshot, lastCancelReason: null, gesture: freeze({ token: ++nextGestureToken, viewId, targets: targets.map(target => ({ ...target })), preview: readMap(new Map()), phase: 'active' as const }) }); notify(); return { enabled: true }; } catch (error) { const result = reject(error); return { enabled: false, code: result.code, targets: result.targets }; } }, beginConnection(viewId: Id, target: Target): Availability { try { view(viewId); if (snapshot.gesture || snapshot.connectionGesture) throw fault('gesture_busy'); const element = targetElement(editor.getSnapshot().document, target); if (element.structure !== 'connection') throw fault('unsupported_capability', [target.id]); const allowed = editor.can({ type: 'connection.path', target, path: element.path }); if (!allowed.enabled) throw fault(allowed.code, allowed.targets); connectionBaseline = element; snapshot = Object.freeze({ ...snapshot, lastCancelReason: null, connectionGesture: freeze({ token: ++nextGestureToken, viewId, target: { ...target }, path: element.path, phase: 'active' as const }) }); notify(); return { enabled: true }; } catch (error) { const result = reject(error); return { enabled: false, code: result.code, targets: result.targets }; } }, updateConnection(viewId: Id, path: Json): Availability { try { view(viewId); if (snapshot.connectionGesture?.viewId !== viewId) throw fault('gesture_not_owned'); if (!validJson(path)) throw fault('invalid_connection_path'); snapshot = Object.freeze({ ...snapshot, connectionGesture: freeze({ ...snapshot.connectionGesture, path: clone(path) }) }); notify(); return { enabled: true }; } catch (error) { const result = reject(error); return { enabled: false, code: result.code, targets: result.targets }; } }, commitConnection(viewId: Id): Result { try { view(viewId); const gesture = snapshot.connectionGesture; if (!gesture || gesture.viewId !== viewId) throw fault('gesture_not_owned'); snapshot = Object.freeze({ ...snapshot, connectionGesture: freeze({ ...gesture, phase: 'committing' as const }) }); const result = editor.dispatch({ type: 'connection.path', target: gesture.target, path: gesture.path }, { label: 'Edit connection' }); cancel(result.status === 'rejected' ? result.code : 'committed'); return result; } catch (error) { cancel('commit_failed'); return reject(error); } }, update, commit(viewId: Id): Result { try { view(viewId); const gesture = snapshot.gesture; if (!gesture || gesture.viewId !== viewId) throw fault('gesture_not_owned'); snapshot = Object.freeze({ ...snapshot, gesture: freeze({ ...gesture, phase: 'committing' as const }) }); const result = editor.transaction(tx => { for (const [id, geometry] of gesture.preview) { const original = baseline.get(id); if (!original || original.structure !== 'node') throw fault('target_changed', [id]); const patch = Object.fromEntries(Object.entries(geometry).filter(([key, value]) => !equal(original.geometry[key as keyof Geometry], value))); tx.dispatch({ type: 'element.geometry', target: { id, incarnation: original.incarnation }, patch }); } }, { label: 'Transform' }); cancel(result.status === 'rejected' ? result.code : 'committed'); return result; } catch (error) { cancel('commit_failed'); return reject(error); } }, cancel, effectiveGeometry(id: Id): Geometry | undefined { const element = editor.getSnapshot().document.elements.get(id); return snapshot.gesture?.preview.get(id) || (element?.structure === 'node' ? element.geometry : undefined); }, subscribe(listener: () => void) { if (disposed) throw fault('sessions_disposed'); listeners.add(listener); return () => { listeners.delete(listener); }; }, dispose() { cancel('disposed'); disposed = true; detachEditor(); snapshot = Object.freeze({ ...snapshot, views: readMap(new Map()) }); notify(); listeners.clear(); }, }); }