diff --git a/assets/snapline/AGENTS.md b/assets/snapline/AGENTS.md index f123d37..5e5390d 100644 --- a/assets/snapline/AGENTS.md +++ b/assets/snapline/AGENTS.md @@ -16,12 +16,14 @@ APIs directly rather than adding compatibility shims. **Dependencies:** `@snap-engine/core` **Exports:** -- `NodeComponent` - Graph node with connectors (opt-in eight-direction resize) -- `ConnectorComponent` - Input/output connector -- `LineComponent` - Visual connection line -- `GroupNodeComponent` - Resizable box that carries the nodes inside it -- `RectSelectComponent` - Rectangle selection tool +- `NodeMirror` - Graph node with connectors (opt-in eight-direction resize) +- `ConnectorMirror` - Input/output connector +- `LineMirror` - Visual connection line +- `GroupNodeMirror` - Resizable box that carries the nodes inside it +- `RectSelectController` - Rectangle selection tool - `PlacementController` - Headless pointer-follow placement state machine +- `attachControlledGraph` - Installs the controlled-graph bridge (LineReconciler) +- `query` - Read-only `GraphQuery` facade over one engine's graph - `snapline-globals` - Typed accessors for the shared `global.data` registries ### @snap-engine/snapline-svelte @@ -36,14 +38,16 @@ APIs directly rather than adding compatibility shims. - `Line.svelte` - Connection line component - `Select.svelte` - Rectangle selection component - `Placement.svelte` - Placement controller binding and preview +- `ControlledGraph.svelte` - Controlled-graph bridge (canonical line records) ### @snap-engine/snapline-react **Location:** `react/src/` **Language:** React/TypeScript **Dependencies:** `@snap-engine/snapline`, `@snap-engine/core` -Exports `Engine`, `Node`, `Group`, `Connector`, `Line`, `Select`, and -`Placement`, with forwarded refs to core objects where applicable. +Exports `Engine`, `Node`, `Group`, `Connector`, `Line`, `Select`, +`Placement`, and `ControlledGraph`, with forwarded refs to core objects +where applicable. ## File Structure @@ -54,55 +58,78 @@ snapline/ │ ├── tsconfig.json │ └── src/ │ ├── index.ts -│ ├── node.ts # NodeComponent -│ ├── connector.ts # ConnectorComponent -│ ├── line.ts # LineComponent -│ └── select.ts # RectSelectComponent -└── svelte/ +│ ├── node.ts # NodeMirror +│ ├── connector.ts # ConnectorMirror + ConnectorRules +│ ├── line.ts # LineMirror +│ ├── group.ts # GroupNodeMirror +│ ├── select.ts # RectSelectController +│ ├── placement.ts # PlacementController +│ ├── graph-mirror.ts # GraphMirror (per-engine registry + scheduler) +│ ├── line-reconciler.ts # LineReconciler + LineRecord/LineChangeRequest +│ ├── query.ts # query() GraphQuery facade +│ ├── geometry.ts # GeometryWriter type +│ └── snapline-globals.ts # global.data accessors + attachControlledGraph +├── svelte/ +│ ├── package.json +│ ├── tsconfig.json +│ └── src/ +│ ├── index.ts +│ ├── Node.svelte +│ ├── Group.svelte +│ ├── Connector.svelte +│ ├── Line.svelte +│ ├── Select.svelte +│ ├── Placement.svelte +│ └── ControlledGraph.svelte +└── react/ ├── package.json ├── tsconfig.json └── src/ ├── index.ts - ├── Node.svelte - ├── Connector.svelte - ├── Line.svelte - └── Select.svelte + ├── Engine.tsx + ├── Node.tsx + ├── Group.tsx + ├── Connector.tsx + ├── Line.tsx + ├── Select.tsx + ├── Placement.tsx + └── ControlledGraph.tsx ``` ## Core Classes -### NodeComponent +### NodeMirror **Extends:** `ElementObject` **Purpose:** Draggable graph node with input/output connectors **Features:** - Multiple connectors -- Property-based data flow +- Stable domain identity (`nodeId` via `config.id`, minted when omitted) - Parent-child relationships - Transform hierarchy **Key Methods:** -- `addConnector(name, connector)` - Register connector -- `setProp(name, value)` - Set output property -- `getProp(name)` - Get property value -- `addSetPropCallback(callback, propName)` - React to property changes +- `addConnectorObject(connector)` - Register connector +- `getConnector(name)` - Look up a connector by name +- `remeasureDomGeometry()` - Re-measure the box and re-glue lines +- `setSize(width, height)` / `setSizeState(width, height)` - Drive size -### ConnectorComponent +### ConnectorMirror **Extends:** `BaseObject` **Purpose:** Connection point on a node **Configuration:** -- `name: string` - Connector identifier -- `maxConnectors: number` - Connection limit (-1 = unlimited, 0 = output-only) -- `allowDragOut: boolean` - Can drag connections from this +- `id?: string` - Stable graph-global `connectorId` (minted when omitted) +- `name: string` - Construction-time key in the parent node's map +- `rules?: Partial` - Connection policy (see Connection Rules) **Features:** -- Input/output mode -- Connection limits -- Drag permissions +- Derived source/target roles (`isSource` = `maxOutgoing !== 0`, `isTarget` = `maxIncoming !== 0`) +- Connection limits and admission predicates +- Surface strategies for headless hit testing and anchors - Connection callbacks -### LineComponent +### LineMirror **Extends:** `ElementObject` **Purpose:** Visual connection between connectors @@ -112,7 +139,7 @@ snapline/ - Start/end world coordinates - Callback-based rendering -### RectSelectComponent +### RectSelectController **Extends:** `ElementObject` **Purpose:** Rectangle selection tool @@ -129,7 +156,7 @@ snapline/ **Props:** - `className?: string` - CSS class - `LineSvelteComponent?: Component` - Custom line component -- `nodeObject?: NodeComponent` (bindable) - Node instance +- `nodeObject?: NodeMirror` (bindable) - Node instance **Slots:** - Default: Node content and connectors @@ -138,18 +165,18 @@ snapline/ **Purpose:** Connector wrapper component **Props:** +- `id?: string` - Stable graph-global connector identity - `name: string` - Connector identifier -- `maxConnectors: number` - Connection limit -- `allowDragOut: boolean` - Allow drag out +- `rules?: Partial` - Connection policy **Methods:** -- `object(): ConnectorComponent` - Get underlying connector +- `object(): ConnectorMirror` - Get underlying connector ### Line.svelte **Purpose:** Renders connection path **Props:** -- `line: LineComponent` - Line instance +- `line: LineMirror` - Line instance **Features:** - SVG path rendering @@ -171,26 +198,26 @@ elements** — frameworks recover fine from property changes. Concretely: -- **Node width/height** are framework-rendered: core fires - `callbacks.onSizeChange({node, width, height})` during a resize drag and the adapter binds - the size as state. Core only updates its collision hitboxes synchronously - (`setSizeState`). The connector/line re-glue closes itself through the - ResizeObserver after the framework's DOM write reflows. +- **Node/group transforms and live width/height** are core-written during a + gesture. Resize uses `WRITE_1 → READ_2 → WRITE_2`: paint the box, remeasure + connectors, then re-glue lines. `onSizeChange` is the live observation and + the batched `onGeometryChanged({ nodes })` reports settled geometry the + framework may persist (geometry is SnapLine-owned; ignoring the event + never reverts the mirror). - **Initial node geometry** is explicit: after assigning a committed framework - element, adapters call `syncDomGeometry()`. ResizeObserver remains the + element, adapters call `remeasureDomGeometry()`. ResizeObserver remains the ongoing invalidation path, not the initial-mount handshake. -- **The rubber-band selection box** is framework-rendered: core fires - `callbacks.onRectChange({x, y, width, height, visible})` and the adapter - draws (and can restyle/replace) the box. Deliberately NO flush handshake — - the box visual is not paint-atomic. -- **Node drag transforms, `data-selected` attributes, and line SVG transforms** - stay engine-written (property writes on existing elements). +- **Line, selection, and placement geometry** use + `bindGeometryWriter(...)`. Adapters mount static structure once; the writer + mutates retained SVG/DOM/graphics refs without framework state. Custom line + components must bind a geometry writer and clean it up on unmount. +- **Semantic state stays separate:** line phase/payload/target changes use + `onStateChange`; geometry never requests a framework render. - **Adapters must render node/group elements with `position: absolute; transform-origin: top left`** (and ideally `will-change: transform`) — core no longer seeds base styles. -- SnapLine has **no `flushMutation`/`settleMutation` equivalent and must not - grow one**: unlike SnapSort's FLIP pipeline, none of SnapLine's delegated - visuals are paint-atomic. +- Adapter cleanup must detach elements or destroy objects with + `removeElement: false`; React/Svelte remain the sole structural DOM owners. ### Callback conventions @@ -204,46 +231,71 @@ explicit origins/reasons so consumers never need teardown heuristics. SnapLine deliberately does not define port types, graph-document mutations, palette contents, or node factories. Consumers express those policies through -metadata and predicates such as `canConnect`, `canContain`, and `canStart`. +metadata and predicates such as `isValidConnection`, `canContain`, and +`canStart`. `PlacementController` similarly computes preview/commit coordinates but leaves rendering and creation to framework adapters and consumer callbacks. Raw input/DOM plumbing stays on the `event.*` slots. -### NodeManager (engine-scoped registry) - -`core/src/node-manager.ts` is the per-engine registry of live SnapLine nodes -and connectors, lazy-created by `getNodeManager(engine)` the first time any -component registers (constructors register, `destroy()` unregisters — no -adapter wiring). `query.ts` enumeration delegates to it, and it hosts -engine-scoped facilities: the controlled-edges controller today, layout -helpers that walk `nodes` tomorrow. GlobalManager is application-wide, so the -managers live in `SnapLineSharedData.nodeManagers` keyed by engine. - -### Controlled edges (EdgeSyncController) - -`core/src/edge-sync.ts` + the `EdgeSync` adapter components implement the -controlled-edges contract: the CONSUMER's edge document is the only edge -authority; SnapLine reconciles rendered lines to it (`sync()`, hydrating -missing lines with origin `"hydration"`) and translates gestures into -semantic intents (`onEdgeConnect` for gesture connects, `onEdgeDisconnect` -for gesture and replacement disconnects). Programmatic, hydration, and -teardown changes never forward as intents, and sync never forwards its own -mutations (`#syncing` guard). Edges exist in exactly two representations — -consumer document and rendered lines; `NodeManager` holds membership only and -the controller stores no edges (`getEdges()` is consulted fresh). Intents fire -synchronously inside the drop dispatch; adapters reconcile in a microtask of -the same task, so accept and reject paths both resolve before the frame -paints WITHOUT any paint-atomic flush contract (the no-flushMutation rule -above still holds). Consumers should write their document synchronously -inside intent handlers; deferred stores degrade to a one-frame pending state, -never an inconsistent one. +### GraphMirror (engine-scoped registry) + +`core/src/graph-mirror.ts` is the per-engine registry of every live SnapLine +mirror, lazy-created by `getGraphMirror(engine)` the first time any mirror +registers (constructors register, `destroy()` unregisters — no adapter +wiring). It holds the node/connector sets, the settled-line and preview-line +sets, and the domain-id indexes (`nodesById`/`connectorsById`/`linesById`, +first-registration-wins with `"duplicate-id"` diagnostics), plus the +engine-scoped interaction state (`selection`, `groups`, `resizingNode`, +`parentGroups`, `membershipResolver`), the installed reconciler slot, and +the coalescing batch-aware reconciliation scheduler +(`scheduleReconciliation`/`flush`/`beginBatch`/`runBatch`). GlobalManager is +application-wide, so the registries live in the +`SnapLineSharedData.graphMirrors` WeakMap keyed by engine. `query(engine)` +is the public read-only facade over it: snapshot lists, `node(id)` / +`connector(id)` / `line(id)` lookups, and `diagnostics()` — never registry +sets or mutation methods; `query.ts` enumeration helpers delegate to the +same registry. + +### Controlled lines (ControlledGraph / LineReconciler) + +Topology is ALWAYS controlled: the CONSUMER's document is the only line +authority, and there is no imperative public topology API (`deleteLine`, +`createLine`, etc. are `@internal`; a gesture on an engine with no attached +graph owner warns and discards the preview). `core/src/line-reconciler.ts` +plus the `ControlledGraph` adapter components implement the contract: +`attachControlledGraph(engine, { onLineChangeRequest, onDiagnosticsChanged? })` +installs the `LineReconciler` and returns +`{ setCanonicalGraph, flush, dispose }`. The app PUSHES its canonical +`{ lines: LineRecord[] }` snapshot (stable ids); internal triggers +(connector register/unregister, batch close) replay the cached snapshot +through the mirror's coalescing scheduler. Each reconcile pass prunes +mirrors whose record is gone (or whose `fromConnectorId` moved), preserves +and retargets by stable `lineId`, settles or discards staged gesture lines, +and creates settled mirrors for fully-mounted records — strict admission, +never evicting: capacity/rule violations become derived diagnostics and +unmounted endpoints stay silently latent. A gesture drop validates (rules + +both endpoints' `isValidConnection` with the real `LineMirror`), stages the +outcome on the same mirror (phase `"staged"`, no topology commitment), and +dispatches ONE atomic `LineChangeRequest` +(`{ intent: connect|disconnect|replace|reconnect, add, remove, update }` — +`replace-oldest` evictions ride the request, never local deletes). Adapters +GUARANTEE a post-request microtask push of the live records ahead of the +decisive pass, so acceptance, normalization, rejection, and +rejection-by-inaction all resolve from the next snapshot — adopting the +proposed `lineId` settles the dragged line in place; rejection needs no code +path. Consumers should write their document synchronously inside the request +handler; deferred stores degrade to a one-frame pending state, never an +inconsistent one. ### Shared global registries Everything SnapLine stores on the engine's shared `global.data` bag is declared in `core/src/snapline-globals.ts` (`SnapLineSharedData`) and accessed through -its typed helpers. Engine core's `input.ts` reads `resizeHandles` duck-typed -(it cannot import snapline) — keep the two shapes in sync. +its typed helpers. It now holds only `resizeHandles` and `sourceSurfaces` +(engine core's `input.ts` reads both duck-typed — it cannot import snapline — +so keep the shapes in sync) plus the `graphMirrors` WeakMap keying each +engine to its `GraphMirror`. Selection, groups, and `resizingNode` are +engine-scoped state on `GraphMirror`, not global arrays. ### Pointer claims (camera blocking) @@ -278,30 +330,42 @@ boolean remains readable by the camera for third-party writers only. - Equal-size group candidates use stable IDs as a deterministic tie-breaker; membership cycles are always rejected. - Carried group members are moved via transform parenting only — they are never - added to `global.data.select`, so a group drag does not alter the selection. + added to the engine's `GraphMirror.selection`, so a group drag does not + alter the selection. - `attachTransformToGroup`/`detachTransformFromGroup` are the public transform-only reparent seam used by the group carry. ## Key Concepts -### Property System -- Nodes have named properties -- Connectors map to properties by name -- Connected connectors share data through properties -- Use `setProp()` to send, `addSetPropCallback()` to receive - ### Connector Types -- **Input:** `maxConnectors > 0`, `allowDragOut = false` -- **Output:** `maxConnectors = 0 or -1`, `allowDragOut = true` -- **Bidirectional:** Custom combinations + +Roles are derived from `ConnectorRules` limits — there are no role booleans: + +- **Target-only (input):** `{ maxOutgoing: 0 }` +- **Source-only (output):** `{ maxIncoming: 0 }` +- **Bidirectional:** both limits non-zero (`isSource` = `maxOutgoing !== 0`, + `isTarget` = `maxIncoming !== 0`) ### Connection Rules -- `-1`: Unlimited connections -- `0`: No incoming (output only) -- `N`: Maximum N incoming connections -- A new connection to a full finite input evicts the oldest live incoming - line(s) required to make room. Disconnect callbacks fire before the new - connect callbacks. + +`ConnectorConfig.rules` (all optional; limits are `number | "unlimited"`, +normalized to `Infinity` internally): + +- `maxOutgoing` (default `"unlimited"`) - outgoing limit; previews reserve a slot +- `maxIncoming` (default `1`) - settled incoming limit +- `reconnect` (default `true`) - existing incoming lines can be picked up +- `allowParallel` (default `false`) - BOTH endpoints must allow parallel lines +- `onFull` (default `"reject"`) - a full target rejects, or `"replace-oldest"` + proposes evicting the oldest incoming lines INSIDE the gesture's atomic + `"replace"` request (never a local delete) +- `isValidConnection(proposal)` - line-aware admission predicate + (`{ line, source, target, phase }`); synchronous and side-effect free + (candidate discovery calls it per pointer move, rechecked on drop and on + record admission); either endpoint may veto + +Canonical-record admission is strict: capacity never evicts regardless of +`onFull`; a refused record stays in the document and surfaces as a +diagnostic. ### Camera edge-pan @@ -324,5 +388,6 @@ resize gestures intentionally do not edge-pan. - Connectors must be children of Node components - Line component injected via `LineSvelteComponent` prop -- Data flows through property system +- Dataflow belongs to the application graph: derive values from the same + records that drive `ControlledGraph` and render through framework state - All input handling automatic via SnapEngine diff --git a/assets/snapline/core/README.md b/assets/snapline/core/README.md index a025ea7..01fa102 100644 --- a/assets/snapline/core/README.md +++ b/assets/snapline/core/README.md @@ -26,16 +26,23 @@ npm install @snap-engine/core @snap-engine/snapline ```ts import { - GroupNodeComponent, - NodeComponent, + GroupNodeMirror, + NodeMirror, getParentGroup, setGroupMembershipResolver, } from "@snap-engine/snapline"; ``` -After assigning a Vanilla-rendered element, call `syncDomGeometry()`. Svelte +After assigning a Vanilla-rendered element, call `remeasureDomGeometry()`. Svelte and React adapters perform that synchronization automatically. +Live gesture geometry stays outside framework state. Nodes and groups write +their retained element transforms and resize dimensions directly. Line, +selection, and placement renderers register one imperative +`bindGeometryWriter(...)`; semantic observers and commit callbacks remain +separate. A custom line renderer should mount its SVG/Canvas structure once, +bind a writer, and call the returned cleanup function when it unmounts. + When another interaction system applies transient transforms inside a node, call `connector.requestDomGeometrySync()` for each affected connector. The request is coalesced into the next read/write cycle and updates every connected @@ -43,9 +50,9 @@ line without coupling SnapLine to the external system. Surface strategies decouple connection hit testing from visible connector elements. They can activate from a node border, rank shape-specific target -hits, and resolve preview and settled anchors from cached geometry. Independent -connector capabilities allow the same logical surface to start and accept -connections. `onPointerDown` runs when a connector claims the primary pointer, +hits, and resolve preview and settled anchors from cached geometry. Symmetric +connector rules (`maxOutgoing`/`maxIncoming`, `"unlimited"` explicit) let the +same logical surface start and accept connections. `onPointerDown` runs when a connector claims the primary pointer, before the drag threshold, so consumers can preserve click selection or other gesture-start UI for headless surfaces. @@ -54,10 +61,13 @@ surface strategies, collider radius, edge-pan behavior, or the line class without replacing the connector or its existing lines. `name` is construction-only because it is the connector's key in its parent node. -For a framework-owned graph, use `onConnectionRequest` to create the domain -edge and return its stable ID as an opaque line payload. Pass that payload -directly when hydrating with `connectToConnector`; programmatic connections do -not invoke the creation request. +Topology is always controlled: attach the graph owner with +`attachControlledGraph(engine, { onLineChangeRequest })` (or mount the +adapter `ControlledGraph` component), push your `LineRecord`s through +`setCanonicalGraph`, and apply each gesture's atomic proposal to your +records — adopting the proposed line id settles the dragged line in place. +Hydration never invokes your request handler, so restoring a saved graph +cannot duplicate application edges. Groups maintain an exclusive direct parent. Ordinary nodes use center containment, nested groups use full-bounds containment, and the smallest safe diff --git a/assets/snapline/core/package.json b/assets/snapline/core/package.json index 98c721f..37da1a5 100644 --- a/assets/snapline/core/package.json +++ b/assets/snapline/core/package.json @@ -18,7 +18,10 @@ "./select": "./src/select.ts", "./group": "./src/group.ts", "./placement": "./src/placement.ts", - "./query": "./src/query.ts" + "./query": "./src/query.ts", + "./graph-mirror": "./src/graph-mirror.ts", + "./line-reconciler": "./src/line-reconciler.ts", + "./geometry": "./src/geometry.ts" }, "files": [ "src", diff --git a/assets/snapline/core/src/connector.ts b/assets/snapline/core/src/connector.ts index 2dcd761..2182d2f 100644 --- a/assets/snapline/core/src/connector.ts +++ b/assets/snapline/core/src/connector.ts @@ -8,25 +8,21 @@ import type { pointerUpProp, } from "@snap-engine/core"; import { CircleCollider } from "@snap-engine/core/collision"; -import type { NodeComponent } from "./node"; -import { LineComponent } from "./line"; -import { getNodeManager } from "./snapline-globals"; +import type { NodeMirror } from "./node"; +import { LineMirror, type LineMirrorPhase } from "./line"; +import { getGraphMirror } from "./snapline-globals"; import { getSourceSurfaces } from "./snapline-globals"; +import { mintDomainId } from "./graph-mirror"; +import type { LineChangeRequest } from "./line-reconciler"; export type SnapLineMetadata = Record; -export type ConnectionOrigin = "gesture" | "programmatic" | "hydration"; +export type ConnectionOrigin = "gesture" | "hydration"; export type DisconnectReason = | "gesture" | "replacement" | "programmatic" | "teardown"; export type ConnectorRole = "source" | "target"; -export type ConnectorLinePhase = - | "source-start" - | "preview-free" - | "preview-target" - | "drop" - | "connected"; export interface ConnectorPoint { x: number; @@ -44,8 +40,8 @@ export interface ConnectorAnchor extends ConnectorPoint { /** Cached world-space bounds derived from the parent node's collision box. */ export interface ConnectorGeometrySnapshot { - connector: ConnectorComponent; - node: NodeComponent; + connector: ConnectorMirror; + node: NodeMirror; x: number; y: number; width: number; @@ -67,23 +63,23 @@ export interface ConnectorHit { } export interface ConnectorCandidate { - connector: ConnectorComponent; + connector: ConnectorMirror; hit: ConnectorHit; } export interface ConnectorSurfaceHitTestEvent { - connector: ConnectorComponent; + connector: ConnectorMirror; position: eventPosition; geometry: ConnectorGeometrySnapshot; - phase: ConnectorLinePhase; + phase: LineMirrorPhase; } export interface ConnectorAnchorEvent { - connector: ConnectorComponent; - peer: ConnectorComponent | null; - line: LineComponent; + connector: ConnectorMirror; + peer: ConnectorMirror | null; + line: LineMirror; role: ConnectorRole; - phase: ConnectorLinePhase; + phase: LineMirrorPhase; position: ConnectorPoint; geometry: ConnectorGeometrySnapshot; peerGeometry: ConnectorGeometrySnapshot | null; @@ -102,31 +98,63 @@ export interface ConnectorSurfaceStrategy { ) => ConnectorAnchor | null | void; } -export interface ConnectorCapabilities { - source: boolean; - target: boolean; +/** `"unlimited"` is the explicit no-limit value (normalized to Infinity + * internally so capacity checks stay branch-free). */ +export type ConnectionLimit = number | "unlimited"; + +export interface ConnectorRules { + /** Maximum outgoing lines (previews reserve a slot). 0 makes the connector + * target-only. Default `"unlimited"`. */ + maxOutgoing: ConnectionLimit; + /** Maximum settled incoming lines. 0 makes the connector source-only. + * Default 1. */ + maxIncoming: ConnectionLimit; + reconnect: boolean; + /** Both endpoints must allow parallel lines between the same pair. */ + allowParallel: boolean; + /** Policy when a proposal would exceed `maxIncoming`: reject it (default) + * or evict the oldest incoming lines to make room. */ + onFull: "reject" | "replace-oldest"; + /** Line-aware admission predicate — synchronous and side-effect free + * (candidate discovery calls it repeatedly; rechecked on the final drop). + * Either endpoint's predicate may veto. */ + isValidConnection?: (proposal: ConnectionProposal) => boolean; +} + +/** Rules with limits normalized to numbers (Infinity = unlimited). */ +export interface ResolvedConnectorRules { + maxOutgoing: number; maxIncoming: number; reconnect: boolean; allowParallel: boolean; + onFull: "reject" | "replace-oldest"; + isValidConnection: ((proposal: ConnectionProposal) => boolean) | null; +} + +export interface ConnectionProposal { + line: LineMirror; + source: ConnectorMirror; + target: ConnectorMirror; + phase: "candidate" | "drop"; } export interface ConnectorPairEvent { - source: ConnectorComponent; - target: ConnectorComponent; + source: ConnectorMirror; + target: ConnectorMirror; } export interface ConnectorCandidateEvent { - source: ConnectorComponent; + source: ConnectorMirror; /** Legacy convenience reference. */ - candidate: ConnectorComponent | null; + candidate: ConnectorMirror | null; resolvedCandidate: ConnectorCandidate | null; - line: LineComponent | null; + line: LineMirror | null; } export interface ConnectorConnectionEvent extends ConnectorPairEvent { - connector: ConnectorComponent; - peer: ConnectorComponent; - line: LineComponent; + connector: ConnectorMirror; + peer: ConnectorMirror; + line: LineMirror; role: ConnectorRole; origin: ConnectionOrigin; } @@ -137,7 +165,7 @@ export interface ConnectorDisconnectionEvent } export interface ConnectorDragEvent { - connector: ConnectorComponent; + connector: ConnectorMirror; position: eventPosition; pointerId: number; } @@ -146,23 +174,7 @@ export interface ConnectorPointerEvent extends ConnectorDragEvent { originalEvent: PointerEvent; } -export interface ConnectorConnectionRequestEvent extends ConnectorPairEvent { - line: LineComponent; - candidate: ConnectorCandidate; - position: eventPosition; -} - -export type ConnectorConnectionRequestResult = - | false - | void - | { payload?: unknown }; - export interface ConnectorCallbacks { - canConnect?: (event: ConnectorPairEvent) => boolean; - /** Gesture-only and source-only canonical-model creation seam. */ - onConnectionRequest?: ( - event: ConnectorConnectionRequestEvent, - ) => ConnectorConnectionRequestResult; /** Fires when this connector claims a primary pointer, before drag threshold. */ onPointerDown?: (event: ConnectorPointerEvent) => void; onDragStart?: (event: ConnectorDragEvent) => void; @@ -179,14 +191,16 @@ enum ConnectorState { } export interface ConnectorConfig { + /** + * Stable application-facing identity (graph-global, one namespace across + * the whole graph). Minted by SnapLine when omitted; supply one for any + * graph that outlives this mirror (persistence, remounts, reloads). + */ + id?: string; name?: string; - /** @deprecated Prefer capabilities.maxIncoming. */ - maxConnectors?: number; - /** @deprecated Prefer capabilities.source/target. */ - allowDragOut?: boolean; - capabilities?: Partial; + rules?: Partial; surfaceStrategies?: readonly ConnectorSurfaceStrategy[]; - lineClass?: typeof LineComponent; + lineClass?: typeof LineMirror; colliderRadius?: number; metadata?: SnapLineMetadata; callbacks?: ConnectorCallbacks; @@ -206,31 +220,34 @@ interface ArmedConnection { pointerId: number; sourceHit: ConnectorHit | null; sourceStrategy: ConnectorSurfaceStrategy | null; - reconnectLine: LineComponent | null; + reconnectLine: LineMirror | null; } -class ConnectorComponent extends ElementObject { +class ConnectorMirror extends ElementObject { + /** Stable domain identity — supplied via `ConnectorConfig.id` or minted. + * Never the engine-internal `BaseObject.id`. */ + readonly connectorId: string; #config: ConnectorConfig; - #capabilities: Readonly; + #rules: Readonly; #name: string; - #prop: { [key: string]: any }; - #outgoingLines: LineComponent[]; - #incomingLines: LineComponent[]; + #outgoingLines: LineMirror[]; + #incomingLines: LineMirror[]; #state: ConnectorState = ConnectorState.IDLE; #hitCircle: CircleCollider; - #targetConnector: ConnectorComponent | null = null; + #targetConnector: ConnectorMirror | null = null; #candidate: ConnectorResolvedHit | null = null; - #dragLine: LineComponent | null = null; + #dragLine: LineMirror | null = null; #edgePanPointerId: number | null = null; #localCenter: ConnectorPoint; #hasMeasuredCenter = false; #armed: ArmedConnection | null = null; + #gestureOrigin: "new" | "reconnect" | null = null; #cancelledPointers = new Set(); #callbacks: ConnectorCallbacks; - get parent(): NodeComponent { - return super.parent as NodeComponent; + get parent(): NodeMirror { + return super.parent as NodeMirror; } set parent(parent: BaseObject | null) { @@ -239,17 +256,17 @@ class ConnectorComponent extends ElementObject { constructor( engine: any, - parent: NodeComponent, + parent: NodeMirror, config: ConnectorConfig = {}, ) { super(engine, parent as unknown as BaseObject); - this.#prop = {}; this.#outgoingLines = []; this.#incomingLines = []; this.#config = { ...config }; - this.#capabilities = Object.freeze(resolveCapabilities(this.#config)); + this.#rules = Object.freeze(resolveRules(this.#config)); this.#name = config.name || this.id || ""; + this.connectorId = config.id ?? mintDomainId("connector", this.global); this.#callbacks = config.callbacks ?? {}; this.#localCenter = { x: 0, y: 0 }; this.transformMode = "none"; @@ -269,7 +286,7 @@ class ConnectorComponent extends ElementObject { ); this.addCollider(this.#hitCircle); this.#syncSourceSurfaceRegistration(); - getNodeManager(this.engine).registerConnector(this); + getGraphMirror(this.engine).registerConnector(this); this.event.dom.onAssignDom = () => { this.schedule( @@ -290,16 +307,22 @@ class ConnectorComponent extends ElementObject { return this.#config; } - get capabilities(): Readonly { - return this.#capabilities; + get rules(): Readonly { + return this.#rules; } - get surfaceStrategies(): readonly ConnectorSurfaceStrategy[] { - return this.#config.surfaceStrategies ?? []; + /** Derived role: this connector may originate lines. */ + get isSource(): boolean { + return this.#rules.maxOutgoing !== 0; } - get prop(): { [key: string]: any } { - return this.#prop; + /** Derived role: this connector may receive lines. */ + get isTarget(): boolean { + return this.#rules.maxIncoming !== 0; + } + + get surfaceStrategies(): readonly ConnectorSurfaceStrategy[] { + return this.#config.surfaceStrategies ?? []; } get metadata(): SnapLineMetadata { @@ -314,19 +337,21 @@ class ConnectorComponent extends ElementObject { this.updateConfig({ callbacks }); } - get outgoingLines(): LineComponent[] { - return this.#outgoingLines; + // Snapshots, never the internal arrays — topology mutation goes through + // connectToConnector/deleteLine/disconnectFromConnector. + get outgoingLines(): readonly LineMirror[] { + return [...this.#outgoingLines]; } - get incomingLines(): LineComponent[] { - return this.#incomingLines; + get incomingLines(): readonly LineMirror[] { + return [...this.#incomingLines]; } - get targetConnector(): ConnectorComponent | null { + get targetConnector(): ConnectorMirror | null { return this.#targetConnector; } - set targetConnector(value: ConnectorComponent | null) { + set targetConnector(value: ConnectorMirror | null) { const resolved = value ? { candidate: { @@ -359,7 +384,7 @@ class ConnectorComponent extends ElementObject { updateConfig(config: ConnectorConfigUpdate): void { this.#config = { ...this.#config, ...config }; this.#callbacks = this.#config.callbacks ?? {}; - this.#capabilities = Object.freeze(resolveCapabilities(this.#config)); + this.#rules = Object.freeze(resolveRules(this.#config)); this.#hitCircle.radius = this.#config.colliderRadius ?? 30; this.#syncSourceSurfaceRegistration(); this.scheduleAllLineWrites(); @@ -499,7 +524,7 @@ class ConnectorComponent extends ElementObject { ): void { if (prop.event.button !== 0) return; const currentIncomingLines = this.#liveIncomingLines(); - if (this.#capabilities.reconnect && currentIncomingLines.length > 0) { + if (this.#rules.reconnect && currentIncomingLines.length > 0) { const line = currentIncomingLines[0]; const source = line.start; this.engine.input.setPointerDragOwner(prop.event.pointerId, source); @@ -511,7 +536,9 @@ class ConnectorComponent extends ElementObject { return; } - if (!this.#capabilities.source) return; + if (!this.isSource) return; + // A new preview reserves an outgoing slot; a full source cannot start one. + if (this.#liveOutgoingLines().length >= this.#rules.maxOutgoing) return; this.#arm(prop, { sourceHit: sourceHit?.candidate.hit ?? null, sourceStrategy: sourceHit?.strategy ?? this.#defaultAnchorStrategy(), @@ -560,6 +587,7 @@ class ConnectorComponent extends ElementObject { const armed = this.#armed; let line = armed.reconnectLine; + this.#gestureOrigin = line ? "reconnect" : "new"; if (line) { this.#detachLineForReconnect(line); line.clearTarget(); @@ -584,13 +612,14 @@ class ConnectorComponent extends ElementObject { line.setPhase("preview-free"); } + /** @internal Reconciler/teardown-only: topology is always controlled — + * applications remove lines by removing their canonical records. */ deleteLine( - i: number, + line: LineMirror, reason: DisconnectReason = "programmatic", - ): LineComponent | null { - if (this.#outgoingLines.length === 0 || i < 0) return null; - const line = this.#outgoingLines[i]; - if (!line) return null; + ): LineMirror | null { + const index = this.#outgoingLines.indexOf(line); + if (index === -1) return null; const target = line.target; if (target) { @@ -600,17 +629,18 @@ class ConnectorComponent extends ElementObject { this.#emitDisconnect(target, line, reason); } line.destroy(false); - this.#outgoingLines.splice(i, 1); + this.#outgoingLines.splice(index, 1); this.parent?.updateNodeLineList(); return line; } + /** @internal Teardown-only. */ deleteAllLines(reason: DisconnectReason = "programmatic"): void { for (const line of [...this.#outgoingLines]) { - this.deleteLine(this.#outgoingLines.indexOf(line), reason); + this.deleteLine(line, reason); } for (const line of [...this.#incomingLines]) { - line.start.deleteLine(line.start.outgoingLines.indexOf(line), reason); + line.start.deleteLine(line, reason); } this.#incomingLines = []; } @@ -637,11 +667,9 @@ class ConnectorComponent extends ElementObject { } } - assignToNode(parent: NodeComponent): void { + assignToNode(parent: NodeMirror): void { this.parent = parent; const parentRef = this.parent; - parentRef._prop[this.#name] = null; - this.#prop = parentRef._prop; parentRef._connectors[this.#name] = this; this.#outgoingLines = []; this.#incomingLines = []; @@ -650,22 +678,16 @@ class ConnectorComponent extends ElementObject { } } - createLine(): LineComponent { + /** @internal Gesture/reconciler-only: lines exist because canonical + * records (or in-flight gestures) say so. */ + createLine(config: { id?: string } = {}): LineMirror { const line = this.#config.lineClass - ? new this.#config.lineClass(this.engine, this) - : new LineComponent(this.engine, this); + ? new this.#config.lineClass(this.engine, this, config) + : new LineMirror(this.engine, this, config); line.setSourceSurfaceContext(this.#defaultAnchorStrategy(), null); return line; } - /** @deprecated Pointer-down now only arms; retained for source compatibility. */ - startDragOutLine(prop: pointerDownProp): void { - this.armSurfaceGesture( - prop, - this.#resolveOwnSourceHit(prop.position, "source-start"), - ); - } - findClosestConnector(): void { if (!this.#dragLine) { this.#setCandidate(null); @@ -683,7 +705,7 @@ class ConnectorComponent extends ElementObject { findClosestConnectorAtPoint( position: ConnectorPoint, - ): ConnectorComponent | null { + ): ConnectorMirror | null { return this.findCandidateAtPoint(position)?.connector ?? null; } @@ -701,33 +723,72 @@ class ConnectorComponent extends ElementObject { return this.#resolveOwnSourceHit(position, "source-start"); } - canConnectToConnector(connector: ConnectorComponent): boolean { + /** + * Imperative admission query. Structural rules always apply; the + * line-aware isValidConnection predicates run only when a line is given. + */ + canConnect(target: ConnectorMirror, line: LineMirror | null = null): boolean { + return this.#admitsConnection(target, line, "drop"); + } + + #admitsConnection( + target: ConnectorMirror, + line: LineMirror | null, + phase: "candidate" | "drop", + ): boolean { + // Gestures admit an over-capacity target when its policy replaces + // (the evictions ride the atomic proposal); records never do. + if (this.#admitsEndpoints(target, line, true) !== true) return false; + return line ? this.#predicatesAdmit(target, line, phase) : true; + } + + /** + * The one structural admission check: roles, capacity (the in-flight + * line never counts against itself), and the parallel rule. + */ + #admitsEndpoints( + target: ConnectorMirror, + line: LineMirror | null, + allowReplacement: boolean, + ): true | "capacity-exceeded" | "connection-rejected" { + if (target.id === this.id || !this.isSource || !target.isTarget) { + return "connection-rejected"; + } + const incoming = target + .#liveIncomingLines() + .filter((incomingLine) => incomingLine !== line); + const outgoing = this.#liveOutgoingLines().filter( + (outgoingLine) => outgoingLine !== line, + ); if ( - connector.id === this.id || - !this.#capabilities.source || - !connector.#capabilities.target || - connector.#capabilities.maxIncoming === 0 + (incoming.length >= target.#rules.maxIncoming && + !(allowReplacement && target.#rules.onFull === "replace-oldest")) || + outgoing.length >= this.#rules.maxOutgoing ) { - return false; + return "capacity-exceeded"; } - - const hasParallel = connector - .#liveIncomingLines() - .some((line) => line.start === this); + const hasParallel = incoming.some( + (incomingLine) => incomingLine.start === this, + ); if ( hasParallel && - !( - this.#capabilities.allowParallel && - connector.#capabilities.allowParallel - ) + !(this.#rules.allowParallel && target.#rules.allowParallel) ) { - return false; + return "connection-rejected"; } + return true; + } - const event = { source: this, target: connector }; + /** Both endpoints' line-aware predicates may veto. */ + #predicatesAdmit( + target: ConnectorMirror, + line: LineMirror, + phase: "candidate" | "drop", + ): boolean { + const proposal: ConnectionProposal = { line, source: this, target, phase }; return ( - this.#callbacks.canConnect?.(event) !== false && - connector.#callbacks.canConnect?.(event) !== false + this.#rules.isValidConnection?.(proposal) !== false && + target.#rules.isValidConnection?.(proposal) !== false ); } @@ -768,9 +829,9 @@ class ConnectorComponent extends ElementObject { } hoverWhileDragging( - targetConnector: ConnectorComponent, + targetConnector: ConnectorMirror, ): [number, number] | void { - if (!(targetConnector instanceof ConnectorComponent) || !this.#dragLine) { + if (!(targetConnector instanceof ConnectorMirror) || !this.#dragLine) { return; } const anchor = targetConnector.resolveAnchor({ @@ -808,48 +869,98 @@ class ConnectorComponent extends ElementObject { line.setPhase("drop"); line.setPreviewPosition(prop.end); - let connected = false; - if (candidate) { - let request: ConnectorConnectionRequestResult; - try { - request = this.#callbacks.onConnectionRequest?.({ - source: this, - target: candidate.candidate.connector, - line, - candidate: candidate.candidate, + const mirror = getGraphMirror(this.engine); + if (typeof mirror.reconciler?.dispatchLineChangeRequest === "function") { + this.#endControlledDrop(line, candidate, prop); + return; + } + // Topology is always controlled: without a graph owner attached there + // is no document to propose to, so the gesture cannot produce a line. + console.warn( + "SnapLine: gesture on an engine with no graph owner — mount (or attachControlledGraph) so gestures have a document to propose to.", + ); + this.#discardDraggedLine(line, prop, false); + } + + /** + * Controlled-mode drop: nothing mutates settled topology locally. The + * gesture stages its outcome on the line mirror and proposes one atomic + * LineChangeRequest; the post-request reconciliation pass settles or + * discards the staged state against the canonical decision. + */ + #endControlledDrop( + line: LineMirror, + candidate: ConnectorResolvedHit | null, + prop: dragEndProp, + ): void { + const target = candidate?.candidate.connector ?? null; + + if (!candidate || !target || !this.#admitsConnection(target, line, "drop")) { + if (this.#gestureOrigin === "reconnect") { + // Gesture disconnect: propose the removal; the line stays visibly + // detached until the decision. A rejected removal re-glues it from + // the unchanged document. + line.stageForRemoval(); + this.parent.updateNodeLineList(); + this.#dispatchRequest({ + intent: "disconnect", + add: [], + remove: [line.lineId], + update: [], + }); + this.parent.scheduleLineWrites(); + this.#callbacks.onDragEnd?.({ + connector: this, position: prop.end, + pointerId: prop.pointerId, + connected: false, }); - } catch (error) { - this.#discardDraggedLine(line, prop, false); - throw error; - } - if (request !== false) { - const connectionOptions: Parameters< - ConnectorComponent["connectToConnector"] - >[0] = { - target: candidate.candidate.connector, - line, - origin: "gesture", - candidate, - }; - if ( - request && - typeof request === "object" && - Object.prototype.hasOwnProperty.call(request, "payload") - ) { - connectionOptions.payload = request.payload; - } - connected = this.connectToConnector(connectionOptions); + this.#resetGesture(); + return; } - } - - if (!connected) { this.#discardDraggedLine(line, prop, false); return; } - candidate!.candidate.connector.#prop[candidate!.candidate.connector.#name] = - this.#prop[this.#name]; + // Evictions ride the atomic proposal ("replace") — never local deletes. + const evictions: string[] = []; + const incoming = target + .#liveIncomingLines() + .filter((incomingLine) => incomingLine !== line); + const overflow = incoming.length - target.#rules.maxIncoming + 1; + if (overflow > 0) { + for (const evicted of incoming.slice(0, overflow)) { + evictions.push(evicted.lineId); + } + } + + line.stageTarget(target, candidate.candidate, candidate.strategy); + this.parent.updateNodeLineList(); + + const request: LineChangeRequest = + this.#gestureOrigin === "reconnect" + ? { + intent: evictions.length > 0 ? "replace" : "reconnect", + add: [], + remove: evictions, + update: [{ id: line.lineId, toConnectorId: target.connectorId }], + } + : { + intent: evictions.length > 0 ? "replace" : "connect", + add: [ + { + id: line.lineId, + fromConnectorId: this.connectorId, + toConnectorId: target.connectorId, + ...(line.payload !== undefined + ? { payload: line.payload } + : {}), + }, + ], + remove: evictions, + update: [], + }; + this.#dispatchRequest(request); this.parent.scheduleLineWrites(); this.#callbacks.onDragEnd?.({ connector: this, @@ -860,11 +971,47 @@ class ConnectorComponent extends ElementObject { this.#resetGesture(); } - _endLineDragCleanup(): void { - this.#resetGesture(); + #dispatchRequest(request: LineChangeRequest): void { + const mirror = getGraphMirror(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(); + } + + /** @internal Reconciler-only: settle a staged gesture line onto its + * accepted target. Capacity and both predicates recheck strictly. */ + settleStagedLineFromRecord( + line: LineMirror, + target: ConnectorMirror, + ): true | "capacity-exceeded" | "connection-rejected" { + const structural = this.#admitsEndpoints(target, line, false); + if (structural !== true) return structural; + if (!this.#predicatesAdmit(target, line, "drop")) { + return "connection-rejected"; + } + this.#settlePreviewLine(line, target, null, "gesture", null); + return true; + } + + /** @internal Reconciler-only: drop a staged line the canonical owner + * declined. No disconnect observation — no connect was ever observed. */ + discardStagedLine(line: LineMirror): void { + const index = this.#outgoingLines.indexOf(line); + if (index === -1) return; + this.#outgoingLines.splice(index, 1); + line.destroy(false); + this.parent?.updateNodeLineList(); } - startPickUpLine(line: LineComponent, prop: pointerDownProp): void { + startPickUpLine(line: LineMirror, prop: pointerDownProp): void { this.engine.input.setPointerDragOwner(prop.event.pointerId, line.start); line.start.#arm(prop, { sourceHit: null, @@ -873,62 +1020,21 @@ class ConnectorComponent extends ElementObject { }); } - connectToConnector(options: { - target: ConnectorComponent; - line?: LineComponent | null; - origin?: ConnectionOrigin; - payload?: unknown; - candidate?: ConnectorResolvedHit | null; - }): boolean { - const { - target, - line: requestedLine = null, - origin = "programmatic", - candidate = null, - } = options; - let line = requestedLine; - const hasPayload = Object.prototype.hasOwnProperty.call(options, "payload"); - if (line && line.start !== this) return false; - - const alreadyConnected = - line?.target === target && - this.#outgoingLines.includes(line) && - target.#incomingLines.includes(line); - if (alreadyConnected && line) { - if (hasPayload) line.setPayload(options.payload); - line.connectTarget( - target, - candidate?.candidate ?? null, - candidate?.strategy ?? target.#defaultAnchorStrategy(), - ); - line.writeTransform(); - return true; - } - - if (!this.canConnectToConnector(target)) return false; - - const maxIncoming = target.#capabilities.maxIncoming; - if (maxIncoming > 0) { - const currentIncomingLines = target.#liveIncomingLines(); - const removeCount = Math.max( - 0, - currentIncomingLines.length - maxIncoming + 1, - ); - for (const incomingLine of currentIncomingLines.slice(0, removeCount)) { - incomingLine.start.deleteLine( - incomingLine.start.outgoingLines.indexOf(incomingLine), - "replacement", - ); - } - } - - if (line == null) { - line = this.createLine(); - this.#outgoingLines.unshift(line); - } else if (!this.#outgoingLines.includes(line)) { - this.#outgoingLines.unshift(line); - } - + /** + * Settle a line that already sits in this connector's outgoing list onto + * its target: run the explicit replacement policy, detach any previous + * target, glue anchors, and emit. Validation happened before this point. + */ + #settlePreviewLine( + line: LineMirror, + target: ConnectorMirror, + candidate: ConnectorResolvedHit | null, + origin: ConnectionOrigin, + payload: { value: unknown } | null, + ): void { + // No local eviction here: replace-oldest evictions ride the atomic + // request, and the reconciler prunes accepted removals before settling — + // by the time a line settles, its target has room by construction. const previousTarget = line.target; if (previousTarget) { previousTarget.#incomingLines = previousTarget.#incomingLines.filter( @@ -939,7 +1045,7 @@ class ConnectorComponent extends ElementObject { } // Payload and anchors are authoritative before render/connect callbacks. - if (hasPayload) line.setPayload(options.payload); + if (payload) line.setPayload(payload.value); line.connectTarget( target, candidate?.candidate ?? null, @@ -952,18 +1058,17 @@ class ConnectorComponent extends ElementObject { this.parent.updateNodeLineList(); this.#emitConnect(target, line, origin); - this.parent.setProp(this.#name, this.#prop[this.#name]); - return true; } + /** @internal Reconciler/teardown-only. */ disconnectFromConnector( - connector: ConnectorComponent, + connector: ConnectorMirror, reason: DisconnectReason = "programmatic", ): void { - const lineIndex = this.#outgoingLines.findIndex( - (line) => line.target === connector, + const line = this.#outgoingLines.find( + (outgoingLine) => outgoingLine.target === connector, ); - if (lineIndex !== -1) this.deleteLine(lineIndex, reason); + if (line) this.deleteLine(line, reason); } resolveAnchor({ @@ -975,10 +1080,10 @@ class ConnectorComponent extends ElementObject { hit, strategy, }: { - line: LineComponent; + line: LineMirror; role: ConnectorRole; - phase: ConnectorLinePhase; - peer: ConnectorComponent | null; + phase: LineMirrorPhase; + peer: ConnectorMirror | null; position: ConnectorPoint; hit: ConnectorHit | null; strategy: ConnectorSurfaceStrategy | null; @@ -1009,25 +1114,25 @@ class ConnectorComponent extends ElementObject { return cloneAnchor(this.center); } - destroy(): void { + destroy(removeElement: boolean = true): void { if (this.#edgePanPointerId != null) { this.engine.edgePanController?.stopEdgePan(this.#edgePanPointerId); this.#edgePanPointerId = null; } this.#resetGesture(); this.deleteAllLines("teardown"); - getNodeManager(this.engine).unregisterConnector(this); + getGraphMirror(this.engine).unregisterConnector(this); if (this.parent?._connectors[this.#name] === this) { delete this.parent._connectors[this.#name]; } this.#removeSourceSurfaceRegistration(); this.globalInput.pointerUp = null; - super.destroy(); + super.destroy(removeElement); } #resolveOwnSourceHit( position: eventPosition, - phase: ConnectorLinePhase, + phase: LineMirrorPhase, ): ConnectorResolvedHit | null { const hits: ConnectorResolvedHit[] = []; for (const [strategyIndex, strategy] of this.surfaceStrategies.entries()) { @@ -1058,7 +1163,7 @@ class ConnectorComponent extends ElementObject { for (const connector of registeredConnectors(this.engine)) { if ( connector.engine !== this.engine || - !this.canConnectToConnector(connector) + !this.#admitsConnection(connector, this.#dragLine, "candidate") ) { continue; } @@ -1132,23 +1237,22 @@ class ConnectorComponent extends ElementObject { }); } - #detachLineForReconnect(line: LineComponent): void { + #detachLineForReconnect(line: LineMirror): void { const target = line.target; if (!target) return; target.#incomingLines = target.#incomingLines.filter( (incomingLine) => incomingLine !== line, ); - line.target = null; + line.detachTarget(); this.#emitDisconnect(target, line, "gesture"); } #discardDraggedLine( - line: LineComponent, + line: LineMirror, prop: dragEndProp, connected: boolean, ): void { - const index = this.#outgoingLines.indexOf(line); - if (index !== -1) this.deleteLine(index, "gesture"); + if (this.#outgoingLines.includes(line)) this.deleteLine(line, "gesture"); this.#callbacks.onDragEnd?.({ connector: this, position: prop.end, @@ -1169,6 +1273,7 @@ class ConnectorComponent extends ElementObject { this.#state = ConnectorState.IDLE; this.#armed = null; this.#dragLine = null; + this.#gestureOrigin = null; this.#setCandidate(null); } @@ -1184,7 +1289,7 @@ class ConnectorComponent extends ElementObject { const sourceSurfaces = getSourceSurfaces(this.global); const index = sourceSurfaces.indexOf(this); const shouldRegister = - this.#capabilities.source && + this.isSource && this.surfaceStrategies.some((strategy) => strategy.sourceHitTest); if (shouldRegister && index === -1) { sourceSurfaces.push(this); @@ -1203,13 +1308,61 @@ class ConnectorComponent extends ElementObject { return this.element != null || this.#hasMeasuredCenter; } - #liveIncomingLines(): LineComponent[] { + /** + * @internal Reconciler-only: create a settled mirror for a canonical line + * record. No gesture policy — capacity is strict (never evicts, regardless + * of onFull) and refusal reports a diagnostic code instead of throwing. + */ + createSettledLineFromRecord( + target: ConnectorMirror, + record: { id: string; payload?: unknown }, + ): LineMirror | "capacity-exceeded" | "connection-rejected" { + const structural = this.#admitsEndpoints(target, null, false); + if (structural !== true) return structural; + + const line = this.createLine({ id: record.id }); + if (!this.#predicatesAdmit(target, line, "drop")) { + line.destroy(false); + return "connection-rejected"; + } + this.#outgoingLines.unshift(line); + this.#settlePreviewLine(line, target, null, "hydration", { + value: record.payload, + }); + return line; + } + + /** + * @internal Reconciler-only: move an existing settled line to a new + * canonical target, preserving the mirror. Strict capacity; never evicts. + */ + retargetSettledLineFromRecord( + line: LineMirror, + target: ConnectorMirror, + ): true | "capacity-exceeded" | "connection-rejected" { + const structural = this.#admitsEndpoints(target, line, false); + if (structural !== true) return structural; + if (!this.#predicatesAdmit(target, line, "drop")) { + return "connection-rejected"; + } + this.#settlePreviewLine(line, target, null, "hydration", null); + 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); } + #liveOutgoingLines(): LineMirror[] { + return this.#outgoingLines.filter((line) => !line.isDeleteRequested); + } + #emitConnect( - target: ConnectorComponent, - line: LineComponent, + target: ConnectorMirror, + line: LineMirror, origin: ConnectionOrigin, ): void { this.#callbacks.onConnect?.({ @@ -1230,20 +1383,11 @@ class ConnectorComponent extends ElementObject { role: "target", origin, }); - getNodeManager(this.engine).edgeSync?.notifyConnect({ - source: this, - target, - connector: this, - peer: target, - line, - role: "source", - origin, - }); } #emitDisconnect( - target: ConnectorComponent, - line: LineComponent, + target: ConnectorMirror, + line: LineMirror, reason: DisconnectReason, ): void { this.#callbacks.onDisconnect?.({ @@ -1264,28 +1408,23 @@ class ConnectorComponent extends ElementObject { role: "target", reason, }); - getNodeManager(this.engine).edgeSync?.notifyDisconnect({ - source: this, - target, - connector: this, - peer: target, - line, - role: "source", - reason, - }); } } -function resolveCapabilities(config: ConnectorConfig): ConnectorCapabilities { - const legacyMaxIncoming = config.maxConnectors ?? 1; - const legacySource = config.allowDragOut ?? false; +function resolveRules(config: ConnectorConfig): ResolvedConnectorRules { + const rules = config.rules ?? {}; + const toCount = ( + limit: ConnectionLimit | undefined, + fallback: number, + ): number => + limit === undefined ? fallback : limit === "unlimited" ? Infinity : limit; return { - source: config.capabilities?.source ?? legacySource, - target: - config.capabilities?.target ?? (!legacySource && legacyMaxIncoming !== 0), - maxIncoming: config.capabilities?.maxIncoming ?? legacyMaxIncoming, - reconnect: config.capabilities?.reconnect ?? true, - allowParallel: config.capabilities?.allowParallel ?? false, + maxOutgoing: toCount(rules.maxOutgoing, Infinity), + maxIncoming: toCount(rules.maxIncoming, 1), + reconnect: rules.reconnect ?? true, + allowParallel: rules.allowParallel ?? false, + onFull: rules.onFull ?? "reject", + isValidConnection: rules.isValidConnection ?? null, }; } @@ -1353,15 +1492,15 @@ function pickResolvedHit( export function resolveConnectorSourceAtPoint( engine: any, position: eventPosition, - node?: NodeComponent, + node?: NodeMirror, ): ConnectorResolvedHit | null { const hits: ConnectorResolvedHit[] = []; for (const surface of getSourceSurfaces(engine.global)) { - const connector = surface as ConnectorComponent; + const connector = surface as ConnectorMirror; if ( connector.engine !== engine || (node && connector.parent !== node) || - !connector.capabilities.source + !connector.isSource ) { continue; } @@ -1371,13 +1510,12 @@ export function resolveConnectorSourceAtPoint( return pickResolvedHit(hits); } -function registeredConnectors(engine: any): ConnectorComponent[] { - const objectTable = engine.global?.getEngineObjectTable?.(engine); - if (!objectTable) return []; - return Object.values(objectTable).filter( - (object): object is ConnectorComponent => - object instanceof ConnectorComponent && !object.isDeleteRequested, +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( + (connector) => !connector.isDeleteRequested, ); } -export { ConnectorComponent }; +export { ConnectorMirror }; diff --git a/assets/snapline/core/src/edge-sync.ts b/assets/snapline/core/src/edge-sync.ts deleted file mode 100644 index d517162..0000000 --- a/assets/snapline/core/src/edge-sync.ts +++ /dev/null @@ -1,224 +0,0 @@ -import type { - ConnectorComponent, - ConnectorConnectionEvent, - ConnectorDisconnectionEvent, -} from "./connector"; -import type { LineComponent } from "./line"; -import { getNodeManager } from "./snapline-globals"; - -export interface EdgeEndpoint { - node: string; - port: string; -} - -export interface EdgeLike { - from: EdgeEndpoint; - to: EdgeEndpoint; -} - -export interface EdgeConnectIntentEvent { - from: EdgeEndpoint; - to: EdgeEndpoint; - source: ConnectorComponent; - target: ConnectorComponent; - line: LineComponent; - origin: "gesture"; -} - -export interface EdgeDisconnectIntentEvent { - from: EdgeEndpoint; - to: EdgeEndpoint; - source: ConnectorComponent; - target: ConnectorComponent; - line: LineComponent; - reason: "gesture" | "replacement"; -} - -export interface EdgeSyncCallbacks { - onEdgeConnect?: (event: EdgeConnectIntentEvent) => void; - onEdgeDisconnect?: (event: EdgeDisconnectIntentEvent) => void; -} - -type EdgeSyncEngine = { global: { data: any } | null }; - -export interface EdgeSyncConfig { - engine: EdgeSyncEngine; - // Maps a connector to its semantic endpoint, or null for connectors that - // are not part of the consumer's edge document (their lines are left - // untouched by sync and never produce intents). - identity: (connector: ConnectorComponent) => EdgeEndpoint | null; - // The consumer's current edge list — the single source of truth. Consulted - // fresh on every sync; the controller never stores edges. - getEdges: () => readonly EdgeLike[]; - callbacks?: EdgeSyncCallbacks; -} - -// Control-character separator: consumer node/port ids are free to contain -// ':' or other printable punctuation. -const KEY_SEPARATOR = ""; - -function endpointKey(endpoint: EdgeEndpoint): string { - return endpoint.node + KEY_SEPARATOR + endpoint.port; -} - -function edgeKey(from: EdgeEndpoint, to: EdgeEndpoint): string { - return endpointKey(from) + KEY_SEPARATOR + endpointKey(to); -} - -// Controlled-edges controller: the consumer owns the edge document, SnapLine -// owns keeping rendered lines reconciled to it and translating gestures into -// semantic intents. -// -// Contract: gesture connects and gesture/replacement disconnects forward as -// intents; programmatic, hydration, and teardown changes never do. Intents -// fire synchronously inside the drop dispatch — a consumer that writes its -// document synchronously in the intent (and whose adapter reconciles in the -// same task) keeps the accept AND reject paths paint-atomic. `sync()` is -// idempotent, skips in-flight drag lines, and never forwards intents for its -// own mutations. -export class EdgeSyncController { - #config: EdgeSyncConfig; - #syncing = false; - #syncQueued = false; - #disposed = false; - - constructor(config: EdgeSyncConfig) { - this.#config = config; - const manager = getNodeManager(config.engine); - if (manager.edgeSync && manager.edgeSync !== this) { - console.warn( - "SnapLine: replacing an existing EdgeSyncController for this engine.", - ); - } - manager.edgeSync = this; - } - - get callbacks(): EdgeSyncCallbacks { - return this.#config.callbacks ?? {}; - } - - dispose(): void { - this.#disposed = true; - const manager = getNodeManager(this.#config.engine); - if (manager.edgeSync === this) manager.edgeSync = null; - } - - // @internal Called by NodeManager when a connector registers after this - // controller exists (a node mounted). Coalesced into one microtask so a - // mounting batch reconciles once, before the frame paints. - connectorRegistered(): void { - if (this.#syncQueued) return; - this.#syncQueued = true; - queueMicrotask(() => { - this.#syncQueued = false; - if (!this.#disposed) this.sync(); - }); - } - - // Reconcile rendered lines to the consumer's edge list. Safe to call at any - // time: in-flight drag lines (no target yet) and foreign lines (either - // endpoint without identity) are left alone, and mutations made here never - // forward as intents. - sync(): void { - if (this.#syncing) return; - this.#syncing = true; - try { - const manager = getNodeManager(this.#config.engine); - const identity = this.#config.identity; - const connectors = manager.connectors; - - const identities = new Map(); - const byKey = new Map(); - for (const connector of connectors) { - const endpoint = identity(connector); - identities.set(connector, endpoint); - if (endpoint) byKey.set(endpointKey(endpoint), connector); - } - - const edges = this.#config.getEdges(); - const edgeKeys = new Set( - edges.map((edge) => edgeKey(edge.from, edge.to)), - ); - - for (const connector of connectors) { - const fromEndpoint = identities.get(connector); - if (!fromEndpoint) continue; - for (const line of [...connector.outgoingLines]) { - if (line.isDeleteRequested) continue; - const target = line.target; - if (!target) continue; // in-flight drag line - const toEndpoint = identities.get(target) ?? identity(target); - if (!toEndpoint) continue; // foreign line — not ours to manage - if (!edgeKeys.has(edgeKey(fromEndpoint, toEndpoint))) { - const index = connector.outgoingLines.indexOf(line); - if (index !== -1) connector.deleteLine(index, "programmatic"); - } - } - } - - for (const edge of edges) { - const from = byKey.get(endpointKey(edge.from)); - const to = byKey.get(endpointKey(edge.to)); - if (!from || !to) continue; // endpoint not mounted yet; next sync - const exists = from.outgoingLines.some( - (line) => !line.isDeleteRequested && line.target === to, - ); - if (exists) continue; - // Document-driven restoration. A false return means a canConnect - // predicate rejected the edge; the consumer owns document validity, - // so leave the document alone. - from.connectToConnector({ target: to, origin: "hydration" }); - } - } finally { - this.#syncing = false; - } - } - - // @internal Called from ConnectorComponent's emit sites. - notifyConnect(event: ConnectorConnectionEvent): void { - if (this.#syncing || event.origin !== "gesture") return; - const endpoints = this.#endpoints(event.source, event.target); - if (!endpoints) return; - this.callbacks.onEdgeConnect?.({ - ...endpoints, - line: event.line, - origin: "gesture", - }); - } - - // @internal Called from ConnectorComponent's emit sites. - notifyDisconnect(event: ConnectorDisconnectionEvent): void { - if (this.#syncing) { - if (event.reason === "replacement") { - console.warn( - "SnapLine EdgeSync: sync evicted a live line via replacement — " + - "the edge document exceeds a connector's incoming capacity.", - ); - } - return; - } - if (event.reason !== "gesture" && event.reason !== "replacement") return; - const endpoints = this.#endpoints(event.source, event.target); - if (!endpoints) return; - this.callbacks.onEdgeDisconnect?.({ - ...endpoints, - line: event.line, - reason: event.reason, - }); - } - - #endpoints( - source: ConnectorComponent, - target: ConnectorComponent, - ): { - from: EdgeEndpoint; - to: EdgeEndpoint; - source: ConnectorComponent; - target: ConnectorComponent; - } | null { - // ConnectorPairEvent's source/target are already in wire direction. - const from = this.#config.identity(source); - const to = this.#config.identity(target); - return from && to ? { from, to, source, target } : null; - } -} diff --git a/assets/snapline/core/src/geometry.ts b/assets/snapline/core/src/geometry.ts new file mode 100644 index 0000000..30e6ed7 --- /dev/null +++ b/assets/snapline/core/src/geometry.ts @@ -0,0 +1,2 @@ +/** Imperative presentation sink for high-frequency geometry. */ +export type GeometryWriter = (geometry: Readonly) => void; diff --git a/assets/snapline/core/src/graph-mirror.ts b/assets/snapline/core/src/graph-mirror.ts new file mode 100644 index 0000000..9055558 --- /dev/null +++ b/assets/snapline/core/src/graph-mirror.ts @@ -0,0 +1,334 @@ +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" + | "missing-node" + | "missing-connector" + | "capacity-exceeded" + | "connection-rejected" + | "identity-changed" + | "unrepresentable-line"; + lineId?: LineId; + nodeId?: NodeId; + connectorId?: ConnectorId; + message: string; + cause?: unknown; +} + +// 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. */ + 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", + global: { createId(): string }, +): string { + return `${kind}-${global.createId()}`; +} + +// Engine-scoped registry of every live SnapLine runtime mirror — the mirror +// of the whole graph (nodes, connectors, settled lines, previews). +// +// Created lazily by `getGraphMirror(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()`. +// +// Domain-identity indexes follow a first-registration-wins policy: a +// 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 { + readonly engine: unknown; + #nodes = new Set(); + #connectors = new Set(); + #nodesById = new Map(); + #connectorsById = new Map(); + #linesById = new Map(); + #previewLines = new Set(); + #duplicateErrors = new Map(); + #reconciliationErrors: readonly ReconciliationError[] = []; + + // Engine-scoped controlled-lines reconciler, installed by + // attachControlledGraph(); the connector emit sites and the scheduler + // 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; + + #reconciliationQueued = false; + #batchDepth = 0; + #batchDirty = false; + + /** + * Coalescing, batch-aware reconciliation trigger: registrations, canonical + * snapshot changes, and dispatched requests all funnel here. One microtask + * pass per burst; open batches defer the pass to the outermost `end()`. + */ + scheduleReconciliation(): void { + if (this.#batchDepth > 0) { + this.#batchDirty = true; + return; + } + if (this.#reconciliationQueued) return; + this.#reconciliationQueued = true; + queueMicrotask(() => { + if (!this.#reconciliationQueued) return; // flushed synchronously + this.#reconciliationQueued = false; + this.reconciler?.reconcile?.(); + }); + } + + /** Run any pending (or batch-deferred) reconciliation synchronously. */ + flush(): void { + this.#reconciliationQueued = false; + this.#batchDirty = false; + this.reconciler?.reconcile?.(); + } + + /** + * Open a bulk boundary: no partial reconciliation runs until the outermost + * `end()`, which schedules one final pass if anything went dirty. Batches + * nest; `end()` is idempotent. + */ + beginBatch(): GraphBatch { + this.#batchDepth += 1; + let closed = false; + return { + end: () => { + if (closed) return; + closed = true; + this.#batchDepth -= 1; + if (this.#batchDepth === 0 && this.#batchDirty) { + this.#batchDirty = false; + this.scheduleReconciliation(); + } + }, + }; + } + + /** Scoped batch — exception-safe by construction. */ + async runBatch(fn: () => T | Promise): Promise { + const batch = this.beginBatch(); + try { + return await fn(); + } finally { + batch.end(); + } + } + + // ---- Engine-scoped interaction state (formerly cross-engine arrays on + // ---- global.data). Live containers mutated in place by their owners. + + /** Currently-selected nodes (multi-select drag moves all of them). */ + readonly selection: NodeMirror[] = []; + /** Live groups on this engine, in registration order. The type-only group + * import keeps node.ts (which reads this) free of any group value import. */ + readonly groups: GroupNodeMirror[] = []; + /** The node mid-resize, so an unrelated pointerUp doesn't click-select. */ + resizingNode: NodeMirror | null = null; + /** Direct parent group per node — settled geometric membership. */ + readonly parentGroups = new WeakMap(); + /** Optional membership resolver overriding the smallest-eligible default. */ + membershipResolver: GroupMembershipResolver | null = null; + /** Re-entrancy guard for group membership reconciliation. */ + reconcilingMembership = false; + + constructor(engine: unknown) { + this.engine = engine; + } + + registerNode(node: NodeMirror): void { + this.#nodes.add(node); + this.#index(this.#nodesById, node.nodeId, node, { nodeId: node.nodeId }); + } + + unregisterNode(node: NodeMirror): void { + this.#nodes.delete(node); + this.#unindex(this.#nodesById, node.nodeId, node, this.#nodes, (n) => n.nodeId); + } + + registerConnector(connector: ConnectorMirror): void { + this.#connectors.add(connector); + this.#index(this.#connectorsById, connector.connectorId, connector, { + connectorId: connector.connectorId, + }); + this.scheduleReconciliation(); + } + + unregisterConnector(connector: ConnectorMirror): void { + this.#connectors.delete(connector); + this.#unindex( + this.#connectorsById, + connector.connectorId, + connector, + this.#connectors, + (c) => c.connectorId, + ); + // A departed endpoint can make canonical lines latent; let the reconciler + // converge (idempotent — teardown already removed the mirror's lines). + this.scheduleReconciliation(); + } + + /** Every line starts life as a preview until it settles against a target. */ + registerLine(line: LineMirror): void { + this.#previewLines.add(line); + } + + /** A line gained a settled target: index it by its stable line ID. */ + settleLine(line: LineMirror): void { + this.#previewLines.delete(line); + this.#index(this.#linesById, line.lineId, line, { lineId: line.lineId }); + } + + /** A settled line lost its target (reconnect pickup): back to preview. */ + unsettleLine(line: LineMirror): void { + if (this.#linesById.get(line.lineId) === line) { + this.#linesById.delete(line.lineId); + } + this.#duplicateErrors.delete(line); + this.#previewLines.add(line); + } + + unregisterLine(line: LineMirror): void { + this.#previewLines.delete(line); + this.#duplicateErrors.delete(line); + if (this.#linesById.get(line.lineId) !== line) return; + this.#linesById.delete(line.lineId); + // First-wins retry: promote a formerly duplicate settled line, if any. + for (const [candidate, error] of this.#duplicateErrors) { + if (error.code === "duplicate-id" && error.lineId === line.lineId) { + this.#linesById.set(line.lineId, candidate as LineMirror); + this.#duplicateErrors.delete(candidate); + return; + } + } + } + + // Live mirrors in registration order. Always copies, never internal state. + get nodes(): readonly NodeMirror[] { + return [...this.#nodes]; + } + + get connectors(): readonly ConnectorMirror[] { + return [...this.#connectors]; + } + + /** Settled lines only; previews are listed separately. */ + get lines(): readonly LineMirror[] { + return [...this.#linesById.values()]; + } + + get previewLines(): readonly LineMirror[] { + return [...this.#previewLines]; + } + + node(id: NodeId): NodeMirror | null { + return this.#nodesById.get(id) ?? null; + } + + connector(id: ConnectorId): ConnectorMirror | null { + return this.#connectorsById.get(id) ?? null; + } + + line(id: LineId): LineMirror | null { + return this.#linesById.get(id) ?? null; + } + + diagnostics(): readonly ReconciliationError[] { + return [...this.#duplicateErrors.values(), ...this.#reconciliationErrors]; + } + + /** @internal Reconciler-only: replace the derived per-pass error set. + * Returns whether the contents changed (shallow, order-insensitive on the + * code+id triple). */ + setReconciliationErrors(errors: readonly ReconciliationError[]): boolean { + const key = (error: ReconciliationError) => + `${error.code}|${error.lineId ?? ""}|${error.connectorId ?? ""}|${error.nodeId ?? ""}`; + const previous = this.#reconciliationErrors.map(key).sort(); + const next = errors.map(key).sort(); + this.#reconciliationErrors = [...errors]; + return ( + previous.length !== next.length || + previous.some((entry, index) => entry !== next[index]) + ); + } + + #index( + map: Map, + id: string, + item: T, + ref: Pick, + ): void { + const existing = map.get(id); + if (existing === undefined) { + map.set(id, item); + return; + } + if (existing === item) return; + this.#duplicateErrors.set(item, { + code: "duplicate-id", + ...ref, + message: `SnapLine: duplicate id "${id}" — the first registration keeps the index entry; this mirror stays unindexed until the conflict resolves.`, + }); + } + + #unindex( + map: Map, + id: string, + item: T, + live: Set, + idOf: (item: T) => string, + ): void { + this.#duplicateErrors.delete(item); + if (map.get(id) !== item) return; + map.delete(id); + // First-wins retry: if a formerly duplicate live mirror carries this id, + // it takes over the index entry and its diagnostic clears. + for (const candidate of live) { + if (candidate !== item && idOf(candidate) === id) { + map.set(id, candidate); + this.#duplicateErrors.delete(candidate); + return; + } + } + } + +} diff --git a/assets/snapline/core/src/group.ts b/assets/snapline/core/src/group.ts index 40b5573..728cdb0 100644 --- a/assets/snapline/core/src/group.ts +++ b/assets/snapline/core/src/group.ts @@ -3,8 +3,8 @@ import type { Engine, eventPosition, } from "@snap-engine/core"; -import { NodeComponent, mergeConfig, type NodeConfig } from "./node"; -import { getGroups, snapData } from "./snapline-globals"; +import { NodeMirror, mergeConfig, type NodeConfig } from "./node"; +import { getGraphMirror } from "./snapline-globals"; export interface GroupConfig extends NodeConfig { width?: number; @@ -15,18 +15,18 @@ export interface GroupConfig extends NodeConfig { } export interface GroupContainEvent { - group: GroupNodeComponent; - node: NodeComponent; + group: GroupNodeMirror; + node: NodeMirror; centerContained: boolean; boundsContained: boolean; } export interface GroupMembershipEvent { - group: GroupNodeComponent; - added: readonly NodeComponent[]; - removed: readonly NodeComponent[]; + group: GroupNodeMirror; + added: readonly NodeMirror[]; + removed: readonly NodeMirror[]; /** Direct members only. Use `group.descendants` for the complete subtree. */ - members: readonly NodeComponent[]; + members: readonly NodeMirror[]; } export interface GroupCallbacks { @@ -34,15 +34,15 @@ export interface GroupCallbacks { } export interface GroupMembershipResolutionEvent { - node: NodeComponent; + node: NodeMirror; /** Safe eligible candidates, ordered from innermost to outermost. */ - candidates: readonly GroupNodeComponent[]; - defaultParent: GroupNodeComponent | null; + candidates: readonly GroupNodeMirror[]; + defaultParent: GroupNodeMirror | null; } export type GroupMembershipResolver = ( event: GroupMembershipResolutionEvent, -) => GroupNodeComponent | null; +) => GroupNodeMirror | null; const DEFAULT_GROUP_CONFIG = { width: 400, @@ -51,12 +51,8 @@ const DEFAULT_GROUP_CONFIG = { minHeight: 120, } satisfies GroupConfig; -const parentGroups = new WeakMap(); -const membershipResolvers = new WeakMap(); -const reconcilingEngines = new WeakSet(); - type Bounds = ReturnType< - NodeComponent["hitBox"]["getWorldBoundsSnapshot"] + NodeMirror["hitBox"]["getWorldBoundsSnapshot"] >; function boundsArea(bounds: Bounds): number { @@ -74,8 +70,8 @@ function containsBounds(container: Bounds, child: Bounds): boolean { } function stableGroupOrder( - left: GroupNodeComponent, - right: GroupNodeComponent, + left: GroupNodeMirror, + right: GroupNodeMirror, ): number { const areaDelta = boundsArea(left.hitBox.getWorldBoundsSnapshot()) - @@ -83,29 +79,21 @@ function stableGroupOrder( return areaDelta || String(left.id).localeCompare(String(right.id)); } -function groupsForEngine(group: GroupNodeComponent): GroupNodeComponent[] { - return getGroups(group.global).filter( - (candidate): candidate is GroupNodeComponent => - candidate instanceof GroupNodeComponent && - candidate.engine === group.engine, - ); +function groupsForEngine(group: GroupNodeMirror): GroupNodeMirror[] { + return [...getGraphMirror(group.engine).groups]; } -function nodesForEngine(group: GroupNodeComponent): NodeComponent[] { - const table = group.global.getEngineObjectTable(group.engine); - return Object.values(table).filter( - (object): object is NodeComponent => object instanceof NodeComponent, - ); +function nodesForEngine(group: GroupNodeMirror): NodeMirror[] { + return [...getGraphMirror(group.engine).nodes]; } function resolveParent( - node: NodeComponent, - candidates: GroupNodeComponent[], - engine: object, -): GroupNodeComponent | null { + node: NodeMirror, + candidates: GroupNodeMirror[], + resolver: GroupMembershipResolver | null, +): GroupNodeMirror | null { candidates.sort(stableGroupOrder); const defaultParent = candidates[0] ?? null; - const resolver = membershipResolvers.get(engine); if (!resolver) return defaultParent; const resolved = resolver({ node, candidates, defaultParent }); @@ -119,12 +107,12 @@ function resolveParent( } function wouldCreateGroupCycle( - node: GroupNodeComponent, - parent: GroupNodeComponent, - nextParents: Map, + node: GroupNodeMirror, + parent: GroupNodeMirror, + nextParents: Map, ): boolean { - let ancestor: GroupNodeComponent | undefined = parent; - const visited = new Set(); + let ancestor: GroupNodeMirror | undefined = parent; + const visited = new Set(); while (ancestor && !visited.has(ancestor)) { if (ancestor === node) return true; visited.add(ancestor); @@ -134,25 +122,25 @@ function wouldCreateGroupCycle( } function reconcileMembership( - source: GroupNodeComponent, + source: GroupNodeMirror, fireDelta: boolean, ): void { - const engine = source.engine as object; - if (reconcilingEngines.has(engine)) return; - reconcilingEngines.add(engine); + const mirror = getGraphMirror(source.engine); + if (mirror.reconcilingMembership) return; + mirror.reconcilingMembership = true; try { const groups = groupsForEngine(source); const nextMembers = new Map< - GroupNodeComponent, - Set + GroupNodeMirror, + Set >(groups.map((group) => [group, new Set()])); - const nextParents = new Map(); + const nextParents = new Map(); const nodes = nodesForEngine(source); const groupNodes = [...groups].sort(stableGroupOrder); const ordinaryNodes = nodes.filter( - (node) => !(node instanceof GroupNodeComponent), + (node) => !(node instanceof GroupNodeMirror), ); // Resolve the group forest first. A proposed edge can point at a group that @@ -165,7 +153,7 @@ function reconcileMembership( group.allowsMembership(node) && !wouldCreateGroupCycle(node, group, nextParents), ); - const parent = resolveParent(node, candidates, engine); + const parent = resolveParent(node, candidates, mirror.membershipResolver); if (!parent) continue; nextMembers.get(parent)?.add(node); nextParents.set(node, parent); @@ -177,7 +165,7 @@ function reconcileMembership( const candidates = groups.filter((group) => group.allowsMembership(node) ); - const parent = resolveParent(node, candidates, engine); + const parent = resolveParent(node, candidates, mirror.membershipResolver); if (!parent) continue; nextMembers.get(parent)?.add(node); nextParents.set(node, parent); @@ -185,7 +173,7 @@ function reconcileMembership( const deltas = groups.map((group) => { const previous = group.members; - const next = nextMembers.get(group) ?? new Set(); + const next = nextMembers.get(group) ?? new Set(); return { group, next, @@ -196,8 +184,8 @@ function reconcileMembership( for (const node of nodes) { const parent = nextParents.get(node); - if (parent) parentGroups.set(node, parent); - else parentGroups.delete(node); + if (parent) mirror.parentGroups.set(node, parent); + else mirror.parentGroups.delete(node); } for (const { group, next } of deltas) group.setResolvedMembers(next); @@ -213,15 +201,15 @@ function reconcileMembership( } } } finally { - reconcilingEngines.delete(engine); + mirror.reconcilingMembership = false; } } /** Return the node's settled, exclusive direct parent group. */ export function getParentGroup( - node: NodeComponent, -): GroupNodeComponent | null { - return parentGroups.get(node) ?? null; + node: NodeMirror, +): GroupNodeMirror | null { + return getGraphMirror(node.engine).parentGroups.get(node) ?? null; } /** @@ -232,32 +220,27 @@ export function setGroupMembershipResolver( engine: Engine, resolver: GroupMembershipResolver, ): () => void { - membershipResolvers.set(engine, resolver); + const mirror = getGraphMirror(engine); + mirror.membershipResolver = resolver; const refresh = () => { - const global = engine.global; - const source = global - ? getGroups(global).find( - (group): group is GroupNodeComponent => - group instanceof GroupNodeComponent && group.engine === engine, - ) - : undefined; - source?.refreshMembership(true); + // Any live group re-derives the whole engine's membership forest. + mirror.groups[0]?.refreshMembership(true); }; refresh(); return () => { - if (membershipResolvers.get(engine) !== resolver) return; - membershipResolvers.delete(engine); + if (mirror.membershipResolver !== resolver) return; + mirror.membershipResolver = null; refresh(); }; } // A resizable box with settled geometric membership. Membership is exclusive: // each node has one direct parent, while nested groups form a recursive tree. -class GroupNodeComponent extends NodeComponent { - #members: Set = new Set(); - #carry: NodeComponent[] = []; - #carryOrigins = new Map(); +class GroupNodeMirror extends NodeMirror { + #members: Set = new Set(); + #carry: NodeMirror[] = []; + #carryOrigins = new Map(); #carryGroupOrigin = { x: 0, y: 0 }; #groupCallbacks: GroupCallbacks; #groupConfig: GroupConfig; @@ -270,7 +253,7 @@ class GroupNodeComponent extends NodeComponent { super(engine, parent, { ...merged, resizable: true }); this.#groupConfig = merged; this.#groupCallbacks = merged.groupCallbacks ?? {}; - getGroups(this.global).push(this); + getGraphMirror(this.engine).groups.push(this); } get groupCallbacks(): GroupCallbacks { @@ -278,38 +261,38 @@ class GroupNodeComponent extends NodeComponent { } /** Direct settled members. */ - get members(): ReadonlySet { + get members(): ReadonlySet { return this.#members; } /** Every settled member below this group, recursively and without duplicates. */ - get descendants(): ReadonlySet { - const result = new Set(); - const visit = (group: GroupNodeComponent): void => { + get descendants(): ReadonlySet { + const result = new Set(); + const visit = (group: GroupNodeMirror): void => { for (const member of group.#members) { if (result.has(member)) continue; result.add(member); - if (member instanceof GroupNodeComponent) visit(member); + if (member instanceof GroupNodeMirror) visit(member); } }; visit(this); return result; } - get parentGroup(): GroupNodeComponent | null { + get parentGroup(): GroupNodeMirror | null { return getParentGroup(this); } /** @internal Used by the engine-wide exclusive-membership reconciliation. */ - setResolvedMembers(members: Set): void { + setResolvedMembers(members: Set): void { this.#members = members; } writeTransformAndLines(): void { - this.writeTransformRecursive(); + super.writeTransformAndLines(); } - allowsMembership(node: NodeComponent): boolean { + allowsMembership(node: NodeMirror): boolean { const box = this.hitBox.getWorldBoundsSnapshot(); const nodeBounds = node.hitBox.getWorldBoundsSnapshot(); const centerContained = @@ -322,7 +305,7 @@ class GroupNodeComponent extends NodeComponent { // Ordinary nodes use center containment. A nested group must fit completely // so partially overlapping peers cannot become a parent/child pair. if ( - node instanceof GroupNodeComponent ? !boundsContained : !centerContained + node instanceof GroupNodeMirror ? !boundsContained : !centerContained ) { return false; } @@ -363,11 +346,11 @@ class GroupNodeComponent extends NodeComponent { } } - containsSelectionDragNode(node: NodeComponent): boolean { + containsSelectionDragNode(node: NodeMirror): boolean { return this.descendants.has(node); } - selectionDragNodes(): NodeComponent[] { + selectionDragNodes(): NodeMirror[] { return [...new Set([this, ...this.#carry])]; } @@ -383,31 +366,24 @@ class GroupNodeComponent extends NodeComponent { y: origin.y + dy, }; } - member.schedule(() => member.writeTransformAndLines(), { - stage: "WRITE_2", - queueId: `${member.id}-transform`, - }); + member.scheduleTransformAndLines(); } this.#carry = []; this.#carryOrigins.clear(); } - destroy(): void { - snapData(this.global).groups = getGroups(this.global).filter( - (group) => group !== (this as unknown), - ); + destroy(removeElement: boolean = true): void { + const mirror = getGraphMirror(this.engine); + const index = mirror.groups.indexOf(this); + if (index >= 0) mirror.groups.splice(index, 1); for (const member of this.#carry) member.detachTransformFromGroup(); this.#carry = []; this.#carryOrigins.clear(); - parentGroups.delete(this); + mirror.parentGroups.delete(this); - const remaining = getGroups(this.global).find( - (group): group is GroupNodeComponent => - group instanceof GroupNodeComponent && group.engine === this.engine, - ); - remaining?.refreshMembership(true); - super.destroy(); + mirror.groups[0]?.refreshMembership(true); + super.destroy(removeElement); } } -export { GroupNodeComponent }; +export { GroupNodeMirror }; diff --git a/assets/snapline/core/src/index.ts b/assets/snapline/core/src/index.ts index d1c2116..7c985b0 100644 --- a/assets/snapline/core/src/index.ts +++ b/assets/snapline/core/src/index.ts @@ -1,5 +1,5 @@ export { - NodeComponent, + NodeMirror, DEFAULT_RESIZE_CURSORS, DEFAULT_RESIZE_HANDLE_THICKNESS, RESIZE_HANDLES, @@ -7,11 +7,11 @@ export { export type { NodeConfig, NodeCallbacks, - NodeDragCommitEvent, + GeometryChangeEvent, NodeDragPositionEvent, NodeLinesEvent, NodePointerEvent, - NodePosition, + NodeGeometry, ResolvedNodeDragPosition, NodeResizeEvent, NodeResizeHandleEvent, @@ -20,39 +20,45 @@ export type { ResizeHandle, SelectionMode, } from "./node"; -export { ConnectorComponent, resolveConnectorSourceAtPoint } from "./connector"; +export { ConnectorMirror, resolveConnectorSourceAtPoint } from "./connector"; export type { + ConnectionLimit, ConnectionOrigin, + ConnectionProposal, ConnectorAnchor, ConnectorAnchorEvent, ConnectorCallbacks, - ConnectorCapabilities, ConnectorCandidateEvent, ConnectorCandidate, ConnectorConfig, ConnectorConfigUpdate, ConnectorConnectionEvent, - ConnectorConnectionRequestEvent, - ConnectorConnectionRequestResult, ConnectorDisconnectionEvent, ConnectorDragEvent, ConnectorGeometrySnapshot, ConnectorHit, - ConnectorLinePhase, ConnectorNormal, ConnectorPairEvent, ConnectorPoint, ConnectorPointerEvent, ConnectorResolvedHit, ConnectorRole, + ConnectorRules, ConnectorSurfaceHitTestEvent, ConnectorSurfaceStrategy, DisconnectReason, + ResolvedConnectorRules, SnapLineMetadata, } from "./connector"; -export { LineComponent } from "./line"; +export { LineMirror } from "./line"; +export type { + LineGeometrySnapshot, + LineMirrorPhase, + LineStateSnapshot, +} from "./line"; +export type { GeometryWriter } from "./geometry"; export { - GroupNodeComponent, + GroupNodeMirror, getParentGroup, setGroupMembershipResolver, } from "./group"; @@ -64,7 +70,7 @@ export type { GroupMembershipResolutionEvent, GroupMembershipResolver, } from "./group"; -export { RectSelectComponent } from "./select"; +export { RectSelectController } from "./select"; export type { SelectCallbacks, SelectChangeEvent, @@ -77,7 +83,9 @@ export { getGroupNodes, getNodes, getSelectedNodes, + query, } from "./query"; +export type { GraphQuery } from "./query"; export { PlacementController } from "./placement"; export type { PlacementAnchor, @@ -85,19 +93,25 @@ export type { PlacementCancelEvent, PlacementConfig, PlacementEvent, + PlacementGeometrySnapshot, PlacementPoint, PlacementSize, PlacementSnapshot, } from "./placement"; -export { NodeManager } from "./node-manager"; -export type { EdgeSyncLike } from "./node-manager"; -export { getNodeManager } from "./snapline-globals"; -export { EdgeSyncController } from "./edge-sync"; export type { - EdgeConnectIntentEvent, - EdgeDisconnectIntentEvent, - EdgeEndpoint, - EdgeLike, - EdgeSyncCallbacks, - EdgeSyncConfig, -} from "./edge-sync"; + NodeId, + ConnectorId, + LineId, + ReconciliationError, + GraphBatch, +} from "./graph-mirror"; +export { attachControlledGraph } from "./snapline-globals"; +export type { + CanonicalGraphSnapshot, + ControlledGraphCallbacks, + ControlledGraphHandle, + LineChangeRequest, + LineEndpointUpdate, + LineRecord, + ProposedLine, +} from "./line-reconciler"; diff --git a/assets/snapline/core/src/line-reconciler.ts b/assets/snapline/core/src/line-reconciler.ts new file mode 100644 index 0000000..a50f0c6 --- /dev/null +++ b/assets/snapline/core/src/line-reconciler.ts @@ -0,0 +1,231 @@ +import type { LineMirror } from "./line"; +import type { + ConnectorId, + GraphMirror, + LineId, + 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[]; + originalEvent?: PointerEvent; +} + +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; +} + +// Converges the engine's line mirrors onto the cached canonical snapshot. +// Read-only with respect to canonical state: reconciliation never emits a +// change request, and it never rewrites, reorders, or deletes canonical +// records — a record the mirror cannot represent stays latent (both +// endpoints not mounted; silent) or errored (rules violation; structured +// diagnostic), and is retried when relevant state changes. +export class LineReconciler { + #mirror: GraphMirror; + #callbacks: ControlledGraphCallbacks; + #snapshot: CanonicalGraphSnapshot = { lines: [] }; + #reconciling = false; + #disposed = false; + + constructor(mirror: GraphMirror, callbacks: ControlledGraphCallbacks) { + this.#mirror = mirror; + this.#callbacks = callbacks; + } + + /** Cache the application's latest canonical snapshot and schedule one + * coalesced reconciliation pass. The only inbound canonical channel. */ + setCanonicalGraph(snapshot: CanonicalGraphSnapshot): void { + this.#snapshot = snapshot; + this.#mirror.scheduleReconciliation(); + } + + dispose(): void { + this.#disposed = true; + if (this.#mirror.reconciler === this) this.#mirror.reconciler = null; + } + + /** Forward a gesture's atomic proposal to the application. */ + dispatchLineChangeRequest(request: LineChangeRequest): void { + this.#callbacks.onLineChangeRequest(request); + } + + /** One pass: prune, preserve/retarget by stable id, create, report. */ + reconcile(): void { + 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. + const recordById = new Map(); + for (const record of this.#snapshot.lines) { + if (recordById.has(record.id)) { + errors.push({ + code: "duplicate-id", + lineId: record.id, + message: `SnapLine: canonical snapshot contains line id "${record.id}" more than once; the first record wins.`, + }); + continue; + } + recordById.set(record.id, record); + } + + // Prune settled mirrors whose record is gone, or whose source moved + // (a line's start connector is fixed at construction, so a + // fromConnectorId change recreates the mirror below). + for (const line of mirror.lines) { + const record = recordById.get(line.lineId); + if (!record || line.start.connectorId !== record.fromConnectorId) { + line.start.deleteLine(line, "programmatic"); + } + } + + // Preview lines by stable id: staged lines await this pass's decision; + // a mid-drag line leaves its record latent. + const previewById = new Map(); + for (const preview of mirror.previewLines) { + previewById.set(preview.lineId, preview); + } + + // Converge every record. + for (const record of recordById.values()) { + const existing = mirror.line(record.id); + if (existing) { + if (existing.target?.connectorId === record.toConnectorId) { + existing.setPayload(record.payload); + continue; + } + const nextTarget = mirror.connector(record.toConnectorId); + if (!nextTarget) { + // Latent until the new endpoint mounts. + existing.start.deleteLine(existing, "programmatic"); + continue; + } + const retargeted = existing.start.retargetSettledLineFromRecord( + existing, + nextTarget, + ); + if (retargeted !== true) { + existing.start.deleteLine(existing, "programmatic"); + errors.push(this.#ruleError(retargeted, record)); + } + continue; + } + + const preview = previewById.get(record.id); + if (preview) { + if (preview.phase !== "staged") continue; // mid-drag: latent + const stagedTarget = mirror.connector(record.toConnectorId); + if ( + preview.start.connectorId === record.fromConnectorId && + stagedTarget + ) { + // The canonical owner adopted the proposed id: settle the staged + // mirror in place (capacity/predicates recheck strictly). + const settled = preview.start.settleStagedLineFromRecord( + preview, + stagedTarget, + ); + if (settled !== true) { + preview.start.discardStagedLine(preview); + errors.push(this.#ruleError(settled, record)); + } else { + preview.setPayload(record.payload); + } + continue; + } + // Normalized away from the staged shape: discard, then converge + // the record like any other below. + preview.start.discardStagedLine(preview); + } + + const source = mirror.connector(record.fromConnectorId); + const target = mirror.connector(record.toConnectorId); + // A soft link whose mirror has not mounted yet is latent, not an + // error; registration schedules the retry. + if (!source || !target) continue; + + const created = source.createSettledLineFromRecord(target, record); + if (typeof created === "string") { + errors.push(this.#ruleError(created, record)); + } + } + + // A staged line whose id the canonical owner declined (or ignored — + // the adapter's post-request push still delivered a snapshot without + // it) is discarded; nothing was ever committed locally. + for (const line of mirror.previewLines) { + if (line.phase === "staged" && !recordById.has(line.lineId)) { + line.start.discardStagedLine(line); + } + } + } finally { + this.#reconciling = false; + mirror.reconcilerActive = false; + mirror.pendingGestureRequest = false; + } + + if (mirror.setReconciliationErrors(errors)) { + this.#callbacks.onDiagnosticsChanged?.(mirror.diagnostics()); + } + } + + #ruleError( + code: "capacity-exceeded" | "connection-rejected", + record: LineRecord, + ): ReconciliationError { + return { + code, + lineId: record.id, + connectorId: + code === "capacity-exceeded" ? record.toConnectorId : undefined, + message: + code === "capacity-exceeded" + ? `SnapLine: canonical line "${record.id}" exceeds a connector's capacity; the record is preserved but unrepresented.` + : `SnapLine: canonical line "${record.id}" was refused by connector rules; the record is preserved but unrepresented.`, + }; + } +} diff --git a/assets/snapline/core/src/line.ts b/assets/snapline/core/src/line.ts index bf43bbf..5e409b4 100644 --- a/assets/snapline/core/src/line.ts +++ b/assets/snapline/core/src/line.ts @@ -2,61 +2,181 @@ import { ElementObject, BaseObject } from "@snap-engine/core"; import type { ConnectorAnchor, ConnectorCandidate, - ConnectorComponent, + ConnectorMirror, ConnectorHit, - ConnectorLinePhase, ConnectorPoint, ConnectorSurfaceStrategy, } from "./connector"; +import type { GeometryWriter } from "./geometry"; +import { getGraphMirror } from "./snapline-globals"; +import { mintDomainId } from "./graph-mirror"; -class LineComponent extends ElementObject { - endWorldX: number; - endWorldY: number; +/** + * Explicit line lifetime. "staged" is a gesture that completed locally and + * awaits the canonical owner's decision (controlled mode only); preview + * phases are the same mirror, not a second entity. + */ +export type LineMirrorPhase = + | "source-start" + | "preview-free" + | "preview-target" + | "drop" + | "staged" + | "connected"; - start: ConnectorComponent; - target: ConnectorComponent | null; - payload: unknown; - startAnchor: ConnectorAnchor; - endAnchor: ConnectorAnchor; - phase: ConnectorLinePhase; - candidate: ConnectorCandidate | null; +export interface LineGeometrySnapshot { + readonly start: ConnectorAnchor; + readonly end: ConnectorAnchor; + readonly delta: Readonly<{ x: number; y: number }>; +} + +export interface LineStateSnapshot { + readonly phase: LineMirrorPhase; + readonly target: ConnectorMirror | null; + readonly candidate: ConnectorMirror | null; + readonly payload: unknown; +} - #renderCallbacks: Set<(line: LineComponent) => void>; +class LineMirror extends ElementObject { + /** Stable domain identity — minted at creation (or supplied by the + * reconciler for canonical records). Never the engine-internal + * `BaseObject.id`. */ + readonly lineId: string; + + #start: ConnectorMirror; + #target: ConnectorMirror | null = null; + #payload: unknown = undefined; + #startAnchor: ConnectorAnchor = { x: 0, y: 0 }; + #endAnchor: ConnectorAnchor = { x: 0, y: 0 }; + #phase: LineMirrorPhase = "source-start"; + #candidate: ConnectorCandidate | null = null; + #geometryWriter: GeometryWriter | null = null; + #stateCallbacks = new Set<(state: LineStateSnapshot) => void>(); #sourceStrategy: ConnectorSurfaceStrategy | null = null; #sourceHit: ConnectorHit | null = null; #targetStrategy: ConnectorSurfaceStrategy | null = null; #targetHit: ConnectorHit | null = null; #previewPosition: ConnectorPoint | null = null; - constructor(engine: any, parent: BaseObject) { + constructor(engine: any, parent: BaseObject, config: { id?: string } = {}) { super(engine, parent); + this.#start = parent as unknown as ConnectorMirror; + this.transformMode = "direct"; + this.lineId = config.id ?? mintDomainId("line", this.global); + getGraphMirror(this.engine).registerLine(this); + } - this.endWorldX = 0; - this.endWorldY = 0; + // Read-only outside the mirror's own lifecycle operations. + get start(): ConnectorMirror { + return this.#start; + } - this.start = parent as unknown as ConnectorComponent; - this.target = null; - this.payload = undefined; - this.startAnchor = { x: 0, y: 0 }; - this.endAnchor = { x: 0, y: 0 }; - this.phase = "source-start"; - this.candidate = null; - this.#renderCallbacks = new Set(); + get target(): ConnectorMirror | null { + return this.#target; + } - this.transformMode = "direct"; + get payload(): unknown { + return this.#payload; + } + + get startAnchor(): ConnectorAnchor { + return this.#startAnchor; + } + + get endAnchor(): ConnectorAnchor { + return this.#endAnchor; + } + + get endWorldX(): number { + return this.#endAnchor.x; + } + + get endWorldY(): number { + return this.#endAnchor.y; + } + + get phase(): LineMirrorPhase { + return this.#phase; + } + + get candidate(): ConnectorCandidate | null { + return this.#candidate; + } + + /** @internal Reconnect pickup: drop the target reference without the + * phase/notification side effects of clearTarget(). */ + detachTarget(): void { + this.#target = null; } - onRender(callback: (line: LineComponent) => void): () => void { - this.#renderCallbacks.add(callback); + /** @internal Controlled gesture staging: attach the drop target visually + * and await the canonical decision. No topology commitment — the line is + * not in the target's incoming list and not in the settled index. */ + stageTarget( + target: ConnectorMirror, + candidate: ConnectorCandidate | null, + strategy: ConnectorSurfaceStrategy | null, + ): void { + this.#target = target; + this.#targetStrategy = strategy ?? this.#targetStrategy; + this.#targetHit = candidate?.hit ?? this.#targetHit; + this.#candidate = null; + this.#phase = "staged"; + this.updateAnchors(); + this.#emitStateChange(); + } + + /** @internal Controlled gesture disconnect: already detached; hold the + * staged phase until the canonical decision (a rejected removal re-glues + * from the unchanged document). */ + stageForRemoval(): void { + this.#phase = "staged"; + this.#emitStateChange(); + } + + override destroy(removeElement: boolean = true): void { + getGraphMirror(this.engine).unregisterLine(this); + super.destroy(removeElement); + } + + bindGeometryWriter( + writer: GeometryWriter, + ): () => void { + this.#geometryWriter = writer; + writer(this.geometrySnapshot()); return () => { - this.#renderCallbacks.delete(callback); + if (this.#geometryWriter === writer) this.#geometryWriter = null; }; } - requestRender(): void { - for (const callback of this.#renderCallbacks) { - callback(this); - } + onStateChange(callback: (state: LineStateSnapshot) => void): () => void { + this.#stateCallbacks.add(callback); + callback(this.stateSnapshot()); + return () => this.#stateCallbacks.delete(callback); + } + + geometrySnapshot(): LineGeometrySnapshot { + const start = cloneAnchor(this.startAnchor); + const end = cloneAnchor(this.endAnchor); + return { + start, + end, + delta: { x: end.x - start.x, y: end.y - start.y }, + }; + } + + stateSnapshot(): LineStateSnapshot { + return { + phase: this.phase, + target: this.target, + candidate: this.candidate?.connector ?? null, + payload: this.payload, + }; + } + + #emitStateChange(): void { + const state = this.stateSnapshot(); + for (const callback of this.#stateCallbacks) callback(state); } setSourceSurfaceContext( @@ -71,53 +191,59 @@ class LineComponent extends ElementObject { candidate: ConnectorCandidate | null, strategy: ConnectorSurfaceStrategy | null = null, ): void { - this.candidate = candidate; + const previousConnector = this.candidate?.connector ?? null; + this.#candidate = candidate; this.#targetStrategy = strategy; this.#targetHit = candidate?.hit ?? null; - this.requestRender(); + if (previousConnector !== (candidate?.connector ?? null)) { + this.#emitStateChange(); + } } - setPhase(phase: ConnectorLinePhase): void { - if (this.phase === phase) return; - this.phase = phase; - this.requestRender(); + setPhase(phase: LineMirrorPhase): void { + if (this.#phase === phase) return; + this.#phase = phase; + this.#emitStateChange(); } setPayload(payload: unknown): void { - this.payload = payload; - this.requestRender(); + if (Object.is(this.#payload, payload)) return; + this.#payload = payload; + this.#emitStateChange(); } setPreviewPosition(position: ConnectorPoint): void { this.#previewPosition = position; - // Pointer and edge-pan updates are committed through the engine's write - // phase by ConnectorComponent. Updating the model here but deferring the - // render callback keeps the line and camera transform in the same frame; - // rendering immediately leaves the preview one camera frame behind during - // continuous edge-pan. - this.updateAnchors(false); + this.updateAnchors(); } connectTarget( - target: ConnectorComponent, + target: ConnectorMirror, candidate: ConnectorCandidate | null = this.candidate, strategy: ConnectorSurfaceStrategy | null = this.#targetStrategy, ): void { - this.target = target; + this.#target = target; this.#targetStrategy = strategy; this.#targetHit = candidate?.hit ?? this.#targetHit; - this.candidate = null; - this.phase = "connected"; + this.#candidate = null; + this.#phase = "connected"; + getGraphMirror(this.engine).settleLine(this); this.updateAnchors(); + this.#emitStateChange(); } clearTarget(): void { - this.target = null; - this.candidate = null; + const changed = + this.target !== null || + this.candidate !== null || + this.phase !== "preview-free"; + this.#target = null; + this.#candidate = null; this.#targetStrategy = null; this.#targetHit = null; - this.phase = "preview-free"; - this.requestRender(); + this.#phase = "preview-free"; + getGraphMirror(this.engine).unsettleLine(this); + if (changed) this.#emitStateChange(); } setLineStartAtConnector(): void { @@ -161,14 +287,12 @@ class LineComponent extends ElementObject { } setLineStartAnchor(anchor: ConnectorAnchor): void { - this.startAnchor = cloneAnchor(anchor); + this.#startAnchor = cloneAnchor(anchor); this.worldTransform = { x: anchor.x, y: anchor.y }; } setLineEndAnchor(anchor: ConnectorAnchor): void { - this.endAnchor = cloneAnchor(anchor); - this.endWorldX = anchor.x; - this.endWorldY = anchor.y; + this.#endAnchor = cloneAnchor(anchor); } setLinePosition( @@ -181,7 +305,7 @@ class LineComponent extends ElementObject { this.setLineEnd(endWorldX, endWorldY); } - updateAnchors(requestRender = true): void { + updateAnchors(): void { const target = this.target ?? this.candidate?.connector ?? null; if (!target) { const preview = this.#previewPosition ?? this.endAnchor; @@ -196,7 +320,6 @@ class LineComponent extends ElementObject { }); this.setLineStartAnchor(startAnchor); this.setLineEndAnchor(preview); - if (requestRender) this.requestRender(); return; } @@ -222,7 +345,6 @@ class LineComponent extends ElementObject { }); this.setLineStartAnchor(sourceAnchor); this.setLineEndAnchor(targetAnchor); - if (requestRender) this.requestRender(); } moveLineToConnectorTransform(): void { @@ -230,9 +352,7 @@ class LineComponent extends ElementObject { } writeTransform(): void { - // A logical/headless line can exist before a framework mounts its SVG. - if (this.element) super.writeTransform(); - this.requestRender(); + this.#geometryWriter?.(this.geometrySnapshot()); } } @@ -246,4 +366,4 @@ function cloneAnchor(anchor: ConnectorAnchor): ConnectorAnchor { }; } -export { LineComponent }; +export { LineMirror }; diff --git a/assets/snapline/core/src/node-manager.ts b/assets/snapline/core/src/node-manager.ts deleted file mode 100644 index b45ce45..0000000 --- a/assets/snapline/core/src/node-manager.ts +++ /dev/null @@ -1,69 +0,0 @@ -import type { - ConnectorComponent, - ConnectorConnectionEvent, - ConnectorDisconnectionEvent, -} from "./connector"; -import type { NodeComponent } from "./node"; - -// Structural stand-in for EdgeSyncController so the manager (and the emit -// sites that reach it) never value-import edge-sync — edge-sync imports the -// manager accessor, not the reverse. -export interface EdgeSyncLike { - notifyConnect(event: ConnectorConnectionEvent): void; - notifyDisconnect(event: ConnectorDisconnectionEvent): void; - /** A connector registered after the controller — reconcile so document - * edges whose endpoints just mounted get their lines. */ - connectorRegistered?(): void; -} - -// Engine-scoped registry of every live SnapLine node and connector. -// -// Created lazily by `getNodeManager(engine)` the first time any SnapLine -// component registers, so it exists exactly when SnapLine is in use — no -// adapter wiring required, vanilla consumers included. Components register in -// their constructors and unregister in `destroy()`. -// -// Beyond enumeration (which `query.ts` delegates to), the manager is the home -// for engine-scoped SnapLine facilities: the controlled-edges controller -// today (`edgeSync`), layout helpers that need to walk `nodes` tomorrow. -export class NodeManager { - readonly engine: unknown; - #nodes = new Set(); - #connectors = new Set(); - - // Engine-scoped controlled-edges controller. Registered by - // EdgeSyncController's constructor; the connector emit sites forward - // connection events through it. - edgeSync: EdgeSyncLike | null = null; - - constructor(engine: unknown) { - this.engine = engine; - } - - registerNode(node: NodeComponent): void { - this.#nodes.add(node); - } - - unregisterNode(node: NodeComponent): void { - this.#nodes.delete(node); - } - - registerConnector(connector: ConnectorComponent): void { - this.#connectors.add(connector); - this.edgeSync?.connectorRegistered?.(); - } - - unregisterConnector(connector: ConnectorComponent): void { - this.#connectors.delete(connector); - } - - // Live nodes in registration order. Returns a copy, never internal state. - get nodes(): readonly NodeComponent[] { - return [...this.#nodes]; - } - - // Live connectors in registration order. Returns a copy. - get connectors(): readonly ConnectorComponent[] { - return [...this.#connectors]; - } -} diff --git a/assets/snapline/core/src/node.ts b/assets/snapline/core/src/node.ts index dd0dc24..5840d5c 100644 --- a/assets/snapline/core/src/node.ts +++ b/assets/snapline/core/src/node.ts @@ -1,9 +1,9 @@ import { BaseObject, ElementObject } from "@snap-engine/core"; import { - ConnectorComponent, + ConnectorMirror, resolveConnectorSourceAtPoint, } from "./connector"; -import { LineComponent } from "./line"; +import { LineMirror } from "./line"; import type { pointerUpProp, pointerDownProp, @@ -14,7 +14,8 @@ import type { pointerMoveProp, } from "@snap-engine/core"; import { RectCollider } from "@snap-engine/core/collision"; -import { getSelectList, getGroups, getNodeManager, getResizeHandles, snapData } from "./snapline-globals"; +import { getGraphMirror, getResizeHandles, snapData } from "./snapline-globals"; +import { mintDomainId } from "./graph-mirror"; import type { SnapLineMetadata } from "./connector"; export type ResizeHandle = "n" | "ne" | "e" | "se" | "s" | "sw" | "w" | "nw"; @@ -42,6 +43,12 @@ export const DEFAULT_RESIZE_CURSORS: Readonly> = { }; 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; /** Enables resize handles. All four sides and corners are enabled by default. */ resizable?: boolean; @@ -65,7 +72,9 @@ export interface NodeConfig { /** Shared by core hitboxes and adapter resize-handle visuals. */ export const DEFAULT_RESIZE_HANDLE_THICKNESS = 14; -const DEFAULT_NODE_CONFIG: Required = { +// `id` is identity, not configuration — read once in the constructor, never +// defaulted or merged. +const DEFAULT_NODE_CONFIG: Required> = { lockPosition: false, resizable: false, minWidth: 0, @@ -91,25 +100,30 @@ export function mergeConfig(defaults: T, config: Partial): /** Consumer policy and lifecycle surfaces. Callbacks receive event objects so * new context can be added without growing positional signatures. */ -export interface NodePosition { - node: NodeComponent; +/** One node's settled geometry — the app persists what it wants. */ +export interface NodeGeometry { + node: NodeMirror; x: number; y: number; + width: number; + height: number; +} + +/** Batched geometry observation: a group/multi-select drag stays one event + * (every moved node in `nodes`); a resize reports a single entry. */ +export interface GeometryChangeEvent { + nodes: readonly NodeGeometry[]; } export interface NodePointerEvent { - node: NodeComponent; + node: NodeMirror; pointerId: number; position: eventPosition; originalEvent?: PointerEvent; } -export interface NodeDragCommitEvent extends NodePointerEvent { - nodes: NodePosition[]; -} - export interface NodeDragPositionEvent { - node: NodeComponent; + node: NodeMirror; x: number; y: number; startX: number; @@ -123,7 +137,7 @@ export interface ResolvedNodeDragPosition { } export interface NodeResizeEvent { - node: NodeComponent; + node: NodeMirror; handle: ResizeHandle | null; x: number; y: number; @@ -132,29 +146,29 @@ export interface NodeResizeEvent { } export interface NodeResizeHandleEvent { - node: NodeComponent; + node: NodeMirror; handle: ResizeHandle | null; cursor: string | null; } export interface NodeSelectionEvent { - node: NodeComponent; + node: NodeMirror; selected: boolean; - selection: readonly NodeComponent[]; + selection: readonly NodeMirror[]; } export type SelectionMode = "replace" | "add" | "toggle"; export interface NodeSelectionModeEvent { - node: NodeComponent; + node: NodeMirror; selected: boolean; - selection: readonly NodeComponent[]; + selection: readonly NodeMirror[]; originalEvent: PointerEvent; } export interface NodeLinesEvent { - node: NodeComponent; - lines: readonly LineComponent[]; + node: NodeMirror; + lines: readonly LineMirror[]; } export interface NodeCallbacks { @@ -167,12 +181,13 @@ export interface NodeCallbacks { resolveSelectionMode?: (event: NodeSelectionModeEvent) => SelectionMode; onDragStart?: (event: NodePointerEvent) => void; onDrag?: (event: NodePointerEvent) => void; - onDragCommit?: (event: NodeDragCommitEvent) => void; + /** 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; onSelectionChange?: (event: NodeSelectionEvent) => void; - /** Live size updates during a resize drag — the adapter renders width/height. */ + /** Observes live size updates; core writes the retained element geometry. */ onSizeChange?: (event: NodeResizeEvent) => void; - /** Final size at resize-drag end — the consumer persists it. */ - onResizeCommit?: (event: NodeResizeEvent) => void; /** The resize handle currently hovered, or null after leaving it. */ onResizeHandleChange?: (event: NodeResizeHandleEvent) => void; /** The set of outgoing lines changed — the adapter re-renders its line list. */ @@ -186,7 +201,7 @@ class ResizeHandleCollider extends RectCollider { constructor( engine: any, - parent: NodeComponent, + parent: NodeMirror, handle: ResizeHandle, cursor: string, ) { @@ -198,7 +213,7 @@ class ResizeHandleCollider extends RectCollider { class ResizeHoverController extends BaseObject { #count = 0; - #node: NodeComponent | null = null; + #node: NodeMirror | null = null; #handle: ResizeHandleCollider | null = null; #target: HTMLElement | null = null; #previousNodeCursor = ""; @@ -214,7 +229,7 @@ class ResizeHoverController extends BaseObject { this.#count++; } - release(node: NodeComponent): void { + release(node: NodeMirror): void { this.#count--; if (this.#node === node) this.clear(); if (this.#count <= 0) { @@ -224,7 +239,7 @@ class ResizeHoverController extends BaseObject { } activate(handle: ResizeHandleCollider, target?: EventTarget | null): void { - const node = handle.parent as NodeComponent; + const node = handle.parent as NodeMirror; const element = target instanceof HTMLElement ? target : null; if (this.#handle === handle && this.#target === element) return; this.#restoreCss(); @@ -290,7 +305,7 @@ function hoverController(engine: any): ResizeHoverController { function findResizeHandle( engine: any, position: eventPosition, - node?: NodeComponent, + node?: NodeMirror, ): ResizeHandleCollider | null { let winner: ResizeHandleCollider | null = null; for (const collider of getResizeHandles(engine.global)) { @@ -304,27 +319,28 @@ function findResizeHandle( return winner; } -class NodeComponent extends ElementObject { - #config: Required; - _connectors: { [key: string]: ConnectorComponent }; - _components: { [key: string]: ElementObject }; - _dragStartX = 0; - _dragStartY = 0; - _prop: { [key: string]: any }; - _propSetCallback: { [key: string]: (value: any) => void }; +class NodeMirror extends ElementObject { + /** Stable domain identity — supplied via `NodeConfig.id` or minted. Never + * the engine-internal `BaseObject.id`. */ + readonly nodeId: string; + #config: Required>; + /** @internal Name-keyed live connectors; written by ConnectorMirror's + * assignToNode/destroy — the one deliberate cross-class field. */ + _connectors: { [key: string]: ConnectorMirror }; + #dragStartX = 0; + #dragStartY = 0; _nodeStyle: any; #hitBox: RectCollider; - _selected: boolean; - _mouseDownX: number; - _mouseDownY: number; + #mouseDownX: number; + #mouseDownY: number; _hasMoved: boolean; #resizeHitBoxes = new Map(); #resizeHandles: readonly ResizeHandle[]; #resizeHandleThickness: number; #resizeHoverController: ResizeHoverController | null = null; #activeResizeHandle: ResizeHandle | null = null; - /** Read by GroupNodeComponent to distinguish a resize from a move drag. */ - protected _resizing = false; + /** Read by GroupNodeMirror to distinguish a resize from a move drag. */ + #resizing = false; #resizeArmed = false; #resizeStartW = 0; #resizeStartH = 0; @@ -334,8 +350,8 @@ class NodeComponent extends ElementObject { #edgePanPointerId: number | null = null; #dragHandles = new Set(); #dragPointerId: number | null = null; - #dragRoots: NodeComponent[] = []; - #dragCommitNodes: NodeComponent[] = []; + #dragRoots: NodeMirror[] = []; + #dragCommitNodes: NodeMirror[] = []; #lastDragPosition: eventPosition | null = null; #pointerSelectionMode: SelectionMode = "replace"; #selectedAtPointerDown = false; @@ -344,7 +360,8 @@ class NodeComponent extends ElementObject { super(engine, parent); this.#config = mergeConfig(DEFAULT_NODE_CONFIG, config); this.#callbacks = this.#config.callbacks; - getNodeManager(this.engine).registerNode(this); + this.nodeId = config.id ?? mintDomainId("node", this.global); + getGraphMirror(this.engine).registerNode(this); const resizeEnabled = config.resizable === true || config.resizeHandles !== undefined; this.#resizeHandles = !resizeEnabled ? [] @@ -358,13 +375,10 @@ class NodeComponent extends ElementObject { DEFAULT_RESIZE_HANDLE_THICKNESS; this._connectors = {}; - this._components = {}; - this._dragStartX = this.worldTransform.x; - this._dragStartY = this.worldTransform.y; - this._mouseDownX = 0; - this._mouseDownY = 0; - this._prop = {}; - this._propSetCallback = {}; + this.#dragStartX = this.worldTransform.x; + this.#dragStartY = this.worldTransform.y; + this.#mouseDownX = 0; + this.#mouseDownY = 0; this.transformMode = "direct"; this.event.input.pointerDown = this.onCursorDown; @@ -395,21 +409,18 @@ class NodeComponent extends ElementObject { this.#positionResizeHitBoxes(0, 0); } - this._selected = false; this._hasMoved = false; // Whenever the DOM box changes size (ResizeObserver) re-measure + re-glue. - this.event.dom.onResize = () => this.syncDomGeometry(); + this.event.dom.onResize = () => this.remeasureDomGeometry(); // Base positioning styles are framework-owned: adapters must render the // element with `position: absolute; transform-origin: top left` (see the // ownership note in assets/snapline/AGENTS.md). - // Initialize global select list if needed - getSelectList(this.global); } - get config(): Required { + get config(): Required> { return this.#config; } @@ -445,25 +456,23 @@ class NodeComponent extends ElementObject { } setStartPositions() { - this._dragStartX = this.worldTransform.x; - this._dragStartY = this.worldTransform.y; + this.#dragStartX = this.worldTransform.x; + this.#dragStartY = this.worldTransform.y; } setSelected(selected: boolean) { - this._selected = selected; this.dataAttribute = { selected: String(selected), "snapline-state": selected ? "focus" : "idle", }; - const selectList = getSelectList(this.global); + const selectList = getGraphMirror(this.engine).selection; if (selected) { if (!selectList.includes(this)) { selectList.push(this); } } else { - snapData(this.global).select = selectList.filter( - (node) => node.id !== this.id, - ); + const index = selectList.indexOf(this); + if (index >= 0) selectList.splice(index, 1); } this.schedule(() => this.writeDom(), { stage: "WRITE_1", @@ -472,19 +481,10 @@ class NodeComponent extends ElementObject { this.#callbacks.onSelectionChange?.({ node: this, selected, - selection: [...getSelectList(this.global)], + selection: [...getGraphMirror(this.engine).selection], }); } - _filterDeletedLines(svgLines: LineComponent[]) { - for (let i = 0; i < svgLines.length; i++) { - if (svgLines[i].isDeleteRequested) { - svgLines.splice(i, 1); - i--; - } - } - } - /** Schedules a WRITE_2 write for every line on every connector of this node. */ scheduleLineWrites(): void { for (const connector of Object.values(this._connectors)) { @@ -494,18 +494,43 @@ class NodeComponent extends ElementObject { /** Synchronously writes every line on every connector (call inside a WRITE stage). */ writeLinesNow(): void { - for (const connector of Object.values(this._connectors)) { - connector.writeAllLinesNow(); + const lines = new Set([ + ...this.getAllOutgoingLines(), + ...this.getAllIncomingLines(), + ]); + for (const line of lines) { + line.moveLineToConnectorTransform(); + line.writeTransform(); } } + #transformNodeTree(): NodeMirror[] { + const nodes: NodeMirror[] = []; + const visit = (node: NodeMirror) => { + nodes.push(node); + for (const child of node.transformChildren) { + if (child instanceof NodeMirror) visit(child); + } + }; + visit(this); + return nodes; + } + + scheduleTransformAndLines(): void { + this.schedule(() => this.writeTransformRecursive(), { + stage: "WRITE_2", + queueId: `${this.id}-transform`, + }); + for (const node of this.#transformNodeTree()) node.scheduleLineWrites(); + } + // Re-measure the node box + each connector's local center (READ_1) and re-glue - // every incoming/outgoing line (WRITE_1). This is the "same handling as a move + // every incoming/outgoing line (WRITE_2). This is the "same handling as a move // plus a size re-measure": moving a node keeps connector local centers valid, // but resizing invalidates them, so they must be re-read. Shared by the // ResizeObserver and the JS-driven setSize; stable queueIds collapse a // same-frame double-fire (idempotent when it runs twice across frames). - syncDomGeometry(): void { + remeasureDomGeometry(): void { if (!this.element) { throw new Error("Cannot sync node geometry before assigning its DOM element"); } @@ -521,26 +546,13 @@ class NodeComponent extends ElementObject { }, { stage: "READ_1", queueId: `${this.id}-remeasure` }, ); - for (const line of [ - ...this.getAllOutgoingLines(), - ...this.getAllIncomingLines(), - ]) { - line.schedule( - () => { - line.moveLineToConnectorTransform(); - line.setLineEndAtConnector(); - line.writeDom(); - line.writeTransform(); - }, - { stage: "WRITE_1", queueId: `${line.id}-reglue` }, - ); - } + this.scheduleLineWrites(); } // State-only half of a size change: clamps to min and synchronously updates - // the collision footprint + resize hitbox so the hit test and group - // containment stay correct mid-drag. Never touches the DOM — the element's - // width/height are framework-owned (rendered by the adapter). + // the collision footprint + resize hitbox so hit testing and group + // containment stay correct mid-drag. `setSize` adds the scheduled DOM write; + // adapters can use this method alone when seeding external dimensions. setSizeState(width: number, height: number): void { const w = Math.max(this.#config.minWidth, width); const h = Math.max(this.#config.minHeight, height); @@ -549,13 +561,11 @@ class NodeComponent extends ElementObject { this.#positionResizeHitBoxes(w, h); } - // Drives the node's size from JS (resize handle): updates state, then asks the - // framework to render the new width/height via onSizeChange. The connector/line - // re-glue closes itself — the adapter's DOM write triggers the ResizeObserver, - // which runs syncDomGeometry AFTER the browser reflows (no handshake needed: - // the box repaint is not paint-atomic). + // Drives live resize geometry directly. The framework observes and persists + // the result, but it is not part of the pointer-move paint path. setSize(width: number, height: number, handle: ResizeHandle | null = null): void { this.setSizeState(width, height); + this.#scheduleSizeGeometryWrite(); this.#callbacks.onSizeChange?.({ node: this, handle, @@ -566,6 +576,35 @@ class NodeComponent extends ElementObject { }); } + #scheduleSizeGeometryWrite(): void { + this.schedule( + () => this.#writeSizeGeometry(), + { stage: "WRITE_1", queueId: `${this.id}-size` }, + ); + this.schedule( + () => { + if (!this.element) return; + const property = this.readDom({ unapplyTransform: false }, "READ_2"); + this.#hitBox.width = property.width; + this.#hitBox.height = property.height; + this.#positionResizeHitBoxes(property.width, property.height); + for (const connector of Object.values(this._connectors)) { + connector.measureLocalCenter("READ_2"); + } + }, + { stage: "READ_2", queueId: `${this.id}-size-measure` }, + ); + this.scheduleLineWrites(); + } + + #writeSizeGeometry(): void { + if (this.element) { + this.element.style.width = `${this.#hitBox.width}px`; + this.element.style.height = `${this.#hitBox.height}px`; + } + this.writeTransformRecursive(); + } + #positionResizeHitBoxes(width: number, height: number): void { const t = Math.max(0, this.#resizeHandleThickness); const half = t / 2; @@ -617,12 +656,6 @@ class NodeComponent extends ElementObject { : this.#resizeStartY; this.worldTransform = { x, y }; this.setSize(width, height, handle); - if (west || north) { - this.schedule(() => this.writeTransformAndLines(), { - stage: "WRITE_2", - queueId: `${this.id}-transform`, - }); - } } writeTransformAndLines(): void { @@ -636,12 +669,11 @@ class NodeComponent extends ElementObject { // line's two ends live on two different nodes). writeTransformRecursive(): void { super.writeTransformRecursive(); - this.writeLinesNow(); } // Transform-only (re)parenting used by group carry: the public/DOM graph is // left alone, so members stay flat siblings in the adapter's node list. - attachTransformToGroup(group: NodeComponent): void { + attachTransformToGroup(group: NodeMirror): void { this.setTransformParent(group, true); } @@ -705,7 +737,7 @@ class NodeComponent extends ElementObject { this.engine.input.claimPointer(e.event.pointerId); this._hasMoved = false; - const selection = [...getSelectList(this.global)]; + const selection = [...getGraphMirror(this.engine).selection]; this.#selectedAtPointerDown = selection.includes(this); this.#pointerSelectionMode = this.#callbacks.resolveSelectionMode?.({ @@ -716,7 +748,7 @@ class NodeComponent extends ElementObject { }) ?? "replace"; if (this.#pointerSelectionMode === "replace" && !this.#selectedAtPointerDown) { - for (const node of [...getSelectList(this.global)]) { + for (const node of [...getGraphMirror(this.engine).selection]) { node.setSelected(false); } this.setSelected(true); @@ -732,16 +764,16 @@ class NodeComponent extends ElementObject { onDragStart(prop: dragStartProp): void { if (this.#dragPointerId !== prop.pointerId) return; if (this.#resizeArmed) { - this._resizing = true; + this.#resizing = true; this.#resizeStartW = this.#hitBox.width; this.#resizeStartH = this.#hitBox.height; this.#resizeStartX = this.worldTransform.x; this.#resizeStartY = this.worldTransform.y; - this._mouseDownX = prop.start.x; - this._mouseDownY = prop.start.y; + this.#mouseDownX = prop.start.x; + this.#mouseDownY = prop.start.y; this._hasMoved = true; // Guard so releasing a resize over another node doesn't click-select it. - snapData(this.global).resizingNode = this; + getGraphMirror(this.engine).resizingNode = this; return; } if (!this.#config.lockPosition && this.#config.edgePan) { @@ -752,7 +784,7 @@ class NodeComponent extends ElementObject { (position) => this.#moveSelectionToPointer(position), ); } - const selected = [...getSelectList(this.global)]; + const selected = [...getGraphMirror(this.engine).selection]; this.#dragRoots = selected.filter( (node) => !selected.some( @@ -781,10 +813,10 @@ class NodeComponent extends ElementObject { console.error("Global stats is null"); return; } - if (this._resizing) { + if (this.#resizing) { this.#applyResizeDrag( - prop.position.x - this._mouseDownX, - prop.position.y - this._mouseDownY, + prop.position.x - this.#mouseDownX, + prop.position.y - this.#mouseDownY, ); return; } @@ -813,17 +845,17 @@ class NodeComponent extends ElementObject { /** @internal Hook used to build one deduplicated multi-selection drag session. */ beginSelectionDrag(position: eventPosition): void { this.setStartPositions(); - this._mouseDownX = position.x; - this._mouseDownY = position.y; + this.#mouseDownX = position.x; + this.#mouseDownY = position.y; } /** @internal Whether this node's drag behavior already carries `node`. */ - containsSelectionDragNode(_node: NodeComponent): boolean { + containsSelectionDragNode(_node: NodeMirror): boolean { return false; } /** @internal Nodes whose final positions belong to this drag root's commit. */ - selectionDragNodes(): NodeComponent[] { + selectionDragNodes(): NodeMirror[] { return [this]; } @@ -831,24 +863,21 @@ class NodeComponent extends ElementObject { finishSelectionDrag(): void {} setDragPosition(prop: dragProp) { - const dx = prop.position.x - this._mouseDownX; - const dy = prop.position.y - this._mouseDownY; - const x = this._dragStartX + dx; - const y = this._dragStartY + dy; + const dx = prop.position.x - this.#mouseDownX; + const dy = prop.position.y - this.#mouseDownY; + const x = this.#dragStartX + dx; + const y = this.#dragStartY + dy; const resolved = this.#callbacks.resolveDragPosition?.({ node: this, x, y, - startX: this._dragStartX, - startY: this._dragStartY, + startX: this.#dragStartX, + startY: this.#dragStartY, position: prop.position, }) ?? { x, y }; this.worldTransform = { x: resolved.x, y: resolved.y }; - this.schedule(() => this.writeTransformAndLines(), { - stage: "WRITE_2", - queueId: `${this.id}-transform`, - }); + this.scheduleTransformAndLines(); } onDragEnd(prop: dragEndProp) { @@ -860,27 +889,26 @@ class NodeComponent extends ElementObject { this.engine.edgePanController?.stopEdgePan(this.#edgePanPointerId); this.#edgePanPointerId = null; } - if (this._resizing) { + if (this.#resizing) { this.#applyResizeDrag( - prop.end.x - this._mouseDownX, - prop.end.y - this._mouseDownY, + prop.end.x - this.#mouseDownX, + prop.end.y - this.#mouseDownY, ); - this.#callbacks.onResizeCommit?.({ - node: this, - handle: this.#activeResizeHandle, - x: this.worldTransform.x, - y: this.worldTransform.y, - width: this.#hitBox.width, - height: this.#hitBox.height, + // 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.onGeometryChanged?.({ + nodes: [this.#geometryOf(this)], }); - this._resizing = false; + this.#resizing = false; this.#resizeArmed = false; this.#activeResizeHandle = null; - snapData(this.global).resizingNode = null; + getGraphMirror(this.engine).resizingNode = null; this.#dragPointerId = null; this.#refreshResizeHover(prop.end); // A resized node's center may have moved into/out of a group. - for (const group of getGroups(this.global)) { + for (const group of getGraphMirror(this.engine).groups) { if ((group as unknown) !== this) group.refreshMembership(true); } return; @@ -894,58 +922,55 @@ class NodeComponent extends ElementObject { } for (const node of this.#dragRoots) { node.finishSelectionDrag(); - node.schedule(() => node.writeTransformAndLines(), { - stage: "WRITE_2", - queueId: `${node.id}-transform`, - }); + node.scheduleTransformAndLines(); } // A settled node may have entered or left a group; groups re-evaluate // membership on settle (never at group-drag-start), so the maintained set is - // current before the next group drag. The structural GroupLike type keeps - // node.ts free of any group import. - for (const group of getGroups(this.global)) { + // 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) { group.refreshMembership(true); } - this.emitDragCommit(prop); + this.emitGeometryChange(); this.#dragRoots = []; this.#dragCommitNodes = []; this.#lastDragPosition = null; this.#dragPointerId = null; } - protected emitDragCommit(prop: dragEndProp): void { - this.#callbacks.onDragCommit?.({ - node: this, - pointerId: prop.pointerId, - position: prop.end, - nodes: this.getDragCommitNodes().map((node) => ({ - node, - x: node.worldTransform.x, - y: node.worldTransform.y, - })), + protected emitGeometryChange(): void { + this.#callbacks.onGeometryChanged?.({ + nodes: this.getDragCommitNodes().map((node) => this.#geometryOf(node)), }); } - protected getDragCommitNodes(): NodeComponent[] { + #geometryOf(node: NodeMirror): NodeGeometry { + return { + node, + x: node.worldTransform.x, + y: node.worldTransform.y, + width: node.hitBox.width, + height: node.hitBox.height, + }; + } + + protected getDragCommitNodes(): NodeMirror[] { return this.#dragCommitNodes.length ? [...this.#dragCommitNodes] - : [...getSelectList(this.global)]; + : [...getGraphMirror(this.engine).selection]; } setUpPosition(prop: dragEndProp) { const [dx, dy] = [ - prop.end.x - this._mouseDownX, - prop.end.y - this._mouseDownY, + prop.end.x - this.#mouseDownX, + prop.end.y - this.#mouseDownY, ]; this.worldTransform = { - x: this._dragStartX + dx, - y: this._dragStartY + dy, + x: this.#dragStartX + dx, + y: this.#dragStartY + dy, }; - this.schedule(() => this.writeTransformAndLines(), { - stage: "WRITE_2", - queueId: `${this.id}-transform`, - }); + this.scheduleTransformAndLines(); } onUp(prop: pointerUpProp) { @@ -954,7 +979,7 @@ class NodeComponent 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 (snapData(this.global).resizingNode) return; + if (getGraphMirror(this.engine).resizingNode) return; if (this.#resizeArmed) { this.#resizeArmed = false; this.#activeResizeHandle = null; @@ -964,7 +989,7 @@ class NodeComponent extends ElementObject { if (this._hasMoved == false) { if (this.#pointerSelectionMode === "replace") { - for (const node of [...getSelectList(this.global)]) { + for (const node of [...getGraphMirror(this.engine).selection]) { if (node !== this) node.setSelected(false); } this.setSelected(true); @@ -978,7 +1003,7 @@ class NodeComponent extends ElementObject { this._hasMoved = false; } - getConnector(name: string): ConnectorComponent | null { + getConnector(name: string): ConnectorMirror | null { if (!(name in this._connectors)) { console.error(`Connector ${name} does not exist in node ${this.id}`); return null; @@ -986,83 +1011,29 @@ class NodeComponent extends ElementObject { return this._connectors[name]; } - addConnectorObject(connector: ConnectorComponent) { + addConnectorObject(connector: ConnectorMirror) { connector.assignToNode(this); } - addSetPropCallback(callback: (value: any) => void, name: string) { - this._propSetCallback[name] = callback; - } - - getAllOutgoingLines(): LineComponent[] { + getAllOutgoingLines(): LineMirror[] { return Object.values(this._connectors).flatMap( (connector) => connector.outgoingLines, ); } - getAllIncomingLines(): LineComponent[] { + getAllIncomingLines(): LineMirror[] { return Object.values(this._connectors).flatMap( (connector) => connector.incomingLines, ); } - getProp(name: string) { - return this._prop[name]; - } - - setProp(name: string, value: any) { - const pending: Array<{ node: NodeComponent; name: string }> = [ - { node: this, name }, - ]; - const visited = new Map>(); - - while (pending.length > 0) { - const current = pending.pop(); - if (!current) continue; - - let visitedNames = visited.get(current.node); - if (!visitedNames) { - visitedNames = new Set(); - visited.set(current.node, visitedNames); - } - if (visitedNames.has(current.name)) continue; - visitedNames.add(current.name); - - if (current.name in current.node._propSetCallback) { - current.node._propSetCallback[current.name](value); - } - current.node._prop[current.name] = value; - - const connector = current.node._connectors[current.name]; - if (!connector) continue; - - const peers = connector.outgoingLines - .filter((line) => line.target && !line.isDeleteRequested) - .map((line) => line.target); - for (let index = peers.length - 1; index >= 0; index -= 1) { - const peer = peers[index]; - if (!peer?.parent) continue; - pending.push({ - node: peer.parent as NodeComponent, - name: peer.name, - }); - } - } - } - - propagateProp() { - for (const connector of Object.values(this._connectors)) { - this.setProp(connector.name, this.getProp(connector.name)); - } - } - #refreshResizeHover(position: eventPosition, target?: EventTarget | null): void { this.#resizeHoverController?.clear(); const handle = findResizeHandle(this.engine, position); if (handle) this.#resizeHoverController?.activate(handle, target); } - destroy() { + destroy(removeElement: boolean = true) { if (this.#edgePanPointerId != null) { this.engine.edgePanController?.stopEdgePan(this.#edgePanPointerId); this.#edgePanPointerId = null; @@ -1072,7 +1043,7 @@ class NodeComponent extends ElementObject { // disconnect — keep the reason contract honest for intent consumers. connector.deleteAllLines("teardown"); } - getNodeManager(this.engine).unregisterNode(this); + getGraphMirror(this.engine).unregisterNode(this); this.setSelected(false); if (this.#resizeHitBoxes.size > 0) { const ownedHandles = new Set(this.#resizeHitBoxes.values()); @@ -1084,8 +1055,8 @@ class NodeComponent extends ElementObject { this.#resizeHitBoxes.clear(); } this._connectors = {}; - super.destroy(); + super.destroy(removeElement); } } -export { NodeComponent }; +export { NodeMirror }; diff --git a/assets/snapline/core/src/placement.ts b/assets/snapline/core/src/placement.ts index 3a5cbac..cbda0d7 100644 --- a/assets/snapline/core/src/placement.ts +++ b/assets/snapline/core/src/placement.ts @@ -1,3 +1,5 @@ +import type { GeometryWriter } from "./geometry"; + export interface PlacementPoint { x: number; y: number; @@ -24,6 +26,16 @@ export interface PlacementSnapshot { allowed: boolean; } +export interface PlacementGeometrySnapshot { + readonly active: boolean; + readonly visible: boolean; + readonly screen: PlacementPoint | null; + readonly world: PlacementPoint | null; + readonly position: PlacementPoint | null; + readonly size: PlacementSize | null; + readonly allowed: boolean; +} + export interface PlacementEvent extends PlacementSnapshot { payload: T; screen: PlacementPoint; @@ -61,10 +73,16 @@ export interface PlacementConfig { */ export class PlacementController { #config: PlacementConfig; + #callbacks: PlacementCallbacks; #snapshot: PlacementSnapshot; + #geometryWriter: GeometryWriter | null = null; + #stateCallbacks = new Set< + (snapshot: PlacementSnapshot) => void + >(); constructor(config: PlacementConfig) { this.#config = config; + this.#callbacks = config.callbacks ?? {}; this.#snapshot = { active: false, payload: null, @@ -82,7 +100,25 @@ export class PlacementController { } get callbacks(): PlacementCallbacks { - return this.#config.callbacks ?? {}; + return this.#callbacks; + } + + bindGeometryWriter( + writer: GeometryWriter, + ): () => void { + this.#geometryWriter = writer; + writer(this.#geometrySnapshot()); + return () => { + if (this.#geometryWriter === writer) this.#geometryWriter = null; + }; + } + + onStateChange( + callback: (snapshot: PlacementSnapshot) => void, + ): () => void { + this.#stateCallbacks.add(callback); + callback(this.#snapshot); + return () => this.#stateCallbacks.delete(callback); } begin( @@ -185,5 +221,20 @@ export class PlacementController { #emitChange(): void { this.callbacks.onChange?.(this.#snapshot); + for (const callback of this.#stateCallbacks) callback(this.#snapshot); + this.#geometryWriter?.(this.#geometrySnapshot()); + } + + #geometrySnapshot(): PlacementGeometrySnapshot { + const snapshot = this.#snapshot; + return { + active: snapshot.active, + visible: snapshot.active && snapshot.position !== null, + screen: snapshot.screen ? { ...snapshot.screen } : null, + world: snapshot.world ? { ...snapshot.world } : null, + position: snapshot.position ? { ...snapshot.position } : null, + size: snapshot.size ? { ...snapshot.size } : null, + allowed: snapshot.allowed, + }; } } diff --git a/assets/snapline/core/src/query.ts b/assets/snapline/core/src/query.ts index 5101c55..6c15f49 100644 --- a/assets/snapline/core/src/query.ts +++ b/assets/snapline/core/src/query.ts @@ -1,7 +1,14 @@ -import type { ConnectorComponent } from "./connector"; -import { GroupNodeComponent } from "./group"; -import type { NodeComponent } from "./node"; -import { getNodeManager, getSelectList } from "./snapline-globals"; +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"; type EngineLike = { global: { @@ -9,30 +16,62 @@ type EngineLike = { }; }; -// Enumeration delegates to the per-engine NodeManager registry (components +/** + * Read-only view of one engine's graph mirror: snapshots and identity + * lookups, nothing mutable. The mirrors it returns are the live interactive + * runtime objects — their own mutation surface is constrained separately — + * and the facade never exposes registry sets, topology arrays, controller + * attachment, or mutation methods. + */ +export interface GraphQuery { + nodes(): readonly NodeMirror[]; + connectors(): readonly ConnectorMirror[]; + groups(): readonly GroupNodeMirror[]; + /** Settled lines; gesture previews are not part of the settled graph. */ + lines(): readonly LineMirror[]; + node(id: NodeId): NodeMirror | null; + connector(id: ConnectorId): ConnectorMirror | null; + line(id: LineId): LineMirror | null; + diagnostics(): readonly ReconciliationError[]; +} + +/** The read-only query facade for one engine's graph. */ +export function query(engine: EngineLike): GraphQuery { + const mirror = getGraphMirror(engine); + return { + nodes: () => mirror.nodes, + connectors: () => mirror.connectors, + groups: () => [...mirror.groups], + lines: () => mirror.lines, + 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 NodeComponent[] { - return getNodeManager(engine).nodes; +export function getNodes(engine: EngineLike): readonly NodeMirror[] { + return getGraphMirror(engine).nodes; } export function getConnectors( engine: EngineLike, -): readonly ConnectorComponent[] { - return getNodeManager(engine).connectors; +): readonly ConnectorMirror[] { + return getGraphMirror(engine).connectors; } export function getGroupNodes( engine: EngineLike, -): readonly GroupNodeComponent[] { - return getNodeManager(engine).nodes.filter( - (node): node is GroupNodeComponent => node instanceof GroupNodeComponent, - ); +): readonly GroupNodeMirror[] { + return [...getGraphMirror(engine).groups]; } export function getSelectedNodes( engine: EngineLike, -): readonly NodeComponent[] { - return [...getSelectList(engine.global)]; +): readonly NodeMirror[] { + return [...getGraphMirror(engine).selection]; } diff --git a/assets/snapline/core/src/select.ts b/assets/snapline/core/src/select.ts index b2d53a0..d8e303c 100644 --- a/assets/snapline/core/src/select.ts +++ b/assets/snapline/core/src/select.ts @@ -5,10 +5,11 @@ import type { pointerUpProp, } from "@snap-engine/core"; import { RectCollider, Collider } from "@snap-engine/core/collision"; -import { NodeComponent, type SelectionMode } from "./node"; -import { getSelectList, snapData } from "./snapline-globals"; +import { NodeMirror, type SelectionMode } from "./node"; +import { getGraphMirror } from "./snapline-globals"; +import type { GeometryWriter } from "./geometry"; -/** World-space rectangle the framework renders as the selection box. */ +/** World-space rectangle delivered to the registered geometry writer. */ export interface SelectRect { x: number; y: number; @@ -18,14 +19,14 @@ export interface SelectRect { } export interface SelectStartEvent { - select: RectSelectComponent; + select: RectSelectController; position: { x: number; y: number }; originalEvent: PointerEvent; } export interface SelectChangeEvent { - select: RectSelectComponent; - selection: readonly NodeComponent[]; + select: RectSelectController; + selection: readonly NodeMirror[]; } export interface SelectCallbacks { @@ -33,11 +34,9 @@ export interface SelectCallbacks { /** Consumer-defined selection policy; SnapLine owns no modifier keys. */ resolveSelectionMode?: (event: SelectStartEvent) => SelectionMode; /** - * The rubber-band rectangle changed — the FRAMEWORK renders it (position, - * size, visibility, and any custom styling). Core keeps only the pointer - * math and the selection collider; it never writes the box's DOM. This is - * deliberately a plain callback with no flush handshake: the box visual is - * not paint-atomic, so the framework may flush on its own schedule. + * Observes rubber-band rectangle changes. Adapters render live geometry + * through `bindGeometryWriter`; this callback is for application behavior, + * logging, and persistence rather than per-frame framework rendering. */ onRectChange?: (rect: SelectRect) => void; onSelectionChange?: (event: SelectChangeEvent) => void; @@ -47,14 +46,22 @@ export interface SelectConfig { callbacks?: SelectCallbacks; } -class RectSelectComponent extends ElementObject { - _state: "none" | "dragging"; - _mouseDownX: number; - _mouseDownY: number; - _selectHitBox: Collider; +class RectSelectController extends ElementObject { + #state: "none" | "dragging"; + #mouseDownX: number; + #mouseDownY: number; + #selectHitBox: Collider; #callbacks: SelectCallbacks; + #geometryWriter: GeometryWriter | null = null; + #rect: SelectRect = { + x: 0, + y: 0, + width: 0, + height: 0, + visible: false, + }; #selectionMode: SelectionMode = "replace"; - #baselineSelection = new Set(); + #baselineSelection = new Set(); constructor( engine: any, @@ -63,21 +70,22 @@ class RectSelectComponent extends ElementObject { ) { super(engine, parent); - this._state = "none"; - this._mouseDownX = 0; - this._mouseDownY = 0; + this.#state = "none"; + this.#mouseDownX = 0; + this.#mouseDownY = 0; this.event.global.pointerDown = this.onGlobalCursorDown; this.event.global.pointerMove = this.onGlobalCursorMove; this.event.global.pointerUp = this.onGlobalCursorUp; - this._selectHitBox = new RectCollider(engine, this, 0, 0, 0, 0); - this._selectHitBox.localTransform = { x: 0, y: 0 }; - this._selectHitBox.event.collider.onCollide = this.onCollideNode; + this.#selectHitBox = new RectCollider(engine, this, 0, 0, 0, 0); + this.#selectHitBox.localTransform = { x: 0, y: 0 }; + this.#selectHitBox.event.collider.onCollide = this.onCollideNode; - this.addCollider(this._selectHitBox); + this.addCollider(this.#selectHitBox); - snapData(this.global).select = []; + // A fresh selection controller starts its engine from an empty selection. + getGraphMirror(this.engine).selection.length = 0; this.#callbacks = config.callbacks ?? {}; } @@ -86,14 +94,34 @@ class RectSelectComponent extends ElementObject { return this.#callbacks; } + get rect(): Readonly { + return this.#rect; + } + + bindGeometryWriter(writer: GeometryWriter): () => void { + this.#geometryWriter = writer; + writer({ ...this.#rect }); + return () => { + if (this.#geometryWriter === writer) this.#geometryWriter = null; + }; + } + #fireRect(width: number, height: number, visible: boolean): void { - this.#callbacks.onRectChange?.({ + this.#rect = { x: this.worldTransform.x, y: this.worldTransform.y, width, height, visible, - }); + }; + this.#callbacks.onRectChange?.({ ...this.#rect }); + this.schedule( + () => this.#geometryWriter?.({ ...this.#rect }), + { + stage: "WRITE_2", + queueId: `${this.id}-geometry`, + }, + ); } onGlobalCursorDown(prop: pointerDownProp): void { @@ -108,34 +136,34 @@ class RectSelectComponent extends ElementObject { if (this.#callbacks.canStart?.(startEvent) === false) return; this.#selectionMode = this.#callbacks.resolveSelectionMode?.(startEvent) ?? "replace"; - this.#baselineSelection = new Set(getSelectList(this.global)); + this.#baselineSelection = new Set(getGraphMirror(this.engine).selection); if (this.#selectionMode === "replace") { - for (let node of [...getSelectList(this.global)]) { + // setSelected(false) removes each node from the engine's selection. + for (let node of [...getGraphMirror(this.engine).selection]) { node.setSelected(false); } - snapData(this.global).select = []; } // worldTransform positions the selection collider (its transform parent); - // the visual box is framework-rendered from the callback rect. + // the registered writer updates the visual box during WRITE_2. this.worldTransform = { x: prop.position.x, y: prop.position.y }; - this._state = "dragging"; - this._mouseDownX = prop.position.x; - this._mouseDownY = prop.position.y; - this._selectHitBox.width = 0; - this._selectHitBox.height = 0; + this.#state = "dragging"; + this.#mouseDownX = prop.position.x; + this.#mouseDownY = prop.position.y; + this.#selectHitBox.width = 0; + this.#selectHitBox.height = 0; this.#fireRect(0, 0, true); this.#callbacks.onSelectionChange?.({ select: this, - selection: [...getSelectList(this.global)], + selection: [...getGraphMirror(this.engine).selection], }); - this._selectHitBox.event.collider.onBeginContact = ( + this.#selectHitBox.event.collider.onBeginContact = ( _: Collider, otherObject: Collider, ) => { - if (otherObject.parent instanceof NodeComponent) { - let node = otherObject.parent as NodeComponent; + if (otherObject.parent instanceof NodeMirror) { + let node = otherObject.parent as NodeMirror; node.setSelected( this.#selectionMode === "toggle" ? !this.#baselineSelection.has(node) @@ -143,53 +171,53 @@ class RectSelectComponent extends ElementObject { ); this.#callbacks.onSelectionChange?.({ select: this, - selection: [...getSelectList(this.global)], + selection: [...getGraphMirror(this.engine).selection], }); } }; - this._selectHitBox.event.collider.onEndContact = ( + this.#selectHitBox.event.collider.onEndContact = ( _thisObject: Collider, otherObject: Collider, ) => { - if (otherObject.parent instanceof NodeComponent) { - let node = otherObject.parent as NodeComponent; + if (otherObject.parent instanceof NodeMirror) { + let node = otherObject.parent as NodeMirror; node.setSelected(this.#baselineSelection.has(node)); this.#callbacks.onSelectionChange?.({ select: this, - selection: [...getSelectList(this.global)], + selection: [...getGraphMirror(this.engine).selection], }); } }; } onGlobalCursorMove(prop: pointerMoveProp): void { - if (this._state === "dragging") { + if (this.#state === "dragging") { let [boxOriginX, boxOriginY] = [ - Math.min(this._mouseDownX, prop.position.x), - Math.min(this._mouseDownY, prop.position.y), + Math.min(this.#mouseDownX, prop.position.x), + Math.min(this.#mouseDownY, prop.position.y), ]; let [boxWidth, boxHeight] = [ - Math.abs(prop.position.x - this._mouseDownX), - Math.abs(prop.position.y - this._mouseDownY), + Math.abs(prop.position.x - this.#mouseDownX), + Math.abs(prop.position.y - this.#mouseDownY), ]; this.worldTransform = { x: boxOriginX, y: boxOriginY }; - this._selectHitBox.localTransform = { x: 0, y: 0 }; - this._selectHitBox.width = boxWidth; - this._selectHitBox.height = boxHeight; + this.#selectHitBox.localTransform = { x: 0, y: 0 }; + this.#selectHitBox.width = boxWidth; + this.#selectHitBox.height = boxHeight; this.#fireRect(boxWidth, boxHeight, true); } } onGlobalCursorUp(_prop: pointerUpProp): void { - const wasDragging = this._state === "dragging"; - this._state = "none"; + const wasDragging = this.#state === "dragging"; + this.#state = "none"; - this._selectHitBox.event.collider.onBeginContact = null; - this._selectHitBox.event.collider.onEndContact = null; + this.#selectHitBox.event.collider.onBeginContact = null; + this.#selectHitBox.event.collider.onEndContact = null; if (wasDragging) this.#fireRect(0, 0, false); } onCollideNode(_hitBox: Collider, _node: Collider): void {} } -export { RectSelectComponent }; +export { RectSelectController }; diff --git a/assets/snapline/core/src/snapline-globals.ts b/assets/snapline/core/src/snapline-globals.ts index 9eec984..c58b0e2 100644 --- a/assets/snapline/core/src/snapline-globals.ts +++ b/assets/snapline/core/src/snapline-globals.ts @@ -1,15 +1,11 @@ import type { RectCollider } from "@snap-engine/core/collision"; import type { eventPosition } from "@snap-engine/core"; -import type { NodeComponent } from "./node"; -import { NodeManager } from "./node-manager"; - -/** - * Structural stand-in for GroupNodeComponent so node.ts can notify groups on - * settle without importing the group module (no group→node import cycle). - */ -export interface GroupLike { - refreshMembership(fireDelta: boolean): void; -} +import { GraphMirror } from "./graph-mirror"; +import { + LineReconciler, + type ControlledGraphCallbacks, + type ControlledGraphHandle, +} from "./line-reconciler"; /** * Structural source-surface contract shared with engine input. Keeping this @@ -43,16 +39,10 @@ export interface SourceSurfaceOwner { * with this declaration. */ export interface SnapLineSharedData { - /** Currently-selected nodes (multi-select drag moves all of them). */ - select?: NodeComponent[]; - /** All live groups; notified on any node's drop so membership stays settled. */ - groups?: GroupLike[]; /** Registered resize hitboxes; input.ts routes pointerdowns over them. */ resizeHandles?: RectCollider[]; /** Registered headless source surfaces; input.ts routes pointerdowns to them. */ sourceSurfaces?: SourceSurfaceOwner[]; - /** The node mid-resize, so an unrelated pointerUp doesn't click-select. */ - resizingNode?: NodeComponent | null; /** * @deprecated Legacy camera-control boolean (last-writer-wins), read by the * camera for third-party writers only. In-repo gesture owners block the @@ -62,10 +52,12 @@ export interface SnapLineSharedData { allowCameraControl?: boolean; /** * Per-engine SnapLine registries. GlobalManager is application-wide, so the - * map is keyed by engine; `getNodeManager` lazy-creates entries the first - * time a SnapLine component registers on that engine. + * map is keyed by engine; `getGraphMirror` 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. */ - nodeManagers?: Map; + graphMirrors?: WeakMap; } /** Typed view over the untyped global data bag (cast at the boundary). */ @@ -73,18 +65,6 @@ export function snapData(global: { data: any }): SnapLineSharedData { return global.data as SnapLineSharedData; } -export function getSelectList(global: { data: any }): NodeComponent[] { - const data = snapData(global); - if (!data.select) data.select = []; - return data.select; -} - -export function getGroups(global: { data: any }): GroupLike[] { - const data = snapData(global); - if (!data.groups) data.groups = []; - return data.groups; -} - export function getResizeHandles(global: { data: any }): RectCollider[] { const data = snapData(global); if (!data.resizeHandles) data.resizeHandles = []; @@ -100,18 +80,43 @@ export function getSourceSurfaces(global: { } -export function getNodeManager(engine: { +export function getGraphMirror(engine: { global: { data: any } | null; -}): NodeManager { +}): GraphMirror { if (!engine.global) { - throw new Error("SnapLine: getNodeManager requires an initialized engine."); + throw new Error("SnapLine: getGraphMirror requires an initialized engine."); } const data = snapData(engine.global); - if (!data.nodeManagers) data.nodeManagers = new Map(); - let manager = data.nodeManagers.get(engine); - if (!manager) { - manager = new NodeManager(engine); - data.nodeManagers.set(engine, manager); + if (!data.graphMirrors) data.graphMirrors = 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.", + ); } - return manager; + const reconciler = new LineReconciler(mirror, callbacks); + mirror.reconciler = reconciler; + return { + setCanonicalGraph: (snapshot) => reconciler.setCanonicalGraph(snapshot), + flush: () => mirror.flush(), + dispose: () => reconciler.dispose(), + }; } diff --git a/assets/snapline/react/README.md b/assets/snapline/react/README.md index fab06f7..d5685e1 100644 --- a/assets/snapline/react/README.md +++ b/assets/snapline/react/README.md @@ -12,7 +12,8 @@ npm install react react-dom @snap-engine/core \ ## Components The package exports `Engine`, `Node`, `Group`, `Connector`, `Line`, `Select`, -and `Placement`. Each component is also available from a named subpath. +`Placement`, and `ControlledGraph`. Each component is also available from a +named subpath. ```tsx import { Engine, Group, Node, Select } from "@snap-engine/snapline-react"; @@ -37,7 +38,7 @@ through `Node`'s `elementProps`. Set `virtual` on `Connector` to keep the logical endpoint without rendering a port. `surfaceStrategies` can then hit-test and anchor against the parent -node's shape, while `capabilities` independently enable source and target +node's shape, while symmetric `rules` limits enable source and target behavior. Keep domain edges in React state and use an opaque line payload as the stable link from a custom renderer. diff --git a/assets/snapline/react/package.json b/assets/snapline/react/package.json index c024a11..b92e69a 100644 --- a/assets/snapline/react/package.json +++ b/assets/snapline/react/package.json @@ -18,7 +18,8 @@ "./Connector": "./src/Connector.tsx", "./Line": "./src/Line.tsx", "./Select": "./src/Select.tsx", - "./Placement": "./src/Placement.tsx" + "./Placement": "./src/Placement.tsx", + "./ControlledGraph": "./src/ControlledGraph.tsx" }, "keywords": [ "node-graph", diff --git a/assets/snapline/react/src/Connector.tsx b/assets/snapline/react/src/Connector.tsx index 9c8975b..f90545f 100644 --- a/assets/snapline/react/src/Connector.tsx +++ b/assets/snapline/react/src/Connector.tsx @@ -8,51 +8,50 @@ import { type CSSProperties, } from "react"; import { - ConnectorComponent, - LineComponent, - type ConnectorCapabilities, + ConnectorMirror, + LineMirror, + type ConnectorRules, type ConnectorCallbacks, type ConnectorSurfaceStrategy, type SnapLineMetadata, } from "@snap-engine/snapline"; import { useSnapLineEngine } from "./Engine"; -import { NodeObjectContext } from "./Node"; +import { NodeMirrorContext } from "./Node"; export interface ConnectorProps { - allowDragOut?: boolean; + /** Stable domain identity; minted when omitted (supply for persistence). */ + id?: string; className?: string; - maxConnectors?: number; name: string; style?: CSSProperties; metadata?: SnapLineMetadata; callbacks?: ConnectorCallbacks; edgePan?: boolean; - capabilities?: Partial; + rules?: Partial; surfaceStrategies?: readonly ConnectorSurfaceStrategy[]; /** Keep the logical connector without rendering a visible port element. */ virtual?: boolean; colliderRadius?: number; - lineClass?: typeof LineComponent; - connectorObject?: ConnectorComponent | null; + lineClass?: typeof LineMirror; + connectorObject?: ConnectorMirror | null; data?: Record; } export interface ConnectorRef { - object(): ConnectorComponent; + object(): ConnectorMirror; } export const Connector = forwardRef( ( { - allowDragOut = true, + id, className = "", - maxConnectors = 1, name, style, metadata = {}, callbacks = {}, edgePan = true, - capabilities, + rules, surfaceStrategies = [], virtual = false, colliderRadius, @@ -63,22 +62,21 @@ export const Connector = forwardRef( ref, ) => { const engine = useSnapLineEngine(); - const nodeObject = useContext(NodeObjectContext); + const nodeObject = useContext(NodeMirrorContext); if (!nodeObject) { throw new Error(" must be rendered inside ."); } const ownsConnectorRef = useRef(connectorObject == null); - const connectorRef = useRef(connectorObject); + const connectorRef = useRef(connectorObject); if (!connectorRef.current) { - connectorRef.current = new ConnectorComponent(engine, nodeObject, { - allowDragOut, - maxConnectors, + connectorRef.current = new ConnectorMirror(engine, nodeObject, { + id, name, + rules, metadata, callbacks, edgePan, - capabilities, surfaceStrategies, colliderRadius, lineClass, @@ -93,26 +91,22 @@ export const Connector = forwardRef( useEffect(() => { connector.updateConfig({ - allowDragOut, - maxConnectors, + rules, metadata, callbacks, edgePan, - capabilities, surfaceStrategies, colliderRadius, lineClass, }); }, [ - allowDragOut, callbacks, - capabilities, colliderRadius, connector, edgePan, lineClass, - maxConnectors, metadata, + rules, surfaceStrategies, ]); @@ -125,7 +119,7 @@ export const Connector = forwardRef( useEffect(() => { return () => { - if (ownsConnectorRef.current) connector.destroy(); + if (ownsConnectorRef.current) connector.destroy(false); }; }, [connector]); @@ -139,7 +133,7 @@ export const Connector = forwardRef( {...Object.fromEntries( Object.entries(data).map(([key, value]) => [`data-${key}`, value]), )} - className={`connector ${capabilities?.source ?? allowDragOut ? "right" : "left"} ${className}`.trim()} + className={`connector ${(rules?.maxOutgoing ?? "unlimited") !== 0 ? "right" : "left"} ${className}`.trim()} style={{ background: "#4f46e5", border: "2px solid #ffffff", diff --git a/assets/snapline/react/src/ControlledGraph.tsx b/assets/snapline/react/src/ControlledGraph.tsx new file mode 100644 index 0000000..2315e2c --- /dev/null +++ b/assets/snapline/react/src/ControlledGraph.tsx @@ -0,0 +1,61 @@ +import { useEffect, useRef } from "react"; +import { + attachControlledGraph, + type ControlledGraphHandle, + type LineChangeRequest, + type LineRecord, + type ReconciliationError, +} from "@snap-engine/snapline"; +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; + onDiagnosticsChanged?: (diagnostics: readonly ReconciliationError[]) => void; +} + +export function ControlledGraph({ + lines, + onLineChangeRequest, + onDiagnosticsChanged, +}: ControlledGraphProps) { + 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 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, + }), + ); + }, + 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]); + + return null; +} diff --git a/assets/snapline/react/src/EdgeSync.tsx b/assets/snapline/react/src/EdgeSync.tsx deleted file mode 100644 index f571a3c..0000000 --- a/assets/snapline/react/src/EdgeSync.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import { - EdgeSyncController, - type ConnectorComponent, - type EdgeConnectIntentEvent, - type EdgeDisconnectIntentEvent, - type EdgeEndpoint, - type EdgeLike, -} from "@snap-engine/snapline"; -import { useEffect, useRef } from "react"; -import { useSnapLineEngine } from "./Engine"; - -export interface EdgeSyncProps { - /** The consumer's edge document — the single source of truth. */ - edges: readonly EdgeLike[]; - /** Maps a connector to its semantic endpoint, or null for unmanaged connectors. */ - identity: (connector: ConnectorComponent) => EdgeEndpoint | null; - onEdgeConnect?: (event: EdgeConnectIntentEvent) => void; - onEdgeDisconnect?: (event: EdgeDisconnectIntentEvent) => void; -} - -export function EdgeSync({ - edges, - identity, - onEdgeConnect, - onEdgeDisconnect, -}: EdgeSyncProps) { - const engine = useSnapLineEngine(); - const propsRef = useRef({ edges, identity, onEdgeConnect, onEdgeDisconnect }); - propsRef.current = { edges, identity, onEdgeConnect, onEdgeDisconnect }; - const controllerRef = useRef(null); - - useEffect(() => { - const controller = new EdgeSyncController({ - engine, - identity: (connector) => propsRef.current.identity(connector), - getEdges: () => propsRef.current.edges, - callbacks: { - // For same-frame reconciliation of gesture intents, write the edge - // document inside flushSync in the handler; the microtask sync then - // sees the fresh document before paint. Deferred stores degrade to a - // one-frame pending state, never an inconsistent one. - onEdgeConnect: (event) => { - propsRef.current.onEdgeConnect?.(event); - queueMicrotask(() => controller.sync()); - }, - onEdgeDisconnect: (event) => { - propsRef.current.onEdgeDisconnect?.(event); - queueMicrotask(() => controller.sync()); - }, - }, - }); - controllerRef.current = controller; - controller.sync(); - return () => { - controller.dispose(); - if (controllerRef.current === controller) controllerRef.current = null; - }; - }, [engine]); - - useEffect(() => { - controllerRef.current?.sync(); - }, [edges]); - - return null; -} diff --git a/assets/snapline/react/src/Group.tsx b/assets/snapline/react/src/Group.tsx index 2e868ba..608c2d2 100644 --- a/assets/snapline/react/src/Group.tsx +++ b/assets/snapline/react/src/Group.tsx @@ -3,18 +3,17 @@ import { useLayoutEffect, useImperativeHandle, useRef, - useState, type CSSProperties, type ReactNode, } from "react"; import { DEFAULT_RESIZE_HANDLE_THICKNESS, - GroupNodeComponent, + GroupNodeMirror, type GroupCallbacks, type GroupContainEvent, type GroupMembershipEvent, type NodeCallbacks, - type NodeDragCommitEvent, + type GeometryChangeEvent, type NodeResizeEvent, type ResizeHandle, type SnapLineMetadata, @@ -22,9 +21,11 @@ import { import { useSnapLineEngine } from "./Engine"; export interface GroupProps { + /** Stable domain identity; minted when omitted (supply for persistence). */ + id?: string; children?: ReactNode; className?: string; - groupObject?: GroupNodeComponent | null; + groupObject?: GroupNodeMirror | null; style?: CSSProperties; title?: string; /** Consumer-rendered header contents. `title` remains the fallback. */ @@ -44,12 +45,12 @@ export interface GroupProps { canContain?: (event: GroupContainEvent) => boolean; edgePan?: boolean; onMembershipChange?: (event: GroupMembershipEvent) => void; - onResizeCommit?: (event: NodeResizeEvent) => void; - onDragCommit?: (event: NodeDragCommitEvent) => void; + onGeometryChanged?: (event: GeometryChangeEvent) => void; } -export const Group = forwardRef(function Group( +export const Group = forwardRef(function Group( { + id, children, className = "", groupObject = null, @@ -71,8 +72,7 @@ export const Group = forwardRef(function Group( canContain, edgePan = true, onMembershipChange, - onResizeCommit, - onDragCommit, + onGeometryChanged, }, ref, ) { @@ -80,9 +80,10 @@ export const Group = forwardRef(function Group( const boxDomRef = useRef(null); const headerRef = useRef(null); const ownsGroupRef = useRef(groupObject == null); - const groupRef = useRef(groupObject); + const groupRef = useRef(groupObject); if (!groupRef.current) { - groupRef.current = new GroupNodeComponent(engine, null, { + groupRef.current = new GroupNodeMirror(engine, null, { + id, width, height, minWidth, @@ -99,22 +100,17 @@ export const Group = forwardRef(function Group( } const group = groupRef.current; - // The box's width/height are framework-owned: seeded from props, updated - // live by core's onSizeChange during a resize drag. - const [box, setBox] = useState<{ w: number; h: number }>({ w: width, h: height }); const latestRef = useRef({ callbacks, groupCallbacks, onMembershipChange, - onResizeCommit, - onDragCommit, + onGeometryChanged, }); latestRef.current = { callbacks, groupCallbacks, onMembershipChange, - onResizeCommit, - onDragCommit, + onGeometryChanged, }; useImperativeHandle(ref, () => group, [group]); @@ -122,7 +118,7 @@ export const Group = forwardRef(function Group( useLayoutEffect(() => { if (boxDomRef.current) { group.element = boxDomRef.current; - group.syncDomGeometry(); + group.remeasureDomGeometry(); } const originalCallbacks = { ...group.callbacks }; const originalGroupCallbacks = { ...group.groupCallbacks }; @@ -185,12 +181,12 @@ export const Group = forwardRef(function Group( latestRef.current.groupCallbacks.onMembershipChange, latestRef.current.onMembershipChange, ); - group.callbacks.onDragCommit = (event) => + group.callbacks.onGeometryChanged = (event) => invoke( event, - originalCallbacks.onDragCommit, - latestRef.current.callbacks.onDragCommit, - latestRef.current.onDragCommit, + originalCallbacks.onGeometryChanged, + latestRef.current.callbacks.onGeometryChanged, + latestRef.current.onGeometryChanged, ); group.callbacks.onSizeChange = (event) => { invoke( @@ -198,17 +194,9 @@ export const Group = forwardRef(function Group( originalCallbacks.onSizeChange, latestRef.current.callbacks.onSizeChange, ); - setBox({ w: event.width, h: event.height }); }; - group.callbacks.onResizeCommit = (event) => - invoke( - event, - originalCallbacks.onResizeCommit, - latestRef.current.callbacks.onResizeCommit, - latestRef.current.onResizeCommit, - ); // Header is the only move surface. setSizeState seeds the collision - // footprint (the DOM size is rendered from state above). + // footprint; core writes live resize geometry directly to this element. const unregisterHandle = headerRef.current ? group.registerDragHandle(headerRef.current) : undefined; @@ -217,6 +205,7 @@ export const Group = forwardRef(function Group( stage: "WRITE_3", queueId: `${group.id}-seed`, }); + const boundElement = boxDomRef.current; return () => { unregisterHandle?.(); @@ -225,17 +214,18 @@ export const Group = forwardRef(function Group( originalCallbacks.resolveSelectionMode; group.callbacks.onDragStart = originalCallbacks.onDragStart; group.callbacks.onDrag = originalCallbacks.onDrag; - group.callbacks.onDragCommit = originalCallbacks.onDragCommit; + group.callbacks.onGeometryChanged = originalCallbacks.onGeometryChanged; group.callbacks.onSelectionChange = originalCallbacks.onSelectionChange; group.callbacks.onResizeHandleChange = originalCallbacks.onResizeHandleChange; group.callbacks.onSizeChange = originalCallbacks.onSizeChange; - group.callbacks.onResizeCommit = originalCallbacks.onResizeCommit; group.groupCallbacks.onMembershipChange = originalGroupCallbacks.onMembershipChange; if (ownsGroupRef.current) { - group.destroy(); + group.destroy(false); + } else if (boundElement) { + group.detachElement(boundElement); } }; }, [group]); @@ -248,15 +238,13 @@ export const Group = forwardRef(function Group( }); }, [group, x, y]); - useLayoutEffect(() => { - setBox({ w: width, h: height }); - }, [width, height]); - useLayoutEffect(() => { if (!group.element) return; - group.setSizeState(box.w, box.h); - group.syncDomGeometry(); - }, [group, box]); + group.element.style.width = `${width}px`; + group.element.style.height = `${height}px`; + group.setSizeState(width, height); + group.remeasureDomGeometry(); + }, [group, width, height]); const handleSize = resizeHandleThickness ?? DEFAULT_RESIZE_HANDLE_THICKNESS; @@ -271,8 +259,8 @@ export const Group = forwardRef(function Group( willChange: "transform", boxSizing: "border-box", pointerEvents: "none", - width: `${box.w}px`, - height: `${box.h}px`, + width: `${width}px`, + height: `${height}px`, ...style, }} > diff --git a/assets/snapline/react/src/Line.tsx b/assets/snapline/react/src/Line.tsx index d3041cf..3cfd461 100644 --- a/assets/snapline/react/src/Line.tsx +++ b/assets/snapline/react/src/Line.tsx @@ -1,8 +1,11 @@ -import { useEffect, useRef, useState, type CSSProperties } from "react"; -import type { LineComponent } from "@snap-engine/snapline"; +import { useLayoutEffect, useRef, type CSSProperties } from "react"; +import type { + LineMirror, + LineGeometrySnapshot, +} from "@snap-engine/snapline"; export interface LineProps { - line: LineComponent; + line: LineMirror; className?: string; pathClassName?: string; pathStyle?: CSSProperties; @@ -10,35 +13,10 @@ export interface LineProps { data?: Record; } -interface LineState { - style: CSSProperties; - x1: number; - x2: number; - x3: number; - y1: number; - y2: number; - y3: number; -} - -function getLineState(line: LineComponent): LineState { - const dx = line.endWorldX - line.worldTransform.x; - const dy = line.endWorldY - line.worldTransform.y; - return { - style: { - overflow: "visible", - pointerEvents: "none", - position: "absolute", - transform: `translate3d(${line.worldTransform.x}px, ${line.worldTransform.y}px, 0)`, - willChange: "transform", - zIndex: 1000, - }, - x1: Math.abs(dx / 2), - y1: 0, - x2: dx - Math.abs(dx / 2), - y2: dy, - x3: dx, - y3: dy, - }; +function pathForGeometry(geometry: LineGeometrySnapshot): string { + const { x: dx, y: dy } = geometry.delta; + const x1 = Math.abs(dx / 2); + return `M 0,0 C ${x1}, 0 ${dx - x1}, ${dy} ${dx}, ${dy}`; } export function Line({ @@ -50,38 +28,45 @@ export function Line({ data = {}, }: LineProps) { const lineDomRef = useRef(null); - const [lineState, setLineState] = useState(() => - getLineState(line), - ); + const pathRef = useRef(null); + const initialGeometry = line.geometrySnapshot(); - useEffect(() => { - if (lineDomRef.current) { - line.element = lineDomRef.current as unknown as HTMLElement; - } - const renderLine = () => { - setLineState(getLineState(line)); - }; - const cleanup = line.onRender(renderLine); - renderLine(); - return cleanup; + useLayoutEffect(() => { + return line.bindGeometryWriter((geometry) => { + const svg = lineDomRef.current; + const path = pathRef.current; + if (!svg || !path) return; + svg.style.transform = + `translate3d(${geometry.start.x}px, ${geometry.start.y}px, 0)`; + path.setAttribute("d", pathForGeometry(geometry)); + }); }, [line]); return ( [`data-${key}`, value]), )} height="4" ref={lineDomRef} - style={lineState.style} + style={{ + overflow: "visible", + pointerEvents: "none", + position: "absolute", + transform: `translate3d(${initialGeometry.start.x}px, ${initialGeometry.start.y}px, 0)`, + willChange: "transform", + zIndex: 1000, + }} width="4" > {showArrow ? ( - + ) : null} diff --git a/assets/snapline/react/src/Node.tsx b/assets/snapline/react/src/Node.tsx index 714ebc2..7e09312 100644 --- a/assets/snapline/react/src/Node.tsx +++ b/assets/snapline/react/src/Node.tsx @@ -14,24 +14,26 @@ import { } from "react"; import { DEFAULT_RESIZE_HANDLE_THICKNESS, - LineComponent, - NodeComponent, + LineMirror, + NodeMirror, type ResizeHandle, type NodeCallbacks, - type NodeDragCommitEvent, + type GeometryChangeEvent, type NodeResizeEvent, type SnapLineMetadata, } from "@snap-engine/snapline"; import { useSnapLineEngine } from "./Engine"; import { Line } from "./Line"; -export const NodeObjectContext = createContext(null); +export const NodeMirrorContext = createContext(null); export interface NodeProps { + /** Stable domain identity; minted when omitted (supply for persistence). */ + id?: string; children: ReactNode; className?: string; - lineComponent?: ComponentType<{ line: LineComponent }>; - nodeObject?: NodeComponent | null; + lineComponent?: ComponentType<{ line: LineMirror }>; + nodeObject?: NodeMirror | null; style?: CSSProperties; x?: number; y?: number; @@ -46,15 +48,15 @@ export interface NodeProps { metadata?: SnapLineMetadata; callbacks?: NodeCallbacks; edgePan?: boolean; - onDragCommit?: (event: NodeDragCommitEvent) => void; - onResizeCommit?: (event: NodeResizeEvent) => void; + onGeometryChanged?: (event: GeometryChangeEvent) => void; onSizeChange?: (event: NodeResizeEvent) => void; /** Framework-native attributes and events for the outer node element. */ elementProps?: HTMLAttributes; } -export const Node = forwardRef(function Node( +export const Node = forwardRef(function Node( { + id, children, className = "", lineComponent: LineRenderer = Line, @@ -73,8 +75,7 @@ export const Node = forwardRef(function Node( metadata = {}, callbacks = {}, edgePan = true, - onDragCommit, - onResizeCommit, + onGeometryChanged, onSizeChange, elementProps, }, @@ -83,9 +84,10 @@ export const Node = forwardRef(function Node( const engine = useSnapLineEngine(); const nodeDomRef = useRef(null); const ownsNodeRef = useRef(nodeObject == null); - const nodeRef = useRef(nodeObject); + const nodeRef = useRef(nodeObject); if (!nodeRef.current) { - nodeRef.current = new NodeComponent(engine, null, { + nodeRef.current = new NodeMirror(engine, null, { + id, resizable, minWidth, minHeight, @@ -98,23 +100,17 @@ export const Node = forwardRef(function Node( }); } const node = nodeRef.current; - const [lineList, setLineList] = useState( + const [lineList, setLineList] = useState( node.getAllOutgoingLines(), ); - // The element's width/height are framework-owned: core reports size changes - // (resize drag) via onSizeChange and this state renders them. Null until the - // first resize so CSS-declared sizes keep applying to non-resized nodes. - const [box, setBox] = useState<{ w: number; h: number } | null>(null); const latestRef = useRef({ callbacks, - onDragCommit, - onResizeCommit, + onGeometryChanged, onSizeChange, }); latestRef.current = { callbacks, - onDragCommit, - onResizeCommit, + onGeometryChanged, onSizeChange, }; @@ -123,7 +119,7 @@ export const Node = forwardRef(function Node( useLayoutEffect(() => { if (nodeDomRef.current) { node.element = nodeDomRef.current; - node.syncDomGeometry(); + node.remeasureDomGeometry(); } const original = { ...node.callbacks }; const invoke = ( @@ -193,23 +189,16 @@ export const Node = forwardRef(function Node( latestRef.current.callbacks.onSizeChange, latestRef.current.onSizeChange, ); - setBox({ w: event.width, h: event.height }); }; - node.callbacks.onResizeCommit = (event) => + node.callbacks.onGeometryChanged = (event) => invoke( event, - original.onResizeCommit, - latestRef.current.callbacks.onResizeCommit, - latestRef.current.onResizeCommit, - ); - node.callbacks.onDragCommit = (event) => - invoke( - event, - original.onDragCommit, - latestRef.current.callbacks.onDragCommit, - latestRef.current.onDragCommit, + original.onGeometryChanged, + latestRef.current.callbacks.onGeometryChanged, + latestRef.current.onGeometryChanged, ); setLineList([...node.getAllOutgoingLines()]); + const boundElement = nodeDomRef.current; return () => { node.callbacks.canStartDrag = original.canStartDrag; @@ -217,14 +206,15 @@ export const Node = forwardRef(function Node( node.callbacks.resolveSelectionMode = original.resolveSelectionMode; node.callbacks.onDragStart = original.onDragStart; node.callbacks.onDrag = original.onDrag; - node.callbacks.onDragCommit = original.onDragCommit; + node.callbacks.onGeometryChanged = original.onGeometryChanged; node.callbacks.onSelectionChange = original.onSelectionChange; node.callbacks.onResizeHandleChange = original.onResizeHandleChange; node.callbacks.onLinesChanged = original.onLinesChanged; node.callbacks.onSizeChange = original.onSizeChange; - node.callbacks.onResizeCommit = original.onResizeCommit; if (ownsNodeRef.current) { - node.destroy(); + node.destroy(false); + } else if (boundElement) { + node.detachElement(boundElement); } }; }, [node]); @@ -235,25 +225,21 @@ export const Node = forwardRef(function Node( }, [node, x, y]); useLayoutEffect(() => { - setBox( - width == null && height == null - ? null - : { w: width ?? node.hitBox.width, h: height ?? node.hitBox.height }, - ); + if (!node.element || (width == null && height == null)) return; + const nextWidth = width ?? node.hitBox.width; + const nextHeight = height ?? node.hitBox.height; + if (width != null) node.element.style.width = `${width}px`; + if (height != null) node.element.style.height = `${height}px`; + node.setSizeState(nextWidth, nextHeight); + node.remeasureDomGeometry(); }, [node, width, height]); - useLayoutEffect(() => { - if (!node.element || !box) return; - node.setSizeState(box.w, box.h); - node.syncDomGeometry(); - }, [node, box]); - const handleSize = resizeHandleThickness ?? DEFAULT_RESIZE_HANDLE_THICKNESS; return ( - + {lineList.map((line) => ( - + ))}
(function Node( position: "absolute", transformOrigin: "top left", willChange: "transform", - ...(box ? { width: `${box.w}px`, height: `${box.h}px` } : null), + ...(width != null ? { width: `${width}px` } : null), + ...(height != null ? { height: `${height}px` } : null), ...style, }} > @@ -294,13 +281,13 @@ export const Node = forwardRef(function Node( /> ))}
- + ); }); /** Callback ref for declaring any descendant as a node drag surface. */ export function useNodeHandle(): RefCallback { - const node = useContext(NodeObjectContext); + const node = useContext(NodeMirrorContext); const cleanup = useRef<(() => void) | null>(null); return (element) => { cleanup.current?.(); diff --git a/assets/snapline/react/src/Placement.tsx b/assets/snapline/react/src/Placement.tsx index 042884a..28abb69 100644 --- a/assets/snapline/react/src/Placement.tsx +++ b/assets/snapline/react/src/Placement.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState, type ReactNode } from "react"; +import { useEffect, useLayoutEffect, useRef, useState, type ReactNode } from "react"; import type { PlacementController, PlacementSnapshot, @@ -20,15 +20,44 @@ export function Placement({ children, }: PlacementProps) { const [snapshot, setSnapshot] = useState(controller.snapshot); + const latestSnapshotRef = useRef(controller.snapshot); + const previewRef = useRef(null); + + useEffect(() => { + return controller.onStateChange((next) => { + const previous = latestSnapshotRef.current; + latestSnapshotRef.current = next; + const positionAvailabilityChanged = + (previous.position === null) !== (next.position === null); + if ( + previous.active !== next.active || + previous.payload !== next.payload || + previous.size !== next.size || + positionAvailabilityChanged + ) { + setSnapshot(next); + } + }); + }, [controller]); + + useLayoutEffect(() => { + return controller.bindGeometryWriter((geometry) => { + const element = previewRef.current; + if (!element) return; + const position = geometry.position; + element.style.visibility = geometry.visible ? "visible" : "hidden"; + element.style.transform = position + ? `translate3d(${position.x}px, ${position.y}px, 0)` + : "translate3d(0px, 0px, 0)"; + if (geometry.size) { + element.style.width = `${geometry.size.width}px`; + element.style.height = `${geometry.size.height}px`; + } + element.dataset.allowed = String(geometry.allowed); + }); + }, [controller, snapshot.active]); useEffect(() => { - const previousOnChange = controller.callbacks.onChange; - const onChange = (next: PlacementSnapshot) => { - setSnapshot(next); - previousOnChange?.(next); - }; - setSnapshot(controller.snapshot); - controller.callbacks.onChange = onChange; const move = (event: PointerEvent) => { if (controller.snapshot.active) { controller.update({ x: event.clientX, y: event.clientY }, event); @@ -60,9 +89,6 @@ export function Placement({ window.addEventListener("pointerdown", down, true); window.addEventListener("keydown", key); return () => { - if (controller.callbacks.onChange === onChange) { - controller.callbacks.onChange = previousOnChange; - } window.removeEventListener("pointermove", move); window.removeEventListener("pointerdown", down, true); window.removeEventListener("keydown", key); @@ -74,5 +100,20 @@ export function Placement({ controller, ]); - return snapshot.active ? children?.(snapshot) : null; + return snapshot.active ? ( +
+ {children?.(snapshot)} +
+ ) : null; } diff --git a/assets/snapline/react/src/Select.tsx b/assets/snapline/react/src/Select.tsx index b9b33c1..54f2128 100644 --- a/assets/snapline/react/src/Select.tsx +++ b/assets/snapline/react/src/Select.tsx @@ -1,5 +1,5 @@ -import { useEffect, useRef, useState, type CSSProperties } from "react"; -import { RectSelectComponent, type SelectCallbacks, type SelectRect } from "@snap-engine/snapline"; +import { useLayoutEffect, useRef, type CSSProperties } from "react"; +import { RectSelectController, type SelectCallbacks } from "@snap-engine/snapline"; import { useSnapLineEngine } from "./Engine"; export interface SelectProps { @@ -16,34 +16,33 @@ export function Select({ callbacks = {}, }: SelectProps) { const engine = useSnapLineEngine(); - const selectRef = useRef(null); + const selectRef = useRef(null); + const selectDomRef = useRef(null); if (!selectRef.current) { - selectRef.current = new RectSelectComponent(engine, null, { callbacks }); + selectRef.current = new RectSelectController(engine, null, { callbacks }); } const select = selectRef.current; - // The selection box is framework-rendered: core reports the world-space rect - // via onRectChange and this state draws it, so consumers can restyle or - // replace the box (className/style props). - const [rect, setRect] = useState({ - x: 0, - y: 0, - width: 0, - height: 0, - visible: false, - }); - - useEffect(() => { - select.callbacks.onRectChange = (r: SelectRect) => setRect(r); + useLayoutEffect(() => { + const unbind = select.bindGeometryWriter((rect) => { + const element = selectDomRef.current; + if (!element) return; + element.style.display = rect.visible ? "block" : "none"; + element.style.width = `${rect.width}px`; + element.style.height = `${rect.height}px`; + element.style.transform = `translate3d(${rect.x}px, ${rect.y}px, 0)`; + }); return () => { - select.destroy(); + unbind(); + select.destroy(false); }; }, [select]); return (
diff --git a/assets/snapline/react/src/index.ts b/assets/snapline/react/src/index.ts index c80ae87..fb96913 100644 --- a/assets/snapline/react/src/index.ts +++ b/assets/snapline/react/src/index.ts @@ -6,11 +6,11 @@ export { Group } from "./Group"; export type { GroupProps } from "./Group"; export { Line } from "./Line"; export type { LineProps } from "./Line"; -export { Node, NodeObjectContext, useNodeHandle } from "./Node"; +export { Node, NodeMirrorContext, useNodeHandle } from "./Node"; export type { NodeProps } from "./Node"; export { Select } from "./Select"; export type { SelectProps } from "./Select"; export { Placement } from "./Placement"; export type { PlacementProps } from "./Placement"; -export { EdgeSync } from "./EdgeSync"; -export type { EdgeSyncProps } from "./EdgeSync"; +export { ControlledGraph } from "./ControlledGraph"; +export type { ControlledGraphProps } from "./ControlledGraph"; diff --git a/assets/snapline/svelte/README.md b/assets/snapline/svelte/README.md index 627e150..d3f0074 100644 --- a/assets/snapline/svelte/README.md +++ b/assets/snapline/svelte/README.md @@ -11,10 +11,10 @@ npm install @snap-engine/core @snap-engine/snapline \ ## Components -`Node`, `Group`, `Connector`, `Line`, `Select`, and `Placement` are exported +`Node`, `Group`, `Connector`, `Line`, `Select`, `Placement`, and `ControlledGraph` are exported from the package root. Component subpaths are also available as `Node.svelte`, `Group.svelte`, `Connector.svelte`, `Line.svelte`, -`Select.svelte`, and `Placement.svelte`. +`Select.svelte`, `Placement.svelte`, and `ControlledGraph.svelte`. ```svelte @@ -98,7 +94,7 @@ data-snapline-type="connector" data-snapline-name={name} {...Object.fromEntries(Object.entries(data).map(([key, value]) => [`data-${key}`, value]))} - class={`connector ${(capabilities?.source ?? allowDragOut) ? "right" : "left"}`} + class={`connector ${(rules?.maxOutgoing ?? "unlimited") !== 0 ? "right" : "left"}`} >
{/if} diff --git a/assets/snapline/svelte/src/ControlledGraph.svelte b/assets/snapline/svelte/src/ControlledGraph.svelte new file mode 100644 index 0000000..9a30fbb --- /dev/null +++ b/assets/snapline/svelte/src/ControlledGraph.svelte @@ -0,0 +1,44 @@ + diff --git a/assets/snapline/svelte/src/EdgeSync.svelte b/assets/snapline/svelte/src/EdgeSync.svelte deleted file mode 100644 index 77b5b79..0000000 --- a/assets/snapline/svelte/src/EdgeSync.svelte +++ /dev/null @@ -1,58 +0,0 @@ - diff --git a/assets/snapline/svelte/src/Group.svelte b/assets/snapline/svelte/src/Group.svelte index 3547c44..7afd378 100644 --- a/assets/snapline/svelte/src/Group.svelte +++ b/assets/snapline/svelte/src/Group.svelte @@ -1,9 +1,10 @@ -{#each lineList as line (line.id)} +{#each lineList as line (line.lineId)} {/each}
{@render children()} diff --git a/assets/snapline/svelte/src/Placement.svelte b/assets/snapline/svelte/src/Placement.svelte index 9ad9ab8..ae4965f 100644 --- a/assets/snapline/svelte/src/Placement.svelte +++ b/assets/snapline/svelte/src/Placement.svelte @@ -20,24 +20,39 @@ } = $props(); let snapshot = $state>(controller.snapshot); + let latestSnapshot = controller.snapshot; $effect(() => { const current = controller; - const previousOnChange = current.callbacks.onChange; - const onChange = (next: PlacementSnapshot) => { - snapshot = next; - previousOnChange?.(next); - }; - snapshot = current.snapshot; - current.callbacks.onChange = onChange; - - return () => { - if (current.callbacks.onChange === onChange) { - current.callbacks.onChange = previousOnChange; + return current.onStateChange((next) => { + const previous = latestSnapshot; + latestSnapshot = next; + if ( + previous.active !== next.active || + previous.payload !== next.payload || + previous.size !== next.size || + (previous.position === null) !== (next.position === null) + ) { + snapshot = next; } - }; + }); }); + function bindPlacementGeometry(element: HTMLDivElement) { + const cleanup = controller.bindGeometryWriter((geometry) => { + element.style.visibility = geometry.visible ? "visible" : "hidden"; + element.style.transform = geometry.position + ? `translate3d(${geometry.position.x}px, ${geometry.position.y}px, 0)` + : "translate3d(0px, 0px, 0)"; + if (geometry.size) { + element.style.width = `${geometry.size.width}px`; + element.style.height = `${geometry.size.height}px`; + } + element.dataset.allowed = String(geometry.allowed); + }); + return { destroy: cleanup }; + } + function onPointerMove(event: PointerEvent): void { if (!controller.snapshot.active) return; controller.update({ x: event.clientX, y: event.clientY }, event); @@ -78,6 +93,13 @@ onkeydown={onKeyDown} /> -{#if snapshot.active && preview} - {@render preview(snapshot)} +{#if snapshot.active} +
+ {@render preview?.(snapshot)} +
{/if} diff --git a/assets/snapline/svelte/src/Select.svelte b/assets/snapline/svelte/src/Select.svelte index 0b0abce..ba81578 100644 --- a/assets/snapline/svelte/src/Select.svelte +++ b/assets/snapline/svelte/src/Select.svelte @@ -1,23 +1,29 @@ + + diff --git a/demo/svelte/src/demo/node_ui_demo/Line.svelte b/demo/svelte/src/demo/node_ui_demo/Line.svelte index 904ad85..5081343 100644 --- a/demo/svelte/src/demo/node_ui_demo/Line.svelte +++ b/demo/svelte/src/demo/node_ui_demo/Line.svelte @@ -1,47 +1,33 @@ @@ -49,8 +35,8 @@ data-snapline-type="connector-line" width="4" height="4" - {style} - bind:this={line.element as any} + style="position: absolute; overflow: visible; pointer-events: none; will-change: transform;" + bind:this={svgDOM} transition:blur|global={{ duration: 200 }} > @@ -63,17 +49,18 @@ - + - + diff --git a/demo/svelte/src/demo/node_ui_resize/ResizableNode.svelte b/demo/svelte/src/demo/node_ui_resize/ResizableNode.svelte index eaf3b20..0f21c1f 100644 --- a/demo/svelte/src/demo/node_ui_resize/ResizableNode.svelte +++ b/demo/svelte/src/demo/node_ui_resize/ResizableNode.svelte @@ -1,7 +1,7 @@ @@ -19,12 +19,12 @@

{title}

-
+
In
Out -
+
diff --git a/docs/snapline/design/current-architecture.md b/docs/snapline/design/current-architecture.md index 6b7b53b..3dc4551 100644 --- a/docs/snapline/design/current-architecture.md +++ b/docs/snapline/design/current-architecture.md @@ -1,600 +1,448 @@ # SnapLine current architecture -Status: review baseline +Status: describes the post-re-architecture implementation Reviewed: 2026-07-25 -Repository baseline: `29373b4`, including the current uncommitted SnapLine work +Companions: +[ownership specification](./ownership-specification.md) · +[migration notes](./migration-notes.md) · +[planned re-architecture](./planned-rearchitecture.md) -This document describes the SnapLine code as it exists in the current working -tree. It is descriptive, not an endorsement of every current boundary. The -companion [ownership specification](./ownership-specification.md) turns the -desired framework-owned model into a proposed normative contract. +This document describes the SnapLine code as it exists after the +controlled-graph re-architecture. The +[ownership specification](./ownership-specification.md) is the normative +contract this implementation conforms to; the +[migration notes](./migration-notes.md) cover the API delta for upgraders. ## Executive summary -SnapLine currently has four layers: - -| Layer | Current responsibility | -| --------------------------- | ------------------------------------------------------------------------------------------------------ | -| Application/framework state | Renders node and connector collections; may own an `EdgeLike[]` document | -| React/Svelte adapters | Create and destroy core objects, render DOM, and translate callbacks into framework updates | -| SnapLine core | Handles gestures, selection, geometry, grouping, connector policy, line objects, and mirror registries | -| SnapEngine core | Provides object lifecycle, input routing, collision, transforms, scheduling, and camera coordinates | - -Node and connector **existence** is already framework-led: mounting a -`` or `` creates a core object and unmounting it destroys the -owned object. `NodeManager` mirrors the live core instances for queries and -edge reconciliation. - -Line ownership is transitional: - -- Without `EdgeSyncController`, connector topology is authoritative. - `ConnectorComponent.connectToConnector()`, connection gestures, and - `deleteLine()` directly create and destroy `LineComponent` objects. -- With `EdgeSyncController`, an application `EdgeLike[]` is treated as - canonical. SnapLine still creates and owns the `LineComponent` instances, - but `sync()` reconciles them to the application edge list and gesture - mutations are reported as application intents. - -That means the current package supports both an imperative topology model and -a controlled-edge model at the same time. Most of the design ambiguity comes -from the overlap between those modes. +Topology — which nodes, connectors, and lines exist — is **always +controlled**: the application document is the single source of truth, and +there is no uncontrolled topology mode (vanilla consumers own the graph with +a plain graph-owner module driving the same contract). SnapLine maintains an +engine-scoped runtime mirror of that document plus everything the document +does not need to contain: gestures, geometry, selection, and groups. User +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. + +| Layer | Responsibility | +| --------------------------- | -------------------------------------------------------------------------------------------------------------- | +| Application/framework state | Canonical document: node/connector collections and the `LineRecord[]` line list; applies change requests | +| React/Svelte adapters | Mount/destroy mirrors from collections, push canonical snapshots, render DOM, forward requests and observations | +| SnapLine core | Runtime mirrors, gesture mechanics, the line reconciler, selection, geometry, grouping, connector rules | +| SnapEngine core | Object lifecycle, input routing, collision, transforms, frame scheduling, camera coordinates | + +Node and connector **existence** is framework-mount-led: mounting a +``/`` constructs the mirror, unmounting destroys it. Line +**existence** is record-led: `attachControlledGraph()` installs a +`LineReconciler` that converges settled `LineMirror`s onto the cached +canonical `{ lines: LineRecord[] }` snapshot. Every mirror carries a stable +domain id (`nodeId` / `connectorId` / `lineId`), supplied via `id` +props/config or minted (`node-42`-style) for the mirror's lifetime. ## Layer and data flow ```mermaid flowchart TB - APP["Application / framework
canonical nodes, connectors, and edges"] - ADAPTER["React / Svelte adapters
component lifecycle and ownership bridge"] - MIRRORS["SnapLine runtime mirrors
NodeComponent, ConnectorComponent, LineComponent"] - STATE["SnapLine interaction state
gestures, geometry, selection, and groups"] - ENGINE["SnapEngine mechanics
input, collision, transforms, and scheduling"] + APP["Application document
canonical nodes, connectors, LineRecord[]"] + 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"] + ENGINE["SnapEngine mechanics
input, collision, transforms, scheduling"] DOM["Framework-owned DOM"] - APP -->|"render records and provide edges[]"| ADAPTER - ADAPTER -->|"create, destroy, and reconcile"| MIRRORS - ENGINE -->|"events and measurements"| STATE - STATE -->|"transient updates"| MIRRORS - MIRRORS -->|"render callbacks and property writes"| DOM + APP -->|"render collections"| ADAPTER + ADAPTER -->|"construct / destroy mirrors"| MIRRORS + ADAPTER -->|"setCanonicalGraph({lines})"| RECON + RECON -->|"create / retarget / prune / settle"| MIRRORS + MIRRORS <-->|"register / index"| REGISTRY + ENGINE -->|"events and measurements"| MIRRORS + MIRRORS -->|"geometry writers, data-* writes"| DOM ADAPTER -->|"structural rendering"| DOM - MIRRORS -.->|"connect / disconnect intents"| APP + MIRRORS -.->|"one atomic LineChangeRequest per gesture"| APP + RECON -.->|"onDiagnosticsChanged"| APP ``` -The word “mirror” is important. Core objects contain interaction and geometry -state that domain records do not need to contain, but their existence should -follow the application records when the controlled model is in use. +"Mirror" is the load-bearing word: runtime objects carry interaction and +geometry state the document does not need, but their existence follows the +application's records and mounted components. -## Entity lifecycle +## Entity lifecycles -```mermaid -flowchart TB - RECORD["Application: render node / connector record"] - CREATE["Framework: construct or attach core object"] - REGISTER["SnapLine: register live mirror in NodeManager"] - COMMIT["Framework: commit and bind DOM element"] - SYNC["Framework → SnapLine: syncDomGeometry()"] - INTERACT["SnapLine: apply transient interaction state"] - REPORT["SnapLine → application: emit commit callbacks"] - PERSIST["Application: persist props or collection update"] - RESYNC["Framework: resynchronize mirror"] - REMOVE["Application: remove record"] - DESTROY["Framework: destroy adapter-owned object and DOM"] - UNREGISTER["SnapLine: unregister mirror"] - - RECORD --> CREATE --> REGISTER --> COMMIT --> SYNC - SYNC --> INTERACT --> REPORT --> PERSIST --> RESYNC - RESYNC --> REMOVE --> DESTROY --> UNREGISTER -``` - -### Nodes - -1. A React or Svelte `Node` adapter is rendered from application state. -2. The adapter either accepts a supplied `NodeComponent` or constructs one. -3. The constructor registers the node with SnapEngine and the per-engine - `NodeManager`. -4. The adapter assigns the committed DOM element, sets the world transform, - and calls `syncDomGeometry()`. -5. During a drag, core mutates `worldTransform` immediately and writes the DOM - transform. `onDragCommit` reports final positions for persistence. -6. During resize, core updates collision state and fires `onSizeChange`; the - adapter renders width and height. `onResizeCommit` reports the settled box. -7. On unmount, an adapter-owned node is destroyed and unregistered. - -The framework controls whether a node is mounted, but live position is locally -owned by core during a gesture. The `x`, `y`, `width`, and `height` props can -resynchronize core, although unchanged props do not by themselves reject and -revert a completed local drag. - -### Connectors - -1. A connector is rendered inside a node. -2. The adapter constructs or accepts a `ConnectorComponent`. -3. `NodeComponent.addConnectorObject()` calls `assignToNode()`, which stores - the connector in the node’s name-keyed `_connectors` map and shares the - node property bag. -4. The constructor also registers the connector in `NodeManager`. -5. Connector policy and presentation-related configuration are updated in - place through `updateConfig()`. -6. A visible connector binds a DOM element; a virtual connector remains a - logical object and uses surface strategies for hit testing and anchors. -7. Destroying a connector removes all of its incoming and outgoing line - mirrors, unregisters it, and removes it from the parent map. - -Connector existence therefore follows framework rendering. Connector -configuration is copied into the core mirror. The connector’s `name` is a -construction-time key; metadata is currently the common place to carry domain -node/port identity. - -### Lines - -Lines are not registered in `NodeManager`. They are organized as topology on -connectors: - -- the source connector holds the line in `outgoingLines`; -- the target connector holds the same object in `incomingLines`; -- `LineComponent.start` points to the source; -- `LineComponent.target` points to the target, or is `null` for a preview; -- the source node exposes the flattened outgoing list to its adapter. - -The source node adapter renders one framework `Line` component per outgoing -`LineComponent`. `onLinesChanged` copies the latest source list into React or -Svelte state, so framework reconciliation owns the SVG DOM while SnapLine owns -the list being mirrored. - -A line also carries transient rendering state: anchors, phase, candidate, -preview position, optional payload, and render subscriptions. - -```mermaid -flowchart TB - SN["Source NodeComponent"] - SC["Source ConnectorComponent"] - LINE["One shared LineComponent"] - TC["Target ConnectorComponent"] - TN["Target NodeComponent"] - LIST["Source node outgoing-line snapshot"] - VIEW["React / Svelte Line component"] - SVG["Framework-owned SVG"] - - SN -->|"name-keyed connector map"| SC - SC -->|"outgoingLines contains"| LINE - LINE -->|"start"| SC - LINE -->|"target"| TC - TC -->|"incomingLines contains same object"| LINE - TC --> TN - SN -->|"getAllOutgoingLines()"| LIST - LIST -->|"onLinesChanged"| VIEW - VIEW --> SVG - LINE -->|"onRender()"| VIEW -``` +### Nodes and connectors (framework-mount-led) -### Groups +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). +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 + surface strategies for hit testing and anchors. +3. Connector registration schedules a reconciliation pass — a newly mounted + endpoint may make a latent canonical line representable. +4. On unmount, `destroy()` unregisters the mirror; connector/node teardown + removes incident line mirrors with reason `"teardown"` and never emits a + document-deletion request (the canonical record stays latent). -`GroupNodeComponent` extends `NodeComponent`. Groups are also kept in the -shared `global.data.groups` list. +Connector policy updates in place through `updateConfig()`; `name` is a +construction-time key in the parent node's map. `bindElement()` attaches or +detaches the optional visible port without destroying the logical connector. -Membership is derived from measured geometry: +### Lines: the controlled protocol -- ordinary nodes use center containment; -- nested groups require full-bounds containment; -- the smallest eligible group wins by default; -- a per-engine resolver may choose another eligible parent or no parent; -- membership cycles are rejected; -- group drag temporarily reparents transforms, not DOM. - -Group membership is therefore SnapLine-derived interaction state, not a -framework graph-document relation in the current design. - -### Selection - -Selection is stored in `global.data.select`. `NodeComponent.setSelected()` -updates that list, writes `data-selected`/`data-snapline-state`, and emits a -selection callback. `RectSelectComponent` owns the selection gesture and -collision box, while the framework adapter renders the visible rubber-band -rectangle from `onRectChange`. - -Selection is not currently a controlled framework prop. - -## Controlled edge reconciliation - -`EdgeSyncController` is the current implementation of application-owned edge -existence. - -### Inputs +The application owns `LineRecord`s: ```ts -interface EdgeSyncConfig { - engine: EngineLike; - identity(connector: ConnectorComponent): EdgeEndpoint | null; - getEdges(): readonly EdgeLike[]; - callbacks?: { - onEdgeConnect?(event: EdgeConnectIntentEvent): void; - onEdgeDisconnect?(event: EdgeDisconnectIntentEvent): void; - }; +interface LineRecord { + id: LineId; + fromConnectorId: ConnectorId; + toConnectorId: ConnectorId; + payload?: unknown; } ``` -`identity()` maps a live connector mirror to `{ node, port }`. Returning -`null` makes that connector and its lines unmanaged by the controller. - -`getEdges()` is consulted fresh for every `sync()`. The controller does not -store a second edge document. - -### Reconciliation algorithm +`attachControlledGraph(engine, { onLineChangeRequest, onDiagnosticsChanged? })` +installs the engine's `LineReconciler` and returns +`{ setCanonicalGraph, flush, dispose }`. Canonical state is **pushed and +cached**: `setCanonicalGraph({ lines })` stores the snapshot and schedules one +coalesced pass. Internal triggers (connector register/unregister, batch +close, request dispatch) replay the cached snapshot — reconciliation never +pulls from the application. -On `sync()`: - -1. Snapshot all registered connectors. -2. Resolve each managed connector to an endpoint key. -3. Snapshot the application edge list. -4. Delete settled managed lines whose endpoint pair is absent from the - application list. -5. Leave preview lines and unmanaged lines untouched. -6. For every application edge whose endpoints are mounted, hydrate a missing - line with `connectToConnector({ origin: "hydration" })`. -7. Leave edges with unmounted endpoints latent until a later sync. +The gesture flow, end to end: ```mermaid -flowchart TD - START["sync() trigger"] - SNAP["Snapshot NodeManager connectors,
identity mapping, and current edges[]"] - WALKLINES["Walk settled managed lines"] - LINECHECK{"Matching canonical edge?"} - KEEPLINE["Preserve existing line mirror"] - REMOVELINE["Delete mirror with
reason: programmatic"] - WALKEDGES["Walk canonical edges"] - MOUNTED{"Both endpoints mounted?"} - EXISTS{"Matching settled line exists?"} - LATENT["Keep edge latent in application document"] - CREATELINE["Hydrate LineComponent
without user intent"] - DONE["Mirror reconciled"] - - START --> SNAP --> WALKLINES --> LINECHECK - LINECHECK -->|"yes"| KEEPLINE - LINECHECK -->|"no"| REMOVELINE - KEEPLINE --> WALKEDGES - REMOVELINE --> WALKEDGES - WALKEDGES --> MOUNTED - MOUNTED -->|"no"| LATENT --> DONE - MOUNTED -->|"yes"| EXISTS - EXISTS -->|"yes"| DONE - EXISTS -->|"no"| CREATELINE --> DONE +sequenceDiagram + actor User + participant Conn as Source connector + participant Line as LineMirror + participant Rec as LineReconciler + participant Adapter as ControlledGraph adapter + participant App as Application document + + User->>Conn: pointerDown (arm) → drag threshold + Conn->>Line: createLine() — phase source-start → preview-free + loop Pointer drag + Conn->>Conn: resolve candidate (rules + isValidConnection, phase "candidate") + Conn->>Line: preview-free / preview-target, anchors via geometry writer + end + User->>Conn: drop + Conn->>Conn: validate drop (rules + both endpoints' isValidConnection, real LineMirror) + Conn->>Line: stageTarget() — phase "staged", no topology commitment + Conn->>Rec: dispatch ONE atomic LineChangeRequest + Rec->>Adapter: onLineChangeRequest(request) + Adapter->>App: application applies (or ignores) the request + Adapter->>Rec: post-request microtask push of live records + Rec->>Rec: decisive reconciliation pass + alt id adopted in the snapshot + Rec->>Line: settle staged mirror in place (strict recheck) + else id absent (rejected or ignored) + Rec->>Line: discard staged line — nothing was ever committed + end ``` -Connector registration asks the active controller to reconcile in a -microtask, allowing latent document edges to rehydrate when endpoints mount. -Connector teardown deletes line mirrors with reason `"teardown"` but does not -ask the application to delete its edge record. - -### Gesture connect - -1. Pointer-down arms a connector. -2. Crossing the drag threshold creates a preview `LineComponent` and adds it - to the source’s outgoing list. -3. Dropping on a candidate calls the legacy `onConnectionRequest` seam. -4. If allowed, `connectToConnector({ origin: "gesture" })` commits the local - topology. -5. Connector callbacks fire. -6. The active `EdgeSyncController` emits `onEdgeConnect`. -7. The application is expected to update its edge document synchronously. -8. The adapter queues `sync()` in a microtask. Accepted lines remain; rejected - lines are removed before the next paint when state updates synchronously. +Key properties: + +- **One atomic request per gesture.** + `{ intent: "connect" | "disconnect" | "replace" | "reconnect", add: + ProposedLine[], remove: LineId[], update: LineEndpointUpdate[] }`. A + gesture-created line carries a SnapLine-minted `lineId` in `add`; a + reconnect arrives as an `update` of the existing id; capacity evictions on + a `replace-oldest` target ride the same request as `remove` entries + (intent `"replace"`) — never local deletes. +- **Staging, not optimism.** The drop stages the outcome on the same mirror + (phase `"staged"`); the line is not in the target's incoming list and not + in the settled index. A gesture disconnect stages the detached line and + proposes `remove`; a rejected removal re-glues it from the unchanged + document. +- **Adapters guarantee a post-request push.** Both `ControlledGraph` + components queue `setCanonicalGraph` with the live records in a microtask + ahead of the decisive pass, so acceptance, normalization, rejection, and + 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 + 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 + propose to. + +### The reconcile pass + +`LineReconciler.reconcile()` is one idempotent pass over the cached snapshot. +It is read-only with respect to canonical state: it never emits a request and +never rewrites, reorders, or deletes records. ```mermaid -flowchart TB - DRAG["User: drag source to target"] - PREVIEW["Connector: create preview LineComponent"] - DROP["User: drop on candidate"] - SETTLE["Connector: optimistically settle local topology"] - INTENT["EdgeSync: emit onEdgeConnect intent"] - DECIDE["Application: accept, reject, or normalize edges[]"] - SYNC["EdgeSync: read latest edges[] in queued microtask"] - ACCEPT{"Matching canonical edge exists?"} - KEEP["Preserve line mirror
and render framework SVG"] - DELETE["Delete unmatched mirror
and remove framework SVG"] - - DRAG --> PREVIEW --> DROP --> SETTLE --> INTENT --> DECIDE --> SYNC --> ACCEPT - ACCEPT -->|"yes"| KEEP - ACCEPT -->|"no"| DELETE -``` - -### Gesture disconnect and replacement - -Picking up an existing connection detaches it locally and emits a gesture -disconnect intent. Connecting to a full finite-capacity target deletes the -oldest required incoming lines with reason `"replacement"` before committing -the new line. The current controlled demo updates the document from the -disconnect and connect callbacks in that order. - -Replacement is therefore exposed as multiple ordered intents rather than one -atomic application transaction. - -## Current ownership matrix - -| Data or resource | Current owner | Notes | -| ----------------------------- | ---------------------------------------- | ------------------------------------------------------------------------- | -| Domain node records | Application/framework | SnapLine does not define node factories or node types | -| Mounted node instances | Framework lifecycle + core mirror | Adapter creates/destroys `NodeComponent` | -| Domain connector/port records | Application/framework | Usually expressed by connector children | -| Mounted connector instances | Framework lifecycle + core mirror | Stored in `NodeManager` and the parent node map | -| Domain edges | Application only when `EdgeSync` is used | Otherwise no separate domain edge source is required | -| Settled line topology | Connector arrays | Derived from `edges` in controlled mode; authoritative in imperative mode | -| Preview line | SnapLine | Ephemeral gesture state, not a domain edge | -| Node/connector/line DOM | React/Svelte adapter | Core never structurally inserts, removes, or reparents framework DOM | -| Node live transform | SnapLine during interaction | Framework geometry props can resynchronize it | -| Node persisted transform | Application by convention | Reported through drag commit callbacks | -| Node size DOM | Framework adapter | Core keeps collision size and emits size callbacks | -| Connector geometry | SnapLine | Measured/cached from node and optional connector DOM | -| Line geometry and anchors | SnapLine | Adapter subscribes through `line.onRender()` | -| Selection | SnapLine shared state | Framework receives callbacks; not controlled | -| Group membership | SnapLine derived state | Framework receives deltas | -| Connector policy | Application config copied into core | `canConnect`, capabilities, strategies, metadata | -| Property propagation | SnapLine | Legacy name-keyed node property graph | - -## Registries and organization - -### SnapEngine object table - -All `BaseObject`/`ElementObject` instances also participate in SnapEngine’s -general object table. Some SnapLine code still scans that table: - -- connector candidate discovery; -- group membership node enumeration. - -### `NodeManager` - -`NodeManager` is lazy and per engine. It contains: - -- a `Set`; -- a `Set`; -- the optional active `edgeSync` controller. - -The public `getNodes()`, `getConnectors()`, and `getGroupNodes()` helpers now -delegate to this registry. Registration-order snapshots are returned as -copies. - -`NodeManager` does not currently track: - -- lines; -- semantic IDs; -- domain records; -- selection; -- group membership; -- ownership of supplied versus adapter-created objects. - -### Shared `global.data` - -`snapline-globals.ts` types the SnapLine fields on SnapEngine’s shared data bag: - -- `select`; -- `groups`; -- `resizeHandles`; -- `sourceSurfaces`; -- `resizingNode`; -- deprecated `allowCameraControl`; -- the per-engine `nodeManagers` map. - -The manager is engine-keyed, but several other lists are shared arrays and -must be filtered by engine at their use sites. Selection currently has no -engine-keyed container. - -```mermaid -flowchart TB - GLOBAL["GlobalManager.data"] - TABLE["SnapEngine object table"] - MAP["nodeManagers: Map<Engine, NodeManager>"] - MANAGER["NodeManager for one engine
• Set<NodeComponent>
• Set<ConnectorComponent>
• optional edgeSync controller"] - SHARED["Shared arrays:
select, groups, resizeHandles,
sourceSurfaces"] - LINES["Line topology on connector arrays"] - - GLOBAL --> MAP --> MANAGER - MANAGER --> LINES - GLOBAL --> SHARED - TABLE -->|"still scanned by candidate discovery
and group membership"| MANAGER +flowchart TD + START["reconcile() — coalesced trigger"] + DUP["Index records by id
(duplicates: first wins + diagnostic)"] + PRUNE["Prune settled mirrors whose record is gone
or whose fromConnectorId moved"] + EACH["For each canonical record"] + SETTLED{"Settled mirror
with this id?"} + SAME{"Same target?"} + KEEP["Keep mirror, refresh payload"] + RETARGET["Retarget the SAME mirror
(strict admission recheck)"] + STAGEDQ{"Staged preview
with this id?"} + SETTLE["Settle staged mirror in place
(strict recheck)"] + MOUNTED{"Both endpoints
mounted?"} + LATENT["Latent — silent, retried on registration"] + CREATE["createSettledLineFromRecord()
(strict admission, never evicts)"] + ERR["Rule/capacity refusal →
derived ReconciliationError"] + SWEEP["Discard staged lines whose id
the snapshot declined"] + REPORT["setReconciliationErrors →
onDiagnosticsChanged if changed"] + + START --> DUP --> PRUNE --> EACH --> SETTLED + SETTLED -->|yes| SAME + SAME -->|yes| KEEP + SAME -->|no| RETARGET + SETTLED -->|no| STAGEDQ + STAGEDQ -->|yes| SETTLE + STAGEDQ -->|no| MOUNTED + MOUNTED -->|no| LATENT + MOUNTED -->|yes| CREATE + RETARGET -.->|refused| ERR + SETTLE -.->|refused| ERR + CREATE -.->|refused| ERR + KEEP --> SWEEP + LATENT --> SWEEP + SWEEP --> REPORT ``` -## Public API surface - -The package exports raw TypeScript source and is still pre-1.0. The root entry -point currently exports the following API families. - -### Core objects - -| Export | Main public role | -| ------------------------ | ------------------------------------------------------------------------ | -| `NodeComponent` | Node geometry, selection, resize, connector lookup, property propagation | -| `ConnectorComponent` | Port policy, hit testing, gestures, topology mutation, geometry | -| `LineComponent` | Line endpoints, phase, anchors, payload, render subscription | -| `GroupNodeComponent` | Derived membership and recursive group carry | -| `RectSelectComponent` | Rectangle selection state machine | -| `PlacementController` | Headless placement preview/commit state machine | -| `NodeManager` | Live node/connector registry and edge-sync attachment | -| `EdgeSyncController` | Reconciles connector line mirrors to application edges | - -### Queries and policy helpers - -- `getNodes(engine)` -- `getConnectors(engine)` -- `getGroupNodes(engine)` -- `getSelectedNodes(engine)` -- `getParentGroup(node)` -- `setGroupMembershipResolver(engine, resolver)` -- `resolveConnectorSourceAtPoint(engine, position, node?)` -- `getNodeManager(engine)` - -### Important node configuration and callbacks - -`NodeConfig` covers position locking, resizing, minimum size, resize handles, -metadata, callbacks, and edge-pan behavior. - -`NodeCallbacks` exposes: - -- drag authorization and position resolution; -- selection-mode resolution; -- drag start/live/commit; -- selection changes; -- size, resize-handle, and resize-commit events; -- outgoing line-list changes. - -Notable callable methods include `setSelected()`, `registerDragHandle()`, -`syncDomGeometry()`, `setSizeState()`, `setSize()`, connector lookup, -incoming/outgoing line queries, property propagation, and `destroy()`. - -### Important connector configuration and callbacks - -`ConnectorConfig` includes: - -- construction-time `name`; -- legacy `maxConnectors` and `allowDragOut`; -- independent source/target/reconnect/parallel capabilities; -- surface hit-test and anchor strategies; -- line class, collider radius, metadata, callbacks, and edge pan. - -`ConnectorCallbacks` exposes connection policy, the legacy -`onConnectionRequest` domain seam, pointer/drag lifecycle, candidate changes, -and connect/disconnect events. - -The imperative topology API includes: - -- `connectToConnector()`; -- `disconnectFromConnector()`; -- `deleteLine()` and `deleteAllLines()`; -- `createLine()`; -- direct `outgoingLines` and `incomingLines` getters; -- `updateConfig()` and `bindElement()`; -- geometry, hit-test, and anchor-resolution helpers. - -### Framework packages - -The Svelte package exports: - -- `Node` -- `Group` -- `Connector` -- `Line` -- `Select` -- `Placement` -- `EdgeSync` - -The React package exports the same component families plus: - -- the asset-base `Engine` and engine context helpers; -- `NodeObjectContext`; -- `useNodeHandle()`; -- prop/ref types. - -Adapters accept caller-supplied core objects. Supplied objects are not -destroyed on unmount; adapter-created objects are. - -## Important design tensions in the current code - -These are the main topics to resolve before treating the ownership model as -settled. +Identity is stable across the pass: a `toConnectorId` change **retargets the +same mirror**; a `fromConnectorId` change recreates under the same id (a +line's start connector is fixed at construction). A mid-drag preview leaves +its record latent. Records the mirror cannot represent are never evicted or +rewritten — unmounted endpoints are silently latent and retried; rule or +capacity violations become structured diagnostics. -### 1. Controlled edges are optional +## ConnectorRules and admission -The same connector mutation methods serve both as the imperative source of -truth and as the implementation detail beneath a controlled mirror. There is -no explicit controlled/uncontrolled mode on an engine or connector, so API -callers must understand which authority model is active. - -```mermaid -flowchart TB - API["Same ConnectorComponent mutation API"] - IMP["Imperative mode"] - CTRL["Controlled EdgeSync mode"] - TOPO["Connector arrays are canonical"] - DOC["Application edges[] are canonical"] - MIRROR["Connector arrays are only a mirror"] - - API --> IMP --> TOPO - API --> CTRL --> DOC - DOC --> MIRROR +```ts +interface ConnectorRules { + maxOutgoing: number | "unlimited"; // default "unlimited"; 0 = target-only + maxIncoming: number | "unlimited"; // default 1; 0 = source-only + reconnect: boolean; // default true + allowParallel: boolean; // default false; BOTH endpoints must allow + onFull: "reject" | "replace-oldest"; // default "reject" + isValidConnection?: (proposal: ConnectionProposal) => boolean; +} ``` -### 2. Two application edge-creation seams overlap - -`ConnectorCallbacks.onConnectionRequest` can create a domain edge and attach a -payload before local connection. `EdgeSync.onEdgeConnect` separately asks the -application to create the canonical edge after local connection. Using both -can duplicate policy or mutation work. - -### 3. `EdgeLike` has no edge identity - -An edge is identified only by its source and target endpoint pair. As a -result: - -- duplicate/parallel domain edges collapse during sync; -- `capabilities.allowParallel` cannot be represented by controlled - `EdgeLike[]`; -- a hydrated line has no standard stable edge ID or payload; -- disconnect intents identify an endpoint pair, not a specific edge record. - -### 4. Hydration still applies connector mutation policy - -Document hydration calls `connectToConnector()`, which runs `canConnect`, -parallel checks, and incoming-capacity replacement. A canonical document can -therefore remain unrendered or be reduced/reordered by view-layer policy. -This is a direct question for the ownership specification: are those rules -gesture policy, document validity, or both? - -### 5. Topology internals are publicly mutable +Limits normalize to numbers internally (`"unlimited"` → `Infinity`), and the +roles are derived: `isSource` = `maxOutgoing !== 0`, `isTarget` = +`maxIncoming !== 0`. `isValidConnection` is line-aware — +`{ line, source, target, phase: "candidate" | "drop" }` — synchronous and +side-effect free (candidate discovery calls it per pointer move), and either +endpoint may veto. + +Two admission paths share the structural rules but differ in policy: + +- **Gesture admission** (candidate discovery and drop): a full + `replace-oldest` target still admits — the evictions ride the request. +- **Record admission** (create/retarget/settle from canonical records): + strict — capacity never evicts regardless of `onFull`, and refusal returns + a diagnostic code (`"capacity-exceeded"` / `"connection-rejected"`) + instead of throwing. + +`canConnect(target, line?)` remains public as a read-only admission query. + +## Geometry ownership + +Position and size are SnapLine-owned visual cues; live and settled geometry +never round-trip the framework: + +- During a drag, core mutates `worldTransform` and writes DOM transforms in + scheduled frame stages; group/multi-select drags move every drag root. +- 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 }] })`** + 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. +- Line, selection, and placement geometry use single-owner + `bindGeometryWriter()` bindings that mutate retained SVG/DOM without + framework state; `onStateChange` carries semantic line state separately. + +## Selection and groups + +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 +`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` +owns the rubber-band gesture. + +Group membership is SnapLine-derived interaction state, computed from +measured geometry: ordinary nodes use center containment, nested groups +require full-bounds containment, the smallest eligible group wins by default, +a per-engine `membershipResolver` may override, and cycles are rejected. +Group drags carry members by transform parenting only — never DOM +reparenting, never selection mutation. Membership refreshes when a drag or +resize settles. + +## Registries + +### GraphMirror (per engine) + +`getGraphMirror(engine)` lazy-creates the engine-scoped registry the first +time any mirror registers (constructors register, `destroy()` unregisters — +no adapter wiring). It holds: + +- live sets: nodes, connectors; settled lines and preview lines separately + (every line starts as a preview; `settleLine`/`unsettleLine` move it); +- domain-id indexes `nodesById` / `connectorsById` / `linesById` with a + **first-registration-wins** duplicate policy — a duplicate id never steals + the index entry; it stays unindexed with a `"duplicate-id"` diagnostic + until the conflict resolves, then promotes; +- engine-scoped interaction state: `selection`, `groups`, `resizingNode`, + `parentGroups`, `membershipResolver`; +- the `reconciler` slot (installed by `attachControlledGraph`) and the + coalescing reconciliation scheduler. + +Connector candidate discovery and public queries both read this registry — +there is one enumeration mechanism. + +### query(engine) + +`query(engine)` returns the read-only `GraphQuery` facade: +`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. + +### What stays on global.data, and why + +`SnapLineSharedData` (typed by `snapline-globals.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` + (GlobalManager is application-wide; the WeakMap lets a destroyed engine + release its registry); +- the deprecated `allowCameraControl` boolean for third-party camera + writers (in-repo gesture owners use `engine.input.claimPointer()`). + +## Scheduler and batching + +All reconciliation triggers funnel through +`GraphMirror.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 +went dirty; `runBatch(fn)` is the exception-safe scoped form. `flush()` +(exposed on the `ControlledGraphHandle`) runs any pending or batch-deferred +pass synchronously for vanilla consumers and tests. Gesture dispatch +schedules the decisive pass behind the adapter's post-request push +microtask. + +## Diagnostics + +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: + +- registry duplicate-id conflicts (nodes, connectors, settled lines); +- per-pass reconciliation errors: duplicate record ids in a snapshot, and + records refused by strict admission (preserved but unrepresented). + +They surface through `query(engine).diagnostics()` and through +`onDiagnosticsChanged`, which fires only when the set actually changes. +Unmounted endpoints are deliberately **not** diagnostics — a latent record is +normal during progressive mount. -The connector returns its actual mutable incoming/outgoing arrays. -`LineComponent.start`, `target`, `payload`, anchors, phase, and candidate are -also public writable fields. `NodeComponent` retains several underscore-named -members that are TypeScript-public. Consumers can bypass lifecycle callbacks -and reconciliation invariants. - -### 6. There is no line registry or line query - -Nodes and connectors have a central mirror registry; lines are only discoverable -by walking connector arrays or node outgoing lists. This makes engine-wide -topology inspection and invariant checking asymmetric. - -### 7. Enumeration has two mechanisms - -Public queries use `NodeManager`, while connector target discovery and group -membership still scan SnapEngine’s general object table. The manager is not -yet the single internal organization boundary. - -### 8. Engine scoping is inconsistent - -`NodeManager` is engine-keyed, but selection and several interaction registries -live directly on application-wide `global.data`. Some readers filter by -engine; selected-node queries currently return the shared selection list. - -### 9. Geometry props are cooperative, not strictly controlled - -Core mutates transforms during interaction and adapters only rerun geometry -effects when prop values change. If an application rejects a move by leaving -its canonical coordinates unchanged, the mirror is not automatically reverted. - -### 10. Package exports lag root exports - -`EdgeSync` is exported from the React/Svelte root indexes, but the current -package subpath maps do not expose `./EdgeSync`. The core root exports -`EdgeSyncController`, `NodeManager`, and `getNodeManager`, while its documented -subpath map does not include `./edge-sync` or `./node-manager`. - -### 11. Callback composition is not uniform +## Public API surface -Node and group adapters compose caller callbacks with adapter callbacks. -Selection adapters directly replace `onRectChange`, which can hide a caller’s -original handler. This is an adapter API consistency issue rather than a graph -ownership issue, but it affects how safely applications observe the mirror. +The packages export raw TypeScript source and are pre-1.0. + +### Root exports (`@snap-engine/snapline`) + +| Family | Exports | +| --- | --- | +| Mirrors | `NodeMirror`, `ConnectorMirror`, `LineMirror`, `GroupNodeMirror` | +| 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` | +| 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`. + +There is **no imperative public topology API**: `deleteLine()`, +`deleteAllLines()`, `disconnectFromConnector()`, `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 +records. `GraphMirror` 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 +`source-start | preview-free | preview-target | drop | staged | connected`. + +### Adapters + +Svelte (`@snap-engine/snapline-svelte`) and React +(`@snap-engine/snapline-react`) export `Node`, `Group`, `Connector`, `Line`, +`Select`, `Placement`, and **`ControlledGraph`** (React additionally exports +the asset-base `Engine`, `NodeMirrorContext`, `useNodeHandle`, and prop/ref +types). Adapter contracts: + +- `Node`/`Group` take `id` props (stable domain identity); +- line lists key by `lineId`; `Line` renders `data-line-id`; SVG markers use + `arrow-${lineId}`; +- `ControlledGraph` takes `{ lines, onLineChangeRequest, + onDiagnosticsChanged? }` and implements the guaranteed post-request push; +- supplied core objects are not destroyed on unmount; adapter-created + objects are. + +## Ownership matrix + +| Data or resource | Owner | Notes | +| ----------------------------- | ------------------------------ | ---------------------------------------------------------------------- | +| 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 | +| 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 | +| 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 | +| 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 | +| Diagnostics | SnapLine derived state | Structured, non-throwing, auto-clearing | ## Source map -| Concern | Primary implementation | -| ------------------------------ | ---------------------------------------------- | -| Public exports | `assets/snapline/core/src/index.ts` | -| Node lifecycle and interaction | `assets/snapline/core/src/node.ts` | -| Connector policy and topology | `assets/snapline/core/src/connector.ts` | -| Line state and geometry | `assets/snapline/core/src/line.ts` | -| Live node/connector registry | `assets/snapline/core/src/node-manager.ts` | -| Controlled edge reconciliation | `assets/snapline/core/src/edge-sync.ts` | -| Shared registries | `assets/snapline/core/src/snapline-globals.ts` | -| Engine queries | `assets/snapline/core/src/query.ts` | -| Svelte ownership bridge | `assets/snapline/svelte/src/*.svelte` | -| React ownership bridge | `assets/snapline/react/src/*.tsx` | -| Controlled-edge example | `demo/svelte/src/demo/node_ui_edges/` | -| Controlled-edge browser tests | `tests/e2e/snapline-edges.spec.ts` | +| Concern | Primary implementation | +| ---------------------------------------- | ----------------------------------------------- | +| Public exports | `assets/snapline/core/src/index.ts` | +| Engine-scoped registry, ids, scheduler | `assets/snapline/core/src/graph-mirror.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` | +| Line state, phases, anchors | `assets/snapline/core/src/line.ts` | +| Groups and membership | `assets/snapline/core/src/group.ts` | +| 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` | +| 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` | +| Controlled-graph demos | `demo/svelte/src/demo/node_ui_edges/`, `demo/react/` | +| Unit tests | `tests/ut/snapline-graph-mirror.spec.ts`, `tests/ut/snapline-line-reconciler.spec.ts`, `tests/ut/snapline-connector-config.spec.ts` | +| Browser tests | `tests/e2e/snapline-edges.spec.ts`, `tests/e2e/snapline-edges-react.spec.ts` | diff --git a/docs/snapline/design/migration-notes.md b/docs/snapline/design/migration-notes.md new file mode 100644 index 0000000..d0a4d53 --- /dev/null +++ b/docs/snapline/design/migration-notes.md @@ -0,0 +1,130 @@ +# SnapLine migration notes — the controlled-graph re-architecture + +Status: migration reference for the pre-1.0 re-architecture +Applies to: every consumer upgrading across the `Cleanup` re-architecture + +SnapLine's topology is now **always controlled**: the application's document +is the single source of truth for which nodes, connectors, and lines exist. +SnapLine maintains an engine-scoped runtime mirror, and user gestures arrive +as atomic proposals the application accepts by updating its records. +Position and size stay SnapLine-owned — geometry is a visual cue, observed +(not negotiated) through one batched callback. + +## Type and callback renames + +| Old | New | +| --- | --- | +| `NodeComponent` / `ConnectorComponent` / `LineComponent` / `GroupNodeComponent` (core) | `NodeMirror` / `ConnectorMirror` / `LineMirror` / `GroupNodeMirror` | +| `RectSelectComponent` | `RectSelectController` | +| `NodeObjectContext` (React) | `NodeMirrorContext` | +| `ConnectorLinePhase` | `LineMirrorPhase` (adds `"staged"`) | +| `syncDomGeometry()` | `remeasureDomGeometry()` | +| `onLinesChanged` | unchanged name; payload is `LineMirror`s | +| `onDragCommit` + `onResizeCommit` | one batched `onGeometryChanged({ nodes })` | +| `NodePosition` / `NodeDragCommitEvent` | `NodeGeometry` / `GeometryChangeEvent` | +| `EdgeId` / `EdgeRecord` / `EdgeLike` / `EdgeEndpoint` | `LineId` / `LineRecord` (stable-id, no endpoint-pair keying) | + +## Connector configuration: `capabilities` → `rules` + +`maxConnectors`, `allowDragOut`, and `capabilities` are gone. The mapping: + +| Old | New `rules` | +| --- | --- | +| `allowDragOut: true` (source-only) | `{ maxIncoming: 0 }` | +| `allowDragOut: false, maxConnectors: N` (target-only, finite) | `{ maxOutgoing: 0, maxIncoming: N, onFull: "replace-oldest" }` | +| `maxConnectors: -1` (unlimited) | `maxIncoming: "unlimited"` (the `-1` sentinel is gone) | +| `capabilities.source/target` booleans | derived: `isSource` = `maxOutgoing !== 0`, `isTarget` = `maxIncoming !== 0` | +| implicit oldest-line eviction | explicit `onFull: "reject"` (default) or `"replace-oldest"` | +| `canConnect(event)` pair predicate (callback) | `rules.isValidConnection(proposal)` — line-aware: `{ line, source, target, phase }`; both endpoints may veto; synchronous and side-effect free | +| `onConnectionRequest` (veto + payload) | veto → `isValidConnection`; payload → canonical `LineRecord.payload` | + +Defaults: `maxOutgoing: "unlimited"`, `maxIncoming: 1`, `reconnect: true`, +`allowParallel: false`, `onFull: "reject"`. + +## EdgeSync → ControlledGraph + +The `EdgeSync` component/controller, its `identity()` callback, and +endpoint-pair edge matching are replaced by the controlled-graph protocol: + +```svelte + +``` + +- `lines: readonly LineRecord[]` — your document's records, each + `{ id, fromConnectorId, toConnectorId, payload? }`. Give connectors stable + `id` props (a composite like `` `${node}:${port}` `` works well) instead of + implementing `identity()`. +- `onLineChangeRequest(request)` — ONE atomic proposal per gesture: + `{ intent: "connect" | "disconnect" | "replace" | "reconnect", add, + remove, update }`. Apply it atomically: filter `remove`, apply `update` + endpoint changes, concat `add`. A capacity replacement arrives as a single + `"replace"` (removals + addition together), never as ordered intents. +- **Adopt the proposed ids.** A gesture-created line carries a + SnapLine-minted `LineId`; keeping it in the record you add settles the + dragged line in place (no flicker, same mirror). Substituting your own id + works but recreates the mirror. +- Rejection needs no code path: apply nothing and the staged line is + discarded on the next pass (the adapter guarantees a post-request push of + your latest records). +- Diagnostics: records the mirror cannot represent (missing endpoints stay + silently latent; capacity/rule violations) surface through + `onDiagnosticsChanged` / `query(engine).diagnostics()` — canonical records + are never rewritten or evicted by SnapLine. + +## Imperative topology API removal + +`connectToConnector()`, `deleteLine()`, `disconnectFromConnector()`, +`deleteAllLines()`, and `createLine()` are no longer public. Create and +remove lines by changing your records; `canConnect(target, line?)` remains +as a read-only admission query. A gesture on an engine with no attached +graph owner warns and discards the preview. + +**Vanilla JS** consumers own the graph with a plain module — a mini emulated +framework holding the records: + +```ts +import { attachControlledGraph } from "@snap-engine/snapline"; + +let lines = []; +const handle = attachControlledGraph(engine, { + onLineChangeRequest(request) { + lines = [ + ...lines + .filter((record) => !request.remove.includes(record.id)) + .map((record) => { + const update = request.update.find((u) => u.id === record.id); + return update + ? { ...record, toConnectorId: update.toConnectorId } + : record; + }), + ...request.add, + ]; + handle.setCanonicalGraph({ lines }); + }, +}); +handle.setCanonicalGraph({ lines }); +``` + +## Stable identity + +Every mirror has a domain id — `nodeId` / `connectorId` / `lineId` — +supplied via the `id` prop/config or minted (`node-42`-style) when omitted. +Minted ids are stable only for the mirror's lifetime: any graph that +outlives it (persistence, remounts, reloads) must supply its own ids, or +stored `LineRecord`s will reference dead connector ids. Adapter line lists +key by `lineId`, and line SVGs carry `data-line-id`. + +## Property propagation removal + +The node property bag (`setProp` / `getProp` / `addSetPropCallback` / +`propagateProp`) is gone. Dataflow belongs to the application graph: derive +values from your own document (the same records that drive +`ControlledGraph`) and render them through normal framework state. + +## Geometry + +`onGeometryChanged({ nodes: [{ node, x, y, width, height }] })` fires once +per settled drag (every moved node of a group/multi-select drag in one +event) or resize (single entry). SnapLine owns live and settled geometry; +persist the observation if you want it back after a reload — ignoring it +never reverts the mirror. diff --git a/docs/snapline/design/ownership-specification.md b/docs/snapline/design/ownership-specification.md index e17d5c9..fd9ac55 100644 --- a/docs/snapline/design/ownership-specification.md +++ b/docs/snapline/design/ownership-specification.md @@ -1,10 +1,11 @@ # SnapLine framework ownership specification -Status: proposed review specification +Status: normative ownership contract — implemented by the controlled-graph +re-architecture Reviewed: 2026-07-25 Companion: [current architecture](./current-architecture.md) -This document proposes the ownership contract implied by the project +This document defines the ownership contract implied by the project direction: > The application/framework owns the source of truth for which nodes, @@ -72,9 +73,9 @@ answer: A SnapLine runtime object associated with a domain entity: -- `NodeComponent`; -- `ConnectorComponent`; -- settled `LineComponent`. +- `NodeMirror`; +- `ConnectorMirror`; +- settled `LineMirror`. A mirror may contain interaction state and geometry that is absent from the domain document. @@ -120,7 +121,7 @@ The application/framework MUST be the sole authority for committed node, connector, and edge existence. SnapLine MUST NOT treat a committed gesture result, registry entry, DOM -element, connector array, or `LineComponent` as proof that a domain entity +element, connector array, or `LineMirror` as proof that a domain entity exists. ### O2. Runtime mirrors @@ -144,7 +145,7 @@ Core MAY update existing-element properties that are interaction outputs: - transforms; - `data-*` state attributes; - cursor and other transient property-level hints; -- line-render subscription state. +- high-frequency geometry through registered imperative writers. Core MUST NOT structurally move framework-owned node elements when groups carry them. Transform parenting is allowed. @@ -177,15 +178,15 @@ copies of complete domain records. | Concern | Required authority | Allowed SnapLine mirror | | ---------------------------- | ------------------------------------ | ---------------------------------------------------------------- | -| Node existence | Domain/framework collection | `NodeComponent` while mounted | -| Connector existence | Domain/framework node/port rendering | `ConnectorComponent` while mounted | -| Edge existence | Domain edge collection | Settled `LineComponent` while resolvable | -| Preview connection | SnapLine gesture | Targetless preview `LineComponent` | +| Node existence | Domain/framework collection | `NodeMirror` while mounted | +| Connector existence | Domain/framework node/port rendering | `ConnectorMirror` while mounted | +| Edge existence | Domain edge collection | Settled `LineMirror` while resolvable | +| Preview connection | SnapLine gesture | Targetless preview `LineMirror` | | Node persisted position/size | Domain/application | Live transform and collision box | | Node live drag/resize | SnapLine | Transient local geometry | -| Connector policy | Application configuration | Normalized core capabilities | -| Selection | Open decision | Current SnapLine mirror is acceptable if explicitly uncontrolled | -| Group membership | Open decision | Current geometry-derived mirror | +| Connector policy | Application configuration | Normalized core rules | +| Selection | SnapLine (decided) | Engine-scoped mirror state; framework owns visuals | +| Group membership | SnapLine (decided) | Geometry-derived mirror state | | DOM structure | Framework adapter | Element handles only | | Line path geometry | SnapLine | Anchors, phase, candidate, render snapshot | @@ -238,9 +239,10 @@ interface ConnectorIdentity { Connector display names and DOM positions MUST NOT be the only identity. -The adapter MAY derive this identity from props. A generic core integration -MAY provide an identity callback. Metadata is acceptable as an integration -bridge but SHOULD NOT be the only long-term typed API. +The adapter MAY derive this identity from props (as shipped: typed `id` +props/config supplying the graph-global `connectorId`). Metadata is +acceptable as an integration bridge but SHOULD NOT be the only long-term +typed API. ### I3. Edge identity @@ -291,7 +293,7 @@ When a framework node mounts, its mirror MUST register exactly once. When an adapter-owned node unmounts, its mirror MUST unregister and destroy exactly once. -Supplying an externally owned `NodeComponent` MUST transfer neither domain +Supplying an externally owned `NodeMirror` MUST transfer neither domain ownership nor destruction responsibility to the adapter. ### L2. Connector mount and unmount @@ -398,9 +400,9 @@ MUST cancel cleanly. Gesture policy and document reconciliation MUST be distinct concepts. -`canConnect`, target capacity, reconnect behavior, and snapping rules MAY -reject or transform a proposed user intent. They MUST NOT silently rewrite the -canonical document during hydration. +`isValidConnection`, target capacity, reconnect behavior, and snapping rules +MAY reject or transform a proposed user intent. They MUST NOT silently +rewrite the canonical document during hydration. If a canonical edge cannot be represented, SnapLine MUST use one explicitly chosen policy: @@ -424,24 +426,28 @@ Multiple same-task triggers SHOULD coalesce. ## Gesture requirements ```mermaid -flowchart TB - BEGIN["User: begin connector drag"] - PREVIEW["SnapLine: create ephemeral preview"] - DROP["User: drop on candidate"] - INTENT["SnapLine: emit semantic connect intent"] - DECISION{"Application decision"} - ACCEPT["Accept / normalize:
update canonical document"] - REJECT["Reject:
leave document unchanged"] - DEFER["Defer:
keep explicitly pending"] - READ["SnapLine: reconcile latest document"] - SETTLE["Preserve or create settled line mirror"] - REMOVE["Remove optimistic preview / mirror"] - LATER["Application: apply later decision"] - - BEGIN --> PREVIEW --> DROP --> INTENT --> DECISION - DECISION -->|"accepted"| ACCEPT --> READ --> SETTLE - DECISION -->|"rejected"| REJECT --> REMOVE - DECISION -->|"deferred"| DEFER --> LATER --> READ +sequenceDiagram + actor User + participant SL as SnapLine + participant App as Application + canonical document + + User->>SL: Begin connector drag + SL->>SL: Create ephemeral preview + User->>SL: Drop on candidate + SL->>App: Emit semantic connect intent + alt Accepted or normalized + App->>App: Update canonical edge document + SL->>App: Read latest document + SL->>SL: Preserve or create settled line mirror + else Rejected + App->>App: Leave document unchanged + SL->>App: Read latest document + SL->>SL: Remove optimistic preview / mirror + else Deferred + App-->>SL: Keep result explicitly pending + App->>App: Apply later decision + SL->>App: Reconcile final document + end ``` ### G1. Preview @@ -527,7 +533,7 @@ Adapters MUST render node/group width and height. Core MAY update collision state synchronously and request a rendered size through callbacks. -Adapters MUST call `syncDomGeometry()` after the committed element and +Adapters MUST call `remeasureDomGeometry()` after the committed element and dimensions are available. ### A4. Callback composition @@ -604,10 +610,10 @@ The intended public API SHOULD separate four categories. Declarative policy and presentation hooks: -- connector capabilities; +- connector rules (`ConnectorRules`, including `isValidConnection`); - hit-test and anchor strategies; - drag/selection/resize policy; -- metadata or typed domain identity; +- typed domain identity (`id` props/config) and metadata; - line renderer selection. ### Intents and lifecycle events @@ -639,89 +645,78 @@ Read-only snapshots: Commands SHOULD be explicit about authority: - interaction commands that change only transient state; -- `sync()`/reconciliation commands; -- unmanaged imperative topology commands for vanilla use. - -An imperative `connect()` command MUST either be unavailable in controlled -mode or clearly mean “emit an application intent,” not “override the -canonical document.” - -## Vanilla/unmanaged mode - -Vanilla consumers may reasonably choose an imperative graph without a -framework store. That mode can coexist with the controlled model if it is -explicit. +- reconciliation commands (`setCanonicalGraph()` pushes the canonical + snapshot; `flush()` runs a pending pass synchronously). -In unmanaged mode: +As shipped there are no public imperative topology commands: the canonical +document is the only way to create or remove lines, and gestures emit +application requests rather than overriding the document. -- the consumer owns calling constructors and `destroy()`; -- connector topology may be the source of truth; -- imperative connect/disconnect commands are allowed; -- DOM ownership remains with the consumer; -- controlled-edge reconciliation is absent. +## Vanilla consumers -Mixing controlled and unmanaged connectors on one engine MAY be supported, but -the identity callback MUST clearly return `null` for unmanaged connectors and -cross-boundary lines need an explicit policy. - -```mermaid -flowchart TB - ENGINE["One SnapLine engine"] - CONTROLLED["Controlled graph partition"] - UNMANAGED["Unmanaged / imperative partition"] - DOC["Application document is canonical"] - ARRAYS["Connector topology is canonical"] - BOUNDARY{"Cross-partition line policy
must be explicit"} - - ENGINE --> CONTROLLED --> DOC - ENGINE --> UNMANAGED --> ARRAYS - CONTROLLED --> BOUNDARY - UNMANAGED --> BOUNDARY -``` +The formerly proposed unmanaged/imperative mode was **eliminated during +implementation** (decided): topology is always controlled, and every engine +has exactly one authority model. A vanilla consumer owns the graph with a +plain module — a mini emulated framework holding the records — driving the +same `attachControlledGraph`/`setCanonicalGraph` contract (see the +[migration notes](./migration-notes.md) for a complete example). The +consumer still owns constructors, `destroy()`, and its DOM directly. ## Current conformance snapshot -| Requirement area | Current status | Comment | -| ----------------------------------- | ------------------ | --------------------------------------------------------- | -| Framework-owned node existence | Mostly conforms | Mount/unmount controls adapter-owned `NodeComponent` | -| Framework-owned connector existence | Mostly conforms | Mount/unmount controls adapter-owned connector | -| Framework-owned structural DOM | Conforms by design | Core performs property/transform writes only | -| Canonical application edges | Partial | Available only through optional `EdgeSync` | -| Fresh/idempotent edge sync | Mostly conforms | `getEdges()` is fresh and sync mutations suppress intents | -| Latent edge remount | Mostly conforms | Connector registration queues reconciliation | -| Preview isolation | Conforms | Targetless drag lines are skipped by sync | -| Stable edge identity | Does not conform | `EdgeLike` is endpoint-pair only | -| Parallel controlled edges | Does not conform | Endpoint pairs collapse | -| Hydration/policy separation | Does not conform | Hydration uses `connectToConnector()` policy/capacity | -| Atomic replacement intent | Does not conform | Separate disconnect then connect events | -| Read-only topology views | Does not conform | Connector arrays are returned directly | -| Unified mirror registry | Partial | Manager tracks nodes/connectors; other scans and no lines | -| Engine isolation | Partial | Several shared registries are not engine-keyed | -| Strictly controlled geometry | Not defined | Current behavior is cooperative/local-first | -| Uniform callback composition | Partial | Node/group compose; selection replaces rect callback | -| Explicit controlled/unmanaged mode | Does not conform | Both authority models share the same mutation surface | - -## Open decisions for review - -1. Should controlled edges become the default and recommended mode, with the - current imperative topology API explicitly labeled unmanaged? -2. Does every edge need a stable `edgeId`, or should SnapLine formally prohibit - parallel controlled edges? -3. Should connector identity become a typed constructor/adapter prop instead - of an `identity(connector)` callback commonly backed by metadata? -4. Should `NodeManager` remain public, become internal, or evolve into a - read-only graph-mirror service? -5. Should settled lines be centrally registered and queryable by edge ID? -6. Should hydration bypass gesture predicates and capacity, or report - structured document-invalid diagnostics? -7. Should replacement and reconnect be atomic edge-change intents? -8. Should `onConnectionRequest` be removed in favor of the controlled-edge - intent API, retained only for unmanaged mode, or merged with it? -9. Are node positions and sizes controlled props, commit outputs, or both via - separate `value`/`defaultValue`-style APIs? -10. Should selection and group membership remain SnapLine-owned derived state, - or also become optionally controlled application state? -11. Can controlled and unmanaged connectors coexist on one engine, and may a - line cross that boundary? -12. Which low-level methods and fields are genuinely public for 1.0, and which - should become private/internal before the API settles? +The controlled-graph re-architecture implements this specification in full. +Stable-id records, atomic requests, hydration that never mutates the +document, structured diagnostics, and engine scoping are all shipped: + +| Requirement area | Current status | Comment | +| ----------------------------------- | -------------- | -------------------------------------------------------------------------------- | +| Framework-owned node existence | Conforms | Mount/unmount controls adapter-owned `NodeMirror`; stable `nodeId` via `id` prop | +| Framework-owned connector existence | Conforms | Mount/unmount controls the mirror; graph-global `connectorId` | +| Framework-owned structural DOM | Conforms | Core performs property/transform writes only | +| Canonical application lines | Conforms | Topology is always controlled: `attachControlledGraph` + pushed `LineRecord[]` | +| Fresh/idempotent reconciliation | Conforms | Cached pushed snapshot; coalesced idempotent passes never emit requests | +| Latent line remount | Conforms | Connector registration schedules a pass; unmounted endpoints stay silently latent | +| Preview isolation | Conforms | Previews/staged lines live outside the settled index; never document-driven | +| Stable line identity | Conforms | `LineRecord.id`; mirrors preserved/retargeted by `lineId`; adapters key by it | +| Parallel controlled lines | Conforms | Stable ids plus `allowParallel` on both endpoints | +| 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 | +| 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 | +| Explicit authority model | Conforms | Exactly one model: always controlled; no imperative public topology surface | + +## Open decisions — all decided and shipped + +Every question raised for review was decided during the re-architecture and +is implemented: + +1. Controlled default? Decided further: topology is **always** controlled — + the imperative topology API was removed entirely, not merely labeled. +2. Stable edge id? Yes — `LineRecord.id` is required, and parallel lines are + 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 + 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. +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 + `"replace"`/`"reconnect"` intents; evictions ride the request. +8. `onConnectionRequest`? Removed — veto moved to `isValidConnection`, + 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`. +10. Selection and groups? They remain SnapLine-owned derived state, + engine-scoped on `GraphMirror` (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. +12. Public surface? Topology mutators are `@internal`, `LineMirror` state is + getter-backed, and the public surface is mirror configs/callbacks, + `query`, and `attachControlledGraph`. diff --git a/docs/snapline/design/planned-rearchitecture.md b/docs/snapline/design/planned-rearchitecture.md new file mode 100644 index 0000000..2215504 --- /dev/null +++ b/docs/snapline/design/planned-rearchitecture.md @@ -0,0 +1,52 @@ +# SnapLine planned re-architecture + +Status: complete — retained as the decision record for the re-architecture. +Started: 2026-07-25 +Related: +[current architecture](./current-architecture.md) · +[ownership specification](./ownership-specification.md) · +[migration notes](./migration-notes.md) + +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` +registry, engine scoping, `ConnectorRules`, the controlled line protocol +(`attachControlledGraph` / `setCanonicalGraph` / `onLineChangeRequest`), +batching, diagnostics, the `ControlledGraph` adapters, property-propagation +removal, the geometry consolidation, identity props, stable line keying, and +the package export alignment — is implemented and verified; it is described +in [current architecture](./current-architecture.md) and, for upgraders, in +[migration notes](./migration-notes.md). + +## Amendments adopted during implementation + +Two decisions were amended from the original plan (both user-directed): + +1. **The uncontrolled graph mode was eliminated entirely.** Topology — + which nodes, connectors, and lines exist — is always controlled: the + application document is the single source of truth, and the imperative + public topology commands were removed rather than gated. Future + vanilla-JS support is a pure-JS graph-owner module driving the same + `attachControlledGraph`/`setCanonicalGraph` contract (a mini emulated + framework), so the core stays common across frameworks. +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` + + `onResizeCommit`. + +## Status: complete + +The final simplification review has landed: the gesture and record admission +checks share one structural check plus one predicate check, the settle path's +redundant eviction block and the reconciler's unused local-topology notify +forwarding are gone, remaining single-class `_`-prefixed fields moved to `#` +privates (with `_connectors` documented as the one deliberate cross-class +field), and an informational perf benchmark +(`tests/ut/snapline-perf.spec.ts`) guards the reconcile / candidate-discovery +/ bulk-load hot paths. The complete verification matrix — unit suites, all +six SnapLine e2e suites, package validation, and the docs e2e — passes. + +This document is retained as the record of the re-architecture's decisions +and amendments; the implementation is described in +[current architecture](./current-architecture.md). diff --git a/docs/snapline/guides/01_core_concepts.mdx b/docs/snapline/guides/01_core_concepts.mdx index 88cffbd..86e4caf 100644 --- a/docs/snapline/guides/01_core_concepts.mdx +++ b/docs/snapline/guides/01_core_concepts.mdx @@ -16,15 +16,19 @@ sectionOrder: 2 - One SnapEngine supplies input dispatch, scheduling, collision data, and the camera coordinate transform. -- A `NodeComponent` owns connector objects and graph properties. -- A `ConnectorComponent` describes direction, capacity, metadata, and +- A `NodeMirror` owns connector objects and mirrors one node record of your + graph document. +- A `ConnectorMirror` describes direction, capacity, metadata, and connection policy. -- A `LineComponent` joins two connectors and keeps its SVG path attached while +- A `LineMirror` joins two connectors and keeps its SVG path attached while either node moves. -`maxConnectors={-1}` permits unlimited incoming connections, `0` makes an -output-only connector, and a positive number caps incoming connections. -`allowDragOut` controls whether a connection gesture can begin there. +Connector `rules` are symmetric limits: `maxIncoming`/`maxOutgoing` accept a +count, `0` (disables that direction), or the explicit `"unlimited"`. A +source-only output is `{ maxIncoming: 0 }`; a single-input port is +`{ maxOutgoing: 0, maxIncoming: 1 }`. Topology is always controlled — lines +exist because your document's `LineRecord`s say so, and gestures propose +changes through `ControlledGraph`'s `onLineChangeRequest`. ## Coordinates diff --git a/docs/snapline/guides/02_selection_resize.mdx b/docs/snapline/guides/02_selection_resize.mdx index 45e32d1..ba0cf52 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"]} - onResizeCommit={(event) => saveSize(event)} + onGeometryChanged={(event) => saveGeometry(event)} /> ``` @@ -51,13 +51,16 @@ Set `resizable` to enable all eight handles, or pass an exact list: width={node.width} height={node.height} resizeHandles={["e", "se", "s"]} - onResizeCommit={saveSize} + onGeometryChanged={saveGeometry} /> ``` -Geometry props are hybrid-controlled: changed props resynchronize the object, -while active drags update locally without waiting for an application render. -Persist final values from `onDragCommit` and `onResizeCommit`. +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 +node in one event (`{ nodes: [{ node, x, y, width, height }] }`), a resize +reports a single entry. Register a specific header or handle with `registerDragHandle()` (Vanilla), `useNodeHandle()` (React), or the component’s exposed node object (Svelte) when diff --git a/docs/snapline/guides/05_state_styling_accessibility.mdx b/docs/snapline/guides/05_state_styling_accessibility.mdx index 86e309f..ad4ded5 100644 --- a/docs/snapline/guides/05_state_styling_accessibility.mdx +++ b/docs/snapline/guides/05_state_styling_accessibility.mdx @@ -43,7 +43,7 @@ instead of expecting SnapLine to bind Delete, Shift, or platform modifiers. ## Troubleshooting - A line that starts in the wrong place usually means a Vanilla integration did - not call `syncDomGeometry()` after the connector DOM committed. + not call `remeasureDomGeometry()` after the connector DOM committed. - A node that moves twice is usually being moved both from application pointer handlers and SnapLine. - Group membership updates only at settle points by design. diff --git a/docs/snapline/guides/06_surface_connectors.mdx b/docs/snapline/guides/06_surface_connectors.mdx index 3b6a532..8c261c2 100644 --- a/docs/snapline/guides/06_surface_connectors.mdx +++ b/docs/snapline/guides/06_surface_connectors.mdx @@ -16,12 +16,12 @@ as their own ports: ```svelte ``` -`virtual` omits the connector element. The logical `ConnectorComponent` still +`virtual` omits the connector element. The logical `ConnectorMirror` still belongs to its node, participates in connection policy, and owns its lines. Source surfaces are registered with the engine's input router, so their hit area may extend beyond the parent node's DOM box (for example, a 16-pixel rim @@ -84,39 +84,29 @@ Hit testing and line attachment are separate: This separation allows a pointer to activate a wide border band while the visible line remains attached to the exact rim of a circle or rectangle. -## Keep application data authoritative +## Your document stays authoritative -SnapLine owns gesture state and connector topology. Your React or Svelte graph -should remain the source of truth for node labels, edge styles, persistence, -and domain behavior. +Topology is always controlled: your graph document is the single source of +truth for which lines exist. Mount `ControlledGraph` with your `LineRecord`s +and apply each gesture's atomic proposal: -For gesture-created connections, a source connector can synchronously accept or -reject the request and attach an opaque payload: - -```ts -const callbacks: ConnectorCallbacks = { - onConnectionRequest({ source, target }) { - const edge = addEdgeToApplicationState(source, target); - return { payload: edge.id }; - }, -}; -``` - -The payload is retained on `LineComponent`. A custom line renderer can use that -stable ID to read the current edge from framework state. Programmatic hydration -passes the same payload directly: - -```ts -source.connectToConnector({ - target, - origin: "hydration", - payload: edge.id, -}); +```svelte + applyToDocument(request)} +/> ``` -Programmatic connections do not call `onConnectionRequest`, so restoring a -saved graph cannot accidentally create a second application edge. - -Node and connector `metadata` remain available for small integration hints. -Treat metadata and line payloads as opaque links, not as a second copy of the +A gesture-created line carries a SnapLine-minted stable id in +`request.add[0].id`; keeping that id in the record you store settles the +dragged line in place — no flicker, same mirror. Records carry an optional +`payload` that lands on `LineMirror.payload` for custom renderers, and +admission rules can inspect the proposed line through +`rules.isValidConnection({ line, source, target, phase })` on either +endpoint. + +Restoring a saved graph is just pushing its records — hydration never calls +your request handler, so a reload cannot create duplicate application edges. +Node and connector `metadata` remain available for small integration hints; +treat metadata and payloads as opaque links, not as a second copy of the application graph. diff --git a/docs/snapline/introduction/01_setup.mdx b/docs/snapline/introduction/01_setup.mdx index 7bea683..de8d275 100644 --- a/docs/snapline/introduction/01_setup.mdx +++ b/docs/snapline/introduction/01_setup.mdx @@ -25,36 +25,83 @@ npm install react react-dom @snap-engine/snapline-react ```svelte framework=svelte - + + Source - + - + Result - + ); @@ -64,25 +111,54 @@ export function Graph() { ```ts framework=vanilla import { Engine } from "@snap-engine/core"; import { CollisionEngine } from "@snap-engine/core/collision"; -import { ConnectorComponent, NodeComponent } from "@snap-engine/snapline"; +import { + ConnectorMirror, + NodeMirror, + attachControlledGraph, + type LineRecord, +} from "@snap-engine/snapline"; const engine = new Engine(); engine.setCollisionEngine(new CollisionEngine()); engine.assignDom(document.querySelector("#graph")!); -const source = new NodeComponent(engine, null); +// A vanilla app owns the graph with a plain module: hold the records, +// apply each atomic proposal, and push the latest snapshot back. +let lines: LineRecord[] = []; +const handle = attachControlledGraph(engine, { + onLineChangeRequest(request) { + lines = [ + ...lines + .filter((record) => !request.remove.includes(record.id)) + .map((record) => { + const update = request.update.find((u) => u.id === record.id); + return update + ? { ...record, toConnectorId: update.toConnectorId } + : record; + }), + ...request.add, + ]; + handle.setCanonicalGraph({ lines }); + }, +}); +handle.setCanonicalGraph({ lines }); + +const source = new NodeMirror(engine, null, { id: "source" }); source.element = document.querySelector("#source")!; source.worldTransform = { x: 80, y: 90 }; -source.syncDomGeometry(); +source.remeasureDomGeometry(); -const output = new ConnectorComponent(engine, source, { +const output = new ConnectorMirror(engine, source, { + id: "source:value", name: "value", - maxConnectors: -1, - allowDragOut: true, + rules: { maxIncoming: 0 }, }); source.addConnectorObject(output); ``` Adapters create and destroy core objects automatically unless you supply an -existing object. Vanilla integrations assign committed DOM elements and call -`syncDomGeometry()` after layout. +existing object. Topology is always controlled: your line records are the +single source of truth, gestures arrive as one atomic `LineChangeRequest` +each, and adopting a proposed line's id settles the dragged line in place. +Vanilla integrations assign committed DOM elements and call +`remeasureDomGeometry()` after layout. diff --git a/docs/snapline/reference/index.mdx b/docs/snapline/reference/index.mdx index 7837a32..0ca7aa3 100644 --- a/docs/snapline/reference/index.mdx +++ b/docs/snapline/reference/index.mdx @@ -11,9 +11,9 @@ React pages. | Package | Primary exports | | --- | --- | -| `@snap-engine/snapline` | Node, connector, line, selection, group, placement, and query core APIs | -| `@snap-engine/snapline-svelte` | `Node`, `Connector`, `Line`, `Select`, `Group`, `Placement` | -| `@snap-engine/snapline-react` | `Engine`, `Node`, `Connector`, `Line`, `Select`, `Group`, `Placement` | +| `@snap-engine/snapline` | Node, connector, line, selection, group, placement, controlled-graph, and query core APIs | +| `@snap-engine/snapline-svelte` | `Node`, `Connector`, `Line`, `Select`, `Group`, `Placement`, `ControlledGraph` | +| `@snap-engine/snapline-react` | `Engine`, `Node`, `Connector`, `Line`, `Select`, `Group`, `Placement`, `ControlledGraph` | Supported core subpaths are `node`, `connector`, `line`, `select`, `group`, -`placement`, and `query`. +`placement`, `query`, `graph-mirror`, `line-reconciler`, and `geometry`. diff --git a/docs/snapline/reference/react/group.mdx b/docs/snapline/reference/react/group.mdx index edfb2c1..a921fef 100644 --- a/docs/snapline/reference/react/group.mdx +++ b/docs/snapline/reference/react/group.mdx @@ -12,7 +12,7 @@ frameworkKey: group `headerContent`, `canContain`, `groupCallbacks`, and membership/commit convenience callbacks. -The forwarded ref is the `GroupNodeComponent`. The group renders a +The forwarded ref is the `GroupNodeMirror`. The group renders a pointer-transparent body, an interactive header, and visual resize cues backed by core collision handles. diff --git a/docs/snapline/reference/react/index.mdx b/docs/snapline/reference/react/index.mdx index 2f17831..009c02f 100644 --- a/docs/snapline/reference/react/index.mdx +++ b/docs/snapline/reference/react/index.mdx @@ -12,12 +12,13 @@ frameworkKey: overview | Component | Important props | | ----------- | ------------------------------------------------------------------------------------------------------- | -| `Node` | `nodeObject`, geometry, resize configuration, callbacks, `lineComponent`, `elementProps` | -| `Connector` | `connectorObject`, legacy port configuration, `virtual`, `capabilities`, `surfaceStrategies`, callbacks | +| `Node` | `id`, `nodeObject`, geometry, resize configuration, callbacks, `onGeometryChanged`, `lineComponent`, `elementProps` | +| `Connector` | `id`, `connectorObject`, `rules`, `virtual`, `surfaceStrategies`, metadata, callbacks | | `Line` | `line`, SVG presentation | | `Select` | callbacks and presentation | | `Group` | node geometry, header content, membership policy and callbacks | | `Placement` | controller, cancellation behavior, render function | +| `ControlledGraph` | `lines` (your `LineRecord`s), `onLineChangeRequest`, `onDiagnosticsChanged` | `Node` and `Group` forward refs to their core objects. `Connector` exposes a `ConnectorRef`, and `useNodeHandle()` returns a callback ref for a dedicated @@ -25,8 +26,10 @@ drag surface. Set `virtual` when a connector should use the parent node's custom shape surfaces without rendering a port element. Opaque connection payloads remain on -`LineComponent`, so a custom `lineComponent` can resolve presentation from -application-owned edge state. +`LineMirror`, so a custom `lineComponent` can resolve presentation from +application-owned edge state. Custom renderers mount static structure and call +`line.bindGeometryWriter(...)` from a layout effect to update retained SVG, +Canvas, or graphics refs without rendering React on pointer movement. Connector callbacks, metadata, policy, surface strategies, collider radius, edge-pan behavior, and line class update across renders. `virtual` can also be diff --git a/docs/snapline/reference/react/placement.mdx b/docs/snapline/reference/react/placement.mdx index 9e80b33..1feba55 100644 --- a/docs/snapline/reference/react/placement.mdx +++ b/docs/snapline/reference/react/placement.mdx @@ -8,8 +8,10 @@ framework: react frameworkKey: placement --- -Pass a core `PlacementController` to `Placement`. Supply `children` as a -render function when an active preview should be displayed. +Pass a core `PlacementController` to `Placement`. Supply `children` as the +static preview content. The adapter mounts an absolute wrapper and the +controller updates its transform, size, visibility, and `data-allowed` +imperatively, so pointer movement does not render React. The component forwards pointer movement, primary commit, secondary-button cancel, outside cancel, and Escape cancel according to its boolean props. diff --git a/docs/snapline/reference/svelte/group.mdx b/docs/snapline/reference/svelte/group.mdx index da9ff88..ffc253e 100644 --- a/docs/snapline/reference/svelte/group.mdx +++ b/docs/snapline/reference/svelte/group.mdx @@ -16,6 +16,6 @@ Use `title` for a text header or `headerContent` for a custom snippet. The header is the move surface; the group body remains pointer-transparent so member nodes receive input. -`onMembershipChange`, `onResizeCommit`, and `onDragCommit` are convenience +`onMembershipChange` and `onGeometryChanged` are convenience callbacks composed with their callback-object equivalents. `getNodeObject()` returns the core group. diff --git a/docs/snapline/reference/svelte/index.mdx b/docs/snapline/reference/svelte/index.mdx index 780917b..bb8846a 100644 --- a/docs/snapline/reference/svelte/index.mdx +++ b/docs/snapline/reference/svelte/index.mdx @@ -12,12 +12,13 @@ Wrap components in `Engine` from `@snap-engine/asset-base-svelte`. | Component | Important props | | ----------- | ------------------------------------------------------------------------------------------------------ | -| `Node` | `nodeObject`, `x`, `y`, `width`, `height`, resize configuration, callbacks, `elementProps` | -| `Connector` | `name`, legacy port configuration, `virtual`, `capabilities`, `surfaceStrategies`, metadata, callbacks | +| `Node` | `id`, `nodeObject`, `x`, `y`, `width`, `height`, resize configuration, callbacks, `onGeometryChanged`, `elementProps` | +| `Connector` | `id`, `name`, `rules`, `virtual`, `surfaceStrategies`, metadata, callbacks | | `Line` | `line` | | `Select` | `callbacks`, `className` | | `Group` | Node geometry plus `title`, `headerContent`, `canContain`, membership callbacks | | `Placement` | `controller`, cancellation options, `preview` snippet | +| `ControlledGraph` | `lines` (your `LineRecord`s), `onLineChangeRequest`, `onDiagnosticsChanged` | `getNodeObject()` exposes the underlying object from `Node` and `Group`; `Connector.object()` exposes its connector. Supplied objects are not destroyed @@ -25,14 +26,16 @@ when the component unmounts. A virtual connector renders no port element. Its source and target surfaces are resolved against the parent node, which is useful for whole-border diagram -connections. Connection-request payloads stay opaque to the adapter and are -available to custom `LineSvelteComponent` renderers through `LineComponent`. +connections. `LineRecord.payload` stays opaque to the adapter and is +available to custom `LineSvelteComponent` renderers through `LineMirror`. +Custom renderers register `line.bindGeometryWriter(...)` on mount and mutate +retained SVG, Canvas, or graphics refs directly. Connector callbacks, metadata, policy, surface strategies, collider radius, edge-pan behavior, and line class are reactive. `virtual` can also be toggled without replacing the logical connector or its lines. `name` and `connectorObject` are construction-time identities. -Changed geometry props are authoritative. During a live pointer gesture the -adapter renders local updates and reports the final values through commit -callbacks. +Changed geometry props resynchronize the object deliberately. During a live +pointer gesture core writes retained element geometry directly; the batched +`onGeometryChanged` reports final values for application persistence. diff --git a/docs/snapline/reference/svelte/placement.mdx b/docs/snapline/reference/svelte/placement.mdx index ddc150a..14dd631 100644 --- a/docs/snapline/reference/svelte/placement.mdx +++ b/docs/snapline/reference/svelte/placement.mdx @@ -9,8 +9,9 @@ frameworkKey: placement --- Pass a core `PlacementController` to `Placement`. The component listens at -window scope while the controller is active and optionally renders -`preview(snapshot)`. +window scope and renders `preview(snapshot)` inside an absolute wrapper. The +controller mutates wrapper geometry and `data-allowed` directly, so the snippet +must not apply pointer-follow transforms through Svelte state. `cancelOnOutside`, `cancelOnSecondaryButton`, and `cancelOnEscape` default to `true`. Creation and persistence belong in the controller’s `onCommit` diff --git a/docs/snapline/reference/vanilla/index.mdx b/docs/snapline/reference/vanilla/index.mdx index df9680f..c51ffa6 100644 --- a/docs/snapline/reference/vanilla/index.mdx +++ b/docs/snapline/reference/vanilla/index.mdx @@ -12,21 +12,26 @@ frameworkKey: overview | Class | Responsibility | | --- | --- | -| `NodeComponent` | Position, selection, properties, resize, and connectors | -| `ConnectorComponent` | Connection capacity, policy, metadata, and gestures | -| `LineComponent` | Connection state and SVG geometry | -| `RectSelectComponent` | Background rectangle selection | -| `GroupNodeComponent` | Resizable exclusive membership and recursive carry | +| `NodeMirror` | Position, selection, resize, and connectors | +| `ConnectorMirror` | Connection rules, metadata, and gestures | +| `LineMirror` | Connection state and SVG geometry | +| `RectSelectController` | Background rectangle selection | +| `GroupNodeMirror` | Resizable exclusive membership and recursive carry | | `PlacementController` | Preview, validation, commit, and cancellation state | Core objects require an engine with collision support. Assign each committed -element to its object, then call `syncDomGeometry()`. Destroy owned objects when -their records are removed. +element to its object, then call `remeasureDomGeometry()`. Destroy owned objects when +their records are removed. Topology is always controlled: attach a graph owner +with `attachControlledGraph(engine, { onLineChangeRequest })`, push your +`LineRecord`s through `setCanonicalGraph`, and apply each gesture's atomic +proposal to your records. ## Query helpers `getNodes`, `getConnectors`, `getGroupNodes`, and `getSelectedNodes` return -engine-scoped snapshots. `getParentGroup` returns settled ownership. +engine-scoped snapshots; `query(engine)` exposes the read-only `GraphQuery` +facade with identity lookups (`node(id)` / `connector(id)` / `line(id)`) and +`diagnostics()`. `getParentGroup` returns settled ownership. Callbacks use event objects carrying the relevant component, metadata, pointer information, and final geometry. Configuration objects are not graph diff --git a/src/object.ts b/src/object.ts index 1c3f7ee..4c95ce6 100755 --- a/src/object.ts +++ b/src/object.ts @@ -498,14 +498,12 @@ export class BaseObject extends CoreObject { } } + // Read-only: the id keys the global object table and render-queue task + // ids, so reassigning it after construction would orphan those entries. get id(): string { return this.#id; } - set id(id: string) { - this.#id = id; - } - get parent(): BaseObject | null { return this.#parent; } @@ -1323,25 +1321,43 @@ export class ElementObject extends BaseObject { } destroyDom(removeElement: boolean = true) { + const element = this.#element; + this.detachElement(); + if (removeElement) { + element?.remove(); + } + super.destroyDom(); + } + + /** + * Stop observing and routing input through the currently assigned element + * without removing framework-owned DOM. + * + * When `expectedElement` is supplied, a stale cleanup is ignored after a + * newer element has already been assigned. + */ + detachElement(expectedElement?: HTMLElement): boolean { + if (expectedElement && this.#element !== expectedElement) { + return false; + } this.#resizeObserver?.disconnect(); + this.#resizeObserver = null; this.#mutationObserver?.disconnect(); + this.#mutationObserver = null; if (this.#inputAlias) { this.engine?.input.unregisterObjectElement(this, this.#inputAlias); this.#inputAlias = null; } if (this.#element) { this.engine?.input.unregisterObjectElement(this, this.#element); - if (removeElement) { - this.#element.remove(); - } } this.#element = null; - super.destroyDom(); + return true; } #assignElement(element: HTMLElement) { if (this.#element) { - this.destroyDom(); + this.detachElement(); } this.#element = element; diff --git a/tests/e2e/snapline-edges-react.spec.ts b/tests/e2e/snapline-edges-react.spec.ts index c065b2d..d31023e 100644 --- a/tests/e2e/snapline-edges-react.spec.ts +++ b/tests/e2e/snapline-edges-react.spec.ts @@ -48,7 +48,7 @@ test("react: programmatic edge add renders a line with zero intents", async ({ p await expect(page.getByTestId("connect-intents")).toHaveText("0"); }); -test("react: full input replaces via the document in order", async ({ page }) => { +test("react: full input replaces via ONE atomic request", async ({ page }) => { await dragFromTo( page, await centerOf(connectorOf(page, "Node A", "output")), @@ -63,6 +63,6 @@ test("react: full input replaces via the document in order", async ({ page }) => await expect(page.getByTestId("edge-count")).toHaveText("1"); await expect(page.locator(LINE)).toHaveCount(1); await expect(page.getByTestId("intent-log")).toHaveText( - "connect:a->b|disconnect(replacement):a->b|connect:c->b", + "connect:a->b|replace:-a->b+c->b", ); }); diff --git a/tests/e2e/snapline-edges.spec.ts b/tests/e2e/snapline-edges.spec.ts index cc144dd..3946fdc 100644 --- a/tests/e2e/snapline-edges.spec.ts +++ b/tests/e2e/snapline-edges.spec.ts @@ -85,7 +85,7 @@ test("programmatic edge add and remove sync lines with zero intents", async ({ p expect(result.disconnect).toBe(0); }); -test("full input replaces via the document: disconnect then connect, in order", async ({ page }) => { +test("full input replaces via ONE atomic request the document applies", async ({ page }) => { await dragFromTo( page, await centerOf(connectorOf(page, "Node A", "output")), @@ -100,9 +100,7 @@ test("full input replaces via the document: disconnect then connect, in order", await expect(page.getByTestId("edge-count")).toHaveText("1"); await expect(page.locator(LINE)).toHaveCount(1); const log = (await page.getByTestId("intent-log").textContent()) ?? ""; - expect(log).toBe( - "connect:a->b|disconnect(replacement):a->b|connect:c->b", - ); + expect(log).toBe("connect:a->b|replace:-a->b+c->b"); }); test("rejected gesture connect leaves no line after re-sync and no paint flicker on accept", async ({ page }) => { diff --git a/tests/helpers/snapline-harness.ts b/tests/helpers/snapline-harness.ts new file mode 100644 index 0000000..2f77a9f --- /dev/null +++ b/tests/helpers/snapline-harness.ts @@ -0,0 +1,182 @@ +import { + ConnectorMirror, + NodeMirror, + attachControlledGraph, + type ConnectorSurfaceStrategy, + type LineChangeRequest, + type ReconciliationError, +} from "../../assets/snapline/core/src"; + +// Headless engine stand-in for SnapLine unit tests: a six-stage frame queue, +// an engine-scoped object table, and the input/collision surface the mirrors +// touch during construction. No DOM, no render loop. +export function createEngineHarness() { + let nextId = 0; + const objects: Record = {}; + const queue = { + READ_1: new Map(), + WRITE_1: new Map(), + READ_2: new Map(), + WRITE_2: new Map(), + READ_3: new Map(), + WRITE_3: new Map(), + }; + const engine: any = { + camera: null, + collisionEngine: { + addObject() {}, + removeObject() {}, + }, + edgePanController: null, + global: null, + input: { + claimPointer() {}, + registerObjectElement() {}, + setPointerDragOwner() {}, + subscribeGlobalCursorEvent() {}, + unregisterObjectElement() {}, + unsubscribeGlobalCursorEvent() {}, + }, + }; + const global: any = { + currentStage: "IDLE", + data: {}, + queue, + createId: () => `${++nextId}`, + getEngineObjectTable: (candidate: unknown) => + candidate === engine ? objects : {}, + registerObject: (object: { id: string }) => { + objects[object.id] = object; + }, + unregisterObject: (object: { id: string }) => { + delete objects[object.id]; + }, + }; + engine.global = global; + return { engine, global }; +} + +/** A second engine sharing the first harness's GlobalManager, for + * multi-engine isolation tests. */ +export function createSiblingEngine(global: any) { + const engine: any = { + camera: null, + collisionEngine: { + addObject() {}, + removeObject() {}, + }, + edgePanController: null, + global, + input: { + claimPointer() {}, + registerObjectElement() {}, + setPointerDragOwner() {}, + subscribeGlobalCursorEvent() {}, + unregisterObjectElement() {}, + unsubscribeGlobalCursorEvent() {}, + }, + }; + return engine; +} + +export function installObserverStubs(): () => void { + const resizeDescriptor = Object.getOwnPropertyDescriptor( + globalThis, + "ResizeObserver", + ); + const mutationDescriptor = Object.getOwnPropertyDescriptor( + globalThis, + "MutationObserver", + ); + class ObserverStub { + observe() {} + disconnect() {} + } + Object.defineProperty(globalThis, "ResizeObserver", { + configurable: true, + value: ObserverStub, + }); + Object.defineProperty(globalThis, "MutationObserver", { + configurable: true, + value: ObserverStub, + }); + return () => { + if (resizeDescriptor) { + Object.defineProperty(globalThis, "ResizeObserver", resizeDescriptor); + } else { + delete (globalThis as Record).ResizeObserver; + } + if (mutationDescriptor) { + Object.defineProperty(globalThis, "MutationObserver", mutationDescriptor); + } else { + delete (globalThis as Record).MutationObserver; + } + }; +} + +/** Engine + attached controlled-graph bridge with request/diagnostic logs. */ +export function createControlledHarness() { + const { engine, global } = createEngineHarness(); + const requests: LineChangeRequest[] = []; + const diagnosticsLog: (readonly ReconciliationError[])[] = []; + const handle = attachControlledGraph(engine, { + onLineChangeRequest: (request) => requests.push(request), + onDiagnosticsChanged: (diagnostics) => diagnosticsLog.push(diagnostics), + }); + return { engine, global, handle, requests, diagnosticsLog }; +} + +export function eventPositionAt(x: number, y: number) { + return { x, y, cameraX: x, cameraY: y, screenX: x, screenY: y }; +} + +/** targetHitTest accepting drops left of x=500 (far drops miss). */ +export const nearTargetStrategy: ConnectorSurfaceStrategy = { + targetHitTest: ({ position }) => + position.x < 500 + ? { anchor: { x: position.x, y: position.y }, distance: 0 } + : null, +}; + +/** Arm a connection gesture on `connector` (pointer down at origin). */ +export function armGesture(connector: ConnectorMirror, pointerId = 7): void { + const event = { button: 0, pointerId } as any; + connector.onCursorDown({ position: eventPositionAt(0, 0), event } as any); +} + +/** Drive dragStart + dragEnd on the gesture owner, dropping at `dropX`. */ +export function driveGestureDrop( + owner: ConnectorMirror, + dropX: number, + pointerId = 7, +): void { + const event = { button: 0, pointerId } as any; + (owner as any).event.input.dragStart({ + start: eventPositionAt(0, 0), + pointerId, + event, + }); + (owner as any).event.input.dragEnd({ + end: eventPositionAt(dropX, 10), + pointerId, + event, + }); +} + +/** Source/target pair with stable ids for controlled-graph tests. */ +export function mountConnectedPair(engine: any) { + const sourceNode = new NodeMirror(engine, null, { id: "n-src" }); + const targetNode = new NodeMirror(engine, null, { id: "n-tgt" }); + const source = new ConnectorMirror(engine, sourceNode, { + id: "out-1", + name: "out", + rules: { maxIncoming: 0 }, + }); + const target = new ConnectorMirror(engine, targetNode, { + id: "in-1", + name: "in", + rules: { maxOutgoing: 0 }, + surfaceStrategies: [nearTargetStrategy], + }); + return { sourceNode, targetNode, source, target }; +} diff --git a/tests/ut/snapline-connector-config.spec.ts b/tests/ut/snapline-connector-config.spec.ts index b9b922e..906e5af 100644 --- a/tests/ut/snapline-connector-config.spec.ts +++ b/tests/ut/snapline-connector-config.spec.ts @@ -1,136 +1,188 @@ import { expect, test } from "@playwright/test"; import { CircleCollider } from "../../src/collision"; import { - ConnectorComponent, - LineComponent, - NodeComponent, + ConnectorMirror, + LineMirror, + NodeMirror, + PlacementController, type ConnectorSurfaceStrategy, } from "../../assets/snapline/core/src"; +import { getGraphMirror } from "../../assets/snapline/core/src/snapline-globals"; -function createEngineHarness() { - let nextId = 0; - const objects: Record = {}; - const queue = { - READ_1: new Map(), - WRITE_1: new Map(), - READ_2: new Map(), - WRITE_2: new Map(), - READ_3: new Map(), - WRITE_3: new Map(), - }; - const engine: any = { - camera: null, - collisionEngine: { - addObject() {}, - removeObject() {}, - }, - edgePanController: null, - global: null, - input: { - claimPointer() {}, - registerObjectElement() {}, - subscribeGlobalCursorEvent() {}, - unregisterObjectElement() {}, - unsubscribeGlobalCursorEvent() {}, - }, - }; - const global: any = { - currentStage: "IDLE", - data: {}, - queue, - createId: () => `${++nextId}`, - getEngineObjectTable: (candidate: unknown) => - candidate === engine ? objects : {}, - registerObject: (object: { id: string }) => { - objects[object.id] = object; +import { + createControlledHarness, + createEngineHarness, + installObserverStubs, +} from "../helpers/snapline-harness"; + +test("line geometry writers are imperative, replaceable, and separate from state", () => { + const { engine } = createEngineHarness(); + const sourceNode = new NodeMirror(engine, null); + const source = new ConnectorMirror(engine, sourceNode, { + name: "source", + rules: { maxIncoming: 0 }, + }); + sourceNode.addConnectorObject(source); + const line = source.createLine(); + const firstWrites: number[] = []; + const secondWrites: number[] = []; + const states: string[] = []; + + const unbindFirst = line.bindGeometryWriter((geometry) => { + firstWrites.push(geometry.delta.x); + }); + const unbindSecond = line.bindGeometryWriter((geometry) => { + secondWrites.push(geometry.delta.x); + }); + const unsubscribeState = line.onStateChange((state) => { + states.push(state.phase); + }); + + // A stale framework cleanup must not detach the newer renderer. + unbindFirst(); + line.setLinePosition(10, 20, 35, 45); + expect(firstWrites).toEqual([0]); + expect(secondWrites).toEqual([0]); + expect(states).toEqual(["source-start"]); + + line.writeTransform(); + expect(secondWrites).toEqual([0, 25]); + expect(states).toEqual(["source-start"]); + + line.setPhase("preview-free"); + expect(states).toEqual(["source-start", "preview-free"]); + expect(secondWrites).toEqual([0, 25]); + + unbindSecond(); + unsubscribeState(); + line.destroy(false); + source.destroy(); + sourceNode.destroy(); +}); + +test("placement geometry writes do not require framework state updates", () => { + const controller = new PlacementController<{ id: string }>({ + screenToWorld: ({ x, y }) => ({ x: x + 10, y: y + 20 }), + }); + const firstWrites: Array<{ visible: boolean; x: number | null }> = []; + const secondWrites: Array<{ visible: boolean; x: number | null }> = []; + const states: boolean[] = []; + + const unbindFirst = controller.bindGeometryWriter((geometry) => { + firstWrites.push({ + visible: geometry.visible, + x: geometry.position?.x ?? null, + }); + }); + const unbindSecond = controller.bindGeometryWriter((geometry) => { + secondWrites.push({ + visible: geometry.visible, + x: geometry.position?.x ?? null, + }); + }); + const unsubscribeState = controller.onStateChange((snapshot) => { + states.push(snapshot.active); + }); + + unbindFirst(); + controller.begin({ id: "new-node" }, { width: 20, height: 10 }); + controller.update({ x: 50, y: 40 }); + + expect(firstWrites).toEqual([{ visible: false, x: null }]); + expect(secondWrites).toEqual([ + { visible: false, x: null }, + { visible: false, x: null }, + { visible: true, x: 50 }, + ]); + expect(states).toEqual([false, true, true]); + + unbindSecond(); + unsubscribeState(); +}); + +test("framework cleanup detaches elements without removing owned DOM", () => { + const restoreObservers = installObserverStubs(); + const { engine } = createEngineHarness(); + const node = new NodeMirror(engine, null); + let firstRemovals = 0; + let secondRemovals = 0; + const firstElement = { + remove: () => { + firstRemovals++; }, - unregisterObject: (object: { id: string }) => { - delete objects[object.id]; + } as unknown as HTMLElement; + const secondElement = { + remove: () => { + secondRemovals++; }, - }; - engine.global = global; - return { engine, global }; -} + } as unknown as HTMLElement; + + try { + node.element = firstElement; + node.element = secondElement; + + expect(node.detachElement(firstElement)).toBe(false); + expect(node.element).toBe(secondElement); -function installObserverStubs(): () => void { - const resizeDescriptor = Object.getOwnPropertyDescriptor( - globalThis, - "ResizeObserver", - ); - const mutationDescriptor = Object.getOwnPropertyDescriptor( - globalThis, - "MutationObserver", - ); - class ObserverStub { - observe() {} - disconnect() {} + node.destroy(false); + expect(node.element).toBeNull(); + expect(firstRemovals).toBe(0); + expect(secondRemovals).toBe(0); + } finally { + restoreObservers(); } - Object.defineProperty(globalThis, "ResizeObserver", { - configurable: true, - value: ObserverStub, - }); - Object.defineProperty(globalThis, "MutationObserver", { - configurable: true, - value: ObserverStub, - }); - return () => { - if (resizeDescriptor) { - Object.defineProperty(globalThis, "ResizeObserver", resizeDescriptor); - } else { - delete (globalThis as Record).ResizeObserver; - } - if (mutationDescriptor) { - Object.defineProperty(globalThis, "MutationObserver", mutationDescriptor); - } else { - delete (globalThis as Record).MutationObserver; - } - }; -} +}); test("connector config updates stay live without replacing topology", () => { - const { engine, global } = createEngineHarness(); - const sourceNode = new NodeComponent(engine, null); - const targetNode = new NodeComponent(engine, null); - const source = new ConnectorComponent(engine, sourceNode, { + const { engine, global, handle } = createControlledHarness(); + const sourceNode = new NodeMirror(engine, null); + const targetNode = new NodeMirror(engine, null); + const source = new ConnectorMirror(engine, sourceNode, { + id: "cfg-out", name: "source", - capabilities: { source: true, target: false }, + rules: { maxIncoming: 0 }, }); - const target = new ConnectorComponent(engine, targetNode, { + const target = new ConnectorMirror(engine, targetNode, { + id: "cfg-in", name: "target", - capabilities: { source: false, target: true, maxIncoming: -1 }, + rules: { maxOutgoing: 0, maxIncoming: "unlimited" }, }); sourceNode.addConnectorObject(source); targetNode.addConnectorObject(target); - expect(source.connectToConnector({ target })).toBe(true); - const existingLine = source.outgoingLines[0]; + handle.setCanonicalGraph({ + lines: [{ id: "cfg-line", fromConnectorId: "cfg-out", toConnectorId: "cfg-in" }], + }); + handle.flush(); + const existingLine = getGraphMirror(engine).line("cfg-line")!; + expect(source.outgoingLines).toEqual([existingLine]); - class UpdatedLine extends LineComponent {} + class UpdatedLine extends LineMirror {} const strategy: ConnectorSurfaceStrategy = { sourceHitTest: ({ position }) => ({ anchor: position, distance: 0, }), }; + const isValidConnection = () => true; const callbacks = { - canConnect: () => true, + onDragStart: () => {}, }; const metadata = { domainId: "updated-source" }; source.updateConfig({ - allowDragOut: true, callbacks, - capabilities: { - source: true, - target: true, + rules: { + maxOutgoing: "unlimited", maxIncoming: 4, reconnect: false, allowParallel: true, + onFull: "replace-oldest", + isValidConnection, }, colliderRadius: 42, edgePan: false, lineClass: UpdatedLine, - maxConnectors: 4, metadata, surfaceStrategies: [strategy], }); @@ -139,13 +191,16 @@ test("connector config updates stay live without replacing topology", () => { expect(existingLine.target).toBe(target); expect(source.callbacks).toBe(callbacks); expect(source.metadata).toBe(metadata); - expect(source.capabilities).toEqual({ - source: true, - target: true, + expect(source.rules).toEqual({ + maxOutgoing: Infinity, maxIncoming: 4, reconnect: false, allowParallel: true, + onFull: "replace-oldest", + isValidConnection, }); + expect(source.isSource).toBe(true); + expect(source.isTarget).toBe(true); expect(source.config.edgePan).toBe(false); expect(source.config.lineClass).toBe(UpdatedLine); expect(source.colliderList[0]).toBeInstanceOf(CircleCollider); @@ -156,13 +211,11 @@ test("connector config updates stay live without replacing topology", () => { updatedLine.destroy(false); source.updateConfig({ - allowDragOut: false, callbacks: undefined, - capabilities: undefined, + rules: { maxOutgoing: 0, maxIncoming: 2 }, colliderRadius: undefined, edgePan: true, lineClass: undefined, - maxConnectors: 2, metadata: undefined, surfaceStrategies: [], }); @@ -170,17 +223,20 @@ test("connector config updates stay live without replacing topology", () => { expect(source.outgoingLines).toEqual([existingLine]); expect(source.callbacks).toEqual({}); expect(source.metadata).toEqual({}); - expect(source.capabilities).toEqual({ - source: false, - target: true, + expect(source.rules).toEqual({ + maxOutgoing: 0, maxIncoming: 2, reconnect: true, allowParallel: false, + onFull: "reject", + isValidConnection: null, }); + expect(source.isSource).toBe(false); + expect(source.isTarget).toBe(true); expect((source.colliderList[0] as CircleCollider).radius).toBe(30); expect(global.data.sourceSurfaces).toEqual([]); const defaultLine = source.createLine(); - expect(defaultLine).toBeInstanceOf(LineComponent); + expect(defaultLine).toBeInstanceOf(LineMirror); expect(defaultLine).not.toBeInstanceOf(UpdatedLine); defaultLine.destroy(false); @@ -192,21 +248,27 @@ test("connector config updates stay live without replacing topology", () => { test("visible port binding can toggle while preserving connector lines", () => { const restoreObservers = installObserverStubs(); - const { engine } = createEngineHarness(); - const sourceNode = new NodeComponent(engine, null); - const targetNode = new NodeComponent(engine, null); - const source = new ConnectorComponent(engine, sourceNode, { + const { engine, handle } = createControlledHarness(); + const sourceNode = new NodeMirror(engine, null); + const targetNode = new NodeMirror(engine, null); + const source = new ConnectorMirror(engine, sourceNode, { + id: "bind-out", name: "source", - capabilities: { source: true, target: false }, + rules: { maxIncoming: 0 }, }); - const target = new ConnectorComponent(engine, targetNode, { + const target = new ConnectorMirror(engine, targetNode, { + id: "bind-in", name: "target", - capabilities: { source: false, target: true }, + rules: { maxOutgoing: 0 }, }); sourceNode.addConnectorObject(source); targetNode.addConnectorObject(target); - expect(source.connectToConnector({ target })).toBe(true); + handle.setCanonicalGraph({ + lines: [{ id: "bind-line", fromConnectorId: "bind-out", toConnectorId: "bind-in" }], + }); + handle.flush(); const line = source.outgoingLines[0]; + expect(line.target).toBe(target); const firstElement = {} as HTMLElement; const secondElement = {} as HTMLElement; @@ -232,51 +294,3 @@ test("visible port binding can toggle while preserving connector lines", () => { restoreObservers(); } }); - -test("bidirectional connector graphs propagate props without recursing forever", () => { - const { engine } = createEngineHarness(); - const firstNode = new NodeComponent(engine, null); - const secondNode = new NodeComponent(engine, null); - const first = new ConnectorComponent(engine, firstNode, { - name: "value", - capabilities: { - source: true, - target: true, - maxIncoming: -1, - }, - }); - const second = new ConnectorComponent(engine, secondNode, { - name: "value", - capabilities: { - source: true, - target: true, - maxIncoming: -1, - }, - }); - firstNode.addConnectorObject(first); - secondNode.addConnectorObject(second); - - expect(first.connectToConnector({ target: second })).toBe(true); - expect(second.connectToConnector({ target: first })).toBe(true); - - let firstUpdates = 0; - let secondUpdates = 0; - firstNode.addSetPropCallback(() => { - firstUpdates += 1; - }, "value"); - secondNode.addSetPropCallback(() => { - secondUpdates += 1; - }, "value"); - - firstNode.setProp("value", "shared"); - - expect(firstNode.getProp("value")).toBe("shared"); - expect(secondNode.getProp("value")).toBe("shared"); - expect(firstUpdates).toBe(1); - expect(secondUpdates).toBe(1); - - first.destroy(); - second.destroy(); - firstNode.destroy(); - secondNode.destroy(); -}); diff --git a/tests/ut/snapline-graph-mirror.spec.ts b/tests/ut/snapline-graph-mirror.spec.ts new file mode 100644 index 0000000..08820ff --- /dev/null +++ b/tests/ut/snapline-graph-mirror.spec.ts @@ -0,0 +1,250 @@ +import { expect, test } from "@playwright/test"; +import { + ConnectorMirror, + GroupNodeMirror, + NodeMirror, +} from "../../assets/snapline/core/src"; +import { getGraphMirror } from "../../assets/snapline/core/src/snapline-globals"; +import { + armGesture, + createControlledHarness, + createEngineHarness, + createSiblingEngine, + driveGestureDrop, + installObserverStubs, + mountConnectedPair, +} from "../helpers/snapline-harness"; + +test("mirrors mint domain ids when none is supplied and honor supplied ids", () => { + const { engine } = createEngineHarness(); + const minted = new NodeMirror(engine, null); + const named = new NodeMirror(engine, null, { id: "app-node" }); + const connector = new ConnectorMirror(engine, named, { name: "out" }); + const namedConnector = new ConnectorMirror(engine, named, { + id: "app-port", + name: "in", + }); + + expect(minted.nodeId).toMatch(/^node-\d+$/); + expect(named.nodeId).toBe("app-node"); + expect(connector.connectorId).toMatch(/^connector-\d+$/); + expect(namedConnector.connectorId).toBe("app-port"); + + const line = connector.createLine(); + expect(line.lineId).toMatch(/^line-\d+$/); +}); + +test("the graph mirror indexes registrations and drops them on destroy", () => { + const { engine } = createEngineHarness(); + const mirror = getGraphMirror(engine); + const node = new NodeMirror(engine, null, { id: "n1" }); + const connector = new ConnectorMirror(engine, node, { + id: "c1", + name: "out", + }); + + expect(mirror.node("n1")).toBe(node); + expect(mirror.connector("c1")).toBe(connector); + expect(mirror.nodes).toContain(node); + expect(mirror.connectors).toContain(connector); + + connector.destroy(false); + node.destroy(false); + expect(mirror.node("n1")).toBeNull(); + expect(mirror.connector("c1")).toBeNull(); + expect(mirror.nodes).not.toContain(node); + expect(mirror.connectors).not.toContain(connector); +}); + +test("duplicate ids never steal the index: first wins, diagnostic until resolved", () => { + const { engine } = createEngineHarness(); + const mirror = getGraphMirror(engine); + const first = new NodeMirror(engine, null, { id: "dup" }); + const second = new NodeMirror(engine, null, { id: "dup" }); + + expect(mirror.node("dup")).toBe(first); + const errors = mirror.diagnostics(); + expect(errors).toHaveLength(1); + expect(errors[0].code).toBe("duplicate-id"); + expect(errors[0].nodeId).toBe("dup"); + + // The conflict resolves when the indexed mirror leaves: the survivor takes + // over the entry and its diagnostic clears. + first.destroy(false); + expect(mirror.node("dup")).toBe(second); + expect(mirror.diagnostics()).toHaveLength(0); + + second.destroy(false); + expect(mirror.node("dup")).toBeNull(); +}); + +test("lines move preview -> settled -> preview and unregister on destroy", () => { + const restore = installObserverStubs(); + try { + const { engine, handle } = createControlledHarness(); + const mirror = getGraphMirror(engine); + const { source } = mountConnectedPair(engine); + + // A freshly minted line is a preview, not part of the settled graph. + const preview = source.createLine(); + expect(mirror.previewLines).toContain(preview); + expect(mirror.lines).not.toContain(preview); + expect(mirror.line(preview.lineId)).toBeNull(); + preview.destroy(false); + expect(mirror.previewLines).not.toContain(preview); + + // A canonical record settles into the id index... + handle.setCanonicalGraph({ + lines: [{ id: "l1", fromConnectorId: "out-1", toConnectorId: "in-1" }], + }); + handle.flush(); + const line = mirror.line("l1")!; + expect(mirror.previewLines).not.toContain(line); + + // ...a reconnect pickup unsettles it back to a preview... + line.clearTarget(); + expect(mirror.line("l1")).toBeNull(); + expect(mirror.previewLines).toContain(line); + + // ...and destroy unregisters it entirely. + line.destroy(false); + expect(mirror.previewLines).not.toContain(line); + } finally { + restore(); + } +}); + +test("each engine on a shared GlobalManager gets its own isolated registry", () => { + const { engine, global } = createEngineHarness(); + const sibling = createSiblingEngine(global); + + 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); + expect(mirrorA).not.toBe(mirrorB); + // Same domain id on different engines is not a conflict. + expect(mirrorA.node("shared-id")).toBe(nodeA); + expect(mirrorB.node("shared-id")).toBe(nodeB); + expect(mirrorA.diagnostics()).toHaveLength(0); + expect(mirrorB.diagnostics()).toHaveLength(0); + expect(mirrorA.nodes).not.toContain(nodeB); + expect(mirrorB.nodes).not.toContain(nodeA); +}); + +test("selection is engine-scoped and removal is identity-based", () => { + const { engine, global } = createEngineHarness(); + const sibling = createSiblingEngine(global); + const nodeA = new NodeMirror(engine, null, { id: "same" }); + const nodeB = new NodeMirror(sibling, null, { id: "same" }); + + nodeA.setSelected(true); + nodeB.setSelected(true); + expect(getGraphMirror(engine).selection).toEqual([nodeA]); + expect(getGraphMirror(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]); +}); + +test("group registries are engine-scoped", () => { + const restore = installObserverStubs(); + try { + const { engine, global } = createEngineHarness(); + const sibling = createSiblingEngine(global); + const groupA = new GroupNodeMirror(engine, null); + const groupB = new GroupNodeMirror(sibling, null); + + expect(getGraphMirror(engine).groups).toEqual([groupA]); + expect(getGraphMirror(sibling).groups).toEqual([groupB]); + + groupA.destroy(false); + expect(getGraphMirror(engine).groups).toEqual([]); + expect(getGraphMirror(sibling).groups).toEqual([groupB]); + groupB.destroy(false); + } finally { + restore(); + } +}); + +test("a gesture without a graph owner warns and discards the preview", () => { + const { engine } = createEngineHarness(); + const { source, target } = mountConnectedPair(engine); + const warnings: string[] = []; + const originalWarn = console.warn; + console.warn = (message: unknown) => { + warnings.push(String(message)); + }; + try { + armGesture(source, 5); + driveGestureDrop(source, 100, 5); + } finally { + console.warn = originalWarn; + } + // Topology is always controlled: with no document to propose to, the + // gesture cannot produce a line. + expect(source.outgoingLines).toEqual([]); + expect(target.incomingLines).toEqual([]); + expect(warnings.some((message) => message.includes("no graph owner"))).toBe( + true, + ); +}); + +test("the scheduler coalesces bursts and defers passes to the outermost batch end", async () => { + const { engine } = createEngineHarness(); + const mirror = getGraphMirror(engine); + let passes = 0; + mirror.reconciler = { + reconcile: () => { + passes += 1; + }, + }; + + // A burst of registrations coalesces into one microtask pass. + const node = new NodeMirror(engine, null); + new ConnectorMirror(engine, node, { name: "a" }); + new ConnectorMirror(engine, node, { name: "b" }); + new ConnectorMirror(engine, node, { name: "c" }); + expect(passes).toBe(0); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(passes).toBe(1); + + // Open batches swallow triggers; only the outermost end schedules, once. + const outer = mirror.beginBatch(); + const inner = mirror.beginBatch(); + new ConnectorMirror(engine, node, { name: "d" }); + new ConnectorMirror(engine, node, { name: "e" }); + inner.end(); + inner.end(); // idempotent + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(passes).toBe(1); + outer.end(); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(passes).toBe(2); + + // A batch with no relevant changes schedules nothing. + await mirror.runBatch(async () => {}); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(passes).toBe(2); + + // runBatch is exception-safe: the boundary closes and the dirty pass runs. + await mirror + .runBatch(async () => { + new ConnectorMirror(engine, node, { name: "f" }); + throw new Error("boom"); + }) + .catch(() => {}); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(passes).toBe(3); + + // flush() runs synchronously and cancels the queued microtask pass. + new ConnectorMirror(engine, node, { name: "g" }); + mirror.flush(); + expect(passes).toBe(4); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(passes).toBe(4); +}); diff --git a/tests/ut/snapline-line-reconciler.spec.ts b/tests/ut/snapline-line-reconciler.spec.ts new file mode 100644 index 0000000..6a350c0 --- /dev/null +++ b/tests/ut/snapline-line-reconciler.spec.ts @@ -0,0 +1,505 @@ +import { expect, test } from "@playwright/test"; +import { + ConnectorMirror, + NodeMirror, +} from "../../assets/snapline/core/src"; +import { getGraphMirror } from "../../assets/snapline/core/src/snapline-globals"; +import { attachControlledGraph, type LineChangeRequest } from "../../assets/snapline/core/src"; +import { + armGesture, + createControlledHarness as controlledHarness, + createSiblingEngine, + driveGestureDrop, + eventPositionAt as pos, + mountConnectedPair, + nearTargetStrategy as nearStrategy, +} from "../helpers/snapline-harness"; + +function mountPair(engine: any) { + const sourceNode = new NodeMirror(engine, null, { id: "n-src" }); + const targetNode = new NodeMirror(engine, null, { id: "n-tgt" }); + const source = new ConnectorMirror(engine, sourceNode, { + id: "out-1", + name: "out", + rules: { maxIncoming: 0 }, + }); + const target = new ConnectorMirror(engine, targetNode, { + id: "in-1", + name: "in", + rules: { maxOutgoing: 0 }, + }); + return { sourceNode, targetNode, source, target }; +} + +test("canonical records hydrate settled lines, stay latent until endpoints mount, and prune on removal", () => { + const { engine, handle, requests } = controlledHarness(); + const mirror = getGraphMirror(engine); + + handle.setCanonicalGraph({ + lines: [ + { + id: "line-a", + fromConnectorId: "out-1", + toConnectorId: "in-1", + payload: { kind: "scalar" }, + }, + ], + }); + handle.flush(); + // Latent: neither endpoint is mounted — silently retried, no diagnostic. + expect(mirror.line("line-a")).toBeNull(); + expect(mirror.diagnostics()).toEqual([]); + + const { source, target } = mountPair(engine); + handle.flush(); + const line = mirror.line("line-a"); + expect(line).not.toBeNull(); + expect(line!.start).toBe(source); + expect(line!.target).toBe(target); + expect(line!.phase).toBe("connected"); + expect(line!.payload).toEqual({ kind: "scalar" }); + + handle.setCanonicalGraph({ lines: [] }); + handle.flush(); + expect(mirror.line("line-a")).toBeNull(); + expect(source.outgoingLines).toEqual([]); + expect(target.incomingLines).toEqual([]); + + // Reconciliation is read-only w.r.t. canonical state: no requests emitted. + expect(requests).toEqual([]); +}); + +test("a settled line is preserved by stable id across endpoint retargets", () => { + const { engine, handle } = controlledHarness(); + const mirror = getGraphMirror(engine); + const { source, targetNode } = mountPair(engine); + const secondTarget = new ConnectorMirror(engine, targetNode, { + id: "in-2", + name: "in2", + rules: { maxOutgoing: 0 }, + }); + + handle.setCanonicalGraph({ + lines: [{ id: "line-a", fromConnectorId: "out-1", toConnectorId: "in-1" }], + }); + handle.flush(); + const original = mirror.line("line-a")!; + + // toConnectorId change: the same mirror instance re-targets. + handle.setCanonicalGraph({ + lines: [{ id: "line-a", fromConnectorId: "out-1", toConnectorId: "in-2" }], + }); + handle.flush(); + expect(mirror.line("line-a")).toBe(original); + expect(original.target).toBe(secondTarget); + + // fromConnectorId change: the start connector is fixed at construction, + // so the mirror is recreated under the same stable id. + const otherSource = new ConnectorMirror( + engine, + new NodeMirror(engine, null), + { id: "out-2", name: "out2", rules: { maxIncoming: 0 } }, + ); + handle.setCanonicalGraph({ + lines: [{ id: "line-a", fromConnectorId: "out-2", toConnectorId: "in-2" }], + }); + handle.flush(); + const recreated = mirror.line("line-a")!; + expect(recreated).not.toBe(original); + expect(recreated.start).toBe(otherSource); + expect(source.outgoingLines).toEqual([]); +}); + +test("rules violations leave records latent with structured diagnostics that clear on resolution", () => { + const { engine, handle, diagnosticsLog } = controlledHarness(); + const mirror = getGraphMirror(engine); + const { target } = mountPair(engine); + // A second source so two records target the same maxIncoming: 1 connector. + new ConnectorMirror(engine, new NodeMirror(engine, null), { + id: "out-2", + name: "out2", + rules: { maxIncoming: 0 }, + }); + expect(target.rules.maxIncoming).toBe(1); + + handle.setCanonicalGraph({ + lines: [ + { id: "line-a", fromConnectorId: "out-1", toConnectorId: "in-1" }, + { id: "line-b", fromConnectorId: "out-2", toConnectorId: "in-1" }, + ], + }); + handle.flush(); + expect(mirror.line("line-a")).not.toBeNull(); + expect(mirror.line("line-b")).toBeNull(); + const errors = mirror.diagnostics(); + expect(errors).toHaveLength(1); + expect(errors[0].code).toBe("capacity-exceeded"); + expect(errors[0].lineId).toBe("line-b"); + expect(diagnosticsLog.at(-1)).toEqual(errors); + + // Canonical reconciliation never evicted line-a to make room ("replace- + // oldest" is gesture policy, not document policy). Fixing the document + // clears the derived diagnostic. + handle.setCanonicalGraph({ + lines: [{ id: "line-a", fromConnectorId: "out-1", toConnectorId: "in-1" }], + }); + handle.flush(); + expect(mirror.diagnostics()).toEqual([]); + expect(diagnosticsLog.at(-1)).toEqual([]); +}); + +test("duplicate canonical ids and predicate vetoes surface as diagnostics", () => { + const { engine, handle } = controlledHarness(); + const mirror = getGraphMirror(engine); + const { source, target } = mountPair(engine); + + handle.setCanonicalGraph({ + lines: [ + { id: "dup", fromConnectorId: "out-1", toConnectorId: "in-1" }, + { id: "dup", fromConnectorId: "out-1", toConnectorId: "in-1" }, + ], + }); + handle.flush(); + expect(mirror.diagnostics().map((error) => error.code)).toEqual([ + "duplicate-id", + ]); + expect(mirror.line("dup")).not.toBeNull(); + + // Predicate veto: the record stays unrepresented with a + // "connection-rejected" diagnostic, and admits once the rule changes. + let admit = false; + target.updateConfig({ + rules: { maxOutgoing: 0, isValidConnection: () => admit }, + }); + handle.setCanonicalGraph({ + lines: [{ id: "line-v", fromConnectorId: "out-1", toConnectorId: "in-1" }], + }); + handle.flush(); + expect(mirror.line("line-v")).toBeNull(); + expect(mirror.diagnostics().map((error) => error.code)).toEqual([ + "connection-rejected", + ]); + + admit = true; + handle.flush(); + expect(mirror.line("line-v")).not.toBeNull(); + expect(mirror.line("line-v")!.start).toBe(source); + expect(mirror.diagnostics()).toEqual([]); +}); + +// ---- Controlled gesture protocol ---- + +function gestureHarness() { + const base = controlledHarness(); + const sourceNode = new NodeMirror(base.engine, null, { id: "n-src" }); + const targetNode = new NodeMirror(base.engine, null, { id: "n-tgt" }); + const source = new ConnectorMirror(base.engine, sourceNode, { + id: "out-1", + name: "out", + rules: { maxIncoming: 0 }, + }); + const target = new ConnectorMirror(base.engine, targetNode, { + id: "in-1", + name: "in", + rules: { maxOutgoing: 0 }, + surfaceStrategies: [nearStrategy], + }); + return { ...base, source, target }; +} + +function dragFrom(connector: ConnectorMirror, dropX: number, pointerId = 7) { + const event = { button: 0, pointerId } as any; + connector.onCursorDown({ position: pos(0, 0), event } as any); +} + +function driveDrop(owner: ConnectorMirror, dropX: number, pointerId = 7) { + const event = { button: 0, pointerId } as any; + (owner as any).event.input.dragStart({ + start: pos(0, 0), + pointerId, + event, + }); + (owner as any).event.input.dragEnd({ + end: pos(dropX, 10), + pointerId, + event, + }); +} + +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); + + dragFrom(source, 100); + driveDrop(source, 100); + + expect(requests).toHaveLength(1); + const request = requests[0]; + expect(request.intent).toBe("connect"); + expect(request.remove).toEqual([]); + expect(request.add).toHaveLength(1); + expect(request.add[0].fromConnectorId).toBe("out-1"); + expect(request.add[0].toConnectorId).toBe("in-1"); + + // Staged: visually attached, but no topology commitment anywhere. + const staged = source.outgoingLines[0]; + expect(staged.lineId).toBe(request.add[0].id); + expect(staged.phase).toBe("staged"); + expect(staged.target).toBe(target); + expect(target.incomingLines).toEqual([]); + expect(mirror.line(staged.lineId)).toBeNull(); + + // Adoption settles the SAME mirror in place. + handle.setCanonicalGraph({ + lines: [ + { + id: request.add[0].id, + fromConnectorId: "out-1", + toConnectorId: "in-1", + }, + ], + }); + handle.flush(); + expect(mirror.line(staged.lineId)).toBe(staged); + expect(staged.phase).toBe("connected"); + expect(target.incomingLines).toEqual([staged]); +}); + +test("rejection-by-inaction discards the staged line on the decisive pass", () => { + const { engine, handle, requests, source } = gestureHarness(); + const mirror = getGraphMirror(engine); + + dragFrom(source, 100); + driveDrop(source, 100); + expect(requests).toHaveLength(1); + expect(source.outgoingLines).toHaveLength(1); + + // The application declines by doing nothing; the adapter's post-request + // push still delivers the unchanged (empty) snapshot. + handle.flush(); + expect(source.outgoingLines).toEqual([]); + expect(mirror.diagnostics()).toEqual([]); + expect(mirror.previewLines).toEqual([]); +}); + +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); + target.updateConfig({ + rules: { maxOutgoing: 0, maxIncoming: 1, onFull: "replace-oldest" }, + surfaceStrategies: [nearStrategy], + }); + const otherSource = new ConnectorMirror( + engine, + new NodeMirror(engine, null), + { id: "out-2", name: "out2", rules: { maxIncoming: 0 } }, + ); + + handle.setCanonicalGraph({ + lines: [{ id: "line-a", fromConnectorId: "out-2", toConnectorId: "in-1" }], + }); + handle.flush(); + const existing = mirror.line("line-a")!; + + dragFrom(source, 100); + driveDrop(source, 100); + expect(requests).toHaveLength(1); + const request = requests[0]; + expect(request.intent).toBe("replace"); + expect(request.remove).toEqual(["line-a"]); + expect(request.add).toHaveLength(1); + + // Nothing was evicted locally: the app decides. + expect(existing.phase).toBe("connected"); + expect(target.incomingLines).toEqual([existing]); + + handle.setCanonicalGraph({ + lines: [ + { + id: request.add[0].id, + fromConnectorId: "out-1", + toConnectorId: "in-1", + }, + ], + }); + handle.flush(); + expect(mirror.line("line-a")).toBeNull(); + expect(mirror.line(request.add[0].id)).not.toBeNull(); + expect(target.incomingLines).toHaveLength(1); + expect(otherSource.outgoingLines).toEqual([]); +}); + +test("a gesture disconnect proposes removal; rejection re-glues, acceptance discards", () => { + const { engine, handle, requests, source, target } = gestureHarness(); + const mirror = getGraphMirror(engine); + + handle.setCanonicalGraph({ + lines: [{ id: "line-a", fromConnectorId: "out-1", toConnectorId: "in-1" }], + }); + handle.flush(); + const line = mirror.line("line-a")!; + + // Pick the line up from its target and drop it in the void (x >= 500). + const event = { button: 0, pointerId: 9 } as any; + target.armSurfaceGesture({ position: pos(0, 0), event } as any, null); + driveDrop(source, 900, 9); + + expect(requests).toHaveLength(1); + expect(requests[0]).toMatchObject({ + intent: "disconnect", + remove: ["line-a"], + }); + expect(line.phase).toBe("staged"); + expect(line.target).toBeNull(); + expect(target.incomingLines).toEqual([]); + + // Rejected: the unchanged document still contains line-a — re-glue the + // SAME mirror back onto its canonical target. + handle.flush(); + expect(mirror.line("line-a")).toBe(line); + expect(line.phase).toBe("connected"); + expect(line.target).toBe(target); + expect(target.incomingLines).toEqual([line]); + + // Accepted: the document drops the record — the mirror goes with it. + target.armSurfaceGesture({ position: pos(0, 0), event } as any, null); + driveDrop(source, 900, 9); + expect(requests).toHaveLength(2); + handle.setCanonicalGraph({ lines: [] }); + handle.flush(); + expect(mirror.line("line-a")).toBeNull(); + expect(source.outgoingLines).toEqual([]); +}); + +// ---- Remaining matrix: isolation, reconnect identity, bulk load ---- + +test("controlled graphs on sibling engines are fully isolated, gestures included", () => { + const { engine, global, handle, requests } = controlledHarness(); + const sibling = createSiblingEngine(global); + const siblingRequests: LineChangeRequest[] = []; + const siblingHandle = attachControlledGraph(sibling, { + onLineChangeRequest: (request) => siblingRequests.push(request), + }); + + // Same connector ids on both engines: no conflict, independent settles. + const a = mountConnectedPair(engine); + const b = mountConnectedPair(sibling); + handle.setCanonicalGraph({ + lines: [{ id: "iso", fromConnectorId: "out-1", toConnectorId: "in-1" }], + }); + handle.flush(); + siblingHandle.flush(); + expect(getGraphMirror(engine).line("iso")).not.toBeNull(); + expect(getGraphMirror(sibling).line("iso")).toBeNull(); + + // A gesture on the sibling engine cannot discover engine A's targets: + // A's target accepts drops near x=100, the sibling's own target does not + // exist at all (destroyed) — the drop finds no candidate anywhere. + b.target.destroy(false); + armGesture(b.source, 11); + driveGestureDrop(b.source, 100, 11); + expect(siblingRequests).toEqual([]); + expect(b.source.outgoingLines).toEqual([]); + expect(requests).toEqual([]); + void a; +}); + +test("a gesture reconnect preserves the line's stable id and mirror", () => { + const { engine, handle, requests } = controlledHarness(); + const mirror = getGraphMirror(engine); + const { source, targetNode, target } = mountConnectedPair(engine); + // Second input on the same node, hit-testable only left of x=500 too but + // distinguished by position: in-2 accepts drops at x >= 200. + const secondTarget = new ConnectorMirror(engine, targetNode, { + id: "in-2", + name: "in2", + rules: { maxOutgoing: 0 }, + surfaceStrategies: [ + { + targetHitTest: ({ position }: any) => + position.x >= 200 && position.x < 500 + ? { anchor: { x: position.x, y: position.y }, distance: 0 } + : null, + }, + ], + }); + // Restrict the first target to drops left of x=200 so the reconnect drop + // at x=300 lands on in-2. + target.updateConfig({ + rules: { maxOutgoing: 0 }, + surfaceStrategies: [ + { + targetHitTest: ({ position }: any) => + position.x < 200 + ? { anchor: { x: position.x, y: position.y }, distance: 0 } + : null, + }, + ], + }); + + handle.setCanonicalGraph({ + lines: [{ id: "line-r", fromConnectorId: "out-1", toConnectorId: "in-1" }], + }); + handle.flush(); + const line = mirror.line("line-r")!; + + // Pick up from in-1, drop on in-2. + const event = { button: 0, pointerId: 13 } as any; + target.armSurfaceGesture({ position: pos(0, 0), event } as any, null); + driveGestureDrop(source, 300, 13); + + expect(requests).toHaveLength(1); + expect(requests[0]).toMatchObject({ + intent: "reconnect", + update: [{ id: "line-r", toConnectorId: "in-2" }], + }); + + // The app applies the endpoint update; the SAME mirror settles onto in-2. + handle.setCanonicalGraph({ + lines: [{ id: "line-r", fromConnectorId: "out-1", toConnectorId: "in-2" }], + }); + handle.flush(); + expect(mirror.line("line-r")).toBe(line); + expect(line.target).toBe(secondTarget); + expect(line.phase).toBe("connected"); +}); + +test("a bulk load reconciles exactly once at the outermost batch end", async () => { + const { engine, handle } = controlledHarness(); + const mirror = getGraphMirror(engine); + const reconciler = mirror.reconciler!; + const originalReconcile = reconciler.reconcile!.bind(reconciler); + let passes = 0; + (reconciler as { reconcile?: () => void }).reconcile = () => { + passes += 1; + originalReconcile(); + }; + + const batch = mirror.beginBatch(); + handle.setCanonicalGraph({ + lines: [ + { id: "bulk-1", fromConnectorId: "out-1", toConnectorId: "in-1" }, + { id: "bulk-2", fromConnectorId: "out-2", toConnectorId: "in-1" }, + ], + }); + // Connectors mount across several "commits" while the batch is open. + const { targetNode } = mountConnectedPair(engine); + await new Promise((resolve) => setTimeout(resolve, 0)); + new ConnectorMirror(engine, new NodeMirror(engine, null), { + id: "out-2", + name: "out2", + rules: { maxIncoming: 0 }, + }); + targetNode.setSizeState(10, 10); + expect(passes).toBe(0); + + batch.end(); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(passes).toBe(1); + expect(mirror.line("bulk-1")).not.toBeNull(); + // bulk-2 targets the same maxIncoming:1 input — latent with a diagnostic, + // exactly one pass regardless. + expect(mirror.diagnostics().map((error) => error.code)).toEqual([ + "capacity-exceeded", + ]); +}); diff --git a/tests/ut/snapline-perf.spec.ts b/tests/ut/snapline-perf.spec.ts new file mode 100644 index 0000000..239a926 --- /dev/null +++ b/tests/ut/snapline-perf.spec.ts @@ -0,0 +1,80 @@ +import { test } from "@playwright/test"; +import { + ConnectorMirror, + NodeMirror, + type LineRecord, +} from "../../assets/snapline/core/src"; +import { getGraphMirror } from "../../assets/snapline/core/src/snapline-globals"; +import { + createControlledHarness, + nearTargetStrategy, +} from "../helpers/snapline-harness"; + +// Informational only — no assertions on timings. Logs the hot paths so a +// pathological regression (accidental O(n²), per-pointer-move scans) is +// visible in test output before it reaches an application. +test("perf: reconcile, candidate discovery, and bulk load on a 200-node graph", () => { + const { engine, handle } = createControlledHarness(); + const mirror = getGraphMirror(engine); + + const NODES = 200; + const LINES = 150; + const sources: ConnectorMirror[] = []; + for (let index = 0; index < NODES; index += 1) { + const node = new NodeMirror(engine, null, { id: `n${index}` }); + sources.push( + new ConnectorMirror(engine, node, { + id: `n${index}:out`, + name: "out", + rules: { maxIncoming: 0 }, + }), + ); + new ConnectorMirror(engine, node, { + id: `n${index}:in`, + name: "in", + rules: { maxOutgoing: 0 }, + surfaceStrategies: [nearTargetStrategy], + }); + } + const records: LineRecord[] = []; + for (let index = 0; index < LINES; index += 1) { + records.push({ + id: `l${index}`, + fromConnectorId: `n${index}:out`, + toConnectorId: `n${(index + 1) % NODES}:in`, + }); + } + + const coldStart = performance.now(); + handle.setCanonicalGraph({ lines: records }); + handle.flush(); + const cold = performance.now() - coldStart; + + const warmStart = performance.now(); + handle.flush(); + const warm = performance.now() - warmStart; + + // Candidate discovery is the per-pointer-move hot path during a drag. + const probeStart = performance.now(); + const PROBES = 100; + for (let index = 0; index < PROBES; index += 1) { + sources[0].findCandidateAtPoint({ x: 100 + (index % 50), y: 10 }); + } + const probe = (performance.now() - probeStart) / PROBES; + + // Bulk load: fresh records over a cleared document inside one batch. + handle.setCanonicalGraph({ lines: [] }); + handle.flush(); + const bulkStart = performance.now(); + const batch = mirror.beginBatch(); + handle.setCanonicalGraph({ lines: records }); + batch.end(); + mirror.flush(); + const bulk = performance.now() - bulkStart; + + console.log( + `[snapline-perf] ${NODES} nodes / ${LINES} lines — ` + + `cold reconcile ${cold.toFixed(1)}ms, warm ${warm.toFixed(1)}ms, ` + + `candidate probe ${probe.toFixed(3)}ms/move, bulk load ${bulk.toFixed(1)}ms`, + ); +}); diff --git a/website/src/lib/components/docs/SnapLineDemo.svelte b/website/src/lib/components/docs/SnapLineDemo.svelte index bc97585..a38cd28 100644 --- a/website/src/lib/components/docs/SnapLineDemo.svelte +++ b/website/src/lib/components/docs/SnapLineDemo.svelte @@ -9,6 +9,8 @@ Placement, Select, } from "@snap-engine/snapline-svelte"; + import { ControlledGraph } from "@snap-engine/snapline-svelte"; + import type { LineChangeRequest, LineRecord } from "@snap-engine/snapline"; import ClientDemoFrame from "$lib/components/ClientDemoFrame.svelte"; let { @@ -18,6 +20,22 @@ } = $props(); let engine = $state(null); + // Topology is always controlled: the demo owns its line document and + // accepts every atomic proposal. + let lines = $state([]); + function applyRequest(request: LineChangeRequest): void { + lines = [ + ...lines + .filter((record) => !request.remove.includes(record.id)) + .map((record) => { + const update = request.update.find((entry) => entry.id === record.id); + return update + ? { ...record, toConnectorId: update.toConnectorId } + : record; + }), + ...request.add, + ]; + } let placement = $state | null>(null); let placedNodes = $state>([]); let nextPlacedId = 1; @@ -74,16 +92,17 @@
{#if mode === "connections"} + Source Drag the port -
+
Result Drop it here
- +
{:else if mode === "selection"} @@ -100,15 +119,7 @@ {#if placement} {#snippet preview(snapshot)} - {#if snapshot.position} -
- Node -
- {/if} +
Node
{/snippet}
{/if} @@ -263,7 +274,7 @@ pointer-events: none; } - .placement-preview.blocked { + :global([data-snapline-type="placement-preview"][data-allowed="false"]) .placement-preview { border-color: #b14f4f; color: #8b3030; } diff --git a/website/svelte.config.js b/website/svelte.config.js index 10038e3..faf1a48 100644 --- a/website/svelte.config.js +++ b/website/svelte.config.js @@ -30,8 +30,16 @@ const config = { remarkPlugins: [remarkAlerts, remarkFrameworkCodeBlocks], highlight: { highlighter: (code, lang = "plaintext") => { + // Unknown fence languages (mermaid diagrams in the design docs, + // etc.) must not break the whole docs build — fall back to plain + // text instead of letting shiki throw. + const resolvedLang = highlighter + .getLoadedLanguages() + .includes(lang) + ? lang + : "plaintext"; const highlighted = highlighter.codeToHtml(code, { - lang, + lang: resolvedLang, theme: "custom-theme", }); const html = escapeSvelte(