From 03f960358514f36829de7ab74e4466de624e70e2 Mon Sep 17 00:00:00 2001 From: tfukaza Date: Sat, 25 Jul 2026 18:04:26 -0700 Subject: [PATCH 01/21] Cleanup baseline: carry in-flight WIP into the re-architecture branch Carries the uncommitted working-tree changes (snapline core + adapters, object.ts, connector-config unit tests, design docs incl. the new planned-rearchitecture.md and geometry.ts) forward from pilot/terrain-lab onto Cleanup, branched from main at e273a93 (PR #52 merge). Co-Authored-By: Claude Fable 5 --- assets/snapline/AGENTS.md | 26 +- assets/snapline/core/README.md | 7 + assets/snapline/core/src/connector.ts | 4 +- assets/snapline/core/src/geometry.ts | 2 + assets/snapline/core/src/group.ts | 11 +- assets/snapline/core/src/index.ts | 6 + assets/snapline/core/src/line.ts | 92 +- assets/snapline/core/src/node.ts | 121 +- assets/snapline/core/src/placement.ts | 53 +- assets/snapline/core/src/select.ts | 45 +- assets/snapline/react/src/Connector.tsx | 2 +- assets/snapline/react/src/Group.tsx | 26 +- assets/snapline/react/src/Line.tsx | 76 +- assets/snapline/react/src/Node.tsx | 31 +- assets/snapline/react/src/Placement.tsx | 65 +- assets/snapline/react/src/Select.tsx | 39 +- assets/snapline/svelte/src/Connector.svelte | 2 +- assets/snapline/svelte/src/Group.svelte | 23 +- assets/snapline/svelte/src/Line.svelte | 46 +- assets/snapline/svelte/src/Node.svelte | 25 +- assets/snapline/svelte/src/Placement.svelte | 50 +- assets/snapline/svelte/src/Select.svelte | 35 +- demo/svelte/src/demo/node_ui_demo/Line.svelte | 63 +- docs/snapline/design/current-architecture.md | 671 ++++++++-- .../design/ownership-specification.md | 42 +- .../snapline/design/planned-rearchitecture.md | 1112 +++++++++++++++++ docs/snapline/reference/react/index.mdx | 4 +- docs/snapline/reference/react/placement.mdx | 6 +- docs/snapline/reference/svelte/index.mdx | 8 +- docs/snapline/reference/svelte/placement.mdx | 5 +- src/object.ts | 28 +- tests/ut/snapline-connector-config.spec.ts | 119 ++ .../lib/components/docs/SnapLineDemo.svelte | 12 +- 33 files changed, 2382 insertions(+), 475 deletions(-) create mode 100644 assets/snapline/core/src/geometry.ts create mode 100644 docs/snapline/design/planned-rearchitecture.md diff --git a/assets/snapline/AGENTS.md b/assets/snapline/AGENTS.md index f123d37..a3ee6eb 100644 --- a/assets/snapline/AGENTS.md +++ b/assets/snapline/AGENTS.md @@ -171,26 +171,24 @@ elements** — frameworks recover fine from property changes. Concretely: -- **Node width/height** are framework-rendered: core fires - `callbacks.onSizeChange({node, width, height})` during a resize drag and the adapter binds - the size as state. Core only updates its collision hitboxes synchronously - (`setSizeState`). The connector/line re-glue closes itself through the - ResizeObserver after the framework's DOM write reflows. +- **Node/group transforms and live width/height** are core-written during a + gesture. Resize uses `WRITE_1 → READ_2 → WRITE_2`: paint the box, remeasure + connectors, then re-glue lines. `onSizeChange` is observational and + `onResizeCommit` is the framework persistence boundary. - **Initial node geometry** is explicit: after assigning a committed framework element, adapters call `syncDomGeometry()`. ResizeObserver remains the ongoing invalidation path, not the initial-mount handshake. -- **The rubber-band selection box** is framework-rendered: core fires - `callbacks.onRectChange({x, y, width, height, visible})` and the adapter - draws (and can restyle/replace) the box. Deliberately NO flush handshake — - the box visual is not paint-atomic. -- **Node drag transforms, `data-selected` attributes, and line SVG transforms** - stay engine-written (property writes on existing elements). +- **Line, selection, and placement geometry** use + `bindGeometryWriter(...)`. Adapters mount static structure once; the writer + mutates retained SVG/DOM/graphics refs without framework state. Custom line + components must bind a geometry writer and clean it up on unmount. +- **Semantic state stays separate:** line phase/payload/target changes use + `onStateChange`; geometry never requests a framework render. - **Adapters must render node/group elements with `position: absolute; transform-origin: top left`** (and ideally `will-change: transform`) — core no longer seeds base styles. -- SnapLine has **no `flushMutation`/`settleMutation` equivalent and must not - grow one**: unlike SnapSort's FLIP pipeline, none of SnapLine's delegated - visuals are paint-atomic. +- Adapter cleanup must detach elements or destroy objects with + `removeElement: false`; React/Svelte remain the sole structural DOM owners. ### Callback conventions diff --git a/assets/snapline/core/README.md b/assets/snapline/core/README.md index a025ea7..74853c8 100644 --- a/assets/snapline/core/README.md +++ b/assets/snapline/core/README.md @@ -36,6 +36,13 @@ import { After assigning a Vanilla-rendered element, call `syncDomGeometry()`. Svelte and React adapters perform that synchronization automatically. +Live gesture geometry stays outside framework state. Nodes and groups write +their retained element transforms and resize dimensions directly. Line, +selection, and placement renderers register one imperative +`bindGeometryWriter(...)`; semantic observers and commit callbacks remain +separate. A custom line renderer should mount its SVG/Canvas structure once, +bind a writer, and call the returned cleanup function when it unmounts. + When another interaction system applies transient transforms inside a node, call `connector.requestDomGeometrySync()` for each affected connector. The request is coalesced into the next read/write cycle and updates every connected diff --git a/assets/snapline/core/src/connector.ts b/assets/snapline/core/src/connector.ts index 2dcd761..a63e7a7 100644 --- a/assets/snapline/core/src/connector.ts +++ b/assets/snapline/core/src/connector.ts @@ -1009,7 +1009,7 @@ class ConnectorComponent extends ElementObject { return cloneAnchor(this.center); } - destroy(): void { + destroy(removeElement: boolean = true): void { if (this.#edgePanPointerId != null) { this.engine.edgePanController?.stopEdgePan(this.#edgePanPointerId); this.#edgePanPointerId = null; @@ -1022,7 +1022,7 @@ class ConnectorComponent extends ElementObject { } this.#removeSourceSurfaceRegistration(); this.globalInput.pointerUp = null; - super.destroy(); + super.destroy(removeElement); } #resolveOwnSourceHit( diff --git a/assets/snapline/core/src/geometry.ts b/assets/snapline/core/src/geometry.ts new file mode 100644 index 0000000..30e6ed7 --- /dev/null +++ b/assets/snapline/core/src/geometry.ts @@ -0,0 +1,2 @@ +/** Imperative presentation sink for high-frequency geometry. */ +export type GeometryWriter = (geometry: Readonly) => void; diff --git a/assets/snapline/core/src/group.ts b/assets/snapline/core/src/group.ts index 40b5573..16fa9c8 100644 --- a/assets/snapline/core/src/group.ts +++ b/assets/snapline/core/src/group.ts @@ -306,7 +306,7 @@ class GroupNodeComponent extends NodeComponent { } writeTransformAndLines(): void { - this.writeTransformRecursive(); + super.writeTransformAndLines(); } allowsMembership(node: NodeComponent): boolean { @@ -383,16 +383,13 @@ class GroupNodeComponent extends NodeComponent { y: origin.y + dy, }; } - member.schedule(() => member.writeTransformAndLines(), { - stage: "WRITE_2", - queueId: `${member.id}-transform`, - }); + member.scheduleTransformAndLines(); } this.#carry = []; this.#carryOrigins.clear(); } - destroy(): void { + destroy(removeElement: boolean = true): void { snapData(this.global).groups = getGroups(this.global).filter( (group) => group !== (this as unknown), ); @@ -406,7 +403,7 @@ class GroupNodeComponent extends NodeComponent { group instanceof GroupNodeComponent && group.engine === this.engine, ); remaining?.refreshMembership(true); - super.destroy(); + super.destroy(removeElement); } } diff --git a/assets/snapline/core/src/index.ts b/assets/snapline/core/src/index.ts index d1c2116..c52c06c 100644 --- a/assets/snapline/core/src/index.ts +++ b/assets/snapline/core/src/index.ts @@ -51,6 +51,11 @@ export type { SnapLineMetadata, } from "./connector"; export { LineComponent } from "./line"; +export type { + LineGeometrySnapshot, + LineStateSnapshot, +} from "./line"; +export type { GeometryWriter } from "./geometry"; export { GroupNodeComponent, getParentGroup, @@ -85,6 +90,7 @@ export type { PlacementCancelEvent, PlacementConfig, PlacementEvent, + PlacementGeometrySnapshot, PlacementPoint, PlacementSize, PlacementSnapshot, diff --git a/assets/snapline/core/src/line.ts b/assets/snapline/core/src/line.ts index bf43bbf..127acbe 100644 --- a/assets/snapline/core/src/line.ts +++ b/assets/snapline/core/src/line.ts @@ -8,6 +8,20 @@ import type { ConnectorPoint, ConnectorSurfaceStrategy, } from "./connector"; +import type { GeometryWriter } from "./geometry"; + +export interface LineGeometrySnapshot { + readonly start: ConnectorAnchor; + readonly end: ConnectorAnchor; + readonly delta: Readonly<{ x: number; y: number }>; +} + +export interface LineStateSnapshot { + readonly phase: ConnectorLinePhase; + readonly target: ConnectorComponent | null; + readonly candidate: ConnectorComponent | null; + readonly payload: unknown; +} class LineComponent extends ElementObject { endWorldX: number; @@ -21,7 +35,8 @@ class LineComponent extends ElementObject { phase: ConnectorLinePhase; candidate: ConnectorCandidate | null; - #renderCallbacks: Set<(line: LineComponent) => void>; + #geometryWriter: GeometryWriter | null = null; + #stateCallbacks = new Set<(state: LineStateSnapshot) => void>(); #sourceStrategy: ConnectorSurfaceStrategy | null = null; #sourceHit: ConnectorHit | null = null; #targetStrategy: ConnectorSurfaceStrategy | null = null; @@ -41,22 +56,47 @@ class LineComponent extends ElementObject { this.endAnchor = { x: 0, y: 0 }; this.phase = "source-start"; this.candidate = null; - this.#renderCallbacks = new Set(); - this.transformMode = "direct"; } - onRender(callback: (line: LineComponent) => void): () => void { - this.#renderCallbacks.add(callback); + bindGeometryWriter( + writer: GeometryWriter, + ): () => void { + this.#geometryWriter = writer; + writer(this.geometrySnapshot()); return () => { - this.#renderCallbacks.delete(callback); + if (this.#geometryWriter === writer) this.#geometryWriter = null; }; } - requestRender(): void { - for (const callback of this.#renderCallbacks) { - callback(this); - } + onStateChange(callback: (state: LineStateSnapshot) => void): () => void { + this.#stateCallbacks.add(callback); + callback(this.stateSnapshot()); + return () => this.#stateCallbacks.delete(callback); + } + + geometrySnapshot(): LineGeometrySnapshot { + const start = cloneAnchor(this.startAnchor); + const end = cloneAnchor(this.endAnchor); + return { + start, + end, + delta: { x: end.x - start.x, y: end.y - start.y }, + }; + } + + stateSnapshot(): LineStateSnapshot { + return { + phase: this.phase, + target: this.target, + candidate: this.candidate?.connector ?? null, + payload: this.payload, + }; + } + + #emitStateChange(): void { + const state = this.stateSnapshot(); + for (const callback of this.#stateCallbacks) callback(state); } setSourceSurfaceContext( @@ -71,31 +111,30 @@ class LineComponent extends ElementObject { candidate: ConnectorCandidate | null, strategy: ConnectorSurfaceStrategy | null = null, ): void { + const previousConnector = this.candidate?.connector ?? null; this.candidate = candidate; this.#targetStrategy = strategy; this.#targetHit = candidate?.hit ?? null; - this.requestRender(); + if (previousConnector !== (candidate?.connector ?? null)) { + this.#emitStateChange(); + } } setPhase(phase: ConnectorLinePhase): void { if (this.phase === phase) return; this.phase = phase; - this.requestRender(); + this.#emitStateChange(); } setPayload(payload: unknown): void { + if (Object.is(this.payload, payload)) return; this.payload = payload; - this.requestRender(); + this.#emitStateChange(); } setPreviewPosition(position: ConnectorPoint): void { this.#previewPosition = position; - // Pointer and edge-pan updates are committed through the engine's write - // phase by ConnectorComponent. Updating the model here but deferring the - // render callback keeps the line and camera transform in the same frame; - // rendering immediately leaves the preview one camera frame behind during - // continuous edge-pan. - this.updateAnchors(false); + this.updateAnchors(); } connectTarget( @@ -109,15 +148,20 @@ class LineComponent extends ElementObject { this.candidate = null; this.phase = "connected"; this.updateAnchors(); + this.#emitStateChange(); } clearTarget(): void { + const changed = + this.target !== null || + this.candidate !== null || + this.phase !== "preview-free"; this.target = null; this.candidate = null; this.#targetStrategy = null; this.#targetHit = null; this.phase = "preview-free"; - this.requestRender(); + if (changed) this.#emitStateChange(); } setLineStartAtConnector(): void { @@ -181,7 +225,7 @@ class LineComponent extends ElementObject { this.setLineEnd(endWorldX, endWorldY); } - updateAnchors(requestRender = true): void { + updateAnchors(): void { const target = this.target ?? this.candidate?.connector ?? null; if (!target) { const preview = this.#previewPosition ?? this.endAnchor; @@ -196,7 +240,6 @@ class LineComponent extends ElementObject { }); this.setLineStartAnchor(startAnchor); this.setLineEndAnchor(preview); - if (requestRender) this.requestRender(); return; } @@ -222,7 +265,6 @@ class LineComponent extends ElementObject { }); this.setLineStartAnchor(sourceAnchor); this.setLineEndAnchor(targetAnchor); - if (requestRender) this.requestRender(); } moveLineToConnectorTransform(): void { @@ -230,9 +272,7 @@ class LineComponent extends ElementObject { } writeTransform(): void { - // A logical/headless line can exist before a framework mounts its SVG. - if (this.element) super.writeTransform(); - this.requestRender(); + this.#geometryWriter?.(this.geometrySnapshot()); } } diff --git a/assets/snapline/core/src/node.ts b/assets/snapline/core/src/node.ts index dd0dc24..006effd 100644 --- a/assets/snapline/core/src/node.ts +++ b/assets/snapline/core/src/node.ts @@ -169,7 +169,7 @@ export interface NodeCallbacks { onDrag?: (event: NodePointerEvent) => void; onDragCommit?: (event: NodeDragCommitEvent) => void; onSelectionChange?: (event: NodeSelectionEvent) => void; - /** Live size updates during a resize drag — the adapter renders width/height. */ + /** Observes live size updates; core writes the retained element geometry. */ onSizeChange?: (event: NodeResizeEvent) => void; /** Final size at resize-drag end — the consumer persists it. */ onResizeCommit?: (event: NodeResizeEvent) => void; @@ -494,13 +494,38 @@ class NodeComponent extends ElementObject { /** Synchronously writes every line on every connector (call inside a WRITE stage). */ writeLinesNow(): void { - for (const connector of Object.values(this._connectors)) { - connector.writeAllLinesNow(); + const lines = new Set([ + ...this.getAllOutgoingLines(), + ...this.getAllIncomingLines(), + ]); + for (const line of lines) { + line.moveLineToConnectorTransform(); + line.writeTransform(); } } + #transformNodeTree(): NodeComponent[] { + const nodes: NodeComponent[] = []; + const visit = (node: NodeComponent) => { + nodes.push(node); + for (const child of node.transformChildren) { + if (child instanceof NodeComponent) visit(child); + } + }; + visit(this); + return nodes; + } + + scheduleTransformAndLines(): void { + this.schedule(() => this.writeTransformRecursive(), { + stage: "WRITE_2", + queueId: `${this.id}-transform`, + }); + for (const node of this.#transformNodeTree()) node.scheduleLineWrites(); + } + // Re-measure the node box + each connector's local center (READ_1) and re-glue - // every incoming/outgoing line (WRITE_1). This is the "same handling as a move + // every incoming/outgoing line (WRITE_2). This is the "same handling as a move // plus a size re-measure": moving a node keeps connector local centers valid, // but resizing invalidates them, so they must be re-read. Shared by the // ResizeObserver and the JS-driven setSize; stable queueIds collapse a @@ -521,26 +546,13 @@ class NodeComponent extends ElementObject { }, { stage: "READ_1", queueId: `${this.id}-remeasure` }, ); - for (const line of [ - ...this.getAllOutgoingLines(), - ...this.getAllIncomingLines(), - ]) { - line.schedule( - () => { - line.moveLineToConnectorTransform(); - line.setLineEndAtConnector(); - line.writeDom(); - line.writeTransform(); - }, - { stage: "WRITE_1", queueId: `${line.id}-reglue` }, - ); - } + this.scheduleLineWrites(); } // State-only half of a size change: clamps to min and synchronously updates - // the collision footprint + resize hitbox so the hit test and group - // containment stay correct mid-drag. Never touches the DOM — the element's - // width/height are framework-owned (rendered by the adapter). + // the collision footprint + resize hitbox so hit testing and group + // containment stay correct mid-drag. `setSize` adds the scheduled DOM write; + // adapters can use this method alone when seeding external dimensions. setSizeState(width: number, height: number): void { const w = Math.max(this.#config.minWidth, width); const h = Math.max(this.#config.minHeight, height); @@ -549,13 +561,11 @@ class NodeComponent extends ElementObject { this.#positionResizeHitBoxes(w, h); } - // Drives the node's size from JS (resize handle): updates state, then asks the - // framework to render the new width/height via onSizeChange. The connector/line - // re-glue closes itself — the adapter's DOM write triggers the ResizeObserver, - // which runs syncDomGeometry AFTER the browser reflows (no handshake needed: - // the box repaint is not paint-atomic). + // Drives live resize geometry directly. The framework observes and persists + // the result, but it is not part of the pointer-move paint path. setSize(width: number, height: number, handle: ResizeHandle | null = null): void { this.setSizeState(width, height); + this.#scheduleSizeGeometryWrite(); this.#callbacks.onSizeChange?.({ node: this, handle, @@ -566,6 +576,35 @@ class NodeComponent extends ElementObject { }); } + #scheduleSizeGeometryWrite(): void { + this.schedule( + () => this.#writeSizeGeometry(), + { stage: "WRITE_1", queueId: `${this.id}-size` }, + ); + this.schedule( + () => { + if (!this.element) return; + const property = this.readDom({ unapplyTransform: false }, "READ_2"); + this.#hitBox.width = property.width; + this.#hitBox.height = property.height; + this.#positionResizeHitBoxes(property.width, property.height); + for (const connector of Object.values(this._connectors)) { + connector.measureLocalCenter("READ_2"); + } + }, + { stage: "READ_2", queueId: `${this.id}-size-measure` }, + ); + this.scheduleLineWrites(); + } + + #writeSizeGeometry(): void { + if (this.element) { + this.element.style.width = `${this.#hitBox.width}px`; + this.element.style.height = `${this.#hitBox.height}px`; + } + this.writeTransformRecursive(); + } + #positionResizeHitBoxes(width: number, height: number): void { const t = Math.max(0, this.#resizeHandleThickness); const half = t / 2; @@ -617,12 +656,6 @@ class NodeComponent extends ElementObject { : this.#resizeStartY; this.worldTransform = { x, y }; this.setSize(width, height, handle); - if (west || north) { - this.schedule(() => this.writeTransformAndLines(), { - stage: "WRITE_2", - queueId: `${this.id}-transform`, - }); - } } writeTransformAndLines(): void { @@ -636,7 +669,6 @@ class NodeComponent extends ElementObject { // line's two ends live on two different nodes). writeTransformRecursive(): void { super.writeTransformRecursive(); - this.writeLinesNow(); } // Transform-only (re)parenting used by group carry: the public/DOM graph is @@ -845,10 +877,7 @@ class NodeComponent extends ElementObject { }) ?? { x, y }; this.worldTransform = { x: resolved.x, y: resolved.y }; - this.schedule(() => this.writeTransformAndLines(), { - stage: "WRITE_2", - queueId: `${this.id}-transform`, - }); + this.scheduleTransformAndLines(); } onDragEnd(prop: dragEndProp) { @@ -865,6 +894,10 @@ class NodeComponent extends ElementObject { prop.end.x - this._mouseDownX, prop.end.y - this._mouseDownY, ); + // Pointer-up is a synchronization boundary for consumers that immediately + // query the committed handle/box. Keep the coalesced frame write for the + // hot path, but make the final retained geometry observable now. + this.#writeSizeGeometry(); this.#callbacks.onResizeCommit?.({ node: this, handle: this.#activeResizeHandle, @@ -894,10 +927,7 @@ class NodeComponent extends ElementObject { } for (const node of this.#dragRoots) { node.finishSelectionDrag(); - node.schedule(() => node.writeTransformAndLines(), { - stage: "WRITE_2", - queueId: `${node.id}-transform`, - }); + node.scheduleTransformAndLines(); } // A settled node may have entered or left a group; groups re-evaluate @@ -942,10 +972,7 @@ class NodeComponent extends ElementObject { x: this._dragStartX + dx, y: this._dragStartY + dy, }; - this.schedule(() => this.writeTransformAndLines(), { - stage: "WRITE_2", - queueId: `${this.id}-transform`, - }); + this.scheduleTransformAndLines(); } onUp(prop: pointerUpProp) { @@ -1062,7 +1089,7 @@ class NodeComponent extends ElementObject { if (handle) this.#resizeHoverController?.activate(handle, target); } - destroy() { + destroy(removeElement: boolean = true) { if (this.#edgePanPointerId != null) { this.engine.edgePanController?.stopEdgePan(this.#edgePanPointerId); this.#edgePanPointerId = null; @@ -1084,7 +1111,7 @@ class NodeComponent extends ElementObject { this.#resizeHitBoxes.clear(); } this._connectors = {}; - super.destroy(); + super.destroy(removeElement); } } diff --git a/assets/snapline/core/src/placement.ts b/assets/snapline/core/src/placement.ts index 3a5cbac..cbda0d7 100644 --- a/assets/snapline/core/src/placement.ts +++ b/assets/snapline/core/src/placement.ts @@ -1,3 +1,5 @@ +import type { GeometryWriter } from "./geometry"; + export interface PlacementPoint { x: number; y: number; @@ -24,6 +26,16 @@ export interface PlacementSnapshot { allowed: boolean; } +export interface PlacementGeometrySnapshot { + readonly active: boolean; + readonly visible: boolean; + readonly screen: PlacementPoint | null; + readonly world: PlacementPoint | null; + readonly position: PlacementPoint | null; + readonly size: PlacementSize | null; + readonly allowed: boolean; +} + export interface PlacementEvent extends PlacementSnapshot { payload: T; screen: PlacementPoint; @@ -61,10 +73,16 @@ export interface PlacementConfig { */ export class PlacementController { #config: PlacementConfig; + #callbacks: PlacementCallbacks; #snapshot: PlacementSnapshot; + #geometryWriter: GeometryWriter | null = null; + #stateCallbacks = new Set< + (snapshot: PlacementSnapshot) => void + >(); constructor(config: PlacementConfig) { this.#config = config; + this.#callbacks = config.callbacks ?? {}; this.#snapshot = { active: false, payload: null, @@ -82,7 +100,25 @@ export class PlacementController { } get callbacks(): PlacementCallbacks { - return this.#config.callbacks ?? {}; + return this.#callbacks; + } + + bindGeometryWriter( + writer: GeometryWriter, + ): () => void { + this.#geometryWriter = writer; + writer(this.#geometrySnapshot()); + return () => { + if (this.#geometryWriter === writer) this.#geometryWriter = null; + }; + } + + onStateChange( + callback: (snapshot: PlacementSnapshot) => void, + ): () => void { + this.#stateCallbacks.add(callback); + callback(this.#snapshot); + return () => this.#stateCallbacks.delete(callback); } begin( @@ -185,5 +221,20 @@ export class PlacementController { #emitChange(): void { this.callbacks.onChange?.(this.#snapshot); + for (const callback of this.#stateCallbacks) callback(this.#snapshot); + this.#geometryWriter?.(this.#geometrySnapshot()); + } + + #geometrySnapshot(): PlacementGeometrySnapshot { + const snapshot = this.#snapshot; + return { + active: snapshot.active, + visible: snapshot.active && snapshot.position !== null, + screen: snapshot.screen ? { ...snapshot.screen } : null, + world: snapshot.world ? { ...snapshot.world } : null, + position: snapshot.position ? { ...snapshot.position } : null, + size: snapshot.size ? { ...snapshot.size } : null, + allowed: snapshot.allowed, + }; } } diff --git a/assets/snapline/core/src/select.ts b/assets/snapline/core/src/select.ts index b2d53a0..71c08d5 100644 --- a/assets/snapline/core/src/select.ts +++ b/assets/snapline/core/src/select.ts @@ -7,8 +7,9 @@ import type { import { RectCollider, Collider } from "@snap-engine/core/collision"; import { NodeComponent, type SelectionMode } from "./node"; import { getSelectList, snapData } from "./snapline-globals"; +import type { GeometryWriter } from "./geometry"; -/** World-space rectangle the framework renders as the selection box. */ +/** World-space rectangle delivered to the registered geometry writer. */ export interface SelectRect { x: number; y: number; @@ -33,11 +34,9 @@ export interface SelectCallbacks { /** Consumer-defined selection policy; SnapLine owns no modifier keys. */ resolveSelectionMode?: (event: SelectStartEvent) => SelectionMode; /** - * The rubber-band rectangle changed — the FRAMEWORK renders it (position, - * size, visibility, and any custom styling). Core keeps only the pointer - * math and the selection collider; it never writes the box's DOM. This is - * deliberately a plain callback with no flush handshake: the box visual is - * not paint-atomic, so the framework may flush on its own schedule. + * Observes rubber-band rectangle changes. Adapters render live geometry + * through `bindGeometryWriter`; this callback is for application behavior, + * logging, and persistence rather than per-frame framework rendering. */ onRectChange?: (rect: SelectRect) => void; onSelectionChange?: (event: SelectChangeEvent) => void; @@ -53,6 +52,14 @@ class RectSelectComponent extends ElementObject { _mouseDownY: number; _selectHitBox: Collider; #callbacks: SelectCallbacks; + #geometryWriter: GeometryWriter | null = null; + #rect: SelectRect = { + x: 0, + y: 0, + width: 0, + height: 0, + visible: false, + }; #selectionMode: SelectionMode = "replace"; #baselineSelection = new Set(); @@ -86,14 +93,34 @@ class RectSelectComponent extends ElementObject { return this.#callbacks; } + get rect(): Readonly { + return this.#rect; + } + + bindGeometryWriter(writer: GeometryWriter): () => void { + this.#geometryWriter = writer; + writer({ ...this.#rect }); + return () => { + if (this.#geometryWriter === writer) this.#geometryWriter = null; + }; + } + #fireRect(width: number, height: number, visible: boolean): void { - this.#callbacks.onRectChange?.({ + this.#rect = { x: this.worldTransform.x, y: this.worldTransform.y, width, height, visible, - }); + }; + this.#callbacks.onRectChange?.({ ...this.#rect }); + this.schedule( + () => this.#geometryWriter?.({ ...this.#rect }), + { + stage: "WRITE_2", + queueId: `${this.id}-geometry`, + }, + ); } onGlobalCursorDown(prop: pointerDownProp): void { @@ -117,7 +144,7 @@ class RectSelectComponent extends ElementObject { } // worldTransform positions the selection collider (its transform parent); - // the visual box is framework-rendered from the callback rect. + // the registered writer updates the visual box during WRITE_2. this.worldTransform = { x: prop.position.x, y: prop.position.y }; this._state = "dragging"; this._mouseDownX = prop.position.x; diff --git a/assets/snapline/react/src/Connector.tsx b/assets/snapline/react/src/Connector.tsx index 9c8975b..0f0a554 100644 --- a/assets/snapline/react/src/Connector.tsx +++ b/assets/snapline/react/src/Connector.tsx @@ -125,7 +125,7 @@ export const Connector = forwardRef( useEffect(() => { return () => { - if (ownsConnectorRef.current) connector.destroy(); + if (ownsConnectorRef.current) connector.destroy(false); }; }, [connector]); diff --git a/assets/snapline/react/src/Group.tsx b/assets/snapline/react/src/Group.tsx index 2e868ba..e1cb16d 100644 --- a/assets/snapline/react/src/Group.tsx +++ b/assets/snapline/react/src/Group.tsx @@ -3,7 +3,6 @@ import { useLayoutEffect, useImperativeHandle, useRef, - useState, type CSSProperties, type ReactNode, } from "react"; @@ -99,9 +98,6 @@ export const Group = forwardRef(function Group( } const group = groupRef.current; - // The box's width/height are framework-owned: seeded from props, updated - // live by core's onSizeChange during a resize drag. - const [box, setBox] = useState<{ w: number; h: number }>({ w: width, h: height }); const latestRef = useRef({ callbacks, groupCallbacks, @@ -198,7 +194,6 @@ export const Group = forwardRef(function Group( originalCallbacks.onSizeChange, latestRef.current.callbacks.onSizeChange, ); - setBox({ w: event.width, h: event.height }); }; group.callbacks.onResizeCommit = (event) => invoke( @@ -208,7 +203,7 @@ export const Group = forwardRef(function Group( latestRef.current.onResizeCommit, ); // Header is the only move surface. setSizeState seeds the collision - // footprint (the DOM size is rendered from state above). + // footprint; core writes live resize geometry directly to this element. const unregisterHandle = headerRef.current ? group.registerDragHandle(headerRef.current) : undefined; @@ -217,6 +212,7 @@ export const Group = forwardRef(function Group( stage: "WRITE_3", queueId: `${group.id}-seed`, }); + const boundElement = boxDomRef.current; return () => { unregisterHandle?.(); @@ -235,7 +231,9 @@ export const Group = forwardRef(function Group( group.groupCallbacks.onMembershipChange = originalGroupCallbacks.onMembershipChange; if (ownsGroupRef.current) { - group.destroy(); + group.destroy(false); + } else if (boundElement) { + group.detachElement(boundElement); } }; }, [group]); @@ -248,15 +246,13 @@ export const Group = forwardRef(function Group( }); }, [group, x, y]); - useLayoutEffect(() => { - setBox({ w: width, h: height }); - }, [width, height]); - useLayoutEffect(() => { if (!group.element) return; - group.setSizeState(box.w, box.h); + group.element.style.width = `${width}px`; + group.element.style.height = `${height}px`; + group.setSizeState(width, height); group.syncDomGeometry(); - }, [group, box]); + }, [group, width, height]); const handleSize = resizeHandleThickness ?? DEFAULT_RESIZE_HANDLE_THICKNESS; @@ -271,8 +267,8 @@ export const Group = forwardRef(function Group( willChange: "transform", boxSizing: "border-box", pointerEvents: "none", - width: `${box.w}px`, - height: `${box.h}px`, + width: `${width}px`, + height: `${height}px`, ...style, }} > diff --git a/assets/snapline/react/src/Line.tsx b/assets/snapline/react/src/Line.tsx index d3041cf..b3f5183 100644 --- a/assets/snapline/react/src/Line.tsx +++ b/assets/snapline/react/src/Line.tsx @@ -1,5 +1,8 @@ -import { useEffect, useRef, useState, type CSSProperties } from "react"; -import type { LineComponent } from "@snap-engine/snapline"; +import { useLayoutEffect, useRef, type CSSProperties } from "react"; +import type { + LineComponent, + LineGeometrySnapshot, +} from "@snap-engine/snapline"; export interface LineProps { line: LineComponent; @@ -10,35 +13,10 @@ export interface LineProps { data?: Record; } -interface LineState { - style: CSSProperties; - x1: number; - x2: number; - x3: number; - y1: number; - y2: number; - y3: number; -} - -function getLineState(line: LineComponent): LineState { - const dx = line.endWorldX - line.worldTransform.x; - const dy = line.endWorldY - line.worldTransform.y; - return { - style: { - overflow: "visible", - pointerEvents: "none", - position: "absolute", - transform: `translate3d(${line.worldTransform.x}px, ${line.worldTransform.y}px, 0)`, - willChange: "transform", - zIndex: 1000, - }, - x1: Math.abs(dx / 2), - y1: 0, - x2: dx - Math.abs(dx / 2), - y2: dy, - x3: dx, - y3: dy, - }; +function pathForGeometry(geometry: LineGeometrySnapshot): string { + const { x: dx, y: dy } = geometry.delta; + const x1 = Math.abs(dx / 2); + return `M 0,0 C ${x1}, 0 ${dx - x1}, ${dy} ${dx}, ${dy}`; } export function Line({ @@ -50,20 +28,18 @@ export function Line({ data = {}, }: LineProps) { const lineDomRef = useRef(null); - const [lineState, setLineState] = useState(() => - getLineState(line), - ); + const pathRef = useRef(null); + const initialGeometry = line.geometrySnapshot(); - useEffect(() => { - if (lineDomRef.current) { - line.element = lineDomRef.current as unknown as HTMLElement; - } - const renderLine = () => { - setLineState(getLineState(line)); - }; - const cleanup = line.onRender(renderLine); - renderLine(); - return cleanup; + useLayoutEffect(() => { + return line.bindGeometryWriter((geometry) => { + const svg = lineDomRef.current; + const path = pathRef.current; + if (!svg || !path) return; + svg.style.transform = + `translate3d(${geometry.start.x}px, ${geometry.start.y}px, 0)`; + path.setAttribute("d", pathForGeometry(geometry)); + }); }, [line]); return ( @@ -75,12 +51,20 @@ export function Line({ )} height="4" ref={lineDomRef} - style={lineState.style} + style={{ + overflow: "visible", + pointerEvents: "none", + position: "absolute", + transform: `translate3d(${initialGeometry.start.x}px, ${initialGeometry.start.y}px, 0)`, + willChange: "transform", + zIndex: 1000, + }} width="4" > (function Node( const [lineList, setLineList] = useState( node.getAllOutgoingLines(), ); - // The element's width/height are framework-owned: core reports size changes - // (resize drag) via onSizeChange and this state renders them. Null until the - // first resize so CSS-declared sizes keep applying to non-resized nodes. - const [box, setBox] = useState<{ w: number; h: number } | null>(null); const latestRef = useRef({ callbacks, onDragCommit, @@ -193,7 +189,6 @@ export const Node = forwardRef(function Node( latestRef.current.callbacks.onSizeChange, latestRef.current.onSizeChange, ); - setBox({ w: event.width, h: event.height }); }; node.callbacks.onResizeCommit = (event) => invoke( @@ -210,6 +205,7 @@ export const Node = forwardRef(function Node( latestRef.current.onDragCommit, ); setLineList([...node.getAllOutgoingLines()]); + const boundElement = nodeDomRef.current; return () => { node.callbacks.canStartDrag = original.canStartDrag; @@ -224,7 +220,9 @@ export const Node = forwardRef(function Node( node.callbacks.onSizeChange = original.onSizeChange; node.callbacks.onResizeCommit = original.onResizeCommit; if (ownsNodeRef.current) { - node.destroy(); + node.destroy(false); + } else if (boundElement) { + node.detachElement(boundElement); } }; }, [node]); @@ -235,18 +233,14 @@ export const Node = forwardRef(function Node( }, [node, x, y]); useLayoutEffect(() => { - setBox( - width == null && height == null - ? null - : { w: width ?? node.hitBox.width, h: height ?? node.hitBox.height }, - ); - }, [node, width, height]); - - useLayoutEffect(() => { - if (!node.element || !box) return; - node.setSizeState(box.w, box.h); + if (!node.element || (width == null && height == null)) return; + const nextWidth = width ?? node.hitBox.width; + const nextHeight = height ?? node.hitBox.height; + if (width != null) node.element.style.width = `${width}px`; + if (height != null) node.element.style.height = `${height}px`; + node.setSizeState(nextWidth, nextHeight); node.syncDomGeometry(); - }, [node, box]); + }, [node, width, height]); const handleSize = resizeHandleThickness ?? DEFAULT_RESIZE_HANDLE_THICKNESS; @@ -264,7 +258,8 @@ export const Node = forwardRef(function Node( position: "absolute", transformOrigin: "top left", willChange: "transform", - ...(box ? { width: `${box.w}px`, height: `${box.h}px` } : null), + ...(width != null ? { width: `${width}px` } : null), + ...(height != null ? { height: `${height}px` } : null), ...style, }} > diff --git a/assets/snapline/react/src/Placement.tsx b/assets/snapline/react/src/Placement.tsx index 042884a..28abb69 100644 --- a/assets/snapline/react/src/Placement.tsx +++ b/assets/snapline/react/src/Placement.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState, type ReactNode } from "react"; +import { useEffect, useLayoutEffect, useRef, useState, type ReactNode } from "react"; import type { PlacementController, PlacementSnapshot, @@ -20,15 +20,44 @@ export function Placement({ children, }: PlacementProps) { const [snapshot, setSnapshot] = useState(controller.snapshot); + const latestSnapshotRef = useRef(controller.snapshot); + const previewRef = useRef(null); + + useEffect(() => { + return controller.onStateChange((next) => { + const previous = latestSnapshotRef.current; + latestSnapshotRef.current = next; + const positionAvailabilityChanged = + (previous.position === null) !== (next.position === null); + if ( + previous.active !== next.active || + previous.payload !== next.payload || + previous.size !== next.size || + positionAvailabilityChanged + ) { + setSnapshot(next); + } + }); + }, [controller]); + + useLayoutEffect(() => { + return controller.bindGeometryWriter((geometry) => { + const element = previewRef.current; + if (!element) return; + const position = geometry.position; + element.style.visibility = geometry.visible ? "visible" : "hidden"; + element.style.transform = position + ? `translate3d(${position.x}px, ${position.y}px, 0)` + : "translate3d(0px, 0px, 0)"; + if (geometry.size) { + element.style.width = `${geometry.size.width}px`; + element.style.height = `${geometry.size.height}px`; + } + element.dataset.allowed = String(geometry.allowed); + }); + }, [controller, snapshot.active]); useEffect(() => { - const previousOnChange = controller.callbacks.onChange; - const onChange = (next: PlacementSnapshot) => { - setSnapshot(next); - previousOnChange?.(next); - }; - setSnapshot(controller.snapshot); - controller.callbacks.onChange = onChange; const move = (event: PointerEvent) => { if (controller.snapshot.active) { controller.update({ x: event.clientX, y: event.clientY }, event); @@ -60,9 +89,6 @@ export function Placement({ window.addEventListener("pointerdown", down, true); window.addEventListener("keydown", key); return () => { - if (controller.callbacks.onChange === onChange) { - controller.callbacks.onChange = previousOnChange; - } window.removeEventListener("pointermove", move); window.removeEventListener("pointerdown", down, true); window.removeEventListener("keydown", key); @@ -74,5 +100,20 @@ export function Placement({ controller, ]); - return snapshot.active ? children?.(snapshot) : null; + return snapshot.active ? ( +
+ {children?.(snapshot)} +
+ ) : null; } diff --git a/assets/snapline/react/src/Select.tsx b/assets/snapline/react/src/Select.tsx index b9b33c1..3cea92f 100644 --- a/assets/snapline/react/src/Select.tsx +++ b/assets/snapline/react/src/Select.tsx @@ -1,5 +1,5 @@ -import { useEffect, useRef, useState, type CSSProperties } from "react"; -import { RectSelectComponent, type SelectCallbacks, type SelectRect } from "@snap-engine/snapline"; +import { useLayoutEffect, useRef, type CSSProperties } from "react"; +import { RectSelectComponent, type SelectCallbacks } from "@snap-engine/snapline"; import { useSnapLineEngine } from "./Engine"; export interface SelectProps { @@ -17,33 +17,32 @@ export function Select({ }: SelectProps) { const engine = useSnapLineEngine(); const selectRef = useRef(null); + const selectDomRef = useRef(null); if (!selectRef.current) { selectRef.current = new RectSelectComponent(engine, null, { callbacks }); } const select = selectRef.current; - // The selection box is framework-rendered: core reports the world-space rect - // via onRectChange and this state draws it, so consumers can restyle or - // replace the box (className/style props). - const [rect, setRect] = useState({ - x: 0, - y: 0, - width: 0, - height: 0, - visible: false, - }); - - useEffect(() => { - select.callbacks.onRectChange = (r: SelectRect) => setRect(r); + useLayoutEffect(() => { + const unbind = select.bindGeometryWriter((rect) => { + const element = selectDomRef.current; + if (!element) return; + element.style.display = rect.visible ? "block" : "none"; + element.style.width = `${rect.width}px`; + element.style.height = `${rect.height}px`; + element.style.transform = `translate3d(${rect.x}px, ${rect.y}px, 0)`; + }); return () => { - select.destroy(); + unbind(); + select.destroy(false); }; }, [select]); return (
diff --git a/assets/snapline/svelte/src/Connector.svelte b/assets/snapline/svelte/src/Connector.svelte index 078dcaa..15fa860 100644 --- a/assets/snapline/svelte/src/Connector.svelte +++ b/assets/snapline/svelte/src/Connector.svelte @@ -88,7 +88,7 @@ }); onDestroy(() => { - if (ownsConnector) connector.destroy(); + if (ownsConnector) connector.destroy(false); }); diff --git a/assets/snapline/svelte/src/Group.svelte b/assets/snapline/svelte/src/Group.svelte index 3547c44..b28af48 100644 --- a/assets/snapline/svelte/src/Group.svelte +++ b/assets/snapline/svelte/src/Group.svelte @@ -61,10 +61,6 @@ groupObject = new GroupNodeComponent(engine, null, { width, height, minWidth, minHeight, resizeHandleThickness, resizeHandles, resizeCursors, metadata, callbacks: {}, groupCallbacks: {}, canContain, edgePan }); } - // The box's width/height are framework-owned: seeded from props in the first - // markup pass, updated live by core's onSizeChange during a resize drag. - let boxW = $state(width); - let boxH = $state(height); let mounted = $state(false); let unregisterHeader: (() => void) | null = null; let originalCallbacks: import("@snap-engine/snapline").NodeCallbacks = {}; @@ -113,12 +109,10 @@ invoke(event, originalCallbacks.onResizeHandleChange, callbacks.onResizeHandleChange); groupObject!.groupCallbacks.onMembershipChange = (event) => invoke(event, originalGroupCallbacks.onMembershipChange, groupCallbacks.onMembershipChange, onMembershipChange); - // Resize is handled by the core edge/corner hitboxes; the framework renders - // the live size, and the consumer persists the committed size. + // Resize is handled by the core edge/corner hitboxes and writes live size + // directly; the consumer persists the committed size. groupObject!.callbacks.onSizeChange = (event) => { invoke(event, originalCallbacks.onSizeChange, callbacks.onSizeChange); - boxW = event.width; - boxH = event.height; }; groupObject!.callbacks.onResizeCommit = (event) => invoke(event, originalCallbacks.onResizeCommit, callbacks.onResizeCommit, onResizeCommit); @@ -130,7 +124,7 @@ void tick().then(() => { if (!mounted || !groupObject!.element) return; if (headerEl) unregisterHeader = groupObject!.registerDragHandle(headerEl); - groupObject!.setSizeState(boxW, boxH); + groupObject!.setSizeState(width, height); groupObject!.syncDomGeometry(); // Seed membership once siblings have mounted, positioned, and had their // hit boxes measured (a WRITE stage runs after READ_1's measure). @@ -155,7 +149,8 @@ groupObject!.callbacks.onSizeChange = originalCallbacks.onSizeChange; groupObject!.callbacks.onResizeCommit = originalCallbacks.onResizeCommit; groupObject!.groupCallbacks.onMembershipChange = originalGroupCallbacks.onMembershipChange; - if (ownsGroup) groupObject!.destroy(); + if (ownsGroup) groupObject!.destroy(false); + else if (boxDOM) groupObject!.detachElement(boxDOM); }); $effect(() => { @@ -174,11 +169,11 @@ const nextWidth = width; const nextHeight = height; if (!mounted) return; - boxW = nextWidth; - boxH = nextHeight; const object = untrack(() => groupObject!); void tick().then(() => { if (!mounted || !object.element) return; + object.element.style.width = `${nextWidth}px`; + object.element.style.height = `${nextHeight}px`; object.setSizeState(nextWidth, nextHeight); object.syncDomGeometry(); }); @@ -194,8 +189,8 @@ data-snapline-type="group" class={`snapline-group ${className}`} style="position: absolute; transform-origin: top left; will-change: transform;" - style:width={`${boxW}px`} - style:height={`${boxH}px`} + style:width={`${width}px`} + style:height={`${height}px`} >
{#if headerContent} diff --git a/assets/snapline/svelte/src/Line.svelte b/assets/snapline/svelte/src/Line.svelte index 0a80944..180d86b 100644 --- a/assets/snapline/svelte/src/Line.svelte +++ b/assets/snapline/svelte/src/Line.svelte @@ -1,5 +1,5 @@ @@ -49,8 +35,8 @@ data-snapline-type="connector-line" width="4" height="4" - {style} - bind:this={line.element as any} + style="position: absolute; overflow: visible; pointer-events: none; will-change: transform;" + bind:this={svgDOM} transition:blur|global={{ duration: 200 }} > @@ -63,17 +49,18 @@ - + - + CREATE --> REGISTER --> COMMIT --> SYNC - SYNC --> INTERACT --> REPORT --> PERSIST --> RESYNC - RESYNC --> REMOVE --> DESTROY --> UNREGISTER +sequenceDiagram + autonumber + participant App as Application + participant FW as Framework adapter + participant SL as SnapLine + + App->>FW: Render node / connector record + FW->>SL: Construct or attach core object + SL->>SL: Register mirror in NodeManager + FW->>FW: Commit framework-owned DOM + FW->>SL: Bind element and syncDomGeometry() + SL-->>FW: Write transient transform / state properties + SL-->>App: Emit commit and lifecycle callbacks + App->>FW: Persist props or update collection + FW->>SL: Resynchronize mirror + App->>FW: Remove record + FW->>SL: Destroy adapter-owned object + SL->>SL: Unregister mirror + FW->>FW: Remove DOM ``` ### Nodes @@ -95,8 +97,9 @@ flowchart TB and calls `syncDomGeometry()`. 5. During a drag, core mutates `worldTransform` immediately and writes the DOM transform. `onDragCommit` reports final positions for persistence. -6. During resize, core updates collision state and fires `onSizeChange`; the - adapter renders width and height. `onResizeCommit` reports the settled box. +6. During resize, core updates collision state, writes width/height, remeasures + connectors, and re-glues lines in staged frame writes. `onSizeChange` is + observational; `onResizeCommit` reports the settled box for persistence. 7. On unmount, an adapter-owned node is destroyed and unregistered. The framework controls whether a node is mounted, but live position is locally @@ -124,6 +127,50 @@ configuration is copied into the core mirror. The connector’s `name` is a construction-time key; metadata is currently the common place to carry domain node/port identity. +### Connector connection rules + +The current capability model is asymmetric: + +```ts +interface ConnectorCapabilities { + source: boolean; + target: boolean; + maxIncoming: number; + reconnect: boolean; + allowParallel: boolean; +} +``` + +- `source` and `target` independently enable the two connector roles. +- `maxIncoming === 0` prevents incoming connections. +- A positive `maxIncoming` is a finite capacity. +- A negative `maxIncoming` means unlimited. +- There is no corresponding `maxOutgoing`; a source connector’s outgoing + collection is currently unbounded. +- The deprecated `maxConnectors` option feeds the `maxIncoming` default. + +When a new connection would exceed finite incoming capacity, +`connectToConnector()` deletes the oldest required incoming lines before +attaching the new line. That replacement behavior is implicit rather than a +separate policy. + +Both source and target connectors may provide `canConnect`. SnapLine accepts a +candidate only if both callbacks accept: + +```ts +canConnect?: (event: { + source: ConnectorComponent; + target: ConnectorComponent; +}) => boolean; +``` + +This already supports endpoint-pair compatibility rules based on connector +configuration or metadata. It does not receive the proposed `LineComponent`, +line payload, gesture phase, or connection origin, so it cannot directly +express line-specific admission rules. It is also currently reused by +programmatic and hydration connections rather than being cleanly separated +from canonical-document validation. + ### Lines Lines are not registered in `NodeManager`. They are organized as topology on @@ -140,30 +187,67 @@ The source node adapter renders one framework `Line` component per outgoing Svelte state, so framework reconciliation owns the SVG DOM while SnapLine owns the list being mirrored. +Preview creation does not synchronously flush that framework update. React or +Svelte may mount the SVG according to its normal scheduler while core +continues updating the targetless `LineComponent`. This does not lose +geometry: + +- anchors and preview position remain current on the line model; +- `writeTransform()` is harmless while no geometry writer is bound; +- the line view reads `geometrySnapshot()` when it renders; +- `bindGeometryWriter()` immediately replays the latest geometry when the view + mounts. + +The preview SVG is therefore not a prerequisite for hit testing or gesture +progress. A very short rejected drag may be created and removed from framework +state before an SVG ever commits, which is valid. + +This is separate from `syncDomGeometry()`. Node and connector DOM sometimes +must be measured after a framework commit; a line preview does not depend on +measuring its own SVG. It is also separate from EdgeSync’s queued +acceptance/rejection reconciliation, whose pre-paint timing prevents a +controlled-edge flicker rather than forcing line DOM to mount synchronously. + A line also carries transient rendering state: anchors, phase, candidate, preview position, optional payload, and render subscriptions. ```mermaid -flowchart TB - SN["Source NodeComponent"] - SC["Source ConnectorComponent"] - LINE["One shared LineComponent"] - TC["Target ConnectorComponent"] - TN["Target NodeComponent"] - LIST["Source node outgoing-line snapshot"] - VIEW["React / Svelte Line component"] - SVG["Framework-owned SVG"] - - SN -->|"name-keyed connector map"| SC - SC -->|"outgoingLines contains"| LINE - LINE -->|"start"| SC - LINE -->|"target"| TC - TC -->|"incomingLines contains same object"| LINE - TC --> TN - SN -->|"getAllOutgoingLines()"| LIST - LIST -->|"onLinesChanged"| VIEW - VIEW --> SVG - LINE -->|"onRender()"| VIEW +classDiagram + direction TB + + class NodeComponent { + +connectorsByName + +getAllOutgoingLines() + } + + class ConnectorComponent { + +outgoingLines + +incomingLines + } + + class LineComponent { + +start + +target + +bindGeometryWriter() + +onStateChange() + } + + class NodeAdapter { + +lineList + +onLinesChanged() + } + + class LineView { + +frameworkOwnedSVG + } + + NodeComponent "1" *-- "0..*" ConnectorComponent : name-keyed map + ConnectorComponent "1 source" o-- "0..*" LineComponent : outgoingLines + ConnectorComponent "0..1 target" o-- "0..*" LineComponent : incomingLines + LineComponent --> ConnectorComponent : start / target references + NodeComponent --> NodeAdapter : outgoing snapshot + NodeAdapter "1" *-- "0..*" LineView : renders + LineComponent --> LineView : render callbacks ``` ### Groups @@ -188,10 +272,14 @@ framework graph-document relation in the current design. Selection is stored in `global.data.select`. `NodeComponent.setSelected()` updates that list, writes `data-selected`/`data-snapline-state`, and emits a selection callback. `RectSelectComponent` owns the selection gesture and -collision box, while the framework adapter renders the visible rubber-band -rectangle from `onRectChange`. +collision box. The adapter mounts the rubber-band element once and binds an +imperative geometry writer; `onRectChange` remains an observer callback. -Selection is not currently a controlled framework prop. +SnapLine is the logical owner of selection because core behaviors such as +multi-node dragging need the selected set synchronously. The framework remains +the visual owner: it may use the emitted callback and state attributes to +highlight a node, render another selection treatment, or render none. +Selection is not a controlled framework prop. ## Controlled edge reconciliation @@ -212,11 +300,246 @@ interface EdgeSyncConfig { } ``` -`identity()` maps a live connector mirror to `{ node, port }`. Returning -`null` makes that connector and its lines unmanaged by the controller. +### How `identity()`, `getEdges()`, and callbacks divide responsibility + +These three seams connect runtime connector objects to the application’s +canonical edge document: + +| Seam | Direction | Current responsibility | +| ------------------------------------ | --------------------------------------- | --------------------------------------------------------------------------------------------- | +| `identity(connector)` | SnapLine runtime → application identity | Maps a live `ConnectorComponent` to `{ node, port }`, or returns `null` to leave it unmanaged | +| `getEdges()` | Application state → SnapLine sync | Returns the latest canonical edge snapshot whenever `sync()` runs | +| `onEdgeConnect` / `onEdgeDisconnect` | SnapLine gesture → application | Reports semantic gesture intents so the application can replace its edge state | + +```mermaid +flowchart TB + CONNECTORS["NodeManager.connectors
live runtime objects"] + IDENTITY["identity(connector)"] + INDEX["Endpoint index
{ node, port } → ConnectorComponent"] + APPSTATE["Application edges[]
canonical document"] + GETEDGES["getEdges()"] + SNAPSHOT["Current EdgeLike[] snapshot"] + SYNC["EdgeSyncController.sync()"] + LINES["Connector line topology mirror"] + GESTURE["User connection gesture"] + INTENT["Gesture connect / disconnect intent"] + + CONNECTORS --> IDENTITY --> INDEX + APPSTATE --> GETEDGES --> SNAPSHOT + INDEX --> SYNC + SNAPSHOT --> SYNC + SYNC --> LINES + GESTURE -.-> INTENT + INTENT -.->|"application updates edges[]"| APPSTATE +``` + +#### `identity(connector)` + +`identity()` is not a node or connector registry. It is a translation function +called by `EdgeSyncController`: + +- at least once for each registered connector while building a sync-time + endpoint index; +- again when a line endpoint needs a fallback lookup; +- again for the source and target when translating a gesture connect or + disconnect into an application intent. + +The returned `{ node, port }` pair is the connector’s semantic identity in the +application document. Both values are strings. Returning `null` means: + +- the connector is excluded from the endpoint index; +- its lines are not added or removed by `sync()`; +- gesture events involving it do not produce controlled-edge intents. + +The function should therefore be deterministic for the duration of a sync. +The controller does not retain the result between sync passes. + +The current implementation silently lets the last connector win if two live +connectors resolve to the same endpoint key. It also identifies an edge only +by its endpoint pair, not by a stable edge ID. + +#### `getEdges()` + +`getEdges()` is a pull API, not a subscription. It is called once near the +start of every `sync()` and must return the application’s latest edge +collection. + +`EdgeSyncController` does not: + +- store its own canonical edge list; +- mutate the returned list; +- subscribe to an application store; +- automatically know that an edge document changed. + +React and Svelte keep the function live through a ref or prop getter, then +explicitly call `sync()` when their `edges` prop changes. A vanilla integration +must arrange the equivalent notification itself. + +#### Why the apparent loop does not recurse + +The data-flow diagram describes an event loop: + +1. A gesture emits an intent. +2. The application updates `edges[]`. +3. A later `sync()` reads the new snapshot. +4. `sync()` converges the line mirror. + +It is not a direct call cycle from `sync()` back into `getEdges()` and then +into `sync()` again. + +The current implementation has four protections: + +1. `getEdges()` is only a synchronous data read. Calling it does not schedule + another sync. +2. `EdgeSyncController.#syncing` is a re-entrancy guard. A nested call to + `sync()` returns immediately while a pass is active. +3. Lines created by reconciliation use `origin: "hydration"`. + `notifyConnect()` forwards only `origin: "gesture"`. +4. Lines removed by reconciliation use `reason: "programmatic"`, and + `notifyDisconnect()` suppresses all notifications while `#syncing` is true. + +Because the controlled `onEdgeConnect`/`onEdgeDisconnect` callbacks do not fire +for sync’s own mutations, the adapter callbacks that queue post-intent +microtasks are not re-entered. + +Several independent triggers can still request redundant passes—for example, +an application edge update and the post-intent microtask. Those passes run +sequentially and `sync()` is intended to be idempotent, so the later pass +finds the mirror already converged and performs no mutation. + +An integration should keep `getEdges()` and `identity()` free of side effects. +The re-entrancy guard prevents a direct nested `sync()`, but it cannot make an +application callback that continually mutates its own edge document converge. + +#### Controlled-edge callbacks + +`onEdgeConnect` and `onEdgeDisconnect` are intent callbacks, not general +topology lifecycle callbacks. + +They are forwarded only for: + +- gesture connections; +- gesture disconnections; +- disconnections labeled `"replacement"`. + +Replacement events do not carry their own connection origin. A programmatic +`connectToConnector()` call made outside `sync()` can therefore also cause a +controlled `onEdgeDisconnect` intent if it evicts an existing line. During +`sync()`, replacement forwarding is suppressed and a warning is logged. -`getEdges()` is consulted fresh for every `sync()`. The controller does not -store a second edge document. +They are not forwarded for: + +- hydration; +- programmatic reconciliation; +- connector or node teardown; +- controller-driven removal of a rejected optimistic line. + +Low-level connector `onConnect`/`onDisconnect` callbacks are broader and still +observe those local topology changes with an explicit `origin` or `reason`. + +| Local topology cause | Node/connector callbacks | Controlled-edge intent | +| -------------------------------- | ------------------------------------------------------------------------------------------------------- | ------------------------------------------ | +| New drag preview | Node `onLinesChanged`, then source `onDragStart` | None | +| Successful gesture connection | Node `onLinesChanged`, then source and target `onConnect`, `origin: "gesture"` | `onEdgeConnect` | +| Document hydration | Node `onLinesChanged`, then source and target `onConnect`, `origin: "hydration"` | None | +| Gesture disconnect | Source and target `onDisconnect`, `reason: "gesture"`, then source-node `onLinesChanged` | `onEdgeDisconnect` | +| Capacity replacement | Source and target `onDisconnect`, `reason: "replacement"`, then old source-node `onLinesChanged` | `onEdgeDisconnect` unless a sync is active | +| Rejected optimistic line cleanup | Source and target `onDisconnect`, `reason: "programmatic"`, then source-node `onLinesChanged` | None | +| Connector/node teardown | Source and target `onDisconnect`, `reason: "teardown"`, then the surviving source-node `onLinesChanged` | None | + +### Controller lifetime and sync triggers + +Only one `EdgeSyncController` is attached to a `NodeManager` at a time. +Constructing another controller replaces the manager reference with a warning. + +```mermaid +sequenceDiagram + participant Adapter as EdgeSync adapter + participant Controller as EdgeSyncController + participant Manager as NodeManager + participant App as Application + + Adapter->>Controller: construct({ identity, getEdges, callbacks }) + Controller->>Manager: manager.edgeSync = controller + Adapter->>Controller: initial sync() + + loop Each later connector mount + Manager->>Manager: registerConnector() + Manager->>Controller: connectorRegistered() + Controller->>Controller: coalesce microtask and start sync() + Controller->>App: getEdges() + end + + App->>Adapter: edges prop changes + Adapter->>Controller: sync() + Controller->>App: getEdges() + + Controller->>App: gesture intent callback + App->>App: synchronously update edges[] + Controller->>Controller: queued microtask sync() + Controller->>App: getEdges() + + Adapter->>Controller: dispose() + Controller->>Manager: clear manager.edgeSync if still current +``` + +Current sync triggers are: + +1. Controller/adapter mount. +2. An `edges` prop change. +3. Registration of a connector after the controller exists. +4. The microtask queued after a controlled connect/disconnect intent. +5. An explicit vanilla call to `controller.sync()`. + +Connector registration is coalesced so a batch of newly mounted endpoints +causes one sync. Connector unregistration does not request a sync: connector +teardown directly removes its incident line mirrors while the application edge +remains canonical and latent. + +#### Bulk registration behavior + +Node registration does not trigger edge reconciliation. Connector registration +does, because a newly available endpoint may make a canonical edge +representable. Each call to `registerConnector()` reaches +`connectorRegistered()`, but the controller uses a `#syncQueued` flag and one +microtask: + +```mermaid +sequenceDiagram + participant Framework + participant Manager as NodeManager + participant Sync as EdgeSyncController + + loop All connectors mounted in the same JavaScript turn + Framework->>Manager: registerConnector(connector) + Manager->>Sync: connectorRegistered() + Sync->>Sync: queue only if #syncQueued is false + end + + Note over Framework,Sync: Current synchronous mount/commit finishes + Sync->>Sync: one microtask sync() +``` + +Consequently, loading 100 nodes in one synchronous framework batch does not +normally run one reconciliation per node or connector. It runs at most one +connector-registration reconciliation for that microtask window. If the +controller is created only after the connectors mount, its initial `sync()` +provides the single pass instead. + +The batching boundary is scheduling-based, not an explicit graph transaction: + +- registrations spread across separate tasks or framework commits can produce + one sync per commit; +- an adapter edge-prop effect and the connector-registration microtask can both + request a pass; +- React and Svelte have different effect timing, so the exact redundant-pass + pattern is adapter-dependent; +- no API currently says “the full saved graph has finished mounting.” + +The redundant passes are intended to be idempotent, but each pass snapshots and +indexes all connectors, scans settled lines, and walks all canonical edges. +Repeated partial-load passes can therefore become materially more expensive +than one final pass on a large graph, even though they remain correct. ### Reconciliation algorithm @@ -266,36 +589,180 @@ ask the application to delete its edge record. ### Gesture connect -1. Pointer-down arms a connector. -2. Crossing the drag threshold creates a preview `LineComponent` and adds it - to the source’s outgoing list. -3. Dropping on a candidate calls the legacy `onConnectionRequest` seam. -4. If allowed, `connectToConnector({ origin: "gesture" })` commits the local - topology. -5. Connector callbacks fire. -6. The active `EdgeSyncController` emits `onEdgeConnect`. -7. The application is expected to update its edge document synchronously. -8. The adapter queues `sync()` in a microtask. Accepted lines remain; rejected - lines are removed before the next paint when state updates synchronously. +The line exists as a preview before the application is asked to create a +canonical edge. A successful drop does **not** delete that preview and create a +replacement. `endDragOutLine()` passes the active `#dragLine` into +`connectToConnector()`, and `connectTarget()` settles that same object by +assigning its target, connected phase, and final anchors. The preview is +destroyed only when the drag is cancelled, rejected, or dropped without a +valid target. + +The current callback lifetime is: ```mermaid -flowchart TB - DRAG["User: drag source to target"] - PREVIEW["Connector: create preview LineComponent"] - DROP["User: drop on candidate"] - SETTLE["Connector: optimistically settle local topology"] - INTENT["EdgeSync: emit onEdgeConnect intent"] - DECIDE["Application: accept, reject, or normalize edges[]"] - SYNC["EdgeSync: read latest edges[] in queued microtask"] - ACCEPT{"Matching canonical edge exists?"} - KEEP["Preserve line mirror
and render framework SVG"] - DELETE["Delete unmatched mirror
and remove framework SVG"] - - DRAG --> PREVIEW --> DROP --> SETTLE --> INTENT --> DECIDE --> SYNC --> ACCEPT - ACCEPT -->|"yes"| KEEP - ACCEPT -->|"no"| DELETE +sequenceDiagram + actor Input as User / input + participant Source as Source connector + participant View as Node + line adapter + participant Target as Target connector + participant Sync as EdgeSync + application + + Input->>Source: pointerDown + Source->>Source: callbacks.onPointerDown + + Input->>Source: dragStart threshold crossed + Source->>Source: createLine() and add to outgoingLines + Source-->>View: node.callbacks.onLinesChanged requests line render + Source->>Source: callbacks.onDragStart + View->>View: framework commits retained Line structure + View->>Source: bindGeometryWriter(writer) + + loop Pointer drag + Source->>Source: resolve candidate and update preview + Source->>Source: callbacks.onCandidateChange + Source->>View: invoke geometry writer in WRITE_2 + end + + Input->>Source: drop on target + Source->>Source: callbacks.onConnectionRequest + + alt Request returns false or local connection fails + Source->>Source: destroy the preview LineComponent + Source->>View: node.callbacks.onLinesChanged removes preview + Source->>Source: callbacks.onDragEnd({ connected: false }) + else Local connection succeeds + Source->>Target: attach same preview LineComponent + Source->>Source: connectTarget() mutates target, phase, and anchors + Source->>View: node.callbacks.onLinesChanged + Source->>Source: callbacks.onConnect({ role: source }) + Source->>Target: callbacks.onConnect({ role: target }) + Source->>Sync: onEdgeConnect intent + Sync->>Sync: application updates edges[] and queues sync() + Source->>Source: callbacks.onDragEnd({ connected: true }) + Sync->>Sync: read getEdges() in microtask + alt Canonical edge exists + Sync->>Source: preserve settled line mirror + else Canonical edge is absent + Sync->>Source: delete line with reason programmatic + Source->>Source: callbacks.onDisconnect({ role: source }) + Source->>Target: callbacks.onDisconnect({ role: target }) + Source->>View: node.callbacks.onLinesChanged removes line + end + end +``` + +#### Line phase lifetime + +The line’s `phase` is semantic state, separate from connector lifecycle +callbacks: + +```mermaid +stateDiagram-v2 + direction TB + [*] --> SourceStart: createLine() + SourceStart --> PreviewFree: drag start initialized + PreviewFree --> PreviewTarget: candidate found + PreviewTarget --> PreviewFree: candidate lost + PreviewFree --> Drop: pointer released + PreviewTarget --> Drop: pointer released + Drop --> Connected: local connection succeeds + Drop --> Destroyed: empty / rejected / failed drop + Connected --> PreviewFree: pickup clears existing target + PreviewFree --> SourceStart: reconnect drag initializes + Connected --> Destroyed: disconnected or teardown + Destroyed --> [*] ``` +`setPhase()`, `setCandidate()`, and payload changes notify +`line.onStateChange()` subscribers. Anchor updates remain local model changes; +the scheduled transform write invokes the single registered geometry writer. +Neither path calls connector `onConnect`/`onDisconnect`. + +#### Preview creation + +Pointer-down only arms the source and calls +`source.callbacks.onPointerDown`. A new `LineComponent` is not created until +the engine’s drag threshold is crossed. + +At drag start, the source: + +1. Creates the targetless line. +2. Adds it to `source.outgoingLines`. +3. Calls `node.callbacks.onLinesChanged`. +4. Calls `source.callbacks.onDragStart`. +5. Changes the line phase from `"source-start"` to `"preview-free"`. + +The node adapter responds to `onLinesChanged` by rendering a line component. +That component mounts its static SVG structure and calls +`line.bindGeometryWriter()`. Binding immediately supplies the current geometry, +so a line created before the framework commit still mounts correctly. During +the drag, scheduled core writes mutate the retained SVG through that writer; +they do not enqueue framework state updates. Custom renderers use the same +contract for their own retained SVG, canvas, or other imperative surface. + +#### Candidate callbacks + +While the pointer moves, the source resolves eligible targets. A changed +candidate updates the line first and then calls +`source.callbacks.onCandidateChange`. + +`canConnect` is a policy predicate rather than a lifecycle callback. It may be +consulted repeatedly during candidate discovery and again before the final +local connection. + +#### Drop policy + +Dropping on a candidate first calls the source-only +`onConnectionRequest`. This is the legacy application-model seam: + +- returning `false` rejects the local connection; +- returning nothing accepts it; +- returning `{ payload }` accepts it and attaches opaque payload to the line. + +If it rejects, or if the drop has no candidate, the preview is removed, +`node.onLinesChanged` fires, and `source.onDragEnd` reports +`connected: false`. No connector `onConnect` callback and no controlled +`onEdgeConnect` intent fires. + +#### Successful local connection + +When the local connection succeeds, the current order is: + +1. Enforce target capacity, potentially disconnecting replacement lines. +2. Attach the line to the target’s `incomingLines`. +3. Update anchors and render state. +4. Fire `node.callbacks.onLinesChanged`. +5. Fire source `onConnect` with `role: "source"`. +6. Fire target `onConnect` with `role: "target"`. +7. Notify `EdgeSyncController`, which synchronously calls `onEdgeConnect`. +8. The adapter invokes the application handler and queues `sync()` in a + microtask. +9. Fire source `onDragEnd({ connected: true })`. +10. Clear the active candidate, which may emit a final + `onCandidateChange` with `candidate: null`. + +`connected: true` means the gesture produced a successful **local** connector +attachment. It does not prove that the application accepted a canonical edge. + +#### Acceptance versus rejection by the application + +The application is expected to update `edges[]` synchronously inside +`onEdgeConnect`. The queued microtask then calls `getEdges()`: + +- If the endpoint pair exists, `sync()` preserves the line object. +- If it does not exist, `sync()` deletes the optimistic line with reason + `"programmatic"`. + +That rejection cleanup fires the low-level source and target `onDisconnect` +callbacks and `node.onLinesChanged`. It does **not** fire controlled +`onEdgeDisconnect`, because programmatic reconciliation is not a user +disconnect intent. + +This distinction explains why an application can observe +`onDragEnd({ connected: true })` and then see the line disappear during the +same task: the first event describes local gesture completion, while the +canonical edge document decides whether the settled mirror survives. + ### Gesture disconnect and replacement Picking up an existing connection detaches it locally and emits a gesture @@ -321,9 +788,9 @@ atomic application transaction. | Node/connector/line DOM | React/Svelte adapter | Core never structurally inserts, removes, or reparents framework DOM | | Node live transform | SnapLine during interaction | Framework geometry props can resynchronize it | | Node persisted transform | Application by convention | Reported through drag commit callbacks | -| Node size DOM | Framework adapter | Core keeps collision size and emits size callbacks | +| Node size DOM | SnapLine during interaction | Framework persists the committed size and may issue later prop updates | | Connector geometry | SnapLine | Measured/cached from node and optional connector DOM | -| Line geometry and anchors | SnapLine | Adapter subscribes through `line.onRender()` | +| Line geometry and anchors | SnapLine | Adapter binds an imperative geometry writer | | Selection | SnapLine shared state | Framework receives callbacks; not controlled | | Group membership | SnapLine derived state | Framework receives deltas | | Connector policy | Application config copied into core | `canConnect`, capabilities, strategies, metadata | @@ -377,18 +844,39 @@ must be filtered by engine at their use sites. Selection currently has no engine-keyed container. ```mermaid -flowchart TB - GLOBAL["GlobalManager.data"] - TABLE["SnapEngine object table"] - MAP["nodeManagers: Map<Engine, NodeManager>"] - MANAGER["NodeManager for one engine
• Set<NodeComponent>
• Set<ConnectorComponent>
• optional edgeSync controller"] - SHARED["Shared arrays:
select, groups, resizeHandles,
sourceSurfaces"] - LINES["Line topology on connector arrays"] - - GLOBAL --> MAP --> MANAGER - MANAGER --> LINES - GLOBAL --> SHARED - TABLE -->|"still scanned by candidate discovery
and group membership"| MANAGER +classDiagram + direction TB + + class GlobalData { + +nodeManagers + +select + +groups + +resizeHandles + +sourceSurfaces + } + + class EngineNodeManagerMap + + class NodeManager { + +nodes + +connectors + +edgeSync + } + + class NodeComponent + class ConnectorComponent + class LineComponent + class EdgeSyncController + class SnapEngineObjectTable + + GlobalData *-- EngineNodeManagerMap : engine-keyed map + EngineNodeManagerMap *-- NodeManager : one per engine + NodeManager o-- NodeComponent : live set + NodeManager o-- ConnectorComponent : live set + NodeManager o-- EdgeSyncController : optional + ConnectorComponent o-- LineComponent : topology arrays + SnapEngineObjectTable ..> NodeComponent : group scans + SnapEngineObjectTable ..> ConnectorComponent : candidate scans ``` ## Public API surface @@ -575,12 +1063,13 @@ package subpath maps do not expose `./EdgeSync`. The core root exports `EdgeSyncController`, `NodeManager`, and `getNodeManager`, while its documented subpath map does not include `./edge-sync` or `./node-manager`. -### 11. Callback composition is not uniform +### 11. Geometry writers are single-owner bindings -Node and group adapters compose caller callbacks with adapter callbacks. -Selection adapters directly replace `onRectChange`, which can hide a caller’s -original handler. This is an adapter API consistency issue rather than a graph -ownership issue, but it affects how safely applications observe the mirror. +Line, selection, and placement geometry use `bindGeometryWriter`. The newest +binding replaces the previous writer, and its cleanup is identity-guarded so a +stale framework cleanup cannot detach a newer renderer. Observer callbacks such +as `onRectChange`, `onSizeChange`, and `onStateChange` remain separate and are +not used by the adapters to drive per-frame framework renders. ## Source map diff --git a/docs/snapline/design/ownership-specification.md b/docs/snapline/design/ownership-specification.md index e17d5c9..ca0525a 100644 --- a/docs/snapline/design/ownership-specification.md +++ b/docs/snapline/design/ownership-specification.md @@ -144,7 +144,7 @@ Core MAY update existing-element properties that are interaction outputs: - transforms; - `data-*` state attributes; - cursor and other transient property-level hints; -- line-render subscription state. +- high-frequency geometry through registered imperative writers. Core MUST NOT structurally move framework-owned node elements when groups carry them. Transform parenting is allowed. @@ -424,24 +424,28 @@ Multiple same-task triggers SHOULD coalesce. ## Gesture requirements ```mermaid -flowchart TB - BEGIN["User: begin connector drag"] - PREVIEW["SnapLine: create ephemeral preview"] - DROP["User: drop on candidate"] - INTENT["SnapLine: emit semantic connect intent"] - DECISION{"Application decision"} - ACCEPT["Accept / normalize:
update canonical document"] - REJECT["Reject:
leave document unchanged"] - DEFER["Defer:
keep explicitly pending"] - READ["SnapLine: reconcile latest document"] - SETTLE["Preserve or create settled line mirror"] - REMOVE["Remove optimistic preview / mirror"] - LATER["Application: apply later decision"] - - BEGIN --> PREVIEW --> DROP --> INTENT --> DECISION - DECISION -->|"accepted"| ACCEPT --> READ --> SETTLE - DECISION -->|"rejected"| REJECT --> REMOVE - DECISION -->|"deferred"| DEFER --> LATER --> READ +sequenceDiagram + actor User + participant SL as SnapLine + participant App as Application + canonical document + + User->>SL: Begin connector drag + SL->>SL: Create ephemeral preview + User->>SL: Drop on candidate + SL->>App: Emit semantic connect intent + alt Accepted or normalized + App->>App: Update canonical edge document + SL->>App: Read latest document + SL->>SL: Preserve or create settled line mirror + else Rejected + App->>App: Leave document unchanged + SL->>App: Read latest document + SL->>SL: Remove optimistic preview / mirror + else Deferred + App-->>SL: Keep result explicitly pending + App->>App: Apply later decision + SL->>App: Reconcile final document + end ``` ### G1. Preview diff --git a/docs/snapline/design/planned-rearchitecture.md b/docs/snapline/design/planned-rearchitecture.md new file mode 100644 index 0000000..dc67713 --- /dev/null +++ b/docs/snapline/design/planned-rearchitecture.md @@ -0,0 +1,1112 @@ +# SnapLine planned re-architecture + +Status: living planning document +Started: 2026-07-25 +Related: +[current architecture](./current-architecture.md) · +[ownership specification](./ownership-specification.md) + +This document is a delta from the current implementation. It contains only: + +- unresolved decisions that block implementation; +- agreed changes that have not been implemented; +- verification required for those changes. + +Current behavior, completed decisions, and invariants that already hold belong +in [current architecture](./current-architecture.md), not here. Remove an item +from this document once its implementation and required verification land. + +## Working objective + +Make the application/framework the unambiguous source of truth for committed +nodes, connectors, and lines, then simplify SnapLine around an engine-scoped, +read-only runtime mirror. + +## Phase 0: legacy audit, vocabulary, and naming cleanup + +SnapLine is one of the oldest areas of the project. Before changing its +architecture, perform a behavior-preserving review of the whole SnapLine +codebase and remove naming and organization debt accumulated over roughly four +years. + +The first naming problem to eliminate is the interchangeable use of “edge” and +“line.” SnapLine APIs will converge on **line**. Suffixes identify the layer: + +| Convention | Meaning | +| ----------------- | -------------------------------------------------------------------------- | +| `*Record` | Canonical application data, such as `NodeRecord` or `LineRecord` | +| `*Object` | SnapLine runtime entity, such as `NodeObject` or `LineObject` | +| `*Component` | React, Svelte, or another front-end framework component | +| `*Element` | Actual framework-owned DOM/SVG element | +| `*Snapshot` | Immutable point-in-time data returned by an object or query | +| `LineRecord` | Canonical committed relationship identified by `LineId` | +| `LineObject` | SnapLine runtime representation in preview, settled, or detached phase | +| `LineObjectPhase` | Explicit lifetime state; “preview” is a phase, not a second runtime entity | +| `LineComponent` | Framework component rendering a `LineObject` | +| `LineCommit` | Atomic staged line change handed to the canonical owner | +| `*LifecycleEvent` | Observation of local object topology without canonical document authority | + +For example, `onLinesChanged` and `onEdgeConnect` participate in the same broad +connection pipeline, but they are not equivalent: + +- `onLinesChanged` publishes `LineObject` instances so an adapter can reconcile + `LineComponent` instances; +- `onEdgeConnect` currently proposes a canonical line-record mutation. + +Their names fail to communicate that difference. Phase 0 must produce and apply +a rename map in which the layer and direction are visible. Architecture-bound +callbacks that D5/D6 will replace should receive their final names when the +unified commit API lands rather than being renamed twice. + +### Naming rules + +- Remove `Edge*` from the SnapLine vocabulary; translate external edge + terminology at integration boundaries when necessary. +- Core runtime classes end in `Object`; front-end framework types end in + `Component`. +- Every callback or consumer-supplied hook begins with `on`. +- No imperative command, query, or method that consumers call begins with + `on`. +- `on*Commit` hands an atomic staged change to the canonical owner. The next + pull confirms the result. +- `on*Changed` observes state after it changed and does not request another + mutation. +- `on*Check` is a synchronous, side-effect-free predicate callback. +- `on*Resolve` is a callback that computes and returns a value without + committing topology. +- `on*Pull` supplies the latest canonical snapshot to a read-only pull + operation. +- Imperative `can*`, `is*`, and `resolve*` methods are queries/calculations, not + callbacks. +- `reconcile*` is the only target verb for making the runtime mirror match + canonical state. +- `write*` means an imperative presentation/DOM property write. +- `bind*Writer` registers a presentation sink and immediately supplies its + latest snapshot. +- `register*` / `unregister*` change mirror indexes. +- `create*`, `settle*`, `detach*`, `discard*`, and `destroy*` describe distinct + lifetime operations and must not be hidden behind optional arguments. + +### Canonical and mirror lifecycle vocabulary + +Use a Git-inspired directional model when the application/framework owns the +canonical graph. The analogy describes ownership and movement, not a literal +Git implementation: + +| Term | SnapLine meaning | +| -------------------- | ------------------------------------------------------------------------------------ | +| **Canonical** | Application-owned node, connector, and line records | +| **Mirror** | Engine-scoped SnapLine objects derived from canonical records | +| **Stage** | Create or modify provisional state on a local object; canonical state is unchanged | +| **Commit** | Hand an atomic staged change to the canonical owner through an `on*Commit` callback | +| **Canonical update** | The application accepts or normalizes a commit by changing its canonical records | +| **DOM render** | The framework materializes the latest canonical state in its owned DOM | +| **Pull** | Read the latest canonical snapshot through a read-only `on*Pull` callback | +| **Reconcile** | Make the SnapLine mirror match the snapshot returned by a pull | +| **Settle** | Confirm a staged object against pulled canonical state without recreating the object | +| **Discard / revert** | Remove or undo staged state after cancellation or canonical rejection | + +Staging is a phase of the same `LineObject`, not a second temporary entity. +Calling `onLineCommit` begins the commit operation. The common case is that the +application accepts it, but the next canonical pull remains authoritative and +may confirm, normalize, or reject the staged change. + +Do not use `push` for the outbound operation. SnapLine is proposing a change, +not replacing the canonical document wholesale. + +`sync` is not part of the target SnapLine vocabulary. For canonical/mirror +alignment, always use `reconcile`; for unrelated operations, name the actual +work, such as `remeasureDomGeometry()` or `writeTransform()`. + +Pull and reconciliation remain separate directional steps: + +- `pullCanonical()` obtains the current canonical snapshot; +- `reconcileMirror(snapshot)` applies that snapshot to SnapLine objects. + +`LineReconciler.reconcile()` is the high-level replacement for the legacy +`EdgeSyncController.sync()`. It pulls once and then reconciles: + +```ts +reconcile(): void { + const snapshot = this.pullCanonical(); + this.reconcileMirror(snapshot); +} +``` + +The word “synchronous” may still describe timing, and external API names such +as React’s `flushSync` remain unchanged. Neither case names a SnapLine +alignment operation. + +A framework adapter may implement the pull callback by returning the latest +props; “pull” does not imply a network request. + +```ts +interface CanonicalLineBridge { + onCanonicalPull(): CanonicalGraphSnapshot; + onLineCommit(commit: LineCommit): void; +} +``` + +```mermaid +sequenceDiagram + autonumber + participant SL as SnapLine mirror + participant A as Adapter callbacks + participant C as Canonical app state + participant F as Framework / DOM + + SL->>SL: Stage change on the existing LineObject + SL->>A: onLineCommit(commit) + A->>C: Accept, normalize, or reject + C->>F: Render latest canonical records + F-->>A: DOM render completes + SL->>A: onCanonicalPull() + A-->>SL: CanonicalGraphSnapshot + SL->>SL: Reconcile mirror + + alt Commit accepted + SL->>SL: Settle the staged LineObject + else Commit rejected + SL->>SL: Discard or revert staged state + end +``` + +Pull and reconciliation are read-only with respect to canonical state and must +never emit a commit. That one-way rule prevents commit → pull → commit +recursion. + +### Initial rename direction + +The exhaustive rename map still requires the Phase 0 inventory. These names +establish the direction and prevent later architecture work from introducing a +second vocabulary: + +| Current or legacy name | Target direction | Reason | +| --------------------------------------------------------- | ---------------------------------------- | ------------------------------------------------------------------- | +| Core `NodeComponent` | `NodeObject` | It is a SnapLine runtime entity, not a framework component | +| Core `ConnectorComponent` | `ConnectorObject` | Same layer distinction | +| Core `LineComponent` | `LineObject` | Same layer distinction and the canonical “line” term | +| Core `GroupNodeComponent` | `GroupNodeObject` | Same layer distinction | +| `EdgeSyncController` | `LineReconciler` | Describes canonical line records converging into runtime objects | +| `EdgeSyncController.sync()` | `LineReconciler.reconcile()` | Uses the precise mirror-alignment verb | +| `#syncing` / `#syncQueued` | `#reconciling` / `#reconciliationQueued` | Names reconciliation state rather than generic synchronization | +| `syncDomGeometry()` | `remeasureDomGeometry()` | Names DOM measurement rather than canonical reconciliation | +| `onLinesChanged` | `onLineObjectsChanged` | Observes the runtime collection used to render framework components | +| `onEdgeConnect`, `onEdgeDisconnect`, connection callbacks | `onLineCommit` | One callback for atomic canonical line-record changes | +| Consumer-supplied `canConnect` predicate | `onConnectionCheck` | The `on` prefix identifies a supplied callback | +| Imperative connection predicate | `canConnect` | No `on` prefix identifies a query the caller invokes | +| `EdgeId`, `EdgeRecord`, and `edgeId` | `LineId`, `LineRecord`, and `lineId` | Removes the edge/line synonym | +| Framework rendering type | `LineComponent` | `Component` remains reserved for React/Svelte/front-end entities | + +This is not a blind global replacement. For example, +`onLineObjectsChanged` and `onLineCommit` intentionally remain separate: +the former reports an already-changed runtime render collection, while the +latter asks the application to change canonical records. + +### Readability and performance rule + +Optimize for code that a developer can read, debug, and modify manually. +Prefer a clean API, small modules, explicit state transitions, and linear logic +over a clever abstraction or fused algorithm that provides only a speculative +or minor speed improvement. + +- Choose the simplest algorithm that meets measured product requirements. +- Profile before introducing algorithmic complexity. +- Prefer two obvious passes over one difficult stateful pass unless a benchmark + demonstrates that the extra pass is a real bottleneck. +- Keep mutations explicit and close to the state they change. +- Avoid hidden work in getters, callbacks, constructors, and optional + arguments. +- Isolate unavoidable hot-path optimizations behind a small, well-named API. +- Require a benchmark, focused tests, and an invariant comment for any + non-obvious optimization. +- Do not accept accidental pathological behavior such as unbounded repeated + full-graph scans merely in the name of simplicity; clarity and reasonable + scaling are both required. + +### Phase 0 tasks + +- Inventory every core, React, and Svelte public type, callback, method, field, + module, and package export. +- Classify each API as canonical graph, runtime mirror, interaction, geometry, + presentation, query, or unmanaged command. +- Produce an old-name → new-name table before editing public APIs. +- Audit every use of `sync`, `commit`, `push`, `pull`, `request`, and + `reconcile` against the canonical/mirror lifecycle. +- Rename misleading internals immediately; schedule architecture-dependent + public replacements in their owning phase. +- Split functions that combine unrelated creation, mutation, notification, and + presentation responsibilities. +- Replace clever or implicit control flow with explicit intermediate values and + named operations, even when that costs a small amount of non-critical work. +- Remove dead code, obsolete aliases, duplicated helpers, stale comments, and + compatibility paths that are unnecessary before 1.0. +- Normalize private-field usage, event/type suffixes, method ordering, and + module boundaries. +- Add characterization tests only where existing behavior is not sufficiently + protected for a safe cleanup. +- Run type checking and the complete SnapLine unit, React, Svelte, and browser + suites before and after the cleanup. + +### Phase 0 exit criteria + +- The glossary and rename map are reviewed. +- No SnapLine public or internal name uses edge and line as synonyms. +- Every callback begins with `on`, and no imperative API begins with `on`. +- No target API uses `sync`: canonical/mirror alignment uses `reconcile`, while + unrelated operations name their concrete measurement, write, or scheduling + work. +- `pull` only retrieves canonical state; it does not imply reconciliation. +- A staged proposal becomes a commit when passed to `onLineCommit`; the next + pull confirms, normalizes, or rejects it. +- Callback names distinguish predicates, observations, presentation writers, + lifecycle events, pulls, and canonical line commits. +- Large functions have one identifiable responsibility or a documented reason + to coordinate several operations. +- Removed APIs have migration notes where externally relevant. +- Behavior and framework DOM ownership remain unchanged. +- All SnapLine checks pass. + +## Priority design decisions + +These decisions block or shape most of the implementation work. + +| ID | Decision | Current leaning | Status | +| --- | -------------------------------------------------------------------------- | ------------------------------------------------------------------------ | ----------- | +| D1 | Is controlled graph state the primary/recommended mode? | Yes; keep imperative behavior as an explicit unmanaged mode | **Planned** | +| D2 | Which canonical graph entities require stable IDs? | Nodes, connectors, and lines | **Planned** | +| D3 | How are references to mirrors that have not registered yet represented? | ID-based soft links resolved through `NodeManager` indexes | **Planned** | +| D4 | What does hydration do with lines that violate gesture policy? | Preserve the canonical record and report a structured error | **Planned** | +| D5 | Are replacement and reconnect atomic operations? | Yes; emit one atomic line commit | **Planned** | +| D6 | What becomes of `onConnectionRequest`? | Merge it with controlled line changes into one commit callback | **Planned** | +| D7 | Is `NodeManager` public API or an internal mirror service? | Keep the service internal and expose a read-only query facade | **Planned** | +| D8 | How do node position and size props interact with SnapLine-owned geometry? | Expose explicit SnapLine-owned and canonical modes | **Planned** | +| D11 | Where is the live engine group registry stored? | `NodeManager`; remove the separate `global.data.groups` registry | **Planned** | +| D12 | How are multi-render-commit graph mounts reconciled? | Add an explicit, nestable bulk-update boundary | **Planned** | +| D13 | How are connector direction, capacity, and compatibility expressed? | Symmetric incoming/outgoing limits plus a line-aware admission predicate | **Planned** | + +## Agreed changes not yet implemented + +### D1. Controlled graph authority with explicit unmanaged code + +**Planned delta** + +Make controlled graph state the primary and recommended integration: + +- application node, connector, and line records are canonical; +- SnapLine objects and settled lines are mirrors; +- user gestures stage line changes and emit atomic line commits; +- direct settled-topology mutation is internal or rejected for controlled + entities. + +Keep imperative behavior through a separately named unmanaged API. Unmanaged +code may treat connector topology as canonical, but it must opt into that +authority model explicitly rather than acquiring it merely because no callback +was supplied. + +Public types, examples, and method names must make the authority mode visible. +The remaining design detail is whether one engine may contain explicitly +partitioned controlled and unmanaged graphs; accidental cross-mode lines are +not allowed. + +### D2/D3. Stable graph IDs and soft-link resolution + +**Planned delta** + +Require application-owned stable IDs for every canonical node, connector, and +line: + +```ts +type NodeId = string; +type ConnectorId = string; +type LineId = string; + +interface NodeRecord { + id: NodeId; +} + +interface ConnectorRecord { + id: ConnectorId; + nodeId: NodeId; +} + +interface LineRecord { + id: LineId; + fromConnectorId: ConnectorId; + toConnectorId: ConnectorId; +} +``` + +The exact primitive type remains to settle, but IDs must be immutable and +unique within one canonical graph. A runtime `BaseObject.id` is not a +substitute: it identifies one instantiated mirror and cannot reference an +entity before that mirror exists or across a remount. + +```mermaid +erDiagram + NODE ||--o{ CONNECTOR : owns + CONNECTOR ||--o{ LINE : source + CONNECTOR ||--o{ LINE : target + + NODE { + NodeId id + } + + CONNECTOR { + ConnectorId id + NodeId nodeId + } + + LINE { + LineId id + ConnectorId fromConnectorId + ConnectorId toConnectorId + } +``` + +Canonical relationships store IDs, not `NodeObject` or `ConnectorObject` +references. `NodeManager` adds live lookup indexes: + +```ts +nodesById: ReadonlyMap; +connectorsById: ReadonlyMap; +linesById: ReadonlyMap; +``` + +The same soft-link rule applies to a connector’s parent-node reference, line +endpoint references, and any future node-to-node or connector-to-connector +relationship. + +If a referenced node or connector has not registered, resolution returns +`null` and the relationship remains latent. Registration marks reconciliation +dirty; a later pass resolves any newly satisfiable links and creates or updates +their mirrors. Unregistration makes affected links latent again without +deleting the canonical records. + +```mermaid +stateDiagram-v2 + direction TB + [*] --> Latent: canonical record contains IDs + Latent --> PartiallyResolved: some referenced mirrors register + PartiallyResolved --> Resolved: every referenced mirror registers + Resolved --> Latent: a referenced mirror unregisters + Resolved --> [*]: canonical record is deleted +``` + +**Planned implementation** + +- Add immutable typed identity to node, connector, and line mirrors. +- Add manager indexes and read-only lookup methods for all three IDs. +- Make controlled records and line commits carry IDs rather than runtime + object references as their durable identity. +- Validate a connector’s `nodeId` against its mounted parent node. +- Detect duplicate IDs and report structured diagnostics; never silently let + the last registration win. +- Keep unresolved records in the application/controller layer rather than + constructing placeholder core objects. +- Trigger the batch-aware reconciliation scheduler when a missing identity + registers or unregisters. +- Preserve a `LineObject` across endpoint changes when its `lineId` remains + the same. + +**Remaining details** + +- Choose whether IDs are strings only or generic string/number values. +- Decide whether connector IDs are graph-global or unique within a node. The + current leaning is graph-global so one `ConnectorId` is an unambiguous soft + link. +- Define whether an attempted identity change is rejected or handled as an + unregister/register transaction. + +### D4. Canonical hydration reports structured errors + +**Planned delta** + +When a canonical line cannot be represented because of duplicate identity, an +invalid reference after a graph load is declared complete, connector capacity, +parallel-line policy, or another connector rule: + +- do not delete, reorder, replace, or otherwise rewrite the canonical record; +- do not emit a line commit from pull/reconciliation; +- leave the mirror latent or explicitly errored; +- publish a structured reconciliation error. + +```ts +interface ReconciliationError { + code: + | "duplicate-id" + | "missing-node" + | "missing-connector" + | "capacity-exceeded" + | "connection-rejected" + | "unrepresentable-line"; + lineId?: LineId; + nodeId?: NodeId; + connectorId?: ConnectorId; + message: string; + cause?: unknown; +} +``` + +Errors must be available through a callback and a read-only diagnostic query. +Reconciliation retries an errored/latent record after relevant identities, +rules, or canonical records change. + +A valid soft link whose mirror simply has not mounted yet remains latent and is +not itself an error. + +### D5/D6. One atomic line-commit callback + +**Planned delta** + +Replace the overlapping `onConnectionRequest`, `onEdgeConnect`, and +`onEdgeDisconnect` application seams with one controlled callback: + +```ts +interface LineCommit { + reason: "connect" | "disconnect" | "replace" | "reconnect"; + add: readonly ProposedLine[]; + remove: readonly LineId[]; + update: readonly LineEndpointUpdate[]; + originalEvent?: PointerEvent; +} + +interface ControlledLineCallbacks { + onLineCommit(commit: LineCommit): void; +} +``` + +- A normal connection proposes one atomic `add`. +- A disconnect proposes one atomic `remove`. +- Capacity replacement proposes its removals and addition together. +- Reconnect proposes an endpoint update for the same stable `LineId`. +- The application accepts, rejects, or normalizes the proposal by updating its + canonical document. + +Low-level connector lifecycle observers may remain for local behavior, but they +must not be alternate application graph-mutation seams. Their names must begin +with `on` and identify them as observations rather than commits. + +**Remaining details** + +- Define how a staged `LineObject` correlates with the accepted `LineRecord` so + a newly connected line settles in place rather than being recreated. +- Choose the post-commit completion signal: a canonical revision, an adapter + post-render notification, an explicit commit result, or a combination. +- Ensure rejection also closes the commit and triggers a pull; do not infer + rejection from a timing guess. +- Define whether a later pull supersedes every older pending commit or whether + commits require individual correlation IDs. + +### D7. Internal manager with a read-only query facade + +**Planned delta** + +Keep registration, unregistration, reconciliation attachment, and mutable +indexes internal to the engine-scoped mirror service. Replace public +`NodeManager`, `getNodeManager()`, and `EdgeSyncLike` access with a read-only +query facade exposing snapshots and identity lookups: + +```ts +interface SnapLineQuery { + nodes(): readonly NodeObject[]; + connectors(): readonly ConnectorObject[]; + groups(): readonly GroupNodeObject[]; + lines(): readonly LineObject[]; + node(id: NodeId): NodeObject | null; + connector(id: ConnectorId): ConnectorObject | null; + line(id: LineId): LineObject | null; + diagnostics(): readonly ReconciliationError[]; +} +``` + +The facade must not expose registry sets, topology arrays, controller +attachment, or mutation methods. + +### D8. Explicit geometry authority modes + +**Planned delta** + +Expose two clearly named position/size modes: + +```ts +type GeometryAuthority = "snapline" | "canonical"; +``` + +`"snapline"` is the default: + +- props provide initial geometry and deliberate later external commands; +- SnapLine owns live and settled position/size during interaction; +- `onGeometryChanged` observes the final position and size; +- the consumer may persist or otherwise honor the observation, but ignoring it + does not revert SnapLine’s settled geometry. + +`"canonical"` is opt-in: + +- props remain canonical; +- SnapLine may display optimistic live interaction geometry; +- SnapLine stages the final position and size and invokes + `onGeometryCommit`; +- updated props accept or normalize the proposal; +- the next pull/reconciliation settles an accepted value or reverts a rejected + value. + +Both callbacks carry the same position/size payload shape, but their names make +the ownership contract explicit: `Changed` is an observation and `Commit` +hands staged data to the canonical owner. The previous provisional `"commit"` +mode name is removed because the authority mode itself is SnapLine-owned; +commit instead names the outbound canonical operation. + +### D11. `NodeManager` owns the engine group registry + +**Planned delta** + +Move the live group registry from `global.data.groups` into the engine’s +`NodeManager`. + +`NodeManager` is the organization boundary for engine-level node information. +A `GroupNodeObject` is both: + +- a node in `manager.nodes`; +- a group in a dedicated `manager.groups` index. + +```mermaid +classDiagram + direction TB + + class NodeManager { + +nodes + +groups + +connectors + } + + class NodeObject + class GroupNodeObject + class ConnectorObject + + NodeManager o-- NodeObject : all live nodes + NodeManager o-- GroupNodeObject : live group index + NodeManager o-- ConnectorObject : all live connectors + GroupNodeObject --|> NodeObject +``` + +**Planned implementation** + +- Add a private `Set` to `NodeManager`. +- Add group registration/unregistration methods used by + `GroupNodeObject`. +- Expose groups as a read-only snapshot. +- Remove `groups` and `getGroups()` from `snapline-globals.ts`. +- Make `getGroupNodes()` read the manager’s dedicated group index. +- Make group membership reconciliation enumerate `manager.nodes` and + `manager.groups`. +- Stop scanning SnapEngine’s general object table for membership nodes. +- Stop filtering a shared group array by `engine`. +- Add registration, teardown, and multi-engine isolation tests. + +**Related state to review** + +The group module also keeps engine-level coordination outside `NodeManager`: + +- `parentGroups`; +- `membershipResolvers`; +- `reconcilingEngines`. + +We should decide whether these become manager-owned group state as well. That +is a follow-up to D11, not yet part of the settled decision. + +### D12. Nestable bulk reconciliation boundary + +**Planned delta** + +Add an explicit reconciliation batch for saved-graph loads that span multiple +framework render commits or asynchronous chunks: + +```ts +const batch = snapLineManager.beginReconciliationBatch(); +try { + // The application updates its canonical nodes, connectors, and lines. + await frameworkCommit(); +} finally { + batch.end(); +} +``` + +The exact adapter surface may be a batch token, `ready` prop, or graph revision +boundary. Its required semantics are: + +- registrations and line-document changes mark reconciliation dirty; +- no partial graph reconciliation runs while a batch is open; +- batches may nest; +- only the outermost `end()` schedules the final pass; +- `end()` is idempotent and exception-safe; +- a batch with no relevant changes does not run a pass; +- the final pass runs after framework connector registration render commits + and reads the latest canonical document; +- vanilla consumers and tests can explicitly `flush()`; +- an unrelated long-running import cannot delay gesture acceptance/rejection. + +The load ordering is: + +1. Install canonical node, connector, and line records. +2. Let the framework mount their available mirrors. +3. Close the outer bulk boundary. +4. Reconcile once, resolving all links whose mirrors are available. + +### D13. Symmetric connector limits and line-aware admission + +**Planned delta** + +Replace the asymmetric source/target-plus-incoming-capacity model with two +general limits on every connector: + +- maximum outgoing lines; +- maximum incoming lines. + +Each limit supports zero, a finite non-negative count, or no limit. A zero +outgoing limit makes a connector target-only; a zero incoming limit makes it +source-only. A connector with nonzero capacity in both directions supports +both roles, so separate `source` and `target` booleans become derived rather +than independent configuration. + +Use an explicit unlimited value instead of the current negative-number +sentinel. The concrete representation remains an API detail to settle; +`number | null` is the current candidate: + +```ts +type ConnectionLimit = number | null; // null means unlimited + +interface ConnectorRules { + maxOutgoing: ConnectionLimit; + maxIncoming: ConnectionLimit; + reconnect: boolean; + onConnectionCheck?: (proposal: ConnectionProposal) => boolean; +} +``` + +The compatibility predicate must receive the actual proposed line in addition +to its endpoints: + +```ts +interface ConnectionProposal { + line: LineObject; + source: ConnectorObject; + target: ConnectorObject; + phase: "candidate" | "drop"; + origin: "gesture"; +} +``` + +This allows a developer to admit a line based on line type, payload, source, +target, and connector metadata. The predicate is synchronous and side-effect +free because candidate discovery may call it repeatedly. It is checked again +on the final drop. Source-level and target-level rules may both veto the same +proposal. + +```mermaid +classDiagram + direction TB + + class ConnectorRules { + +maxOutgoing + +maxIncoming + +reconnect + +onConnectionCheck(proposal) + } + + class ConnectionProposal { + +line + +source + +target + +phase + +origin + } + + class ConnectorObject + class LineObject + + ConnectorObject *-- ConnectorRules + ConnectorRules ..> ConnectionProposal + ConnectionProposal --> LineObject + ConnectionProposal --> ConnectorObject : source / target +``` + +**Capacity semantics** + +- Creating a new preview reserves one outgoing slot. +- A reconnect reuses its existing outgoing slot. +- Incoming capacity is reserved when a target is accepted. +- Lines pending deletion do not consume capacity. +- Capacity and the compatibility predicate are rechecked before settlement. +- Canonical reconciliation does not silently delete application lines to + satisfy gesture limits; it follows the policy selected under D4 and emits a + diagnostic when needed. + +Reject an over-capacity proposal by default. If replacement is desired, add a +separate explicit replacement policy that selects affected stable line IDs and +produces one atomic line commit. Remove implicit oldest-line eviction from +`connectToConnector()`. + +**Remaining details** + +- Choose the public unlimited representation (`null`, `"unlimited"`, or + another explicit value). +- Decide whether `allowParallel` remains a convenience rule or is expressed + entirely through the compatibility predicate. +- Decide how proposed canonical line-record data is exposed alongside a preview + line when admission depends on application-specific line fields. + +## Workstream A: one engine-scoped mirror service + +**Problem** + +The current organization is split: + +- `NodeManager` tracks nodes and connectors; +- lines live only in connector arrays; +- selection/groups/surfaces live in shared `global.data` lists; +- candidate discovery and group membership still scan SnapEngine’s object + table; +- public queries and internal discovery do not use one source. + +**Candidate structure** + +```mermaid +classDiagram + direction TB + + class SnapLineManager { + +nodesById + +connectorsById + +linesById + +previewLines + +selection + +groups + +query() + +scheduleReconciliation() + } + + class NodeObject + class ConnectorObject + class LineObject + class LineReconciler + + SnapLineManager o-- NodeObject + SnapLineManager o-- ConnectorObject + SnapLineManager o-- LineObject : settled and preview indexes + SnapLineManager o-- LineReconciler +``` + +`SnapLineManager` is the provisional target name if the current `NodeManager` +grows into the one registry for every SnapLine object. If its responsibility +remains node-specific, retain `NodeManager` and place line reconciliation in a +separate, clearly named internal service. Do not keep the name `NodeManager` +for a general graph registry merely to avoid a rename. + +**Planned cleanup** + +- Move the live group registry from `global.data.groups` to + `NodeManager.groups`. +- Route connector candidate discovery and group membership enumeration through + the manager instead of SnapEngine’s general object table. +- Track settled lines separately from previews. +- Add lookup by stable domain identity. +- Make connector topology and new manager indexes read-only to consumers. +- Remove stale per-engine registry entries during engine/controller teardown. +- Move remaining application-wide selection and interaction registries behind + engine-scoped manager state or accessors. + +## Workstream B: controlled line reconciliation + +**Problem** + +The legacy `EdgeSyncController` establishes the correct high-level direction, +but it +reuses imperative connector mutation behavior underneath. Hydration therefore +still runs gesture policy, parallel checks, capacity replacement, and callback +paths that can conflict with a canonical document. + +**Planned separation** + +```mermaid +flowchart TB + PULL["Pull canonical snapshot"] + LINE["Canonical line record"] + LOOKUP{"Both connector mirrors mounted?"} + LATENT["Keep line latent"] + EXISTING{"Line object for line ID exists?"} + PRESERVE["Preserve mirror identity"] + CREATE["Create settled mirror directly"] + ERROR{"Representable?"} + REPORT["Emit structured reconciliation diagnostic"] + DONE["Mirror matches document"] + + PULL --> LINE --> LOOKUP + LOOKUP -->|"no"| LATENT + LOOKUP -->|"yes"| EXISTING + EXISTING -->|"yes"| PRESERVE --> DONE + EXISTING -->|"no"| ERROR + ERROR -->|"yes"| CREATE --> DONE + ERROR -->|"no"| REPORT +``` + +**Candidate implementation changes** + +- Replace `EdgeSyncController.sync()` with `LineReconciler.reconcile()`; + implement it as one `pullCanonical()` followed by + `reconcileMirror(snapshot)`. +- Keep pull/reconciliation read-only with respect to canonical state; only + staged interactions may invoke `onLineCommit`. +- Separate a private “create settled mirror from canonical line” path from a + user connect command. +- Separate gesture settlement of an existing preview line from line creation; + do not hide both operations behind an optional `line` argument. +- Preserve a settled line by stable line ID when its endpoints are updated. +- Route connector registration, identity changes, canonical changes, and + explicit pulls through one batch-aware scheduler. +- Surface duplicate identities, missing endpoints, and unrepresentable lines + as diagnostics. +- Leave policy-violating canonical records latent/errored and publish the D4 + structured diagnostic. + +## Workstream C: public API cleanup + +**Cleanup candidates** + +- Make `incomingLines` and `outgoingLines` read-only snapshots. +- Make `LineObject.start`, `target`, phase, candidate, and anchors + read-only outside internal mutation paths. +- Convert underscore-prefixed implementation fields to `#` private fields or + internal module state. +- Stop exposing input event handlers as incidental public methods. +- Rename and document low-level lifecycle events versus semantic application + commits so their roles are unambiguous. +- Remove public `NodeManager`, `getNodeManager()`, and `EdgeSyncLike` access in + favor of the D7 read-only query facade. +- Add missing package subpaths for APIs that remain public. +- Remove or rename deprecated connector options instead of carrying parallel + legacy and capability APIs indefinitely. +- Standardize property-style access where it improves the API without hiding + commands that perform meaningful work. + +## Workstream D: adapter consistency + +**Cleanup candidates** + +- Use stable line identity for framework line keys. +- Make node and connector identity typed adapter props. +- After D8, expose geometry explicitly as SnapLine-owned/default or canonical + with commit/rejection semantics. +- Add direct package exports for every documented component and replace the + legacy `EdgeSync` name with the final line-reconciliation name. + +## Workstream E: engine isolation + +**Cleanup candidates** + +- Key selection by engine. +- Key group registries by engine. +- Key resize handles and source surfaces by engine, or enforce filtering at a + single access boundary. +- Remove cross-engine connector candidates by construction. +- Add multi-engine tests for queries, selection, groups, and connections. + +## Workstream F: legacy property propagation + +`NodeObject` currently owns a name-keyed property bag and propagates values +through outgoing connectors. + +**Decision** + +Choose one: + +1. Keep it as a supported SnapLine data-flow feature. +2. Extract it into an optional module. +3. Remove it from SnapLine so the application graph owns evaluation/data flow. + +If retained: + +- type the property API; +- separate connector identity from property name; +- preserve cycle safety; +- define behavior for parallel lines and controlled line remounts; +- avoid treating the property bag as domain graph persistence. + +## Workstream G: tests and diagnostics + +Add coverage only for behavior introduced or altered by this plan: + +- stable line ID preservation; +- parallel controlled lines; +- reconnect preserving line identity; +- atomic capacity replacement; +- zero, finite, and unlimited incoming/outgoing capacity; +- outgoing preview reservation and reconnect slot reuse; +- line-aware source and target admission predicates; +- explicit over-capacity rejection and replacement-policy behavior; +- hydration that conflicts with gesture policy; +- duplicate node/connector/line identity diagnostics; +- direct mutation rejection in controlled mode; +- read-only topology query behavior; +- multi-engine isolation; +- controlled geometry rejection/reversion; +- React/Svelte semantic parity; +- one final pull/reconciliation pass for a multi-render-commit explicit batch, + including nested batches; +- manager-owned group registration, teardown, and engine isolation; +- package export coverage for APIs retained by the re-architecture. + +## Final phase: post-rearchitecture simplification review + +After the planned behavior and ownership changes work end to end, review the +result as a new codebase rather than assuming every intermediate abstraction +must survive. This phase may simplify the architecture more aggressively, but +must preserve the agreed ownership model, public behavior, and verified +performance requirements. + +### Review method + +- Trace line creation, preview settlement, reconnect, disconnect, canonical + reconciliation, bulk load, selection, and teardown from public entry point + to final state. +- Remove redundant adapters, forwarding callbacks, snapshots, indexes, + schedulers, wrappers, and compatibility aliases. +- Challenge every abstraction that exists only because of the legacy + architecture or an intermediate migration step. +- Consolidate registries or schedulers when one explicit implementation is + easier to understand. +- Flatten control flow where layers merely delegate without enforcing an + ownership boundary or invariant. +- Prefer explicit state machines and small named operations over clever + multi-purpose methods. +- Profile large graph loads, candidate discovery, reconciliation, and drag + interaction before retaining or adding non-obvious optimization. +- Delete migration-only internals that are no longer needed before 1.0. +- Repeat the vocabulary audit so one concept has one name and every remaining + callback starts with `on`. +- Update the current-architecture and ownership documents to describe the + resulting implementation, not the migration history. + +### Exit criteria + +- Each major lifecycle has one obvious path through the code. +- No known layer, registry, callback, or snapshot merely duplicates another. +- Public APIs expose the smallest surface required by the ownership model. +- Core runtime `Object` types and framework `Component` types never overlap in + meaning. +- All tests, type checks, adapter suites, browser tests, and relevant + benchmarks pass after the simplification. + +## Proposed implementation sequence + +```mermaid +flowchart TB + P0["Phase 0 — legacy cleanup
characterize behavior, vocabulary, rename map"] + P1["Phase 1 — finalize API details
authority, identity, rules, batching"] + P2["Phase 2 — mirror internals
engine scoping, identity indexes, read-only queries"] + P3["Phase 3 — controlled line protocol
stage, commit, pull, reconciliation"] + P4["Phase 4 — framework adapters and API
React / Svelte parity, exports, privacy"] + P5["Phase 5 — verification and migration
tests, diagnostics, docs, migration notes"] + P6["Phase 6 — simplification review
remove redundancy and challenge architecture"] + + P0 --> P1 --> P2 --> P3 --> P4 --> P5 --> P6 +``` + +This order is provisional. It protects legacy behavior before structural work, +settles identity and authority before rewriting adapters, and reserves a final +pass for simplification after the complete system can be evaluated. + +## Initial task board + +### Phase 0 + +- [ ] Inventory and classify the current core, React, and Svelte APIs. +- [ ] Add characterization tests for behavior needed during cleanup. +- [ ] Review and approve the vocabulary and old-name → new-name map. +- [ ] Rename core runtime `*Component` types to `*Object`. +- [ ] Replace internal `Edge*` terminology with `Line*`. +- [ ] Ensure every callback/hook starts with `on` and no command or query does. +- [ ] Replace canonical/mirror `sync` names with `reconcile`; replace unrelated + `sync` names with their concrete measurement, write, or scheduling + operation. +- [ ] Split mixed-responsibility functions and simplify implicit control flow. +- [ ] Remove dead code, obsolete aliases, duplicate helpers, and stale comments. +- [ ] Run all SnapLine checks before beginning architectural changes. + +### Remaining API details + +- [ ] Decide whether explicitly partitioned controlled and unmanaged graphs may + coexist in one engine. +- [ ] Finalize D2/D3 details: ID primitive, connector-ID scope, identity + mutation, and adapter delivery API. +- [ ] Finalize D4 diagnostic delivery, retention, and clearing semantics. +- [ ] Finalize the D5/D6 unified commit schema and application acceptance + contract. +- [ ] Finalize the canonical pull callback, post-commit completion signal, and + staged-object correlation contract. +- [ ] Finalize the D7 query-facade naming and package location. +- [ ] Finalize D8 mode and prop names. +- [ ] Choose the D12 framework adapter surface: batch token, `ready`, revision, + or a combination. +- [ ] Resolve D13 details: unlimited representation, parallel shorthand, and + proposed line data. + +### Core + +- [ ] Design the engine-scoped mirror service. +- [ ] Add immutable node, connector, and line identity to controlled mirrors. +- [ ] Add read-only manager indexes and soft-link resolution by those IDs. +- [ ] Keep missing references latent and retry them after registration. +- [ ] Reject duplicate IDs with structured diagnostics. +- [ ] Move the group registry into `NodeManager`. +- [ ] Rework group reconciliation to use manager node/group indexes. +- [ ] Remove `global.data.groups` and its accessor. +- [ ] Decide where parent mappings, membership resolvers, and the + reconciliation guard live. +- [ ] Add settled-line and preview-line indexes. +- [ ] Unify object discovery. +- [ ] Make topology views read-only. +- [ ] Replace role booleans and incoming-only capacity with symmetric limits. +- [ ] Make connection admission line-aware and gesture-specific. +- [ ] Extract implicit oldest-line eviction into an explicit replacement rule. +- [ ] Split preview settlement, preview disposal, and programmatic settled-line + creation into explicit internal operations. +- [ ] Unify pull/reconciliation work behind one coalescing, batch-aware + scheduler. +- [ ] Separate hydration from imperative connection mutation. +- [ ] Add structured reconciliation diagnostics. +- [ ] Scope all shared SnapLine state by engine. + +### Adapters + +- [ ] Design the shared controlled graph contract. +- [ ] Use stable line IDs for line rendering. +- [ ] Define canonical/SnapLine-owned geometry APIs. +- [ ] Align root and subpath exports. + +### Documentation and migration + +- [ ] Remove overlapping or obsolete callback seams. +- [ ] Decide the future of property propagation. +- [ ] Update examples to show one recommended ownership model. +- [ ] Write migration notes before the next package release. + +### Verification + +- [ ] Add unit tests around reconciliation and identity. +- [ ] Expand controlled-line browser coverage. +- [ ] Add multi-engine isolation coverage. +- [ ] Run the full React and Svelte SnapLine suites. +- [ ] Verify the package export maps. + +### Final simplification + +- [ ] Trace every major lifecycle through the completed architecture. +- [ ] Remove migration-only and forwarding layers that no longer enforce an + invariant. +- [ ] Look for registries, schedulers, snapshots, and algorithms that can be + consolidated or made more direct. +- [ ] Profile before retaining any non-obvious optimization. +- [ ] Repeat the naming, public-surface, and ownership audits. +- [ ] Update the architecture documents to the final implementation. +- [ ] Run the complete verification matrix again. diff --git a/docs/snapline/reference/react/index.mdx b/docs/snapline/reference/react/index.mdx index 2f17831..bc3ffec 100644 --- a/docs/snapline/reference/react/index.mdx +++ b/docs/snapline/reference/react/index.mdx @@ -26,7 +26,9 @@ drag surface. Set `virtual` when a connector should use the parent node's custom shape surfaces without rendering a port element. Opaque connection payloads remain on `LineComponent`, so a custom `lineComponent` can resolve presentation from -application-owned edge state. +application-owned edge state. Custom renderers mount static structure and call +`line.bindGeometryWriter(...)` from a layout effect to update retained SVG, +Canvas, or graphics refs without rendering React on pointer movement. Connector callbacks, metadata, policy, surface strategies, collider radius, edge-pan behavior, and line class update across renders. `virtual` can also be diff --git a/docs/snapline/reference/react/placement.mdx b/docs/snapline/reference/react/placement.mdx index 9e80b33..1feba55 100644 --- a/docs/snapline/reference/react/placement.mdx +++ b/docs/snapline/reference/react/placement.mdx @@ -8,8 +8,10 @@ framework: react frameworkKey: placement --- -Pass a core `PlacementController` to `Placement`. Supply `children` as a -render function when an active preview should be displayed. +Pass a core `PlacementController` to `Placement`. Supply `children` as the +static preview content. The adapter mounts an absolute wrapper and the +controller updates its transform, size, visibility, and `data-allowed` +imperatively, so pointer movement does not render React. The component forwards pointer movement, primary commit, secondary-button cancel, outside cancel, and Escape cancel according to its boolean props. diff --git a/docs/snapline/reference/svelte/index.mdx b/docs/snapline/reference/svelte/index.mdx index 780917b..14d1091 100644 --- a/docs/snapline/reference/svelte/index.mdx +++ b/docs/snapline/reference/svelte/index.mdx @@ -27,12 +27,14 @@ A virtual connector renders no port element. Its source and target surfaces are resolved against the parent node, which is useful for whole-border diagram connections. Connection-request payloads stay opaque to the adapter and are available to custom `LineSvelteComponent` renderers through `LineComponent`. +Custom renderers register `line.bindGeometryWriter(...)` on mount and mutate +retained SVG, Canvas, or graphics refs directly. Connector callbacks, metadata, policy, surface strategies, collider radius, edge-pan behavior, and line class are reactive. `virtual` can also be toggled without replacing the logical connector or its lines. `name` and `connectorObject` are construction-time identities. -Changed geometry props are authoritative. During a live pointer gesture the -adapter renders local updates and reports the final values through commit -callbacks. +Changed geometry props are authoritative. During a live pointer gesture core +writes retained element geometry directly; commit callbacks report final values +for application persistence. diff --git a/docs/snapline/reference/svelte/placement.mdx b/docs/snapline/reference/svelte/placement.mdx index ddc150a..14dd631 100644 --- a/docs/snapline/reference/svelte/placement.mdx +++ b/docs/snapline/reference/svelte/placement.mdx @@ -9,8 +9,9 @@ frameworkKey: placement --- Pass a core `PlacementController` to `Placement`. The component listens at -window scope while the controller is active and optionally renders -`preview(snapshot)`. +window scope and renders `preview(snapshot)` inside an absolute wrapper. The +controller mutates wrapper geometry and `data-allowed` directly, so the snippet +must not apply pointer-follow transforms through Svelte state. `cancelOnOutside`, `cancelOnSecondaryButton`, and `cancelOnEscape` default to `true`. Creation and persistence belong in the controller’s `onCommit` diff --git a/src/object.ts b/src/object.ts index 1c3f7ee..86ba7e1 100755 --- a/src/object.ts +++ b/src/object.ts @@ -1323,25 +1323,43 @@ export class ElementObject extends BaseObject { } destroyDom(removeElement: boolean = true) { + const element = this.#element; + this.detachElement(); + if (removeElement) { + element?.remove(); + } + super.destroyDom(); + } + + /** + * Stop observing and routing input through the currently assigned element + * without removing framework-owned DOM. + * + * When `expectedElement` is supplied, a stale cleanup is ignored after a + * newer element has already been assigned. + */ + detachElement(expectedElement?: HTMLElement): boolean { + if (expectedElement && this.#element !== expectedElement) { + return false; + } this.#resizeObserver?.disconnect(); + this.#resizeObserver = null; this.#mutationObserver?.disconnect(); + this.#mutationObserver = null; if (this.#inputAlias) { this.engine?.input.unregisterObjectElement(this, this.#inputAlias); this.#inputAlias = null; } if (this.#element) { this.engine?.input.unregisterObjectElement(this, this.#element); - if (removeElement) { - this.#element.remove(); - } } this.#element = null; - super.destroyDom(); + return true; } #assignElement(element: HTMLElement) { if (this.#element) { - this.destroyDom(); + this.detachElement(); } this.#element = element; diff --git a/tests/ut/snapline-connector-config.spec.ts b/tests/ut/snapline-connector-config.spec.ts index b9b922e..1cb61cd 100644 --- a/tests/ut/snapline-connector-config.spec.ts +++ b/tests/ut/snapline-connector-config.spec.ts @@ -4,6 +4,7 @@ import { ConnectorComponent, LineComponent, NodeComponent, + PlacementController, type ConnectorSurfaceStrategy, } from "../../assets/snapline/core/src"; @@ -87,6 +88,124 @@ function installObserverStubs(): () => void { }; } +test("line geometry writers are imperative, replaceable, and separate from state", () => { + const { engine } = createEngineHarness(); + const sourceNode = new NodeComponent(engine, null); + const source = new ConnectorComponent(engine, sourceNode, { + name: "source", + capabilities: { source: true, target: false }, + }); + sourceNode.addConnectorObject(source); + const line = source.createLine(); + const firstWrites: number[] = []; + const secondWrites: number[] = []; + const states: string[] = []; + + const unbindFirst = line.bindGeometryWriter((geometry) => { + firstWrites.push(geometry.delta.x); + }); + const unbindSecond = line.bindGeometryWriter((geometry) => { + secondWrites.push(geometry.delta.x); + }); + const unsubscribeState = line.onStateChange((state) => { + states.push(state.phase); + }); + + // A stale framework cleanup must not detach the newer renderer. + unbindFirst(); + line.setLinePosition(10, 20, 35, 45); + expect(firstWrites).toEqual([0]); + expect(secondWrites).toEqual([0]); + expect(states).toEqual(["source-start"]); + + line.writeTransform(); + expect(secondWrites).toEqual([0, 25]); + expect(states).toEqual(["source-start"]); + + line.setPhase("preview-free"); + expect(states).toEqual(["source-start", "preview-free"]); + expect(secondWrites).toEqual([0, 25]); + + unbindSecond(); + unsubscribeState(); + line.destroy(false); + source.destroy(); + sourceNode.destroy(); +}); + +test("placement geometry writes do not require framework state updates", () => { + const controller = new PlacementController<{ id: string }>({ + screenToWorld: ({ x, y }) => ({ x: x + 10, y: y + 20 }), + }); + const firstWrites: Array<{ visible: boolean; x: number | null }> = []; + const secondWrites: Array<{ visible: boolean; x: number | null }> = []; + const states: boolean[] = []; + + const unbindFirst = controller.bindGeometryWriter((geometry) => { + firstWrites.push({ + visible: geometry.visible, + x: geometry.position?.x ?? null, + }); + }); + const unbindSecond = controller.bindGeometryWriter((geometry) => { + secondWrites.push({ + visible: geometry.visible, + x: geometry.position?.x ?? null, + }); + }); + const unsubscribeState = controller.onStateChange((snapshot) => { + states.push(snapshot.active); + }); + + unbindFirst(); + controller.begin({ id: "new-node" }, { width: 20, height: 10 }); + controller.update({ x: 50, y: 40 }); + + expect(firstWrites).toEqual([{ visible: false, x: null }]); + expect(secondWrites).toEqual([ + { visible: false, x: null }, + { visible: false, x: null }, + { visible: true, x: 50 }, + ]); + expect(states).toEqual([false, true, true]); + + unbindSecond(); + unsubscribeState(); +}); + +test("framework cleanup detaches elements without removing owned DOM", () => { + const restoreObservers = installObserverStubs(); + const { engine } = createEngineHarness(); + const node = new NodeComponent(engine, null); + let firstRemovals = 0; + let secondRemovals = 0; + const firstElement = { + remove: () => { + firstRemovals++; + }, + } as unknown as HTMLElement; + const secondElement = { + remove: () => { + secondRemovals++; + }, + } as unknown as HTMLElement; + + try { + node.element = firstElement; + node.element = secondElement; + + expect(node.detachElement(firstElement)).toBe(false); + expect(node.element).toBe(secondElement); + + node.destroy(false); + expect(node.element).toBeNull(); + expect(firstRemovals).toBe(0); + expect(secondRemovals).toBe(0); + } finally { + restoreObservers(); + } +}); + test("connector config updates stay live without replacing topology", () => { const { engine, global } = createEngineHarness(); const sourceNode = new NodeComponent(engine, null); diff --git a/website/src/lib/components/docs/SnapLineDemo.svelte b/website/src/lib/components/docs/SnapLineDemo.svelte index bc97585..65f53ed 100644 --- a/website/src/lib/components/docs/SnapLineDemo.svelte +++ b/website/src/lib/components/docs/SnapLineDemo.svelte @@ -100,15 +100,7 @@ {#if placement} {#snippet preview(snapshot)} - {#if snapshot.position} -
- Node -
- {/if} +
Node
{/snippet}
{/if} @@ -263,7 +255,7 @@ pointer-events: none; } - .placement-preview.blocked { + :global([data-snapline-type="placement-preview"][data-allowed="false"]) .placement-preview { border-color: #b14f4f; color: #8b3030; } From b44d85c67a7d212c07751d2ae76f42eb4462a814 Mon Sep 17 00:00:00 2001 From: tfukaza Date: Sat, 25 Jul 2026 18:08:39 -0700 Subject: [PATCH 02/21] docs: reconcile planned re-architecture with approved design decisions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Applies the settled decisions from the design review: - Push model: canonical state arrives via setCanonicalGraph(snapshot); the mirror caches and replays it. The on*Pull callback category and pullCanonical() are removed, along with the request→pull→request recursion rules they required. - Request vocabulary: the outbound proposal is onLineChangeRequest with an `intent` field; "commit" refers only to completed canonical or framework updates. - Mirror naming: core runtime types are *Mirror (NodeMirror, LineMirror, ...); Component stays reserved for framework types. - IDs: strings only, graph-global connector IDs, minted by SnapLine when not supplied; supplied IDs are the persistence contract. Gesture lines carry a minted LineId that the app adopts (settle-in-place) or substitutes (discard/recreate). - controlled/uncontrolled is the authority vocabulary for both the graph mode (one mode per engine, fail-fast) and geometry modes. - Predicate callbacks may use is*/can* (isValidConnection); "unlimited" is the explicit no-limit value; GraphMirror/GraphQuery are the settled registry and facade names; runBatch() is the primary batch API; property propagation is removed; diagnostics stay minimal. - Phase 0 scope shrunk to renames + dead code; prefix normalization and function splitting move to their owning phases. Keeps the newer non-colliding edits: remeasureDomGeometry(), #reconciling/#reconciliationQueued, and the strict reconcile-only alignment-verb rule. Co-Authored-By: Claude Fable 5 --- .../snapline/design/planned-rearchitecture.md | 843 ++++++++++-------- 1 file changed, 474 insertions(+), 369 deletions(-) diff --git a/docs/snapline/design/planned-rearchitecture.md b/docs/snapline/design/planned-rearchitecture.md index dc67713..362870a 100644 --- a/docs/snapline/design/planned-rearchitecture.md +++ b/docs/snapline/design/planned-rearchitecture.md @@ -19,8 +19,12 @@ from this document once its implementation and required verification land. ## Working objective Make the application/framework the unambiguous source of truth for committed -nodes, connectors, and lines, then simplify SnapLine around an engine-scoped, -read-only runtime mirror. +nodes, connectors, and lines, then simplify SnapLine around an engine-scoped +runtime mirror that is reconciled from application-pushed canonical snapshots. + +**Graph** is the standard term for the network of nodes, connectors, and +lines. Use it consistently: the application owns the canonical graph; SnapLine +maintains the graph mirror. ## Phase 0: legacy audit, vocabulary, and naming cleanup @@ -32,54 +36,65 @@ years. The first naming problem to eliminate is the interchangeable use of “edge” and “line.” SnapLine APIs will converge on **line**. Suffixes identify the layer: -| Convention | Meaning | -| ----------------- | -------------------------------------------------------------------------- | -| `*Record` | Canonical application data, such as `NodeRecord` or `LineRecord` | -| `*Object` | SnapLine runtime entity, such as `NodeObject` or `LineObject` | -| `*Component` | React, Svelte, or another front-end framework component | -| `*Element` | Actual framework-owned DOM/SVG element | -| `*Snapshot` | Immutable point-in-time data returned by an object or query | -| `LineRecord` | Canonical committed relationship identified by `LineId` | -| `LineObject` | SnapLine runtime representation in preview, settled, or detached phase | -| `LineObjectPhase` | Explicit lifetime state; “preview” is a phase, not a second runtime entity | -| `LineComponent` | Framework component rendering a `LineObject` | -| `LineCommit` | Atomic staged line change handed to the canonical owner | -| `*LifecycleEvent` | Observation of local object topology without canonical document authority | +| Convention | Meaning | +| ------------------- | -------------------------------------------------------------------------- | +| `*Record` | Canonical application data, such as `NodeRecord` or `LineRecord` | +| `*Mirror` | SnapLine runtime entity, such as `NodeMirror` or `LineMirror` | +| `*Component` | React, Svelte, or another front-end framework component | +| `*Element` | Actual framework-owned DOM/SVG element | +| `*Snapshot` | Immutable point-in-time data returned by a mirror or query | +| `LineRecord` | Canonical committed relationship identified by `LineId` | +| `LineMirror` | SnapLine runtime representation in preview, settled, or detached phase | +| `LineMirrorPhase` | Explicit lifetime state; “preview” is a phase, not a second runtime entity | +| `LineComponent` | Framework component rendering a `LineMirror` | +| `LineChangeRequest` | Atomic request for the application to change canonical line records | +| `*LifecycleEvent` | Observation of local mirror topology without canonical document authority | + +The `Mirror` suffix is deliberate: it names the architecture role directly, so +the type names teach the ownership model. In uncontrolled mode a mirror is +locally authoritative rather than a reflection of canonical records, but the +type is the same and the name stays `Mirror`. For example, `onLinesChanged` and `onEdgeConnect` participate in the same broad connection pipeline, but they are not equivalent: -- `onLinesChanged` publishes `LineObject` instances so an adapter can reconcile +- `onLinesChanged` publishes `LineMirror` instances so an adapter can reconcile `LineComponent` instances; - `onEdgeConnect` currently proposes a canonical line-record mutation. Their names fail to communicate that difference. Phase 0 must produce and apply a rename map in which the layer and direction are visible. Architecture-bound callbacks that D5/D6 will replace should receive their final names when the -unified commit API lands rather than being renamed twice. +unified request API lands rather than being renamed twice. ### Naming rules - Remove `Edge*` from the SnapLine vocabulary; translate external edge terminology at integration boundaries when necessary. -- Core runtime classes end in `Object`; front-end framework types end in +- Core runtime classes end in `Mirror`; front-end framework types end in `Component`. -- Every callback or consumer-supplied hook begins with `on`. +- Every callback or consumer-supplied hook begins with `on`, **except** + boolean predicate callbacks, which begin with `is` or `can` and must be + synchronous and side-effect free (for example `isValidConnection`). - No imperative command, query, or method that consumers call begins with `on`. -- `on*Commit` hands an atomic staged change to the canonical owner. The next - pull confirms the result. +- `on*Request` asks an owner to perform an operation; it does not imply + acceptance or completion. - `on*Changed` observes state after it changed and does not request another mutation. -- `on*Check` is a synchronous, side-effect-free predicate callback. - `on*Resolve` is a callback that computes and returns a value without committing topology. -- `on*Pull` supplies the latest canonical snapshot to a read-only pull +- Imperative `can*`, `is*`, and `resolve*` methods are queries/calculations. + A `can*`/`is*` name on a config field is a supplied predicate callback; the + same name on a mirror or query object is an imperative query. Both answer a + question and neither mutates. +- `reconcile*` is the only verb for making the runtime mirror match canonical + state. `sync` is not part of the target vocabulary: for canonical/mirror + alignment always use `reconcile`; for unrelated operations name the actual + work, such as `remeasureDomGeometry()` or `writeTransform()`. The word + “synchronous” may still describe timing, and external API names such as + React’s `flushSync` remain unchanged — neither names a SnapLine alignment operation. -- Imperative `can*`, `is*`, and `resolve*` methods are queries/calculations, not - callbacks. -- `reconcile*` is the only target verb for making the runtime mirror match - canonical state. - `write*` means an imperative presentation/DOM property write. - `bind*Writer` registers a presentation sink and immediately supplies its latest snapshot. @@ -89,91 +104,104 @@ unified commit API lands rather than being renamed twice. ### Canonical and mirror lifecycle vocabulary -Use a Git-inspired directional model when the application/framework owns the -canonical graph. The analogy describes ownership and movement, not a literal +The ownership model is directional: canonical state flows into the mirror, and +proposals flow back out as requests. The analogy to staging and discarding is +loosely Git-inspired, but it describes ownership and movement, not a literal Git implementation: -| Term | SnapLine meaning | -| -------------------- | ------------------------------------------------------------------------------------ | -| **Canonical** | Application-owned node, connector, and line records | -| **Mirror** | Engine-scoped SnapLine objects derived from canonical records | -| **Stage** | Create or modify provisional state on a local object; canonical state is unchanged | -| **Commit** | Hand an atomic staged change to the canonical owner through an `on*Commit` callback | -| **Canonical update** | The application accepts or normalizes a commit by changing its canonical records | -| **DOM render** | The framework materializes the latest canonical state in its owned DOM | -| **Pull** | Read the latest canonical snapshot through a read-only `on*Pull` callback | -| **Reconcile** | Make the SnapLine mirror match the snapshot returned by a pull | -| **Settle** | Confirm a staged object against pulled canonical state without recreating the object | -| **Discard / revert** | Remove or undo staged state after cancellation or canonical rejection | - -Staging is a phase of the same `LineObject`, not a second temporary entity. -Calling `onLineCommit` begins the commit operation. The common case is that the -application accepts it, but the next canonical pull remains authoritative and -may confirm, normalize, or reject the staged change. - -Do not use `push` for the outbound operation. SnapLine is proposing a change, -not replacing the canonical document wholesale. - -`sync` is not part of the target SnapLine vocabulary. For canonical/mirror -alignment, always use `reconcile`; for unrelated operations, name the actual -work, such as `remeasureDomGeometry()` or `writeTransform()`. - -Pull and reconciliation remain separate directional steps: - -- `pullCanonical()` obtains the current canonical snapshot; -- `reconcileMirror(snapshot)` applies that snapshot to SnapLine objects. - -`LineReconciler.reconcile()` is the high-level replacement for the legacy -`EdgeSyncController.sync()`. It pulls once and then reconciles: +| Term | SnapLine meaning | +| -------------------- | ------------------------------------------------------------------------------------------- | +| **Canonical** | Application-owned node, connector, and line records | +| **Mirror** | Engine-scoped SnapLine runtime entities derived from canonical records | +| **Stage** | Make a provisional local change to an existing mirror; canonical state is unchanged | +| **Request** | Ask the canonical owner to accept an atomic staged change through an `on*Request` callback | +| **Canonical update** | The application accepts or normalizes a request by changing its canonical records | +| **Framework commit** | React, Svelte, or another framework finishes rendering canonical state into its DOM | +| **Set canonical** | The application/adapter pushes the latest canonical snapshot via `setCanonicalGraph()` | +| **Reconcile** | Make the SnapLine mirror match the most recently set canonical snapshot | +| **Settle** | Confirm a staged mirror against the canonical snapshot without recreating the mirror | +| **Discard / revert** | Remove or undo staged state after cancellation or canonical rejection | + +Staging is a phase of the same `LineMirror`, not a second temporary entity. +Calling a request callback is not a commit: the application may reject or +normalize the request, and SnapLine does not own the framework’s DOM commit. +Use “commit” only when referring to an update that the canonical owner or +frontend framework has actually completed. + +**SnapLine never pulls canonical state through a callback.** The application +(usually through an adapter) pushes the latest snapshot into the mirror with +`setCanonicalGraph(snapshot)`. The mirror caches the last snapshot it was +given; internally triggered reconciliation — a connector registering, an +identity resolving, a batch closing — replays the cached snapshot rather than +calling back into the application: ```ts +setCanonicalGraph(snapshot: CanonicalGraphSnapshot): void { + this.#canonicalSnapshot = snapshot; + this.#scheduleReconciliation(); +} + +// Internal triggers replay the cache: reconcile(): void { - const snapshot = this.pullCanonical(); - this.reconcileMirror(snapshot); + this.reconcileMirror(this.#canonicalSnapshot); } ``` -The word “synchronous” may still describe timing, and external API names such -as React’s `flushSync` remain unchanged. Neither case names a SnapLine -alignment operation. +Because canonical state only ever arrives through this one setter, there is no +pull callback to keep read-only, no getter-freshness contract for adapters, +and no request → pull → request recursion risk to design around. The +staleness obligation is symmetrical to any controlled model: the application +must call `setCanonicalGraph()` whenever its canonical graph changes. -A framework adapter may implement the pull callback by returning the latest -props; “pull” does not imply a network request. +Do not use `push` for the outbound operation. SnapLine is proposing a change, +not publishing authoritative state, so `request` expresses the boundary more +accurately. ```ts -interface CanonicalLineBridge { - onCanonicalPull(): CanonicalGraphSnapshot; - onLineCommit(commit: LineCommit): void; +interface GraphMirrorCallbacks { + onLineChangeRequest(request: LineChangeRequest): void; + onDiagnosticsChanged?(diagnostics: readonly ReconciliationError[]): void; } + +// Inbound canonical state is a plain setter, not a callback: +graphMirror.setCanonicalGraph(snapshot); ``` ```mermaid sequenceDiagram autonumber - participant SL as SnapLine mirror - participant A as Adapter callbacks + participant SL as SnapLine graph mirror + participant A as Adapter participant C as Canonical app state participant F as Framework / DOM - SL->>SL: Stage change on the existing LineObject - SL->>A: onLineCommit(commit) + SL->>SL: Stage change on the existing LineMirror + SL->>A: onLineChangeRequest(request) A->>C: Accept, normalize, or reject C->>F: Render latest canonical records - F-->>A: DOM render completes - SL->>A: onCanonicalPull() - A-->>SL: CanonicalGraphSnapshot - SL->>SL: Reconcile mirror - - alt Commit accepted - SL->>SL: Settle the staged LineObject - else Commit rejected + F-->>A: Framework DOM commit completes + A->>SL: setCanonicalGraph(latest snapshot) + SL->>SL: Reconcile mirror against the snapshot + + alt Snapshot contains the staged LineId + SL->>SL: Settle the staged LineMirror in place + else Snapshot omits the staged LineId SL->>SL: Discard or revert staged state end ``` -Pull and reconciliation are read-only with respect to canonical state and must -never emit a commit. That one-way rule prevents commit → pull → commit -recursion. +After dispatching a request, the adapter must guarantee a follow-up +`setCanonicalGraph()` call (a queued microtask that reads the adapter’s live +snapshot source, matching the timing the current `getEdges()` ref relies on). +Acceptance and rejection are then both decided by the next snapshot: no +request result object, no timing inference. An application that rejects by +doing nothing still resolves, because the follow-up push delivers an unchanged +snapshot that omits the staged `LineId`. + +`setCanonicalGraph()` and reconciliation are read-only with respect to +canonical state and must never emit a change request. Only staged interactions +may invoke `onLineChangeRequest`. That one-way rule prevents request → +reconcile → request recursion. ### Initial rename direction @@ -181,25 +209,26 @@ The exhaustive rename map still requires the Phase 0 inventory. These names establish the direction and prevent later architecture work from introducing a second vocabulary: -| Current or legacy name | Target direction | Reason | -| --------------------------------------------------------- | ---------------------------------------- | ------------------------------------------------------------------- | -| Core `NodeComponent` | `NodeObject` | It is a SnapLine runtime entity, not a framework component | -| Core `ConnectorComponent` | `ConnectorObject` | Same layer distinction | -| Core `LineComponent` | `LineObject` | Same layer distinction and the canonical “line” term | -| Core `GroupNodeComponent` | `GroupNodeObject` | Same layer distinction | -| `EdgeSyncController` | `LineReconciler` | Describes canonical line records converging into runtime objects | -| `EdgeSyncController.sync()` | `LineReconciler.reconcile()` | Uses the precise mirror-alignment verb | -| `#syncing` / `#syncQueued` | `#reconciling` / `#reconciliationQueued` | Names reconciliation state rather than generic synchronization | -| `syncDomGeometry()` | `remeasureDomGeometry()` | Names DOM measurement rather than canonical reconciliation | -| `onLinesChanged` | `onLineObjectsChanged` | Observes the runtime collection used to render framework components | -| `onEdgeConnect`, `onEdgeDisconnect`, connection callbacks | `onLineCommit` | One callback for atomic canonical line-record changes | -| Consumer-supplied `canConnect` predicate | `onConnectionCheck` | The `on` prefix identifies a supplied callback | -| Imperative connection predicate | `canConnect` | No `on` prefix identifies a query the caller invokes | -| `EdgeId`, `EdgeRecord`, and `edgeId` | `LineId`, `LineRecord`, and `lineId` | Removes the edge/line synonym | -| Framework rendering type | `LineComponent` | `Component` remains reserved for React/Svelte/front-end entities | +| Current or legacy name | Target direction | Reason | +| --------------------------------------------------------- | ------------------------------------------ | ------------------------------------------------------------------- | +| Core `NodeComponent` | `NodeMirror` | It is a SnapLine runtime entity, not a framework component | +| Core `ConnectorComponent` | `ConnectorMirror` | Same layer distinction | +| Core `LineComponent` | `LineMirror` | Same layer distinction and the canonical “line” term | +| Core `GroupNodeComponent` | `GroupNodeMirror` | Same layer distinction | +| `NodeManager` (grown into the general registry) | `GraphMirror` | It becomes the one engine-scoped registry for the whole graph | +| `EdgeSyncController` | `LineReconciler` (internal) | Describes canonical line records converging into runtime mirrors | +| `EdgeSyncController.sync()` | `setCanonicalGraph()` + `reconcile()` | Splits inbound snapshot supply from mirror convergence | +| `#syncing` / `#syncQueued` | `#reconciling` / `#reconciliationQueued` | Names reconciliation state rather than generic synchronization | +| `syncDomGeometry()` | `remeasureDomGeometry()` | Names DOM measurement rather than canonical reconciliation | +| `onLinesChanged` | `onLineMirrorsChanged` | Observes the runtime collection used to render framework components | +| `onEdgeConnect`, `onEdgeDisconnect`, connection callbacks | `onLineChangeRequest` | One callback for atomic canonical line-record proposals | +| Consumer-supplied `canConnect` predicate | `isValidConnection` | Predicate callback named as a question, per the predicate exemption | +| Imperative connection predicate | `canConnect` | Imperative query the caller invokes on a mirror | +| `EdgeId`, `EdgeRecord`, and `edgeId` | `LineId`, `LineRecord`, and `lineId` | Removes the edge/line synonym | +| Framework rendering type | `LineComponent` | `Component` remains reserved for React/Svelte/front-end entities | This is not a blind global replacement. For example, -`onLineObjectsChanged` and `onLineCommit` intentionally remain separate: +`onLineMirrorsChanged` and `onLineChangeRequest` intentionally remain separate: the former reports an already-changed runtime render collection, while the latter asks the application to change canonical records. @@ -224,27 +253,32 @@ or minor speed improvement. full-graph scans merely in the name of simplicity; clarity and reasonable scaling are both required. +### Phase 0 scope + +Phase 0 is intentionally small. Because D5/D6 replaces the connection callback +surface and Workstream C rewrites mirror mutability, renaming or refactoring +those areas in Phase 0 would churn code that is scheduled for replacement, and +the current behavior coverage is overwhelmingly Playwright-based, so every +extra churn pass is verified by slow, coarse tests. Deferred to each API’s +owning phase, where new focused tests protect the change: + +- normalizing the `on`/`is`/`can` prefix rules across callbacks; +- splitting mixed-responsibility functions and simplifying implicit control + flow. + ### Phase 0 tasks - Inventory every core, React, and Svelte public type, callback, method, field, module, and package export. - Classify each API as canonical graph, runtime mirror, interaction, geometry, - presentation, query, or unmanaged command. + presentation, query, or uncontrolled command. - Produce an old-name → new-name table before editing public APIs. -- Audit every use of `sync`, `commit`, `push`, `pull`, `request`, and - `reconcile` against the canonical/mirror lifecycle. -- Rename misleading internals immediately; schedule architecture-dependent - public replacements in their owning phase. -- Split functions that combine unrelated creation, mutation, notification, and - presentation responsibilities. -- Replace clever or implicit control flow with explicit intermediate values and - named operations, even when that costs a small amount of non-critical work. -- Remove dead code, obsolete aliases, duplicated helpers, stale comments, and - compatibility paths that are unnecessary before 1.0. -- Normalize private-field usage, event/type suffixes, method ordering, and - module boundaries. - Add characterization tests only where existing behavior is not sufficiently protected for a safe cleanup. +- Rename core runtime `*Component` types to `*Mirror`. +- Replace internal `Edge*` terminology with `Line*`. +- Remove dead code, obsolete aliases, duplicated helpers, stale comments, and + compatibility paths that are unnecessary before 1.0. - Run type checking and the complete SnapLine unit, React, Svelte, and browser suites before and after the cleanup. @@ -252,17 +286,12 @@ or minor speed improvement. - The glossary and rename map are reviewed. - No SnapLine public or internal name uses edge and line as synonyms. -- Every callback begins with `on`, and no imperative API begins with `on`. +- Core runtime types use the `Mirror` suffix; no core type uses `Component`. - No target API uses `sync`: canonical/mirror alignment uses `reconcile`, while unrelated operations name their concrete measurement, write, or scheduling work. -- `pull` only retrieves canonical state; it does not imply reconciliation. -- A staged proposal becomes a commit when passed to `onLineCommit`; the next - pull confirms, normalizes, or rejects it. -- Callback names distinguish predicates, observations, presentation writers, - lifecycle events, pulls, and canonical line commits. -- Large functions have one identifiable responsibility or a documented reason - to coordinate several operations. +- A pending proposal is named a request, while `commit` refers only to a + completed canonical or framework update. - Removed APIs have migration notes where externally relevant. - Behavior and framework DOM ownership remain unchanged. - All SnapLine checks pass. @@ -271,50 +300,57 @@ or minor speed improvement. These decisions block or shape most of the implementation work. -| ID | Decision | Current leaning | Status | -| --- | -------------------------------------------------------------------------- | ------------------------------------------------------------------------ | ----------- | -| D1 | Is controlled graph state the primary/recommended mode? | Yes; keep imperative behavior as an explicit unmanaged mode | **Planned** | -| D2 | Which canonical graph entities require stable IDs? | Nodes, connectors, and lines | **Planned** | -| D3 | How are references to mirrors that have not registered yet represented? | ID-based soft links resolved through `NodeManager` indexes | **Planned** | -| D4 | What does hydration do with lines that violate gesture policy? | Preserve the canonical record and report a structured error | **Planned** | -| D5 | Are replacement and reconnect atomic operations? | Yes; emit one atomic line commit | **Planned** | -| D6 | What becomes of `onConnectionRequest`? | Merge it with controlled line changes into one commit callback | **Planned** | -| D7 | Is `NodeManager` public API or an internal mirror service? | Keep the service internal and expose a read-only query facade | **Planned** | -| D8 | How do node position and size props interact with SnapLine-owned geometry? | Expose explicit SnapLine-owned and canonical modes | **Planned** | -| D11 | Where is the live engine group registry stored? | `NodeManager`; remove the separate `global.data.groups` registry | **Planned** | -| D12 | How are multi-render-commit graph mounts reconciled? | Add an explicit, nestable bulk-update boundary | **Planned** | -| D13 | How are connector direction, capacity, and compatibility expressed? | Symmetric incoming/outgoing limits plus a line-aware admission predicate | **Planned** | +| ID | Decision | Resolution | Status | +| --- | -------------------------------------------------------------------------- | --------------------------------------------------------------------------- | ----------- | +| D1 | Is controlled graph state the primary/recommended mode? | Yes; uncontrolled mode is an explicit opt-in; one mode per engine | **Decided** | +| D2 | Which canonical graph entities require stable IDs? | Nodes, connectors, and lines; string IDs; SnapLine mints when not supplied | **Decided** | +| D3 | How are references to mirrors that have not registered yet represented? | ID-based soft links resolved through `GraphMirror` indexes | **Decided** | +| D4 | What does hydration do with lines that violate gesture policy? | Preserve the canonical record and report a structured error | **Decided** | +| D5 | Are replacement and reconnect atomic operations? | Yes; emit one line-change request | **Decided** | +| D6 | What becomes of `onConnectionRequest`? | Merge it with controlled line changes into one request callback | **Decided** | +| D7 | Is the mirror registry public API or an internal service? | Internal; expose the read-only `GraphQuery` facade | **Decided** | +| D8 | How do node position and size props interact with SnapLine-owned geometry? | Explicit `"uncontrolled"` (default) and `"controlled"` geometry modes | **Decided** | +| D11 | Where is the live engine group registry stored? | The mirror registry; remove the separate `global.data.groups` registry | **Decided** | +| D12 | How are multi-render-commit graph mounts reconciled? | Nestable `runBatch()` boundary with `beginBatch`/`end` escape hatch | **Decided** | +| D13 | How are connector direction, capacity, and compatibility expressed? | Symmetric incoming/outgoing limits plus a line-aware admission predicate | **Decided** | + +“Decided” means the design question is settled; every one of these remains +unimplemented and stays in this document until its implementation and +verification land. Remaining sub-details are listed inside each section. ## Agreed changes not yet implemented -### D1. Controlled graph authority with explicit unmanaged code +### D1. Controlled graph authority with explicit uncontrolled mode **Planned delta** -Make controlled graph state the primary and recommended integration: +Make controlled graph state the primary and recommended integration. The +vocabulary matches React’s controlled/uncontrolled idiom so frontend +developers can transfer their intuition directly: -- application node, connector, and line records are canonical; -- SnapLine objects and settled lines are mirrors; -- user gestures stage line changes and emit atomic line commits; -- direct settled-topology mutation is internal or rejected for controlled - entities. - -Keep imperative behavior through a separately named unmanaged API. Unmanaged -code may treat connector topology as canonical, but it must opt into that -authority model explicitly rather than acquiring it merely because no callback -was supplied. +- **Controlled**: application node, connector, and line records are canonical; + SnapLine mirrors and settled lines are reflections; user gestures stage line + changes and emit line-change requests; direct settled-topology mutation is + internal or rejected. +- **Uncontrolled**: SnapLine mirror topology is locally authoritative and the + imperative API mutates it directly. Uncontrolled code must opt into that + authority model explicitly rather than acquiring it merely because no + callback was supplied. Public types, examples, and method names must make the authority mode visible. -The remaining design detail is whether one engine may contain explicitly -partitioned controlled and unmanaged graphs; accidental cross-mode lines are -not allowed. + +**Resolved:** one engine has exactly one authority mode. Mixed or partitioned +controlled/uncontrolled graphs within a single engine are not supported for +1.0: a per-engine mode flag is set at engine or provider construction, and any +operation belonging to the other mode fails fast with a structured diagnostic. +Applications that genuinely need both models run two engines; engine scoping +(Workstream E) makes that safe. ### D2/D3. Stable graph IDs and soft-link resolution **Planned delta** -Require application-owned stable IDs for every canonical node, connector, and -line: +Every canonical node, connector, and line has a stable string ID: ```ts type NodeId = string; @@ -337,10 +373,27 @@ interface LineRecord { } ``` -The exact primitive type remains to settle, but IDs must be immutable and -unique within one canonical graph. A runtime `BaseObject.id` is not a -substitute: it identifies one instantiated mirror and cannot reference an -entity before that mirror exists or across a remount. +**Resolved ID semantics:** + +- IDs are strings only. Generic `string | number` identity would infect every + interface signature for near-zero benefit. +- Connector IDs are graph-global, so one `ConnectorId` is an unambiguous soft + link. `ConnectorRecord.nodeId` is denormalized parentage used for + validation. +- The `id` prop/record field is **optional**. When omitted, SnapLine mints a + stable string ID at mirror creation and reports it through the normal + snapshot and request surfaces. +- A minted ID is stable for the lifetime of the mirror, and no longer. Any + canonical graph that outlives a mirror’s lifetime — persisted documents, + cross-session reloads, framework remounts — must supply its own IDs, or + stored `LineRecord`s will reference dead connector IDs after the reload. + Auto-minting is the convenience default; supplying IDs is the persistence + contract. +- IDs are immutable and unique within one canonical graph. + +A runtime `BaseObject.id` is not a substitute for a supplied ID in persistent +graphs: it identifies one instantiated mirror and cannot reference an entity +before that mirror exists or across a remount. ```mermaid erDiagram @@ -364,13 +417,13 @@ erDiagram } ``` -Canonical relationships store IDs, not `NodeObject` or `ConnectorObject` -references. `NodeManager` adds live lookup indexes: +Canonical relationships store IDs, not `NodeMirror` or `ConnectorMirror` +references. The mirror registry adds live lookup indexes: ```ts -nodesById: ReadonlyMap; -connectorsById: ReadonlyMap; -linesById: ReadonlyMap; +nodesById: ReadonlyMap; +connectorsById: ReadonlyMap; +linesById: ReadonlyMap; ``` The same soft-link rule applies to a connector’s parent-node reference, line @@ -395,28 +448,26 @@ stateDiagram-v2 **Planned implementation** -- Add immutable typed identity to node, connector, and line mirrors. -- Add manager indexes and read-only lookup methods for all three IDs. -- Make controlled records and line commits carry IDs rather than runtime - object references as their durable identity. +- Add immutable typed identity to node, connector, and line mirrors, minted at + creation when not supplied. +- Add registry indexes and read-only lookup methods for all three IDs. +- Make controlled records and line-change requests carry IDs rather than + runtime mirror references as their durable identity. - Validate a connector’s `nodeId` against its mounted parent node. - Detect duplicate IDs and report structured diagnostics; never silently let the last registration win. - Keep unresolved records in the application/controller layer rather than - constructing placeholder core objects. + constructing placeholder core mirrors. - Trigger the batch-aware reconciliation scheduler when a missing identity registers or unregisters. -- Preserve a `LineObject` across endpoint changes when its `lineId` remains +- Preserve a `LineMirror` across endpoint changes when its `lineId` remains the same. **Remaining details** -- Choose whether IDs are strings only or generic string/number values. -- Decide whether connector IDs are graph-global or unique within a node. The - current leaning is graph-global so one `ConnectorId` is an unambiguous soft - link. - Define whether an attempted identity change is rejected or handled as an - unregister/register transaction. + unregister/register transaction. Current leaning: rejected with a structured + diagnostic, consistent with ID immutability. ### D4. Canonical hydration reports structured errors @@ -427,7 +478,7 @@ invalid reference after a graph load is declared complete, connector capacity, parallel-line policy, or another connector rule: - do not delete, reorder, replace, or otherwise rewrite the canonical record; -- do not emit a line commit from pull/reconciliation; +- do not emit a line-change request from reconciliation; - leave the mirror latent or explicitly errored; - publish a structured reconciliation error. @@ -448,14 +499,22 @@ interface ReconciliationError { } ``` -Errors must be available through a callback and a read-only diagnostic query. +**Resolved delivery:** diagnostics stay deliberately minimal — one current +array available through the `GraphQuery.diagnostics()` read-only query, plus +one `onDiagnosticsChanged` callback that observes the array after it changes. +No separate diagnostics subsystem, severity hierarchy, or subscription API. Reconciliation retries an errored/latent record after relevant identities, rules, or canonical records change. A valid soft link whose mirror simply has not mounted yet remains latent and is not itself an error. -### D5/D6. One atomic line-commit callback +**Remaining details** + +- Diagnostic retention and clearing semantics (when a resolved error leaves + the array; whether cleared errors are reported once more). + +### D5/D6. One atomic line-change request callback **Planned delta** @@ -463,59 +522,80 @@ Replace the overlapping `onConnectionRequest`, `onEdgeConnect`, and `onEdgeDisconnect` application seams with one controlled callback: ```ts -interface LineCommit { - reason: "connect" | "disconnect" | "replace" | "reconnect"; +interface LineChangeRequest { + intent: "connect" | "disconnect" | "replace" | "reconnect"; add: readonly ProposedLine[]; remove: readonly LineId[]; update: readonly LineEndpointUpdate[]; originalEvent?: PointerEvent; } +interface ProposedLine { + id: LineId; // minted by SnapLine for the staged mirror + fromConnectorId: ConnectorId; + toConnectorId: ConnectorId; +} + interface ControlledLineCallbacks { - onLineCommit(commit: LineCommit): void; + onLineChangeRequest(request: LineChangeRequest): void; } ``` +The field is `intent`, not `reason`: the request has not happened yet, and +“reason” would imply past causation. + - A normal connection proposes one atomic `add`. - A disconnect proposes one atomic `remove`. - Capacity replacement proposes its removals and addition together. - Reconnect proposes an endpoint update for the same stable `LineId`. - The application accepts, rejects, or normalizes the proposal by updating its - canonical document. + canonical document and calling `setCanonicalGraph()`. + +**Resolved correlation contract — SnapLine mints the `LineId`:** + +- A gesture-created line receives its `LineId` from SnapLine at staging time, + and the `ProposedLine` in the request carries it. +- The application **adopts the proposed ID** when it accepts. On the next + `setCanonicalGraph()`, a canonical record with the staged ID settles the + staged `LineMirror` in place; a snapshot without the ID discards it. No + correlation IDs, no request result object, no timing inference. +- The application retains ID authority: it may substitute its own ID, but then + it forfeits settle-in-place — the staged mirror is discarded and a fresh + mirror is created for the app-named record on reconciliation. Document this + trade explicitly in the public API docs. +- Rejection needs no signal. The adapter’s guaranteed post-request + `setCanonicalGraph()` (see the lifecycle section) delivers a snapshot; if + the application declined to add the record, the snapshot omits the staged ID + and the mirror is discarded. +- **Invariant:** at most one in-flight gesture request per engine. Pointer + gestures are serial, so this is already true; documenting it removes any + need for request supersession rules or per-request correlation. +- The staged-awaiting-decision state is an explicit `LineMirrorPhase`, so a + staged line that has not yet been settled or discarded is visible and + debuggable rather than silently ambiguous. Low-level connector lifecycle observers may remain for local behavior, but they must not be alternate application graph-mutation seams. Their names must begin -with `on` and identify them as observations rather than commits. +with `on` and identify them as observations rather than requests. -**Remaining details** - -- Define how a staged `LineObject` correlates with the accepted `LineRecord` so - a newly connected line settles in place rather than being recreated. -- Choose the post-commit completion signal: a canonical revision, an adapter - post-render notification, an explicit commit result, or a combination. -- Ensure rejection also closes the commit and triggers a pull; do not infer - rejection from a timing guess. -- Define whether a later pull supersedes every older pending commit or whether - commits require individual correlation IDs. - -### D7. Internal manager with a read-only query facade +### D7. Internal registry with the read-only `GraphQuery` facade **Planned delta** Keep registration, unregistration, reconciliation attachment, and mutable -indexes internal to the engine-scoped mirror service. Replace public +indexes internal to the engine-scoped mirror registry. Replace public `NodeManager`, `getNodeManager()`, and `EdgeSyncLike` access with a read-only query facade exposing snapshots and identity lookups: ```ts -interface SnapLineQuery { - nodes(): readonly NodeObject[]; - connectors(): readonly ConnectorObject[]; - groups(): readonly GroupNodeObject[]; - lines(): readonly LineObject[]; - node(id: NodeId): NodeObject | null; - connector(id: ConnectorId): ConnectorObject | null; - line(id: LineId): LineObject | null; +interface GraphQuery { + nodes(): readonly NodeMirror[]; + connectors(): readonly ConnectorMirror[]; + groups(): readonly GroupNodeMirror[]; + lines(): readonly LineMirror[]; + node(id: NodeId): NodeMirror | null; + connector(id: ConnectorId): ConnectorMirror | null; + line(id: LineId): LineMirror | null; diagnostics(): readonly ReconciliationError[]; } ``` @@ -523,17 +603,26 @@ interface SnapLineQuery { The facade must not expose registry sets, topology arrays, controller attachment, or mutation methods. +**Where read-only is enforced:** the facade returns **live mirrors**, not deep +snapshots. The facade’s read-only guarantee applies to the registry layer — a +consumer cannot add, remove, or re-attach anything through it. The mirrors it +returns are the real interactive runtime objects; their own mutation surface +is constrained separately, by the Workstream C read-only topology work and by +controlled-mode mutation rejection (D1). Do not describe the facade as +returning immutable data. + ### D8. Explicit geometry authority modes **Planned delta** -Expose two clearly named position/size modes: +Expose two clearly named position/size modes, using the same +controlled/uncontrolled vocabulary as D1: ```ts -type GeometryAuthority = "snapline" | "canonical"; +type GeometryAuthority = "uncontrolled" | "controlled"; ``` -`"snapline"` is the default: +`"uncontrolled"` is the default: - props provide initial geometry and deliberate later external commands; - SnapLine owns live and settled position/size during interaction; @@ -541,78 +630,79 @@ type GeometryAuthority = "snapline" | "canonical"; - the consumer may persist or otherwise honor the observation, but ignoring it does not revert SnapLine’s settled geometry. -`"canonical"` is opt-in: +`"controlled"` is opt-in: - props remain canonical; - SnapLine may display optimistic live interaction geometry; - SnapLine stages the final position and size and invokes - `onGeometryCommit`; + `onGeometryChangeRequest`; - updated props accept or normalize the proposal; -- the next pull/reconciliation settles an accepted value or reverts a rejected - value. +- the next `setCanonicalGraph()`/reconciliation settles an accepted value or + reverts a rejected value. Both callbacks carry the same position/size payload shape, but their names make -the ownership contract explicit: `Changed` is an observation and `Commit` -hands staged data to the canonical owner. The previous provisional `"commit"` -mode name is removed because the authority mode itself is SnapLine-owned; -commit instead names the outbound canonical operation. +the ownership contract explicit: `Changed` is an observation and `Request` +asks the canonical owner to act. The previous provisional `"commit"` and +`"snapline"` mode names are removed: SnapLine-owned geometry is not a +canonical or framework commit, and a mode value should name the authority +model, not the library. -### D11. `NodeManager` owns the engine group registry +### D11. The mirror registry owns the engine group registry **Planned delta** Move the live group registry from `global.data.groups` into the engine’s -`NodeManager`. +mirror registry (currently `NodeManager`, becoming `GraphMirror` under +Workstream A). -`NodeManager` is the organization boundary for engine-level node information. -A `GroupNodeObject` is both: +The registry is the organization boundary for engine-level node information. +A `GroupNodeMirror` is both: -- a node in `manager.nodes`; -- a group in a dedicated `manager.groups` index. +- a node in the registry’s node index; +- a group in a dedicated group index. ```mermaid classDiagram direction TB - class NodeManager { + class GraphMirror { +nodes +groups +connectors } - class NodeObject - class GroupNodeObject - class ConnectorObject + class NodeMirror + class GroupNodeMirror + class ConnectorMirror - NodeManager o-- NodeObject : all live nodes - NodeManager o-- GroupNodeObject : live group index - NodeManager o-- ConnectorObject : all live connectors - GroupNodeObject --|> NodeObject + GraphMirror o-- NodeMirror : all live nodes + GraphMirror o-- GroupNodeMirror : live group index + GraphMirror o-- ConnectorMirror : all live connectors + GroupNodeMirror --|> NodeMirror ``` **Planned implementation** -- Add a private `Set` to `NodeManager`. -- Add group registration/unregistration methods used by - `GroupNodeObject`. +- Add a private `Set` to the registry. +- Add group registration/unregistration methods used by `GroupNodeMirror`. - Expose groups as a read-only snapshot. - Remove `groups` and `getGroups()` from `snapline-globals.ts`. -- Make `getGroupNodes()` read the manager’s dedicated group index. -- Make group membership reconciliation enumerate `manager.nodes` and - `manager.groups`. +- Make `getGroupNodes()` read the registry’s dedicated group index. +- Make group membership reconciliation enumerate the registry’s node and group + indexes. - Stop scanning SnapEngine’s general object table for membership nodes. - Stop filtering a shared group array by `engine`. - Add registration, teardown, and multi-engine isolation tests. **Related state to review** -The group module also keeps engine-level coordination outside `NodeManager`: +The group module also keeps engine-level coordination outside the registry: - `parentGroups`; - `membershipResolvers`; - `reconcilingEngines`. -We should decide whether these become manager-owned group state as well. That +We should decide whether these become registry-owned group state as well. That is a follow-up to D11, not yet part of the settled decision. ### D12. Nestable bulk reconciliation boundary @@ -620,35 +710,39 @@ is a follow-up to D11, not yet part of the settled decision. **Planned delta** Add an explicit reconciliation batch for saved-graph loads that span multiple -framework render commits or asynchronous chunks: +framework render commits or asynchronous chunks. The primary API is a scoped +function, so exception safety is structural rather than a call-site +convention: ```ts -const batch = snapLineManager.beginReconciliationBatch(); -try { - // The application updates its canonical nodes, connectors, and lines. - await frameworkCommit(); -} finally { - batch.end(); -} +await graphMirror.runBatch(async () => { + // The application installs canonical records and the framework mounts + // their mirrors, possibly across multiple render commits. +}); ``` -The exact adapter surface may be a batch token, `ready` prop, or graph revision -boundary. Its required semantics are: +A low-level `beginBatch()`/`end()` token pair remains as an escape hatch for +adapters whose lifecycles do not nest inside one function. Required semantics: -- registrations and line-document changes mark reconciliation dirty; +- registrations and `setCanonicalGraph()` calls mark reconciliation dirty; - no partial graph reconciliation runs while a batch is open; - batches may nest; -- only the outermost `end()` schedules the final pass; -- `end()` is idempotent and exception-safe; +- only the outermost close schedules the final pass; +- closing is idempotent and exception-safe; - a batch with no relevant changes does not run a pass; - the final pass runs after framework connector registration render commits - and reads the latest canonical document; + and reconciles against the latest cached canonical snapshot; - vanilla consumers and tests can explicitly `flush()`; - an unrelated long-running import cannot delay gesture acceptance/rejection. +Because canonical state arrives through `setCanonicalGraph()` and is cached, +the batch boundary has no adapter-surface question left: batching only defers +when the cached snapshot is reconciled, never how it is obtained. + The load ordering is: -1. Install canonical node, connector, and line records. +1. Install canonical node, connector, and line records via + `setCanonicalGraph()`. 2. Let the framework mount their available mirrors. 3. Close the outer bulk boundary. 4. Reconcile once, resolving all links whose mirrors are available. @@ -663,24 +757,26 @@ general limits on every connector: - maximum outgoing lines; - maximum incoming lines. -Each limit supports zero, a finite non-negative count, or no limit. A zero +Each limit supports zero, a finite non-negative count, or unlimited. A zero outgoing limit makes a connector target-only; a zero incoming limit makes it source-only. A connector with nonzero capacity in both directions supports both roles, so separate `source` and `target` booleans become derived rather than independent configuration. -Use an explicit unlimited value instead of the current negative-number -sentinel. The concrete representation remains an API detail to settle; -`number | null` is the current candidate: +**Resolved representation:** unlimited is the explicit string `"unlimited"`, +which is self-documenting at call sites and JSON-safe. Internally it is +normalized to `Infinity` so capacity checks stay branch-free +(`count < max` handles unlimited for free). The negative-number sentinel is +removed. ```ts -type ConnectionLimit = number | null; // null means unlimited +type ConnectionLimit = number | "unlimited"; interface ConnectorRules { maxOutgoing: ConnectionLimit; maxIncoming: ConnectionLimit; reconnect: boolean; - onConnectionCheck?: (proposal: ConnectionProposal) => boolean; + isValidConnection?: (proposal: ConnectionProposal) => boolean; } ``` @@ -689,11 +785,10 @@ to its endpoints: ```ts interface ConnectionProposal { - line: LineObject; - source: ConnectorObject; - target: ConnectorObject; + line: LineMirror; + source: ConnectorMirror; + target: ConnectorMirror; phase: "candidate" | "drop"; - origin: "gesture"; } ``` @@ -701,7 +796,9 @@ This allows a developer to admit a line based on line type, payload, source, target, and connector metadata. The predicate is synchronous and side-effect free because candidate discovery may call it repeatedly. It is checked again on the final drop. Source-level and target-level rules may both veto the same -proposal. +proposal. (An `origin` field is deliberately omitted: gestures are currently +the only origin, and a single-value union is speculative surface. Add it when +a second origin exists.) ```mermaid classDiagram @@ -711,7 +808,7 @@ classDiagram +maxOutgoing +maxIncoming +reconnect - +onConnectionCheck(proposal) + +isValidConnection(proposal) } class ConnectionProposal { @@ -719,16 +816,15 @@ classDiagram +source +target +phase - +origin } - class ConnectorObject - class LineObject + class ConnectorMirror + class LineMirror - ConnectorObject *-- ConnectorRules + ConnectorMirror *-- ConnectorRules ConnectorRules ..> ConnectionProposal - ConnectionProposal --> LineObject - ConnectionProposal --> ConnectorObject : source / target + ConnectionProposal --> LineMirror + ConnectionProposal --> ConnectorMirror : source / target ``` **Capacity semantics** @@ -744,19 +840,17 @@ classDiagram Reject an over-capacity proposal by default. If replacement is desired, add a separate explicit replacement policy that selects affected stable line IDs and -produces one atomic line commit. Remove implicit oldest-line eviction from -`connectToConnector()`. +produces one atomic line-change request. Remove implicit oldest-line eviction +from `connectToConnector()`. **Remaining details** -- Choose the public unlimited representation (`null`, `"unlimited"`, or - another explicit value). - Decide whether `allowParallel` remains a convenience rule or is expressed entirely through the compatibility predicate. - Decide how proposed canonical line-record data is exposed alongside a preview line when admission depends on application-specific line fields. -## Workstream A: one engine-scoped mirror service +## Workstream A: one engine-scoped graph mirror **Problem** @@ -769,13 +863,19 @@ The current organization is split: table; - public queries and internal discovery do not use one source. -**Candidate structure** +**Decided structure** + +`GraphMirror` is the settled name: the one engine-scoped registry for every +SnapLine runtime entity — the mirror of the whole graph. The name states what +the thing is rather than its role, and it avoids the redundancy of a +“SnapLineManager” inside the SnapLine package. `LineReconciler` (the +`EdgeSyncController` successor) is an internal service owned by `GraphMirror`. ```mermaid classDiagram direction TB - class SnapLineManager { + class GraphMirror { +nodesById +connectorsById +linesById @@ -783,65 +883,60 @@ classDiagram +selection +groups +query() + +setCanonicalGraph(snapshot) + +runBatch(fn) +scheduleReconciliation() } - class NodeObject - class ConnectorObject - class LineObject + class NodeMirror + class ConnectorMirror + class LineMirror class LineReconciler - SnapLineManager o-- NodeObject - SnapLineManager o-- ConnectorObject - SnapLineManager o-- LineObject : settled and preview indexes - SnapLineManager o-- LineReconciler + GraphMirror o-- NodeMirror + GraphMirror o-- ConnectorMirror + GraphMirror o-- LineMirror : settled and preview indexes + GraphMirror o-- LineReconciler ``` -`SnapLineManager` is the provisional target name if the current `NodeManager` -grows into the one registry for every SnapLine object. If its responsibility -remains node-specific, retain `NodeManager` and place line reconciliation in a -separate, clearly named internal service. Do not keep the name `NodeManager` -for a general graph registry merely to avoid a rename. - **Planned cleanup** - Move the live group registry from `global.data.groups` to - `NodeManager.groups`. + `GraphMirror.groups`. - Route connector candidate discovery and group membership enumeration through - the manager instead of SnapEngine’s general object table. + the registry instead of SnapEngine’s general object table. - Track settled lines separately from previews. - Add lookup by stable domain identity. -- Make connector topology and new manager indexes read-only to consumers. +- Make connector topology and new registry indexes read-only to consumers. - Remove stale per-engine registry entries during engine/controller teardown. - Move remaining application-wide selection and interaction registries behind - engine-scoped manager state or accessors. + engine-scoped registry state or accessors. ## Workstream B: controlled line reconciliation **Problem** The legacy `EdgeSyncController` establishes the correct high-level direction, -but it -reuses imperative connector mutation behavior underneath. Hydration therefore -still runs gesture policy, parallel checks, capacity replacement, and callback -paths that can conflict with a canonical document. +but it reuses imperative connector mutation behavior underneath. Hydration +therefore still runs gesture policy, parallel checks, capacity replacement, +and callback paths that can conflict with a canonical document. **Planned separation** ```mermaid flowchart TB - PULL["Pull canonical snapshot"] + SET["setCanonicalGraph(snapshot) caches the document"] LINE["Canonical line record"] LOOKUP{"Both connector mirrors mounted?"} LATENT["Keep line latent"] - EXISTING{"Line object for line ID exists?"} + EXISTING{"Line mirror for line ID exists?"} PRESERVE["Preserve mirror identity"] CREATE["Create settled mirror directly"] ERROR{"Representable?"} REPORT["Emit structured reconciliation diagnostic"] DONE["Mirror matches document"] - PULL --> LINE --> LOOKUP + SET --> LINE --> LOOKUP LOOKUP -->|"no"| LATENT LOOKUP -->|"yes"| EXISTING EXISTING -->|"yes"| PRESERVE --> DONE @@ -852,18 +947,18 @@ flowchart TB **Candidate implementation changes** -- Replace `EdgeSyncController.sync()` with `LineReconciler.reconcile()`; - implement it as one `pullCanonical()` followed by - `reconcileMirror(snapshot)`. -- Keep pull/reconciliation read-only with respect to canonical state; only - staged interactions may invoke `onLineCommit`. +- Replace `EdgeSyncController.sync()` with `setCanonicalGraph(snapshot)` plus + `LineReconciler.reconcile()`, which reconciles the mirror against the cached + snapshot. +- Keep reconciliation read-only with respect to canonical state; only staged + interactions may invoke `onLineChangeRequest`. - Separate a private “create settled mirror from canonical line” path from a user connect command. - Separate gesture settlement of an existing preview line from line creation; do not hide both operations behind an optional `line` argument. - Preserve a settled line by stable line ID when its endpoints are updated. -- Route connector registration, identity changes, canonical changes, and - explicit pulls through one batch-aware scheduler. +- Route connector registration, identity changes, canonical snapshot changes, + and explicit requests through one batch-aware scheduler. - Surface duplicate identities, missing endpoints, and unrepresentable lines as diagnostics. - Leave policy-violating canonical records latent/errored and publish the D4 @@ -874,15 +969,15 @@ flowchart TB **Cleanup candidates** - Make `incomingLines` and `outgoingLines` read-only snapshots. -- Make `LineObject.start`, `target`, phase, candidate, and anchors +- Make `LineMirror.start`, `target`, phase, candidate, and anchors read-only outside internal mutation paths. - Convert underscore-prefixed implementation fields to `#` private fields or internal module state. - Stop exposing input event handlers as incidental public methods. - Rename and document low-level lifecycle events versus semantic application - commits so their roles are unambiguous. + requests so their roles are unambiguous. - Remove public `NodeManager`, `getNodeManager()`, and `EdgeSyncLike` access in - favor of the D7 read-only query facade. + favor of the D7 read-only `GraphQuery` facade. - Add missing package subpaths for APIs that remain public. - Remove or rename deprecated connector options instead of carrying parallel legacy and capability APIs indefinitely. @@ -895,8 +990,10 @@ flowchart TB - Use stable line identity for framework line keys. - Make node and connector identity typed adapter props. -- After D8, expose geometry explicitly as SnapLine-owned/default or canonical - with commit/rejection semantics. +- After D8, expose geometry explicitly as uncontrolled/default or controlled + with request/rejection semantics. +- Guarantee the post-request `setCanonicalGraph()` follow-up push in both the + React and Svelte adapters with identical timing semantics. - Add direct package exports for every documented component and replace the legacy `EdgeSync` name with the final line-reconciliation name. @@ -913,47 +1010,53 @@ flowchart TB ## Workstream F: legacy property propagation -`NodeObject` currently owns a name-keyed property bag and propagates values +`NodeMirror` currently owns a name-keyed property bag and propagates values through outgoing connectors. -**Decision** +**Decided: remove it from SnapLine.** -Choose one: +The thesis of this re-architecture is that the application owns the canonical +graph and its dataflow. A second, name-keyed dataflow mechanism living in the +view library contradicts that ownership model, and keeping it would require +typing, cycle safety, parallel-line semantics, and controlled-remount behavior +— substantial cost for a legacy feature. -1. Keep it as a supported SnapLine data-flow feature. -2. Extract it into an optional module. -3. Remove it from SnapLine so the application graph owns evaluation/data flow. +**Removal plan** -If retained: - -- type the property API; -- separate connector identity from property name; -- preserve cycle safety; -- define behavior for parallel lines and controlled line remounts; -- avoid treating the property bag as domain graph persistence. +- Remove the property bag and propagation API from node and connector mirrors. +- Write a migration note showing how an application expresses dataflow in its + own graph document and pushes results through canonical records. +- If a real consumer surfaces during migration that cannot adopt + application-level dataflow, extraction into an optional module may be + reconsidered; do not preemptively build that module. ## Workstream G: tests and diagnostics Add coverage only for behavior introduced or altered by this plan: - stable line ID preservation; +- minted-ID adoption: staged line settles in place when the next snapshot + contains the proposed `LineId`, and is discarded when it does not; +- rejection-by-inaction resolving through the adapter’s post-request push; - parallel controlled lines; - reconnect preserving line identity; - atomic capacity replacement; -- zero, finite, and unlimited incoming/outgoing capacity; +- zero, finite, and `"unlimited"` incoming/outgoing capacity; - outgoing preview reservation and reconnect slot reuse; - line-aware source and target admission predicates; - explicit over-capacity rejection and replacement-policy behavior; - hydration that conflicts with gesture policy; - duplicate node/connector/line identity diagnostics; -- direct mutation rejection in controlled mode; +- direct mutation rejection in controlled mode, and cross-mode operations + failing fast under the per-engine authority flag; - read-only topology query behavior; - multi-engine isolation; - controlled geometry rejection/reversion; - React/Svelte semantic parity; -- one final pull/reconciliation pass for a multi-render-commit explicit batch, - including nested batches; -- manager-owned group registration, teardown, and engine isolation; +- one final reconciliation pass for a multi-render-commit `runBatch()`, + including nested batches and the `beginBatch`/`end` escape hatch; +- registry-owned group registration, teardown, and engine isolation; +- property-propagation removal migration notes verified against the demos; - package export coverage for APIs retained by the re-architecture. ## Final phase: post-rearchitecture simplification review @@ -982,8 +1085,9 @@ performance requirements. - Profile large graph loads, candidate discovery, reconciliation, and drag interaction before retaining or adding non-obvious optimization. - Delete migration-only internals that are no longer needed before 1.0. -- Repeat the vocabulary audit so one concept has one name and every remaining - callback starts with `on`. +- Repeat the vocabulary audit so one concept has one name, every observation + and request callback starts with `on`, and every predicate callback starts + with `is` or `can`. - Update the current-architecture and ownership documents to describe the resulting implementation, not the migration history. @@ -992,7 +1096,7 @@ performance requirements. - Each major lifecycle has one obvious path through the code. - No known layer, registry, callback, or snapshot merely duplicates another. - Public APIs expose the smallest surface required by the ownership model. -- Core runtime `Object` types and framework `Component` types never overlap in +- Core runtime `Mirror` types and framework `Component` types never overlap in meaning. - All tests, type checks, adapter suites, browser tests, and relevant benchmarks pass after the simplification. @@ -1004,7 +1108,7 @@ flowchart TB P0["Phase 0 — legacy cleanup
characterize behavior, vocabulary, rename map"] P1["Phase 1 — finalize API details
authority, identity, rules, batching"] P2["Phase 2 — mirror internals
engine scoping, identity indexes, read-only queries"] - P3["Phase 3 — controlled line protocol
stage, commit, pull, reconciliation"] + P3["Phase 3 — controlled line protocol
setCanonicalGraph, reconciliation, atomic requests"] P4["Phase 4 — framework adapters and API
React / Svelte parity, exports, privacy"] P5["Phase 5 — verification and migration
tests, diagnostics, docs, migration notes"] P6["Phase 6 — simplification review
remove redundancy and challenge architecture"] @@ -1023,71 +1127,72 @@ pass for simplification after the complete system can be evaluated. - [ ] Inventory and classify the current core, React, and Svelte APIs. - [ ] Add characterization tests for behavior needed during cleanup. - [ ] Review and approve the vocabulary and old-name → new-name map. -- [ ] Rename core runtime `*Component` types to `*Object`. +- [ ] Rename core runtime `*Component` types to `*Mirror`. - [ ] Replace internal `Edge*` terminology with `Line*`. -- [ ] Ensure every callback/hook starts with `on` and no command or query does. -- [ ] Replace canonical/mirror `sync` names with `reconcile`; replace unrelated - `sync` names with their concrete measurement, write, or scheduling - operation. -- [ ] Split mixed-responsibility functions and simplify implicit control flow. - [ ] Remove dead code, obsolete aliases, duplicate helpers, and stale comments. - [ ] Run all SnapLine checks before beginning architectural changes. +(Callback-prefix normalization and mixed-responsibility function splitting are +deliberately deferred to each API’s owning phase; see “Phase 0 scope.”) + ### Remaining API details -- [ ] Decide whether explicitly partitioned controlled and unmanaged graphs may - coexist in one engine. -- [ ] Finalize D2/D3 details: ID primitive, connector-ID scope, identity - mutation, and adapter delivery API. -- [ ] Finalize D4 diagnostic delivery, retention, and clearing semantics. -- [ ] Finalize the D5/D6 unified commit schema and application acceptance - contract. -- [ ] Finalize the canonical pull callback, post-commit completion signal, and - staged-object correlation contract. -- [ ] Finalize the D7 query-facade naming and package location. -- [ ] Finalize D8 mode and prop names. -- [ ] Choose the D12 framework adapter surface: batch token, `ready`, revision, - or a combination. -- [ ] Resolve D13 details: unlimited representation, parallel shorthand, and - proposed line data. +- [ ] Decide identity-change handling: reject with a diagnostic (leaning) or + unregister/register transaction. +- [ ] Finalize D4 diagnostic retention and clearing semantics. +- [ ] Resolve D13 details: parallel shorthand and proposed line-record data + exposure. +- [ ] Finalize the D7 `GraphQuery` package location. +- [ ] Finalize D8 prop names for the geometry authority modes. ### Core -- [ ] Design the engine-scoped mirror service. -- [ ] Add immutable node, connector, and line identity to controlled mirrors. -- [ ] Add read-only manager indexes and soft-link resolution by those IDs. +- [ ] Design `GraphMirror`, the engine-scoped mirror registry. +- [ ] Add immutable node, connector, and line identity to mirrors, minted at + creation when not supplied. +- [ ] Add read-only registry indexes and soft-link resolution by those IDs. - [ ] Keep missing references latent and retry them after registration. - [ ] Reject duplicate IDs with structured diagnostics. -- [ ] Move the group registry into `NodeManager`. -- [ ] Rework group reconciliation to use manager node/group indexes. +- [ ] Move the group registry into the mirror registry. +- [ ] Rework group reconciliation to use registry node/group indexes. - [ ] Remove `global.data.groups` and its accessor. - [ ] Decide where parent mappings, membership resolvers, and the reconciliation guard live. - [ ] Add settled-line and preview-line indexes. - [ ] Unify object discovery. - [ ] Make topology views read-only. -- [ ] Replace role booleans and incoming-only capacity with symmetric limits. -- [ ] Make connection admission line-aware and gesture-specific. +- [ ] Add the per-engine controlled/uncontrolled authority flag with fail-fast + cross-mode diagnostics. +- [ ] Replace role booleans and incoming-only capacity with symmetric limits + and the `"unlimited"` representation. +- [ ] Make connection admission line-aware via `isValidConnection`. - [ ] Extract implicit oldest-line eviction into an explicit replacement rule. - [ ] Split preview settlement, preview disposal, and programmatic settled-line creation into explicit internal operations. -- [ ] Unify pull/reconciliation work behind one coalescing, batch-aware - scheduler. +- [ ] Implement `setCanonicalGraph()` with snapshot caching and one coalescing, + batch-aware reconciliation scheduler. +- [ ] Implement `runBatch()` plus the `beginBatch()`/`end()` escape hatch and + `flush()`. - [ ] Separate hydration from imperative connection mutation. -- [ ] Add structured reconciliation diagnostics. +- [ ] Add structured reconciliation diagnostics with `onDiagnosticsChanged`. +- [ ] Remove the property bag and propagation API. - [ ] Scope all shared SnapLine state by engine. ### Adapters -- [ ] Design the shared controlled graph contract. -- [ ] Use stable line IDs for line rendering. -- [ ] Define canonical/SnapLine-owned geometry APIs. +- [ ] Design the shared controlled graph contract around `setCanonicalGraph()` + and `onLineChangeRequest`. +- [ ] Guarantee the post-request follow-up push in both adapters. +- [ ] Use stable line IDs for line rendering keys. +- [ ] Define controlled/uncontrolled geometry APIs. - [ ] Align root and subpath exports. ### Documentation and migration - [ ] Remove overlapping or obsolete callback seams. -- [ ] Decide the future of property propagation. +- [ ] Write the property-propagation removal migration note. +- [ ] Document the minted-ID adoption contract and the persistence requirement + for app-supplied IDs. - [ ] Update examples to show one recommended ownership model. - [ ] Write migration notes before the next package release. From a2c804f5fc4334b12f2d542ce201c8edd1624154 Mon Sep 17 00:00:00 2001 From: tfukaza Date: Sat, 25 Jul 2026 18:12:47 -0700 Subject: [PATCH 03/21] =?UTF-8?q?snapline:=20Phase=200=20renames=20?= =?UTF-8?q?=E2=80=94=20core=20runtime=20types=20adopt=20the=20Mirror=20suf?= =?UTF-8?q?fix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Behavior-preserving vocabulary cleanup per the re-architecture plan: - NodeComponent → NodeMirror, ConnectorComponent → ConnectorMirror, LineComponent → LineMirror, GroupNodeComponent → GroupNodeMirror. Component is now reserved for framework types; the framework-facing Node/Connector/Line/Group component names are unchanged. - RectSelectComponent → RectSelectController (interaction controller, not a canonical-record mirror; follows the PlacementController precedent so no core type carries the Component suffix). - React NodeObjectContext → NodeMirrorContext. - syncDomGeometry() → remeasureDomGeometry() (names the DOM measurement, not canonical reconciliation). - EdgeSyncController privates #syncing/#syncQueued → #reconciling/#reconciliationQueued. The controller's public name and sync() entry point are unchanged until the Phase 3 protocol replaces them, per the no-double-rename rule. - Remove dead startDragOutLine() (zero callers; pointer-down only arms). - Update demos, unit/e2e tests, package READMEs, AGENTS.md, and the snapline docs to the new names. Deferred by design: maxConnectors/allowDragOut removal (the whole capability model is replaced by D13 in Phase 3) and the EdgeSync seam's Edge* types (replaced wholesale by the controlled line protocol). Verified: tsc, check:adapters (0 errors), all six snapline e2e suites green (55 tests; one camera flake passed on isolated rerun). Co-Authored-By: Claude Fable 5 --- assets/snapline/AGENTS.md | 36 ++--- assets/snapline/core/README.md | 6 +- assets/snapline/core/src/connector.ts | 130 ++++++++---------- assets/snapline/core/src/edge-sync.ts | 54 ++++---- assets/snapline/core/src/group.ts | 112 +++++++-------- assets/snapline/core/src/index.ts | 10 +- assets/snapline/core/src/line.ts | 18 +-- assets/snapline/core/src/node-manager.ts | 20 +-- assets/snapline/core/src/node.ts | 84 +++++------ assets/snapline/core/src/query.ts | 16 +-- assets/snapline/core/src/select.ts | 22 +-- assets/snapline/core/src/snapline-globals.ts | 10 +- assets/snapline/react/src/Connector.tsx | 18 +-- assets/snapline/react/src/EdgeSync.tsx | 4 +- assets/snapline/react/src/Group.tsx | 14 +- assets/snapline/react/src/Line.tsx | 4 +- assets/snapline/react/src/Node.tsx | 28 ++-- assets/snapline/react/src/Select.tsx | 6 +- assets/snapline/react/src/index.ts | 2 +- assets/snapline/svelte/src/Connector.svelte | 16 +-- assets/snapline/svelte/src/EdgeSync.svelte | 4 +- assets/snapline/svelte/src/Group.svelte | 10 +- assets/snapline/svelte/src/Line.svelte | 4 +- assets/snapline/svelte/src/Node.svelte | 12 +- assets/snapline/svelte/src/Select.svelte | 4 +- demo/react/src/components/lib/Input.jsx | 4 +- demo/react/src/components/lib/Output.jsx | 4 +- demo/svelte/src/demo/node_ui_demo/Line.svelte | 4 +- demo/svelte/src/demo/node_ui_demo/Math.svelte | 4 +- .../svelte/src/demo/node_ui_demo/Print.svelte | 4 +- .../src/demo/node_ui_demo/TextBox.svelte | 4 +- .../demo/node_ui_edges/NodeUIEdgesDemo.svelte | 4 +- .../demo/node_ui_group/NodeUIGroupDemo.svelte | 2 +- .../NodeUINestedGroupDemo.svelte | 4 +- docs/snapline/design/current-architecture.md | 122 ++++++++-------- .../design/ownership-specification.md | 22 +-- docs/snapline/guides/01_core_concepts.mdx | 6 +- .../guides/05_state_styling_accessibility.mdx | 2 +- .../snapline/guides/06_surface_connectors.mdx | 4 +- docs/snapline/introduction/01_setup.mdx | 10 +- docs/snapline/reference/react/group.mdx | 2 +- docs/snapline/reference/react/index.mdx | 2 +- docs/snapline/reference/svelte/index.mdx | 2 +- docs/snapline/reference/vanilla/index.mdx | 12 +- tests/ut/snapline-connector-config.spec.ts | 40 +++--- 45 files changed, 447 insertions(+), 455 deletions(-) diff --git a/assets/snapline/AGENTS.md b/assets/snapline/AGENTS.md index a3ee6eb..ff1d264 100644 --- a/assets/snapline/AGENTS.md +++ b/assets/snapline/AGENTS.md @@ -16,11 +16,11 @@ APIs directly rather than adding compatibility shims. **Dependencies:** `@snap-engine/core` **Exports:** -- `NodeComponent` - Graph node with connectors (opt-in eight-direction resize) -- `ConnectorComponent` - Input/output connector -- `LineComponent` - Visual connection line -- `GroupNodeComponent` - Resizable box that carries the nodes inside it -- `RectSelectComponent` - Rectangle selection tool +- `NodeMirror` - Graph node with connectors (opt-in eight-direction resize) +- `ConnectorMirror` - Input/output connector +- `LineMirror` - Visual connection line +- `GroupNodeMirror` - Resizable box that carries the nodes inside it +- `RectSelectController` - Rectangle selection tool - `PlacementController` - Headless pointer-follow placement state machine - `snapline-globals` - Typed accessors for the shared `global.data` registries @@ -54,10 +54,10 @@ snapline/ │ ├── tsconfig.json │ └── src/ │ ├── index.ts -│ ├── node.ts # NodeComponent -│ ├── connector.ts # ConnectorComponent -│ ├── line.ts # LineComponent -│ └── select.ts # RectSelectComponent +│ ├── node.ts # NodeMirror +│ ├── connector.ts # ConnectorMirror +│ ├── line.ts # LineMirror +│ └── select.ts # RectSelectController └── svelte/ ├── package.json ├── tsconfig.json @@ -71,7 +71,7 @@ snapline/ ## Core Classes -### NodeComponent +### NodeMirror **Extends:** `ElementObject` **Purpose:** Draggable graph node with input/output connectors @@ -87,7 +87,7 @@ snapline/ - `getProp(name)` - Get property value - `addSetPropCallback(callback, propName)` - React to property changes -### ConnectorComponent +### ConnectorMirror **Extends:** `BaseObject` **Purpose:** Connection point on a node @@ -102,7 +102,7 @@ snapline/ - Drag permissions - Connection callbacks -### LineComponent +### LineMirror **Extends:** `ElementObject` **Purpose:** Visual connection between connectors @@ -112,7 +112,7 @@ snapline/ - Start/end world coordinates - Callback-based rendering -### RectSelectComponent +### RectSelectController **Extends:** `ElementObject` **Purpose:** Rectangle selection tool @@ -129,7 +129,7 @@ snapline/ **Props:** - `className?: string` - CSS class - `LineSvelteComponent?: Component` - Custom line component -- `nodeObject?: NodeComponent` (bindable) - Node instance +- `nodeObject?: NodeMirror` (bindable) - Node instance **Slots:** - Default: Node content and connectors @@ -143,13 +143,13 @@ snapline/ - `allowDragOut: boolean` - Allow drag out **Methods:** -- `object(): ConnectorComponent` - Get underlying connector +- `object(): ConnectorMirror` - Get underlying connector ### Line.svelte **Purpose:** Renders connection path **Props:** -- `line: LineComponent` - Line instance +- `line: LineMirror` - Line instance **Features:** - SVG path rendering @@ -176,7 +176,7 @@ Concretely: connectors, then re-glue lines. `onSizeChange` is observational and `onResizeCommit` is the framework persistence boundary. - **Initial node geometry** is explicit: after assigning a committed framework - element, adapters call `syncDomGeometry()`. ResizeObserver remains the + element, adapters call `remeasureDomGeometry()`. ResizeObserver remains the ongoing invalidation path, not the initial-mount handshake. - **Line, selection, and placement geometry** use `bindGeometryWriter(...)`. Adapters mount static structure once; the writer @@ -226,7 +226,7 @@ missing lines with origin `"hydration"`) and translates gestures into semantic intents (`onEdgeConnect` for gesture connects, `onEdgeDisconnect` for gesture and replacement disconnects). Programmatic, hydration, and teardown changes never forward as intents, and sync never forwards its own -mutations (`#syncing` guard). Edges exist in exactly two representations — +mutations (`#reconciling` guard). Edges exist in exactly two representations — consumer document and rendered lines; `NodeManager` holds membership only and the controller stores no edges (`getEdges()` is consulted fresh). Intents fire synchronously inside the drop dispatch; adapters reconcile in a microtask of diff --git a/assets/snapline/core/README.md b/assets/snapline/core/README.md index 74853c8..2088a4e 100644 --- a/assets/snapline/core/README.md +++ b/assets/snapline/core/README.md @@ -26,14 +26,14 @@ npm install @snap-engine/core @snap-engine/snapline ```ts import { - GroupNodeComponent, - NodeComponent, + GroupNodeMirror, + NodeMirror, getParentGroup, setGroupMembershipResolver, } from "@snap-engine/snapline"; ``` -After assigning a Vanilla-rendered element, call `syncDomGeometry()`. Svelte +After assigning a Vanilla-rendered element, call `remeasureDomGeometry()`. Svelte and React adapters perform that synchronization automatically. Live gesture geometry stays outside framework state. Nodes and groups write diff --git a/assets/snapline/core/src/connector.ts b/assets/snapline/core/src/connector.ts index a63e7a7..b166667 100644 --- a/assets/snapline/core/src/connector.ts +++ b/assets/snapline/core/src/connector.ts @@ -8,8 +8,8 @@ import type { pointerUpProp, } from "@snap-engine/core"; import { CircleCollider } from "@snap-engine/core/collision"; -import type { NodeComponent } from "./node"; -import { LineComponent } from "./line"; +import type { NodeMirror } from "./node"; +import { LineMirror } from "./line"; import { getNodeManager } from "./snapline-globals"; import { getSourceSurfaces } from "./snapline-globals"; @@ -44,8 +44,8 @@ export interface ConnectorAnchor extends ConnectorPoint { /** Cached world-space bounds derived from the parent node's collision box. */ export interface ConnectorGeometrySnapshot { - connector: ConnectorComponent; - node: NodeComponent; + connector: ConnectorMirror; + node: NodeMirror; x: number; y: number; width: number; @@ -67,21 +67,21 @@ export interface ConnectorHit { } export interface ConnectorCandidate { - connector: ConnectorComponent; + connector: ConnectorMirror; hit: ConnectorHit; } export interface ConnectorSurfaceHitTestEvent { - connector: ConnectorComponent; + connector: ConnectorMirror; position: eventPosition; geometry: ConnectorGeometrySnapshot; phase: ConnectorLinePhase; } export interface ConnectorAnchorEvent { - connector: ConnectorComponent; - peer: ConnectorComponent | null; - line: LineComponent; + connector: ConnectorMirror; + peer: ConnectorMirror | null; + line: LineMirror; role: ConnectorRole; phase: ConnectorLinePhase; position: ConnectorPoint; @@ -111,22 +111,22 @@ export interface ConnectorCapabilities { } export interface ConnectorPairEvent { - source: ConnectorComponent; - target: ConnectorComponent; + source: ConnectorMirror; + target: ConnectorMirror; } export interface ConnectorCandidateEvent { - source: ConnectorComponent; + source: ConnectorMirror; /** Legacy convenience reference. */ - candidate: ConnectorComponent | null; + candidate: ConnectorMirror | null; resolvedCandidate: ConnectorCandidate | null; - line: LineComponent | null; + line: LineMirror | null; } export interface ConnectorConnectionEvent extends ConnectorPairEvent { - connector: ConnectorComponent; - peer: ConnectorComponent; - line: LineComponent; + connector: ConnectorMirror; + peer: ConnectorMirror; + line: LineMirror; role: ConnectorRole; origin: ConnectionOrigin; } @@ -137,7 +137,7 @@ export interface ConnectorDisconnectionEvent } export interface ConnectorDragEvent { - connector: ConnectorComponent; + connector: ConnectorMirror; position: eventPosition; pointerId: number; } @@ -147,7 +147,7 @@ export interface ConnectorPointerEvent extends ConnectorDragEvent { } export interface ConnectorConnectionRequestEvent extends ConnectorPairEvent { - line: LineComponent; + line: LineMirror; candidate: ConnectorCandidate; position: eventPosition; } @@ -186,7 +186,7 @@ export interface ConnectorConfig { allowDragOut?: boolean; capabilities?: Partial; surfaceStrategies?: readonly ConnectorSurfaceStrategy[]; - lineClass?: typeof LineComponent; + lineClass?: typeof LineMirror; colliderRadius?: number; metadata?: SnapLineMetadata; callbacks?: ConnectorCallbacks; @@ -206,22 +206,22 @@ interface ArmedConnection { pointerId: number; sourceHit: ConnectorHit | null; sourceStrategy: ConnectorSurfaceStrategy | null; - reconnectLine: LineComponent | null; + reconnectLine: LineMirror | null; } -class ConnectorComponent extends ElementObject { +class ConnectorMirror extends ElementObject { #config: ConnectorConfig; #capabilities: Readonly; #name: string; #prop: { [key: string]: any }; - #outgoingLines: LineComponent[]; - #incomingLines: LineComponent[]; + #outgoingLines: LineMirror[]; + #incomingLines: LineMirror[]; #state: ConnectorState = ConnectorState.IDLE; #hitCircle: CircleCollider; - #targetConnector: ConnectorComponent | null = null; + #targetConnector: ConnectorMirror | null = null; #candidate: ConnectorResolvedHit | null = null; - #dragLine: LineComponent | null = null; + #dragLine: LineMirror | null = null; #edgePanPointerId: number | null = null; #localCenter: ConnectorPoint; #hasMeasuredCenter = false; @@ -229,8 +229,8 @@ class ConnectorComponent extends ElementObject { #cancelledPointers = new Set(); #callbacks: ConnectorCallbacks; - get parent(): NodeComponent { - return super.parent as NodeComponent; + get parent(): NodeMirror { + return super.parent as NodeMirror; } set parent(parent: BaseObject | null) { @@ -239,7 +239,7 @@ class ConnectorComponent extends ElementObject { constructor( engine: any, - parent: NodeComponent, + parent: NodeMirror, config: ConnectorConfig = {}, ) { super(engine, parent as unknown as BaseObject); @@ -314,19 +314,19 @@ class ConnectorComponent extends ElementObject { this.updateConfig({ callbacks }); } - get outgoingLines(): LineComponent[] { + get outgoingLines(): LineMirror[] { return this.#outgoingLines; } - get incomingLines(): LineComponent[] { + get incomingLines(): LineMirror[] { return this.#incomingLines; } - get targetConnector(): ConnectorComponent | null { + get targetConnector(): ConnectorMirror | null { return this.#targetConnector; } - set targetConnector(value: ConnectorComponent | null) { + set targetConnector(value: ConnectorMirror | null) { const resolved = value ? { candidate: { @@ -587,7 +587,7 @@ class ConnectorComponent extends ElementObject { deleteLine( i: number, reason: DisconnectReason = "programmatic", - ): LineComponent | null { + ): LineMirror | null { if (this.#outgoingLines.length === 0 || i < 0) return null; const line = this.#outgoingLines[i]; if (!line) return null; @@ -637,7 +637,7 @@ class ConnectorComponent extends ElementObject { } } - assignToNode(parent: NodeComponent): void { + assignToNode(parent: NodeMirror): void { this.parent = parent; const parentRef = this.parent; parentRef._prop[this.#name] = null; @@ -650,22 +650,14 @@ class ConnectorComponent extends ElementObject { } } - createLine(): LineComponent { + createLine(): LineMirror { const line = this.#config.lineClass ? new this.#config.lineClass(this.engine, this) - : new LineComponent(this.engine, this); + : new LineMirror(this.engine, this); line.setSourceSurfaceContext(this.#defaultAnchorStrategy(), null); return line; } - /** @deprecated Pointer-down now only arms; retained for source compatibility. */ - startDragOutLine(prop: pointerDownProp): void { - this.armSurfaceGesture( - prop, - this.#resolveOwnSourceHit(prop.position, "source-start"), - ); - } - findClosestConnector(): void { if (!this.#dragLine) { this.#setCandidate(null); @@ -683,7 +675,7 @@ class ConnectorComponent extends ElementObject { findClosestConnectorAtPoint( position: ConnectorPoint, - ): ConnectorComponent | null { + ): ConnectorMirror | null { return this.findCandidateAtPoint(position)?.connector ?? null; } @@ -701,7 +693,7 @@ class ConnectorComponent extends ElementObject { return this.#resolveOwnSourceHit(position, "source-start"); } - canConnectToConnector(connector: ConnectorComponent): boolean { + canConnectToConnector(connector: ConnectorMirror): boolean { if ( connector.id === this.id || !this.#capabilities.source || @@ -768,9 +760,9 @@ class ConnectorComponent extends ElementObject { } hoverWhileDragging( - targetConnector: ConnectorComponent, + targetConnector: ConnectorMirror, ): [number, number] | void { - if (!(targetConnector instanceof ConnectorComponent) || !this.#dragLine) { + if (!(targetConnector instanceof ConnectorMirror) || !this.#dragLine) { return; } const anchor = targetConnector.resolveAnchor({ @@ -825,7 +817,7 @@ class ConnectorComponent extends ElementObject { } if (request !== false) { const connectionOptions: Parameters< - ConnectorComponent["connectToConnector"] + ConnectorMirror["connectToConnector"] >[0] = { target: candidate.candidate.connector, line, @@ -864,7 +856,7 @@ class ConnectorComponent extends ElementObject { this.#resetGesture(); } - startPickUpLine(line: LineComponent, prop: pointerDownProp): void { + startPickUpLine(line: LineMirror, prop: pointerDownProp): void { this.engine.input.setPointerDragOwner(prop.event.pointerId, line.start); line.start.#arm(prop, { sourceHit: null, @@ -874,8 +866,8 @@ class ConnectorComponent extends ElementObject { } connectToConnector(options: { - target: ConnectorComponent; - line?: LineComponent | null; + target: ConnectorMirror; + line?: LineMirror | null; origin?: ConnectionOrigin; payload?: unknown; candidate?: ConnectorResolvedHit | null; @@ -957,7 +949,7 @@ class ConnectorComponent extends ElementObject { } disconnectFromConnector( - connector: ConnectorComponent, + connector: ConnectorMirror, reason: DisconnectReason = "programmatic", ): void { const lineIndex = this.#outgoingLines.findIndex( @@ -975,10 +967,10 @@ class ConnectorComponent extends ElementObject { hit, strategy, }: { - line: LineComponent; + line: LineMirror; role: ConnectorRole; phase: ConnectorLinePhase; - peer: ConnectorComponent | null; + peer: ConnectorMirror | null; position: ConnectorPoint; hit: ConnectorHit | null; strategy: ConnectorSurfaceStrategy | null; @@ -1132,7 +1124,7 @@ class ConnectorComponent extends ElementObject { }); } - #detachLineForReconnect(line: LineComponent): void { + #detachLineForReconnect(line: LineMirror): void { const target = line.target; if (!target) return; target.#incomingLines = target.#incomingLines.filter( @@ -1143,7 +1135,7 @@ class ConnectorComponent extends ElementObject { } #discardDraggedLine( - line: LineComponent, + line: LineMirror, prop: dragEndProp, connected: boolean, ): void { @@ -1203,13 +1195,13 @@ class ConnectorComponent extends ElementObject { return this.element != null || this.#hasMeasuredCenter; } - #liveIncomingLines(): LineComponent[] { + #liveIncomingLines(): LineMirror[] { return this.#incomingLines.filter((line) => !line.isDeleteRequested); } #emitConnect( - target: ConnectorComponent, - line: LineComponent, + target: ConnectorMirror, + line: LineMirror, origin: ConnectionOrigin, ): void { this.#callbacks.onConnect?.({ @@ -1242,8 +1234,8 @@ class ConnectorComponent extends ElementObject { } #emitDisconnect( - target: ConnectorComponent, - line: LineComponent, + target: ConnectorMirror, + line: LineMirror, reason: DisconnectReason, ): void { this.#callbacks.onDisconnect?.({ @@ -1353,11 +1345,11 @@ function pickResolvedHit( export function resolveConnectorSourceAtPoint( engine: any, position: eventPosition, - node?: NodeComponent, + node?: NodeMirror, ): ConnectorResolvedHit | null { const hits: ConnectorResolvedHit[] = []; for (const surface of getSourceSurfaces(engine.global)) { - const connector = surface as ConnectorComponent; + const connector = surface as ConnectorMirror; if ( connector.engine !== engine || (node && connector.parent !== node) || @@ -1371,13 +1363,13 @@ export function resolveConnectorSourceAtPoint( return pickResolvedHit(hits); } -function registeredConnectors(engine: any): ConnectorComponent[] { +function registeredConnectors(engine: any): ConnectorMirror[] { const objectTable = engine.global?.getEngineObjectTable?.(engine); if (!objectTable) return []; return Object.values(objectTable).filter( - (object): object is ConnectorComponent => - object instanceof ConnectorComponent && !object.isDeleteRequested, + (object): object is ConnectorMirror => + object instanceof ConnectorMirror && !object.isDeleteRequested, ); } -export { ConnectorComponent }; +export { ConnectorMirror }; diff --git a/assets/snapline/core/src/edge-sync.ts b/assets/snapline/core/src/edge-sync.ts index d517162..2279715 100644 --- a/assets/snapline/core/src/edge-sync.ts +++ b/assets/snapline/core/src/edge-sync.ts @@ -1,9 +1,9 @@ import type { - ConnectorComponent, + ConnectorMirror, ConnectorConnectionEvent, ConnectorDisconnectionEvent, } from "./connector"; -import type { LineComponent } from "./line"; +import type { LineMirror } from "./line"; import { getNodeManager } from "./snapline-globals"; export interface EdgeEndpoint { @@ -19,18 +19,18 @@ export interface EdgeLike { export interface EdgeConnectIntentEvent { from: EdgeEndpoint; to: EdgeEndpoint; - source: ConnectorComponent; - target: ConnectorComponent; - line: LineComponent; + source: ConnectorMirror; + target: ConnectorMirror; + line: LineMirror; origin: "gesture"; } export interface EdgeDisconnectIntentEvent { from: EdgeEndpoint; to: EdgeEndpoint; - source: ConnectorComponent; - target: ConnectorComponent; - line: LineComponent; + source: ConnectorMirror; + target: ConnectorMirror; + line: LineMirror; reason: "gesture" | "replacement"; } @@ -46,7 +46,7 @@ export interface EdgeSyncConfig { // Maps a connector to its semantic endpoint, or null for connectors that // are not part of the consumer's edge document (their lines are left // untouched by sync and never produce intents). - identity: (connector: ConnectorComponent) => EdgeEndpoint | null; + identity: (connector: ConnectorMirror) => EdgeEndpoint | null; // The consumer's current edge list — the single source of truth. Consulted // fresh on every sync; the controller never stores edges. getEdges: () => readonly EdgeLike[]; @@ -78,8 +78,8 @@ function edgeKey(from: EdgeEndpoint, to: EdgeEndpoint): string { // own mutations. export class EdgeSyncController { #config: EdgeSyncConfig; - #syncing = false; - #syncQueued = false; + #reconciling = false; + #reconciliationQueued = false; #disposed = false; constructor(config: EdgeSyncConfig) { @@ -107,10 +107,10 @@ export class EdgeSyncController { // controller exists (a node mounted). Coalesced into one microtask so a // mounting batch reconciles once, before the frame paints. connectorRegistered(): void { - if (this.#syncQueued) return; - this.#syncQueued = true; + if (this.#reconciliationQueued) return; + this.#reconciliationQueued = true; queueMicrotask(() => { - this.#syncQueued = false; + this.#reconciliationQueued = false; if (!this.#disposed) this.sync(); }); } @@ -120,15 +120,15 @@ export class EdgeSyncController { // endpoint without identity) are left alone, and mutations made here never // forward as intents. sync(): void { - if (this.#syncing) return; - this.#syncing = true; + if (this.#reconciling) return; + this.#reconciling = true; try { const manager = getNodeManager(this.#config.engine); const identity = this.#config.identity; const connectors = manager.connectors; - const identities = new Map(); - const byKey = new Map(); + const identities = new Map(); + const byKey = new Map(); for (const connector of connectors) { const endpoint = identity(connector); identities.set(connector, endpoint); @@ -170,13 +170,13 @@ export class EdgeSyncController { from.connectToConnector({ target: to, origin: "hydration" }); } } finally { - this.#syncing = false; + this.#reconciling = false; } } - // @internal Called from ConnectorComponent's emit sites. + // @internal Called from ConnectorMirror's emit sites. notifyConnect(event: ConnectorConnectionEvent): void { - if (this.#syncing || event.origin !== "gesture") return; + if (this.#reconciling || event.origin !== "gesture") return; const endpoints = this.#endpoints(event.source, event.target); if (!endpoints) return; this.callbacks.onEdgeConnect?.({ @@ -186,9 +186,9 @@ export class EdgeSyncController { }); } - // @internal Called from ConnectorComponent's emit sites. + // @internal Called from ConnectorMirror's emit sites. notifyDisconnect(event: ConnectorDisconnectionEvent): void { - if (this.#syncing) { + if (this.#reconciling) { if (event.reason === "replacement") { console.warn( "SnapLine EdgeSync: sync evicted a live line via replacement — " + @@ -208,13 +208,13 @@ export class EdgeSyncController { } #endpoints( - source: ConnectorComponent, - target: ConnectorComponent, + source: ConnectorMirror, + target: ConnectorMirror, ): { from: EdgeEndpoint; to: EdgeEndpoint; - source: ConnectorComponent; - target: ConnectorComponent; + source: ConnectorMirror; + target: ConnectorMirror; } | null { // ConnectorPairEvent's source/target are already in wire direction. const from = this.#config.identity(source); diff --git a/assets/snapline/core/src/group.ts b/assets/snapline/core/src/group.ts index 16fa9c8..6ce9414 100644 --- a/assets/snapline/core/src/group.ts +++ b/assets/snapline/core/src/group.ts @@ -3,7 +3,7 @@ import type { Engine, eventPosition, } from "@snap-engine/core"; -import { NodeComponent, mergeConfig, type NodeConfig } from "./node"; +import { NodeMirror, mergeConfig, type NodeConfig } from "./node"; import { getGroups, snapData } from "./snapline-globals"; export interface GroupConfig extends NodeConfig { @@ -15,18 +15,18 @@ export interface GroupConfig extends NodeConfig { } export interface GroupContainEvent { - group: GroupNodeComponent; - node: NodeComponent; + group: GroupNodeMirror; + node: NodeMirror; centerContained: boolean; boundsContained: boolean; } export interface GroupMembershipEvent { - group: GroupNodeComponent; - added: readonly NodeComponent[]; - removed: readonly NodeComponent[]; + group: GroupNodeMirror; + added: readonly NodeMirror[]; + removed: readonly NodeMirror[]; /** Direct members only. Use `group.descendants` for the complete subtree. */ - members: readonly NodeComponent[]; + members: readonly NodeMirror[]; } export interface GroupCallbacks { @@ -34,15 +34,15 @@ export interface GroupCallbacks { } export interface GroupMembershipResolutionEvent { - node: NodeComponent; + node: NodeMirror; /** Safe eligible candidates, ordered from innermost to outermost. */ - candidates: readonly GroupNodeComponent[]; - defaultParent: GroupNodeComponent | null; + candidates: readonly GroupNodeMirror[]; + defaultParent: GroupNodeMirror | null; } export type GroupMembershipResolver = ( event: GroupMembershipResolutionEvent, -) => GroupNodeComponent | null; +) => GroupNodeMirror | null; const DEFAULT_GROUP_CONFIG = { width: 400, @@ -51,12 +51,12 @@ const DEFAULT_GROUP_CONFIG = { minHeight: 120, } satisfies GroupConfig; -const parentGroups = new WeakMap(); +const parentGroups = new WeakMap(); const membershipResolvers = new WeakMap(); const reconcilingEngines = new WeakSet(); type Bounds = ReturnType< - NodeComponent["hitBox"]["getWorldBoundsSnapshot"] + NodeMirror["hitBox"]["getWorldBoundsSnapshot"] >; function boundsArea(bounds: Bounds): number { @@ -74,8 +74,8 @@ function containsBounds(container: Bounds, child: Bounds): boolean { } function stableGroupOrder( - left: GroupNodeComponent, - right: GroupNodeComponent, + left: GroupNodeMirror, + right: GroupNodeMirror, ): number { const areaDelta = boundsArea(left.hitBox.getWorldBoundsSnapshot()) - @@ -83,26 +83,26 @@ function stableGroupOrder( return areaDelta || String(left.id).localeCompare(String(right.id)); } -function groupsForEngine(group: GroupNodeComponent): GroupNodeComponent[] { +function groupsForEngine(group: GroupNodeMirror): GroupNodeMirror[] { return getGroups(group.global).filter( - (candidate): candidate is GroupNodeComponent => - candidate instanceof GroupNodeComponent && + (candidate): candidate is GroupNodeMirror => + candidate instanceof GroupNodeMirror && candidate.engine === group.engine, ); } -function nodesForEngine(group: GroupNodeComponent): NodeComponent[] { +function nodesForEngine(group: GroupNodeMirror): NodeMirror[] { const table = group.global.getEngineObjectTable(group.engine); return Object.values(table).filter( - (object): object is NodeComponent => object instanceof NodeComponent, + (object): object is NodeMirror => object instanceof NodeMirror, ); } function resolveParent( - node: NodeComponent, - candidates: GroupNodeComponent[], + node: NodeMirror, + candidates: GroupNodeMirror[], engine: object, -): GroupNodeComponent | null { +): GroupNodeMirror | null { candidates.sort(stableGroupOrder); const defaultParent = candidates[0] ?? null; const resolver = membershipResolvers.get(engine); @@ -119,12 +119,12 @@ function resolveParent( } function wouldCreateGroupCycle( - node: GroupNodeComponent, - parent: GroupNodeComponent, - nextParents: Map, + node: GroupNodeMirror, + parent: GroupNodeMirror, + nextParents: Map, ): boolean { - let ancestor: GroupNodeComponent | undefined = parent; - const visited = new Set(); + let ancestor: GroupNodeMirror | undefined = parent; + const visited = new Set(); while (ancestor && !visited.has(ancestor)) { if (ancestor === node) return true; visited.add(ancestor); @@ -134,7 +134,7 @@ function wouldCreateGroupCycle( } function reconcileMembership( - source: GroupNodeComponent, + source: GroupNodeMirror, fireDelta: boolean, ): void { const engine = source.engine as object; @@ -144,15 +144,15 @@ function reconcileMembership( try { const groups = groupsForEngine(source); const nextMembers = new Map< - GroupNodeComponent, - Set + GroupNodeMirror, + Set >(groups.map((group) => [group, new Set()])); - const nextParents = new Map(); + const nextParents = new Map(); const nodes = nodesForEngine(source); const groupNodes = [...groups].sort(stableGroupOrder); const ordinaryNodes = nodes.filter( - (node) => !(node instanceof GroupNodeComponent), + (node) => !(node instanceof GroupNodeMirror), ); // Resolve the group forest first. A proposed edge can point at a group that @@ -185,7 +185,7 @@ function reconcileMembership( const deltas = groups.map((group) => { const previous = group.members; - const next = nextMembers.get(group) ?? new Set(); + const next = nextMembers.get(group) ?? new Set(); return { group, next, @@ -219,8 +219,8 @@ function reconcileMembership( /** Return the node's settled, exclusive direct parent group. */ export function getParentGroup( - node: NodeComponent, -): GroupNodeComponent | null { + node: NodeMirror, +): GroupNodeMirror | null { return parentGroups.get(node) ?? null; } @@ -237,8 +237,8 @@ export function setGroupMembershipResolver( const global = engine.global; const source = global ? getGroups(global).find( - (group): group is GroupNodeComponent => - group instanceof GroupNodeComponent && group.engine === engine, + (group): group is GroupNodeMirror => + group instanceof GroupNodeMirror && group.engine === engine, ) : undefined; source?.refreshMembership(true); @@ -254,10 +254,10 @@ export function setGroupMembershipResolver( // A resizable box with settled geometric membership. Membership is exclusive: // each node has one direct parent, while nested groups form a recursive tree. -class GroupNodeComponent extends NodeComponent { - #members: Set = new Set(); - #carry: NodeComponent[] = []; - #carryOrigins = new Map(); +class GroupNodeMirror extends NodeMirror { + #members: Set = new Set(); + #carry: NodeMirror[] = []; + #carryOrigins = new Map(); #carryGroupOrigin = { x: 0, y: 0 }; #groupCallbacks: GroupCallbacks; #groupConfig: GroupConfig; @@ -278,30 +278,30 @@ class GroupNodeComponent extends NodeComponent { } /** Direct settled members. */ - get members(): ReadonlySet { + get members(): ReadonlySet { return this.#members; } /** Every settled member below this group, recursively and without duplicates. */ - get descendants(): ReadonlySet { - const result = new Set(); - const visit = (group: GroupNodeComponent): void => { + get descendants(): ReadonlySet { + const result = new Set(); + const visit = (group: GroupNodeMirror): void => { for (const member of group.#members) { if (result.has(member)) continue; result.add(member); - if (member instanceof GroupNodeComponent) visit(member); + if (member instanceof GroupNodeMirror) visit(member); } }; visit(this); return result; } - get parentGroup(): GroupNodeComponent | null { + get parentGroup(): GroupNodeMirror | null { return getParentGroup(this); } /** @internal Used by the engine-wide exclusive-membership reconciliation. */ - setResolvedMembers(members: Set): void { + setResolvedMembers(members: Set): void { this.#members = members; } @@ -309,7 +309,7 @@ class GroupNodeComponent extends NodeComponent { super.writeTransformAndLines(); } - allowsMembership(node: NodeComponent): boolean { + allowsMembership(node: NodeMirror): boolean { const box = this.hitBox.getWorldBoundsSnapshot(); const nodeBounds = node.hitBox.getWorldBoundsSnapshot(); const centerContained = @@ -322,7 +322,7 @@ class GroupNodeComponent extends NodeComponent { // Ordinary nodes use center containment. A nested group must fit completely // so partially overlapping peers cannot become a parent/child pair. if ( - node instanceof GroupNodeComponent ? !boundsContained : !centerContained + node instanceof GroupNodeMirror ? !boundsContained : !centerContained ) { return false; } @@ -363,11 +363,11 @@ class GroupNodeComponent extends NodeComponent { } } - containsSelectionDragNode(node: NodeComponent): boolean { + containsSelectionDragNode(node: NodeMirror): boolean { return this.descendants.has(node); } - selectionDragNodes(): NodeComponent[] { + selectionDragNodes(): NodeMirror[] { return [...new Set([this, ...this.#carry])]; } @@ -399,12 +399,12 @@ class GroupNodeComponent extends NodeComponent { parentGroups.delete(this); const remaining = getGroups(this.global).find( - (group): group is GroupNodeComponent => - group instanceof GroupNodeComponent && group.engine === this.engine, + (group): group is GroupNodeMirror => + group instanceof GroupNodeMirror && group.engine === this.engine, ); remaining?.refreshMembership(true); super.destroy(removeElement); } } -export { GroupNodeComponent }; +export { GroupNodeMirror }; diff --git a/assets/snapline/core/src/index.ts b/assets/snapline/core/src/index.ts index c52c06c..523f74c 100644 --- a/assets/snapline/core/src/index.ts +++ b/assets/snapline/core/src/index.ts @@ -1,5 +1,5 @@ export { - NodeComponent, + NodeMirror, DEFAULT_RESIZE_CURSORS, DEFAULT_RESIZE_HANDLE_THICKNESS, RESIZE_HANDLES, @@ -20,7 +20,7 @@ export type { ResizeHandle, SelectionMode, } from "./node"; -export { ConnectorComponent, resolveConnectorSourceAtPoint } from "./connector"; +export { ConnectorMirror, resolveConnectorSourceAtPoint } from "./connector"; export type { ConnectionOrigin, ConnectorAnchor, @@ -50,14 +50,14 @@ export type { DisconnectReason, SnapLineMetadata, } from "./connector"; -export { LineComponent } from "./line"; +export { LineMirror } from "./line"; export type { LineGeometrySnapshot, LineStateSnapshot, } from "./line"; export type { GeometryWriter } from "./geometry"; export { - GroupNodeComponent, + GroupNodeMirror, getParentGroup, setGroupMembershipResolver, } from "./group"; @@ -69,7 +69,7 @@ export type { GroupMembershipResolutionEvent, GroupMembershipResolver, } from "./group"; -export { RectSelectComponent } from "./select"; +export { RectSelectController } from "./select"; export type { SelectCallbacks, SelectChangeEvent, diff --git a/assets/snapline/core/src/line.ts b/assets/snapline/core/src/line.ts index 127acbe..f3b2078 100644 --- a/assets/snapline/core/src/line.ts +++ b/assets/snapline/core/src/line.ts @@ -2,7 +2,7 @@ import { ElementObject, BaseObject } from "@snap-engine/core"; import type { ConnectorAnchor, ConnectorCandidate, - ConnectorComponent, + ConnectorMirror, ConnectorHit, ConnectorLinePhase, ConnectorPoint, @@ -18,17 +18,17 @@ export interface LineGeometrySnapshot { export interface LineStateSnapshot { readonly phase: ConnectorLinePhase; - readonly target: ConnectorComponent | null; - readonly candidate: ConnectorComponent | null; + readonly target: ConnectorMirror | null; + readonly candidate: ConnectorMirror | null; readonly payload: unknown; } -class LineComponent extends ElementObject { +class LineMirror extends ElementObject { endWorldX: number; endWorldY: number; - start: ConnectorComponent; - target: ConnectorComponent | null; + start: ConnectorMirror; + target: ConnectorMirror | null; payload: unknown; startAnchor: ConnectorAnchor; endAnchor: ConnectorAnchor; @@ -49,7 +49,7 @@ class LineComponent extends ElementObject { this.endWorldX = 0; this.endWorldY = 0; - this.start = parent as unknown as ConnectorComponent; + this.start = parent as unknown as ConnectorMirror; this.target = null; this.payload = undefined; this.startAnchor = { x: 0, y: 0 }; @@ -138,7 +138,7 @@ class LineComponent extends ElementObject { } connectTarget( - target: ConnectorComponent, + target: ConnectorMirror, candidate: ConnectorCandidate | null = this.candidate, strategy: ConnectorSurfaceStrategy | null = this.#targetStrategy, ): void { @@ -286,4 +286,4 @@ function cloneAnchor(anchor: ConnectorAnchor): ConnectorAnchor { }; } -export { LineComponent }; +export { LineMirror }; diff --git a/assets/snapline/core/src/node-manager.ts b/assets/snapline/core/src/node-manager.ts index b45ce45..8d554f6 100644 --- a/assets/snapline/core/src/node-manager.ts +++ b/assets/snapline/core/src/node-manager.ts @@ -1,9 +1,9 @@ import type { - ConnectorComponent, + ConnectorMirror, ConnectorConnectionEvent, ConnectorDisconnectionEvent, } from "./connector"; -import type { NodeComponent } from "./node"; +import type { NodeMirror } from "./node"; // Structural stand-in for EdgeSyncController so the manager (and the emit // sites that reach it) never value-import edge-sync — edge-sync imports the @@ -28,8 +28,8 @@ export interface EdgeSyncLike { // today (`edgeSync`), layout helpers that need to walk `nodes` tomorrow. export class NodeManager { readonly engine: unknown; - #nodes = new Set(); - #connectors = new Set(); + #nodes = new Set(); + #connectors = new Set(); // Engine-scoped controlled-edges controller. Registered by // EdgeSyncController's constructor; the connector emit sites forward @@ -40,30 +40,30 @@ export class NodeManager { this.engine = engine; } - registerNode(node: NodeComponent): void { + registerNode(node: NodeMirror): void { this.#nodes.add(node); } - unregisterNode(node: NodeComponent): void { + unregisterNode(node: NodeMirror): void { this.#nodes.delete(node); } - registerConnector(connector: ConnectorComponent): void { + registerConnector(connector: ConnectorMirror): void { this.#connectors.add(connector); this.edgeSync?.connectorRegistered?.(); } - unregisterConnector(connector: ConnectorComponent): void { + unregisterConnector(connector: ConnectorMirror): void { this.#connectors.delete(connector); } // Live nodes in registration order. Returns a copy, never internal state. - get nodes(): readonly NodeComponent[] { + get nodes(): readonly NodeMirror[] { return [...this.#nodes]; } // Live connectors in registration order. Returns a copy. - get connectors(): readonly ConnectorComponent[] { + get connectors(): readonly ConnectorMirror[] { return [...this.#connectors]; } } diff --git a/assets/snapline/core/src/node.ts b/assets/snapline/core/src/node.ts index 006effd..bcbbf0e 100644 --- a/assets/snapline/core/src/node.ts +++ b/assets/snapline/core/src/node.ts @@ -1,9 +1,9 @@ import { BaseObject, ElementObject } from "@snap-engine/core"; import { - ConnectorComponent, + ConnectorMirror, resolveConnectorSourceAtPoint, } from "./connector"; -import { LineComponent } from "./line"; +import { LineMirror } from "./line"; import type { pointerUpProp, pointerDownProp, @@ -92,13 +92,13 @@ export function mergeConfig(defaults: T, config: Partial): /** Consumer policy and lifecycle surfaces. Callbacks receive event objects so * new context can be added without growing positional signatures. */ export interface NodePosition { - node: NodeComponent; + node: NodeMirror; x: number; y: number; } export interface NodePointerEvent { - node: NodeComponent; + node: NodeMirror; pointerId: number; position: eventPosition; originalEvent?: PointerEvent; @@ -109,7 +109,7 @@ export interface NodeDragCommitEvent extends NodePointerEvent { } export interface NodeDragPositionEvent { - node: NodeComponent; + node: NodeMirror; x: number; y: number; startX: number; @@ -123,7 +123,7 @@ export interface ResolvedNodeDragPosition { } export interface NodeResizeEvent { - node: NodeComponent; + node: NodeMirror; handle: ResizeHandle | null; x: number; y: number; @@ -132,29 +132,29 @@ export interface NodeResizeEvent { } export interface NodeResizeHandleEvent { - node: NodeComponent; + node: NodeMirror; handle: ResizeHandle | null; cursor: string | null; } export interface NodeSelectionEvent { - node: NodeComponent; + node: NodeMirror; selected: boolean; - selection: readonly NodeComponent[]; + selection: readonly NodeMirror[]; } export type SelectionMode = "replace" | "add" | "toggle"; export interface NodeSelectionModeEvent { - node: NodeComponent; + node: NodeMirror; selected: boolean; - selection: readonly NodeComponent[]; + selection: readonly NodeMirror[]; originalEvent: PointerEvent; } export interface NodeLinesEvent { - node: NodeComponent; - lines: readonly LineComponent[]; + node: NodeMirror; + lines: readonly LineMirror[]; } export interface NodeCallbacks { @@ -186,7 +186,7 @@ class ResizeHandleCollider extends RectCollider { constructor( engine: any, - parent: NodeComponent, + parent: NodeMirror, handle: ResizeHandle, cursor: string, ) { @@ -198,7 +198,7 @@ class ResizeHandleCollider extends RectCollider { class ResizeHoverController extends BaseObject { #count = 0; - #node: NodeComponent | null = null; + #node: NodeMirror | null = null; #handle: ResizeHandleCollider | null = null; #target: HTMLElement | null = null; #previousNodeCursor = ""; @@ -214,7 +214,7 @@ class ResizeHoverController extends BaseObject { this.#count++; } - release(node: NodeComponent): void { + release(node: NodeMirror): void { this.#count--; if (this.#node === node) this.clear(); if (this.#count <= 0) { @@ -224,7 +224,7 @@ class ResizeHoverController extends BaseObject { } activate(handle: ResizeHandleCollider, target?: EventTarget | null): void { - const node = handle.parent as NodeComponent; + const node = handle.parent as NodeMirror; const element = target instanceof HTMLElement ? target : null; if (this.#handle === handle && this.#target === element) return; this.#restoreCss(); @@ -290,7 +290,7 @@ function hoverController(engine: any): ResizeHoverController { function findResizeHandle( engine: any, position: eventPosition, - node?: NodeComponent, + node?: NodeMirror, ): ResizeHandleCollider | null { let winner: ResizeHandleCollider | null = null; for (const collider of getResizeHandles(engine.global)) { @@ -304,9 +304,9 @@ function findResizeHandle( return winner; } -class NodeComponent extends ElementObject { +class NodeMirror extends ElementObject { #config: Required; - _connectors: { [key: string]: ConnectorComponent }; + _connectors: { [key: string]: ConnectorMirror }; _components: { [key: string]: ElementObject }; _dragStartX = 0; _dragStartY = 0; @@ -323,7 +323,7 @@ class NodeComponent extends ElementObject { #resizeHandleThickness: number; #resizeHoverController: ResizeHoverController | null = null; #activeResizeHandle: ResizeHandle | null = null; - /** Read by GroupNodeComponent to distinguish a resize from a move drag. */ + /** Read by GroupNodeMirror to distinguish a resize from a move drag. */ protected _resizing = false; #resizeArmed = false; #resizeStartW = 0; @@ -334,8 +334,8 @@ class NodeComponent extends ElementObject { #edgePanPointerId: number | null = null; #dragHandles = new Set(); #dragPointerId: number | null = null; - #dragRoots: NodeComponent[] = []; - #dragCommitNodes: NodeComponent[] = []; + #dragRoots: NodeMirror[] = []; + #dragCommitNodes: NodeMirror[] = []; #lastDragPosition: eventPosition | null = null; #pointerSelectionMode: SelectionMode = "replace"; #selectedAtPointerDown = false; @@ -399,7 +399,7 @@ class NodeComponent extends ElementObject { this._hasMoved = false; // Whenever the DOM box changes size (ResizeObserver) re-measure + re-glue. - this.event.dom.onResize = () => this.syncDomGeometry(); + this.event.dom.onResize = () => this.remeasureDomGeometry(); // Base positioning styles are framework-owned: adapters must render the // element with `position: absolute; transform-origin: top left` (see the @@ -476,7 +476,7 @@ class NodeComponent extends ElementObject { }); } - _filterDeletedLines(svgLines: LineComponent[]) { + _filterDeletedLines(svgLines: LineMirror[]) { for (let i = 0; i < svgLines.length; i++) { if (svgLines[i].isDeleteRequested) { svgLines.splice(i, 1); @@ -504,12 +504,12 @@ class NodeComponent extends ElementObject { } } - #transformNodeTree(): NodeComponent[] { - const nodes: NodeComponent[] = []; - const visit = (node: NodeComponent) => { + #transformNodeTree(): NodeMirror[] { + const nodes: NodeMirror[] = []; + const visit = (node: NodeMirror) => { nodes.push(node); for (const child of node.transformChildren) { - if (child instanceof NodeComponent) visit(child); + if (child instanceof NodeMirror) visit(child); } }; visit(this); @@ -530,7 +530,7 @@ class NodeComponent extends ElementObject { // but resizing invalidates them, so they must be re-read. Shared by the // ResizeObserver and the JS-driven setSize; stable queueIds collapse a // same-frame double-fire (idempotent when it runs twice across frames). - syncDomGeometry(): void { + remeasureDomGeometry(): void { if (!this.element) { throw new Error("Cannot sync node geometry before assigning its DOM element"); } @@ -673,7 +673,7 @@ class NodeComponent extends ElementObject { // Transform-only (re)parenting used by group carry: the public/DOM graph is // left alone, so members stay flat siblings in the adapter's node list. - attachTransformToGroup(group: NodeComponent): void { + attachTransformToGroup(group: NodeMirror): void { this.setTransformParent(group, true); } @@ -850,12 +850,12 @@ class NodeComponent extends ElementObject { } /** @internal Whether this node's drag behavior already carries `node`. */ - containsSelectionDragNode(_node: NodeComponent): boolean { + containsSelectionDragNode(_node: NodeMirror): boolean { return false; } /** @internal Nodes whose final positions belong to this drag root's commit. */ - selectionDragNodes(): NodeComponent[] { + selectionDragNodes(): NodeMirror[] { return [this]; } @@ -957,7 +957,7 @@ class NodeComponent extends ElementObject { }); } - protected getDragCommitNodes(): NodeComponent[] { + protected getDragCommitNodes(): NodeMirror[] { return this.#dragCommitNodes.length ? [...this.#dragCommitNodes] : [...getSelectList(this.global)]; @@ -1005,7 +1005,7 @@ class NodeComponent extends ElementObject { this._hasMoved = false; } - getConnector(name: string): ConnectorComponent | null { + getConnector(name: string): ConnectorMirror | null { if (!(name in this._connectors)) { console.error(`Connector ${name} does not exist in node ${this.id}`); return null; @@ -1013,7 +1013,7 @@ class NodeComponent extends ElementObject { return this._connectors[name]; } - addConnectorObject(connector: ConnectorComponent) { + addConnectorObject(connector: ConnectorMirror) { connector.assignToNode(this); } @@ -1021,13 +1021,13 @@ class NodeComponent extends ElementObject { this._propSetCallback[name] = callback; } - getAllOutgoingLines(): LineComponent[] { + getAllOutgoingLines(): LineMirror[] { return Object.values(this._connectors).flatMap( (connector) => connector.outgoingLines, ); } - getAllIncomingLines(): LineComponent[] { + getAllIncomingLines(): LineMirror[] { return Object.values(this._connectors).flatMap( (connector) => connector.incomingLines, ); @@ -1038,10 +1038,10 @@ class NodeComponent extends ElementObject { } setProp(name: string, value: any) { - const pending: Array<{ node: NodeComponent; name: string }> = [ + const pending: Array<{ node: NodeMirror; name: string }> = [ { node: this, name }, ]; - const visited = new Map>(); + const visited = new Map>(); while (pending.length > 0) { const current = pending.pop(); @@ -1070,7 +1070,7 @@ class NodeComponent extends ElementObject { const peer = peers[index]; if (!peer?.parent) continue; pending.push({ - node: peer.parent as NodeComponent, + node: peer.parent as NodeMirror, name: peer.name, }); } @@ -1115,4 +1115,4 @@ class NodeComponent extends ElementObject { } } -export { NodeComponent }; +export { NodeMirror }; diff --git a/assets/snapline/core/src/query.ts b/assets/snapline/core/src/query.ts index 5101c55..c507091 100644 --- a/assets/snapline/core/src/query.ts +++ b/assets/snapline/core/src/query.ts @@ -1,6 +1,6 @@ -import type { ConnectorComponent } from "./connector"; -import { GroupNodeComponent } from "./group"; -import type { NodeComponent } from "./node"; +import type { ConnectorMirror } from "./connector"; +import { GroupNodeMirror } from "./group"; +import type { NodeMirror } from "./node"; import { getNodeManager, getSelectList } from "./snapline-globals"; type EngineLike = { @@ -13,26 +13,26 @@ type EngineLike = { // register in their constructors), replacing the old engine-object-table // scans. Public signatures unchanged. -export function getNodes(engine: EngineLike): readonly NodeComponent[] { +export function getNodes(engine: EngineLike): readonly NodeMirror[] { return getNodeManager(engine).nodes; } export function getConnectors( engine: EngineLike, -): readonly ConnectorComponent[] { +): readonly ConnectorMirror[] { return getNodeManager(engine).connectors; } export function getGroupNodes( engine: EngineLike, -): readonly GroupNodeComponent[] { +): readonly GroupNodeMirror[] { return getNodeManager(engine).nodes.filter( - (node): node is GroupNodeComponent => node instanceof GroupNodeComponent, + (node): node is GroupNodeMirror => node instanceof GroupNodeMirror, ); } export function getSelectedNodes( engine: EngineLike, -): readonly NodeComponent[] { +): readonly NodeMirror[] { return [...getSelectList(engine.global)]; } diff --git a/assets/snapline/core/src/select.ts b/assets/snapline/core/src/select.ts index 71c08d5..ae11301 100644 --- a/assets/snapline/core/src/select.ts +++ b/assets/snapline/core/src/select.ts @@ -5,7 +5,7 @@ import type { pointerUpProp, } from "@snap-engine/core"; import { RectCollider, Collider } from "@snap-engine/core/collision"; -import { NodeComponent, type SelectionMode } from "./node"; +import { NodeMirror, type SelectionMode } from "./node"; import { getSelectList, snapData } from "./snapline-globals"; import type { GeometryWriter } from "./geometry"; @@ -19,14 +19,14 @@ export interface SelectRect { } export interface SelectStartEvent { - select: RectSelectComponent; + select: RectSelectController; position: { x: number; y: number }; originalEvent: PointerEvent; } export interface SelectChangeEvent { - select: RectSelectComponent; - selection: readonly NodeComponent[]; + select: RectSelectController; + selection: readonly NodeMirror[]; } export interface SelectCallbacks { @@ -46,7 +46,7 @@ export interface SelectConfig { callbacks?: SelectCallbacks; } -class RectSelectComponent extends ElementObject { +class RectSelectController extends ElementObject { _state: "none" | "dragging"; _mouseDownX: number; _mouseDownY: number; @@ -61,7 +61,7 @@ class RectSelectComponent extends ElementObject { visible: false, }; #selectionMode: SelectionMode = "replace"; - #baselineSelection = new Set(); + #baselineSelection = new Set(); constructor( engine: any, @@ -161,8 +161,8 @@ class RectSelectComponent extends ElementObject { _: Collider, otherObject: Collider, ) => { - if (otherObject.parent instanceof NodeComponent) { - let node = otherObject.parent as NodeComponent; + if (otherObject.parent instanceof NodeMirror) { + let node = otherObject.parent as NodeMirror; node.setSelected( this.#selectionMode === "toggle" ? !this.#baselineSelection.has(node) @@ -178,8 +178,8 @@ class RectSelectComponent extends ElementObject { _thisObject: Collider, otherObject: Collider, ) => { - if (otherObject.parent instanceof NodeComponent) { - let node = otherObject.parent as NodeComponent; + if (otherObject.parent instanceof NodeMirror) { + let node = otherObject.parent as NodeMirror; node.setSelected(this.#baselineSelection.has(node)); this.#callbacks.onSelectionChange?.({ select: this, @@ -219,4 +219,4 @@ class RectSelectComponent extends ElementObject { onCollideNode(_hitBox: Collider, _node: Collider): void {} } -export { RectSelectComponent }; +export { RectSelectController }; diff --git a/assets/snapline/core/src/snapline-globals.ts b/assets/snapline/core/src/snapline-globals.ts index 9eec984..242825d 100644 --- a/assets/snapline/core/src/snapline-globals.ts +++ b/assets/snapline/core/src/snapline-globals.ts @@ -1,10 +1,10 @@ import type { RectCollider } from "@snap-engine/core/collision"; import type { eventPosition } from "@snap-engine/core"; -import type { NodeComponent } from "./node"; +import type { NodeMirror } from "./node"; import { NodeManager } from "./node-manager"; /** - * Structural stand-in for GroupNodeComponent so node.ts can notify groups on + * Structural stand-in for GroupNodeMirror so node.ts can notify groups on * settle without importing the group module (no group→node import cycle). */ export interface GroupLike { @@ -44,7 +44,7 @@ export interface SourceSurfaceOwner { */ export interface SnapLineSharedData { /** Currently-selected nodes (multi-select drag moves all of them). */ - select?: NodeComponent[]; + select?: NodeMirror[]; /** All live groups; notified on any node's drop so membership stays settled. */ groups?: GroupLike[]; /** Registered resize hitboxes; input.ts routes pointerdowns over them. */ @@ -52,7 +52,7 @@ export interface SnapLineSharedData { /** Registered headless source surfaces; input.ts routes pointerdowns to them. */ sourceSurfaces?: SourceSurfaceOwner[]; /** The node mid-resize, so an unrelated pointerUp doesn't click-select. */ - resizingNode?: NodeComponent | null; + resizingNode?: NodeMirror | null; /** * @deprecated Legacy camera-control boolean (last-writer-wins), read by the * camera for third-party writers only. In-repo gesture owners block the @@ -73,7 +73,7 @@ export function snapData(global: { data: any }): SnapLineSharedData { return global.data as SnapLineSharedData; } -export function getSelectList(global: { data: any }): NodeComponent[] { +export function getSelectList(global: { data: any }): NodeMirror[] { const data = snapData(global); if (!data.select) data.select = []; return data.select; diff --git a/assets/snapline/react/src/Connector.tsx b/assets/snapline/react/src/Connector.tsx index 0f0a554..55ab0e5 100644 --- a/assets/snapline/react/src/Connector.tsx +++ b/assets/snapline/react/src/Connector.tsx @@ -8,15 +8,15 @@ import { type CSSProperties, } from "react"; import { - ConnectorComponent, - LineComponent, + ConnectorMirror, + LineMirror, type ConnectorCapabilities, type ConnectorCallbacks, type ConnectorSurfaceStrategy, type SnapLineMetadata, } from "@snap-engine/snapline"; import { useSnapLineEngine } from "./Engine"; -import { NodeObjectContext } from "./Node"; +import { NodeMirrorContext } from "./Node"; export interface ConnectorProps { allowDragOut?: boolean; @@ -32,13 +32,13 @@ export interface ConnectorProps { /** Keep the logical connector without rendering a visible port element. */ virtual?: boolean; colliderRadius?: number; - lineClass?: typeof LineComponent; - connectorObject?: ConnectorComponent | null; + lineClass?: typeof LineMirror; + connectorObject?: ConnectorMirror | null; data?: Record; } export interface ConnectorRef { - object(): ConnectorComponent; + object(): ConnectorMirror; } export const Connector = forwardRef( @@ -63,15 +63,15 @@ export const Connector = forwardRef( ref, ) => { const engine = useSnapLineEngine(); - const nodeObject = useContext(NodeObjectContext); + const nodeObject = useContext(NodeMirrorContext); if (!nodeObject) { throw new Error(" must be rendered inside ."); } const ownsConnectorRef = useRef(connectorObject == null); - const connectorRef = useRef(connectorObject); + const connectorRef = useRef(connectorObject); if (!connectorRef.current) { - connectorRef.current = new ConnectorComponent(engine, nodeObject, { + connectorRef.current = new ConnectorMirror(engine, nodeObject, { allowDragOut, maxConnectors, name, diff --git a/assets/snapline/react/src/EdgeSync.tsx b/assets/snapline/react/src/EdgeSync.tsx index f571a3c..bb3d3ce 100644 --- a/assets/snapline/react/src/EdgeSync.tsx +++ b/assets/snapline/react/src/EdgeSync.tsx @@ -1,6 +1,6 @@ import { EdgeSyncController, - type ConnectorComponent, + type ConnectorMirror, type EdgeConnectIntentEvent, type EdgeDisconnectIntentEvent, type EdgeEndpoint, @@ -13,7 +13,7 @@ export interface EdgeSyncProps { /** The consumer's edge document — the single source of truth. */ edges: readonly EdgeLike[]; /** Maps a connector to its semantic endpoint, or null for unmanaged connectors. */ - identity: (connector: ConnectorComponent) => EdgeEndpoint | null; + identity: (connector: ConnectorMirror) => EdgeEndpoint | null; onEdgeConnect?: (event: EdgeConnectIntentEvent) => void; onEdgeDisconnect?: (event: EdgeDisconnectIntentEvent) => void; } diff --git a/assets/snapline/react/src/Group.tsx b/assets/snapline/react/src/Group.tsx index e1cb16d..ac3874b 100644 --- a/assets/snapline/react/src/Group.tsx +++ b/assets/snapline/react/src/Group.tsx @@ -8,7 +8,7 @@ import { } from "react"; import { DEFAULT_RESIZE_HANDLE_THICKNESS, - GroupNodeComponent, + GroupNodeMirror, type GroupCallbacks, type GroupContainEvent, type GroupMembershipEvent, @@ -23,7 +23,7 @@ import { useSnapLineEngine } from "./Engine"; export interface GroupProps { children?: ReactNode; className?: string; - groupObject?: GroupNodeComponent | null; + groupObject?: GroupNodeMirror | null; style?: CSSProperties; title?: string; /** Consumer-rendered header contents. `title` remains the fallback. */ @@ -47,7 +47,7 @@ export interface GroupProps { onDragCommit?: (event: NodeDragCommitEvent) => void; } -export const Group = forwardRef(function Group( +export const Group = forwardRef(function Group( { children, className = "", @@ -79,9 +79,9 @@ export const Group = forwardRef(function Group( const boxDomRef = useRef(null); const headerRef = useRef(null); const ownsGroupRef = useRef(groupObject == null); - const groupRef = useRef(groupObject); + const groupRef = useRef(groupObject); if (!groupRef.current) { - groupRef.current = new GroupNodeComponent(engine, null, { + groupRef.current = new GroupNodeMirror(engine, null, { width, height, minWidth, @@ -118,7 +118,7 @@ export const Group = forwardRef(function Group( useLayoutEffect(() => { if (boxDomRef.current) { group.element = boxDomRef.current; - group.syncDomGeometry(); + group.remeasureDomGeometry(); } const originalCallbacks = { ...group.callbacks }; const originalGroupCallbacks = { ...group.groupCallbacks }; @@ -251,7 +251,7 @@ export const Group = forwardRef(function Group( group.element.style.width = `${width}px`; group.element.style.height = `${height}px`; group.setSizeState(width, height); - group.syncDomGeometry(); + group.remeasureDomGeometry(); }, [group, width, height]); const handleSize = diff --git a/assets/snapline/react/src/Line.tsx b/assets/snapline/react/src/Line.tsx index b3f5183..9941363 100644 --- a/assets/snapline/react/src/Line.tsx +++ b/assets/snapline/react/src/Line.tsx @@ -1,11 +1,11 @@ import { useLayoutEffect, useRef, type CSSProperties } from "react"; import type { - LineComponent, + LineMirror, LineGeometrySnapshot, } from "@snap-engine/snapline"; export interface LineProps { - line: LineComponent; + line: LineMirror; className?: string; pathClassName?: string; pathStyle?: CSSProperties; diff --git a/assets/snapline/react/src/Node.tsx b/assets/snapline/react/src/Node.tsx index 1ffc5d8..760c41e 100644 --- a/assets/snapline/react/src/Node.tsx +++ b/assets/snapline/react/src/Node.tsx @@ -14,8 +14,8 @@ import { } from "react"; import { DEFAULT_RESIZE_HANDLE_THICKNESS, - LineComponent, - NodeComponent, + LineMirror, + NodeMirror, type ResizeHandle, type NodeCallbacks, type NodeDragCommitEvent, @@ -25,13 +25,13 @@ import { import { useSnapLineEngine } from "./Engine"; import { Line } from "./Line"; -export const NodeObjectContext = createContext(null); +export const NodeMirrorContext = createContext(null); export interface NodeProps { children: ReactNode; className?: string; - lineComponent?: ComponentType<{ line: LineComponent }>; - nodeObject?: NodeComponent | null; + lineComponent?: ComponentType<{ line: LineMirror }>; + nodeObject?: NodeMirror | null; style?: CSSProperties; x?: number; y?: number; @@ -53,7 +53,7 @@ export interface NodeProps { elementProps?: HTMLAttributes; } -export const Node = forwardRef(function Node( +export const Node = forwardRef(function Node( { children, className = "", @@ -83,9 +83,9 @@ export const Node = forwardRef(function Node( const engine = useSnapLineEngine(); const nodeDomRef = useRef(null); const ownsNodeRef = useRef(nodeObject == null); - const nodeRef = useRef(nodeObject); + const nodeRef = useRef(nodeObject); if (!nodeRef.current) { - nodeRef.current = new NodeComponent(engine, null, { + nodeRef.current = new NodeMirror(engine, null, { resizable, minWidth, minHeight, @@ -98,7 +98,7 @@ export const Node = forwardRef(function Node( }); } const node = nodeRef.current; - const [lineList, setLineList] = useState( + const [lineList, setLineList] = useState( node.getAllOutgoingLines(), ); const latestRef = useRef({ @@ -119,7 +119,7 @@ export const Node = forwardRef(function Node( useLayoutEffect(() => { if (nodeDomRef.current) { node.element = nodeDomRef.current; - node.syncDomGeometry(); + node.remeasureDomGeometry(); } const original = { ...node.callbacks }; const invoke = ( @@ -239,13 +239,13 @@ export const Node = forwardRef(function Node( if (width != null) node.element.style.width = `${width}px`; if (height != null) node.element.style.height = `${height}px`; node.setSizeState(nextWidth, nextHeight); - node.syncDomGeometry(); + node.remeasureDomGeometry(); }, [node, width, height]); const handleSize = resizeHandleThickness ?? DEFAULT_RESIZE_HANDLE_THICKNESS; return ( - + {lineList.map((line) => ( ))} @@ -289,13 +289,13 @@ export const Node = forwardRef(function Node( /> ))} - + ); }); /** Callback ref for declaring any descendant as a node drag surface. */ export function useNodeHandle(): RefCallback { - const node = useContext(NodeObjectContext); + const node = useContext(NodeMirrorContext); const cleanup = useRef<(() => void) | null>(null); return (element) => { cleanup.current?.(); diff --git a/assets/snapline/react/src/Select.tsx b/assets/snapline/react/src/Select.tsx index 3cea92f..54f2128 100644 --- a/assets/snapline/react/src/Select.tsx +++ b/assets/snapline/react/src/Select.tsx @@ -1,5 +1,5 @@ import { useLayoutEffect, useRef, type CSSProperties } from "react"; -import { RectSelectComponent, type SelectCallbacks } from "@snap-engine/snapline"; +import { RectSelectController, type SelectCallbacks } from "@snap-engine/snapline"; import { useSnapLineEngine } from "./Engine"; export interface SelectProps { @@ -16,11 +16,11 @@ export function Select({ callbacks = {}, }: SelectProps) { const engine = useSnapLineEngine(); - const selectRef = useRef(null); + const selectRef = useRef(null); const selectDomRef = useRef(null); if (!selectRef.current) { - selectRef.current = new RectSelectComponent(engine, null, { callbacks }); + selectRef.current = new RectSelectController(engine, null, { callbacks }); } const select = selectRef.current; diff --git a/assets/snapline/react/src/index.ts b/assets/snapline/react/src/index.ts index c80ae87..9ac2705 100644 --- a/assets/snapline/react/src/index.ts +++ b/assets/snapline/react/src/index.ts @@ -6,7 +6,7 @@ export { Group } from "./Group"; export type { GroupProps } from "./Group"; export { Line } from "./Line"; export type { LineProps } from "./Line"; -export { Node, NodeObjectContext, useNodeHandle } from "./Node"; +export { Node, NodeMirrorContext, useNodeHandle } from "./Node"; export type { NodeProps } from "./Node"; export { Select } from "./Select"; export type { SelectProps } from "./Select"; diff --git a/assets/snapline/svelte/src/Connector.svelte b/assets/snapline/svelte/src/Connector.svelte index 15fa860..a9f8dd0 100644 --- a/assets/snapline/svelte/src/Connector.svelte +++ b/assets/snapline/svelte/src/Connector.svelte @@ -1,8 +1,8 @@ diff --git a/assets/snapline/svelte/src/EdgeSync.svelte b/assets/snapline/svelte/src/EdgeSync.svelte deleted file mode 100644 index 391831e..0000000 --- a/assets/snapline/svelte/src/EdgeSync.svelte +++ /dev/null @@ -1,58 +0,0 @@ - diff --git a/assets/snapline/svelte/src/Node.svelte b/assets/snapline/svelte/src/Node.svelte index 1f7f682..25f7d89 100644 --- a/assets/snapline/svelte/src/Node.svelte +++ b/assets/snapline/svelte/src/Node.svelte @@ -175,10 +175,6 @@ }); }); - export function addSetPropCallback(name: string, callback: (prop: any) => void) { - nodeObject!.addSetPropCallback(callback, name); - } - export function getNodeObject() { return nodeObject; } diff --git a/assets/snapline/svelte/src/index.ts b/assets/snapline/svelte/src/index.ts index d7cbe9a..7831b53 100644 --- a/assets/snapline/svelte/src/index.ts +++ b/assets/snapline/svelte/src/index.ts @@ -4,4 +4,4 @@ export { default as Connector } from "./Connector.svelte"; export { default as Line } from "./Line.svelte"; export { default as Select } from "./Select.svelte"; export { default as Placement } from "./Placement.svelte"; -export { default as EdgeSync } from "./EdgeSync.svelte"; +export { default as ControlledGraph } from "./ControlledGraph.svelte"; diff --git a/demo/react/src/App.jsx b/demo/react/src/App.jsx index f233596..18f276b 100644 --- a/demo/react/src/App.jsx +++ b/demo/react/src/App.jsx @@ -1,11 +1,11 @@ import { Connector, - EdgeSync, + ControlledGraph, Group, Node, Select, } from "@snap-engine/snapline-react"; -import { useState } from "react"; +import { useCallback, useState } from "react"; import { Engine as SnapEngine } from "@snap-engine/asset-base-react"; import { DropSnapNestedDemo, @@ -192,9 +192,9 @@ function EdgeSyncNode({ nodeId, title, x, y, maxIncoming = 1 }) {
Input @@ -203,9 +203,9 @@ function EdgeSyncNode({ nodeId, title, x, y, maxIncoming = 1 }) { Output
@@ -215,35 +215,64 @@ function EdgeSyncNode({ nodeId, title, x, y, maxIncoming = 1 }) { } function SnapLineEdgesDemo() { - const [edges, setEdges] = useState([]); + const [lines, setLines] = useState([]); const [connectIntents, setConnectIntents] = useState(0); const [intentLog, setIntentLog] = useState([]); - const sameEdge = (a, b) => - a.from.node === b.from.node && - a.from.port === b.from.port && - a.to.node === b.to.node && - a.to.port === b.to.port; - - const addEdge = (edge) => - setEdges((current) => - current.some((existing) => sameEdge(existing, edge)) + const addDocLine = (record) => + setLines((current) => + current.some((existing) => existing.id === record.id) ? current : [ ...current.filter( - (existing) => - !(existing.to.node === edge.to.node && existing.to.port === edge.to.port), + (existing) => existing.toConnectorId !== record.toConnectorId, ), - edge, + record, ], ); - const identity = (connector) => { - const metadata = connector.metadata; - return typeof metadata.node === "string" && typeof metadata.port === "string" - ? { node: metadata.node, port: metadata.port } - : null; - }; + const handleRequest = useCallback((request) => { + const nodeOf = (connectorId) => connectorId.split(":")[0]; + setLines((current) => { + const byId = new Map(current.map((record) => [record.id, record])); + const label = (record) => + `${nodeOf(record.fromConnectorId)}->${nodeOf(record.toConnectorId)}`; + const removed = request.remove + .map((id) => (byId.has(id) ? label(byId.get(id)) : id)) + .join(","); + const added = request.add.map(label).join(","); + const updated = request.update + .map((update) => + byId.has(update.id) + ? label({ ...byId.get(update.id), toConnectorId: update.toConnectorId }) + : update.id, + ) + .join(","); + const entry = + request.intent === "connect" + ? `connect:${added}` + : request.intent === "disconnect" + ? `disconnect:${removed}` + : request.intent === "reconnect" + ? `reconnect:${updated}` + : `replace:-${removed}+${added || updated}`; + setIntentLog((log) => [...log, entry]); + if (request.add.length > 0) setConnectIntents((count) => count + 1); + // Accept the atomic proposal — adopting the proposed ids settles the + // staged lines in place. + return [ + ...current + .filter((record) => !request.remove.includes(record.id)) + .map((record) => { + const update = request.update.find((u) => u.id === record.id); + return update + ? { ...record, toConnectorId: update.toConnectorId } + : record; + }), + ...request.add, + ]; + }); + }, []); return (
@@ -251,37 +280,24 @@ function SnapLineEdgesDemo() { {connectIntents} - {edges.length} + {lines.length} {intentLog.join("|")}
+
diff --git a/demo/svelte/src/demo/node_ui_edges/EdgeNode.svelte b/demo/svelte/src/demo/node_ui_edges/EdgeNode.svelte index 582e849..8096194 100644 --- a/demo/svelte/src/demo/node_ui_edges/EdgeNode.svelte +++ b/demo/svelte/src/demo/node_ui_edges/EdgeNode.svelte @@ -12,9 +12,9 @@
Input @@ -23,9 +23,9 @@ Output
diff --git a/demo/svelte/src/demo/node_ui_edges/NodeUIEdgesDemo.svelte b/demo/svelte/src/demo/node_ui_edges/NodeUIEdgesDemo.svelte index fe8a5d1..c1ea465 100644 --- a/demo/svelte/src/demo/node_ui_edges/NodeUIEdgesDemo.svelte +++ b/demo/svelte/src/demo/node_ui_edges/NodeUIEdgesDemo.svelte @@ -1,66 +1,115 @@
- + {connectIntents} {disconnectIntents} - {edges.length} + {lines.length} {intentLog.join("|")}
@@ -81,22 +130,7 @@
+ diff --git a/demo/svelte/src/demo/node_ui_camera/NodeUICameraDemo.svelte b/demo/svelte/src/demo/node_ui_camera/NodeUICameraDemo.svelte index 8cc4a4b..3af26fd 100644 --- a/demo/svelte/src/demo/node_ui_camera/NodeUICameraDemo.svelte +++ b/demo/svelte/src/demo/node_ui_camera/NodeUICameraDemo.svelte @@ -1,5 +1,6 @@ + + diff --git a/demo/svelte/src/demo/node_ui_demo/NodeUIDemo.svelte b/demo/svelte/src/demo/node_ui_demo/NodeUIDemo.svelte index de876da..69aaef7 100644 --- a/demo/svelte/src/demo/node_ui_demo/NodeUIDemo.svelte +++ b/demo/svelte/src/demo/node_ui_demo/NodeUIDemo.svelte @@ -1,6 +1,7 @@ @@ -8,6 +9,7 @@
- - - + + +
From a5432d4d9667075677690a631b002013547c573a Mon Sep 17 00:00:00 2001 From: tfukaza Date: Sat, 25 Jul 2026 20:25:29 -0700 Subject: [PATCH 17/21] =?UTF-8?q?snapline:=20Phase=205a=20=E2=80=94=20rema?= =?UTF-8?q?ining=20verification=20matrix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Sibling controlled engines are fully isolated: same connector/line ids settle independently and a gesture cannot discover the other engine's targets. - A gesture reconnect preserves the line's stable id AND mirror instance: pickup from one input, drop on another, apply the endpoint update — same LineMirror settles onto the new target. - A bulk load (open batch + setCanonicalGraph + connectors mounting across multiple tasks) runs exactly one reconciliation pass at the outermost end(), with rule violations surfacing as diagnostics in that same single pass. ut 50/50 green. Co-Authored-By: Claude Fable 5 --- tests/ut/snapline-line-reconciler.spec.ts | 138 ++++++++++++++++++++++ 1 file changed, 138 insertions(+) diff --git a/tests/ut/snapline-line-reconciler.spec.ts b/tests/ut/snapline-line-reconciler.spec.ts index a7788f3..6a350c0 100644 --- a/tests/ut/snapline-line-reconciler.spec.ts +++ b/tests/ut/snapline-line-reconciler.spec.ts @@ -4,9 +4,14 @@ import { NodeMirror, } from "../../assets/snapline/core/src"; import { getGraphMirror } from "../../assets/snapline/core/src/snapline-globals"; +import { attachControlledGraph, type LineChangeRequest } from "../../assets/snapline/core/src"; import { + armGesture, createControlledHarness as controlledHarness, + createSiblingEngine, + driveGestureDrop, eventPositionAt as pos, + mountConnectedPair, nearTargetStrategy as nearStrategy, } from "../helpers/snapline-harness"; @@ -365,3 +370,136 @@ test("a gesture disconnect proposes removal; rejection re-glues, acceptance disc expect(mirror.line("line-a")).toBeNull(); expect(source.outgoingLines).toEqual([]); }); + +// ---- Remaining matrix: isolation, reconnect identity, bulk load ---- + +test("controlled graphs on sibling engines are fully isolated, gestures included", () => { + const { engine, global, handle, requests } = controlledHarness(); + const sibling = createSiblingEngine(global); + const siblingRequests: LineChangeRequest[] = []; + const siblingHandle = attachControlledGraph(sibling, { + onLineChangeRequest: (request) => siblingRequests.push(request), + }); + + // Same connector ids on both engines: no conflict, independent settles. + const a = mountConnectedPair(engine); + const b = mountConnectedPair(sibling); + handle.setCanonicalGraph({ + lines: [{ id: "iso", fromConnectorId: "out-1", toConnectorId: "in-1" }], + }); + handle.flush(); + siblingHandle.flush(); + expect(getGraphMirror(engine).line("iso")).not.toBeNull(); + expect(getGraphMirror(sibling).line("iso")).toBeNull(); + + // A gesture on the sibling engine cannot discover engine A's targets: + // A's target accepts drops near x=100, the sibling's own target does not + // exist at all (destroyed) — the drop finds no candidate anywhere. + b.target.destroy(false); + armGesture(b.source, 11); + driveGestureDrop(b.source, 100, 11); + expect(siblingRequests).toEqual([]); + expect(b.source.outgoingLines).toEqual([]); + expect(requests).toEqual([]); + void a; +}); + +test("a gesture reconnect preserves the line's stable id and mirror", () => { + const { engine, handle, requests } = controlledHarness(); + const mirror = getGraphMirror(engine); + const { source, targetNode, target } = mountConnectedPair(engine); + // Second input on the same node, hit-testable only left of x=500 too but + // distinguished by position: in-2 accepts drops at x >= 200. + const secondTarget = new ConnectorMirror(engine, targetNode, { + id: "in-2", + name: "in2", + rules: { maxOutgoing: 0 }, + surfaceStrategies: [ + { + targetHitTest: ({ position }: any) => + position.x >= 200 && position.x < 500 + ? { anchor: { x: position.x, y: position.y }, distance: 0 } + : null, + }, + ], + }); + // Restrict the first target to drops left of x=200 so the reconnect drop + // at x=300 lands on in-2. + target.updateConfig({ + rules: { maxOutgoing: 0 }, + surfaceStrategies: [ + { + targetHitTest: ({ position }: any) => + position.x < 200 + ? { anchor: { x: position.x, y: position.y }, distance: 0 } + : null, + }, + ], + }); + + handle.setCanonicalGraph({ + lines: [{ id: "line-r", fromConnectorId: "out-1", toConnectorId: "in-1" }], + }); + handle.flush(); + const line = mirror.line("line-r")!; + + // Pick up from in-1, drop on in-2. + const event = { button: 0, pointerId: 13 } as any; + target.armSurfaceGesture({ position: pos(0, 0), event } as any, null); + driveGestureDrop(source, 300, 13); + + expect(requests).toHaveLength(1); + expect(requests[0]).toMatchObject({ + intent: "reconnect", + update: [{ id: "line-r", toConnectorId: "in-2" }], + }); + + // The app applies the endpoint update; the SAME mirror settles onto in-2. + handle.setCanonicalGraph({ + lines: [{ id: "line-r", fromConnectorId: "out-1", toConnectorId: "in-2" }], + }); + handle.flush(); + expect(mirror.line("line-r")).toBe(line); + expect(line.target).toBe(secondTarget); + expect(line.phase).toBe("connected"); +}); + +test("a bulk load reconciles exactly once at the outermost batch end", async () => { + const { engine, handle } = controlledHarness(); + const mirror = getGraphMirror(engine); + const reconciler = mirror.reconciler!; + const originalReconcile = reconciler.reconcile!.bind(reconciler); + let passes = 0; + (reconciler as { reconcile?: () => void }).reconcile = () => { + passes += 1; + originalReconcile(); + }; + + const batch = mirror.beginBatch(); + handle.setCanonicalGraph({ + lines: [ + { id: "bulk-1", fromConnectorId: "out-1", toConnectorId: "in-1" }, + { id: "bulk-2", fromConnectorId: "out-2", toConnectorId: "in-1" }, + ], + }); + // Connectors mount across several "commits" while the batch is open. + const { targetNode } = mountConnectedPair(engine); + await new Promise((resolve) => setTimeout(resolve, 0)); + new ConnectorMirror(engine, new NodeMirror(engine, null), { + id: "out-2", + name: "out2", + rules: { maxIncoming: 0 }, + }); + targetNode.setSizeState(10, 10); + expect(passes).toBe(0); + + batch.end(); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(passes).toBe(1); + expect(mirror.line("bulk-1")).not.toBeNull(); + // bulk-2 targets the same maxIncoming:1 input — latent with a diagnostic, + // exactly one pass regardless. + expect(mirror.diagnostics().map((error) => error.code)).toEqual([ + "capacity-exceeded", + ]); +}); From 64329c10d049f667974aa9942b3a2748067d31d9 Mon Sep 17 00:00:00 2001 From: tfukaza Date: Sat, 25 Jul 2026 20:26:12 -0700 Subject: [PATCH 18/21] =?UTF-8?q?docs:=20Phase=205b=20=E2=80=94=20migratio?= =?UTF-8?q?n=20notes=20for=20the=20controlled-graph=20re-architecture?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One reference for consumers crossing the Cleanup re-architecture: the Mirror renames, the capabilities/maxConnectors/allowDragOut → rules mapping, EdgeSync → ControlledGraph (stable-id LineRecords, atomic requests, id adoption, rejection-by-inaction), the imperative-API removal with the vanilla graph-owner-module pattern, stable identity and its persistence contract, property-propagation removal, and the consolidated geometry observation. Co-Authored-By: Claude Fable 5 --- docs/snapline/design/migration-notes.md | 130 ++++++++++++++++++++++++ 1 file changed, 130 insertions(+) create mode 100644 docs/snapline/design/migration-notes.md diff --git a/docs/snapline/design/migration-notes.md b/docs/snapline/design/migration-notes.md new file mode 100644 index 0000000..d0a4d53 --- /dev/null +++ b/docs/snapline/design/migration-notes.md @@ -0,0 +1,130 @@ +# SnapLine migration notes — the controlled-graph re-architecture + +Status: migration reference for the pre-1.0 re-architecture +Applies to: every consumer upgrading across the `Cleanup` re-architecture + +SnapLine's topology is now **always controlled**: the application's document +is the single source of truth for which nodes, connectors, and lines exist. +SnapLine maintains an engine-scoped runtime mirror, and user gestures arrive +as atomic proposals the application accepts by updating its records. +Position and size stay SnapLine-owned — geometry is a visual cue, observed +(not negotiated) through one batched callback. + +## Type and callback renames + +| Old | New | +| --- | --- | +| `NodeComponent` / `ConnectorComponent` / `LineComponent` / `GroupNodeComponent` (core) | `NodeMirror` / `ConnectorMirror` / `LineMirror` / `GroupNodeMirror` | +| `RectSelectComponent` | `RectSelectController` | +| `NodeObjectContext` (React) | `NodeMirrorContext` | +| `ConnectorLinePhase` | `LineMirrorPhase` (adds `"staged"`) | +| `syncDomGeometry()` | `remeasureDomGeometry()` | +| `onLinesChanged` | unchanged name; payload is `LineMirror`s | +| `onDragCommit` + `onResizeCommit` | one batched `onGeometryChanged({ nodes })` | +| `NodePosition` / `NodeDragCommitEvent` | `NodeGeometry` / `GeometryChangeEvent` | +| `EdgeId` / `EdgeRecord` / `EdgeLike` / `EdgeEndpoint` | `LineId` / `LineRecord` (stable-id, no endpoint-pair keying) | + +## Connector configuration: `capabilities` → `rules` + +`maxConnectors`, `allowDragOut`, and `capabilities` are gone. The mapping: + +| Old | New `rules` | +| --- | --- | +| `allowDragOut: true` (source-only) | `{ maxIncoming: 0 }` | +| `allowDragOut: false, maxConnectors: N` (target-only, finite) | `{ maxOutgoing: 0, maxIncoming: N, onFull: "replace-oldest" }` | +| `maxConnectors: -1` (unlimited) | `maxIncoming: "unlimited"` (the `-1` sentinel is gone) | +| `capabilities.source/target` booleans | derived: `isSource` = `maxOutgoing !== 0`, `isTarget` = `maxIncoming !== 0` | +| implicit oldest-line eviction | explicit `onFull: "reject"` (default) or `"replace-oldest"` | +| `canConnect(event)` pair predicate (callback) | `rules.isValidConnection(proposal)` — line-aware: `{ line, source, target, phase }`; both endpoints may veto; synchronous and side-effect free | +| `onConnectionRequest` (veto + payload) | veto → `isValidConnection`; payload → canonical `LineRecord.payload` | + +Defaults: `maxOutgoing: "unlimited"`, `maxIncoming: 1`, `reconnect: true`, +`allowParallel: false`, `onFull: "reject"`. + +## EdgeSync → ControlledGraph + +The `EdgeSync` component/controller, its `identity()` callback, and +endpoint-pair edge matching are replaced by the controlled-graph protocol: + +```svelte + +``` + +- `lines: readonly LineRecord[]` — your document's records, each + `{ id, fromConnectorId, toConnectorId, payload? }`. Give connectors stable + `id` props (a composite like `` `${node}:${port}` `` works well) instead of + implementing `identity()`. +- `onLineChangeRequest(request)` — ONE atomic proposal per gesture: + `{ intent: "connect" | "disconnect" | "replace" | "reconnect", add, + remove, update }`. Apply it atomically: filter `remove`, apply `update` + endpoint changes, concat `add`. A capacity replacement arrives as a single + `"replace"` (removals + addition together), never as ordered intents. +- **Adopt the proposed ids.** A gesture-created line carries a + SnapLine-minted `LineId`; keeping it in the record you add settles the + dragged line in place (no flicker, same mirror). Substituting your own id + works but recreates the mirror. +- Rejection needs no code path: apply nothing and the staged line is + discarded on the next pass (the adapter guarantees a post-request push of + your latest records). +- Diagnostics: records the mirror cannot represent (missing endpoints stay + silently latent; capacity/rule violations) surface through + `onDiagnosticsChanged` / `query(engine).diagnostics()` — canonical records + are never rewritten or evicted by SnapLine. + +## Imperative topology API removal + +`connectToConnector()`, `deleteLine()`, `disconnectFromConnector()`, +`deleteAllLines()`, and `createLine()` are no longer public. Create and +remove lines by changing your records; `canConnect(target, line?)` remains +as a read-only admission query. A gesture on an engine with no attached +graph owner warns and discards the preview. + +**Vanilla JS** consumers own the graph with a plain module — a mini emulated +framework holding the records: + +```ts +import { attachControlledGraph } from "@snap-engine/snapline"; + +let lines = []; +const handle = attachControlledGraph(engine, { + onLineChangeRequest(request) { + lines = [ + ...lines + .filter((record) => !request.remove.includes(record.id)) + .map((record) => { + const update = request.update.find((u) => u.id === record.id); + return update + ? { ...record, toConnectorId: update.toConnectorId } + : record; + }), + ...request.add, + ]; + handle.setCanonicalGraph({ lines }); + }, +}); +handle.setCanonicalGraph({ lines }); +``` + +## Stable identity + +Every mirror has a domain id — `nodeId` / `connectorId` / `lineId` — +supplied via the `id` prop/config or minted (`node-42`-style) when omitted. +Minted ids are stable only for the mirror's lifetime: any graph that +outlives it (persistence, remounts, reloads) must supply its own ids, or +stored `LineRecord`s will reference dead connector ids. Adapter line lists +key by `lineId`, and line SVGs carry `data-line-id`. + +## Property propagation removal + +The node property bag (`setProp` / `getProp` / `addSetPropCallback` / +`propagateProp`) is gone. Dataflow belongs to the application graph: derive +values from your own document (the same records that drive +`ControlledGraph`) and render them through normal framework state. + +## Geometry + +`onGeometryChanged({ nodes: [{ node, x, y, width, height }] })` fires once +per settled drag (every moved node of a group/multi-select drag in one +event) or resize (single entry). SnapLine owns live and settled geometry; +persist the observation if you want it back after a reload — ignoring it +never reverts the mirror. From 0a21deb55da6c532d35cef74f5ccf65886693ddb Mon Sep 17 00:00:00 2001 From: tfukaza Date: Sat, 25 Jul 2026 20:40:08 -0700 Subject: [PATCH 19/21] =?UTF-8?q?docs:=20Phase=205c=20=E2=80=94=20full=20a?= =?UTF-8?q?ccuracy=20pass=20onto=20the=20final=20architecture?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The new API was 100% undocumented; every doc now describes the shipped system: - current-architecture.md rewritten end to end: always-controlled topology, the ControlledGraph protocol (gesture→request→adopt sequence, reconcile-pass flowchart), ConnectorRules and the two admission paths, SnapLine-owned geometry with the batched observation, GraphMirror registries and what stays on global.data, scheduler/ batching, diagnostics, the real public API surface and subpaths, ownership matrix, source map. - planned-rearchitecture.md pruned per its own contract: records the two implementation amendments (uncontrolled mode eliminated; geometry modes dropped) and keeps only the final simplification review with its known consolidation candidates. - ownership-specification.md: conformance snapshot now reflects full conformance; every "open decision" answered as decided-and-shipped; dead-API wording fixed without touching the normative requirement sections. - AGENTS.md: GraphMirror/query() and ControlledGraph/LineReconciler sections replace NodeManager/EdgeSyncController; property-system section deleted; connection rules rewritten around ConnectorRules; globals and file-structure sections updated. - Guides + references + READMEs: setup examples rewritten on the controlled protocol in all three frameworks (keeping exactly two framework-tagged blocks per the docs e2e), core-concepts and surface-connectors teach rules/ControlledGraph instead of maxConnectors/onConnectionRequest, selection-resize uses the batched onGeometryChanged, reference tables gain ControlledGraph/id/rules and the query() facade, subpath lists updated. - Website shiki highlighter falls back to plaintext for unknown fence languages — the design docs' mermaid blocks were breaking the ENTIRE docs build with a 500 (pre-existing on main); the docs e2e suite now passes except one pre-existing llms.txt heading drift ("SnapEngine Core Documentation" vs the spec's expectation) that predates this work. Co-Authored-By: Claude Fable 5 --- assets/snapline/AGENTS.md | 215 ++- assets/snapline/core/README.md | 17 +- assets/snapline/react/README.md | 5 +- assets/snapline/svelte/README.md | 6 +- docs/snapline/design/current-architecture.md | 1435 +++++------------ .../design/ownership-specification.md | 177 +- .../snapline/design/planned-rearchitecture.md | 1332 +-------------- docs/snapline/guides/01_core_concepts.mdx | 12 +- docs/snapline/guides/02_selection_resize.mdx | 13 +- .../snapline/guides/06_surface_connectors.mdx | 60 +- docs/snapline/introduction/01_setup.mdx | 106 +- docs/snapline/reference/index.mdx | 8 +- docs/snapline/reference/react/index.mdx | 5 +- docs/snapline/reference/svelte/group.mdx | 2 +- docs/snapline/reference/svelte/index.mdx | 13 +- docs/snapline/reference/vanilla/index.mdx | 13 +- website/svelte.config.js | 10 +- 17 files changed, 870 insertions(+), 2559 deletions(-) diff --git a/assets/snapline/AGENTS.md b/assets/snapline/AGENTS.md index ff1d264..5e5390d 100644 --- a/assets/snapline/AGENTS.md +++ b/assets/snapline/AGENTS.md @@ -22,6 +22,8 @@ APIs directly rather than adding compatibility shims. - `GroupNodeMirror` - Resizable box that carries the nodes inside it - `RectSelectController` - Rectangle selection tool - `PlacementController` - Headless pointer-follow placement state machine +- `attachControlledGraph` - Installs the controlled-graph bridge (LineReconciler) +- `query` - Read-only `GraphQuery` facade over one engine's graph - `snapline-globals` - Typed accessors for the shared `global.data` registries ### @snap-engine/snapline-svelte @@ -36,14 +38,16 @@ APIs directly rather than adding compatibility shims. - `Line.svelte` - Connection line component - `Select.svelte` - Rectangle selection component - `Placement.svelte` - Placement controller binding and preview +- `ControlledGraph.svelte` - Controlled-graph bridge (canonical line records) ### @snap-engine/snapline-react **Location:** `react/src/` **Language:** React/TypeScript **Dependencies:** `@snap-engine/snapline`, `@snap-engine/core` -Exports `Engine`, `Node`, `Group`, `Connector`, `Line`, `Select`, and -`Placement`, with forwarded refs to core objects where applicable. +Exports `Engine`, `Node`, `Group`, `Connector`, `Line`, `Select`, +`Placement`, and `ControlledGraph`, with forwarded refs to core objects +where applicable. ## File Structure @@ -54,19 +58,42 @@ snapline/ │ ├── tsconfig.json │ └── src/ │ ├── index.ts -│ ├── node.ts # NodeMirror -│ ├── connector.ts # ConnectorMirror -│ ├── line.ts # LineMirror -│ └── select.ts # RectSelectController -└── svelte/ +│ ├── node.ts # NodeMirror +│ ├── connector.ts # ConnectorMirror + ConnectorRules +│ ├── line.ts # LineMirror +│ ├── group.ts # GroupNodeMirror +│ ├── select.ts # RectSelectController +│ ├── placement.ts # PlacementController +│ ├── graph-mirror.ts # GraphMirror (per-engine registry + scheduler) +│ ├── line-reconciler.ts # LineReconciler + LineRecord/LineChangeRequest +│ ├── query.ts # query() GraphQuery facade +│ ├── geometry.ts # GeometryWriter type +│ └── snapline-globals.ts # global.data accessors + attachControlledGraph +├── svelte/ +│ ├── package.json +│ ├── tsconfig.json +│ └── src/ +│ ├── index.ts +│ ├── Node.svelte +│ ├── Group.svelte +│ ├── Connector.svelte +│ ├── Line.svelte +│ ├── Select.svelte +│ ├── Placement.svelte +│ └── ControlledGraph.svelte +└── react/ ├── package.json ├── tsconfig.json └── src/ ├── index.ts - ├── Node.svelte - ├── Connector.svelte - ├── Line.svelte - └── Select.svelte + ├── Engine.tsx + ├── Node.tsx + ├── Group.tsx + ├── Connector.tsx + ├── Line.tsx + ├── Select.tsx + ├── Placement.tsx + └── ControlledGraph.tsx ``` ## Core Classes @@ -77,29 +104,29 @@ snapline/ **Features:** - Multiple connectors -- Property-based data flow +- Stable domain identity (`nodeId` via `config.id`, minted when omitted) - Parent-child relationships - Transform hierarchy **Key Methods:** -- `addConnector(name, connector)` - Register connector -- `setProp(name, value)` - Set output property -- `getProp(name)` - Get property value -- `addSetPropCallback(callback, propName)` - React to property changes +- `addConnectorObject(connector)` - Register connector +- `getConnector(name)` - Look up a connector by name +- `remeasureDomGeometry()` - Re-measure the box and re-glue lines +- `setSize(width, height)` / `setSizeState(width, height)` - Drive size ### ConnectorMirror **Extends:** `BaseObject` **Purpose:** Connection point on a node **Configuration:** -- `name: string` - Connector identifier -- `maxConnectors: number` - Connection limit (-1 = unlimited, 0 = output-only) -- `allowDragOut: boolean` - Can drag connections from this +- `id?: string` - Stable graph-global `connectorId` (minted when omitted) +- `name: string` - Construction-time key in the parent node's map +- `rules?: Partial` - Connection policy (see Connection Rules) **Features:** -- Input/output mode -- Connection limits -- Drag permissions +- Derived source/target roles (`isSource` = `maxOutgoing !== 0`, `isTarget` = `maxIncoming !== 0`) +- Connection limits and admission predicates +- Surface strategies for headless hit testing and anchors - Connection callbacks ### LineMirror @@ -138,9 +165,9 @@ snapline/ **Purpose:** Connector wrapper component **Props:** +- `id?: string` - Stable graph-global connector identity - `name: string` - Connector identifier -- `maxConnectors: number` - Connection limit -- `allowDragOut: boolean` - Allow drag out +- `rules?: Partial` - Connection policy **Methods:** - `object(): ConnectorMirror` - Get underlying connector @@ -173,8 +200,10 @@ Concretely: - **Node/group transforms and live width/height** are core-written during a gesture. Resize uses `WRITE_1 → READ_2 → WRITE_2`: paint the box, remeasure - connectors, then re-glue lines. `onSizeChange` is observational and - `onResizeCommit` is the framework persistence boundary. + connectors, then re-glue lines. `onSizeChange` is the live observation and + the batched `onGeometryChanged({ nodes })` reports settled geometry the + framework may persist (geometry is SnapLine-owned; ignoring the event + never reverts the mirror). - **Initial node geometry** is explicit: after assigning a committed framework element, adapters call `remeasureDomGeometry()`. ResizeObserver remains the ongoing invalidation path, not the initial-mount handshake. @@ -202,46 +231,71 @@ explicit origins/reasons so consumers never need teardown heuristics. SnapLine deliberately does not define port types, graph-document mutations, palette contents, or node factories. Consumers express those policies through -metadata and predicates such as `canConnect`, `canContain`, and `canStart`. +metadata and predicates such as `isValidConnection`, `canContain`, and +`canStart`. `PlacementController` similarly computes preview/commit coordinates but leaves rendering and creation to framework adapters and consumer callbacks. Raw input/DOM plumbing stays on the `event.*` slots. -### NodeManager (engine-scoped registry) - -`core/src/node-manager.ts` is the per-engine registry of live SnapLine nodes -and connectors, lazy-created by `getNodeManager(engine)` the first time any -component registers (constructors register, `destroy()` unregisters — no -adapter wiring). `query.ts` enumeration delegates to it, and it hosts -engine-scoped facilities: the controlled-edges controller today, layout -helpers that walk `nodes` tomorrow. GlobalManager is application-wide, so the -managers live in `SnapLineSharedData.nodeManagers` keyed by engine. - -### Controlled edges (EdgeSyncController) - -`core/src/edge-sync.ts` + the `EdgeSync` adapter components implement the -controlled-edges contract: the CONSUMER's edge document is the only edge -authority; SnapLine reconciles rendered lines to it (`sync()`, hydrating -missing lines with origin `"hydration"`) and translates gestures into -semantic intents (`onEdgeConnect` for gesture connects, `onEdgeDisconnect` -for gesture and replacement disconnects). Programmatic, hydration, and -teardown changes never forward as intents, and sync never forwards its own -mutations (`#reconciling` guard). Edges exist in exactly two representations — -consumer document and rendered lines; `NodeManager` holds membership only and -the controller stores no edges (`getEdges()` is consulted fresh). Intents fire -synchronously inside the drop dispatch; adapters reconcile in a microtask of -the same task, so accept and reject paths both resolve before the frame -paints WITHOUT any paint-atomic flush contract (the no-flushMutation rule -above still holds). Consumers should write their document synchronously -inside intent handlers; deferred stores degrade to a one-frame pending state, -never an inconsistent one. +### GraphMirror (engine-scoped registry) + +`core/src/graph-mirror.ts` is the per-engine registry of every live SnapLine +mirror, lazy-created by `getGraphMirror(engine)` the first time any mirror +registers (constructors register, `destroy()` unregisters — no adapter +wiring). It holds the node/connector sets, the settled-line and preview-line +sets, and the domain-id indexes (`nodesById`/`connectorsById`/`linesById`, +first-registration-wins with `"duplicate-id"` diagnostics), plus the +engine-scoped interaction state (`selection`, `groups`, `resizingNode`, +`parentGroups`, `membershipResolver`), the installed reconciler slot, and +the coalescing batch-aware reconciliation scheduler +(`scheduleReconciliation`/`flush`/`beginBatch`/`runBatch`). GlobalManager is +application-wide, so the registries live in the +`SnapLineSharedData.graphMirrors` WeakMap keyed by engine. `query(engine)` +is the public read-only facade over it: snapshot lists, `node(id)` / +`connector(id)` / `line(id)` lookups, and `diagnostics()` — never registry +sets or mutation methods; `query.ts` enumeration helpers delegate to the +same registry. + +### Controlled lines (ControlledGraph / LineReconciler) + +Topology is ALWAYS controlled: the CONSUMER's document is the only line +authority, and there is no imperative public topology API (`deleteLine`, +`createLine`, etc. are `@internal`; a gesture on an engine with no attached +graph owner warns and discards the preview). `core/src/line-reconciler.ts` +plus the `ControlledGraph` adapter components implement the contract: +`attachControlledGraph(engine, { onLineChangeRequest, onDiagnosticsChanged? })` +installs the `LineReconciler` and returns +`{ setCanonicalGraph, flush, dispose }`. The app PUSHES its canonical +`{ lines: LineRecord[] }` snapshot (stable ids); internal triggers +(connector register/unregister, batch close) replay the cached snapshot +through the mirror's coalescing scheduler. Each reconcile pass prunes +mirrors whose record is gone (or whose `fromConnectorId` moved), preserves +and retargets by stable `lineId`, settles or discards staged gesture lines, +and creates settled mirrors for fully-mounted records — strict admission, +never evicting: capacity/rule violations become derived diagnostics and +unmounted endpoints stay silently latent. A gesture drop validates (rules + +both endpoints' `isValidConnection` with the real `LineMirror`), stages the +outcome on the same mirror (phase `"staged"`, no topology commitment), and +dispatches ONE atomic `LineChangeRequest` +(`{ intent: connect|disconnect|replace|reconnect, add, remove, update }` — +`replace-oldest` evictions ride the request, never local deletes). Adapters +GUARANTEE a post-request microtask push of the live records ahead of the +decisive pass, so acceptance, normalization, rejection, and +rejection-by-inaction all resolve from the next snapshot — adopting the +proposed `lineId` settles the dragged line in place; rejection needs no code +path. Consumers should write their document synchronously inside the request +handler; deferred stores degrade to a one-frame pending state, never an +inconsistent one. ### Shared global registries Everything SnapLine stores on the engine's shared `global.data` bag is declared in `core/src/snapline-globals.ts` (`SnapLineSharedData`) and accessed through -its typed helpers. Engine core's `input.ts` reads `resizeHandles` duck-typed -(it cannot import snapline) — keep the two shapes in sync. +its typed helpers. It now holds only `resizeHandles` and `sourceSurfaces` +(engine core's `input.ts` reads both duck-typed — it cannot import snapline — +so keep the shapes in sync) plus the `graphMirrors` WeakMap keying each +engine to its `GraphMirror`. Selection, groups, and `resizingNode` are +engine-scoped state on `GraphMirror`, not global arrays. ### Pointer claims (camera blocking) @@ -276,30 +330,42 @@ boolean remains readable by the camera for third-party writers only. - Equal-size group candidates use stable IDs as a deterministic tie-breaker; membership cycles are always rejected. - Carried group members are moved via transform parenting only — they are never - added to `global.data.select`, so a group drag does not alter the selection. + added to the engine's `GraphMirror.selection`, so a group drag does not + alter the selection. - `attachTransformToGroup`/`detachTransformFromGroup` are the public transform-only reparent seam used by the group carry. ## Key Concepts -### Property System -- Nodes have named properties -- Connectors map to properties by name -- Connected connectors share data through properties -- Use `setProp()` to send, `addSetPropCallback()` to receive - ### Connector Types -- **Input:** `maxConnectors > 0`, `allowDragOut = false` -- **Output:** `maxConnectors = 0 or -1`, `allowDragOut = true` -- **Bidirectional:** Custom combinations + +Roles are derived from `ConnectorRules` limits — there are no role booleans: + +- **Target-only (input):** `{ maxOutgoing: 0 }` +- **Source-only (output):** `{ maxIncoming: 0 }` +- **Bidirectional:** both limits non-zero (`isSource` = `maxOutgoing !== 0`, + `isTarget` = `maxIncoming !== 0`) ### Connection Rules -- `-1`: Unlimited connections -- `0`: No incoming (output only) -- `N`: Maximum N incoming connections -- A new connection to a full finite input evicts the oldest live incoming - line(s) required to make room. Disconnect callbacks fire before the new - connect callbacks. + +`ConnectorConfig.rules` (all optional; limits are `number | "unlimited"`, +normalized to `Infinity` internally): + +- `maxOutgoing` (default `"unlimited"`) - outgoing limit; previews reserve a slot +- `maxIncoming` (default `1`) - settled incoming limit +- `reconnect` (default `true`) - existing incoming lines can be picked up +- `allowParallel` (default `false`) - BOTH endpoints must allow parallel lines +- `onFull` (default `"reject"`) - a full target rejects, or `"replace-oldest"` + proposes evicting the oldest incoming lines INSIDE the gesture's atomic + `"replace"` request (never a local delete) +- `isValidConnection(proposal)` - line-aware admission predicate + (`{ line, source, target, phase }`); synchronous and side-effect free + (candidate discovery calls it per pointer move, rechecked on drop and on + record admission); either endpoint may veto + +Canonical-record admission is strict: capacity never evicts regardless of +`onFull`; a refused record stays in the document and surfaces as a +diagnostic. ### Camera edge-pan @@ -322,5 +388,6 @@ resize gestures intentionally do not edge-pan. - Connectors must be children of Node components - Line component injected via `LineSvelteComponent` prop -- Data flows through property system +- Dataflow belongs to the application graph: derive values from the same + records that drive `ControlledGraph` and render through framework state - All input handling automatic via SnapEngine diff --git a/assets/snapline/core/README.md b/assets/snapline/core/README.md index 2088a4e..01fa102 100644 --- a/assets/snapline/core/README.md +++ b/assets/snapline/core/README.md @@ -50,9 +50,9 @@ line without coupling SnapLine to the external system. Surface strategies decouple connection hit testing from visible connector elements. They can activate from a node border, rank shape-specific target -hits, and resolve preview and settled anchors from cached geometry. Independent -connector capabilities allow the same logical surface to start and accept -connections. `onPointerDown` runs when a connector claims the primary pointer, +hits, and resolve preview and settled anchors from cached geometry. Symmetric +connector rules (`maxOutgoing`/`maxIncoming`, `"unlimited"` explicit) let the +same logical surface start and accept connections. `onPointerDown` runs when a connector claims the primary pointer, before the drag threshold, so consumers can preserve click selection or other gesture-start UI for headless surfaces. @@ -61,10 +61,13 @@ surface strategies, collider radius, edge-pan behavior, or the line class without replacing the connector or its existing lines. `name` is construction-only because it is the connector's key in its parent node. -For a framework-owned graph, use `onConnectionRequest` to create the domain -edge and return its stable ID as an opaque line payload. Pass that payload -directly when hydrating with `connectToConnector`; programmatic connections do -not invoke the creation request. +Topology is always controlled: attach the graph owner with +`attachControlledGraph(engine, { onLineChangeRequest })` (or mount the +adapter `ControlledGraph` component), push your `LineRecord`s through +`setCanonicalGraph`, and apply each gesture's atomic proposal to your +records — adopting the proposed line id settles the dragged line in place. +Hydration never invokes your request handler, so restoring a saved graph +cannot duplicate application edges. Groups maintain an exclusive direct parent. Ordinary nodes use center containment, nested groups use full-bounds containment, and the smallest safe diff --git a/assets/snapline/react/README.md b/assets/snapline/react/README.md index fab06f7..d5685e1 100644 --- a/assets/snapline/react/README.md +++ b/assets/snapline/react/README.md @@ -12,7 +12,8 @@ npm install react react-dom @snap-engine/core \ ## Components The package exports `Engine`, `Node`, `Group`, `Connector`, `Line`, `Select`, -and `Placement`. Each component is also available from a named subpath. +`Placement`, and `ControlledGraph`. Each component is also available from a +named subpath. ```tsx import { Engine, Group, Node, Select } from "@snap-engine/snapline-react"; @@ -37,7 +38,7 @@ through `Node`'s `elementProps`. Set `virtual` on `Connector` to keep the logical endpoint without rendering a port. `surfaceStrategies` can then hit-test and anchor against the parent -node's shape, while `capabilities` independently enable source and target +node's shape, while symmetric `rules` limits enable source and target behavior. Keep domain edges in React state and use an opaque line payload as the stable link from a custom renderer. diff --git a/assets/snapline/svelte/README.md b/assets/snapline/svelte/README.md index 627e150..d3f0074 100644 --- a/assets/snapline/svelte/README.md +++ b/assets/snapline/svelte/README.md @@ -11,10 +11,10 @@ npm install @snap-engine/core @snap-engine/snapline \ ## Components -`Node`, `Group`, `Connector`, `Line`, `Select`, and `Placement` are exported +`Node`, `Group`, `Connector`, `Line`, `Select`, `Placement`, and `ControlledGraph` are exported from the package root. Component subpaths are also available as `Node.svelte`, `Group.svelte`, `Connector.svelte`, `Line.svelte`, -`Select.svelte`, and `Placement.svelte`. +`Select.svelte`, `Placement.svelte`, and `ControlledGraph.svelte`. ```svelte - + + Source - + - + Result - + ); @@ -64,25 +111,54 @@ export function Graph() { ```ts framework=vanilla import { Engine } from "@snap-engine/core"; import { CollisionEngine } from "@snap-engine/core/collision"; -import { ConnectorMirror, NodeMirror } from "@snap-engine/snapline"; +import { + ConnectorMirror, + NodeMirror, + attachControlledGraph, + type LineRecord, +} from "@snap-engine/snapline"; const engine = new Engine(); engine.setCollisionEngine(new CollisionEngine()); engine.assignDom(document.querySelector("#graph")!); -const source = new NodeMirror(engine, null); +// A vanilla app owns the graph with a plain module: hold the records, +// apply each atomic proposal, and push the latest snapshot back. +let lines: LineRecord[] = []; +const handle = attachControlledGraph(engine, { + onLineChangeRequest(request) { + lines = [ + ...lines + .filter((record) => !request.remove.includes(record.id)) + .map((record) => { + const update = request.update.find((u) => u.id === record.id); + return update + ? { ...record, toConnectorId: update.toConnectorId } + : record; + }), + ...request.add, + ]; + handle.setCanonicalGraph({ lines }); + }, +}); +handle.setCanonicalGraph({ lines }); + +const source = new NodeMirror(engine, null, { id: "source" }); source.element = document.querySelector("#source")!; source.worldTransform = { x: 80, y: 90 }; source.remeasureDomGeometry(); const output = new ConnectorMirror(engine, source, { + id: "source:value", name: "value", - maxConnectors: -1, - allowDragOut: true, + rules: { maxIncoming: 0 }, }); source.addConnectorObject(output); ``` Adapters create and destroy core objects automatically unless you supply an -existing object. Vanilla integrations assign committed DOM elements and call +existing object. Topology is always controlled: your line records are the +single source of truth, gestures arrive as one atomic `LineChangeRequest` +each, and adopting a proposed line's id settles the dragged line in place. +Vanilla integrations assign committed DOM elements and call `remeasureDomGeometry()` after layout. diff --git a/docs/snapline/reference/index.mdx b/docs/snapline/reference/index.mdx index 7837a32..0ca7aa3 100644 --- a/docs/snapline/reference/index.mdx +++ b/docs/snapline/reference/index.mdx @@ -11,9 +11,9 @@ React pages. | Package | Primary exports | | --- | --- | -| `@snap-engine/snapline` | Node, connector, line, selection, group, placement, and query core APIs | -| `@snap-engine/snapline-svelte` | `Node`, `Connector`, `Line`, `Select`, `Group`, `Placement` | -| `@snap-engine/snapline-react` | `Engine`, `Node`, `Connector`, `Line`, `Select`, `Group`, `Placement` | +| `@snap-engine/snapline` | Node, connector, line, selection, group, placement, controlled-graph, and query core APIs | +| `@snap-engine/snapline-svelte` | `Node`, `Connector`, `Line`, `Select`, `Group`, `Placement`, `ControlledGraph` | +| `@snap-engine/snapline-react` | `Engine`, `Node`, `Connector`, `Line`, `Select`, `Group`, `Placement`, `ControlledGraph` | Supported core subpaths are `node`, `connector`, `line`, `select`, `group`, -`placement`, and `query`. +`placement`, `query`, `graph-mirror`, `line-reconciler`, and `geometry`. diff --git a/docs/snapline/reference/react/index.mdx b/docs/snapline/reference/react/index.mdx index bf2d82f..009c02f 100644 --- a/docs/snapline/reference/react/index.mdx +++ b/docs/snapline/reference/react/index.mdx @@ -12,12 +12,13 @@ frameworkKey: overview | Component | Important props | | ----------- | ------------------------------------------------------------------------------------------------------- | -| `Node` | `nodeObject`, geometry, resize configuration, callbacks, `lineComponent`, `elementProps` | -| `Connector` | `connectorObject`, legacy port configuration, `virtual`, `capabilities`, `surfaceStrategies`, callbacks | +| `Node` | `id`, `nodeObject`, geometry, resize configuration, callbacks, `onGeometryChanged`, `lineComponent`, `elementProps` | +| `Connector` | `id`, `connectorObject`, `rules`, `virtual`, `surfaceStrategies`, metadata, callbacks | | `Line` | `line`, SVG presentation | | `Select` | callbacks and presentation | | `Group` | node geometry, header content, membership policy and callbacks | | `Placement` | controller, cancellation behavior, render function | +| `ControlledGraph` | `lines` (your `LineRecord`s), `onLineChangeRequest`, `onDiagnosticsChanged` | `Node` and `Group` forward refs to their core objects. `Connector` exposes a `ConnectorRef`, and `useNodeHandle()` returns a callback ref for a dedicated diff --git a/docs/snapline/reference/svelte/group.mdx b/docs/snapline/reference/svelte/group.mdx index da9ff88..ffc253e 100644 --- a/docs/snapline/reference/svelte/group.mdx +++ b/docs/snapline/reference/svelte/group.mdx @@ -16,6 +16,6 @@ Use `title` for a text header or `headerContent` for a custom snippet. The header is the move surface; the group body remains pointer-transparent so member nodes receive input. -`onMembershipChange`, `onResizeCommit`, and `onDragCommit` are convenience +`onMembershipChange` and `onGeometryChanged` are convenience callbacks composed with their callback-object equivalents. `getNodeObject()` returns the core group. diff --git a/docs/snapline/reference/svelte/index.mdx b/docs/snapline/reference/svelte/index.mdx index 9c7adce..bb8846a 100644 --- a/docs/snapline/reference/svelte/index.mdx +++ b/docs/snapline/reference/svelte/index.mdx @@ -12,12 +12,13 @@ Wrap components in `Engine` from `@snap-engine/asset-base-svelte`. | Component | Important props | | ----------- | ------------------------------------------------------------------------------------------------------ | -| `Node` | `nodeObject`, `x`, `y`, `width`, `height`, resize configuration, callbacks, `elementProps` | -| `Connector` | `name`, legacy port configuration, `virtual`, `capabilities`, `surfaceStrategies`, metadata, callbacks | +| `Node` | `id`, `nodeObject`, `x`, `y`, `width`, `height`, resize configuration, callbacks, `onGeometryChanged`, `elementProps` | +| `Connector` | `id`, `name`, `rules`, `virtual`, `surfaceStrategies`, metadata, callbacks | | `Line` | `line` | | `Select` | `callbacks`, `className` | | `Group` | Node geometry plus `title`, `headerContent`, `canContain`, membership callbacks | | `Placement` | `controller`, cancellation options, `preview` snippet | +| `ControlledGraph` | `lines` (your `LineRecord`s), `onLineChangeRequest`, `onDiagnosticsChanged` | `getNodeObject()` exposes the underlying object from `Node` and `Group`; `Connector.object()` exposes its connector. Supplied objects are not destroyed @@ -25,7 +26,7 @@ when the component unmounts. A virtual connector renders no port element. Its source and target surfaces are resolved against the parent node, which is useful for whole-border diagram -connections. Connection-request payloads stay opaque to the adapter and are +connections. `LineRecord.payload` stays opaque to the adapter and is available to custom `LineSvelteComponent` renderers through `LineMirror`. Custom renderers register `line.bindGeometryWriter(...)` on mount and mutate retained SVG, Canvas, or graphics refs directly. @@ -35,6 +36,6 @@ edge-pan behavior, and line class are reactive. `virtual` can also be toggled without replacing the logical connector or its lines. `name` and `connectorObject` are construction-time identities. -Changed geometry props are authoritative. During a live pointer gesture core -writes retained element geometry directly; commit callbacks report final values -for application persistence. +Changed geometry props resynchronize the object deliberately. During a live +pointer gesture core writes retained element geometry directly; the batched +`onGeometryChanged` reports final values for application persistence. diff --git a/docs/snapline/reference/vanilla/index.mdx b/docs/snapline/reference/vanilla/index.mdx index b3c9be8..c51ffa6 100644 --- a/docs/snapline/reference/vanilla/index.mdx +++ b/docs/snapline/reference/vanilla/index.mdx @@ -12,8 +12,8 @@ frameworkKey: overview | Class | Responsibility | | --- | --- | -| `NodeMirror` | Position, selection, properties, resize, and connectors | -| `ConnectorMirror` | Connection capacity, policy, metadata, and gestures | +| `NodeMirror` | Position, selection, resize, and connectors | +| `ConnectorMirror` | Connection rules, metadata, and gestures | | `LineMirror` | Connection state and SVG geometry | | `RectSelectController` | Background rectangle selection | | `GroupNodeMirror` | Resizable exclusive membership and recursive carry | @@ -21,12 +21,17 @@ frameworkKey: overview Core objects require an engine with collision support. Assign each committed element to its object, then call `remeasureDomGeometry()`. Destroy owned objects when -their records are removed. +their records are removed. Topology is always controlled: attach a graph owner +with `attachControlledGraph(engine, { onLineChangeRequest })`, push your +`LineRecord`s through `setCanonicalGraph`, and apply each gesture's atomic +proposal to your records. ## Query helpers `getNodes`, `getConnectors`, `getGroupNodes`, and `getSelectedNodes` return -engine-scoped snapshots. `getParentGroup` returns settled ownership. +engine-scoped snapshots; `query(engine)` exposes the read-only `GraphQuery` +facade with identity lookups (`node(id)` / `connector(id)` / `line(id)`) and +`diagnostics()`. `getParentGroup` returns settled ownership. Callbacks use event objects carrying the relevant component, metadata, pointer information, and final geometry. Configuration objects are not graph diff --git a/website/svelte.config.js b/website/svelte.config.js index 10038e3..faf1a48 100644 --- a/website/svelte.config.js +++ b/website/svelte.config.js @@ -30,8 +30,16 @@ const config = { remarkPlugins: [remarkAlerts, remarkFrameworkCodeBlocks], highlight: { highlighter: (code, lang = "plaintext") => { + // Unknown fence languages (mermaid diagrams in the design docs, + // etc.) must not break the whole docs build — fall back to plain + // text instead of letting shiki throw. + const resolvedLang = highlighter + .getLoadedLanguages() + .includes(lang) + ? lang + : "plaintext"; const highlighted = highlighter.codeToHtml(code, { - lang, + lang: resolvedLang, theme: "custom-theme", }); const html = escapeSvelte( From 2863f3ef848fbd62d751cec787e24bd5a6485436 Mon Sep 17 00:00:00 2001 From: tfukaza Date: Sat, 25 Jul 2026 20:45:31 -0700 Subject: [PATCH 20/21] =?UTF-8?q?snapline:=20Phase=206=20=E2=80=94=20simpl?= =?UTF-8?q?ification=20review?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The re-architecture reviewed as a new codebase: - One structural admission check: #admitsEndpoints (roles, capacity with the in-flight line excluded, parallel rule) with an allowReplacement flag replaces the parallel gesture/record implementations; predicates live in #predicatesAdmit. Gesture admission composes both; the record paths call them strictly. - #settlePreviewLine's replacement-eviction block removed: evictions ride the atomic request and the reconciler prunes accepted removals before settling, so a settling line's target has room by construction. - The reconciler slot's local-topology notify hooks are gone — the controlled reconciler never consumed them, so the emit sites stop forwarding and GraphReconcilerLike shrinks to reconcile()/dispatch. - Dead code out: _endLineDragCleanup (no callers), the write-only #selected field (selection truth is GraphMirror.selection). - Remaining single-class _-prefixed fields converted to # privates in node.ts and select.ts; _connectors documented as the one deliberate cross-class field. pendingGestureRequest evaluated and kept (catches stalled adapter pushes); the four adapters' callback-merge blocks deliberately NOT extracted into a shared helper — a parameterized cross-framework helper would trade real clarity for deduplication. - New informational benchmark tests/ut/snapline-perf.spec.ts (no timing assertions): 200 nodes / 150 lines — cold reconcile ~4ms, warm ~0.1ms, candidate discovery ~0.34ms per pointer move, bulk load ~3.5ms. - planned-rearchitecture.md closed out as the decision record. Final matrix: tsc, check:adapters (0 errors), ut 50/50, all six snapline e2e suites green (55 tests), validate:packages (10), docs e2e green except the pre-existing llms.txt heading drift. Co-Authored-By: Claude Fable 5 --- assets/snapline/core/src/connector.ts | 165 +++++------------- assets/snapline/core/src/graph-mirror.ts | 9 +- assets/snapline/core/src/node.ts | 71 ++++---- assets/snapline/core/src/select.ts | 60 +++---- .../snapline/design/planned-rearchitecture.md | 69 ++------ tests/ut/snapline-graph-mirror.spec.ts | 2 - tests/ut/snapline-perf.spec.ts | 80 +++++++++ 7 files changed, 208 insertions(+), 248 deletions(-) create mode 100644 tests/ut/snapline-perf.spec.ts diff --git a/assets/snapline/core/src/connector.ts b/assets/snapline/core/src/connector.ts index 5f73049..2182d2f 100644 --- a/assets/snapline/core/src/connector.ts +++ b/assets/snapline/core/src/connector.ts @@ -736,24 +736,37 @@ class ConnectorMirror extends ElementObject { line: LineMirror | null, phase: "candidate" | "drop", ): boolean { + // Gestures admit an over-capacity target when its policy replaces + // (the evictions ride the atomic proposal); records never do. + if (this.#admitsEndpoints(target, line, true) !== true) return false; + return line ? this.#predicatesAdmit(target, line, phase) : true; + } + + /** + * The one structural admission check: roles, capacity (the in-flight + * line never counts against itself), and the parallel rule. + */ + #admitsEndpoints( + target: ConnectorMirror, + line: LineMirror | null, + allowReplacement: boolean, + ): true | "capacity-exceeded" | "connection-rejected" { if (target.id === this.id || !this.isSource || !target.isTarget) { - return false; + return "connection-rejected"; } - - // The in-flight line never counts against capacity or parallel checks - // (it may still be attached during an idempotent re-connect). const incoming = target .#liveIncomingLines() .filter((incomingLine) => incomingLine !== line); - - // A full target only admits when its policy makes room. + const outgoing = this.#liveOutgoingLines().filter( + (outgoingLine) => outgoingLine !== line, + ); if ( - incoming.length >= target.#rules.maxIncoming && - target.#rules.onFull === "reject" + (incoming.length >= target.#rules.maxIncoming && + !(allowReplacement && target.#rules.onFull === "replace-oldest")) || + outgoing.length >= this.#rules.maxOutgoing ) { - return false; + return "capacity-exceeded"; } - const hasParallel = incoming.some( (incomingLine) => incomingLine.start === this, ); @@ -761,22 +774,24 @@ class ConnectorMirror extends ElementObject { hasParallel && !(this.#rules.allowParallel && target.#rules.allowParallel) ) { - return false; - } - - if (line) { - const proposal: ConnectionProposal = { - line, - source: this, - target, - phase, - }; - if (this.#rules.isValidConnection?.(proposal) === false) return false; - if (target.#rules.isValidConnection?.(proposal) === false) return false; + return "connection-rejected"; } return true; } + /** Both endpoints' line-aware predicates may veto. */ + #predicatesAdmit( + target: ConnectorMirror, + line: LineMirror, + phase: "candidate" | "drop", + ): boolean { + const proposal: ConnectionProposal = { line, source: this, target, phase }; + return ( + this.#rules.isValidConnection?.(proposal) !== false && + target.#rules.isValidConnection?.(proposal) !== false + ); + } + runDragOutLine(prop: dragProp): void { if ( this.#state !== ConnectorState.DRAGGING || @@ -977,18 +992,9 @@ class ConnectorMirror extends ElementObject { line: LineMirror, target: ConnectorMirror, ): true | "capacity-exceeded" | "connection-rejected" { - const structural = this.#admitsRecordEndpoints(target, line); + const structural = this.#admitsEndpoints(target, line, false); if (structural !== true) return structural; - const proposal: ConnectionProposal = { - line, - source: this, - target, - phase: "drop", - }; - if ( - this.#rules.isValidConnection?.(proposal) === false || - target.#rules.isValidConnection?.(proposal) === false - ) { + if (!this.#predicatesAdmit(target, line, "drop")) { return "connection-rejected"; } this.#settlePreviewLine(line, target, null, "gesture", null); @@ -1005,10 +1011,6 @@ class ConnectorMirror extends ElementObject { this.parent?.updateNodeLineList(); } - _endLineDragCleanup(): void { - this.#resetGesture(); - } - startPickUpLine(line: LineMirror, prop: pointerDownProp): void { this.engine.input.setPointerDragOwner(prop.event.pointerId, line.start); line.start.#arm(prop, { @@ -1030,18 +1032,9 @@ class ConnectorMirror extends ElementObject { origin: ConnectionOrigin, payload: { value: unknown } | null, ): void { - // Explicit replacement policy: a full target admitted this line only - // because it evicts its oldest incoming lines ("replace-oldest"). - const incoming = target - .#liveIncomingLines() - .filter((incomingLine) => incomingLine !== line); - const overflow = incoming.length - target.#rules.maxIncoming + 1; - if (overflow > 0) { - for (const incomingLine of incoming.slice(0, overflow)) { - incomingLine.start.deleteLine(incomingLine, "replacement"); - } - } - + // No local eviction here: replace-oldest evictions ride the atomic + // request, and the reconciler prunes accepted removals before settling — + // by the time a line settles, its target has room by construction. const previousTarget = line.target; if (previousTarget) { previousTarget.#incomingLines = previousTarget.#incomingLines.filter( @@ -1324,20 +1317,11 @@ class ConnectorMirror extends ElementObject { target: ConnectorMirror, record: { id: string; payload?: unknown }, ): LineMirror | "capacity-exceeded" | "connection-rejected" { - const structural = this.#admitsRecordEndpoints(target, null); + const structural = this.#admitsEndpoints(target, null, false); if (structural !== true) return structural; const line = this.createLine({ id: record.id }); - const proposal: ConnectionProposal = { - line, - source: this, - target, - phase: "drop", - }; - if ( - this.#rules.isValidConnection?.(proposal) === false || - target.#rules.isValidConnection?.(proposal) === false - ) { + if (!this.#predicatesAdmit(target, line, "drop")) { line.destroy(false); return "connection-rejected"; } @@ -1356,18 +1340,9 @@ class ConnectorMirror extends ElementObject { line: LineMirror, target: ConnectorMirror, ): true | "capacity-exceeded" | "connection-rejected" { - const structural = this.#admitsRecordEndpoints(target, line); + const structural = this.#admitsEndpoints(target, line, false); if (structural !== true) return structural; - const proposal: ConnectionProposal = { - line, - source: this, - target, - phase: "drop", - }; - if ( - this.#rules.isValidConnection?.(proposal) === false || - target.#rules.isValidConnection?.(proposal) === false - ) { + if (!this.#predicatesAdmit(target, line, "drop")) { return "connection-rejected"; } this.#settlePreviewLine(line, target, null, "hydration", null); @@ -1376,36 +1351,6 @@ class ConnectorMirror extends ElementObject { /** Strict structural admission for canonical records: roles, capacity * without replacement, and the parallel rule. */ - #admitsRecordEndpoints( - target: ConnectorMirror, - line: LineMirror | null, - ): true | "capacity-exceeded" | "connection-rejected" { - if (target.id === this.id || !this.isSource || !target.isTarget) { - return "connection-rejected"; - } - const incoming = target - .#liveIncomingLines() - .filter((incomingLine) => incomingLine !== line); - const outgoing = this.#liveOutgoingLines().filter( - (outgoingLine) => outgoingLine !== line, - ); - if ( - incoming.length >= target.#rules.maxIncoming || - outgoing.length >= this.#rules.maxOutgoing - ) { - return "capacity-exceeded"; - } - const hasParallel = incoming.some( - (incomingLine) => incomingLine.start === this, - ); - if ( - hasParallel && - !(this.#rules.allowParallel && target.#rules.allowParallel) - ) { - return "connection-rejected"; - } - return true; - } #liveIncomingLines(): LineMirror[] { return this.#incomingLines.filter((line) => !line.isDeleteRequested); @@ -1438,15 +1383,6 @@ class ConnectorMirror extends ElementObject { role: "target", origin, }); - getGraphMirror(this.engine).reconciler?.notifyConnect?.({ - source: this, - target, - connector: this, - peer: target, - line, - role: "source", - origin, - }); } #emitDisconnect( @@ -1472,15 +1408,6 @@ class ConnectorMirror extends ElementObject { role: "target", reason, }); - getGraphMirror(this.engine).reconciler?.notifyDisconnect?.({ - source: this, - target, - connector: this, - peer: target, - line, - role: "source", - reason, - }); } } diff --git a/assets/snapline/core/src/graph-mirror.ts b/assets/snapline/core/src/graph-mirror.ts index 70942fb..9055558 100644 --- a/assets/snapline/core/src/graph-mirror.ts +++ b/assets/snapline/core/src/graph-mirror.ts @@ -1,8 +1,4 @@ -import type { - ConnectorMirror, - ConnectorConnectionEvent, - ConnectorDisconnectionEvent, -} from "./connector"; +import type { ConnectorMirror } from "./connector"; import type { NodeMirror } from "./node"; import type { LineMirror } from "./line"; import type { GroupNodeMirror, GroupMembershipResolver } from "./group"; @@ -39,9 +35,6 @@ export interface ReconciliationError { // sites that reach it) never value-import the reconciler module — it imports // the registry accessor, not the reverse. export interface GraphReconcilerLike { - /** Local-topology observations; the controlled reconciler ignores them. */ - notifyConnect?(event: ConnectorConnectionEvent): void; - notifyDisconnect?(event: ConnectorDisconnectionEvent): void; /** Run one reconciliation pass against the latest canonical state. Invoked * by the mirror's coalescing, batch-aware scheduler. */ reconcile?(): void; diff --git a/assets/snapline/core/src/node.ts b/assets/snapline/core/src/node.ts index 1dc8b1c..5840d5c 100644 --- a/assets/snapline/core/src/node.ts +++ b/assets/snapline/core/src/node.ts @@ -324,14 +324,15 @@ class NodeMirror extends ElementObject { * the engine-internal `BaseObject.id`. */ readonly nodeId: string; #config: Required>; + /** @internal Name-keyed live connectors; written by ConnectorMirror's + * assignToNode/destroy — the one deliberate cross-class field. */ _connectors: { [key: string]: ConnectorMirror }; - _dragStartX = 0; - _dragStartY = 0; + #dragStartX = 0; + #dragStartY = 0; _nodeStyle: any; #hitBox: RectCollider; - _selected: boolean; - _mouseDownX: number; - _mouseDownY: number; + #mouseDownX: number; + #mouseDownY: number; _hasMoved: boolean; #resizeHitBoxes = new Map(); #resizeHandles: readonly ResizeHandle[]; @@ -339,7 +340,7 @@ class NodeMirror extends ElementObject { #resizeHoverController: ResizeHoverController | null = null; #activeResizeHandle: ResizeHandle | null = null; /** Read by GroupNodeMirror to distinguish a resize from a move drag. */ - protected _resizing = false; + #resizing = false; #resizeArmed = false; #resizeStartW = 0; #resizeStartH = 0; @@ -374,10 +375,10 @@ class NodeMirror extends ElementObject { DEFAULT_RESIZE_HANDLE_THICKNESS; this._connectors = {}; - this._dragStartX = this.worldTransform.x; - this._dragStartY = this.worldTransform.y; - this._mouseDownX = 0; - this._mouseDownY = 0; + this.#dragStartX = this.worldTransform.x; + this.#dragStartY = this.worldTransform.y; + this.#mouseDownX = 0; + this.#mouseDownY = 0; this.transformMode = "direct"; this.event.input.pointerDown = this.onCursorDown; @@ -408,7 +409,6 @@ class NodeMirror extends ElementObject { this.#positionResizeHitBoxes(0, 0); } - this._selected = false; this._hasMoved = false; // Whenever the DOM box changes size (ResizeObserver) re-measure + re-glue. @@ -456,12 +456,11 @@ class NodeMirror extends ElementObject { } setStartPositions() { - this._dragStartX = this.worldTransform.x; - this._dragStartY = this.worldTransform.y; + this.#dragStartX = this.worldTransform.x; + this.#dragStartY = this.worldTransform.y; } setSelected(selected: boolean) { - this._selected = selected; this.dataAttribute = { selected: String(selected), "snapline-state": selected ? "focus" : "idle", @@ -765,13 +764,13 @@ class NodeMirror extends ElementObject { onDragStart(prop: dragStartProp): void { if (this.#dragPointerId !== prop.pointerId) return; if (this.#resizeArmed) { - this._resizing = true; + this.#resizing = true; this.#resizeStartW = this.#hitBox.width; this.#resizeStartH = this.#hitBox.height; this.#resizeStartX = this.worldTransform.x; this.#resizeStartY = this.worldTransform.y; - this._mouseDownX = prop.start.x; - this._mouseDownY = prop.start.y; + this.#mouseDownX = prop.start.x; + this.#mouseDownY = prop.start.y; this._hasMoved = true; // Guard so releasing a resize over another node doesn't click-select it. getGraphMirror(this.engine).resizingNode = this; @@ -814,10 +813,10 @@ class NodeMirror extends ElementObject { console.error("Global stats is null"); return; } - if (this._resizing) { + if (this.#resizing) { this.#applyResizeDrag( - prop.position.x - this._mouseDownX, - prop.position.y - this._mouseDownY, + prop.position.x - this.#mouseDownX, + prop.position.y - this.#mouseDownY, ); return; } @@ -846,8 +845,8 @@ class NodeMirror extends ElementObject { /** @internal Hook used to build one deduplicated multi-selection drag session. */ beginSelectionDrag(position: eventPosition): void { this.setStartPositions(); - this._mouseDownX = position.x; - this._mouseDownY = position.y; + this.#mouseDownX = position.x; + this.#mouseDownY = position.y; } /** @internal Whether this node's drag behavior already carries `node`. */ @@ -864,16 +863,16 @@ class NodeMirror extends ElementObject { finishSelectionDrag(): void {} setDragPosition(prop: dragProp) { - const dx = prop.position.x - this._mouseDownX; - const dy = prop.position.y - this._mouseDownY; - const x = this._dragStartX + dx; - const y = this._dragStartY + dy; + const dx = prop.position.x - this.#mouseDownX; + const dy = prop.position.y - this.#mouseDownY; + const x = this.#dragStartX + dx; + const y = this.#dragStartY + dy; const resolved = this.#callbacks.resolveDragPosition?.({ node: this, x, y, - startX: this._dragStartX, - startY: this._dragStartY, + startX: this.#dragStartX, + startY: this.#dragStartY, position: prop.position, }) ?? { x, y }; @@ -890,10 +889,10 @@ class NodeMirror extends ElementObject { this.engine.edgePanController?.stopEdgePan(this.#edgePanPointerId); this.#edgePanPointerId = null; } - if (this._resizing) { + if (this.#resizing) { this.#applyResizeDrag( - prop.end.x - this._mouseDownX, - prop.end.y - this._mouseDownY, + prop.end.x - this.#mouseDownX, + prop.end.y - this.#mouseDownY, ); // Pointer-up is a synchronization boundary for consumers that immediately // query the committed handle/box. Keep the coalesced frame write for the @@ -902,7 +901,7 @@ class NodeMirror extends ElementObject { this.#callbacks.onGeometryChanged?.({ nodes: [this.#geometryOf(this)], }); - this._resizing = false; + this.#resizing = false; this.#resizeArmed = false; this.#activeResizeHandle = null; getGraphMirror(this.engine).resizingNode = null; @@ -964,12 +963,12 @@ class NodeMirror extends ElementObject { setUpPosition(prop: dragEndProp) { const [dx, dy] = [ - prop.end.x - this._mouseDownX, - prop.end.y - this._mouseDownY, + prop.end.x - this.#mouseDownX, + prop.end.y - this.#mouseDownY, ]; this.worldTransform = { - x: this._dragStartX + dx, - y: this._dragStartY + dy, + x: this.#dragStartX + dx, + y: this.#dragStartY + dy, }; this.scheduleTransformAndLines(); } diff --git a/assets/snapline/core/src/select.ts b/assets/snapline/core/src/select.ts index 7fe075a..d8e303c 100644 --- a/assets/snapline/core/src/select.ts +++ b/assets/snapline/core/src/select.ts @@ -47,10 +47,10 @@ export interface SelectConfig { } class RectSelectController extends ElementObject { - _state: "none" | "dragging"; - _mouseDownX: number; - _mouseDownY: number; - _selectHitBox: Collider; + #state: "none" | "dragging"; + #mouseDownX: number; + #mouseDownY: number; + #selectHitBox: Collider; #callbacks: SelectCallbacks; #geometryWriter: GeometryWriter | null = null; #rect: SelectRect = { @@ -70,19 +70,19 @@ class RectSelectController extends ElementObject { ) { super(engine, parent); - this._state = "none"; - this._mouseDownX = 0; - this._mouseDownY = 0; + this.#state = "none"; + this.#mouseDownX = 0; + this.#mouseDownY = 0; this.event.global.pointerDown = this.onGlobalCursorDown; this.event.global.pointerMove = this.onGlobalCursorMove; this.event.global.pointerUp = this.onGlobalCursorUp; - this._selectHitBox = new RectCollider(engine, this, 0, 0, 0, 0); - this._selectHitBox.localTransform = { x: 0, y: 0 }; - this._selectHitBox.event.collider.onCollide = this.onCollideNode; + this.#selectHitBox = new RectCollider(engine, this, 0, 0, 0, 0); + this.#selectHitBox.localTransform = { x: 0, y: 0 }; + this.#selectHitBox.event.collider.onCollide = this.onCollideNode; - this.addCollider(this._selectHitBox); + this.addCollider(this.#selectHitBox); // A fresh selection controller starts its engine from an empty selection. getGraphMirror(this.engine).selection.length = 0; @@ -147,18 +147,18 @@ class RectSelectController extends ElementObject { // worldTransform positions the selection collider (its transform parent); // the registered writer updates the visual box during WRITE_2. this.worldTransform = { x: prop.position.x, y: prop.position.y }; - this._state = "dragging"; - this._mouseDownX = prop.position.x; - this._mouseDownY = prop.position.y; - this._selectHitBox.width = 0; - this._selectHitBox.height = 0; + this.#state = "dragging"; + this.#mouseDownX = prop.position.x; + this.#mouseDownY = prop.position.y; + this.#selectHitBox.width = 0; + this.#selectHitBox.height = 0; this.#fireRect(0, 0, true); this.#callbacks.onSelectionChange?.({ select: this, selection: [...getGraphMirror(this.engine).selection], }); - this._selectHitBox.event.collider.onBeginContact = ( + this.#selectHitBox.event.collider.onBeginContact = ( _: Collider, otherObject: Collider, ) => { @@ -175,7 +175,7 @@ class RectSelectController extends ElementObject { }); } }; - this._selectHitBox.event.collider.onEndContact = ( + this.#selectHitBox.event.collider.onEndContact = ( _thisObject: Collider, otherObject: Collider, ) => { @@ -191,29 +191,29 @@ class RectSelectController extends ElementObject { } onGlobalCursorMove(prop: pointerMoveProp): void { - if (this._state === "dragging") { + if (this.#state === "dragging") { let [boxOriginX, boxOriginY] = [ - Math.min(this._mouseDownX, prop.position.x), - Math.min(this._mouseDownY, prop.position.y), + Math.min(this.#mouseDownX, prop.position.x), + Math.min(this.#mouseDownY, prop.position.y), ]; let [boxWidth, boxHeight] = [ - Math.abs(prop.position.x - this._mouseDownX), - Math.abs(prop.position.y - this._mouseDownY), + Math.abs(prop.position.x - this.#mouseDownX), + Math.abs(prop.position.y - this.#mouseDownY), ]; this.worldTransform = { x: boxOriginX, y: boxOriginY }; - this._selectHitBox.localTransform = { x: 0, y: 0 }; - this._selectHitBox.width = boxWidth; - this._selectHitBox.height = boxHeight; + this.#selectHitBox.localTransform = { x: 0, y: 0 }; + this.#selectHitBox.width = boxWidth; + this.#selectHitBox.height = boxHeight; this.#fireRect(boxWidth, boxHeight, true); } } onGlobalCursorUp(_prop: pointerUpProp): void { - const wasDragging = this._state === "dragging"; - this._state = "none"; + const wasDragging = this.#state === "dragging"; + this.#state = "none"; - this._selectHitBox.event.collider.onBeginContact = null; - this._selectHitBox.event.collider.onEndContact = null; + this.#selectHitBox.event.collider.onBeginContact = null; + this.#selectHitBox.event.collider.onEndContact = null; if (wasDragging) this.#fireRect(0, 0, false); } diff --git a/docs/snapline/design/planned-rearchitecture.md b/docs/snapline/design/planned-rearchitecture.md index c03f9f2..2215504 100644 --- a/docs/snapline/design/planned-rearchitecture.md +++ b/docs/snapline/design/planned-rearchitecture.md @@ -1,7 +1,6 @@ # SnapLine planned re-architecture -Status: living planning document — **implementation complete through the -adapter/consumer phases**; only the final simplification review remains. +Status: complete — retained as the decision record for the re-architecture. Started: 2026-07-25 Related: [current architecture](./current-architecture.md) · @@ -36,54 +35,18 @@ Two decisions were amended from the original plan (both user-directed): `onGeometryChanged` observation replaces `onDragCommit` + `onResizeCommit`. -## Final phase: post-rearchitecture simplification review - -After the re-architecture has settled, review the result as a new codebase -rather than assuming every intermediate abstraction must survive. This phase -may simplify aggressively, but must preserve the ownership model, public -behavior, and verified performance. - -### Review method - -- Trace every major lifecycle (connect, reconnect, disconnect, hydration, - bulk load, selection, teardown) from public entry point to final state. -- Remove redundant adapters, forwarding callbacks, snapshots, indexes, - schedulers, wrappers, and migration-only internals. -- Known consolidation candidates from the migration itself: - - unify the gesture admission check with the strict record admission - check (`#admitsConnection` / `#admitsRecordEndpoints`); - - the settle path's replacement-eviction block should be dead now that - accepted replaces prune first — verify and remove; - - the reconciler slot's local-topology notify forwarding - (`notifyConnect`/`notifyDisconnect`) has no consumer — drop it from the - emit sites if nothing needs it; - - evaluate whether `pendingGestureRequest` still earns its keep; - - remaining `_`-prefixed cross-class fields → `#` or documented - internals; delete `_endLineDragCleanup` if unused; - - a shared adapter callback-merge helper to replace the duplicated - monkey-patch blocks in the four Node/Group adapters. -- Profile before retaining or adding non-obvious optimization: an - informational (non-asserting) unit benchmark over a ~200-node/300-line - synthetic graph timing the reconcile pass, per-pointer-move candidate - discovery, and a bulk load. -- Repeat the vocabulary audit: one concept, one name; `on*` for - observations/requests, `is*`/`can*` for predicates. -- Finish with the complete verification matrix: all SnapLine unit and e2e - suites, `validate:packages`, the docs e2e, and the lab consumer checks. - -### Exit criteria - -- Each major lifecycle has one obvious path through the code. -- No layer, registry, callback, or snapshot merely duplicates another. -- Public APIs expose the smallest surface the ownership model requires. -- All tests, type checks, adapter suites, browser tests, and relevant - benchmarks pass after the simplification. - -## Remaining task board - -- [ ] Trace every major lifecycle through the completed architecture. -- [ ] Apply the consolidation candidates above (verify each is truly - redundant before removing). -- [ ] Add the informational profiling benchmark. -- [ ] Repeat the naming and public-surface audits. -- [ ] Run the complete verification matrix again. +## Status: complete + +The final simplification review has landed: the gesture and record admission +checks share one structural check plus one predicate check, the settle path's +redundant eviction block and the reconciler's unused local-topology notify +forwarding are gone, remaining single-class `_`-prefixed fields moved to `#` +privates (with `_connectors` documented as the one deliberate cross-class +field), and an informational perf benchmark +(`tests/ut/snapline-perf.spec.ts`) guards the reconcile / candidate-discovery +/ bulk-load hot paths. The complete verification matrix — unit suites, all +six SnapLine e2e suites, package validation, and the docs e2e — passes. + +This document is retained as the record of the re-architecture's decisions +and amendments; the implementation is described in +[current architecture](./current-architecture.md). diff --git a/tests/ut/snapline-graph-mirror.spec.ts b/tests/ut/snapline-graph-mirror.spec.ts index e70a6fb..08820ff 100644 --- a/tests/ut/snapline-graph-mirror.spec.ts +++ b/tests/ut/snapline-graph-mirror.spec.ts @@ -199,8 +199,6 @@ test("the scheduler coalesces bursts and defers passes to the outermost batch en const mirror = getGraphMirror(engine); let passes = 0; mirror.reconciler = { - notifyConnect() {}, - notifyDisconnect() {}, reconcile: () => { passes += 1; }, diff --git a/tests/ut/snapline-perf.spec.ts b/tests/ut/snapline-perf.spec.ts new file mode 100644 index 0000000..239a926 --- /dev/null +++ b/tests/ut/snapline-perf.spec.ts @@ -0,0 +1,80 @@ +import { test } from "@playwright/test"; +import { + ConnectorMirror, + NodeMirror, + type LineRecord, +} from "../../assets/snapline/core/src"; +import { getGraphMirror } from "../../assets/snapline/core/src/snapline-globals"; +import { + createControlledHarness, + nearTargetStrategy, +} from "../helpers/snapline-harness"; + +// Informational only — no assertions on timings. Logs the hot paths so a +// pathological regression (accidental O(n²), per-pointer-move scans) is +// visible in test output before it reaches an application. +test("perf: reconcile, candidate discovery, and bulk load on a 200-node graph", () => { + const { engine, handle } = createControlledHarness(); + const mirror = getGraphMirror(engine); + + const NODES = 200; + const LINES = 150; + const sources: ConnectorMirror[] = []; + for (let index = 0; index < NODES; index += 1) { + const node = new NodeMirror(engine, null, { id: `n${index}` }); + sources.push( + new ConnectorMirror(engine, node, { + id: `n${index}:out`, + name: "out", + rules: { maxIncoming: 0 }, + }), + ); + new ConnectorMirror(engine, node, { + id: `n${index}:in`, + name: "in", + rules: { maxOutgoing: 0 }, + surfaceStrategies: [nearTargetStrategy], + }); + } + const records: LineRecord[] = []; + for (let index = 0; index < LINES; index += 1) { + records.push({ + id: `l${index}`, + fromConnectorId: `n${index}:out`, + toConnectorId: `n${(index + 1) % NODES}:in`, + }); + } + + const coldStart = performance.now(); + handle.setCanonicalGraph({ lines: records }); + handle.flush(); + const cold = performance.now() - coldStart; + + const warmStart = performance.now(); + handle.flush(); + const warm = performance.now() - warmStart; + + // Candidate discovery is the per-pointer-move hot path during a drag. + const probeStart = performance.now(); + const PROBES = 100; + for (let index = 0; index < PROBES; index += 1) { + sources[0].findCandidateAtPoint({ x: 100 + (index % 50), y: 10 }); + } + const probe = (performance.now() - probeStart) / PROBES; + + // Bulk load: fresh records over a cleared document inside one batch. + handle.setCanonicalGraph({ lines: [] }); + handle.flush(); + const bulkStart = performance.now(); + const batch = mirror.beginBatch(); + handle.setCanonicalGraph({ lines: records }); + batch.end(); + mirror.flush(); + const bulk = performance.now() - bulkStart; + + console.log( + `[snapline-perf] ${NODES} nodes / ${LINES} lines — ` + + `cold reconcile ${cold.toFixed(1)}ms, warm ${warm.toFixed(1)}ms, ` + + `candidate probe ${probe.toFixed(3)}ms/move, bulk load ${bulk.toFixed(1)}ms`, + ); +}); From 87b08a0ac4369e13373dc1b1276aaddc75a7a4df Mon Sep 17 00:00:00 2001 From: tfukaza Date: Sun, 26 Jul 2026 17:51:54 -0700 Subject: [PATCH 21/21] Snapshot SnapEngineJS line reconciliation state --- assets/snapline/core/src/line-reconciler.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/assets/snapline/core/src/line-reconciler.ts b/assets/snapline/core/src/line-reconciler.ts index b030a4f..a50f0c6 100644 --- a/assets/snapline/core/src/line-reconciler.ts +++ b/assets/snapline/core/src/line-reconciler.ts @@ -172,6 +172,8 @@ export class LineReconciler { if (settled !== true) { preview.start.discardStagedLine(preview); errors.push(this.#ruleError(settled, record)); + } else { + preview.setPayload(record.payload); } continue; }