From d4b20b95be8f94e291c98b21dff17eb7bc0a1960 Mon Sep 17 00:00:00 2001 From: tfukaza Date: Mon, 27 Jul 2026 21:05:39 -0700 Subject: [PATCH 01/10] =?UTF-8?q?snapline:=20Phase=201a=20=E2=80=94=20dele?= =?UTF-8?q?te=20dead=20code=20and=20collapse=20duplicated=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removes code with no reachable callers anywhere in core, adapters, demos, website, tests, or docs, and collapses public surfaces that offered two ways to do one thing (SNAPZEN: "there should only be one way to do something"). Unreachable methods: ConnectorMirror.requestDomGeometrySync, writeAllLinesNow, findClosestConnector, findClosestConnectorAtPoint, hoverWhileDragging, startPickUpLine, disconnectFromConnector; LineMirror.setLineStartAtConnector/setLineEndAtConnector; NodeMirror.setUpPosition. Unread state: GraphMirror.reconcilerActive (its comment described an authority gate that no longer exists), ConnectorMirror.#targetConnector and its accessors, numIncomingLines/numOutgoingLines, the never-populated LineChangeRequest.originalEvent, and 4 of 7 ReconciliationError codes that nothing emits. Dead accessors and no-op overrides: the `callbacks` setters on ConnectorMirror and NodeMirror (adapters use updateConfig or in-place mutation), NodeMirror.writeTransformRecursive and GroupNodeMirror.writeTransformAndLines (pure super passthroughs — the former carried a comment describing a re-glue it never performed; the re-glue actually happens via the sibling scheduleLineWrites in scheduleTransformAndLines), and RectSelectController.onCollideNode. Collapses LineMirror's seven positioning methods to three: setLineStart, setLineEnd, setLinePosition, endWorldX/endWorldY, and the moveLineToConnectorTransform alias are gone in favour of setLineStartAnchor/setLineEndAnchor/updateAnchors. cloneAnchor was defined byte-identically in line.ts and connector.ts; connector.ts now imports it. Removes the query.ts free functions and the package.json subpath exports map (both gave every symbol a second import path with no consumers). Adds GraphQuery.selection() first — getSelectedNodes was the only one of the four with no query() equivalent, so deleting it outright would have removed the sole public way to read the selection. Docs updated for every removed symbol. Co-Authored-By: Claude Opus 5 (1M context) --- SNAPZEN.md | 2 +- assets/snapline/core/README.md | 6 +- assets/snapline/core/package.json | 12 +- assets/snapline/core/src/connector.ts | 151 +----------------- assets/snapline/core/src/graph-mirror.ts | 14 +- assets/snapline/core/src/group.ts | 4 - assets/snapline/core/src/index.ts | 8 +- assets/snapline/core/src/line-reconciler.ts | 3 - assets/snapline/core/src/line.ts | 64 +------- assets/snapline/core/src/node.ts | 26 +-- assets/snapline/core/src/query.ts | 29 +--- assets/snapline/core/src/select.ts | 2 - assets/snapline/react/src/Group.tsx | 1 - docs/snapline/design/current-architecture.md | 23 +-- docs/snapline/design/migration-notes.md | 5 +- docs/snapline/guides/02_selection_resize.mdx | 2 +- docs/snapline/reference/react/connector.mdx | 2 +- .../reference/react/controlled-graph.mdx | 2 +- docs/snapline/reference/react/select.mdx | 2 +- docs/snapline/reference/svelte/connector.mdx | 2 +- .../reference/svelte/controlled-graph.mdx | 2 +- docs/snapline/reference/svelte/select.mdx | 2 +- docs/snapline/reference/vanilla/connector.mdx | 5 +- .../reference/vanilla/controlled-graph.mdx | 2 +- docs/snapline/reference/vanilla/index.mdx | 6 +- docs/snapline/reference/vanilla/node.mdx | 2 +- docs/snapline/reference/vanilla/select.mdx | 2 +- tests/ut/snapline-connector-config.spec.ts | 3 +- 28 files changed, 48 insertions(+), 336 deletions(-) diff --git a/SNAPZEN.md b/SNAPZEN.md index 88ccafd..58c48f6 100644 --- a/SNAPZEN.md +++ b/SNAPZEN.md @@ -10,5 +10,5 @@ - The engine owns the representation. - When representation must change due to data mutation, request the engine to update. -- No external dependancies. +- No external dependencies. - Prioritize code maintainability, reliability, feature set, browser support, bundle size, in that order. diff --git a/assets/snapline/core/README.md b/assets/snapline/core/README.md index 3734882..8fbe0cb 100644 --- a/assets/snapline/core/README.md +++ b/assets/snapline/core/README.md @@ -47,9 +47,9 @@ 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 -line without coupling SnapLine to the external system. +call `node.remeasureDomGeometry()`. The remeasure is coalesced into the next +read/write cycle and re-glues every connected 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 diff --git a/assets/snapline/core/package.json b/assets/snapline/core/package.json index 37da1a5..74abcf9 100644 --- a/assets/snapline/core/package.json +++ b/assets/snapline/core/package.json @@ -11,17 +11,7 @@ "main": "./src/index.ts", "types": "./src/index.ts", "exports": { - ".": "./src/index.ts", - "./node": "./src/node.ts", - "./connector": "./src/connector.ts", - "./line": "./src/line.ts", - "./select": "./src/select.ts", - "./group": "./src/group.ts", - "./placement": "./src/placement.ts", - "./query": "./src/query.ts", - "./graph-mirror": "./src/graph-mirror.ts", - "./line-reconciler": "./src/line-reconciler.ts", - "./geometry": "./src/geometry.ts" + ".": "./src/index.ts" }, "files": [ "src", diff --git a/assets/snapline/core/src/connector.ts b/assets/snapline/core/src/connector.ts index b9f2c3a..a269254 100644 --- a/assets/snapline/core/src/connector.ts +++ b/assets/snapline/core/src/connector.ts @@ -9,9 +9,8 @@ import type { } from "@snap-engine/core"; import { CircleCollider } from "@snap-engine/core/collision"; import type { NodeMirror } from "./node"; -import { LineMirror, type LineMirrorPhase } from "./line"; -import { getGraphMirror } from "./snapline-globals"; -import { getSourceSurfaces } from "./snapline-globals"; +import { LineMirror, cloneAnchor, type LineMirrorPhase } from "./line"; +import { getGraphMirror, getSourceSurfaces } from "./snapline-globals"; import { mintDomainId } from "./graph-mirror"; import type { LineChangeRequest } from "./line-reconciler"; @@ -234,7 +233,6 @@ class ConnectorMirror extends ElementObject { #state: ConnectorState = ConnectorState.IDLE; #hitCircle: CircleCollider; - #targetConnector: ConnectorMirror | null = null; #candidate: ConnectorResolvedHit | null = null; #dragLine: LineMirror | null = null; #edgePanPointerId: number | null = null; @@ -328,12 +326,8 @@ class ConnectorMirror extends ElementObject { return this.#callbacks; } - set callbacks(callbacks: ConnectorCallbacks) { - this.updateConfig({ callbacks }); - } - // Snapshots, never the internal arrays — topology mutation goes through - // connectToConnector/deleteLine/disconnectFromConnector. + // the reconciler-only lifecycle operations, never these accessors. get outgoingLines(): readonly LineMirror[] { return [...this.#outgoingLines]; } @@ -342,35 +336,6 @@ class ConnectorMirror extends ElementObject { return [...this.#incomingLines]; } - get targetConnector(): ConnectorMirror | null { - return this.#targetConnector; - } - - set targetConnector(value: ConnectorMirror | null) { - const resolved = value - ? { - candidate: { - connector: value, - hit: { - anchor: value.center, - distance: 0, - }, - }, - strategy: value.#defaultAnchorStrategy(), - strategyIndex: -1, - } - : null; - this.#setCandidate(resolved); - } - - get numIncomingLines(): number { - return this.#incomingLines.length; - } - - get numOutgoingLines(): number { - return this.#outgoingLines.length; - } - /** * Updates runtime connector policy without replacing the connector or its * existing topology. `name` remains construction-only because it keys the @@ -479,34 +444,6 @@ class ConnectorMirror extends ElementObject { }; } - requestDomGeometrySync(): boolean { - if (!this.element?.isConnected || !this.parent) return false; - - this.schedule( - () => { - if (!this.element?.isConnected || !this.parent) return; - this.measureLocalCenter("READ_1"); - }, - { - stage: "READ_1", - queueId: `${this.id}-dom-geometry`, - }, - ); - for (const line of [...this.#outgoingLines, ...this.#incomingLines]) { - line.schedule( - () => { - line.moveLineToConnectorTransform(); - line.writeTransform(); - }, - { - stage: "WRITE_1", - queueId: `${line.id}-dom-geometry`, - }, - ); - } - return true; - } - onCursorDown(prop: pointerDownProp): void { if (prop.event.button !== 0) return; const sourceHit = this.#resolveOwnSourceHit(prop.position, "source-start"); @@ -644,7 +581,7 @@ class ConnectorMirror extends ElementObject { for (const line of [...this.#outgoingLines, ...this.#incomingLines]) { line.schedule( () => { - line.moveLineToConnectorTransform(); + line.updateAnchors(); line.writeTransform(); }, { @@ -655,13 +592,6 @@ class ConnectorMirror extends ElementObject { } } - writeAllLinesNow(): void { - for (const line of [...this.#outgoingLines, ...this.#incomingLines]) { - line.moveLineToConnectorTransform(); - line.writeTransform(); - } - } - assignToNode(parent: NodeMirror): void { this.parent = parent; const parentRef = this.parent; @@ -681,27 +611,6 @@ class ConnectorMirror extends ElementObject { return line; } - findClosestConnector(): void { - if (!this.#dragLine) { - this.#setCandidate(null); - return; - } - const position = { - ...this.#dragLine.endAnchor, - cameraX: this.#dragLine.endAnchor.x, - cameraY: this.#dragLine.endAnchor.y, - screenX: this.#dragLine.endAnchor.x, - screenY: this.#dragLine.endAnchor.y, - }; - this.#setCandidate(this.#resolveTargetAtPoint(position, "preview-target")); - } - - findClosestConnectorAtPoint( - position: ConnectorPoint, - ): ConnectorMirror | null { - return this.findCandidateAtPoint(position)?.connector ?? null; - } - findCandidateAtPoint( position: ConnectorPoint, phase: "preview-target" | "drop" = "preview-target", @@ -821,24 +730,6 @@ class ConnectorMirror extends ElementObject { }); } - hoverWhileDragging( - targetConnector: ConnectorMirror, - ): [number, number] | void { - if (!(targetConnector instanceof ConnectorMirror) || !this.#dragLine) { - return; - } - const anchor = targetConnector.resolveAnchor({ - line: this.#dragLine, - role: "target", - phase: "preview-target", - peer: this, - position: this.geometry.center, - hit: this.#candidate?.candidate.hit ?? null, - strategy: this.#candidate?.strategy ?? null, - }); - return [anchor.x, anchor.y]; - } - endDragOutLine(prop: dragEndProp): void { if ( this.#state !== ConnectorState.DRAGGING || @@ -1008,15 +899,6 @@ class ConnectorMirror extends ElementObject { this.parent?.updateNodeLineList(); } - startPickUpLine(line: LineMirror, prop: pointerDownProp): void { - this.engine.input.setPointerDragOwner(prop.event.pointerId, line.start); - line.start.#arm(prop, { - sourceHit: null, - sourceStrategy: null, - reconnectLine: line, - }); - } - /** * Settle a line that already sits in this connector's outgoing list onto * its target: run the explicit replacement policy, detach any previous @@ -1057,17 +939,6 @@ class ConnectorMirror extends ElementObject { this.#emitConnect(target, line, origin); } - /** @internal Reconciler/teardown-only. */ - disconnectFromConnector( - connector: ConnectorMirror, - reason: DisconnectReason = "programmatic", - ): void { - const line = this.#outgoingLines.find( - (outgoingLine) => outgoingLine.target === connector, - ); - if (line) this.deleteLine(line, reason); - } - resolveAnchor({ line, role, @@ -1221,7 +1092,6 @@ class ConnectorMirror extends ElementObject { return; } this.#candidate = candidate; - this.#targetConnector = candidate?.candidate.connector ?? null; this.#dragLine?.setCandidate( candidate?.candidate ?? null, candidate?.strategy ?? null, @@ -1346,9 +1216,6 @@ class ConnectorMirror extends ElementObject { return true; } - /** Strict structural admission for canonical records: roles, capacity - * without replacement, and the parallel rule. */ - #liveIncomingLines(): LineMirror[] { return this.#incomingLines.filter((line) => !line.isDeleteRequested); } @@ -1441,16 +1308,6 @@ function isFinitePoint(point: ConnectorPoint): boolean { return Number.isFinite(point.x) && Number.isFinite(point.y); } -function cloneAnchor(anchor: ConnectorAnchor): ConnectorAnchor { - return { - x: anchor.x, - y: anchor.y, - ...(anchor.normal - ? { normal: { x: anchor.normal.x, y: anchor.normal.y } } - : {}), - }; -} - function asEventPosition(position: ConnectorPoint): eventPosition { const value = position as Partial; return { diff --git a/assets/snapline/core/src/graph-mirror.ts b/assets/snapline/core/src/graph-mirror.ts index 9055558..88f3079 100644 --- a/assets/snapline/core/src/graph-mirror.ts +++ b/assets/snapline/core/src/graph-mirror.ts @@ -16,14 +16,7 @@ export type LineId = string; * represent. Derived state: entries drop out when their cause resolves. */ export interface ReconciliationError { - code: - | "duplicate-id" - | "missing-node" - | "missing-connector" - | "capacity-exceeded" - | "connection-rejected" - | "identity-changed" - | "unrepresentable-line"; + code: "duplicate-id" | "capacity-exceeded" | "connection-rejected"; lineId?: LineId; nodeId?: NodeId; connectorId?: ConnectorId; @@ -84,11 +77,6 @@ export class GraphMirror { // reach it through this slot. reconciler: GraphReconcilerLike | null = null; - /** @internal True while a reconciler pass mutates topology on the - * canonical document's behalf — those mutations bypass the authority gate - * on imperative commands. */ - reconcilerActive = false; - /** @internal One in-flight gesture request per engine (gestures are * serial); cleared by the next reconciliation pass. */ pendingGestureRequest = false; diff --git a/assets/snapline/core/src/group.ts b/assets/snapline/core/src/group.ts index 728cdb0..df44c92 100644 --- a/assets/snapline/core/src/group.ts +++ b/assets/snapline/core/src/group.ts @@ -288,10 +288,6 @@ class GroupNodeMirror extends NodeMirror { this.#members = members; } - writeTransformAndLines(): void { - super.writeTransformAndLines(); - } - allowsMembership(node: NodeMirror): boolean { const box = this.hitBox.getWorldBoundsSnapshot(); const nodeBounds = node.hitBox.getWorldBoundsSnapshot(); diff --git a/assets/snapline/core/src/index.ts b/assets/snapline/core/src/index.ts index 7c985b0..4689649 100644 --- a/assets/snapline/core/src/index.ts +++ b/assets/snapline/core/src/index.ts @@ -78,13 +78,7 @@ export type { SelectRect, SelectStartEvent, } from "./select"; -export { - getConnectors, - getGroupNodes, - getNodes, - getSelectedNodes, - query, -} from "./query"; +export { query } from "./query"; export type { GraphQuery } from "./query"; export { PlacementController } from "./placement"; export type { diff --git a/assets/snapline/core/src/line-reconciler.ts b/assets/snapline/core/src/line-reconciler.ts index a50f0c6..d87a9f6 100644 --- a/assets/snapline/core/src/line-reconciler.ts +++ b/assets/snapline/core/src/line-reconciler.ts @@ -40,7 +40,6 @@ export interface LineChangeRequest { add: readonly ProposedLine[]; remove: readonly LineId[]; update: readonly LineEndpointUpdate[]; - originalEvent?: PointerEvent; } export interface ControlledGraphCallbacks { @@ -96,7 +95,6 @@ export class LineReconciler { if (this.#reconciling || this.#disposed) return; this.#reconciling = true; const mirror = this.#mirror; - mirror.reconcilerActive = true; const errors: ReconciliationError[] = []; try { // Canonical records by id — duplicates never silently collapse. @@ -204,7 +202,6 @@ export class LineReconciler { } } finally { this.#reconciling = false; - mirror.reconcilerActive = false; mirror.pendingGestureRequest = false; } diff --git a/assets/snapline/core/src/line.ts b/assets/snapline/core/src/line.ts index 5e409b4..9ee1a57 100644 --- a/assets/snapline/core/src/line.ts +++ b/assets/snapline/core/src/line.ts @@ -87,14 +87,6 @@ class LineMirror extends ElementObject { return this.#endAnchor; } - get endWorldX(): number { - return this.#endAnchor.x; - } - - get endWorldY(): number { - return this.#endAnchor.y; - } - get phase(): LineMirrorPhase { return this.#phase; } @@ -246,46 +238,6 @@ class LineMirror extends ElementObject { if (changed) this.#emitStateChange(); } - setLineStartAtConnector(): void { - const peer = this.target ?? this.candidate?.connector ?? null; - const peerGeometry = peer?.geometry ?? null; - const position = - peerGeometry?.center ?? this.#previewPosition ?? this.endAnchor; - const anchor = this.start.resolveAnchor({ - line: this, - role: "source", - phase: this.phase, - peer, - position, - hit: this.#sourceHit, - strategy: this.#sourceStrategy, - }); - this.setLineStartAnchor(anchor); - } - - setLineEndAtConnector(): void { - const target = this.target ?? this.candidate?.connector ?? null; - if (!target) return; - const anchor = target.resolveAnchor({ - line: this, - role: "target", - phase: this.phase, - peer: this.start, - position: this.start.geometry.center, - hit: this.#targetHit, - strategy: this.#targetStrategy, - }); - this.setLineEndAnchor(anchor); - } - - setLineStart(startPositionX: number, startPositionY: number): void { - this.setLineStartAnchor({ x: startPositionX, y: startPositionY }); - } - - setLineEnd(endWorldX: number, endWorldY: number): void { - this.setLineEndAnchor({ x: endWorldX, y: endWorldY }); - } - setLineStartAnchor(anchor: ConnectorAnchor): void { this.#startAnchor = cloneAnchor(anchor); this.worldTransform = { x: anchor.x, y: anchor.y }; @@ -295,16 +247,6 @@ class LineMirror extends ElementObject { this.#endAnchor = cloneAnchor(anchor); } - setLinePosition( - startWorldX: number, - startWorldY: number, - endWorldX: number, - endWorldY: number, - ): void { - this.setLineStart(startWorldX, startWorldY); - this.setLineEnd(endWorldX, endWorldY); - } - updateAnchors(): void { const target = this.target ?? this.candidate?.connector ?? null; if (!target) { @@ -347,16 +289,12 @@ class LineMirror extends ElementObject { this.setLineEndAnchor(targetAnchor); } - moveLineToConnectorTransform(): void { - this.updateAnchors(); - } - writeTransform(): void { this.#geometryWriter?.(this.geometrySnapshot()); } } -function cloneAnchor(anchor: ConnectorAnchor): ConnectorAnchor { +export function cloneAnchor(anchor: ConnectorAnchor): ConnectorAnchor { return { x: anchor.x, y: anchor.y, diff --git a/assets/snapline/core/src/node.ts b/assets/snapline/core/src/node.ts index 7bd7a70..ce116d5 100644 --- a/assets/snapline/core/src/node.ts +++ b/assets/snapline/core/src/node.ts @@ -466,10 +466,6 @@ class NodeMirror extends ElementObject { return this.#callbacks; } - set callbacks(callbacks: NodeCallbacks) { - this.#callbacks = callbacks; - } - get metadata(): SnapLineMetadata { return this.#config.metadata; } @@ -537,7 +533,7 @@ class NodeMirror extends ElementObject { ...this.getAllIncomingLines(), ]); for (const line of lines) { - line.moveLineToConnectorTransform(); + line.updateAnchors(); line.writeTransform(); } } @@ -735,14 +731,6 @@ class NodeMirror extends ElementObject { this.writeLinesNow(); } - // Called when a parent (e.g. a group) cascades a transform write down the - // transform graph: paint this node + recurse to its transform-children, then - // re-glue this node's own lines (the pure-transform cascade can't, since a - // line's two ends live on two different nodes). - writeTransformRecursive(): void { - super.writeTransformRecursive(); - } - // 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: NodeMirror): void { @@ -1054,18 +1042,6 @@ class NodeMirror extends ElementObject { : [...getGraphMirror(this.engine).selection]; } - setUpPosition(prop: dragEndProp) { - const [dx, dy] = [ - prop.end.x - this.#mouseDownX, - prop.end.y - this.#mouseDownY, - ]; - this.worldTransform = { - x: this.#dragStartX + dx, - y: this.#dragStartY + dy, - }; - this.scheduleTransformAndLines(); - } - onUp(prop: pointerUpProp) { if (this.#dragPointerId !== prop.event.pointerId) return; diff --git a/assets/snapline/core/src/query.ts b/assets/snapline/core/src/query.ts index 6c15f49..450502d 100644 --- a/assets/snapline/core/src/query.ts +++ b/assets/snapline/core/src/query.ts @@ -29,6 +29,8 @@ export interface GraphQuery { groups(): readonly GroupNodeMirror[]; /** Settled lines; gesture previews are not part of the settled graph. */ lines(): readonly LineMirror[]; + /** The settled selection, in selection order. */ + selection(): readonly NodeMirror[]; node(id: NodeId): NodeMirror | null; connector(id: ConnectorId): ConnectorMirror | null; line(id: LineId): LineMirror | null; @@ -43,35 +45,10 @@ export function query(engine: EngineLike): GraphQuery { connectors: () => mirror.connectors, groups: () => [...mirror.groups], lines: () => mirror.lines, + selection: () => [...mirror.selection], node: (id) => mirror.node(id), connector: (id) => mirror.connector(id), line: (id) => mirror.line(id), diagnostics: () => mirror.diagnostics(), }; } - -// Enumeration delegates to the per-engine GraphMirror registry (components -// register in their constructors), replacing the old engine-object-table -// scans. Public signatures unchanged. - -export function getNodes(engine: EngineLike): readonly NodeMirror[] { - return getGraphMirror(engine).nodes; -} - -export function getConnectors( - engine: EngineLike, -): readonly ConnectorMirror[] { - return getGraphMirror(engine).connectors; -} - -export function getGroupNodes( - engine: EngineLike, -): readonly GroupNodeMirror[] { - return [...getGraphMirror(engine).groups]; -} - -export function getSelectedNodes( - engine: EngineLike, -): readonly NodeMirror[] { - return [...getGraphMirror(engine).selection]; -} diff --git a/assets/snapline/core/src/select.ts b/assets/snapline/core/src/select.ts index d8e303c..a561b83 100644 --- a/assets/snapline/core/src/select.ts +++ b/assets/snapline/core/src/select.ts @@ -80,7 +80,6 @@ class RectSelectController extends ElementObject { 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); @@ -217,7 +216,6 @@ class RectSelectController extends ElementObject { if (wasDragging) this.#fireRect(0, 0, false); } - onCollideNode(_hitBox: Collider, _node: Collider): void {} } export { RectSelectController }; diff --git a/assets/snapline/react/src/Group.tsx b/assets/snapline/react/src/Group.tsx index 0ab0e68..20266ce 100644 --- a/assets/snapline/react/src/Group.tsx +++ b/assets/snapline/react/src/Group.tsx @@ -14,7 +14,6 @@ import { type GroupMembershipEvent, type NodeCallbacks, type GeometryChangeEvent, - type NodeResizeEvent, type ResizeHandle, type SnapLineMetadata, } from "@snap-engine/snapline"; diff --git a/docs/snapline/design/current-architecture.md b/docs/snapline/design/current-architecture.md index 9e5b195..7839c25 100644 --- a/docs/snapline/design/current-architecture.md +++ b/docs/snapline/design/current-architecture.md @@ -320,9 +320,10 @@ there is one enumeration mechanism. `nodes() / connectors() / groups() / lines()` snapshots, `node(id) / connector(id) / line(id)` domain-id lookups, and `diagnostics()`. It returns live mirrors (whose own mutation surface is constrained separately) and never -exposes registry sets, topology arrays, or mutation methods. The standalone -`getNodes` / `getConnectors` / `getGroupNodes` / `getSelectedNodes` helpers -delegate to the same registry. +exposes registry sets, topology arrays, or mutation methods. It is the only +enumeration surface — the standalone `getNodes` / `getConnectors` / +`getGroupNodes` / `getSelectedNodes` helpers were one-line duplicates of the +same registry reads and were removed. ### What stays on global.data, and why @@ -355,9 +356,9 @@ microtask. Diagnostics are **derived, non-throwing state**: entries drop out when their cause resolves. `ReconciliationError` carries a code (`"duplicate-id"`, -`"missing-node"`, `"missing-connector"`, `"capacity-exceeded"`, -`"connection-rejected"`, `"identity-changed"`, `"unrepresentable-line"`), -the offending domain ids, and a message. Sources: +`"capacity-exceeded"`, `"connection-rejected"`), the offending domain ids, and +a message. The union lists exactly the codes that are emitted; four further +codes that were declared but never produced have been removed. Sources: - registry duplicate-id conflicts (nodes, connectors, settled lines); - per-pass reconciliation errors: duplicate record ids in a snapshot, and @@ -380,15 +381,15 @@ The packages export raw TypeScript source and are pre-1.0. | Controllers | `RectSelectController`, `PlacementController` | | Controlled graph | `attachControlledGraph`; types `LineRecord`, `CanonicalGraphSnapshot`, `LineChangeRequest`, `ProposedLine`, `LineEndpointUpdate`, `ControlledGraphCallbacks`, `ControlledGraphHandle` | | Identity/diagnostics | types `NodeId`, `ConnectorId`, `LineId`, `ReconciliationError`, `GraphBatch` | -| Queries | `query` (+ `GraphQuery`), `getNodes`, `getConnectors`, `getGroupNodes`, `getSelectedNodes`, `getParentGroup`, `setGroupMembershipResolver`, `resolveConnectorSourceAtPoint` | +| Queries | `query` (+ `GraphQuery`), `getParentGroup`, `setGroupMembershipResolver`, `resolveConnectorSourceAtPoint` | | Config/callback types | `NodeConfig`/`NodeCallbacks` (incl. `GeometryChangeEvent`), `ConnectorConfig`/`ConnectorRules`/`ConnectorCallbacks`/`ConnectionProposal`, line/group/select/placement types | -Package subpaths: `./node`, `./connector`, `./line`, `./select`, `./group`, -`./placement`, `./query`, `./graph-mirror`, `./line-reconciler`, -`./geometry`. +The package has a single entry point (`.`). The per-module subpath exports were +removed: they gave every symbol a second import path with no consumers, which +the "only one way to do something" rule forbids. There is **no imperative public topology API**: `deleteLine()`, -`deleteAllLines()`, `disconnectFromConnector()`, `createLine()`, and the +`deleteAllLines()`, `createLine()`, and the record-driven settle/retarget/discard methods are `@internal` (reconciler/teardown-only), and connecting two connectors imperatively is not possible — applications create and remove lines by changing their diff --git a/docs/snapline/design/migration-notes.md b/docs/snapline/design/migration-notes.md index f9d1392..ca58aed 100644 --- a/docs/snapline/design/migration-notes.md +++ b/docs/snapline/design/migration-notes.md @@ -79,8 +79,9 @@ endpoint-pair edge matching are replaced by the controlled-graph protocol: ## Imperative topology API removal -`connectToConnector()`, `deleteLine()`, `disconnectFromConnector()`, -`deleteAllLines()`, and `createLine()` are no longer public. Create and +`connectToConnector()`, `deleteLine()`, `deleteAllLines()`, and `createLine()` +are no longer public (`disconnectFromConnector()` has been removed +outright). 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. diff --git a/docs/snapline/guides/02_selection_resize.mdx b/docs/snapline/guides/02_selection_resize.mdx index ba0cf52..57f7e4f 100644 --- a/docs/snapline/guides/02_selection_resize.mdx +++ b/docs/snapline/guides/02_selection_resize.mdx @@ -13,7 +13,7 @@ sectionOrder: 2 `Select` starts a rubber-band gesture on unclaimed background space. Selection -is reflected in `getSelectedNodes(engine)` and on each node through +is reflected in `query(engine).selection()` and on each node through `data-selected`. SnapLine does not assign meanings to Shift, Control, or any other key. Use diff --git a/docs/snapline/reference/react/connector.mdx b/docs/snapline/reference/react/connector.mdx index dbdc52d..30c64ff 100644 --- a/docs/snapline/reference/react/connector.mdx +++ b/docs/snapline/reference/react/connector.mdx @@ -46,5 +46,5 @@ single-incoming connector that rejects extra incoming lines. > [!WARNING] > Topology methods on `ConnectorMirror` such as `createLine`, `deleteLine`, -> `deleteAllLines`, and `disconnectFromConnector` are `@internal`. Change +> and `deleteAllLines` are `@internal`. Change > topology by updating your canonical `LineRecord`s through `ControlledGraph`. diff --git a/docs/snapline/reference/react/controlled-graph.mdx b/docs/snapline/reference/react/controlled-graph.mdx index 0a46d54..741fc78 100644 --- a/docs/snapline/reference/react/controlled-graph.mdx +++ b/docs/snapline/reference/react/controlled-graph.mdx @@ -20,7 +20,7 @@ topology. It renders nothing. A `LineChangeRequest` carries an `intent` (`"connect" | "disconnect" | "replace" | "reconnect"`) plus `add` (`ProposedLine[]` with SnapLine-minted ids), `remove` (`LineId[]`), `update` -(endpoint retargets), and the `originalEvent`. Accept a proposal by applying +(endpoint retargets). Accept a proposal by applying it to your records in the handler's state update — adopting a proposed id settles the staged line in place. Reject it by leaving state unchanged; the staged line is then removed. The component pushes your updated `lines` prop diff --git a/docs/snapline/reference/react/select.mdx b/docs/snapline/reference/react/select.mdx index e79de30..6499f54 100644 --- a/docs/snapline/reference/react/select.mdx +++ b/docs/snapline/reference/react/select.mdx @@ -20,7 +20,7 @@ frameworkKey: select The marquee element carries `data-snapline-type="selection"`; its geometry is written imperatively by the controller, so style it with CSS rather than React state. Read the current selection at any time with -`getSelectedNodes(engine)`. +`query(engine).selection()`. Per-node selection behavior (for example toggle-on-shift) is decided by the node's `resolveSelectionMode` callback, not by `Select`. diff --git a/docs/snapline/reference/svelte/connector.mdx b/docs/snapline/reference/svelte/connector.mdx index 8656ee7..4a80675 100644 --- a/docs/snapline/reference/svelte/connector.mdx +++ b/docs/snapline/reference/svelte/connector.mdx @@ -45,5 +45,5 @@ single-incoming connector that rejects extra incoming lines. > [!WARNING] > Topology methods on `ConnectorMirror` such as `createLine`, `deleteLine`, -> `deleteAllLines`, and `disconnectFromConnector` are `@internal`. Change +> and `deleteAllLines` are `@internal`. Change > topology by updating your canonical `LineRecord`s through `ControlledGraph`. diff --git a/docs/snapline/reference/svelte/controlled-graph.mdx b/docs/snapline/reference/svelte/controlled-graph.mdx index f71017b..58f6f8d 100644 --- a/docs/snapline/reference/svelte/controlled-graph.mdx +++ b/docs/snapline/reference/svelte/controlled-graph.mdx @@ -20,7 +20,7 @@ topology. It renders nothing. A `LineChangeRequest` carries an `intent` (`"connect" | "disconnect" | "replace" | "reconnect"`) plus `add` (`ProposedLine[]` with SnapLine-minted ids), `remove` (`LineId[]`), `update` -(endpoint retargets), and the `originalEvent`. Accept a proposal by applying +(endpoint retargets). Accept a proposal by applying it to your records — adopting a proposed id settles the staged line in place. Reject it by leaving your records unchanged; the staged line is then removed. The component pushes your updated `lines` prop back to the reconciler diff --git a/docs/snapline/reference/svelte/select.mdx b/docs/snapline/reference/svelte/select.mdx index 87a9820..58b207c 100644 --- a/docs/snapline/reference/svelte/select.mdx +++ b/docs/snapline/reference/svelte/select.mdx @@ -21,7 +21,7 @@ frameworkKey: select The marquee element carries `data-snapline-type="selection"`; its geometry is written imperatively by the controller, so style it with CSS rather than Svelte state. Read the current selection at any time with -`getSelectedNodes(engine)`. +`query(engine).selection()`. Per-node selection behavior (for example toggle-on-shift) is decided by the node's `resolveSelectionMode` callback, not by `Select`. diff --git a/docs/snapline/reference/vanilla/connector.mdx b/docs/snapline/reference/vanilla/connector.mdx index 20c3b59..5f8c1a3 100644 --- a/docs/snapline/reference/vanilla/connector.mdx +++ b/docs/snapline/reference/vanilla/connector.mdx @@ -40,11 +40,10 @@ lines. `ConnectorSurfaceStrategy` supplies `sourceHitTest`, `targetHitTest`, and `resolveAnchor` for custom shapes. `resolveConnectorSourceAtPoint(engine, point)` resolves which connector a pointer position would start a line from. -Enumerate connectors with `getConnectors(engine)` or -`query(engine).connectors()`. +Enumerate connectors with `query(engine).connectors()`. > [!WARNING] > Topology methods on `ConnectorMirror` such as `createLine`, `deleteLine`, -> `deleteAllLines`, and `disconnectFromConnector` are `@internal` even though +> and `deleteAllLines` are `@internal` even though > they appear in editor autocomplete. Change topology by updating canonical > `LineRecord`s through the controlled-graph handle. diff --git a/docs/snapline/reference/vanilla/controlled-graph.mdx b/docs/snapline/reference/vanilla/controlled-graph.mdx index 4547233..263ad55 100644 --- a/docs/snapline/reference/vanilla/controlled-graph.mdx +++ b/docs/snapline/reference/vanilla/controlled-graph.mdx @@ -28,7 +28,7 @@ framework-mount-led; the snapshot carries only line records. `LineChangeRequest` per gesture: an `intent` (`"connect" | "disconnect" | "replace" | "reconnect"`) plus `add` (`ProposedLine[]` with SnapLine-minted ids), `remove` (`LineId[]`), `update` -(`LineEndpointUpdate[]`), and the `originalEvent`. Apply the proposal to your +(`LineEndpointUpdate[]`). Apply the proposal to your records and push them back through `setCanonicalGraph` — adopting a proposed id settles the staged line in place; pushing unchanged records rejects it. diff --git a/docs/snapline/reference/vanilla/index.mdx b/docs/snapline/reference/vanilla/index.mdx index 48b2991..73465ea 100644 --- a/docs/snapline/reference/vanilla/index.mdx +++ b/docs/snapline/reference/vanilla/index.mdx @@ -28,10 +28,10 @@ atomic proposal to your records. ## Query helpers -`getNodes`, `getConnectors`, `getGroupNodes`, and `getSelectedNodes` return -engine-scoped snapshots; `query(engine)` exposes the read-only `GraphQuery` +`query(engine)` is the single enumeration surface: a read-only `GraphQuery` facade with collection snapshots (`nodes()` / `connectors()` / `groups()` / -`lines()` — settled lines only, and the only public way to enumerate them), +`selection()` / `lines()` — settled lines only, and the only public way to +enumerate them), identity lookups (`node(id)` / `connector(id)` / `line(id)`), and `diagnostics()`. `getParentGroup` returns settled ownership. diff --git a/docs/snapline/reference/vanilla/node.mdx b/docs/snapline/reference/vanilla/node.mdx index 42e8a6e..003ac09 100644 --- a/docs/snapline/reference/vanilla/node.mdx +++ b/docs/snapline/reference/vanilla/node.mdx @@ -37,5 +37,5 @@ Selection state is written to the element as `data-selected="true" | "false"` and `data-snapline-state="focus" | "idle"` — style either without extra callbacks. Adapters additionally stamp `data-snapline-type="node"`. -Enumerate nodes with `getNodes(engine)` or `query(engine).nodes()`; look one +Enumerate nodes with `query(engine).nodes()`; look one up by domain id with `query(engine).node(id)`. diff --git a/docs/snapline/reference/vanilla/select.mdx b/docs/snapline/reference/vanilla/select.mdx index 1d09c6d..681de8a 100644 --- a/docs/snapline/reference/vanilla/select.mdx +++ b/docs/snapline/reference/vanilla/select.mdx @@ -19,6 +19,6 @@ receives a `SelectRect` (`x`, `y`, `width`, `height`, `visible`) and writes element geometry directly, returning a cleanup function. Destroy the controller with `destroy(false)` when tearing down. -Read the settled selection with `getSelectedNodes(engine)`. Per-node +Read the settled selection with `query(engine).selection()`. Per-node selection behavior (replace, toggle, append) is decided by each node's `resolveSelectionMode` callback. diff --git a/tests/ut/snapline-connector-config.spec.ts b/tests/ut/snapline-connector-config.spec.ts index 7a4b99b..1ccd4c3 100644 --- a/tests/ut/snapline-connector-config.spec.ts +++ b/tests/ut/snapline-connector-config.spec.ts @@ -40,7 +40,8 @@ test("line geometry writers are imperative, replaceable, and separate from state // A stale framework cleanup must not detach the newer renderer. unbindFirst(); - line.setLinePosition(10, 20, 35, 45); + line.setLineStartAnchor({ x: 10, y: 20 }); + line.setLineEndAnchor({ x: 35, y: 45 }); expect(firstWrites).toEqual([0]); expect(secondWrites).toEqual([0]); expect(states).toEqual(["source-start"]); From c89d785d0a5cf71b9a36b3ccf95cd887005da2bc Mon Sep 17 00:00:00 2001 From: tfukaza Date: Mon, 27 Jul 2026 21:13:30 -0700 Subject: [PATCH 02/10] =?UTF-8?q?snapline:=20Phase=201b=20=E2=80=94=20reor?= =?UTF-8?q?ganize=20core/src=20around=20the=20public/internal=20seam?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Domain entities and the public contract stay directly under src/; the registry, reconciler, and global.data machinery move to src/internal/. Three modules mixed public API with internals, so a straight directory move would have pushed public API into internal/. They are split on that seam first: types.ts NEW — pure types, zero imports: NodeId/ConnectorId/ LineId, ReconciliationError, GraphBatch, GeometryWriter (absorbing the 2-line geometry.ts), and the whole controlled-graph contract (LineRecord, LineChangeRequest, ProposedLine, LineEndpointUpdate, CanonicalGraphSnapshot, ControlledGraphCallbacks/Handle). controlled-graph.ts NEW — attachControlledGraph, the package's headline entry point, previously declared next to an internal global.data accessor bag. internal/graph-registry.ts the registry class + mintDomainId + GraphReconcilerLike (was graph-mirror.ts) internal/line-reconciler.ts the LineReconciler algorithm only internal/shared-data.ts SnapLineSharedData, SourceSurfaceOwner, snapData, getResizeHandles, getSourceSurfaces, getGraphRegistry (was snapline-globals.ts) Renames that ride along, both fixing names that described the wrong thing: GraphMirror -> GraphRegistry (it is a registry *of* mirrors, and the suffix collided with the NodeMirror/ConnectorMirror/LineMirror entity convention — AGENTS.md already called it "the per-engine registry"), and its accessor getGraphMirror -> getGraphRegistry. snapline-globals.ts was the only file named after a storage location rather than a concept. getGraphRegistry is now exported from index.ts. Four unit specs previously deep-imported it by relative path while AGENTS.md advertised it as a package export; they now import from the package root, so the move is invisible to them. Also updates the two engine-core comments in src/input.ts that tell contributors to keep the resizeHandles/sourceSurfaces structural types in sync with a file that no longer exists under that name. Co-Authored-By: Claude Opus 5 (1M context) --- assets/snapline/AGENTS.md | 38 ++++---- assets/snapline/core/README.md | 18 ++-- assets/snapline/core/src/connector.ts | 16 ++-- assets/snapline/core/src/controlled-graph.ts | 27 ++++++ assets/snapline/core/src/geometry.ts | 2 - assets/snapline/core/src/group.ts | 57 ++++++------ assets/snapline/core/src/index.ts | 19 ++-- .../graph-registry.ts} | 61 +++++-------- .../src/{ => internal}/line-reconciler.ts | 64 ++----------- .../shared-data.ts} | 61 ++++--------- assets/snapline/core/src/line.ts | 18 ++-- assets/snapline/core/src/node.ts | 36 ++++---- assets/snapline/core/src/placement.ts | 6 +- assets/snapline/core/src/query.ts | 11 +-- assets/snapline/core/src/select.ts | 28 +++--- assets/snapline/core/src/types.ts | 89 +++++++++++++++++++ docs/snapline/design/current-architecture.md | 26 +++--- .../design/ownership-specification.md | 8 +- .../snapline/design/planned-rearchitecture.md | 2 +- docs/snapline/reference/index.mdx | 4 +- src/input.ts | 4 +- tests/ut/snapline-connector-config.spec.ts | 4 +- tests/ut/snapline-graph-mirror.spec.ts | 30 +++---- tests/ut/snapline-line-reconciler.spec.ts | 26 +++--- tests/ut/snapline-perf.spec.ts | 4 +- 25 files changed, 334 insertions(+), 325 deletions(-) create mode 100644 assets/snapline/core/src/controlled-graph.ts delete mode 100644 assets/snapline/core/src/geometry.ts rename assets/snapline/core/src/{graph-mirror.ts => internal/graph-registry.ts} (87%) rename assets/snapline/core/src/{ => internal}/line-reconciler.ts (79%) rename assets/snapline/core/src/{snapline-globals.ts => internal/shared-data.ts} (64%) create mode 100644 assets/snapline/core/src/types.ts diff --git a/assets/snapline/AGENTS.md b/assets/snapline/AGENTS.md index f1beb97..29349c8 100644 --- a/assets/snapline/AGENTS.md +++ b/assets/snapline/AGENTS.md @@ -23,8 +23,13 @@ APIs directly rather than adding compatibility shims. - `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 +- `query` - Read-only `GraphQuery` facade over one engine's graph (the only + enumeration surface: `nodes` / `connectors` / `groups` / `selection` / `lines`) +- `getGraphRegistry` - The per-engine `GraphRegistry`, lazy-created on first use + +Everything is reached through the package root (`@snap-engine/snapline`); the +package declares a single `.` export and no per-module subpaths. Modules under +`core/src/internal/` are implementation detail — do not deep-import them. ### @snap-engine/snapline-svelte **Location:** `svelte/src/` @@ -64,11 +69,14 @@ snapline/ │ ├── 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 +│ ├── types.ts # pure public types (identity, diagnostics, +│ │ # GeometryWriter, controlled-graph contract) +│ ├── controlled-graph.ts # attachControlledGraph +│ └── internal/ # not part of the public surface +│ ├── graph-registry.ts # GraphRegistry (per-engine registry + scheduler) +│ ├── line-reconciler.ts # LineReconciler +│ └── shared-data.ts # global.data accessors + getGraphRegistry ├── svelte/ │ ├── package.json │ ├── tsconfig.json @@ -249,10 +257,10 @@ metadata and predicates such as `isValidConnection`, `canContain`, and rendering and creation to framework adapters and consumer callbacks. Raw input/DOM plumbing stays on the `event.*` slots. -### GraphMirror (engine-scoped registry) +### GraphRegistry (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 +`core/src/internal/graph-registry.ts` is the per-engine registry of every live SnapLine +mirror, lazy-created by `getGraphRegistry(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`, @@ -262,7 +270,7 @@ engine-scoped interaction state (`selection`, `groups`, `resizingNode`, 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)` +`SnapLineSharedData.graphRegistries` 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 @@ -302,12 +310,12 @@ 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 +in `core/src/internal/shared-data.ts` (`SnapLineSharedData`) and accessed through 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. +so keep the shapes in sync) plus the `graphRegistries` WeakMap keying each +engine to its `GraphRegistry`. Selection, groups, and `resizingNode` are +engine-scoped state on `GraphRegistry`, not global arrays. ### Pointer claims (camera blocking) @@ -342,7 +350,7 @@ 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 the engine's `GraphMirror.selection`, so a group drag does not + added to the engine's `GraphRegistry.selection`, so a group drag does not alter the selection. - `attachTransformToGroup`/`detachTransformFromGroup` are the public transform-only reparent seam used by the group carry. diff --git a/assets/snapline/core/README.md b/assets/snapline/core/README.md index 8fbe0cb..89b3ff3 100644 --- a/assets/snapline/core/README.md +++ b/assets/snapline/core/README.md @@ -13,19 +13,11 @@ node types, validation, persistence, and styling. npm install @snap-engine/core @snap-engine/snapline ``` -## Entry points - -- `@snap-engine/snapline` -- `@snap-engine/snapline/node` -- `@snap-engine/snapline/connector` -- `@snap-engine/snapline/line` -- `@snap-engine/snapline/select` -- `@snap-engine/snapline/group` -- `@snap-engine/snapline/placement` -- `@snap-engine/snapline/query` -- `@snap-engine/snapline/graph-mirror` -- `@snap-engine/snapline/line-reconciler` -- `@snap-engine/snapline/geometry` +## Entry point + +`@snap-engine/snapline` — one entry point, no per-module subpaths. Everything +public is re-exported from the package root; `core/src/internal/` is +implementation detail and must not be deep-imported. ```ts import { diff --git a/assets/snapline/core/src/connector.ts b/assets/snapline/core/src/connector.ts index a269254..ae7ec51 100644 --- a/assets/snapline/core/src/connector.ts +++ b/assets/snapline/core/src/connector.ts @@ -10,9 +10,9 @@ import type { import { CircleCollider } from "@snap-engine/core/collision"; import type { NodeMirror } from "./node"; import { LineMirror, cloneAnchor, type LineMirrorPhase } from "./line"; -import { getGraphMirror, getSourceSurfaces } from "./snapline-globals"; -import { mintDomainId } from "./graph-mirror"; -import type { LineChangeRequest } from "./line-reconciler"; +import { getGraphRegistry, getSourceSurfaces } from "./internal/shared-data"; +import { mintDomainId } from "./internal/graph-registry"; +import type { LineChangeRequest } from "./types"; export type SnapLineMetadata = Record; export type ConnectionOrigin = "gesture" | "hydration"; @@ -279,7 +279,7 @@ class ConnectorMirror extends ElementObject { ); this.addCollider(this.#hitCircle); this.#syncSourceSurfaceRegistration(); - getGraphMirror(this.engine).registerConnector(this); + getGraphRegistry(this.engine).registerConnector(this); this.event.dom.onAssignDom = () => { this.schedule( @@ -753,7 +753,7 @@ class ConnectorMirror extends ElementObject { line.setPhase("drop"); line.setPreviewPosition(prop.end); - const mirror = getGraphMirror(this.engine); + const mirror = getGraphRegistry(this.engine); if (typeof mirror.reconciler?.dispatchLineChangeRequest === "function") { this.#endControlledDrop(line, candidate, prop); return; @@ -860,7 +860,7 @@ class ConnectorMirror extends ElementObject { } #dispatchRequest(request: LineChangeRequest): void { - const mirror = getGraphMirror(this.engine); + const mirror = getGraphRegistry(this.engine); if (mirror.pendingGestureRequest) { console.warn( "SnapLine: a line-change request is already in flight; gestures are serial, so this indicates a stalled adapter push.", @@ -989,7 +989,7 @@ class ConnectorMirror extends ElementObject { } this.#resetGesture(); this.deleteAllLines("teardown"); - getGraphMirror(this.engine).unregisterConnector(this); + getGraphRegistry(this.engine).unregisterConnector(this); if (this.parent?._connectors[this.#name] === this) { delete this.parent._connectors[this.#name]; } @@ -1367,7 +1367,7 @@ export function resolveConnectorSourceAtPoint( function registeredConnectors(engine: any): ConnectorMirror[] { // The registry is the one connector source — no engine-object-table scan // (this runs per pointer move during a connection drag). - return getGraphMirror(engine).connectors.filter( + return getGraphRegistry(engine).connectors.filter( (connector) => !connector.isDeleteRequested, ); } diff --git a/assets/snapline/core/src/controlled-graph.ts b/assets/snapline/core/src/controlled-graph.ts new file mode 100644 index 0000000..37f4606 --- /dev/null +++ b/assets/snapline/core/src/controlled-graph.ts @@ -0,0 +1,27 @@ +import { LineReconciler } from "./internal/line-reconciler"; +import { getGraphRegistry } from "./internal/shared-data"; +import type { ControlledGraphCallbacks, ControlledGraphHandle } from "./types"; + +/** + * Attach the controlled-graph bridge: declares "controlled" authority, + * installs the line reconciler, and returns the handle the application (or + * adapter) pushes canonical snapshots through. + */ +export function attachControlledGraph( + engine: { global: { data: any } | null }, + callbacks: ControlledGraphCallbacks, +): ControlledGraphHandle { + const registry = getGraphRegistry(engine); + if (registry.reconciler) { + console.warn( + "SnapLine: replacing this engine's existing controlled-graph bridge.", + ); + } + const reconciler = new LineReconciler(registry, callbacks); + registry.reconciler = reconciler; + return { + setCanonicalGraph: (snapshot) => reconciler.setCanonicalGraph(snapshot), + flush: () => registry.flush(), + dispose: () => reconciler.dispose(), + }; +} diff --git a/assets/snapline/core/src/geometry.ts b/assets/snapline/core/src/geometry.ts deleted file mode 100644 index 30e6ed7..0000000 --- a/assets/snapline/core/src/geometry.ts +++ /dev/null @@ -1,2 +0,0 @@ -/** 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 df44c92..8cf35fe 100644 --- a/assets/snapline/core/src/group.ts +++ b/assets/snapline/core/src/group.ts @@ -1,10 +1,6 @@ -import type { - BaseObject, - Engine, - eventPosition, -} from "@snap-engine/core"; +import type { BaseObject, Engine, eventPosition } from "@snap-engine/core"; import { NodeMirror, mergeConfig, type NodeConfig } from "./node"; -import { getGraphMirror } from "./snapline-globals"; +import { getGraphRegistry } from "./internal/shared-data"; export interface GroupConfig extends NodeConfig { width?: number; @@ -51,13 +47,13 @@ const DEFAULT_GROUP_CONFIG = { minHeight: 120, } satisfies GroupConfig; -type Bounds = ReturnType< - NodeMirror["hitBox"]["getWorldBoundsSnapshot"] ->; +type Bounds = ReturnType; function boundsArea(bounds: Bounds): number { - return Math.max(0, bounds.right - bounds.left) * - Math.max(0, bounds.bottom - bounds.top); + return ( + Math.max(0, bounds.right - bounds.left) * + Math.max(0, bounds.bottom - bounds.top) + ); } function containsBounds(container: Bounds, child: Bounds): boolean { @@ -80,11 +76,11 @@ function stableGroupOrder( } function groupsForEngine(group: GroupNodeMirror): GroupNodeMirror[] { - return [...getGraphMirror(group.engine).groups]; + return [...getGraphRegistry(group.engine).groups]; } function nodesForEngine(group: GroupNodeMirror): NodeMirror[] { - return [...getGraphMirror(group.engine).nodes]; + return [...getGraphRegistry(group.engine).nodes]; } function resolveParent( @@ -125,16 +121,15 @@ function reconcileMembership( source: GroupNodeMirror, fireDelta: boolean, ): void { - const mirror = getGraphMirror(source.engine); + const mirror = getGraphRegistry(source.engine); if (mirror.reconcilingMembership) return; mirror.reconcilingMembership = true; try { const groups = groupsForEngine(source); - const nextMembers = new Map< - GroupNodeMirror, - Set - >(groups.map((group) => [group, new Set()])); + const nextMembers = new Map>( + groups.map((group) => [group, new Set()]), + ); const nextParents = new Map(); const nodes = nodesForEngine(source); @@ -162,9 +157,7 @@ function reconcileMembership( // Ordinary nodes cannot form membership cycles. They choose the innermost // eligible group after the group hierarchy is settled. for (const node of ordinaryNodes) { - const candidates = groups.filter((group) => - group.allowsMembership(node) - ); + const candidates = groups.filter((group) => group.allowsMembership(node)); const parent = resolveParent(node, candidates, mirror.membershipResolver); if (!parent) continue; nextMembers.get(parent)?.add(node); @@ -206,10 +199,8 @@ function reconcileMembership( } /** Return the node's settled, exclusive direct parent group. */ -export function getParentGroup( - node: NodeMirror, -): GroupNodeMirror | null { - return getGraphMirror(node.engine).parentGroups.get(node) ?? null; +export function getParentGroup(node: NodeMirror): GroupNodeMirror | null { + return getGraphRegistry(node.engine).parentGroups.get(node) ?? null; } /** @@ -220,7 +211,7 @@ export function setGroupMembershipResolver( engine: Engine, resolver: GroupMembershipResolver, ): () => void { - const mirror = getGraphMirror(engine); + const mirror = getGraphRegistry(engine); mirror.membershipResolver = resolver; const refresh = () => { // Any live group re-derives the whole engine's membership forest. @@ -245,7 +236,11 @@ class GroupNodeMirror extends NodeMirror { #groupCallbacks: GroupCallbacks; #groupConfig: GroupConfig; - constructor(engine: any, parent: BaseObject | null, config: GroupConfig = {}) { + constructor( + engine: any, + parent: BaseObject | null, + config: GroupConfig = {}, + ) { const merged = mergeConfig( { ...DEFAULT_GROUP_CONFIG }, config, @@ -253,7 +248,7 @@ class GroupNodeMirror extends NodeMirror { super(engine, parent, { ...merged, resizable: true }); this.#groupConfig = merged; this.#groupCallbacks = merged.groupCallbacks ?? {}; - getGraphMirror(this.engine).groups.push(this); + getGraphRegistry(this.engine).groups.push(this); } get groupCallbacks(): GroupCallbacks { @@ -300,9 +295,7 @@ class GroupNodeMirror extends NodeMirror { // Ordinary nodes use center containment. A nested group must fit completely // so partially overlapping peers cannot become a parent/child pair. - if ( - node instanceof GroupNodeMirror ? !boundsContained : !centerContained - ) { + if (node instanceof GroupNodeMirror ? !boundsContained : !centerContained) { return false; } @@ -369,7 +362,7 @@ class GroupNodeMirror extends NodeMirror { } destroy(removeElement: boolean = true): void { - const mirror = getGraphMirror(this.engine); + const mirror = getGraphRegistry(this.engine); const index = mirror.groups.indexOf(this); if (index >= 0) mirror.groups.splice(index, 1); for (const member of this.#carry) member.detachTransformFromGroup(); diff --git a/assets/snapline/core/src/index.ts b/assets/snapline/core/src/index.ts index 4689649..bd682ea 100644 --- a/assets/snapline/core/src/index.ts +++ b/assets/snapline/core/src/index.ts @@ -56,7 +56,6 @@ export type { LineMirrorPhase, LineStateSnapshot, } from "./line"; -export type { GeometryWriter } from "./geometry"; export { GroupNodeMirror, getParentGroup, @@ -92,20 +91,20 @@ export type { PlacementSize, PlacementSnapshot, } from "./placement"; -export type { - NodeId, - ConnectorId, - LineId, - ReconciliationError, - GraphBatch, -} from "./graph-mirror"; -export { attachControlledGraph } from "./snapline-globals"; +export { attachControlledGraph } from "./controlled-graph"; +export { getGraphRegistry } from "./internal/shared-data"; export type { CanonicalGraphSnapshot, + ConnectorId, ControlledGraphCallbacks, ControlledGraphHandle, + GeometryWriter, + GraphBatch, LineChangeRequest, LineEndpointUpdate, + LineId, LineRecord, + NodeId, ProposedLine, -} from "./line-reconciler"; + ReconciliationError, +} from "./types"; diff --git a/assets/snapline/core/src/graph-mirror.ts b/assets/snapline/core/src/internal/graph-registry.ts similarity index 87% rename from assets/snapline/core/src/graph-mirror.ts rename to assets/snapline/core/src/internal/graph-registry.ts index 88f3079..f392fe5 100644 --- a/assets/snapline/core/src/graph-mirror.ts +++ b/assets/snapline/core/src/internal/graph-registry.ts @@ -1,46 +1,28 @@ -import type { ConnectorMirror } from "./connector"; -import type { NodeMirror } from "./node"; -import type { LineMirror } from "./line"; -import type { GroupNodeMirror, GroupMembershipResolver } from "./group"; -import type { LineChangeRequest } from "./line-reconciler"; - -/** Stable application-facing identity of a canonical node. */ -export type NodeId = string; -/** Stable application-facing identity of a canonical connector (graph-global). */ -export type ConnectorId = string; -/** Stable application-facing identity of a canonical line. */ -export type LineId = string; - -/** - * Structured, non-throwing report of a graph state the mirror cannot - * represent. Derived state: entries drop out when their cause resolves. - */ -export interface ReconciliationError { - code: "duplicate-id" | "capacity-exceeded" | "connection-rejected"; - lineId?: LineId; - nodeId?: NodeId; - connectorId?: ConnectorId; - message: string; - cause?: unknown; -} +import type { ConnectorMirror } from "../connector"; +import type { NodeMirror } from "../node"; +import type { LineMirror } from "../line"; +import type { GroupNodeMirror, GroupMembershipResolver } from "../group"; +import type { + ConnectorId, + GraphBatch, + LineChangeRequest, + LineId, + NodeId, + ReconciliationError, +} from "../types"; // Structural reconciler contract so the registry (and the connector emit // sites that reach it) never value-import the reconciler module — it imports // the registry accessor, not the reverse. export interface GraphReconcilerLike { /** Run one reconciliation pass against the latest canonical state. Invoked - * by the mirror's coalescing, batch-aware scheduler. */ + * by the registry's coalescing, batch-aware scheduler. */ reconcile?(): void; /** Forward a gesture's atomic proposal to the application. Present only on * the controlled bridge; its presence routes gesture drops. */ dispatchLineChangeRequest?(request: LineChangeRequest): void; } -/** A batch token from `beginBatch()`; `end()` is idempotent. */ -export interface GraphBatch { - end(): void; -} - /** Mint a domain ID for a mirror created without an application-supplied id. */ export function mintDomainId( kind: "node" | "connector" | "line", @@ -49,10 +31,10 @@ export function mintDomainId( return `${kind}-${global.createId()}`; } -// Engine-scoped registry of every live SnapLine runtime mirror — the mirror -// of the whole graph (nodes, connectors, settled lines, previews). +// Engine-scoped registry of every live SnapLine runtime mirror: nodes, +// connectors, settled lines, and previews. // -// Created lazily by `getGraphMirror(engine)` the first time any SnapLine +// Created lazily by `getGraphRegistry(engine)` the first time any SnapLine // mirror registers, so it exists exactly when SnapLine is in use — no adapter // wiring required, vanilla consumers included. Mirrors register in their // constructors and unregister in `destroy()`. @@ -61,7 +43,7 @@ export function mintDomainId( // duplicate ID is never silently allowed to steal the index entry; it stays // unindexed and is reported through `diagnostics()` until the conflict // resolves (either mirror unregisters). -export class GraphMirror { +export class GraphRegistry { readonly engine: unknown; #nodes = new Set(); #connectors = new Set(); @@ -170,7 +152,13 @@ export class GraphMirror { unregisterNode(node: NodeMirror): void { this.#nodes.delete(node); - this.#unindex(this.#nodesById, node.nodeId, node, this.#nodes, (n) => n.nodeId); + this.#unindex( + this.#nodesById, + node.nodeId, + node, + this.#nodes, + (n) => n.nodeId, + ); } registerConnector(connector: ConnectorMirror): void { @@ -318,5 +306,4 @@ export class GraphMirror { } } } - } diff --git a/assets/snapline/core/src/line-reconciler.ts b/assets/snapline/core/src/internal/line-reconciler.ts similarity index 79% rename from assets/snapline/core/src/line-reconciler.ts rename to assets/snapline/core/src/internal/line-reconciler.ts index d87a9f6..6a2b20e 100644 --- a/assets/snapline/core/src/line-reconciler.ts +++ b/assets/snapline/core/src/internal/line-reconciler.ts @@ -1,59 +1,13 @@ -import type { LineMirror } from "./line"; +import type { LineMirror } from "../line"; import type { - ConnectorId, - GraphMirror, + CanonicalGraphSnapshot, + ControlledGraphCallbacks, + LineChangeRequest, LineId, + LineRecord, ReconciliationError, -} from "./graph-mirror"; - -/** Canonical committed relationship, owned by the application. */ -export interface LineRecord { - id: LineId; - fromConnectorId: ConnectorId; - toConnectorId: ConnectorId; - payload?: unknown; -} - -/** The application-pushed canonical document. Node and connector existence - * stays framework-mount-led; the snapshot carries the line records. */ -export interface CanonicalGraphSnapshot { - lines: readonly LineRecord[]; -} - -/** A gesture-created line proposed to the canonical owner. The `id` is - * minted by SnapLine; adopting it settles the staged mirror in place. */ -export interface ProposedLine { - id: LineId; - fromConnectorId: ConnectorId; - toConnectorId: ConnectorId; - payload?: unknown; -} - -export interface LineEndpointUpdate { - id: LineId; - toConnectorId: ConnectorId; -} - -/** One atomic proposal for the application to change canonical records. */ -export interface LineChangeRequest { - intent: "connect" | "disconnect" | "replace" | "reconnect"; - add: readonly ProposedLine[]; - remove: readonly LineId[]; - update: readonly LineEndpointUpdate[]; -} - -export interface ControlledGraphCallbacks { - onLineChangeRequest(request: LineChangeRequest): void; - onDiagnosticsChanged?(diagnostics: readonly ReconciliationError[]): void; -} - -/** What `attachControlledGraph()` hands the adapter. */ -export interface ControlledGraphHandle { - setCanonicalGraph(snapshot: CanonicalGraphSnapshot): void; - /** Run any pending reconciliation synchronously (vanilla/tests). */ - flush(): void; - dispose(): void; -} +} from "../types"; +import type { GraphRegistry } from "./graph-registry"; // Converges the engine's line mirrors onto the cached canonical snapshot. // Read-only with respect to canonical state: reconciliation never emits a @@ -62,13 +16,13 @@ export interface ControlledGraphHandle { // endpoints not mounted; silent) or errored (rules violation; structured // diagnostic), and is retried when relevant state changes. export class LineReconciler { - #mirror: GraphMirror; + #mirror: GraphRegistry; #callbacks: ControlledGraphCallbacks; #snapshot: CanonicalGraphSnapshot = { lines: [] }; #reconciling = false; #disposed = false; - constructor(mirror: GraphMirror, callbacks: ControlledGraphCallbacks) { + constructor(mirror: GraphRegistry, callbacks: ControlledGraphCallbacks) { this.#mirror = mirror; this.#callbacks = callbacks; } diff --git a/assets/snapline/core/src/snapline-globals.ts b/assets/snapline/core/src/internal/shared-data.ts similarity index 64% rename from assets/snapline/core/src/snapline-globals.ts rename to assets/snapline/core/src/internal/shared-data.ts index c58b0e2..fc5d5b7 100644 --- a/assets/snapline/core/src/snapline-globals.ts +++ b/assets/snapline/core/src/internal/shared-data.ts @@ -1,11 +1,6 @@ import type { RectCollider } from "@snap-engine/core/collision"; import type { eventPosition } from "@snap-engine/core"; -import { GraphMirror } from "./graph-mirror"; -import { - LineReconciler, - type ControlledGraphCallbacks, - type ControlledGraphHandle, -} from "./line-reconciler"; +import { GraphRegistry } from "./graph-registry"; /** * Structural source-surface contract shared with engine input. Keeping this @@ -52,12 +47,12 @@ export interface SnapLineSharedData { allowCameraControl?: boolean; /** * Per-engine SnapLine registries. GlobalManager is application-wide, so the - * map is keyed by engine; `getGraphMirror` lazy-creates entries the first + * map is keyed by engine; `getGraphRegistry` lazy-creates entries the first * time a SnapLine mirror registers on that engine. WeakMap so a destroyed * engine releases its registry (and every mirror it indexes) — nothing ever * enumerates this map. */ - graphMirrors?: WeakMap; + graphRegistries?: WeakMap; } /** Typed view over the untyped global data bag (cast at the boundary). */ @@ -71,52 +66,28 @@ export function getResizeHandles(global: { data: any }): RectCollider[] { return data.resizeHandles; } -export function getSourceSurfaces(global: { - data: any; -}): SourceSurfaceOwner[] { +export function getSourceSurfaces(global: { data: any }): SourceSurfaceOwner[] { const data = snapData(global); if (!data.sourceSurfaces) data.sourceSurfaces = []; return data.sourceSurfaces; } - -export function getGraphMirror(engine: { +/** The per-engine registry, lazy-created on first access. */ +export function getGraphRegistry(engine: { global: { data: any } | null; -}): GraphMirror { +}): GraphRegistry { if (!engine.global) { - throw new Error("SnapLine: getGraphMirror requires an initialized engine."); + throw new Error( + "SnapLine: getGraphRegistry requires an initialized engine.", + ); } const data = snapData(engine.global); - if (!data.graphMirrors) data.graphMirrors = new WeakMap(); + if (!data.graphRegistries) data.graphRegistries = new WeakMap(); const key = engine as unknown as object; - let mirror = data.graphMirrors.get(key); - if (!mirror) { - mirror = new GraphMirror(engine); - data.graphMirrors.set(key, mirror); - } - return mirror; -} - -/** - * Attach the controlled-graph bridge: declares "controlled" authority, - * installs the line reconciler, and returns the handle the application (or - * adapter) pushes canonical snapshots through. - */ -export function attachControlledGraph( - engine: { global: { data: any } | null }, - callbacks: ControlledGraphCallbacks, -): ControlledGraphHandle { - const mirror = getGraphMirror(engine); - if (mirror.reconciler) { - console.warn( - "SnapLine: replacing this engine's existing controlled-graph bridge.", - ); + let registry = data.graphRegistries.get(key); + if (!registry) { + registry = new GraphRegistry(engine); + data.graphRegistries.set(key, registry); } - const reconciler = new LineReconciler(mirror, callbacks); - mirror.reconciler = reconciler; - return { - setCanonicalGraph: (snapshot) => reconciler.setCanonicalGraph(snapshot), - flush: () => mirror.flush(), - dispose: () => reconciler.dispose(), - }; + return registry; } diff --git a/assets/snapline/core/src/line.ts b/assets/snapline/core/src/line.ts index 9ee1a57..dfee90f 100644 --- a/assets/snapline/core/src/line.ts +++ b/assets/snapline/core/src/line.ts @@ -7,9 +7,9 @@ import type { ConnectorPoint, ConnectorSurfaceStrategy, } from "./connector"; -import type { GeometryWriter } from "./geometry"; -import { getGraphMirror } from "./snapline-globals"; -import { mintDomainId } from "./graph-mirror"; +import type { GeometryWriter } from "./types"; +import { getGraphRegistry } from "./internal/shared-data"; +import { mintDomainId } from "./internal/graph-registry"; /** * Explicit line lifetime. "staged" is a gesture that completed locally and @@ -63,7 +63,7 @@ class LineMirror extends ElementObject { this.#start = parent as unknown as ConnectorMirror; this.transformMode = "direct"; this.lineId = config.id ?? mintDomainId("line", this.global); - getGraphMirror(this.engine).registerLine(this); + getGraphRegistry(this.engine).registerLine(this); } // Read-only outside the mirror's own lifecycle operations. @@ -127,13 +127,11 @@ class LineMirror extends ElementObject { } override destroy(removeElement: boolean = true): void { - getGraphMirror(this.engine).unregisterLine(this); + getGraphRegistry(this.engine).unregisterLine(this); super.destroy(removeElement); } - bindGeometryWriter( - writer: GeometryWriter, - ): () => void { + bindGeometryWriter(writer: GeometryWriter): () => void { this.#geometryWriter = writer; writer(this.geometrySnapshot()); return () => { @@ -219,7 +217,7 @@ class LineMirror extends ElementObject { this.#targetHit = candidate?.hit ?? this.#targetHit; this.#candidate = null; this.#phase = "connected"; - getGraphMirror(this.engine).settleLine(this); + getGraphRegistry(this.engine).settleLine(this); this.updateAnchors(); this.#emitStateChange(); } @@ -234,7 +232,7 @@ class LineMirror extends ElementObject { this.#targetStrategy = null; this.#targetHit = null; this.#phase = "preview-free"; - getGraphMirror(this.engine).unsettleLine(this); + getGraphRegistry(this.engine).unsettleLine(this); if (changed) this.#emitStateChange(); } diff --git a/assets/snapline/core/src/node.ts b/assets/snapline/core/src/node.ts index ce116d5..2faa439 100644 --- a/assets/snapline/core/src/node.ts +++ b/assets/snapline/core/src/node.ts @@ -11,8 +11,12 @@ import type { pointerMoveProp, } from "@snap-engine/core"; import { RectCollider } from "@snap-engine/core/collision"; -import { getGraphMirror, getResizeHandles, snapData } from "./snapline-globals"; -import { mintDomainId } from "./graph-mirror"; +import { + getGraphRegistry, + getResizeHandles, + snapData, +} from "./internal/shared-data"; +import { mintDomainId } from "./internal/graph-registry"; import type { SnapLineMetadata } from "./connector"; export type ResizeHandle = "n" | "ne" | "e" | "se" | "s" | "sw" | "w" | "nw"; @@ -400,7 +404,7 @@ class NodeMirror extends ElementObject { this.#config = mergeConfig(DEFAULT_NODE_CONFIG, config); this.#callbacks = this.#config.callbacks; this.nodeId = config.id ?? mintDomainId("node", this.global); - getGraphMirror(this.engine).registerNode(this); + getGraphRegistry(this.engine).registerNode(this); const resizeEnabled = config.resizable === true || config.resizeHandles !== undefined; this.#resizeHandles = !resizeEnabled @@ -499,7 +503,7 @@ class NodeMirror extends ElementObject { selected: String(selected), "snapline-state": selected ? "focus" : "idle", }; - const selectList = getGraphMirror(this.engine).selection; + const selectList = getGraphRegistry(this.engine).selection; if (selected) { if (!selectList.includes(this)) { selectList.push(this); @@ -515,7 +519,7 @@ class NodeMirror extends ElementObject { this.#callbacks.onSelectionChange?.({ node: this, selected, - selection: [...getGraphMirror(this.engine).selection], + selection: [...getGraphRegistry(this.engine).selection], }); } @@ -799,7 +803,7 @@ class NodeMirror extends ElementObject { this.engine.input.claimPointer(e.event.pointerId); this._hasMoved = false; - const selection = [...getGraphMirror(this.engine).selection]; + const selection = [...getGraphRegistry(this.engine).selection]; this.#selectedAtPointerDown = selection.includes(this); this.#pointerSelectionMode = this.#callbacks.resolveSelectionMode?.({ @@ -813,7 +817,7 @@ class NodeMirror extends ElementObject { this.#pointerSelectionMode === "replace" && !this.#selectedAtPointerDown ) { - for (const node of [...getGraphMirror(this.engine).selection]) { + for (const node of [...getGraphRegistry(this.engine).selection]) { node.setSelected(false); } this.setSelected(true); @@ -838,7 +842,7 @@ class NodeMirror extends ElementObject { 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; + getGraphRegistry(this.engine).resizingNode = this; return; } if (!this.#config.lockPosition && this.#config.edgePan) { @@ -849,7 +853,7 @@ class NodeMirror extends ElementObject { (position) => this.#moveSelectionToPointer(position), ); } - const selected = [...getGraphMirror(this.engine).selection]; + const selected = [...getGraphRegistry(this.engine).selection]; this.#dragRoots = selected.filter( (node) => !selected.some( @@ -976,7 +980,7 @@ class NodeMirror extends ElementObject { this.#resizing = false; this.#resizeArmed = false; this.#activeResizeHandle = null; - getGraphMirror(this.engine).resizingNode = null; + getGraphRegistry(this.engine).resizingNode = null; this.#dragPointerId = null; // No target element: dragEndProp carries no originating event. activate() // still writes the node and container cursors, and the next pointermove @@ -989,7 +993,7 @@ class NodeMirror extends ElementObject { // this an authored size the stylesheet refused would stick. this.remeasureDomGeometry(); // A resized node's center may have moved into/out of a group. - for (const group of getGraphMirror(this.engine).groups) { + for (const group of getGraphRegistry(this.engine).groups) { if ((group as unknown) !== this) group.refreshMembership(true); } return; @@ -1010,7 +1014,7 @@ class NodeMirror extends ElementObject { // membership on settle (never at group-drag-start), so the maintained set is // current before the next group drag. The graph mirror's type-only group // reference keeps node.ts free of any group value import. - for (const group of getGraphMirror(this.engine).groups) { + for (const group of getGraphRegistry(this.engine).groups) { group.refreshMembership(true); } this.emitGeometryChange(); @@ -1039,7 +1043,7 @@ class NodeMirror extends ElementObject { protected getDragCommitNodes(): NodeMirror[] { return this.#dragCommitNodes.length ? [...this.#dragCommitNodes] - : [...getGraphMirror(this.engine).selection]; + : [...getGraphRegistry(this.engine).selection]; } onUp(prop: pointerUpProp) { @@ -1048,7 +1052,7 @@ class NodeMirror extends ElementObject { // pointerUp is dispatched to whatever is under the release point, which for a // resize may be a DIFFERENT node than the one being resized. Skip click-select // while any resize is settling so releasing a resize doesn't select this node. - if (getGraphMirror(this.engine).resizingNode) return; + if (getGraphRegistry(this.engine).resizingNode) return; if (this.#resizeArmed) { this.#resizeArmed = false; this.#activeResizeHandle = null; @@ -1058,7 +1062,7 @@ class NodeMirror extends ElementObject { if (this._hasMoved == false) { if (this.#pointerSelectionMode === "replace") { - for (const node of [...getGraphMirror(this.engine).selection]) { + for (const node of [...getGraphRegistry(this.engine).selection]) { if (node !== this) node.setSelected(false); } this.setSelected(true); @@ -1115,7 +1119,7 @@ class NodeMirror extends ElementObject { // disconnect — keep the reason contract honest for intent consumers. connector.deleteAllLines("teardown"); } - getGraphMirror(this.engine).unregisterNode(this); + getGraphRegistry(this.engine).unregisterNode(this); this.setSelected(false); if (this.#resizeHitBoxes.size > 0) { const ownedHandles = new Set(this.#resizeHitBoxes.values()); diff --git a/assets/snapline/core/src/placement.ts b/assets/snapline/core/src/placement.ts index cbda0d7..a6f6abf 100644 --- a/assets/snapline/core/src/placement.ts +++ b/assets/snapline/core/src/placement.ts @@ -1,4 +1,4 @@ -import type { GeometryWriter } from "./geometry"; +import type { GeometryWriter } from "./types"; export interface PlacementPoint { x: number; @@ -76,9 +76,7 @@ export class PlacementController { #callbacks: PlacementCallbacks; #snapshot: PlacementSnapshot; #geometryWriter: GeometryWriter | null = null; - #stateCallbacks = new Set< - (snapshot: PlacementSnapshot) => void - >(); + #stateCallbacks = new Set<(snapshot: PlacementSnapshot) => void>(); constructor(config: PlacementConfig) { this.#config = config; diff --git a/assets/snapline/core/src/query.ts b/assets/snapline/core/src/query.ts index 450502d..173bbbf 100644 --- a/assets/snapline/core/src/query.ts +++ b/assets/snapline/core/src/query.ts @@ -2,13 +2,8 @@ import type { ConnectorMirror } from "./connector"; import type { GroupNodeMirror } from "./group"; import type { LineMirror } from "./line"; import type { NodeMirror } from "./node"; -import type { - ConnectorId, - LineId, - NodeId, - ReconciliationError, -} from "./graph-mirror"; -import { getGraphMirror } from "./snapline-globals"; +import type { ConnectorId, LineId, NodeId, ReconciliationError } from "./types"; +import { getGraphRegistry } from "./internal/shared-data"; type EngineLike = { global: { @@ -39,7 +34,7 @@ export interface GraphQuery { /** The read-only query facade for one engine's graph. */ export function query(engine: EngineLike): GraphQuery { - const mirror = getGraphMirror(engine); + const mirror = getGraphRegistry(engine); return { nodes: () => mirror.nodes, connectors: () => mirror.connectors, diff --git a/assets/snapline/core/src/select.ts b/assets/snapline/core/src/select.ts index a561b83..a6cd4d4 100644 --- a/assets/snapline/core/src/select.ts +++ b/assets/snapline/core/src/select.ts @@ -6,8 +6,8 @@ import type { } from "@snap-engine/core"; import { RectCollider, Collider } from "@snap-engine/core/collision"; import { NodeMirror, type SelectionMode } from "./node"; -import { getGraphMirror } from "./snapline-globals"; -import type { GeometryWriter } from "./geometry"; +import { getGraphRegistry } from "./internal/shared-data"; +import type { GeometryWriter } from "./types"; /** World-space rectangle delivered to the registered geometry writer. */ export interface SelectRect { @@ -84,7 +84,7 @@ class RectSelectController extends ElementObject { this.addCollider(this.#selectHitBox); // A fresh selection controller starts its engine from an empty selection. - getGraphMirror(this.engine).selection.length = 0; + getGraphRegistry(this.engine).selection.length = 0; this.#callbacks = config.callbacks ?? {}; } @@ -114,13 +114,10 @@ class RectSelectController extends ElementObject { visible, }; this.#callbacks.onRectChange?.({ ...this.#rect }); - this.schedule( - () => this.#geometryWriter?.({ ...this.#rect }), - { - stage: "WRITE_2", - queueId: `${this.id}-geometry`, - }, - ); + this.schedule(() => this.#geometryWriter?.({ ...this.#rect }), { + stage: "WRITE_2", + queueId: `${this.id}-geometry`, + }); } onGlobalCursorDown(prop: pointerDownProp): void { @@ -135,10 +132,10 @@ class RectSelectController extends ElementObject { if (this.#callbacks.canStart?.(startEvent) === false) return; this.#selectionMode = this.#callbacks.resolveSelectionMode?.(startEvent) ?? "replace"; - this.#baselineSelection = new Set(getGraphMirror(this.engine).selection); + this.#baselineSelection = new Set(getGraphRegistry(this.engine).selection); if (this.#selectionMode === "replace") { // setSelected(false) removes each node from the engine's selection. - for (let node of [...getGraphMirror(this.engine).selection]) { + for (let node of [...getGraphRegistry(this.engine).selection]) { node.setSelected(false); } } @@ -154,7 +151,7 @@ class RectSelectController extends ElementObject { this.#fireRect(0, 0, true); this.#callbacks.onSelectionChange?.({ select: this, - selection: [...getGraphMirror(this.engine).selection], + selection: [...getGraphRegistry(this.engine).selection], }); this.#selectHitBox.event.collider.onBeginContact = ( @@ -170,7 +167,7 @@ class RectSelectController extends ElementObject { ); this.#callbacks.onSelectionChange?.({ select: this, - selection: [...getGraphMirror(this.engine).selection], + selection: [...getGraphRegistry(this.engine).selection], }); } }; @@ -183,7 +180,7 @@ class RectSelectController extends ElementObject { node.setSelected(this.#baselineSelection.has(node)); this.#callbacks.onSelectionChange?.({ select: this, - selection: [...getGraphMirror(this.engine).selection], + selection: [...getGraphRegistry(this.engine).selection], }); } }; @@ -215,7 +212,6 @@ class RectSelectController extends ElementObject { this.#selectHitBox.event.collider.onEndContact = null; if (wasDragging) this.#fireRect(0, 0, false); } - } export { RectSelectController }; diff --git a/assets/snapline/core/src/types.ts b/assets/snapline/core/src/types.ts new file mode 100644 index 0000000..7bea0ef --- /dev/null +++ b/assets/snapline/core/src/types.ts @@ -0,0 +1,89 @@ +/** + * Pure type declarations shared across SnapLine — identity, diagnostics, the + * geometry-writer signature, and the controlled-graph contract the application + * negotiates through. + * + * This module deliberately imports nothing. Everything here is part of the + * public surface, so it stays at the top level while the machinery that + * consumes it lives under `internal/`. + */ + +/** Stable application-facing identity of a canonical node. */ +export type NodeId = string; +/** Stable application-facing identity of a canonical connector (graph-global). */ +export type ConnectorId = string; +/** Stable application-facing identity of a canonical line. */ +export type LineId = string; + +/** + * Sink for high-frequency geometry: core hands over numbers, the consumer owns + * the element. Used by lines, selection, and placement. + */ +export type GeometryWriter = (geometry: Readonly) => void; + +/** + * Structured, non-throwing report of a graph state the registry cannot + * represent. Derived state: entries drop out when their cause resolves. + */ +export interface ReconciliationError { + code: "duplicate-id" | "capacity-exceeded" | "connection-rejected"; + lineId?: LineId; + nodeId?: NodeId; + connectorId?: ConnectorId; + message: string; + cause?: unknown; +} + +/** A batch token from `beginBatch()`; `end()` is idempotent. */ +export interface GraphBatch { + end(): void; +} + +/** Canonical committed relationship, owned by the application. */ +export interface LineRecord { + id: LineId; + fromConnectorId: ConnectorId; + toConnectorId: ConnectorId; + payload?: unknown; +} + +/** The application-pushed canonical document. Node and connector existence + * stays framework-mount-led; the snapshot carries the line records. */ +export interface CanonicalGraphSnapshot { + lines: readonly LineRecord[]; +} + +/** A gesture-created line proposed to the canonical owner. The `id` is + * minted by SnapLine; adopting it settles the staged mirror in place. */ +export interface ProposedLine { + id: LineId; + fromConnectorId: ConnectorId; + toConnectorId: ConnectorId; + payload?: unknown; +} + +export interface LineEndpointUpdate { + id: LineId; + toConnectorId: ConnectorId; +} + +/** One atomic proposal for the application to change canonical records. */ +export interface LineChangeRequest { + intent: "connect" | "disconnect" | "replace" | "reconnect"; + add: readonly ProposedLine[]; + remove: readonly LineId[]; + update: readonly LineEndpointUpdate[]; +} + +export interface ControlledGraphCallbacks { + onLineChangeRequest(request: LineChangeRequest): void; + onDiagnosticsChanged?(diagnostics: readonly ReconciliationError[]): void; +} + +/** What `attachControlledGraph()` hands the adapter. */ +export interface ControlledGraphHandle { + setCanonicalGraph(snapshot: CanonicalGraphSnapshot): void; + /** Run any pending reconciliation synchronously (vanilla/tests). */ + flush(): void; + dispose(): void; +} diff --git a/docs/snapline/design/current-architecture.md b/docs/snapline/design/current-architecture.md index 7839c25..d23bbf3 100644 --- a/docs/snapline/design/current-architecture.md +++ b/docs/snapline/design/current-architecture.md @@ -56,7 +56,7 @@ flowchart TB ADAPTER["React / Svelte adapters
mount lifecycle + ControlledGraph bridge"] RECON["LineReconciler
converges line mirrors onto the snapshot"] MIRRORS["Runtime mirrors
NodeMirror, ConnectorMirror, LineMirror, GroupNodeMirror"] - REGISTRY["GraphMirror (per engine)
registries, ids, selection, groups, scheduler"] + REGISTRY["GraphRegistry (per engine)
registries, ids, selection, groups, scheduler"] ENGINE["SnapEngine mechanics
input, collision, transforms, scheduling"] DOM["Framework-owned DOM"] @@ -82,7 +82,7 @@ application's records and mounted components. 1. The adapter renders a `Node`/`Connector` from application state and constructs the mirror; the constructor registers it with the engine's - `GraphMirror` under its domain id (`config.id` or minted). + `GraphRegistry` under its domain id (`config.id` or minted). 2. The adapter assigns the committed DOM element and calls `remeasureDomGeometry()` (nodes) / relies on the connector's scheduled local-center measurement. A connector may stay headless (virtual) and use @@ -170,7 +170,7 @@ Key properties: rejection-by-inaction all resolve from the next snapshot — rejection needs no code path. - **Gestures are serial.** One in-flight request per engine - (`GraphMirror.pendingGestureRequest`); a second dispatch before the pass + (`GraphRegistry.pendingGestureRequest`); a second dispatch before the pass warns about a stalled adapter push. - **No graph owner, no gesture.** A drop on an engine without an attached reconciler warns and discards the preview — there is no document to @@ -278,7 +278,7 @@ never round-trip the framework: Selection is logically SnapLine-owned — core behaviors such as multi-node dragging need the selected set synchronously — and **engine-scoped** on -`GraphMirror.selection`. `setSelected()` maintains the list, writes +`GraphRegistry.selection`. `setSelected()` maintains the list, writes `data-selected`/`data-snapline-state`, and emits `onSelectionChange`. The framework is the visual owner; the consumer supplies pointer policy through `resolveSelectionMode` (SnapLine owns no modifier keys). `RectSelectController` @@ -294,9 +294,9 @@ resize settles. ## Registries -### GraphMirror (per engine) +### GraphRegistry (per engine) -`getGraphMirror(engine)` lazy-creates the engine-scoped registry the first +`getGraphRegistry(engine)` lazy-creates the engine-scoped registry the first time any mirror registers (constructors register, `destroy()` unregisters — no adapter wiring). It holds: @@ -327,13 +327,13 @@ same registry reads and were removed. ### What stays on global.data, and why -`SnapLineSharedData` (typed by `snapline-globals.ts`) now holds only: +`SnapLineSharedData` (typed by `internal/shared-data.ts`) now holds only: - `resizeHandles` and `sourceSurfaces` — engine core's `input.ts` duck-reads these to route pointerdowns to resize hitboxes and headless source surfaces (engine core cannot import snapline, so the contract is structural and lives on the shared bag); -- the `graphMirrors` WeakMap keying each engine to its `GraphMirror` +- the `graphRegistries` WeakMap keying each engine to its `GraphRegistry` (GlobalManager is application-wide; the WeakMap lets a destroyed engine release its registry); - the deprecated `allowCameraControl` boolean for third-party camera @@ -342,7 +342,7 @@ same registry reads and were removed. ## Scheduler and batching All reconciliation triggers funnel through -`GraphMirror.scheduleReconciliation()`: one microtask pass per burst, so a +`GraphRegistry.scheduleReconciliation()`: one microtask pass per burst, so a bulk mount of 100 nodes runs one pass, not one per connector. `beginBatch()` opens a nestable bulk boundary — no partial pass runs until the outermost idempotent `end()`, which schedules one final pass if anything @@ -393,7 +393,7 @@ There is **no imperative public topology API**: `deleteLine()`, record-driven settle/retarget/discard methods are `@internal` (reconciler/teardown-only), and connecting two connectors imperatively is not possible — applications create and remove lines by changing their -records. `GraphMirror` and `LineReconciler` are internal classes reached +records. `GraphRegistry` and `LineReconciler` are internal classes reached only through `attachControlledGraph` and `query`. `LineMirror` state is getter-backed: `start`, `target`, `payload`, `phase`, `candidate`, and anchors are read-only publicly, and `LineMirrorPhase` is @@ -421,7 +421,7 @@ types). Adapter contracts: | ----------------------------- | ------------------------------ | ---------------------------------------------------------------------- | | Domain node/connector records | Application/framework | Expressed by mounting components with stable `id` props | | Canonical line records | Application | `LineRecord[]` pushed via `setCanonicalGraph`; SnapLine never edits it | -| Mounted mirrors | Framework lifecycle | Constructors register with `GraphMirror`; `destroy()` unregisters | +| Mounted mirrors | Framework lifecycle | Constructors register with `GraphRegistry`; `destroy()` unregisters | | Settled line mirrors | LineReconciler | Derived from records; preserved by stable `lineId` | | Preview/staged lines | SnapLine gesture | Ephemeral; staged outcome awaits the canonical decision | | Gesture outcome | Application | One atomic `LineChangeRequest`; adopt the proposed id to settle in place | @@ -437,7 +437,7 @@ types). Adapter contracts: | Concern | Primary implementation | | ---------------------------------------- | ----------------------------------------------- | | Public exports | `assets/snapline/core/src/index.ts` | -| Engine-scoped registry, ids, scheduler | `assets/snapline/core/src/graph-mirror.ts` | +| Engine-scoped registry, ids, scheduler | `assets/snapline/core/src/internal/graph-registry.ts` | | Controlled line reconciliation, records | `assets/snapline/core/src/line-reconciler.ts` | | Connector rules, gestures, admission | `assets/snapline/core/src/connector.ts` | | Node lifecycle, drag/resize, geometry | `assets/snapline/core/src/node.ts` | @@ -446,7 +446,7 @@ types). Adapter contracts: | Rectangle selection | `assets/snapline/core/src/select.ts` | | Placement state machine | `assets/snapline/core/src/placement.ts` | | Read-only query facade | `assets/snapline/core/src/query.ts` | -| Shared global.data + attachControlledGraph | `assets/snapline/core/src/snapline-globals.ts` | +| Shared global.data + attachControlledGraph | `assets/snapline/core/src/internal/shared-data.ts` | | Geometry writer type | `assets/snapline/core/src/geometry.ts` | | Svelte adapters (incl. ControlledGraph) | `assets/snapline/svelte/src/*.svelte` | | React adapters (incl. ControlledGraph) | `assets/snapline/react/src/*.tsx` | diff --git a/docs/snapline/design/ownership-specification.md b/docs/snapline/design/ownership-specification.md index 3ac6e29..ed5b140 100644 --- a/docs/snapline/design/ownership-specification.md +++ b/docs/snapline/design/ownership-specification.md @@ -703,7 +703,7 @@ document, structured diagnostics, and engine scoping are all shipped: | Hydration/policy separation | Conforms | Strict record admission never evicts; refusals become structured diagnostics | | Atomic replacement request | Conforms | One `LineChangeRequest`; evictions ride the `"replace"` intent | | Read-only topology views | Conforms | Snapshot getters; getter-backed `LineMirror`; topology mutators are `@internal` | -| Unified mirror registry | Conforms | `GraphMirror` indexes nodes, connectors, and settled lines; one enumeration path | +| Unified mirror registry | Conforms | `GraphRegistry` indexes nodes, connectors, and settled lines; one enumeration path | | Engine isolation | Conforms | Selection, groups, `resizingNode`, and the reconciler are engine-scoped | | Diagnostics | Conforms | Derived `ReconciliationError`s via `onDiagnosticsChanged` / `query().diagnostics()` | | Geometry authority | Decided | SnapLine-owned visual cue; one batched `onGeometryChanged` observation, no controlled-geometry mode | @@ -720,10 +720,10 @@ is implemented: supported when both endpoints set `allowParallel`. 3. Connector identity? Typed `id` props/config (graph-global `connectorId`); the `identity(connector)` callback is gone. -4. `NodeManager`? Became the internal `GraphMirror` registry with the public +4. `NodeManager`? Became the internal `GraphRegistry` registry with the public read-only `GraphQuery` facade (`query(engine)`). 5. Central line registry? Yes — settled lines index by `lineId` in - `GraphMirror`; `query(engine).line(id)` looks them up. + `GraphRegistry`; `query(engine).line(id)` looks them up. 6. Hydration policy? Strict admission that never evicts, with structured `ReconciliationError` diagnostics; canonical records are never rewritten. 7. Atomic replace/reconnect? Yes — one `LineChangeRequest` per gesture with @@ -734,7 +734,7 @@ is implemented: 9. Geometry props? Neither controlled nor negotiated — geometry is SnapLine-owned and observed through one batched `onGeometryChanged`. 10. Selection and groups? They remain SnapLine-owned derived state, - engine-scoped on `GraphMirror` (framework owns the visuals). + engine-scoped on `GraphRegistry` (framework owns the visuals). 11. Mixed controlled/unmanaged engines? Moot — the unmanaged mode was eliminated; vanilla consumers drive the same controlled contract from a plain graph-owner module. diff --git a/docs/snapline/design/planned-rearchitecture.md b/docs/snapline/design/planned-rearchitecture.md index bc59132..c24e7fe 100644 --- a/docs/snapline/design/planned-rearchitecture.md +++ b/docs/snapline/design/planned-rearchitecture.md @@ -15,7 +15,7 @@ Related: This document is a delta from the current implementation and contains only work that has not landed. Everything previously specified here — the -vocabulary and `*Mirror` renames, stable domain identity, the `GraphMirror` +vocabulary and `*Mirror` renames, stable domain identity, the `GraphRegistry` registry, engine scoping, `ConnectorRules`, the controlled line protocol (`attachControlledGraph` / `setCanonicalGraph` / `onLineChangeRequest`), batching, diagnostics, the `ControlledGraph` adapters, property-propagation diff --git a/docs/snapline/reference/index.mdx b/docs/snapline/reference/index.mdx index b007299..b4814c7 100644 --- a/docs/snapline/reference/index.mdx +++ b/docs/snapline/reference/index.mdx @@ -16,5 +16,5 @@ React pages. | `@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`, `query`, `graph-mirror`, `line-reconciler`, and `geometry`. +The core package has a single entry point and no subpaths — import everything +from `@snap-engine/snapline`. diff --git a/src/input.ts b/src/input.ts index dd135a4..ed2c2d5 100644 --- a/src/input.ts +++ b/src/input.ts @@ -1015,7 +1015,7 @@ class InputControl { // hitbox's object (collider.parent), so the whole gesture flows to it through // the normal owner dispatch. Synchronous point test — independent of the // frame-delayed collision sweep. The registry shape is declared in - // snapline's snapline-globals.ts (engine core cannot import snapline, hence + // snapline's internal/shared-data.ts (engine core cannot import snapline, hence // the structural type here) — keep the two in sync. #resolveResizeOwner(position: eventPosition): ElementObject | null { const handles = this.global?.data?.resizeHandles as @@ -1038,7 +1038,7 @@ class InputControl { // Headless connector surfaces can extend beyond their parent node's DOM box. // SnapLine registers them in global.data so pointerdown ownership can be // resolved geometrically before composedPath routing. The registry shape is - // declared in snapline-globals.ts (engine core cannot import SnapLine). + // declared in internal/shared-data.ts (engine core cannot import SnapLine). #resolveSourceSurfaceOwner(position: eventPosition): ElementObject | null { const surfaces = this.global?.data?.sourceSurfaces as | Array<{ diff --git a/tests/ut/snapline-connector-config.spec.ts b/tests/ut/snapline-connector-config.spec.ts index 1ccd4c3..9729a46 100644 --- a/tests/ut/snapline-connector-config.spec.ts +++ b/tests/ut/snapline-connector-config.spec.ts @@ -7,7 +7,7 @@ import { PlacementController, type ConnectorSurfaceStrategy, } from "../../assets/snapline/core/src"; -import { getGraphMirror } from "../../assets/snapline/core/src/snapline-globals"; +import { getGraphRegistry } from "../../assets/snapline/core/src"; import { createControlledHarness, @@ -157,7 +157,7 @@ test("connector config updates stay live without replacing topology", () => { ], }); handle.flush(); - const existingLine = getGraphMirror(engine).line("cfg-line")!; + const existingLine = getGraphRegistry(engine).line("cfg-line")!; expect(source.outgoingLines).toEqual([existingLine]); const strategy: ConnectorSurfaceStrategy = { diff --git a/tests/ut/snapline-graph-mirror.spec.ts b/tests/ut/snapline-graph-mirror.spec.ts index 08820ff..01a9f46 100644 --- a/tests/ut/snapline-graph-mirror.spec.ts +++ b/tests/ut/snapline-graph-mirror.spec.ts @@ -4,7 +4,7 @@ import { GroupNodeMirror, NodeMirror, } from "../../assets/snapline/core/src"; -import { getGraphMirror } from "../../assets/snapline/core/src/snapline-globals"; +import { getGraphRegistry } from "../../assets/snapline/core/src"; import { armGesture, createControlledHarness, @@ -36,7 +36,7 @@ test("mirrors mint domain ids when none is supplied and honor supplied ids", () test("the graph mirror indexes registrations and drops them on destroy", () => { const { engine } = createEngineHarness(); - const mirror = getGraphMirror(engine); + const mirror = getGraphRegistry(engine); const node = new NodeMirror(engine, null, { id: "n1" }); const connector = new ConnectorMirror(engine, node, { id: "c1", @@ -58,7 +58,7 @@ test("the graph mirror indexes registrations and drops them on destroy", () => { test("duplicate ids never steal the index: first wins, diagnostic until resolved", () => { const { engine } = createEngineHarness(); - const mirror = getGraphMirror(engine); + const mirror = getGraphRegistry(engine); const first = new NodeMirror(engine, null, { id: "dup" }); const second = new NodeMirror(engine, null, { id: "dup" }); @@ -82,7 +82,7 @@ test("lines move preview -> settled -> preview and unregister on destroy", () => const restore = installObserverStubs(); try { const { engine, handle } = createControlledHarness(); - const mirror = getGraphMirror(engine); + const mirror = getGraphRegistry(engine); const { source } = mountConnectedPair(engine); // A freshly minted line is a preview, not part of the settled graph. @@ -121,8 +121,8 @@ test("each engine on a shared GlobalManager gets its own isolated registry", () const nodeA = new NodeMirror(engine, null, { id: "shared-id" }); const nodeB = new NodeMirror(sibling, null, { id: "shared-id" }); - const mirrorA = getGraphMirror(engine); - const mirrorB = getGraphMirror(sibling); + const mirrorA = getGraphRegistry(engine); + const mirrorB = getGraphRegistry(sibling); expect(mirrorA).not.toBe(mirrorB); // Same domain id on different engines is not a conflict. expect(mirrorA.node("shared-id")).toBe(nodeA); @@ -141,14 +141,14 @@ test("selection is engine-scoped and removal is identity-based", () => { nodeA.setSelected(true); nodeB.setSelected(true); - expect(getGraphMirror(engine).selection).toEqual([nodeA]); - expect(getGraphMirror(sibling).selection).toEqual([nodeB]); + expect(getGraphRegistry(engine).selection).toEqual([nodeA]); + expect(getGraphRegistry(sibling).selection).toEqual([nodeB]); // Identity-based removal: deselecting A must not evict the same-id node on // the sibling engine (the old shared list filtered by id). nodeA.setSelected(false); - expect(getGraphMirror(engine).selection).toEqual([]); - expect(getGraphMirror(sibling).selection).toEqual([nodeB]); + expect(getGraphRegistry(engine).selection).toEqual([]); + expect(getGraphRegistry(sibling).selection).toEqual([nodeB]); }); test("group registries are engine-scoped", () => { @@ -159,12 +159,12 @@ test("group registries are engine-scoped", () => { const groupA = new GroupNodeMirror(engine, null); const groupB = new GroupNodeMirror(sibling, null); - expect(getGraphMirror(engine).groups).toEqual([groupA]); - expect(getGraphMirror(sibling).groups).toEqual([groupB]); + expect(getGraphRegistry(engine).groups).toEqual([groupA]); + expect(getGraphRegistry(sibling).groups).toEqual([groupB]); groupA.destroy(false); - expect(getGraphMirror(engine).groups).toEqual([]); - expect(getGraphMirror(sibling).groups).toEqual([groupB]); + expect(getGraphRegistry(engine).groups).toEqual([]); + expect(getGraphRegistry(sibling).groups).toEqual([groupB]); groupB.destroy(false); } finally { restore(); @@ -196,7 +196,7 @@ test("a gesture without a graph owner warns and discards the preview", () => { test("the scheduler coalesces bursts and defers passes to the outermost batch end", async () => { const { engine } = createEngineHarness(); - const mirror = getGraphMirror(engine); + const mirror = getGraphRegistry(engine); let passes = 0; mirror.reconciler = { reconcile: () => { diff --git a/tests/ut/snapline-line-reconciler.spec.ts b/tests/ut/snapline-line-reconciler.spec.ts index 6a350c0..4507213 100644 --- a/tests/ut/snapline-line-reconciler.spec.ts +++ b/tests/ut/snapline-line-reconciler.spec.ts @@ -3,7 +3,7 @@ import { ConnectorMirror, NodeMirror, } from "../../assets/snapline/core/src"; -import { getGraphMirror } from "../../assets/snapline/core/src/snapline-globals"; +import { getGraphRegistry } from "../../assets/snapline/core/src"; import { attachControlledGraph, type LineChangeRequest } from "../../assets/snapline/core/src"; import { armGesture, @@ -33,7 +33,7 @@ function mountPair(engine: any) { test("canonical records hydrate settled lines, stay latent until endpoints mount, and prune on removal", () => { const { engine, handle, requests } = controlledHarness(); - const mirror = getGraphMirror(engine); + const mirror = getGraphRegistry(engine); handle.setCanonicalGraph({ lines: [ @@ -71,7 +71,7 @@ test("canonical records hydrate settled lines, stay latent until endpoints mount test("a settled line is preserved by stable id across endpoint retargets", () => { const { engine, handle } = controlledHarness(); - const mirror = getGraphMirror(engine); + const mirror = getGraphRegistry(engine); const { source, targetNode } = mountPair(engine); const secondTarget = new ConnectorMirror(engine, targetNode, { id: "in-2", @@ -112,7 +112,7 @@ test("a settled line is preserved by stable id across endpoint retargets", () => test("rules violations leave records latent with structured diagnostics that clear on resolution", () => { const { engine, handle, diagnosticsLog } = controlledHarness(); - const mirror = getGraphMirror(engine); + const mirror = getGraphRegistry(engine); const { target } = mountPair(engine); // A second source so two records target the same maxIncoming: 1 connector. new ConnectorMirror(engine, new NodeMirror(engine, null), { @@ -150,7 +150,7 @@ test("rules violations leave records latent with structured diagnostics that cle test("duplicate canonical ids and predicate vetoes surface as diagnostics", () => { const { engine, handle } = controlledHarness(); - const mirror = getGraphMirror(engine); + const mirror = getGraphRegistry(engine); const { source, target } = mountPair(engine); handle.setCanonicalGraph({ @@ -228,7 +228,7 @@ function driveDrop(owner: ConnectorMirror, dropX: number, pointerId = 7) { test("a controlled connect stages, proposes one atomic request, and settles in place on adoption", () => { const { engine, handle, requests, source, target } = gestureHarness(); - const mirror = getGraphMirror(engine); + const mirror = getGraphRegistry(engine); dragFrom(source, 100); driveDrop(source, 100); @@ -267,7 +267,7 @@ test("a controlled connect stages, proposes one atomic request, and settles in p test("rejection-by-inaction discards the staged line on the decisive pass", () => { const { engine, handle, requests, source } = gestureHarness(); - const mirror = getGraphMirror(engine); + const mirror = getGraphRegistry(engine); dragFrom(source, 100); driveDrop(source, 100); @@ -284,7 +284,7 @@ test("rejection-by-inaction discards the staged line on the decisive pass", () = test("a full replace-oldest target yields one atomic replace request with no local eviction", () => { const { engine, handle, requests, source, target } = gestureHarness(); - const mirror = getGraphMirror(engine); + const mirror = getGraphRegistry(engine); target.updateConfig({ rules: { maxOutgoing: 0, maxIncoming: 1, onFull: "replace-oldest" }, surfaceStrategies: [nearStrategy], @@ -331,7 +331,7 @@ test("a full replace-oldest target yields one atomic replace request with no loc test("a gesture disconnect proposes removal; rejection re-glues, acceptance discards", () => { const { engine, handle, requests, source, target } = gestureHarness(); - const mirror = getGraphMirror(engine); + const mirror = getGraphRegistry(engine); handle.setCanonicalGraph({ lines: [{ id: "line-a", fromConnectorId: "out-1", toConnectorId: "in-1" }], @@ -389,8 +389,8 @@ test("controlled graphs on sibling engines are fully isolated, gestures included }); handle.flush(); siblingHandle.flush(); - expect(getGraphMirror(engine).line("iso")).not.toBeNull(); - expect(getGraphMirror(sibling).line("iso")).toBeNull(); + expect(getGraphRegistry(engine).line("iso")).not.toBeNull(); + expect(getGraphRegistry(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 @@ -406,7 +406,7 @@ test("controlled graphs on sibling engines are fully isolated, gestures included test("a gesture reconnect preserves the line's stable id and mirror", () => { const { engine, handle, requests } = controlledHarness(); - const mirror = getGraphMirror(engine); + const mirror = getGraphRegistry(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. @@ -466,7 +466,7 @@ test("a gesture reconnect preserves the line's stable id and mirror", () => { test("a bulk load reconciles exactly once at the outermost batch end", async () => { const { engine, handle } = controlledHarness(); - const mirror = getGraphMirror(engine); + const mirror = getGraphRegistry(engine); const reconciler = mirror.reconciler!; const originalReconcile = reconciler.reconcile!.bind(reconciler); let passes = 0; diff --git a/tests/ut/snapline-perf.spec.ts b/tests/ut/snapline-perf.spec.ts index 239a926..07395f6 100644 --- a/tests/ut/snapline-perf.spec.ts +++ b/tests/ut/snapline-perf.spec.ts @@ -4,7 +4,7 @@ import { NodeMirror, type LineRecord, } from "../../assets/snapline/core/src"; -import { getGraphMirror } from "../../assets/snapline/core/src/snapline-globals"; +import { getGraphRegistry } from "../../assets/snapline/core/src"; import { createControlledHarness, nearTargetStrategy, @@ -15,7 +15,7 @@ import { // 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 mirror = getGraphRegistry(engine); const NODES = 200; const LINES = 150; From 293e5221724f303d203f479d8e69c05945f2f232 Mon Sep 17 00:00:00 2001 From: tfukaza Date: Mon, 27 Jul 2026 21:18:18 -0700 Subject: [PATCH 03/10] =?UTF-8?q?snapline:=20Phase=202=20=E2=80=94=20geome?= =?UTF-8?q?try=20invalidation=20observers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a multicast observation channel so a library outside SnapLine can keep something (a text overlay, a badge) glued to a line or node while it moves. This was previously impossible: LineMirror.#geometryWriter is a single field already owned by , so a consumer calling bindGeometryWriter would not add a watcher but silently replace the renderer; NodeMirror had no subscription channel at all, only a single-slot #callbacks object the adapters take over wholesale on mount. LineMirror.onGeometryInvalidated / NodeMirror.onGeometryInvalidated are Set-backed registrars returning an unsubscribe, matching the existing onStateChange shape. The signal is an INVALIDATION, not a paint hook. It fires synchronously during input dispatch, before any frame task is queued, and carries no geometry. Invoking it from inside writeTransform() would have silently decided the subscriber's write phase for them — WRITE_2, with no way to opt into a read stage or a later write stage, and nothing in the signature saying so. Instead the subscriber schedules its own task with the existing schedule(cb, { stage, queueId }) primitive and reads geometrySnapshot() there. Per SNAPZEN: expose the primitives, no magic abstraction. Consequences the docs have to state, and the unit spec pins: a line paints at WRITE_2 with updateAnchors() inside that same task, so an overlay wanting this frame's position schedules at WRITE_3. There is no priming call — nothing is invalidated at subscribe time, so read geometrySnapshot() directly for the initial position. Observer throws are isolated per subscriber so third-party code cannot blank the graph. Fire sites are consolidated rather than sprinkled. Line writes now funnel through LineMirror.invalidateGeometry() (deferred) and invalidateGeometryNow() (synchronous), each notifying before it queues; connector/node schedulers became loops over those. On the node side the two scheduling entry points cover it, and because scheduleTransformAndLines already walks #transformNodeTree(), multi-select peers and group-carried members each get their own signal — three cases NodeCallbacks.onDrag misses, along with camera edge-pan. Adds NodeMirror.geometrySnapshot() sourced from #authoredWidth/#authoredHeight rather than leaving consumers to reach for the public hitBox, which is DOM truth a ResizeObserver-scheduled READ_1 overwrites with the previously rendered box — reading it mid-gesture pairs last frame's height with this frame's y. Renames NodeCallbacks.onGeometryChanged -> onGeometryCommit. It is gesture-end only, and sitting beside a per-frame onGeometryInvalidated the old name was a trap; the pair now reads unambiguously. Adds tests/ut/snapline-observers.spec.ts and a test:snapline-ut script (the SnapLine unit specs previously had no npm entry point). Co-Authored-By: Claude Opus 5 (1M context) --- assets/snapline/AGENTS.md | 2 +- assets/snapline/core/src/connector.ts | 11 +- assets/snapline/core/src/index.ts | 1 + assets/snapline/core/src/line.ts | 76 +++++++- assets/snapline/core/src/node.ts | 72 +++++++- assets/snapline/core/src/types.ts | 11 ++ assets/snapline/react/src/Group.tsx | 18 +- assets/snapline/react/src/Node.tsx | 18 +- assets/snapline/svelte/src/Group.svelte | 10 +- assets/snapline/svelte/src/Node.svelte | 10 +- docs/snapline/design/current-architecture.md | 6 +- docs/snapline/design/migration-notes.md | 4 +- .../design/ownership-specification.md | 4 +- .../snapline/design/planned-rearchitecture.md | 2 +- docs/snapline/guides/02_selection_resize.mdx | 6 +- docs/snapline/reference/react/index.mdx | 2 +- docs/snapline/reference/react/node.mdx | 4 +- docs/snapline/reference/svelte/group.mdx | 2 +- docs/snapline/reference/svelte/index.mdx | 4 +- docs/snapline/reference/svelte/node.mdx | 4 +- docs/snapline/reference/vanilla/node.mdx | 2 +- package.json | 1 + tests/ut/snapline-observers.spec.ts | 171 ++++++++++++++++++ 23 files changed, 374 insertions(+), 67 deletions(-) create mode 100644 tests/ut/snapline-observers.spec.ts diff --git a/assets/snapline/AGENTS.md b/assets/snapline/AGENTS.md index 29349c8..0b9ffde 100644 --- a/assets/snapline/AGENTS.md +++ b/assets/snapline/AGENTS.md @@ -209,7 +209,7 @@ 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 the live observation and - the batched `onGeometryChanged({ nodes })` reports settled geometry the + the batched `onGeometryCommit({ nodes })` reports settled geometry the framework may persist (geometry is SnapLine-owned; ignoring the event never reverts the mirror). - **Position and size are one commit.** `#writeSizeGeometry` paints diff --git a/assets/snapline/core/src/connector.ts b/assets/snapline/core/src/connector.ts index ae7ec51..0f4a852 100644 --- a/assets/snapline/core/src/connector.ts +++ b/assets/snapline/core/src/connector.ts @@ -579,16 +579,7 @@ class ConnectorMirror extends ElementObject { scheduleAllLineWrites(): void { for (const line of [...this.#outgoingLines, ...this.#incomingLines]) { - line.schedule( - () => { - line.updateAnchors(); - line.writeTransform(); - }, - { - stage: "WRITE_2", - queueId: `${line.id}-transform`, - }, - ); + line.invalidateGeometry(); } } diff --git a/assets/snapline/core/src/index.ts b/assets/snapline/core/src/index.ts index bd682ea..0099a7a 100644 --- a/assets/snapline/core/src/index.ts +++ b/assets/snapline/core/src/index.ts @@ -98,6 +98,7 @@ export type { ConnectorId, ControlledGraphCallbacks, ControlledGraphHandle, + GeometryInvalidationObserver, GeometryWriter, GraphBatch, LineChangeRequest, diff --git a/assets/snapline/core/src/line.ts b/assets/snapline/core/src/line.ts index dfee90f..6f9bbd4 100644 --- a/assets/snapline/core/src/line.ts +++ b/assets/snapline/core/src/line.ts @@ -7,7 +7,7 @@ import type { ConnectorPoint, ConnectorSurfaceStrategy, } from "./connector"; -import type { GeometryWriter } from "./types"; +import type { GeometryInvalidationObserver, GeometryWriter } from "./types"; import { getGraphRegistry } from "./internal/shared-data"; import { mintDomainId } from "./internal/graph-registry"; @@ -51,6 +51,7 @@ class LineMirror extends ElementObject { #phase: LineMirrorPhase = "source-start"; #candidate: ConnectorCandidate | null = null; #geometryWriter: GeometryWriter | null = null; + #geometryObservers = new Set>(); #stateCallbacks = new Set<(state: LineStateSnapshot) => void>(); #sourceStrategy: ConnectorSurfaceStrategy | null = null; #sourceHit: ConnectorHit | null = null; @@ -139,6 +140,39 @@ class LineMirror extends ElementObject { }; } + /** + * Subscribe to "this line's geometry is about to change". + * + * Fires **synchronously, during input dispatch, before any frame task is + * queued** — not at paint time. It deliberately hands over no geometry: + * schedule your own task at whatever stage suits you and read + * `geometrySnapshot()` there. + * + * ```ts + * const stop = line.onGeometryInvalidated(() => + * line.schedule(place, { stage: "WRITE_3", queueId: "label" }), + * ); + * ``` + * + * The line itself paints at `WRITE_2` (and resolves its anchors inside that + * same task), so `WRITE_3` sees this frame's position while `WRITE_1` and + * `READ_2` still see the previous frame's. Choosing that is the caller's + * job, which is why no stage is implied here. + * + * Unlike {@link bindGeometryWriter} — the single owner that paints the line — + * any number of observers may subscribe. There is no priming call: nothing + * has been invalidated at subscribe time, so read `geometrySnapshot()` + * directly for the initial position. + * + * @returns an unsubscribe function. + */ + onGeometryInvalidated( + observer: GeometryInvalidationObserver, + ): () => void { + this.#geometryObservers.add(observer); + return () => this.#geometryObservers.delete(observer); + } + onStateChange(callback: (state: LineStateSnapshot) => void): () => void { this.#stateCallbacks.add(callback); callback(this.stateSnapshot()); @@ -245,6 +279,46 @@ class LineMirror extends ElementObject { this.#endAnchor = cloneAnchor(anchor); } + /** + * The deferred re-glue: notify observers now, paint next WRITE_2. + * + * Coalesces on `(objectId, queueId)`, so many invalidations in one frame + * collapse to a single write task. + */ + invalidateGeometry(): void { + this.#notifyGeometryInvalidated(); + this.schedule( + () => { + this.updateAnchors(); + this.writeTransform(); + }, + { stage: "WRITE_2", queueId: `${this.id}-transform` }, + ); + } + + /** + * The synchronous re-glue, for callers already inside a WRITE stage + * (settle, prop-driven node moves). Observers still fire first, so a + * subscriber's own scheduled task is queued before the paint happens. + */ + invalidateGeometryNow(): void { + this.#notifyGeometryInvalidated(); + this.updateAnchors(); + this.writeTransform(); + } + + #notifyGeometryInvalidated(): void { + for (const observer of this.#geometryObservers) { + // A third-party observer must never be able to stop the line painting + // or starve its peers. + try { + observer(this); + } catch (error) { + console.error("SnapLine: a line geometry observer threw.", error); + } + } + } + updateAnchors(): void { const target = this.target ?? this.candidate?.connector ?? null; if (!target) { diff --git a/assets/snapline/core/src/node.ts b/assets/snapline/core/src/node.ts index 2faa439..114612c 100644 --- a/assets/snapline/core/src/node.ts +++ b/assets/snapline/core/src/node.ts @@ -18,6 +18,7 @@ import { } from "./internal/shared-data"; import { mintDomainId } from "./internal/graph-registry"; import type { SnapLineMetadata } from "./connector"; +import type { GeometryInvalidationObserver } from "./types"; export type ResizeHandle = "n" | "ne" | "e" | "se" | "s" | "sw" | "w" | "nw"; @@ -188,7 +189,7 @@ export interface NodeCallbacks { /** Settled geometry after a drag or resize — SnapLine owns live and * settled position/size; the consumer may persist this observation, and * ignoring it does not revert the mirror. */ - onGeometryChanged?: (event: GeometryChangeEvent) => void; + onGeometryCommit?: (event: GeometryChangeEvent) => void; onSelectionChange?: (event: NodeSelectionEvent) => void; /** Observes live size updates; core writes the retained element geometry. */ onSizeChange?: (event: NodeResizeEvent) => void; @@ -381,6 +382,7 @@ class NodeMirror extends ElementObject { // The size last authored through setSizeState, kept apart from #hitBox (which // tracks what the browser actually rendered) so a write always paints the // value its own tick authored. + #geometryObservers = new Set>(); #authoredWidth = 0; #authoredHeight = 0; #hasAuthoredSize = false; @@ -537,8 +539,7 @@ class NodeMirror extends ElementObject { ...this.getAllIncomingLines(), ]); for (const line of lines) { - line.updateAnchors(); - line.writeTransform(); + line.invalidateGeometryNow(); } } @@ -559,7 +560,12 @@ class NodeMirror extends ElementObject { stage: "WRITE_2", queueId: `${this.id}-transform`, }); - for (const node of this.#transformNodeTree()) node.scheduleLineWrites(); + // Every node in the transform tree moves, not just this one — multi-select + // peers and group-carried members included — so each gets its own signal. + for (const node of this.#transformNodeTree()) { + node.notifyGeometryInvalidated(); + node.scheduleLineWrites(); + } } // Re-measure the node box + each connector's local center (READ_1) and re-glue @@ -604,6 +610,57 @@ class NodeMirror extends ElementObject { } } + /** + * Subscribe to "this node's geometry is about to change". + * + * Fires **synchronously, during input dispatch, before any frame task is + * queued** — for drags (including camera edge-pan, multi-select peers, and + * group-carried members, none of which `onDrag` reports) and for resizes. + * It hands over no geometry: schedule your own task at the stage you want + * and read {@link geometrySnapshot} there. + * + * Distinct from `onGeometryCommit`, which fires once per gesture with the + * settled result. This one fires every frame, before the paint. + * + * @returns an unsubscribe function. + */ + onGeometryInvalidated( + observer: GeometryInvalidationObserver, + ): () => void { + this.#geometryObservers.add(observer); + return () => this.#geometryObservers.delete(observer); + } + + /** @internal Fired by the scheduling entry points, before they queue. */ + notifyGeometryInvalidated(): void { + for (const observer of this.#geometryObservers) { + // A third-party observer must never be able to stop the node painting + // or starve its peers. + try { + observer(this); + } catch (error) { + console.error("SnapLine: a node geometry observer threw.", error); + } + } + } + + /** + * This node's position and **authored** size. + * + * Deliberately not sourced from `hitBox`: that is DOM truth, which a + * ResizeObserver-scheduled READ_1 overwrites with the *previously rendered* + * box. Reading it mid-gesture pairs last frame's height with this frame's + * `y`. Position and size here always come from the same authoring tick. + */ + geometrySnapshot(): { x: number; y: number; width: number; height: number } { + return { + x: this.worldTransform.x, + y: this.worldTransform.y, + width: this.#authoredWidth, + height: this.#authoredHeight, + }; + } + // State-only half of a size change: clamps to min and synchronously updates // the collision footprint + resize hitbox so hit testing and group // containment stay correct mid-drag. `setSize` adds the scheduled DOM write; @@ -654,6 +711,7 @@ class NodeMirror extends ElementObject { } #scheduleSizeGeometryWrite(): void { + this.notifyGeometryInvalidated(); this.schedule(() => this.#writeSizeGeometry(), { stage: "WRITE_1", queueId: `${this.id}-size`, @@ -960,7 +1018,7 @@ class NodeMirror extends ElementObject { } if (this.#resizing) { // The teardown runs in `finally` because everything above it calls out: - // #writeSizeGeometry touches the DOM and onGeometryChanged is consumer + // #writeSizeGeometry touches the DOM and onGeometryCommit is consumer // code. A throw that skipped these resets would strand `resizingNode`, // which permanently disables click-selection (see onUp), and would leave // the resize cursor pinned with no path back to a hover recompute. @@ -973,7 +1031,7 @@ class NodeMirror extends ElementObject { // 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.onGeometryChanged?.({ + this.#callbacks.onGeometryCommit?.({ nodes: [this.#geometryOf(this)], }); } finally { @@ -1025,7 +1083,7 @@ class NodeMirror extends ElementObject { } protected emitGeometryChange(): void { - this.#callbacks.onGeometryChanged?.({ + this.#callbacks.onGeometryCommit?.({ nodes: this.getDragCommitNodes().map((node) => this.#geometryOf(node)), }); } diff --git a/assets/snapline/core/src/types.ts b/assets/snapline/core/src/types.ts index 7bea0ef..4883529 100644 --- a/assets/snapline/core/src/types.ts +++ b/assets/snapline/core/src/types.ts @@ -21,6 +21,17 @@ export type LineId = string; */ export type GeometryWriter = (geometry: Readonly) => void; +/** + * Notified the moment core determines an object's geometry will change this + * frame — synchronously during input dispatch, before anything is queued. + * + * It carries no geometry on purpose. The subscriber schedules its own task at + * the stage it wants (`schedule(cb, { stage, queueId })`) and reads the + * object's `geometrySnapshot()` there, rather than having core pick a write + * phase on its behalf. + */ +export type GeometryInvalidationObserver = (source: T) => void; + /** * Structured, non-throwing report of a graph state the registry cannot * represent. Derived state: entries drop out when their cause resolves. diff --git a/assets/snapline/react/src/Group.tsx b/assets/snapline/react/src/Group.tsx index 20266ce..a644ac6 100644 --- a/assets/snapline/react/src/Group.tsx +++ b/assets/snapline/react/src/Group.tsx @@ -44,7 +44,7 @@ export interface GroupProps { canContain?: (event: GroupContainEvent) => boolean; edgePan?: boolean; onMembershipChange?: (event: GroupMembershipEvent) => void; - onGeometryChanged?: (event: GeometryChangeEvent) => void; + onGeometryCommit?: (event: GeometryChangeEvent) => void; } export const Group = forwardRef(function Group( @@ -71,7 +71,7 @@ export const Group = forwardRef(function Group( canContain, edgePan = true, onMembershipChange, - onGeometryChanged, + onGeometryCommit, }, ref, ) { @@ -107,13 +107,13 @@ export const Group = forwardRef(function Group( callbacks, groupCallbacks, onMembershipChange, - onGeometryChanged, + onGeometryCommit, }); latestRef.current = { callbacks, groupCallbacks, onMembershipChange, - onGeometryChanged, + onGeometryCommit, }; useImperativeHandle(ref, () => group, [group]); @@ -184,12 +184,12 @@ export const Group = forwardRef(function Group( latestRef.current.groupCallbacks.onMembershipChange, latestRef.current.onMembershipChange, ); - group.callbacks.onGeometryChanged = (event) => + group.callbacks.onGeometryCommit = (event) => invoke( event, - originalCallbacks.onGeometryChanged, - latestRef.current.callbacks.onGeometryChanged, - latestRef.current.onGeometryChanged, + originalCallbacks.onGeometryCommit, + latestRef.current.callbacks.onGeometryCommit, + latestRef.current.onGeometryCommit, ); group.callbacks.onSizeChange = (event) => { invoke( @@ -217,7 +217,7 @@ export const Group = forwardRef(function Group( originalCallbacks.resolveSelectionMode; group.callbacks.onDragStart = originalCallbacks.onDragStart; group.callbacks.onDrag = originalCallbacks.onDrag; - group.callbacks.onGeometryChanged = originalCallbacks.onGeometryChanged; + group.callbacks.onGeometryCommit = originalCallbacks.onGeometryCommit; group.callbacks.onSelectionChange = originalCallbacks.onSelectionChange; group.callbacks.onResizeHandleChange = originalCallbacks.onResizeHandleChange; diff --git a/assets/snapline/react/src/Node.tsx b/assets/snapline/react/src/Node.tsx index 7e09312..fa09b75 100644 --- a/assets/snapline/react/src/Node.tsx +++ b/assets/snapline/react/src/Node.tsx @@ -48,7 +48,7 @@ export interface NodeProps { metadata?: SnapLineMetadata; callbacks?: NodeCallbacks; edgePan?: boolean; - onGeometryChanged?: (event: GeometryChangeEvent) => void; + onGeometryCommit?: (event: GeometryChangeEvent) => void; onSizeChange?: (event: NodeResizeEvent) => void; /** Framework-native attributes and events for the outer node element. */ elementProps?: HTMLAttributes; @@ -75,7 +75,7 @@ export const Node = forwardRef(function Node( metadata = {}, callbacks = {}, edgePan = true, - onGeometryChanged, + onGeometryCommit, onSizeChange, elementProps, }, @@ -105,12 +105,12 @@ export const Node = forwardRef(function Node( ); const latestRef = useRef({ callbacks, - onGeometryChanged, + onGeometryCommit, onSizeChange, }); latestRef.current = { callbacks, - onGeometryChanged, + onGeometryCommit, onSizeChange, }; @@ -190,12 +190,12 @@ export const Node = forwardRef(function Node( latestRef.current.onSizeChange, ); }; - node.callbacks.onGeometryChanged = (event) => + node.callbacks.onGeometryCommit = (event) => invoke( event, - original.onGeometryChanged, - latestRef.current.callbacks.onGeometryChanged, - latestRef.current.onGeometryChanged, + original.onGeometryCommit, + latestRef.current.callbacks.onGeometryCommit, + latestRef.current.onGeometryCommit, ); setLineList([...node.getAllOutgoingLines()]); const boundElement = nodeDomRef.current; @@ -206,7 +206,7 @@ export const Node = forwardRef(function Node( node.callbacks.resolveSelectionMode = original.resolveSelectionMode; node.callbacks.onDragStart = original.onDragStart; node.callbacks.onDrag = original.onDrag; - node.callbacks.onGeometryChanged = original.onGeometryChanged; + node.callbacks.onGeometryCommit = original.onGeometryCommit; node.callbacks.onSelectionChange = original.onSelectionChange; node.callbacks.onResizeHandleChange = original.onResizeHandleChange; node.callbacks.onLinesChanged = original.onLinesChanged; diff --git a/assets/snapline/svelte/src/Group.svelte b/assets/snapline/svelte/src/Group.svelte index 884c1b2..e453f42 100644 --- a/assets/snapline/svelte/src/Group.svelte +++ b/assets/snapline/svelte/src/Group.svelte @@ -25,7 +25,7 @@ canContain = undefined, edgePan = true, onMembershipChange = undefined, - onGeometryChanged = undefined, + onGeometryCommit = undefined, children = undefined, }: { /** Stable domain identity; minted when omitted (supply for persistence). */ @@ -50,7 +50,7 @@ canContain?: (event: GroupContainEvent) => boolean; edgePan?: boolean; onMembershipChange?: (event: GroupMembershipEvent) => void; - onGeometryChanged?: (event: GeometryChangeEvent) => void; + onGeometryCommit?: (event: GeometryChangeEvent) => void; children?: any; } = $props(); @@ -121,8 +121,8 @@ groupObject!.callbacks.onSizeChange = (event) => { invoke(event, originalCallbacks.onSizeChange, callbacks.onSizeChange); }; - groupObject!.callbacks.onGeometryChanged = (event) => - invoke(event, originalCallbacks.onGeometryChanged, callbacks.onGeometryChanged, onGeometryChanged); + groupObject!.callbacks.onGeometryCommit = (event) => + invoke(event, originalCallbacks.onGeometryCommit, callbacks.onGeometryCommit, onGeometryCommit); // Header is the only move surface; wait a tick so the alias wins over the // element registration. The geometry effect seeds the collision footprint // and schedules the first paint once `mounted` flips. @@ -146,7 +146,7 @@ groupObject!.callbacks.resolveSelectionMode = originalCallbacks.resolveSelectionMode; groupObject!.callbacks.onDragStart = originalCallbacks.onDragStart; groupObject!.callbacks.onDrag = originalCallbacks.onDrag; - groupObject!.callbacks.onGeometryChanged = originalCallbacks.onGeometryChanged; + groupObject!.callbacks.onGeometryCommit = originalCallbacks.onGeometryCommit; groupObject!.callbacks.onSelectionChange = originalCallbacks.onSelectionChange; groupObject!.callbacks.onResizeHandleChange = originalCallbacks.onResizeHandleChange; groupObject!.callbacks.onSizeChange = originalCallbacks.onSizeChange; diff --git a/assets/snapline/svelte/src/Node.svelte b/assets/snapline/svelte/src/Node.svelte index 3671d1e..70f23a4 100644 --- a/assets/snapline/svelte/src/Node.svelte +++ b/assets/snapline/svelte/src/Node.svelte @@ -24,7 +24,7 @@ metadata = {}, callbacks = {}, edgePan = true, - onGeometryChanged = undefined, + onGeometryCommit = undefined, onSizeChange = undefined, elementProps = {}, children, @@ -47,7 +47,7 @@ metadata?: SnapLineMetadata; callbacks?: NodeCallbacks; edgePan?: boolean; - onGeometryChanged?: (event: GeometryChangeEvent) => void; + onGeometryCommit?: (event: GeometryChangeEvent) => void; onSizeChange?: (event: NodeResizeEvent) => void; /** Framework-native attributes and events for the outer node element. */ elementProps?: HTMLAttributes; @@ -110,8 +110,8 @@ invoke(event, originalCallbacks.onSelectionChange, callbacks.onSelectionChange); nodeObject.callbacks.onResizeHandleChange = (event) => invoke(event, originalCallbacks.onResizeHandleChange, callbacks.onResizeHandleChange); - nodeObject.callbacks.onGeometryChanged = (event) => - invoke(event, originalCallbacks.onGeometryChanged, callbacks.onGeometryChanged, onGeometryChanged); + nodeObject.callbacks.onGeometryCommit = (event) => + invoke(event, originalCallbacks.onGeometryCommit, callbacks.onGeometryCommit, onGeometryCommit); nodeObject.callbacks.onSizeChange = (event) => { invoke(event, originalCallbacks.onSizeChange, callbacks.onSizeChange, onSizeChange); }; @@ -132,7 +132,7 @@ nodeObject.callbacks.resolveSelectionMode = originalCallbacks.resolveSelectionMode; nodeObject.callbacks.onDragStart = originalCallbacks.onDragStart; nodeObject.callbacks.onDrag = originalCallbacks.onDrag; - nodeObject.callbacks.onGeometryChanged = originalCallbacks.onGeometryChanged; + nodeObject.callbacks.onGeometryCommit = originalCallbacks.onGeometryCommit; nodeObject.callbacks.onSelectionChange = originalCallbacks.onSelectionChange; nodeObject.callbacks.onResizeHandleChange = originalCallbacks.onResizeHandleChange; nodeObject.callbacks.onLinesChanged = originalCallbacks.onLinesChanged; diff --git a/docs/snapline/design/current-architecture.md b/docs/snapline/design/current-architecture.md index d23bbf3..d99ba73 100644 --- a/docs/snapline/design/current-architecture.md +++ b/docs/snapline/design/current-architecture.md @@ -31,7 +31,7 @@ gestures never mutate settled topology locally; each gesture proposes one atomic `LineChangeRequest` that the application accepts, normalizes, or rejects by updating its records. Position and size stay SnapLine-owned — geometry is a visual cue, observed (not negotiated) through one batched -`onGeometryChanged` callback. +`onGeometryCommit` callback. | Layer | Responsibility | | --------------------------- | -------------------------------------------------------------------------------------------------------------- | @@ -266,7 +266,7 @@ never round-trip the framework: - During a resize, core clamps, updates collision state, writes width/height, remeasures connector centers, and re-glues lines (`WRITE_1 → READ_2 → WRITE_2`). `onSizeChange` is the live observation. -- One batched **`onGeometryChanged({ nodes: [{ node, x, y, width, height }] })`** +- One batched **`onGeometryCommit({ nodes: [{ node, x, y, width, height }] })`** fires per settled gesture: a group or multi-select drag reports every moved node in one event; a resize reports a single entry. The application may persist the observation; ignoring it never reverts the mirror. @@ -426,7 +426,7 @@ types). Adapter contracts: | Preview/staged lines | SnapLine gesture | Ephemeral; staged outcome awaits the canonical decision | | Gesture outcome | Application | One atomic `LineChangeRequest`; adopt the proposed id to settle in place | | Node/connector/line DOM | React/Svelte adapter | Core writes transforms/`data-*` on existing elements only | -| Live + settled geometry | SnapLine | Observed via batched `onGeometryChanged`; never round-trips | +| Live + settled geometry | SnapLine | Observed via batched `onGeometryCommit`; never round-trips | | Connector policy | Application config | `ConnectorRules` + surface strategies, copied into the mirror | | Selection | SnapLine, engine-scoped | Framework owns visuals and pointer policy | | Group membership | SnapLine derived state | Computed from measured geometry; resolver overridable | diff --git a/docs/snapline/design/migration-notes.md b/docs/snapline/design/migration-notes.md index ca58aed..833ab7c 100644 --- a/docs/snapline/design/migration-notes.md +++ b/docs/snapline/design/migration-notes.md @@ -26,7 +26,7 @@ Position and size stay SnapLine-owned — geometry is a visual cue, observed | `ConnectorLinePhase` | `LineMirrorPhase` (adds `"staged"`) | | `syncDomGeometry()` | `remeasureDomGeometry()` | | `onLinesChanged` | unchanged name; payload is `LineMirror`s | -| `onDragCommit` + `onResizeCommit` | one batched `onGeometryChanged({ nodes })` | +| `onDragCommit` + `onResizeCommit` | one batched `onGeometryCommit({ nodes })` | | `NodePosition` / `NodeDragCommitEvent` | `NodeGeometry` / `GeometryChangeEvent` | | `EdgeId` / `EdgeRecord` / `EdgeLike` / `EdgeEndpoint` | `LineId` / `LineRecord` (stable-id, no endpoint-pair keying) | @@ -130,7 +130,7 @@ values from your own document (the same records that drive ## Geometry -`onGeometryChanged({ nodes: [{ node, x, y, width, height }] })` fires once +`onGeometryCommit({ 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 diff --git a/docs/snapline/design/ownership-specification.md b/docs/snapline/design/ownership-specification.md index ed5b140..09df9af 100644 --- a/docs/snapline/design/ownership-specification.md +++ b/docs/snapline/design/ownership-specification.md @@ -706,7 +706,7 @@ document, structured diagnostics, and engine scoping are all shipped: | Unified mirror registry | Conforms | `GraphRegistry` indexes nodes, connectors, and settled lines; one enumeration path | | Engine isolation | Conforms | Selection, groups, `resizingNode`, and the reconciler are engine-scoped | | Diagnostics | Conforms | Derived `ReconciliationError`s via `onDiagnosticsChanged` / `query().diagnostics()` | -| Geometry authority | Decided | SnapLine-owned visual cue; one batched `onGeometryChanged` observation, no controlled-geometry mode | +| Geometry authority | Decided | SnapLine-owned visual cue; one batched `onGeometryCommit` observation, no controlled-geometry mode | | Explicit authority model | Conforms | Exactly one model: always controlled; no imperative public topology surface | ## Open decisions — all decided and shipped @@ -732,7 +732,7 @@ is implemented: payload to canonical `LineRecord.payload`, mutation to the request protocol. 9. Geometry props? Neither controlled nor negotiated — geometry is - SnapLine-owned and observed through one batched `onGeometryChanged`. + SnapLine-owned and observed through one batched `onGeometryCommit`. 10. Selection and groups? They remain SnapLine-owned derived state, engine-scoped on `GraphRegistry` (framework owns the visuals). 11. Mixed controlled/unmanaged engines? Moot — the unmanaged mode was diff --git a/docs/snapline/design/planned-rearchitecture.md b/docs/snapline/design/planned-rearchitecture.md index c24e7fe..9b39083 100644 --- a/docs/snapline/design/planned-rearchitecture.md +++ b/docs/snapline/design/planned-rearchitecture.md @@ -38,7 +38,7 @@ Two decisions were amended from the original plan (both user-directed): 2. **Geometry authority modes were dropped.** Position and size are visual cues owned by SnapLine; there is no controlled-geometry variant and no `onGeometryChangeRequest`. D8 reduced to the consolidation: one batched - `onGeometryChanged` observation replaces `onDragCommit` + + `onGeometryCommit` observation replaces `onDragCommit` + `onResizeCommit`. ## Status: complete diff --git a/docs/snapline/guides/02_selection_resize.mdx b/docs/snapline/guides/02_selection_resize.mdx index 57f7e4f..7c864b1 100644 --- a/docs/snapline/guides/02_selection_resize.mdx +++ b/docs/snapline/guides/02_selection_resize.mdx @@ -40,7 +40,7 @@ Set `resizable` to enable all eight handles, or pass an exact list: width={node.width} height={node.height} resizeHandles={["e", "se", "s"]} - onGeometryChanged={(event) => saveGeometry(event)} + onGeometryCommit={(event) => saveGeometry(event)} /> ``` @@ -51,14 +51,14 @@ Set `resizable` to enable all eight handles, or pass an exact list: width={node.width} height={node.height} resizeHandles={["e", "se", "s"]} - onGeometryChanged={saveGeometry} + onGeometryCommit={saveGeometry} /> ``` SnapLine owns live and settled geometry — position and size are visual cues that never wait for an application render; changed props still resynchronize the object deliberately. Persist final values from the batched -`onGeometryChanged` event: a group or multi-select drag reports every moved +`onGeometryCommit` event: a group or multi-select drag reports every moved node in one event (`{ nodes: [{ node, x, y, width, height }] }`), a resize reports a single entry. diff --git a/docs/snapline/reference/react/index.mdx b/docs/snapline/reference/react/index.mdx index 8546b61..b61c742 100644 --- a/docs/snapline/reference/react/index.mdx +++ b/docs/snapline/reference/react/index.mdx @@ -12,7 +12,7 @@ frameworkKey: overview | Component | Important props | | ----------- | ------------------------------------------------------------------------------------------------------- | -| [`Node`](/docs/snapline/reference/react/node) | `id`, `nodeObject`, geometry, resize configuration, callbacks, `onGeometryChanged`, `lineComponent`, `elementProps` | +| [`Node`](/docs/snapline/reference/react/node) | `id`, `nodeObject`, geometry, resize configuration, callbacks, `onGeometryCommit`, `lineComponent`, `elementProps` | | [`Connector`](/docs/snapline/reference/react/connector) | `id`, `name` (required), `connectorObject`, `rules`, `virtual`, `surfaceStrategies`, metadata, callbacks | | [`Line`](/docs/snapline/reference/react/line) | `line`, SVG presentation | | [`Select`](/docs/snapline/reference/react/select) | callbacks and presentation | diff --git a/docs/snapline/reference/react/node.mdx b/docs/snapline/reference/react/node.mdx index c5d1728..351c3d4 100644 --- a/docs/snapline/reference/react/node.mdx +++ b/docs/snapline/reference/react/node.mdx @@ -23,7 +23,7 @@ frameworkKey: node | `resizeCursors` | Per-handle cursor overrides | | `metadata` | `SnapLineMetadata` attached to the node | | `callbacks` | `NodeCallbacks` object (drag, selection, resize, lines) | -| `onGeometryChanged` | Convenience callback; batched final geometry for persistence | +| `onGeometryCommit` | Convenience callback; batched final geometry for persistence | | `onSizeChange` | Convenience callback for resize events | | `edgePan` | Pan the camera when dragging near the viewport edge (default `true`) | | `lineComponent` | Custom line renderer for this node's outgoing lines (defaults to `Line`) | @@ -37,7 +37,7 @@ dedicated drag surface. Callback props stay current across renders and compose with callbacks already present on a supplied core object. During a live pointer gesture, core writes -retained element geometry directly; the batched `onGeometryChanged` reports +retained element geometry directly; the batched `onGeometryCommit` reports final values for application persistence. The rendered element carries `data-snapline-type="node"` and diff --git a/docs/snapline/reference/svelte/group.mdx b/docs/snapline/reference/svelte/group.mdx index 775b942..6083065 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` and `onGeometryChanged` are convenience +`onMembershipChange` and `onGeometryCommit` 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 d7fc6f1..a82f109 100644 --- a/docs/snapline/reference/svelte/index.mdx +++ b/docs/snapline/reference/svelte/index.mdx @@ -12,7 +12,7 @@ Wrap components in `Engine` from `@snap-engine/asset-base-svelte`. | Component | Important props | | ----------- | ------------------------------------------------------------------------------------------------------ | -| [`Node`](/docs/snapline/reference/svelte/node) | `id`, `nodeObject`, `x`, `y`, `width`, `height`, resize configuration, callbacks, `onGeometryChanged`, `onSizeChange`, `edgePan`, `metadata`, `LineSvelteComponent`, `className`, `elementProps` | +| [`Node`](/docs/snapline/reference/svelte/node) | `id`, `nodeObject`, `x`, `y`, `width`, `height`, resize configuration, callbacks, `onGeometryCommit`, `onSizeChange`, `edgePan`, `metadata`, `LineSvelteComponent`, `className`, `elementProps` | | [`Connector`](/docs/snapline/reference/svelte/connector) | `id`, `name`, `rules`, `virtual`, `surfaceStrategies`, `colliderRadius`, metadata, callbacks | | [`Line`](/docs/snapline/reference/svelte/line) | `line`, SVG presentation | | [`Select`](/docs/snapline/reference/svelte/select) | `callbacks`, `className` | @@ -39,4 +39,4 @@ without replacing the logical connector or its lines. `name` and 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. +`onGeometryCommit` reports final values for application persistence. diff --git a/docs/snapline/reference/svelte/node.mdx b/docs/snapline/reference/svelte/node.mdx index af8fe4f..9b10fb6 100644 --- a/docs/snapline/reference/svelte/node.mdx +++ b/docs/snapline/reference/svelte/node.mdx @@ -23,7 +23,7 @@ frameworkKey: node | `resizeCursors` | Per-handle cursor overrides | | `metadata` | `SnapLineMetadata` attached to the node | | `callbacks` | `NodeCallbacks` object (drag, selection, resize, lines) | -| `onGeometryChanged` | Convenience callback; batched final geometry for persistence | +| `onGeometryCommit` | Convenience callback; batched final geometry for persistence | | `onSizeChange` | Convenience callback for resize events | | `edgePan` | Pan the camera when dragging near the viewport edge (default `true`) | | `LineSvelteComponent` | Custom line renderer used for this node's outgoing lines (defaults to `Line`) | @@ -36,7 +36,7 @@ is not destroyed on unmount. Convenience callbacks compose with their `callbacks`-object equivalents rather than replacing them. During a live pointer gesture, core writes retained -element geometry directly; the batched `onGeometryChanged` reports final +element geometry directly; the batched `onGeometryCommit` reports final values for application persistence. The rendered element carries `data-snapline-type="node"` and diff --git a/docs/snapline/reference/vanilla/node.mdx b/docs/snapline/reference/vanilla/node.mdx index 003ac09..ba49fc4 100644 --- a/docs/snapline/reference/vanilla/node.mdx +++ b/docs/snapline/reference/vanilla/node.mdx @@ -20,7 +20,7 @@ supply it for persistence), `resizable`, `minWidth`, `minHeight`, `NodeCallbacks` covers drag (`canStartDrag`, `resolveDragPosition`, `onDragStart`, `onDrag`), selection (`resolveSelectionMode`, `onSelectionChange`), resize (`onResizeHandleChange`, `onSizeChange`), lines -(`onLinesChanged`), and the batched `onGeometryChanged` used for +(`onLinesChanged`), and the batched `onGeometryCommit` used for persistence. ## Resize constants diff --git a/package.json b/package.json index 176ccf5..e998dc9 100755 --- a/package.json +++ b/package.json @@ -46,6 +46,7 @@ "test:camera-control": "playwright test -c tests/e2e/camera-control.playwright.config.ts", "test:camera-control-react": "playwright test -c tests/e2e/camera-control-react.playwright.config.ts", "test:asset-base-react": "playwright test -c tests/e2e/asset-base-react.playwright.config.ts", + "test:snapline-ut": "playwright test tests/ut/snapline-*.spec.ts --project=chromium", "test:snapline": "playwright test -c tests/e2e/snapline.playwright.config.ts", "test:snapline-edges": "playwright test -c tests/e2e/snapline-edges.playwright.config.ts", "test:snapline-camera": "playwright test -c tests/e2e/snapline-camera.playwright.config.ts", diff --git a/tests/ut/snapline-observers.spec.ts b/tests/ut/snapline-observers.spec.ts new file mode 100644 index 0000000..293f407 --- /dev/null +++ b/tests/ut/snapline-observers.spec.ts @@ -0,0 +1,171 @@ +import { expect, test } from "@playwright/test"; +import { ConnectorMirror, NodeMirror } from "../../assets/snapline/core/src"; + +import { + createEngineHarness, + installObserverStubs, +} from "../helpers/snapline-harness"; + +/** Tasks queued for one object at one stage, keyed by their queueId. */ +function queuedIds(global: any, stage: string, objectId: string): string[] { + const perObject = global.queue[stage].get(objectId); + return perObject ? [...perObject.keys()] : []; +} + +function mountPair() { + const { engine, global } = createEngineHarness(); + const sourceNode = new NodeMirror(engine, null); + const targetNode = new NodeMirror(engine, null); + const source = new ConnectorMirror(engine, sourceNode, { + id: "obs-out", + name: "source", + rules: { maxIncoming: 0 }, + }); + const target = new ConnectorMirror(engine, targetNode, { + id: "obs-in", + name: "target", + rules: { maxOutgoing: 0 }, + }); + sourceNode.addConnectorObject(source); + targetNode.addConnectorObject(target); + return { engine, global, sourceNode, targetNode, source, target }; +} + +test("line geometry observers are multicast, and unsubscribing detaches only that one", () => { + const { source } = mountPair(); + const line = source.createLine(); + const first: number[] = []; + const second: number[] = []; + + const stopFirst = line.onGeometryInvalidated(() => first.push(1)); + line.onGeometryInvalidated(() => second.push(1)); + + // No priming call: subscribing invalidates nothing. + expect(first).toEqual([]); + expect(second).toEqual([]); + + line.invalidateGeometry(); + expect(first).toEqual([1]); + expect(second).toEqual([1]); + + stopFirst(); + line.invalidateGeometry(); + expect(first).toEqual([1]); + expect(second).toEqual([1, 1]); + + line.destroy(false); +}); + +test("the line signal fires synchronously BEFORE the write task is queued", () => { + const { global, source } = mountPair(); + const line = source.createLine(); + let queuedAtNotifyTime: string[] | null = null; + + line.onGeometryInvalidated(() => { + queuedAtNotifyTime = queuedIds(global, "WRITE_2", line.id); + }); + + expect(queuedIds(global, "WRITE_2", line.id)).toEqual([]); + line.invalidateGeometry(); + + // The whole point of the design: the observer runs early enough to schedule + // its own work into any stage, including one that runs before the line's. + expect(queuedAtNotifyTime).toEqual([]); + expect(queuedIds(global, "WRITE_2", line.id)).toEqual([ + `${line.id}-transform`, + ]); + + line.destroy(false); +}); + +test("a throwing observer stops neither its peers nor the paint", () => { + const { source } = mountPair(); + const line = source.createLine(); + const survived: number[] = []; + const painted: number[] = []; + const errors: unknown[] = []; + const originalError = console.error; + console.error = (...args: unknown[]) => errors.push(args); + + try { + line.onGeometryInvalidated(() => { + throw new Error("observer blew up"); + }); + line.onGeometryInvalidated(() => survived.push(1)); + line.bindGeometryWriter(() => painted.push(1)); + + line.invalidateGeometryNow(); + } finally { + console.error = originalError; + } + + expect(survived).toEqual([1]); + expect(errors).toHaveLength(1); + // bindGeometryWriter primes once on bind, then paints once here. + expect(painted).toEqual([1, 1]); + + line.destroy(false); +}); + +test("a node drag signals every node in the transform tree, not just the dragged one", () => { + const restoreObservers = installObserverStubs(); + try { + const { engine } = createEngineHarness(); + const parent = new NodeMirror(engine, null); + const carried = new NodeMirror(engine, null); + // Group carry and multi-select both move peers through the transform tree, + // which is exactly what NodeCallbacks.onDrag fails to report. + carried.attachTransformToGroup(parent); + + const parentHits: number[] = []; + const carriedHits: number[] = []; + parent.onGeometryInvalidated(() => parentHits.push(1)); + carried.onGeometryInvalidated(() => carriedHits.push(1)); + + parent.scheduleTransformAndLines(); + + expect(parentHits).toEqual([1]); + expect(carriedHits).toEqual([1]); + + carried.destroy(false); + parent.destroy(false); + } finally { + restoreObservers(); + } +}); + +test("a resize signals the node, and its snapshot reports authored size", () => { + const restoreObservers = installObserverStubs(); + try { + const { engine } = createEngineHarness(); + const node = new NodeMirror(engine, null, { minWidth: 40, minHeight: 20 }); + const seen: Array<{ width: number; height: number }> = []; + + node.worldTransform = { x: 7, y: 11 }; + node.onGeometryInvalidated((source) => { + const geometry = source.geometrySnapshot(); + seen.push({ width: geometry.width, height: geometry.height }); + }); + + node.setSize(120, 80); + node.setSize(10, 5); // below the clamp + + // Authored size, read in the same tick it was authored — never the + // previously rendered hitBox, which a READ_1 remeasure can overwrite + // between authoring and paint. + expect(seen).toEqual([ + { width: 120, height: 80 }, + { width: 40, height: 20 }, + ]); + expect(node.geometrySnapshot()).toEqual({ + x: 7, + y: 11, + width: 40, + height: 20, + }); + + node.destroy(false); + } finally { + restoreObservers(); + } +}); From 83c68de662ccef17229e22dcd455268cca08f58f Mon Sep 17 00:00:00 2001 From: tfukaza Date: Mon, 27 Jul 2026 21:38:56 -0700 Subject: [PATCH 04/10] =?UTF-8?q?snapline:=20Phase=203a/3c=20=E2=80=94=20t?= =?UTF-8?q?he=20request=20returns=20the=20next=20document?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit onLineChangeRequest now RETURNS the line list that should be canonical, and the bridge hands it straight to setCanonicalGraph. The `lines` prop is gone from both adapters, along with both queueMicrotask pushes and GraphRegistry.pendingGestureRequest. Why the microtask existed, and why returning removes the need for it: a dropped line is staged — visually targeted, no topology commitment — so its only exit is the next reconcile pass, which either settles it or discards it in the end-of-pass sweep. The reconciler has no pull channel. If the app rejected by doing nothing, no prop changed, no effect fired, no push happened, and the staged line hung forever; the unconditional post-request push closed that hole. Making the handler return the list closes it structurally instead, and takes three fragilities with it: the React adapter's correctness rested on an inference about React internals (that the handler's setState flushes and re-renders the props ref before microtasks drain — true for React 18 discrete events, not a contract); an ordering hazard where the decisive pass could be queued ahead of the adapter's push; and pendingGestureRequest, which gated nothing and only warned about a stalled push whose failure mode no longer exists. Rejection is now `return lines` — strictly better than the old bare `return`, because "I reject" and "I forgot" stop being the same code. The return type is non-optional so the second one is a type error. Adds applyLineChange(lines, request) to core, replacing a reducer that was byte-identical across nine sites (three doc variants, migration notes, the website demo, two Svelte demos, two React demos). Both adapters gain the imperative handle the protocol always needed for changes with no originating request — hydration/load, undo/redo, collaboration. Svelte exports setLines/flush (it exported nothing before); React is now forwardRef instead of a plain function returning null, which also makes AGENTS.md's claim about forwarded refs true. Consumers migrated. Note the React idiom the synchronous return forces: the document lives in a ref, because a functional setState has not produced the next list by the time the handler must return it. Svelte demos move to $state.raw, since applyLineChange replaces rather than mutates. The demos' addDocLine stays app code rather than becoming a core helper: its "replace any record sharing toConnectorId" step is single-capacity policy the application owns, not a request being applied. Test harness now stands in for a document: it tracks the records, defaults to rejection (return unchanged), and offers respondWith for accept/normalize. All 33 unit and 58 e2e tests pass with no assertion changes. Co-Authored-By: Claude Opus 5 (1M context) --- assets/snapline/AGENTS.md | 9 +- assets/snapline/core/src/connector.ts | 18 +-- assets/snapline/core/src/controlled-graph.ts | 48 +++++- assets/snapline/core/src/index.ts | 2 +- .../core/src/internal/graph-registry.ts | 4 - .../core/src/internal/line-reconciler.ts | 15 +- assets/snapline/core/src/types.ts | 19 ++- assets/snapline/react/src/ControlledGraph.tsx | 61 +++---- assets/snapline/react/src/index.ts | 7 +- .../svelte/src/ControlledGraph.svelte | 35 ++-- demo/react/src/App.jsx | 153 ++++++++++-------- .../src/demo/node_ui_demo/DemoGraph.svelte | 28 ++-- .../demo/node_ui_edges/NodeUIEdgesDemo.svelte | 44 ++--- docs/snapline/design/current-architecture.md | 28 ++-- docs/snapline/design/migration-notes.md | 10 +- .../snapline/guides/06_surface_connectors.mdx | 6 +- docs/snapline/introduction/01_setup.mdx | 75 ++++----- .../reference/react/controlled-graph.mdx | 39 +++-- docs/snapline/reference/react/index.mdx | 2 +- .../reference/svelte/controlled-graph.mdx | 42 ++++- docs/snapline/reference/svelte/index.mdx | 2 +- .../reference/vanilla/controlled-graph.mdx | 24 ++- tests/helpers/snapline-harness.ts | 54 ++++++- .../lib/components/docs/SnapLineDemo.svelte | 23 +-- 24 files changed, 457 insertions(+), 291 deletions(-) diff --git a/assets/snapline/AGENTS.md b/assets/snapline/AGENTS.md index 0b9ffde..85f210c 100644 --- a/assets/snapline/AGENTS.md +++ b/assets/snapline/AGENTS.md @@ -298,10 +298,11 @@ 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 +`replace-oldest` evictions ride the request, never local deletes). `onLineChangeRequest` +RETURNS the line list that should now be canonical, and the bridge adopts it +synchronously — so exactly one decisive pass runs per request, acceptance and +rejection alike, with no dependence on when a framework flushes state. +Rejection is returning the list unchanged; 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 diff --git a/assets/snapline/core/src/connector.ts b/assets/snapline/core/src/connector.ts index 0f4a852..c2401db 100644 --- a/assets/snapline/core/src/connector.ts +++ b/assets/snapline/core/src/connector.ts @@ -851,18 +851,12 @@ class ConnectorMirror extends ElementObject { } #dispatchRequest(request: LineChangeRequest): void { - const mirror = getGraphRegistry(this.engine); - if (mirror.pendingGestureRequest) { - console.warn( - "SnapLine: a line-change request is already in flight; gestures are serial, so this indicates a stalled adapter push.", - ); - } - mirror.pendingGestureRequest = true; - mirror.reconciler?.dispatchLineChangeRequest?.(request); - // The decisive pass runs after the adapter's post-request push — the - // adapter queues its push inside the dispatch above, ahead of this - // scheduled microtask. - mirror.scheduleReconciliation(); + // The dispatch itself adopts the application's returned line list, so the + // decisive pass this schedules always sees the app's answer — including + // rejection, which returns the list unchanged. + const registry = getGraphRegistry(this.engine); + registry.reconciler?.dispatchLineChangeRequest?.(request); + registry.scheduleReconciliation(); } /** @internal Reconciler-only: settle a staged gesture line onto its diff --git a/assets/snapline/core/src/controlled-graph.ts b/assets/snapline/core/src/controlled-graph.ts index 37f4606..4072b5f 100644 --- a/assets/snapline/core/src/controlled-graph.ts +++ b/assets/snapline/core/src/controlled-graph.ts @@ -1,6 +1,52 @@ import { LineReconciler } from "./internal/line-reconciler"; import { getGraphRegistry } from "./internal/shared-data"; -import type { ControlledGraphCallbacks, ControlledGraphHandle } from "./types"; +import type { + ControlledGraphCallbacks, + ControlledGraphHandle, + LineChangeRequest, + LineRecord, +} from "./types"; + +/** + * Apply one atomic {@link LineChangeRequest} to a line list, returning a new + * list. This is the whole body of a typical `onLineChangeRequest`: + * + * ```ts + * onLineChangeRequest={(r) => (lines = applyLineChange(lines, r))} + * ``` + * + * Removals are dropped, endpoint updates are applied, and additions are + * appended — in that order, as one commit. `intent` is not consulted: it + * describes the gesture for logging, and the three lists already say what to + * do. + * + * **Adopt the proposed ids.** `request.add` carries SnapLine-minted ids; + * keeping them settles the dragged line in place with no flicker. Substituting + * your own id works, but recreates the mirror. + * + * Consumer-owned fields survive an update (the record is spread), but cannot + * be invented for an addition — seed those at drag start with the node's + * `resolveNewLine`, and they ride into `request.add` for you. + * + * Replace-not-mutate by construction: every call builds a new array, which is + * what makes `$state.raw` safe for the list in Svelte. + */ +export function applyLineChange( + lines: readonly T[], + request: LineChangeRequest, +): T[] { + const removed = new Set(request.remove); + const next: T[] = []; + for (const record of lines) { + if (removed.has(record.id)) continue; + const update = request.update.find((entry) => entry.id === record.id); + next.push( + update ? { ...record, toConnectorId: update.toConnectorId } : record, + ); + } + for (const addition of request.add) next.push(addition as unknown as T); + return next; +} /** * Attach the controlled-graph bridge: declares "controlled" authority, diff --git a/assets/snapline/core/src/index.ts b/assets/snapline/core/src/index.ts index 0099a7a..f4033b1 100644 --- a/assets/snapline/core/src/index.ts +++ b/assets/snapline/core/src/index.ts @@ -91,7 +91,7 @@ export type { PlacementSize, PlacementSnapshot, } from "./placement"; -export { attachControlledGraph } from "./controlled-graph"; +export { applyLineChange, attachControlledGraph } from "./controlled-graph"; export { getGraphRegistry } from "./internal/shared-data"; export type { CanonicalGraphSnapshot, diff --git a/assets/snapline/core/src/internal/graph-registry.ts b/assets/snapline/core/src/internal/graph-registry.ts index f392fe5..3dc363a 100644 --- a/assets/snapline/core/src/internal/graph-registry.ts +++ b/assets/snapline/core/src/internal/graph-registry.ts @@ -59,10 +59,6 @@ export class GraphRegistry { // reach it through this slot. reconciler: GraphReconcilerLike | null = null; - /** @internal One in-flight gesture request per engine (gestures are - * serial); cleared by the next reconciliation pass. */ - pendingGestureRequest = false; - #reconciliationQueued = false; #batchDepth = 0; #batchDirty = false; diff --git a/assets/snapline/core/src/internal/line-reconciler.ts b/assets/snapline/core/src/internal/line-reconciler.ts index 6a2b20e..932d67e 100644 --- a/assets/snapline/core/src/internal/line-reconciler.ts +++ b/assets/snapline/core/src/internal/line-reconciler.ts @@ -39,9 +39,19 @@ export class LineReconciler { if (this.#mirror.reconciler === this) this.#mirror.reconciler = null; } - /** Forward a gesture's atomic proposal to the application. */ + /** + * Forward a gesture's atomic proposal to the application and adopt whatever + * it returns as the new canonical state. + * + * Synchronous and unconditional, which is what makes the protocol's + * invariants structural rather than timed: exactly one decisive pass per + * request (including a rejected one, since the end-of-pass sweep is + * snapshot-driven, not flag-driven), and no dependence on when a framework + * happens to flush its state. + */ dispatchLineChangeRequest(request: LineChangeRequest): void { - this.#callbacks.onLineChangeRequest(request); + const lines = this.#callbacks.onLineChangeRequest(request); + this.setCanonicalGraph({ lines }); } /** One pass: prune, preserve/retarget by stable id, create, report. */ @@ -156,7 +166,6 @@ export class LineReconciler { } } finally { this.#reconciling = false; - mirror.pendingGestureRequest = false; } if (mirror.setReconciliationErrors(errors)) { diff --git a/assets/snapline/core/src/types.ts b/assets/snapline/core/src/types.ts index 4883529..2f7fc9c 100644 --- a/assets/snapline/core/src/types.ts +++ b/assets/snapline/core/src/types.ts @@ -87,7 +87,24 @@ export interface LineChangeRequest { } export interface ControlledGraphCallbacks { - onLineChangeRequest(request: LineChangeRequest): void; + /** + * One atomic proposal per gesture. **Return the line list that should now be + * canonical** — the bridge hands it straight to the reconciler, so exactly + * one decisive pass runs per request whether you accept, normalize, or + * reject. + * + * ```ts + * onLineChangeRequest: (r) => (lines = applyLineChange(lines, r)) + * ``` + * + * To reject, return the list unchanged (`return lines`). The return type is + * non-optional on purpose: "I reject" and "I forgot to return anything" used + * to be the same code, and this makes the second one a type error. + * + * Must be synchronous — the staged preview line is resolved by the pass that + * follows this call. + */ + onLineChangeRequest(request: LineChangeRequest): readonly LineRecord[]; onDiagnosticsChanged?(diagnostics: readonly ReconciliationError[]): void; } diff --git a/assets/snapline/react/src/ControlledGraph.tsx b/assets/snapline/react/src/ControlledGraph.tsx index 2315e2c..b263f4e 100644 --- a/assets/snapline/react/src/ControlledGraph.tsx +++ b/assets/snapline/react/src/ControlledGraph.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef } from "react"; +import { forwardRef, useEffect, useImperativeHandle, useRef } from "react"; import { attachControlledGraph, type ControlledGraphHandle, @@ -9,53 +9,54 @@ import { import { useSnapLineEngine } from "./Engine"; export interface ControlledGraphProps { - /** The application-owned canonical line records (stable ids). */ - lines: readonly LineRecord[]; - /** One atomic proposal per gesture; accept/normalize/reject by updating - * the records — adopting a proposed id settles the line in place. */ - onLineChangeRequest: (request: LineChangeRequest) => void; + /** One atomic proposal per gesture. Return the list that should now be + * canonical — adopting a proposed id settles the line in place; returning + * the list unchanged rejects. Must be synchronous. */ + onLineChangeRequest: (request: LineChangeRequest) => readonly LineRecord[]; onDiagnosticsChanged?: (diagnostics: readonly ReconciliationError[]) => void; } -export function ControlledGraph({ - lines, - onLineChangeRequest, - onDiagnosticsChanged, -}: ControlledGraphProps) { +/** + * The app↔SnapLine bridge. Renders nothing. + * + * Gesture-driven changes flow through `onLineChangeRequest`'s return value. + * For records no request asked for — hydration/load, undo/redo, a + * collaborator's edit — take a ref and call `setLines`. + */ +export const ControlledGraph = forwardRef< + ControlledGraphHandle, + ControlledGraphProps +>(function ControlledGraph({ onLineChangeRequest, onDiagnosticsChanged }, ref) { const engine = useSnapLineEngine(); // Written during render so the bridge's closures always read fresh props. - const propsRef = useRef({ lines, onLineChangeRequest, onDiagnosticsChanged }); - propsRef.current = { lines, onLineChangeRequest, onDiagnosticsChanged }; + const propsRef = useRef({ onLineChangeRequest, onDiagnosticsChanged }); + propsRef.current = { onLineChangeRequest, onDiagnosticsChanged }; const handleRef = useRef(null); useEffect(() => { const handle = attachControlledGraph(engine, { - onLineChangeRequest: (request) => { - propsRef.current.onLineChangeRequest(request); - // Guaranteed post-request push: React flushes the handler's state - // update (and re-renders propsRef) before this microtask runs, so - // the decisive pass sees the app's decision — including - // rejection-by-inaction, where the unchanged records come through. - queueMicrotask(() => - handleRef.current?.setCanonicalGraph({ - lines: propsRef.current.lines, - }), - ); - }, + onLineChangeRequest: (request) => + propsRef.current.onLineChangeRequest(request), onDiagnosticsChanged: (diagnostics) => propsRef.current.onDiagnosticsChanged?.(diagnostics), }); handleRef.current = handle; - handle.setCanonicalGraph({ lines: propsRef.current.lines }); return () => { handle.dispose(); handleRef.current = null; }; }, [engine]); - useEffect(() => { - handleRef.current?.setCanonicalGraph({ lines }); - }, [lines]); + useImperativeHandle( + ref, + () => ({ + setCanonicalGraph: (snapshot) => + handleRef.current?.setCanonicalGraph(snapshot), + flush: () => handleRef.current?.flush(), + dispose: () => handleRef.current?.dispose(), + }), + [], + ); return null; -} +}); diff --git a/assets/snapline/react/src/index.ts b/assets/snapline/react/src/index.ts index fb96913..e620806 100644 --- a/assets/snapline/react/src/index.ts +++ b/assets/snapline/react/src/index.ts @@ -1,6 +1,11 @@ export { Connector } from "./Connector"; export type { ConnectorProps, ConnectorRef } from "./Connector"; -export { Engine, EngineContext, SnapLineEngine, useSnapLineEngine } from "./Engine"; +export { + Engine, + EngineContext, + SnapLineEngine, + useSnapLineEngine, +} from "./Engine"; export type { EngineProps } from "./Engine"; export { Group } from "./Group"; export type { GroupProps } from "./Group"; diff --git a/assets/snapline/svelte/src/ControlledGraph.svelte b/assets/snapline/svelte/src/ControlledGraph.svelte index 9a30fbb..4007d38 100644 --- a/assets/snapline/svelte/src/ControlledGraph.svelte +++ b/assets/snapline/svelte/src/ControlledGraph.svelte @@ -9,15 +9,13 @@ import { getContext, onDestroy } from "svelte"; let { - lines, onLineChangeRequest, onDiagnosticsChanged = undefined, }: { - /** The application-owned canonical line records (stable ids). */ - lines: readonly LineRecord[]; - /** One atomic proposal per gesture; accept/normalize/reject by updating - * the records — adopting a proposed id settles the line in place. */ - onLineChangeRequest: (request: LineChangeRequest) => void; + /** One atomic proposal per gesture. Return the list that should now be + * canonical — adopting a proposed id settles the line in place; returning + * the list unchanged rejects. Must be synchronous. */ + onLineChangeRequest: (request: LineChangeRequest) => readonly LineRecord[]; onDiagnosticsChanged?: ( diagnostics: readonly ReconciliationError[], ) => void; @@ -25,20 +23,25 @@ const engine: Engine = getContext("engine"); const handle = attachControlledGraph(engine, { - onLineChangeRequest: (request) => { - onLineChangeRequest(request); - // Guaranteed post-request push: reads the live prop after the app's - // synchronous document update, queued ahead of the decisive - // reconciliation pass so acceptance and rejection both resolve - // without timing inference. - queueMicrotask(() => handle.setCanonicalGraph({ lines })); - }, + onLineChangeRequest: (request) => onLineChangeRequest(request), onDiagnosticsChanged: (diagnostics) => onDiagnosticsChanged?.(diagnostics), }); - $effect(() => { + /** + * Push records that no originating request asked for: hydration/load, + * undo/redo, or a collaborator's edit. Reach it with `bind:this`. + * + * Gesture-driven changes need none of this — returning the list from + * `onLineChangeRequest` already delivers them. + */ + export function setLines(lines: readonly LineRecord[]): void { handle.setCanonicalGraph({ lines }); - }); + } + + /** Run any pending reconciliation synchronously (tests, imperative flows). */ + export function flush(): void { + handle.flush(); + } onDestroy(() => handle.dispose()); diff --git a/demo/react/src/App.jsx b/demo/react/src/App.jsx index b3a9c58..77ae32e 100644 --- a/demo/react/src/App.jsx +++ b/demo/react/src/App.jsx @@ -5,7 +5,8 @@ import { Node, Select, } from "@snap-engine/snapline-react"; -import { useCallback, useState } from "react"; +import { applyLineChange } from "@snap-engine/snapline"; +import { useCallback, useRef, useState } from "react"; import { Engine as SnapEngine } from "@snap-engine/asset-base-react"; import { DropSnapNestedDemo, @@ -20,21 +21,16 @@ import AssetBaseReactDemo from "./AssetBaseReactDemo"; // The demo's canonical line document: topology is always controlled, so // even a sandbox owns its lines and accepts every atomic proposal. function DemoGraph() { - const [lines, setLines] = useState([]); - const applyRequest = useCallback((request) => { - setLines((current) => [ - ...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 ; + // The handler must return the next list synchronously, so the document lives + // in a ref: a functional setState would not have produced it in time. Nothing + // here renders the records, so no component state is needed at all. + const linesRef = useRef([]); + const applyRequest = useCallback( + (request) => + (linesRef.current = applyLineChange(linesRef.current, request)), + [], + ); + return ; } function ResizableNode({ title, id = title, x, y }) { @@ -54,14 +50,26 @@ function ResizableNode({ title, id = title, x, y }) {
- +
Input
Output
- +
@@ -129,14 +137,26 @@ function SimpleNode({ title, id = title, x, y }) {
- +
Input
Output
- +
@@ -152,24 +172,15 @@ export default function App() { return ; } - if ( - path === "/snapsort-insertion" || - demo === "snapsort_insertion" - ) { + if (path === "/snapsort-insertion" || demo === "snapsort_insertion") { return ; } - if ( - path === "/snapsort-website-core" || - demo === "snapsort_website_core" - ) { + if (path === "/snapsort-website-core" || demo === "snapsort_website_core") { return ; } - if ( - path === "/snapsort-components" || - demo === "snapsort_components" - ) { + if (path === "/snapsort-components" || demo === "snapsort_components") { return ; } @@ -215,7 +226,11 @@ function GraphNode({ nodeId, title, x, y, maxIncoming = 1 }) { Input @@ -239,22 +254,34 @@ function SnapLineEdgesDemo() { const [lines, setLines] = useState([]); const [connectIntents, setConnectIntents] = useState(0); const [intentLog, setIntentLog] = useState([]); + // The document also drives rendering (edge-count), so it is mirrored into a + // ref the synchronous handler can read. + const linesRef = useRef(lines); + const graphRef = useRef(null); - const addDocLine = (record) => - setLines((current) => - current.some((existing) => existing.id === record.id) - ? current - : [ - ...current.filter( - (existing) => existing.toConnectorId !== record.toConnectorId, - ), - record, - ], - ); + const commit = useCallback((next) => { + linesRef.current = next; + setLines(next); + return next; + }, []); + + // No originating request, so this one has to be pushed through the handle. + const addDocLine = (record) => { + const current = linesRef.current; + if (current.some((existing) => existing.id === record.id)) return; + const next = commit([ + ...current.filter( + (existing) => existing.toConnectorId !== record.toConnectorId, + ), + record, + ]); + graphRef.current?.setCanonicalGraph({ lines: next }); + }; - const handleRequest = useCallback((request) => { - const nodeOf = (connectorId) => connectorId.split(":")[0]; - setLines((current) => { + const handleRequest = useCallback( + (request) => { + const nodeOf = (connectorId) => connectorId.split(":")[0]; + const current = linesRef.current; const byId = new Map(current.map((record) => [record.id, record])); const label = (record) => `${nodeOf(record.fromConnectorId)}->${nodeOf(record.toConnectorId)}`; @@ -265,7 +292,10 @@ function SnapLineEdgesDemo() { const updated = request.update .map((update) => byId.has(update.id) - ? label({ ...byId.get(update.id), toConnectorId: update.toConnectorId }) + ? label({ + ...byId.get(update.id), + toConnectorId: update.toConnectorId, + }) : update.id, ) .join(","); @@ -281,19 +311,10 @@ function SnapLineEdgesDemo() { 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 commit(applyLineChange(current, request)); + }, + [commit], + ); return (
@@ -318,9 +339,15 @@ function SnapLineEdgesDemo() {
- + (lines = applyLineChange(lines, request))} + /> Source @@ -65,7 +57,7 @@ npm install react react-dom @snap-engine/snapline-react ``` ```tsx framework=react -import { useCallback, useState } from "react"; +import { useCallback, useRef } from "react"; import { Connector, ControlledGraph, @@ -73,28 +65,24 @@ import { Node, Select, } from "@snap-engine/snapline-react"; +import { applyLineChange } from "@snap-engine/snapline"; import type { LineChangeRequest, LineRecord } from "@snap-engine/snapline"; export function Graph() { // Your document owns the lines; each gesture proposes one atomic change. - const [lines, setLines] = useState([]); - const applyRequest = useCallback((request: LineChangeRequest) => { - setLines((current) => [ - ...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, - ]); - }, []); + // The handler must return the next list synchronously, so keep the records + // in a ref (a functional setState would not have produced it in time) and + // mirror them into component state only if you render them. + const linesRef = useRef([]); + const applyRequest = useCallback( + (request: LineChangeRequest) => + (linesRef.current = applyLineChange(linesRef.current, request)), + [], + ); return ( - One - Two - Three + One{@render resizeRegions()} + Two{@render resizeRegions()} + Three{@render resizeRegions()} {:else if mode === "groups"} @@ -208,6 +215,40 @@ min-height: 54px; } + :global(.doc-resize-region) { + --resize-size: 12px; + position: absolute; + } + :global(.doc-resize-region[data-handle="n"]), + :global(.doc-resize-region[data-handle="s"]) { + left: var(--resize-size); + right: var(--resize-size); + height: var(--resize-size); + cursor: ns-resize; + } + :global(.doc-resize-region[data-handle="e"]), + :global(.doc-resize-region[data-handle="w"]) { + top: var(--resize-size); + bottom: var(--resize-size); + width: var(--resize-size); + cursor: ew-resize; + } + :global(.doc-resize-region[data-handle^="n"]) { top: -6px; } + :global(.doc-resize-region[data-handle^="s"]) { bottom: -6px; } + :global(.doc-resize-region[data-handle$="e"]) { right: -6px; } + :global(.doc-resize-region[data-handle$="w"]) { left: -6px; } + :global(.doc-resize-region[data-handle="ne"]), + :global(.doc-resize-region[data-handle="se"]), + :global(.doc-resize-region[data-handle="sw"]), + :global(.doc-resize-region[data-handle="nw"]) { + width: var(--resize-size); + height: var(--resize-size); + } + :global(.doc-resize-region[data-handle="ne"]), + :global(.doc-resize-region[data-handle="sw"]) { cursor: nesw-resize; } + :global(.doc-resize-region[data-handle="nw"]), + :global(.doc-resize-region[data-handle="se"]) { cursor: nwse-resize; } + :global(.doc-node[data-selected="true"]) { outline: 3px solid color-mix(in srgb, var(--color-primary) 38%, transparent); outline-offset: 3px; From d9f7e239317ac14d85b71e467dc3b3339fba25e5 Mon Sep 17 00:00:00 2001 From: tfukaza Date: Wed, 5 Aug 2026 21:01:09 -0700 Subject: [PATCH 10/10] Refine input ownership and SnapLine connectors Add native pointer capture handoff and resilient gesture finalization, migrate SnapLine connectors to DOM-owned input with collision point queries, and update SnapSort integration, adapters, documentation, and regression coverage. --- assets/snapline/AGENTS.md | 30 +- assets/snapline/core/README.md | 14 +- assets/snapline/core/src/connector.ts | 191 ++-- assets/snapline/core/src/controlled-graph.ts | 4 +- assets/snapline/core/src/group.ts | 187 ++-- assets/snapline/core/src/index.ts | 2 +- .../snapline/core/src/internal/shared-data.ts | 32 - assets/snapline/core/src/node.ts | 376 +++----- assets/snapline/react/README.md | 19 +- assets/snapline/react/src/Connector.tsx | 18 +- assets/snapline/react/src/Group.tsx | 7 + assets/snapline/react/src/Node.tsx | 11 +- assets/snapline/svelte/README.md | 19 +- assets/snapline/svelte/src/Connector.svelte | 20 +- assets/snapline/svelte/src/Group.svelte | 5 + assets/snapline/svelte/src/Node.svelte | 12 +- assets/snapsort/core/src/drag/flow-ghost.ts | 14 +- assets/snapsort/core/src/drag/session.ts | 37 +- assets/snapsort/core/src/item.ts | 10 +- .../introduction/04_input_system.mdx | 22 +- docs/snapengine/introduction/06_collision.mdx | 5 + docs/snapengine/reference/collision.mdx | 6 + docs/snapengine/reference/input_system.mdx | 41 +- docs/snapline/design/current-architecture.md | 12 +- .../snapline/design/planned-rearchitecture.md | 4 +- .../snapline/guides/06_surface_connectors.mdx | 117 ++- docs/snapline/reference/react/connector.mdx | 35 +- docs/snapline/reference/react/index.mdx | 17 +- docs/snapline/reference/react/node.mdx | 2 +- docs/snapline/reference/svelte/connector.mdx | 35 +- docs/snapline/reference/svelte/index.mdx | 14 +- docs/snapline/reference/svelte/node.mdx | 2 +- docs/snapline/reference/vanilla/connector.mdx | 17 +- docs/snapline/reference/vanilla/node.mdx | 5 +- src/AGENTS.md | 5 + src/collision.ts | 14 + src/index.ts | 5 +- src/input.ts | 858 ++++++++++------- src/object.ts | 21 +- src/util.ts | 9 +- tests/helpers/snapline-harness.ts | 31 +- tests/ut/input-headless-owner.spec.ts | 891 +++++++++++++++++- tests/ut/snapline-connector-config.spec.ts | 157 ++- tests/ut/snapline-graph-mirror.spec.ts | 5 +- tests/ut/snapline-observers.spec.ts | 50 +- tests/ut/transform.spec.ts | 26 + 46 files changed, 2368 insertions(+), 1046 deletions(-) diff --git a/assets/snapline/AGENTS.md b/assets/snapline/AGENTS.md index aeb10eb..ea34f91 100644 --- a/assets/snapline/AGENTS.md +++ b/assets/snapline/AGENTS.md @@ -30,8 +30,8 @@ The three that come up constantly, and what they have already decided: the application mounts the node and adds the record. **There are no such thing as sensible defaults.** When a hook seeds -application data, its absence is meaningful — see `ResolvedNodeConfig`, which -keeps `resolveNewLine` optional rather than defaulting it to a no-op. +application data, its absence is meaningful — `NodeCallbacks.resolveNewLine` +stays optional rather than defaulting to a no-op. A convenience API that duplicates an existing primitive should be rejected on these grounds, not merely debated. @@ -166,7 +166,7 @@ snapline/ **Features:** - Derived source/target roles (`isSource` = `maxOutgoing !== 0`, `isTarget` = `maxIncoming !== 0`) - Connection limits and admission predicates -- Surface strategies for headless hit testing and anchors +- Target hit-testing and anchor strategies around DOM-owned connector roots - Connection callbacks ### LineMirror @@ -203,9 +203,9 @@ requiring PascalCase for dynamic components): `ResizeRegion` children - Rendering: `LineSvelteComponent` (one renderer for all this node's lines), `resolveLineComponent(line)` (per-line override, resolved at render time) -- Data: `metadata`, `resolveNewLine` (seeds payload onto a line this node's - connectors create) -- Callbacks: `callbacks` (the whole `NodeCallbacks` dictionary), plus the +- Data: `metadata` +- Callbacks: `callbacks` (the whole `NodeCallbacks` dictionary, including + `resolveNewLine` for seeding payload onto newly dragged lines), plus the convenience props `onGeometryCommit` and `onSizeChange` - `edgePan` @@ -344,10 +344,11 @@ 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: +authority, and there is no imperative public topology API (creation and +deletion are implementation operations; 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 @@ -377,10 +378,11 @@ inconsistent one. Everything SnapLine stores on the engine's shared `global.data` bag is declared in `core/src/internal/shared-data.ts` (`SnapLineSharedData`) and accessed through -its typed helpers. It holds `sourceSurfaces` (engine core's `input.ts` reads -the shape duck-typed — it cannot import snapline) plus the `graphRegistries` -WeakMap keying each engine to its `GraphRegistry`. Selection, groups, and -`resizingNode` are engine-scoped state on `GraphRegistry`, not global arrays. +its typed helpers. It holds the `graphRegistries` WeakMap keying each engine to +its `GraphRegistry`, plus the deprecated third-party camera-control flag. +Selection, groups, and `resizingNode` are engine-scoped state on +`GraphRegistry`, not global arrays. Connector source input is ordinary DOM +targeting; SnapLine stores no headless source registry. ### Pointer claims (camera blocking) diff --git a/assets/snapline/core/README.md b/assets/snapline/core/README.md index 9272be2..a348f0a 100644 --- a/assets/snapline/core/README.md +++ b/assets/snapline/core/README.md @@ -48,13 +48,13 @@ call `node.remeasureDomGeometry()`. The remeasure is coalesced into the next read/write cycle and re-glues every connected 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. 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. +Connector roots own gesture initiation, while target-hit and anchor strategies +can rank shape-specific collision candidates and resolve preview and settled +anchors from cached geometry. Symmetric connector rules +(`maxOutgoing`/`maxIncoming`, `"unlimited"` explicit) let the same logical +connector 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 on custom HTML or SVG roots. Call `connector.updateConfig(...)` to change callbacks, metadata, policy, surface strategies, collider radius, or edge-pan behavior diff --git a/assets/snapline/core/src/connector.ts b/assets/snapline/core/src/connector.ts index f94c149..4a1f84a 100644 --- a/assets/snapline/core/src/connector.ts +++ b/assets/snapline/core/src/connector.ts @@ -1,4 +1,4 @@ -import { ElementObject, BaseObject } from "@snap-engine/core"; +import { ElementObject, BaseObject, type DomElement } from "@snap-engine/core"; import type { dragEndProp, dragProp, @@ -10,7 +10,7 @@ import type { import { CircleCollider } from "@snap-engine/core/collision"; import type { NodeMirror } from "./node"; import { LineMirror, cloneAnchor, type LineMirrorPhase } from "./line"; -import { getGraphRegistry, getSourceSurfaces } from "./internal/shared-data"; +import { getGraphRegistry } from "./internal/shared-data"; import { mintDomainId } from "./internal/graph-registry"; import type { LineChangeRequest } from "./types"; @@ -101,9 +101,6 @@ export interface ConnectorAnchorEvent { } export interface ConnectorSurfaceStrategy { - sourceHitTest?: ( - event: ConnectorSurfaceHitTestEvent, - ) => ConnectorHit | null | false | void; targetHitTest?: ( event: ConnectorSurfaceHitTestEvent, ) => ConnectorHit | null | false | void; @@ -189,6 +186,8 @@ export interface ConnectorPointerEvent extends ConnectorDragEvent { } export interface ConnectorCallbacks { + /** Overrides the parent node's `resolveNewLine` for this connector only. */ + resolveNewLine?: NewLineResolver; /** Fires when this connector claims a primary pointer, before drag threshold. */ onPointerDown?: (event: ConnectorPointerEvent) => void; onDragStart?: (event: ConnectorDragEvent) => void; @@ -226,8 +225,6 @@ export interface ConnectorConfig { callbacks?: ConnectorCallbacks; /** Allows this connector gesture to use the engine's configured edge pan. */ edgePan?: boolean; - /** Overrides the parent node's `resolveNewLine` for this connector only. */ - resolveNewLine?: NewLineResolver; } /** Context for seeding a brand-new line at drag start. */ @@ -266,7 +263,7 @@ interface ArmedConnection { reconnectLine: LineMirror | null; } -class ConnectorMirror extends ElementObject { +class ConnectorMirror extends ElementObject { /** Stable domain identity — supplied via `ConnectorConfig.id` or minted. * Never the engine-internal `BaseObject.id`. */ readonly connectorId: string; @@ -286,6 +283,7 @@ class ConnectorMirror extends ElementObject { #armed: ArmedConnection | null = null; #gestureOrigin: "new" | "reconnect" | null = null; #cancelledPointers = new Set(); + #dragDelegate: ConnectorMirror | null = null; #callbacks: ConnectorCallbacks; get parent(): NodeMirror { @@ -323,7 +321,6 @@ class ConnectorMirror extends ElementObject { this.#config.colliderRadius ?? 30, ); this.addCollider(this.#hitCircle); - this.#syncSourceSurfaceRegistration(); getGraphRegistry(this.engine).registerConnector(this); this.event.dom.onAssignDom = () => { @@ -391,15 +388,14 @@ class ConnectorMirror extends ElementObject { this.#callbacks = this.#config.callbacks ?? {}; this.#rules = Object.freeze(resolveRules(this.#config)); this.#hitCircle.radius = this.#config.colliderRadius ?? 30; - this.#syncSourceSurfaceRegistration(); this.scheduleAllLineWrites(); } /** - * Attaches or detaches the optional visible port without destroying the - * logical connector. Framework adapters use this for live `virtual` changes. + * Attaches or detaches the developer-rendered HTML or SVG input root without + * destroying the logical connector. */ - bindElement(element: HTMLElement | null): void { + bindElement(element: DomElement | null): void { if (this.element === element) return; if (this.element) { this.destroyDom(false); @@ -491,8 +487,7 @@ class ConnectorMirror extends ElementObject { onCursorDown(prop: pointerDownProp): void { if (prop.event.button !== 0) return; - const sourceHit = this.#resolveOwnSourceHit(prop.position, "source-start"); - this.armSurfaceGesture(prop, sourceHit); + this.armSurfaceGesture(prop, null); } armSurfaceGesture( @@ -504,7 +499,7 @@ class ConnectorMirror extends ElementObject { if (this.#rules.reconnect && currentIncomingLines.length > 0) { const line = currentIncomingLines[0]; const source = line.start; - this.engine.input.setPointerDragOwner(prop.event.pointerId, source); + this.#dragDelegate = source; source.#arm(prop, { sourceHit: null, sourceStrategy: null, @@ -545,8 +540,17 @@ class ConnectorMirror extends ElementObject { #onPointerUp(prop: pointerUpProp): void { const pointerId = prop.event.pointerId; + const delegate = this.#dragDelegate; + this.#dragDelegate = null; + if ( + delegate && + delegate.#armed?.pointerId === pointerId && + delegate.#state === ConnectorState.ARMED + ) { + delegate.#resetGesture(); + } if (this.#armed?.pointerId !== pointerId) return; - if (prop.event.type === "pointercancel") { + if (prop.cancelled) { this.#cancelledPointers.add(pointerId); } if (this.#state === ConnectorState.ARMED) { @@ -555,6 +559,13 @@ class ConnectorMirror extends ElementObject { } #onDragStart(prop: dragStartProp): void { + const delegate = this.#dragDelegate; + if (delegate && delegate.#armed?.pointerId === prop.pointerId) { + this.#dragDelegate = null; + prop.handoffTo(delegate); + delegate.#onDragStart(prop); + return; + } if ( this.#state !== ConnectorState.ARMED || this.#armed?.pointerId !== prop.pointerId @@ -569,7 +580,7 @@ class ConnectorMirror extends ElementObject { this.#detachLineForReconnect(line); line.clearTarget(); } else { - line = this.createLine(); + line = this.#createLine(); line.setSourceSurfaceContext(armed.sourceStrategy, armed.sourceHit); // Seed app data onto a genuinely new line. This branch structurally // cannot run for a reconnect, so it can never clobber payload the @@ -637,7 +648,7 @@ class ConnectorMirror extends ElementObject { assignToNode(parent: NodeMirror): void { this.parent = parent; const parentRef = this.parent; - parentRef._connectors[this.#name] = this; + parentRef.attachConnector(this); this.#outgoingLines = []; this.#incomingLines = []; if (parentRef.global && this.global == null) { @@ -645,9 +656,9 @@ class ConnectorMirror extends ElementObject { } } - /** @internal Gesture/reconciler-only: lines exist because canonical + /** Gesture/reconciler-only: lines exist because canonical * records (or in-flight gestures) say so. */ - createLine(config: { id?: string } = {}): LineMirror { + #createLine(config: { id?: string } = {}): LineMirror { const line = new LineMirror(this.engine, this, config); line.setSourceSurfaceContext(this.#defaultAnchorStrategy(), null); return line; @@ -663,10 +674,6 @@ class ConnectorMirror extends ElementObject { ); } - resolveSourceHit(position: eventPosition): ConnectorResolvedHit | null { - return this.#resolveOwnSourceHit(position, "source-start"); - } - /** * Imperative admission query. Structural rules always apply; the * line-aware isValidConnection predicates run only when a line is given. @@ -785,7 +792,7 @@ class ConnectorMirror extends ElementObject { return; } - if (this.#cancelledPointers.has(prop.pointerId)) { + if (prop.cancelled || this.#cancelledPointers.has(prop.pointerId)) { this.#discardDraggedLine(line, prop, false, "cancelled"); return; } @@ -912,7 +919,11 @@ class ConnectorMirror extends ElementObject { /** Connector-level override, else the parent node's resolver. */ #resolveNewLine(): NewLineResolver | null { - return this.#config.resolveNewLine ?? this.parent?.resolveNewLine ?? null; + return ( + this.#callbacks.resolveNewLine ?? + this.parent?.callbacks.resolveNewLine ?? + null + ); } #dispatchRequest(request: LineChangeRequest): void { @@ -1040,56 +1051,33 @@ class ConnectorMirror extends ElementObject { this.#resetGesture(); this.deleteAllLines("teardown"); getGraphRegistry(this.engine).unregisterConnector(this); - if (this.parent?._connectors[this.#name] === this) { - delete this.parent._connectors[this.#name]; - } - this.#removeSourceSurfaceRegistration(); + this.parent?.detachConnector(this); this.globalInput.pointerUp = null; super.destroy(removeElement); } - #resolveOwnSourceHit( - position: eventPosition, - phase: LineMirrorPhase, - ): ConnectorResolvedHit | null { - const hits: ConnectorResolvedHit[] = []; - for (const [strategyIndex, strategy] of this.surfaceStrategies.entries()) { - const hit = normalizeHit( - strategy.sourceHitTest?.({ - connector: this, - position, - geometry: this.geometry, - phase, - }), - ); - if (hit) { - hits.push({ - candidate: { connector: this, hit }, - strategy, - strategyIndex, - }); - } - } - return pickResolvedHit(hits); - } - #resolveTargetAtPoint( position: eventPosition, phase: "preview-target" | "drop", ): ConnectorResolvedHit | null { const hits: ConnectorResolvedHit[] = []; - for (const connector of registeredConnectors(this.engine)) { + for (const collider of this.engine.collisionEngine?.queryPoint(position) ?? + []) { + const connector = collider.parent; if ( + !(connector instanceof ConnectorMirror) || + collider !== connector.#hitCircle || connector.engine !== this.engine || + connector.isDeleteRequested || !this.#admitsConnection(connector, this.#dragLine, "candidate") ) { continue; } - for (const [ - strategyIndex, - strategy, - ] of connector.surfaceStrategies.entries()) { + const targetStrategies = connector.surfaceStrategies + .map((strategy, strategyIndex) => ({ strategy, strategyIndex })) + .filter(({ strategy }) => strategy.targetHitTest != null); + for (const { strategyIndex, strategy } of targetStrategies) { const hit = normalizeHit( strategy.targetHitTest?.({ connector, @@ -1107,22 +1095,25 @@ class ConnectorMirror extends ElementObject { } } - if (connector.#hasOrdinaryPortGeometry()) { + if ( + targetStrategies.length === 0 && + connector.#hasOrdinaryPortGeometry() + ) { const center = connector.center; - const distance = Math.hypot( - center.x - position.x, - center.y - position.y, - ); - if (distance <= (connector.#config.colliderRadius ?? 30)) { - hits.push({ - candidate: { - connector, - hit: { anchor: center, distance }, + hits.push({ + candidate: { + connector, + hit: { + anchor: center, + distance: Math.hypot( + center.x - position.x, + center.y - position.y, + ), }, - strategy: connector.#defaultAnchorStrategy(), - strategyIndex: Number.MAX_SAFE_INTEGER, - }); - } + }, + strategy: connector.#defaultAnchorStrategy(), + strategyIndex: Number.MAX_SAFE_INTEGER, + }); } } return pickResolvedHit(hits); @@ -1204,25 +1195,6 @@ class ConnectorMirror extends ElementObject { ); } - #syncSourceSurfaceRegistration(): void { - const sourceSurfaces = getSourceSurfaces(this.global); - const index = sourceSurfaces.indexOf(this); - const shouldRegister = - this.isSource && - this.surfaceStrategies.some((strategy) => strategy.sourceHitTest); - if (shouldRegister && index === -1) { - sourceSurfaces.push(this); - } else if (!shouldRegister && index !== -1) { - sourceSurfaces.splice(index, 1); - } - } - - #removeSourceSurfaceRegistration(): void { - const sourceSurfaces = getSourceSurfaces(this.global); - const index = sourceSurfaces.indexOf(this); - if (index !== -1) sourceSurfaces.splice(index, 1); - } - #hasOrdinaryPortGeometry(): boolean { return this.element != null || this.#hasMeasuredCenter; } @@ -1239,7 +1211,7 @@ class ConnectorMirror extends ElementObject { const structural = this.#admitsEndpoints(target, null, false); if (structural !== true) return structural; - const line = this.createLine({ id: record.id }); + const line = this.#createLine({ id: record.id }); if (!this.#predicatesAdmit(target, line, "drop")) { line.destroy(false); return "connection-rejected"; @@ -1395,33 +1367,4 @@ function pickResolvedHit( return hits[0] ?? null; } -export function resolveConnectorSourceAtPoint( - engine: any, - position: eventPosition, - node?: NodeMirror, -): ConnectorResolvedHit | null { - const hits: ConnectorResolvedHit[] = []; - for (const surface of getSourceSurfaces(engine.global)) { - const connector = surface as ConnectorMirror; - if ( - connector.engine !== engine || - (node && connector.parent !== node) || - !connector.isSource - ) { - continue; - } - const resolved = connector.resolveSourceHit(position); - if (resolved) hits.push(resolved); - } - return pickResolvedHit(hits); -} - -function registeredConnectors(engine: any): ConnectorMirror[] { - // The registry is the one connector source — no engine-object-table scan - // (this runs per pointer move during a connection drag). - return getGraphRegistry(engine).connectors.filter( - (connector) => !connector.isDeleteRequested, - ); -} - export { ConnectorMirror }; diff --git a/assets/snapline/core/src/controlled-graph.ts b/assets/snapline/core/src/controlled-graph.ts index 4072b5f..a63d87c 100644 --- a/assets/snapline/core/src/controlled-graph.ts +++ b/assets/snapline/core/src/controlled-graph.ts @@ -25,8 +25,8 @@ import type { * your own id works, but recreates the mirror. * * Consumer-owned fields survive an update (the record is spread), but cannot - * be invented for an addition — seed those at drag start with the node's - * `resolveNewLine`, and they ride into `request.add` for you. + * be invented for an addition — seed those at drag start with + * `node.callbacks.resolveNewLine`, and they ride into `request.add` for you. * * Replace-not-mutate by construction: every call builds a new array, which is * what makes `$state.raw` safe for the list in Svelte. diff --git a/assets/snapline/core/src/group.ts b/assets/snapline/core/src/group.ts index 9e20052..2e91e2c 100644 --- a/assets/snapline/core/src/group.ts +++ b/assets/snapline/core/src/group.ts @@ -122,87 +122,6 @@ function wouldCreateGroupCycle( return false; } -function reconcileMembership( - source: GroupNodeMirror, - fireDelta: boolean, -): void { - const mirror = getGraphRegistry(source.engine); - if (mirror.reconcilingMembership) return; - mirror.reconcilingMembership = true; - - try { - const groups = groupsForEngine(source); - const nextMembers = new Map>( - groups.map((group) => [group, new Set()]), - ); - const nextParents = new Map(); - - const nodes = nodesForEngine(source); - const groupNodes = [...groups].sort(stableGroupOrder); - const ordinaryNodes = nodes.filter( - (node) => !(node instanceof GroupNodeMirror), - ); - - // Resolve the group forest first. A proposed edge can point at a group that - // has already chosen another parent, so walking the partial parent map is - // enough to reject the edge that would close any cycle. - for (const node of groupNodes) { - const candidates = groups.filter( - (group) => - group !== node && - group.allowsMembership(node) && - !wouldCreateGroupCycle(node, group, nextParents), - ); - const parent = resolveParent(node, candidates, mirror.membershipResolver); - if (!parent) continue; - nextMembers.get(parent)?.add(node); - nextParents.set(node, parent); - } - - // Ordinary nodes cannot form membership cycles. They choose the innermost - // eligible group after the group hierarchy is settled. - for (const node of ordinaryNodes) { - const candidates = groups.filter((group) => group.allowsMembership(node)); - const parent = resolveParent(node, candidates, mirror.membershipResolver); - if (!parent) continue; - nextMembers.get(parent)?.add(node); - nextParents.set(node, parent); - } - - const deltas = groups.map((group) => { - const previous = group.members; - const next = nextMembers.get(group) ?? new Set(); - return { - group, - next, - added: [...next].filter((node) => !previous.has(node)), - removed: [...previous].filter((node) => !next.has(node)), - }; - }); - - for (const node of nodes) { - const parent = nextParents.get(node); - if (parent) mirror.parentGroups.set(node, parent); - else mirror.parentGroups.delete(node); - } - for (const { group, next } of deltas) group.setResolvedMembers(next); - - if (fireDelta) { - for (const { group, next, added, removed } of deltas) { - if (!added.length && !removed.length) continue; - group.groupCallbacks.onMembershipChange?.({ - group, - added, - removed, - members: [...next], - }); - } - } - } finally { - mirror.reconcilingMembership = false; - } -} - /** Return the node's settled, exclusive direct parent group. */ export function getParentGroup(node: NodeMirror): GroupNodeMirror | null { return getGraphRegistry(node.engine).parentGroups.get(node) ?? null; @@ -241,6 +160,97 @@ class GroupNodeMirror extends NodeMirror { #groupCallbacks: GroupCallbacks; #groupConfig: GroupConfig; + static #reconcileMembership( + source: GroupNodeMirror, + fireDelta: boolean, + ): void { + const mirror = getGraphRegistry(source.engine); + if (mirror.reconcilingMembership) return; + mirror.reconcilingMembership = true; + + try { + const groups = groupsForEngine(source); + const nextMembers = new Map>( + groups.map((group) => [group, new Set()]), + ); + const nextParents = new Map(); + + const nodes = nodesForEngine(source); + const groupNodes = [...groups].sort(stableGroupOrder); + const ordinaryNodes = nodes.filter( + (node) => !(node instanceof GroupNodeMirror), + ); + + // Resolve the group forest first. A proposed edge can point at a group + // that has already chosen another parent, so walking the partial parent + // map is enough to reject the edge that would close any cycle. + for (const node of groupNodes) { + const candidates = groups.filter( + (group) => + group !== node && + group.allowsMembership(node) && + !wouldCreateGroupCycle(node, group, nextParents), + ); + const parent = resolveParent( + node, + candidates, + mirror.membershipResolver, + ); + if (!parent) continue; + nextMembers.get(parent)?.add(node); + nextParents.set(node, parent); + } + + // Ordinary nodes cannot form membership cycles. They choose the + // innermost eligible group after the group hierarchy is settled. + for (const node of ordinaryNodes) { + const candidates = groups.filter((group) => + group.allowsMembership(node), + ); + const parent = resolveParent( + node, + candidates, + mirror.membershipResolver, + ); + if (!parent) continue; + nextMembers.get(parent)?.add(node); + nextParents.set(node, parent); + } + + const deltas = groups.map((group) => { + const previous = group.#members; + const next = nextMembers.get(group) ?? new Set(); + return { + group, + next, + added: [...next].filter((node) => !previous.has(node)), + removed: [...previous].filter((node) => !next.has(node)), + }; + }); + + for (const node of nodes) { + const parent = nextParents.get(node); + if (parent) mirror.parentGroups.set(node, parent); + else mirror.parentGroups.delete(node); + } + for (const { group, next } of deltas) group.#members = next; + + if (fireDelta) { + for (const { group, next, added, removed } of deltas) { + if (!added.length && !removed.length) continue; + group.#groupCallbacks.onMembershipChange?.({ + group, + added, + removed, + members: [...next], + }); + } + } + } finally { + mirror.reconcilingMembership = false; + } + } + constructor( engine: any, parent: BaseObject | null, @@ -283,11 +293,6 @@ class GroupNodeMirror extends NodeMirror { return getParentGroup(this); } - /** @internal Used by the engine-wide exclusive-membership reconciliation. */ - setResolvedMembers(members: Set): void { - this.#members = members; - } - allowsMembership(node: NodeMirror): boolean { const box = this.hitBox.getWorldBoundsSnapshot(); const nodeBounds = node.hitBox.getWorldBoundsSnapshot(); @@ -315,7 +320,7 @@ class GroupNodeMirror extends NodeMirror { } refreshMembership(fireDelta: boolean): void { - reconcileMembership(this, fireDelta); + GroupNodeMirror.#reconcileMembership(this, fireDelta); } setSizeState(width: number, height: number): void { @@ -323,7 +328,7 @@ class GroupNodeMirror extends NodeMirror { this.refreshMembership(true); } - beginSelectionDrag(position: eventPosition): void { + protected override beginSelectionDrag(position: eventPosition): void { super.beginSelectionDrag(position); this.#carryGroupOrigin = { x: this.worldTransform.x, @@ -340,15 +345,15 @@ class GroupNodeMirror extends NodeMirror { } } - containsSelectionDragNode(node: NodeMirror): boolean { + protected override containsSelectionDragNode(node: NodeMirror): boolean { return this.descendants.has(node); } - selectionDragNodes(): NodeMirror[] { + protected override selectionDragNodes(): NodeMirror[] { return [...new Set([this, ...this.#carry])]; } - finishSelectionDrag(): void { + protected override finishSelectionDrag(): void { const dx = this.worldTransform.x - this.#carryGroupOrigin.x; const dy = this.worldTransform.y - this.#carryGroupOrigin.y; for (const member of this.#carry) { diff --git a/assets/snapline/core/src/index.ts b/assets/snapline/core/src/index.ts index 9816918..739875d 100644 --- a/assets/snapline/core/src/index.ts +++ b/assets/snapline/core/src/index.ts @@ -15,7 +15,7 @@ export type { ResolvedNodeConfig, SelectionMode, } from "./node"; -export { ConnectorMirror, resolveConnectorSourceAtPoint } from "./connector"; +export { ConnectorMirror } from "./connector"; export type { ConnectionLimit, ConnectionOrigin, diff --git a/assets/snapline/core/src/internal/shared-data.ts b/assets/snapline/core/src/internal/shared-data.ts index 2a41c32..ee097e6 100644 --- a/assets/snapline/core/src/internal/shared-data.ts +++ b/assets/snapline/core/src/internal/shared-data.ts @@ -1,39 +1,13 @@ -import type { eventPosition } from "@snap-engine/core"; import { GraphRegistry } from "./graph-registry"; -/** - * Structural source-surface contract shared with engine input. Keeping this - * shape here avoids an engine-core -> SnapLine dependency while still allowing - * a headless connector to own pointer input outside its parent's DOM bounds. - */ -export interface SourceSurfaceOwner { - id: string; - engine: unknown; - isDeleteRequested: boolean; - resolveSourceHit(position: eventPosition): { - candidate: { - hit: { - distance: number; - priority?: number; - }; - }; - strategyIndex: number; - } | null; -} - /** * The shape of everything SnapLine stores on the engine's shared `global.data` * bag. This is the single declaration site for these cross-module contracts — * every reader/writer goes through the typed accessors below instead of * re-deriving the shape inline. * - * NOTE for engine core: `input.ts#resolveSourceSurfaceOwner` reads - * `sourceSurfaces` duck-typed (engine core cannot import snapline); keep its - * structural type in sync with this declaration. */ export interface SnapLineSharedData { - /** Registered headless source surfaces; input.ts routes pointerdowns to them. */ - sourceSurfaces?: SourceSurfaceOwner[]; /** * @deprecated Legacy camera-control boolean (last-writer-wins), read by the * camera for third-party writers only. In-repo gesture owners block the @@ -56,12 +30,6 @@ export function snapData(global: { data: any }): SnapLineSharedData { return global.data as SnapLineSharedData; } -export function getSourceSurfaces(global: { data: any }): SourceSurfaceOwner[] { - const data = snapData(global); - if (!data.sourceSurfaces) data.sourceSurfaces = []; - return data.sourceSurfaces; -} - /** The per-engine registry, lazy-created on first access. */ export function getGraphRegistry(engine: { global: { data: any } | null; diff --git a/assets/snapline/core/src/node.ts b/assets/snapline/core/src/node.ts index 90a2d11..da21792 100644 --- a/assets/snapline/core/src/node.ts +++ b/assets/snapline/core/src/node.ts @@ -1,5 +1,5 @@ import { BaseObject, ElementObject, mergeDefined } from "@snap-engine/core"; -import { ConnectorMirror, resolveConnectorSourceAtPoint } from "./connector"; +import { ConnectorMirror } from "./connector"; import { LineMirror } from "./line"; import type { pointerUpProp, @@ -29,36 +29,17 @@ export const RESIZE_HANDLES: readonly ResizeHandle[] = [ ]; export interface NodeConfig { - /** - * Stable application-facing identity (graph-global). Minted by SnapLine - * when omitted; supply one for any graph that outlives this mirror - * (persistence, remounts, cross-session reloads). - */ id?: string; lockPosition?: boolean; minWidth?: number; minHeight?: number; metadata?: SnapLineMetadata; callbacks?: NodeCallbacks; - /** Allows this node gesture to use the engine's configured edge pan. */ edgePan?: boolean; - /** - * Seeds application data onto a line a drag from any of this node's - * connectors creates. One place for all its ports; the event carries the - * connector so it can branch. A connector may override it. - */ - resolveNewLine?: NewLineResolver; } -/** - * Config with every defaultable field resolved. `resolveNewLine` stays - * optional: it seeds application data, and there is no sensible default for - * that — its absence is the meaningful state. - */ -export type ResolvedNodeConfig = Required< - Omit -> & - Pick; +/** Config with every defaultable field resolved. */ +export type ResolvedNodeConfig = Required>; const DEFAULT_NODE_CONFIG: ResolvedNodeConfig = { lockPosition: false, @@ -137,6 +118,11 @@ export interface NodeLinesEvent { } export interface NodeCallbacks { + /** + * Seeds application data onto a line a drag from any of this node's + * connectors creates. A connector-level resolver may override it. + */ + resolveNewLine?: NewLineResolver; /** Determines if a drag gesture should start. */ canStartDrag?: (event: NodePointerEvent) => boolean; /** Allow node position to be overridden during drag, e.g. to implement snap-to-grid. */ @@ -166,34 +152,26 @@ export interface NodeCallbacks { } class NodeMirror extends ElementObject { - /** Stable domain identity — supplied via `NodeConfig.id` or minted. Never - * the engine-internal `BaseObject.id`. */ + readonly nodeId: string; #config: ResolvedNodeConfig; - /** @internal Name-keyed live connectors; written by ConnectorMirror's - * assignToNode/destroy — the one deliberate cross-class field. */ - _connectors: { [key: string]: ConnectorMirror }; + #connectors: { [key: string]: ConnectorMirror }; #dragStartX = 0; #dragStartY = 0; - _nodeStyle: any; #hitBox: RectCollider; - #mouseDownX: number; - #mouseDownY: number; - _hasMoved: boolean; + #pointerReferenceX: number; + #pointerReferenceY: number; + #hasMoved: boolean; #resizeRegions = new Set(); #activeResizeHandle: ResizeHandle | null = null; - /** Read by GroupNodeMirror to distinguish a resize from a move drag. */ - #resizing = false; - // The size last authored through setSizeState, kept apart from #hitBox (which - // tracks what the browser actually rendered) so a write always paints the - // value its own tick authored. + #isResizing = false; #geometryObservers = new Set>(); #authoredWidth = 0; #authoredHeight = 0; #hasAuthoredSize = false; - #resizeArmed = false; - #resizeStartW = 0; - #resizeStartH = 0; + #isResizeArmed = false; + #resizeStartWidth = 0; + #resizeStartHeight = 0; #resizeStartX = 0; #resizeStartY = 0; #callbacks: NodeCallbacks; @@ -204,7 +182,7 @@ class NodeMirror extends ElementObject { #dragCommitNodes: NodeMirror[] = []; #lastDragPosition: eventPosition | null = null; #pointerSelectionMode: SelectionMode = "replace"; - #selectedAtPointerDown = false; + #wasSelectedAtPointerDown = false; constructor(engine: any, parent: BaseObject | null, config: NodeConfig = {}) { super(engine, parent); @@ -213,11 +191,11 @@ class NodeMirror extends ElementObject { this.nodeId = config.id ?? mintDomainId("node", this.global); getGraphRegistry(this.engine).registerNode(this); - this._connectors = {}; + this.#connectors = {}; this.#dragStartX = this.worldTransform.x; this.#dragStartY = this.worldTransform.y; - this.#mouseDownX = 0; - this.#mouseDownY = 0; + this.#pointerReferenceX = 0; + this.#pointerReferenceY = 0; this.transformMode = "direct"; this.event.input.pointerDown = this.onCursorDown; @@ -228,14 +206,9 @@ class NodeMirror extends ElementObject { this.#hitBox = new RectCollider(this.engine, this, 0, 0, 0, 0); this.addCollider(this.#hitBox); - this._hasMoved = false; + this.#hasMoved = false; - // Whenever the DOM box changes size (ResizeObserver) re-measure + re-glue. 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 - // ownership note in assets/snapline/AGENTS.md). } get config(): ResolvedNodeConfig { @@ -246,15 +219,15 @@ class NodeMirror extends ElementObject { return this.#callbacks; } - /** The resolver a drag from this node's connectors seeds new lines with. */ - get resolveNewLine(): NewLineResolver | null { - return this.#config.resolveNewLine ?? null; - } - get metadata(): SnapLineMetadata { return this.#config.metadata; } + /** The node's collision footprint (world = worldTransform + width/height). */ + get hitBox(): RectCollider { + return this.#hitBox; + } + registerDragHandle(element: HTMLElement): () => void { this.#dragHandles.add(element); return () => this.#dragHandles.delete(element); @@ -270,12 +243,6 @@ class NodeMirror extends ElementObject { this.#resizeRegions.delete(region); } - /** The node's collision footprint (world = worldTransform + width/height). - * Read by groups for geometric membership. */ - get hitBox(): RectCollider { - return this.#hitBox; - } - setStartPositions() { this.#dragStartX = this.worldTransform.x; this.#dragStartY = this.worldTransform.y; @@ -284,7 +251,7 @@ class NodeMirror extends ElementObject { setSelected(selected: boolean) { this.dataAttribute = { selected: String(selected), - "snapline-state": selected ? "focus" : "idle", + "snapline-state": selected ? "focus" : "idle", // TODO: Not used? }; const selectList = getGraphRegistry(this.engine).selection; if (selected) { @@ -306,14 +273,16 @@ class NodeMirror extends ElementObject { }); } - /** Schedules a WRITE_2 write for every line on every connector of this node. */ + /** Schedules a WRITE_2 for every line on every connector of this node. */ scheduleLineWrites(): void { - for (const connector of Object.values(this._connectors)) { + for (const connector of Object.values(this.#connectors)) { connector.scheduleAllLineWrites(); } } /** Synchronously writes every line on every connector (call inside a WRITE stage). */ + // TODO: We should have some kind of guard for these typed of functions that + // need to be called in specific stages. writeLinesNow(): void { const lines = new Set([ ...this.getAllOutgoingLines(), @@ -341,20 +310,16 @@ class NodeMirror extends ElementObject { stage: "WRITE_2", queueId: `${this.id}-transform`, }); - // Every node in the transform tree moves, not just this one — multi-select - // peers and group-carried members included — so each gets its own signal. for (const node of this.#transformNodeTree()) { - node.notifyGeometryInvalidated(); + node.#notifyGeometryInvalidated(); node.scheduleLineWrites(); } } - // Re-measure the node box + each connector's local center (READ_1) and re-glue - // 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 - // same-frame double-fire (idempotent when it runs twice across frames). + /** + * Re-measure the node box + each connector's local center (READ_1) and re-glue + * every incoming/outgoing line (WRITE_2). + */ remeasureDomGeometry(): void { if (!this.element) { throw new Error( @@ -368,41 +333,26 @@ class NodeMirror extends ElementObject { this.scheduleLineWrites(); } - // Reconciles state with what the browser actually rendered: the node box and - // each connector's local center. Shared by the ResizeObserver (READ_1) and - // the post-write re-measure (READ_2) so the two cannot drift apart. + /* Reconciles state with what the browser actually rendered */ #syncMeasuredGeometry(stage: "READ_1" | "READ_2"): void { if (!this.element) return; const property = this.readDom({ unapplyTransform: false }, stage); - // While a gesture is authoring the size, the authored box is the truth and - // the rendered box is one frame behind: the ResizeObserver fires for frame - // N's paint, so this read lands in frame N+1 AFTER that frame's pointermove - // already advanced both the size and worldTransform. Adopting it here would - // make the WRITE_1 paint a stale height beside a fresh transform — a - // one-frame jump of the anchored edge on every north/west drag. - if (!this.#resizing) { + // During resize, setSizeState sets the correct dimensions. + if (!this.#isResizing) { this.#hitBox.width = property.width; this.#hitBox.height = property.height; } - for (const connector of Object.values(this._connectors)) { + for (const connector of Object.values(this.#connectors)) { connector.measureLocalCenter(stage); } } /** * Subscribe to "this node's geometry is about to change". - * - * Fires **synchronously, during input dispatch, before any frame task is - * queued** — for drags (including camera edge-pan, multi-select peers, and - * group-carried members, none of which `onDrag` reports) and for resizes. - * It hands over no geometry: schedule your own task at the stage you want - * and read {@link geometrySnapshot} there. - * - * Distinct from `onGeometryCommit`, which fires once per gesture with the - * settled result. This one fires every frame, before the paint. - * * @returns an unsubscribe function. */ + // TODO: Unify semantics of subscribe/unsubscribe functions + // (e.g. with global input callback) onGeometryInvalidated( observer: GeometryInvalidationObserver, ): () => void { @@ -410,11 +360,9 @@ class NodeMirror extends ElementObject { return () => this.#geometryObservers.delete(observer); } - /** @internal Fired by the scheduling entry points, before they queue. */ - notifyGeometryInvalidated(): void { + /** Fired by the scheduling entry points, before they queue. */ + #notifyGeometryInvalidated(): void { for (const observer of this.#geometryObservers) { - // A third-party observer must never be able to stop the node painting - // or starve its peers. try { observer(this); } catch (error) { @@ -423,14 +371,6 @@ class NodeMirror extends ElementObject { } } - /** - * This node's position and **authored** size. - * - * Deliberately not sourced from `hitBox`: that is DOM truth, which a - * ResizeObserver-scheduled READ_1 overwrites with the *previously rendered* - * box. Reading it mid-gesture pairs last frame's height with this frame's - * `y`. Position and size here always come from the same authoring tick. - */ geometrySnapshot(): { x: number; y: number; width: number; height: number } { return { x: this.worldTransform.x, @@ -440,16 +380,9 @@ class NodeMirror extends ElementObject { }; } - // State-only half of a size change: clamps to min and synchronously updates - // the collision footprint 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); - // The authored size is captured here and painted verbatim, so whatever - // reconciles #hitBox with the DOM in between cannot desynchronize the size - // from the worldTransform authored in the same tick. this.#authoredWidth = w; this.#authoredHeight = h; this.#hasAuthoredSize = true; @@ -457,15 +390,17 @@ class NodeMirror extends ElementObject { this.#hitBox.height = h; } - // Drives live resize geometry directly. The framework observes and persists - // the result, but it is not part of the pointer-move paint path. + /** + * Set the size of a node, and request DOM update to + * render the new size. + */ setSize( width: number, height: number, handle: ResizeHandle | null = null, ): void { this.setSizeState(width, height); - this.#scheduleSizeGeometryWrite(); + this.scheduleGeometryWrite(); this.#callbacks.onSizeChange?.({ node: this, handle, @@ -477,19 +412,11 @@ class NodeMirror extends ElementObject { } /** - * Schedules the one task that paints size and transform together, plus the - * re-measure and line re-glue. Adapters mutate `worldTransform` and - * `setSizeState(...)` and then call this, so a prop-driven position+size - * change lands in one frame, in one stage-legal task. Unlike `setSize` it - * emits no `onSizeChange`, which would echo a controlled app's own value - * back at it. + * Schedules the task that paints size and transform together, + * plus the re-measure and line re-glue. */ scheduleGeometryWrite(): void { - this.#scheduleSizeGeometryWrite(); - } - - #scheduleSizeGeometryWrite(): void { - this.notifyGeometryInvalidated(); + this.#notifyGeometryInvalidated(); this.schedule(() => this.#writeSizeGeometry(), { stage: "WRITE_1", queueId: `${this.id}-size`, @@ -501,10 +428,6 @@ class NodeMirror extends ElementObject { this.scheduleLineWrites(); } - // The single atomic geometry commit: size and transform are painted in one - // synchronous block, in one task, in one stage — and from values authored in - // the same tick, never re-read from state that a later measurement may have - // moved underneath them. #writeSizeGeometry(): void { if (this.element && this.#hasAuthoredSize) { this.element.style.width = `${this.#authoredWidth}px`; @@ -513,7 +436,6 @@ class NodeMirror extends ElementObject { this.writeTransformRecursive(); } - // Applies one side/corner resize while keeping the opposite edges fixed. #applyResizeDrag(dx: number, dy: number): void { const handle = this.#activeResizeHandle; if (!handle) return; @@ -522,22 +444,22 @@ class NodeMirror extends ElementObject { const north = handle === "n" || handle === "ne" || handle === "nw"; const south = handle === "s" || handle === "se" || handle === "sw"; const proposedWidth = west - ? this.#resizeStartW - dx + ? this.#resizeStartWidth - dx : east - ? this.#resizeStartW + dx - : this.#resizeStartW; + ? this.#resizeStartWidth + dx + : this.#resizeStartWidth; const proposedHeight = north - ? this.#resizeStartH - dy + ? this.#resizeStartHeight - dy : south - ? this.#resizeStartH + dy - : this.#resizeStartH; + ? this.#resizeStartHeight + dy + : this.#resizeStartHeight; const width = Math.max(this.#config.minWidth, proposedWidth); const height = Math.max(this.#config.minHeight, proposedHeight); const x = west - ? this.#resizeStartX + (this.#resizeStartW - width) + ? this.#resizeStartX + (this.#resizeStartWidth - width) : this.#resizeStartX; const y = north - ? this.#resizeStartY + (this.#resizeStartH - height) + ? this.#resizeStartY + (this.#resizeStartHeight - height) : this.#resizeStartY; this.worldTransform = { x, y }; this.setSize(width, height, handle); @@ -548,8 +470,6 @@ class NodeMirror extends ElementObject { this.writeLinesNow(); } - // 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: NodeMirror): void { this.setTransformParent(group, true); } @@ -564,6 +484,11 @@ class NodeMirror extends ElementObject { } onCursorDown(e: pointerDownProp): void { + // Child input bubbles. A connector or resize region owns its own + // pointerdown and must not reset or start a node drag. + // TODO: Prevent bubbling in child object + if (e.objectId !== this.id) return; + // Authorization belongs to one pointer gesture. Clear any stale permission // before evaluating this pointerdown (including non-primary buttons). this.#dragPointerId = null; @@ -571,15 +496,7 @@ class NodeMirror extends ElementObject { return; } - const source = resolveConnectorSourceAtPoint(this.engine, e.position, this); - if (source) { - this.engine.input.setPointerDragOwner( - e.event.pointerId, - source.candidate.connector, - ); - source.candidate.connector.armSurfaceGesture(e, source); - return; - } + // TODO: Replace draghandle with inputAlias const target = e.event.target as Node | null; const dragAllowed = (this.#dragHandles.size === 0 || @@ -600,35 +517,32 @@ class NodeMirror extends ElementObject { armResize(e: pointerDownProp, handle: ResizeHandle): void { this.#dragPointerId = null; if (e.event.button !== 0) return; - this.#resizeArmed = true; + this.#isResizeArmed = true; this.#activeResizeHandle = handle; - this.engine.input.setPointerDragOwner(e.event.pointerId, this); this.#authorizePointer(e); } #authorizePointer(e: pointerDownProp): void { this.#dragPointerId = e.event.pointerId; - // Claim the pointer from the very first pointer event: waiting for the - // drag-start threshold would let the camera pan by one move event before - // onDragStart runs. The claim auto-releases when the gesture ends, so no - // paired release is needed anywhere. + // Claim the pointer from the very first pointer event to + // prevent camera pan. this.engine.input.claimPointer(e.event.pointerId); - this._hasMoved = false; + this.#hasMoved = false; const selection = [...getGraphRegistry(this.engine).selection]; - this.#selectedAtPointerDown = selection.includes(this); + this.#wasSelectedAtPointerDown = selection.includes(this); this.#pointerSelectionMode = this.#callbacks.resolveSelectionMode?.({ node: this, - selected: this.#selectedAtPointerDown, + selected: this.#wasSelectedAtPointerDown, selection, originalEvent: e.event, }) ?? "replace"; if ( this.#pointerSelectionMode === "replace" && - !this.#selectedAtPointerDown + !this.#wasSelectedAtPointerDown ) { for (const node of [...getGraphRegistry(this.engine).selection]) { node.setSelected(false); @@ -637,7 +551,7 @@ class NodeMirror extends ElementObject { } else if ( (this.#pointerSelectionMode === "add" || this.#pointerSelectionMode === "toggle") && - !this.#selectedAtPointerDown + !this.#wasSelectedAtPointerDown ) { this.setSelected(true); } @@ -645,21 +559,23 @@ class NodeMirror extends ElementObject { onDragStart(prop: dragStartProp): void { if (this.#dragPointerId !== prop.pointerId) return; - if (this.#resizeArmed) { - this.#resizing = true; - this.#resizeStartW = this.#hitBox.width; - this.#resizeStartH = this.#hitBox.height; + if (this.#isResizeArmed) { + this.#isResizing = true; + this.#resizeStartWidth = this.#hitBox.width; + this.#resizeStartHeight = this.#hitBox.height; this.#resizeStartX = this.worldTransform.x; this.#resizeStartY = this.worldTransform.y; - this.#mouseDownX = prop.start.x; - this.#mouseDownY = prop.start.y; - this._hasMoved = true; + this.#pointerReferenceX = prop.start.x; + this.#pointerReferenceY = prop.start.y; + this.#hasMoved = true; // Guard so releasing a resize over another node doesn't click-select it. + // TODO: Not needed since input now captures pointer on original DOM? getGraphRegistry(this.engine).resizingNode = this; return; } if (!this.#config.lockPosition && this.#config.edgePan) { this.#edgePanPointerId = prop.pointerId; + // TODO: edgePanController should be part of SnapZap this.engine.edgePanController?.startEdgePan( prop.pointerId, prop.start, @@ -681,7 +597,7 @@ class NodeMirror extends ElementObject { this.#dragCommitNodes = [ ...new Set(this.#dragRoots.flatMap((node) => node.selectionDragNodes())), ]; - this._hasMoved = true; + this.#hasMoved = true; this.#callbacks.onDragStart?.({ node: this, pointerId: prop.pointerId, @@ -695,10 +611,10 @@ class NodeMirror extends ElementObject { console.error("Global stats is null"); return; } - if (this.#resizing) { + if (this.#isResizing) { this.#applyResizeDrag( - prop.position.x - this.#mouseDownX, - prop.position.y - this.#mouseDownY, + prop.position.x - this.#pointerReferenceX, + prop.position.y - this.#pointerReferenceY, ); return; } @@ -724,29 +640,33 @@ class NodeMirror extends ElementObject { } } - /** @internal Hook used to build one deduplicated multi-selection drag session. */ - beginSelectionDrag(position: eventPosition): void { + /** Hook used to build one deduplicated multi-selection drag session. */ + protected beginSelectionDrag(position: eventPosition): void { this.setStartPositions(); - this.#mouseDownX = position.x; - this.#mouseDownY = position.y; + this.#pointerReferenceX = position.x; + this.#pointerReferenceY = position.y; } - /** @internal Whether this node's drag behavior already carries `node`. */ - containsSelectionDragNode(_node: NodeMirror): boolean { + /** + * Whether this node's drag already carries another nodeId + * within it. Needed when checking if a selected group also has a child + * node that is selected, otherwise we may apply drag twice to the child. + .*/ + protected containsSelectionDragNode(_node: NodeMirror): boolean { return false; } - /** @internal Nodes whose final positions belong to this drag root's commit. */ - selectionDragNodes(): NodeMirror[] { + /** Nodes whose final positions belong to this drag root's commit. */ + protected selectionDragNodes(): NodeMirror[] { return [this]; } - /** @internal Finalize any temporary carry state owned by this drag root. */ - finishSelectionDrag(): void {} + /** Finalize any temporary carry state owned by this drag root. */ + protected finishSelectionDrag(): void {} setDragPosition(prop: dragProp) { - const dx = prop.position.x - this.#mouseDownX; - const dy = prop.position.y - this.#mouseDownY; + const dx = prop.position.x - this.#pointerReferenceX; + const dy = prop.position.y - this.#pointerReferenceY; const x = this.#dragStartX + dx; const y = this.#dragStartY + dy; const resolved = this.#callbacks.resolveDragPosition?.({ @@ -763,42 +683,32 @@ class NodeMirror extends ElementObject { } onDragEnd(prop: dragEndProp) { - // A pointerdown rejected by a drag handle/predicate still becomes a generic - // input drag gesture once it crosses the engine threshold. It must not - // commit stale selection coordinates on release. + // this.#dragPointerId will be undefined if drag was canceled + // earlier for any reason, in which case we should not proceed. if (this.#dragPointerId !== prop.pointerId) return; if (this.#edgePanPointerId != null) { this.engine.edgePanController?.stopEdgePan(this.#edgePanPointerId); this.#edgePanPointerId = null; } - if (this.#resizing) { - // The teardown runs in `finally` because everything above it calls out: - // #writeSizeGeometry touches the DOM and onGeometryCommit is consumer - // code. A throw that skipped these resets would strand `resizingNode`, - // which permanently disables click-selection (see onUp). + if (this.#isResizing) { + // The teardown runs in `finally` because everything above it calls out. try { this.#applyResizeDrag( - prop.end.x - this.#mouseDownX, - prop.end.y - this.#mouseDownY, + prop.end.x - this.#pointerReferenceX, + prop.end.y - this.#pointerReferenceY, ); - // 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.onGeometryCommit?.({ nodes: [this.#geometryOf(this)], }); } finally { - this.#resizing = false; - this.#resizeArmed = false; + this.#isResizing = false; + this.#isResizeArmed = false; this.#activeResizeHandle = null; getGraphRegistry(this.engine).resizingNode = null; this.#dragPointerId = null; } - // Settle #hitBox against what actually rendered. The gesture suppressed - // that reconciliation, and the release write often authors the previous - // frame's values, so no ResizeObserver would fire to trigger it — without - // this an authored size the stylesheet refused would stick. + // Settle #hitBox against what actually rendered. this.remeasureDomGeometry(); // A resized node's center may have moved into/out of a group. for (const group of getGraphRegistry(this.engine).groups) { @@ -807,9 +717,6 @@ class NodeMirror extends ElementObject { return; } - // The live position is authoritative. Recomputing from the raw pointer-up - // coordinate would discard edge-pan compensation and can make the release - // frame jump away from what the user was dragging. if (this.#lastDragPosition) { this.#moveSelectionToPointer(this.#lastDragPosition); } @@ -818,10 +725,7 @@ class NodeMirror extends ElementObject { node.scheduleTransformAndLines(); } - // A settled node may have entered or left a group; groups re-evaluate - // membership on settle (never at group-drag-start), so the maintained set is - // current before the next group drag. The graph mirror's type-only group - // reference keeps node.ts free of any group value import. + // A settled node may have entered or left a group for (const group of getGraphRegistry(this.engine).groups) { group.refreshMembership(true); } @@ -857,18 +761,18 @@ class NodeMirror extends ElementObject { onUp(prop: pointerUpProp) { if (this.#dragPointerId !== prop.event.pointerId) return; - // pointerUp is dispatched to whatever is under the release point, which for a - // resize may be a DIFFERENT node than the one being resized. Skip click-select - // while any resize is settling so releasing a resize doesn't select this node. + // InputControl dispatches pointerUp before dragEnd. Keep active resize state + // intact so onDragEnd can commit the geometry and perform teardown. + // TODO: onUp should not fire if drag event fired? if (getGraphRegistry(this.engine).resizingNode) return; - if (this.#resizeArmed) { - this.#resizeArmed = false; + if (this.#isResizeArmed) { + this.#isResizeArmed = false; this.#activeResizeHandle = null; this.#dragPointerId = null; return; } - if (this._hasMoved == false) { + if (!this.#hasMoved) { if (this.#pointerSelectionMode === "replace") { for (const node of [...getGraphRegistry(this.engine).selection]) { if (node !== this) node.setSelected(false); @@ -877,33 +781,45 @@ class NodeMirror extends ElementObject { } else if (this.#pointerSelectionMode === "add") { this.setSelected(true); } else { - this.setSelected(!this.#selectedAtPointerDown); + this.setSelected(!this.#wasSelectedAtPointerDown); } this.#dragPointerId = null; } - this._hasMoved = false; + this.#hasMoved = false; } getConnector(name: string): ConnectorMirror | null { - if (!(name in this._connectors)) { + if (!(name in this.#connectors)) { console.error(`Connector ${name} does not exist in node ${this.id}`); return null; } - return this._connectors[name]; + return this.#connectors[name]; } addConnectorObject(connector: ConnectorMirror) { connector.assignToNode(this); } + /** @internal Registers a connector assigned to this node. */ + attachConnector(connector: ConnectorMirror): void { + this.#connectors[connector.name] = connector; + } + + /** @internal Removes a connector only if it is still the registered value. */ + detachConnector(connector: ConnectorMirror): void { + if (this.#connectors[connector.name] === connector) { + delete this.#connectors[connector.name]; + } + } + getAllOutgoingLines(): LineMirror[] { - return Object.values(this._connectors).flatMap( + return Object.values(this.#connectors).flatMap( (connector) => connector.outgoingLines, ); } getAllIncomingLines(): LineMirror[] { - return Object.values(this._connectors).flatMap( + return Object.values(this.#connectors).flatMap( (connector) => connector.incomingLines, ); } @@ -913,7 +829,7 @@ class NodeMirror extends ElementObject { this.engine.edgePanController?.stopEdgePan(this.#edgePanPointerId); this.#edgePanPointerId = null; } - for (const connector of Object.values(this._connectors)) { + for (const connector of Object.values(this.#connectors)) { // A node unmount is a teardown, not a deliberate programmatic // disconnect — keep the reason contract honest for intent consumers. connector.deleteAllLines("teardown"); @@ -922,7 +838,7 @@ class NodeMirror extends ElementObject { this.setSelected(false); for (const region of [...this.#resizeRegions]) region.destroy(false); this.#resizeRegions.clear(); - this._connectors = {}; + this.#connectors = {}; super.destroy(removeElement); } } @@ -945,23 +861,17 @@ class ResizeRegionMirror extends ElementObject { this.handle = handle; this.transformMode = "none"; this.event.input.pointerDown = this.#onPointerDown; - this.event.input.pointerUp = this.#onPointerUp; + this.event.input.dragStart = this.#onDragStart; node.attachResizeRegion(this); } #onPointerDown(prop: pointerDownProp): void { if (prop.event.button !== 0) return; this.node.armResize(prop, this.handle); - try { - this.element?.setPointerCapture(prop.event.pointerId); - } catch { - // Pointer capture may fail if application code detached the region while - // handling pointerdown. Engine document routing still settles the gesture. - } } - #onPointerUp(prop: pointerUpProp): void { - this.node.onUp(prop); + #onDragStart(prop: dragStartProp): void { + prop.handoffTo(this.node); } destroy(removeElement: boolean = true): void { diff --git a/assets/snapline/react/README.md b/assets/snapline/react/README.md index 2b9c539..46d460d 100644 --- a/assets/snapline/react/README.md +++ b/assets/snapline/react/README.md @@ -39,14 +39,15 @@ through `Node`'s `elementProps`. Render explicit `ResizeRegion` children to opt into resizing. Their CSS owns the hit area, position, cursor, hover behavior, and visuals. -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 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. - -Connector policy, metadata, callbacks, strategies, and `virtual` stay live -across renders. Toggling `virtual` removes or remounts only the visible port; -the logical connector and existing lines are preserved. +Pass a render function to `Connector` when the input root should be custom +HTML or SVG. Attach its callback ref to exactly one element; for an SVG path, +use `pointerEvents="stroke"` to make the painted stroke the source hit area. +`surfaceStrategies` customize target admission and endpoint anchors. Keep +domain edges in React state and use an opaque line payload as the stable link +from a custom renderer. + +Connector policy, metadata, callbacks, strategies, and collider radius stay +live across renders. `name` and an adopted `connectorObject` are +construction-time identities. Full documentation: https://snapengine.dev/docs/snapline/introduction diff --git a/assets/snapline/react/src/Connector.tsx b/assets/snapline/react/src/Connector.tsx index 7bd32c3..7b82285 100644 --- a/assets/snapline/react/src/Connector.tsx +++ b/assets/snapline/react/src/Connector.tsx @@ -6,6 +6,7 @@ import { useImperativeHandle, useRef, type CSSProperties, + type ReactNode, } from "react"; import { ConnectorMirror, @@ -28,8 +29,13 @@ export interface ConnectorProps { edgePan?: boolean; rules?: Partial; surfaceStrategies?: readonly ConnectorSurfaceStrategy[]; - /** Keep the logical connector without rendering a visible port element. */ - virtual?: boolean; + /** + * Render a custom HTML or SVG connector root. Attach the supplied callback + * ref to the one element that should receive pointer input. + */ + children?: (bind: { + ref: (element: HTMLElement | SVGElement | null) => void; + }) => ReactNode; colliderRadius?: number; connectorObject?: ConnectorMirror | null; data?: Record; @@ -51,7 +57,7 @@ export const Connector = forwardRef( edgePan = true, rules, surfaceStrategies = [], - virtual = false, + children, colliderRadius, connectorObject = null, data = {}, @@ -105,7 +111,7 @@ export const Connector = forwardRef( ]); const bindConnectorElement = useCallback( - (element: HTMLDivElement | null) => { + (element: HTMLElement | SVGElement | null) => { connector.bindElement(element); }, [connector], @@ -117,7 +123,9 @@ export const Connector = forwardRef( }; }, [connector]); - if (virtual) return null; + if (children) { + return children({ ref: bindConnectorElement }); + } return (
(function Group( latestRef.current.callbacks.resolveSelectionMode?.(event) ?? originalCallbacks.resolveSelectionMode?.(event) ?? "replace"; + group.callbacks.resolveNewLine = (event) => { + const resolver = + latestRef.current.callbacks.resolveNewLine ?? + originalCallbacks.resolveNewLine; + return resolver?.(event); + }; group.callbacks.onDragStart = (event) => invoke( event, @@ -199,6 +205,7 @@ export const Group = forwardRef(function Group( group.callbacks.canStartDrag = originalCallbacks.canStartDrag; group.callbacks.resolveSelectionMode = originalCallbacks.resolveSelectionMode; + group.callbacks.resolveNewLine = originalCallbacks.resolveNewLine; group.callbacks.onDragStart = originalCallbacks.onDragStart; group.callbacks.onDrag = originalCallbacks.onDrag; group.callbacks.onGeometryCommit = originalCallbacks.onGeometryCommit; diff --git a/assets/snapline/react/src/Node.tsx b/assets/snapline/react/src/Node.tsx index d590f5b..e7516b7 100644 --- a/assets/snapline/react/src/Node.tsx +++ b/assets/snapline/react/src/Node.tsx @@ -15,7 +15,6 @@ import { import { LineMirror, NodeMirror, - type NewLineResolver, type NodeCallbacks, type GeometryChangeEvent, type NodeResizeEvent, @@ -48,8 +47,6 @@ export interface NodeProps { resolveLineComponent?: ( line: LineMirror, ) => ComponentType<{ line: LineMirror }> | null | undefined; - /** Seeds application data onto a line a drag from this node creates. */ - resolveNewLine?: NewLineResolver; nodeObject?: NodeMirror | null; style?: CSSProperties; x?: number; @@ -74,7 +71,6 @@ export const Node = forwardRef(function Node( className = "", lineComponent: LineRenderer = Line, resolveLineComponent, - resolveNewLine, nodeObject = null, style, x = 0, @@ -104,7 +100,6 @@ export const Node = forwardRef(function Node( metadata, callbacks: {}, edgePan, - resolveNewLine, }); } const node = nodeRef.current; @@ -161,6 +156,11 @@ export const Node = forwardRef(function Node( latestRef.current.callbacks.resolveSelectionMode?.(event) ?? original.resolveSelectionMode?.(event) ?? "replace"; + node.callbacks.resolveNewLine = (event) => { + const resolver = + latestRef.current.callbacks.resolveNewLine ?? original.resolveNewLine; + return resolver?.(event); + }; node.callbacks.onDragStart = (event) => invoke( event, @@ -205,6 +205,7 @@ export const Node = forwardRef(function Node( node.callbacks.canStartDrag = original.canStartDrag; node.callbacks.resolveDragPosition = original.resolveDragPosition; node.callbacks.resolveSelectionMode = original.resolveSelectionMode; + node.callbacks.resolveNewLine = original.resolveNewLine; node.callbacks.onDragStart = original.onDragStart; node.callbacks.onDrag = original.onDrag; node.callbacks.onGeometryCommit = original.onGeometryCommit; diff --git a/assets/snapline/svelte/README.md b/assets/snapline/svelte/README.md index 1069887..b48f610 100644 --- a/assets/snapline/svelte/README.md +++ b/assets/snapline/svelte/README.md @@ -38,14 +38,15 @@ element through `Node`'s `elementProps`. Render explicit `ResizeRegion` children to opt into resizing. Their CSS owns the hit area, position, cursor, hover behavior, and visuals. -`` creates a logical connector without a visible port. -Combine it with `surfaceStrategies` and symmetric `rules` limits to make a -node border or another application-defined shape act as the connection surface. -Application graph state remains authoritative; an opaque line payload can link -a custom renderer back to the corresponding domain edge. - -Connector policy, metadata, callbacks, strategies, and `virtual` are reactive. -Switching `virtual` detaches or remounts only the visible port; the logical -connector and its existing lines remain intact. +Pass a child snippet to `Connector` when the input root should be custom HTML +or SVG. Apply the snippet argument as a Svelte action to exactly one element; +for an SVG path, use `pointer-events="stroke"` to make the painted stroke the +source hit area. `surfaceStrategies` customize target admission and endpoint +anchors. Application graph state remains authoritative; an opaque line payload +can link a custom renderer back to the corresponding domain edge. + +Connector policy, metadata, callbacks, strategies, and collider radius are +reactive. `name` and an adopted `connectorObject` are construction-time +identities. Full documentation: https://snapengine.dev/docs/snapline/introduction diff --git a/assets/snapline/svelte/src/Connector.svelte b/assets/snapline/svelte/src/Connector.svelte index f3686c1..ce30572 100644 --- a/assets/snapline/svelte/src/Connector.svelte +++ b/assets/snapline/svelte/src/Connector.svelte @@ -8,7 +8,11 @@ type SnapLineMetadata, } from "@snap-engine/snapline"; import type { Engine } from "@snap-engine/core"; - import { getContext, onDestroy } from "svelte"; + import { getContext, onDestroy, type Snippet } from "svelte"; + + type ConnectorAction = ( + element: HTMLElement | SVGElement, + ) => { destroy(): void }; let { id = undefined, @@ -18,7 +22,7 @@ callbacks = {}, edgePan = true, surfaceStrategies = [], - virtual = false, + children = undefined, colliderRadius = undefined, connectorObject = null, data = {}, @@ -31,8 +35,8 @@ callbacks?: ConnectorCallbacks; edgePan?: boolean; surfaceStrategies?: readonly ConnectorSurfaceStrategy[]; - /** Keep the logical connector without rendering a visible port element. */ - virtual?: boolean; + /** Custom connector root. Apply the snippet argument with `use:`. */ + children?: Snippet<[ConnectorAction]>; colliderRadius?: number; connectorObject?: ConnectorMirror | null; data?: Record; @@ -58,11 +62,11 @@ return connector; } - function bindConnectorElement(element: HTMLDivElement) { + function bindConnectorElement(element: HTMLElement | SVGElement) { connector.bindElement(element); return { destroy() { - connector.bindElement(null); + if (connector.element === element) connector.bindElement(null); }, }; } @@ -83,7 +87,9 @@ }); -{#if !virtual} +{#if children} + {@render children(bindConnectorElement)} +{:else}
{ + const resolver = callbacks.resolveNewLine ?? originalCallbacks.resolveNewLine; + return resolver?.(event); + }; groupObject!.callbacks.onDragStart = (event) => invoke(event, originalCallbacks.onDragStart, callbacks.onDragStart); groupObject!.callbacks.onDrag = (event) => @@ -138,6 +142,7 @@ unregisterHeader = null; groupObject!.callbacks.canStartDrag = originalCallbacks.canStartDrag; groupObject!.callbacks.resolveSelectionMode = originalCallbacks.resolveSelectionMode; + groupObject!.callbacks.resolveNewLine = originalCallbacks.resolveNewLine; groupObject!.callbacks.onDragStart = originalCallbacks.onDragStart; groupObject!.callbacks.onDrag = originalCallbacks.onDrag; groupObject!.callbacks.onGeometryCommit = originalCallbacks.onGeometryCommit; diff --git a/assets/snapline/svelte/src/Node.svelte b/assets/snapline/svelte/src/Node.svelte index 61ef560..01050f0 100644 --- a/assets/snapline/svelte/src/Node.svelte +++ b/assets/snapline/svelte/src/Node.svelte @@ -1,5 +1,5 @@