167 lines
14 KiB
TypeScript
167 lines
14 KiB
TypeScript
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<typeof readSet<Id>>; readonly viewport: { readonly x: number; readonly y: number; readonly zoom: number }; readonly tool: 'select' | 'pan'; readonly modes: ReadonlyMap<Id, string> };
|
|
export type GestureState = { readonly token: number; readonly viewId: Id; readonly targets: readonly Target[]; readonly preview: ReadonlyMap<Id, Geometry>; 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<Id, ViewState>; 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<Id, ViewState>()), gesture: null, connectionGesture: null, lastCancelReason: null });
|
|
let baseline = new Map<Id, ElementRecord>(), 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<Geometry>): 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>][] = [
|
|
['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<Id>([]), viewport: { x: 0, y: 0, zoom: 1 }, tool: 'select', modes: readMap(new Map<Id, string>()) }));
|
|
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<Id>([]), modes: readMap(new Map<Id, string>()) }));
|
|
},
|
|
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<Id, Geometry>()), 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<Id, ViewState>()) }); notify(); listeners.clear(); },
|
|
});
|
|
}
|