diff --git a/SNAPZEN.md b/SNAPZEN.md index 88ccafd..58c48f6 100644 --- a/SNAPZEN.md +++ b/SNAPZEN.md @@ -10,5 +10,5 @@ - The engine owns the representation. - When representation must change due to data mutation, request the engine to update. -- No external dependancies. +- No external dependencies. - Prioritize code maintainability, reliability, feature set, browser support, bundle size, in that order. diff --git a/assets/snapline/AGENTS.md b/assets/snapline/AGENTS.md index f1beb97..ea34f91 100644 --- a/assets/snapline/AGENTS.md +++ b/assets/snapline/AGENTS.md @@ -8,6 +8,34 @@ SnapLine is experimental and published as synchronized core, Svelte, and React packages. Breaking changes are allowed before 1.0 and should replace obsolete APIs directly rather than adding compatibility shims. +## Design rules (from `SNAPZEN.md`) + +`SNAPZEN.md` at the repo root is the tie-breaker for every API decision here. +The three that come up constantly, and what they have already decided: + +1. **Explicit verbosity over magic abstraction. Expose the primitives; let + developers combine them.** This is why `onGeometryInvalidated` hands over + no geometry and picks no write stage — a callback fired from inside + `writeTransform()` would silently decide the subscriber runs in WRITE_2, + with nothing in the signature saying so. +2. **There should only be one way to do something.** This is why the per-line + renderer resolver has two levels and not four, why the package has one + entry point instead of eleven subpaths, why `query()` is the only + enumeration surface, and why there is no adapter prop wrapping + `onGeometryInvalidated`. +3. **The developer owns the data; when it must change, ask permission. The + engine owns the representation.** This is the whole controlled-graph + protocol — and why there is no `onNodeChangeRequest`: the application + decides when a node exists, so SnapLine reports the empty-space drop and + the application mounts the node and adds the record. + +**There are no such thing as sensible defaults.** When a hook seeds +application data, its absence is meaningful — `NodeCallbacks.resolveNewLine` +stays optional rather than defaulting to a no-op. + +A convenience API that duplicates an existing primitive should be rejected on +these grounds, not merely debated. + ## Packages ### @snap-engine/snapline @@ -16,15 +44,21 @@ APIs directly rather than adding compatibility shims. **Dependencies:** `@snap-engine/core` **Exports:** -- `NodeMirror` - Graph node with connectors (opt-in eight-direction resize) +- `NodeMirror` - Graph node with connectors and DOM-region resizing +- `ResizeRegionMirror` - Developer-owned DOM resize surface parented to a node - `ConnectorMirror` - Input/output connector - `LineMirror` - Visual connection line -- `GroupNodeMirror` - Resizable box that carries the nodes inside it +- `GroupNodeMirror` - Group 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 +- `query` - Read-only `GraphQuery` facade over one engine's graph (the only + enumeration surface: `nodes` / `connectors` / `groups` / `selection` / `lines`) +- `getGraphRegistry` - The per-engine `GraphRegistry`, lazy-created on first use + +Everything is reached through the package root (`@snap-engine/snapline`); the +package declares a single `.` export and no per-module subpaths. Modules under +`core/src/internal/` are implementation detail — do not deep-import them. ### @snap-engine/snapline-svelte **Location:** `svelte/src/` @@ -34,6 +68,7 @@ APIs directly rather than adding compatibility shims. **Exports:** - `Node.svelte` - Node component - `Group.svelte` - Exclusive nested group component +- `ResizeRegion.svelte` - Developer-styled DOM resize surface - `Connector.svelte` - Connector component - `Line.svelte` - Connection line component - `Select.svelte` - Rectangle selection component @@ -45,7 +80,7 @@ APIs directly rather than adding compatibility shims. **Language:** React/TypeScript **Dependencies:** `@snap-engine/snapline`, `@snap-engine/core` -Exports `Engine`, `Node`, `Group`, `Connector`, `Line`, `Select`, +Exports `Engine`, `Node`, `Group`, `ResizeRegion`, `Connector`, `Line`, `Select`, `Placement`, and `ControlledGraph`, with forwarded refs to core objects where applicable. @@ -64,11 +99,14 @@ snapline/ │ ├── group.ts # GroupNodeMirror │ ├── select.ts # RectSelectController │ ├── placement.ts # PlacementController -│ ├── graph-mirror.ts # GraphMirror (per-engine registry + scheduler) -│ ├── line-reconciler.ts # LineReconciler + LineRecord/LineChangeRequest │ ├── query.ts # query() GraphQuery facade -│ ├── geometry.ts # GeometryWriter type -│ └── snapline-globals.ts # global.data accessors + attachControlledGraph +│ ├── types.ts # pure public types (identity, diagnostics, +│ │ # GeometryWriter, controlled-graph contract) +│ ├── controlled-graph.ts # attachControlledGraph +│ └── internal/ # not part of the public surface +│ ├── graph-registry.ts # GraphRegistry (per-engine registry + scheduler) +│ ├── line-reconciler.ts # LineReconciler +│ └── shared-data.ts # global.data accessors + getGraphRegistry ├── svelte/ │ ├── package.json │ ├── tsconfig.json @@ -76,6 +114,7 @@ snapline/ │ ├── index.ts │ ├── Node.svelte │ ├── Group.svelte +│ ├── ResizeRegion.svelte │ ├── Connector.svelte │ ├── Line.svelte │ ├── Select.svelte @@ -89,6 +128,7 @@ snapline/ ├── Engine.tsx ├── Node.tsx ├── Group.tsx + ├── ResizeRegion.tsx ├── Connector.tsx ├── Line.tsx ├── Select.tsx @@ -126,7 +166,7 @@ snapline/ **Features:** - Derived source/target roles (`isSource` = `maxOutgoing !== 0`, `isTarget` = `maxIncoming !== 0`) - Connection limits and admission predicates -- Surface strategies for headless hit testing and anchors +- Target hit-testing and anchor strategies around DOM-owned connector roots - Connection callbacks ### LineMirror @@ -153,10 +193,21 @@ snapline/ ### Node.svelte **Purpose:** Node wrapper component -**Props:** -- `className?: string` - CSS class -- `LineSvelteComponent?: Component` - Custom line component -- `nodeObject?: NodeMirror` (bindable) - Node instance +**Props** (React's `Node` mirrors these; the notable naming difference is +Svelte's `LineSvelteComponent` vs React's `lineComponent`, forced by Svelte 5 +requiring PascalCase for dynamic components): + +- Identity/DOM: `id`, `className`, `elementProps`, `nodeObject` (bindable) +- Geometry: `x`, `y`, `width`, `height` +- Resize floor: `minWidth`, `minHeight`; opt in by rendering explicit + `ResizeRegion` children +- Rendering: `LineSvelteComponent` (one renderer for all this node's lines), + `resolveLineComponent(line)` (per-line override, resolved at render time) +- Data: `metadata` +- Callbacks: `callbacks` (the whole `NodeCallbacks` dictionary, including + `resolveNewLine` for seeding payload onto newly dragged lines), plus the + convenience props `onGeometryCommit` and `onSizeChange` +- `edgePan` **Slots:** - Default: Node content and connectors @@ -201,7 +252,7 @@ Concretely: - **Node/group transforms and live width/height** are core-written during a gesture. Resize uses `WRITE_1 → READ_2 → WRITE_2`: paint the box, remeasure connectors, then re-glue lines. `onSizeChange` is the live observation and - the batched `onGeometryChanged({ nodes })` reports settled geometry the + the batched `onGeometryCommit({ nodes })` reports settled geometry the framework may persist (geometry is SnapLine-owned; ignoring the event never reverts the mirror). - **Position and size are one commit.** `#writeSizeGeometry` paints @@ -225,6 +276,16 @@ Concretely: 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. +- **Observation is separate from painting.** `bindGeometryWriter` is + single-owner (binding a second writer replaces the first — it is the thing + that draws). Anything that merely *watches* geometry uses + `onGeometryInvalidated` on `LineMirror`/`NodeMirror`: multicast, and it + fires synchronously at input dispatch **before any frame task is queued**, + carrying no geometry. Subscribers schedule their own task + (`schedule(cb, { stage, queueId })`) and read `geometrySnapshot()` there. + Core does not pick a write phase on a consumer's behalf — a line paints at + WRITE_2, so an overlay wanting this frame's position schedules WRITE_3. + Never add an adapter prop that wraps this; the mirror is already in hand. - **Adapters must render node/group elements with `position: absolute; transform-origin: top left`** (and ideally `will-change: transform`) — core no longer seeds base styles. @@ -233,6 +294,18 @@ Concretely: ### Callback conventions +Two shapes, and they are not interchangeable. **Notification** callbacks are +`on*` and return `void`. **Registrars** are also `on*` but return an +unsubscribe function and are backed by a `Set`, so any number of consumers may +attach: `LineMirror.onStateChange`, `PlacementController.onStateChange`, and +`onGeometryInvalidated` on line and node. **Value-returning policy** hooks are +named `can*`/`resolve*`/`isValidConnection` — plus the one deliberate +exception, `ControlledGraphCallbacks.onLineChangeRequest`, which returns the +next canonical line list because the return value *is* the protocol. + +Tense matters: `onGeometryInvalidated` fires every frame before the paint; +`onGeometryCommit` fires once, at rest, after a gesture. + Domain/lifecycle callbacks live in `EventProxyFactory` dictionaries — Configuration owns plain callback objects. Node callbacks report drag, selection, resize, and line-list events; group callbacks report membership @@ -249,10 +322,10 @@ metadata and predicates such as `isValidConnection`, `canContain`, and rendering and creation to framework adapters and consumer callbacks. Raw input/DOM plumbing stays on the `event.*` slots. -### GraphMirror (engine-scoped registry) +### GraphRegistry (engine-scoped registry) -`core/src/graph-mirror.ts` is the per-engine registry of every live SnapLine -mirror, lazy-created by `getGraphMirror(engine)` the first time any mirror +`core/src/internal/graph-registry.ts` is the per-engine registry of every live SnapLine +mirror, lazy-created by `getGraphRegistry(engine)` the first time any mirror registers (constructors register, `destroy()` unregisters — no adapter wiring). It holds the node/connector sets, the settled-line and preview-line sets, and the domain-id indexes (`nodesById`/`connectorsById`/`linesById`, @@ -262,7 +335,7 @@ engine-scoped interaction state (`selection`, `groups`, `resizingNode`, the coalescing batch-aware reconciliation scheduler (`scheduleReconciliation`/`flush`/`beginBatch`/`runBatch`). GlobalManager is application-wide, so the registries live in the -`SnapLineSharedData.graphMirrors` WeakMap keyed by engine. `query(engine)` +`SnapLineSharedData.graphRegistries` WeakMap keyed by engine. `query(engine)` is the public read-only facade over it: snapshot lists, `node(id)` / `connector(id)` / `line(id)` lookups, and `diagnostics()` — never registry sets or mutation methods; `query.ts` enumeration helpers delegate to the @@ -271,10 +344,11 @@ same registry. ### Controlled lines (ControlledGraph / LineReconciler) Topology is ALWAYS controlled: the CONSUMER's document is the only line -authority, and there is no imperative public topology API (`deleteLine`, -`createLine`, etc. are `@internal`; a gesture on an engine with no attached -graph owner warns and discards the preview). `core/src/line-reconciler.ts` -plus the `ControlledGraph` adapter components implement the contract: +authority, and there is no imperative public topology API (creation and +deletion are implementation operations; a gesture on an engine with no +attached graph owner warns and discards the preview). +`core/src/line-reconciler.ts` plus the `ControlledGraph` adapter components +implement the contract: `attachControlledGraph(engine, { onLineChangeRequest, onDiagnosticsChanged? })` installs the `LineReconciler` and returns `{ setCanonicalGraph, flush, dispose }`. The app PUSHES its canonical @@ -290,10 +364,11 @@ both endpoints' `isValidConnection` with the real `LineMirror`), stages the outcome on the same mirror (phase `"staged"`, no topology commitment), and dispatches ONE atomic `LineChangeRequest` (`{ intent: connect|disconnect|replace|reconnect, add, remove, update }` — -`replace-oldest` evictions ride the request, never local deletes). Adapters -GUARANTEE a post-request microtask push of the live records ahead of the -decisive pass, so acceptance, normalization, rejection, and -rejection-by-inaction all resolve from the next snapshot — adopting the +`replace-oldest` evictions ride the request, never local deletes). `onLineChangeRequest` +RETURNS the line list that should now be canonical, and the bridge adopts it +synchronously — so exactly one decisive pass runs per request, acceptance and +rejection alike, with no dependence on when a framework flushes state. +Rejection is returning the list unchanged; adopting the proposed `lineId` settles the dragged line in place; rejection needs no code path. Consumers should write their document synchronously inside the request handler; deferred stores degrade to a one-frame pending state, never an @@ -302,12 +377,12 @@ inconsistent one. ### Shared global registries Everything SnapLine stores on the engine's shared `global.data` bag is declared -in `core/src/snapline-globals.ts` (`SnapLineSharedData`) and accessed through -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. +in `core/src/internal/shared-data.ts` (`SnapLineSharedData`) and accessed through +its typed helpers. It holds the `graphRegistries` WeakMap keying each engine to +its `GraphRegistry`, plus the deprecated third-party camera-control flag. +Selection, groups, and `resizingNode` are engine-scoped state on +`GraphRegistry`, not global arrays. Connector source input is ordinary DOM +targeting; SnapLine stores no headless source registry. ### Pointer claims (camera blocking) @@ -342,7 +417,7 @@ boolean remains readable by the camera for third-party writers only. - Equal-size group candidates use stable IDs as a deterministic tie-breaker; membership cycles are always rejected. - Carried group members are moved via transform parenting only — they are never - added to the engine's `GraphMirror.selection`, so a group drag does not + added to the engine's `GraphRegistry.selection`, so a group drag does not alter the selection. - `attachTransformToGroup`/`detachTransformFromGroup` are the public transform-only reparent seam used by the group carry. diff --git a/assets/snapline/core/README.md b/assets/snapline/core/README.md index 3734882..a348f0a 100644 --- a/assets/snapline/core/README.md +++ b/assets/snapline/core/README.md @@ -2,7 +2,7 @@ Framework-neutral node graph interaction primitives for SnapEngine. -SnapLine provides draggable and resizable nodes, connector policy, SVG line +SnapLine provides draggable nodes with explicit DOM resize regions, connector policy, SVG line geometry, rectangle selection, exclusive nested groups, engine queries, and headless palette placement. Applications retain ownership of graph documents, node types, validation, persistence, and styling. @@ -13,24 +13,17 @@ node types, validation, persistence, and styling. npm install @snap-engine/core @snap-engine/snapline ``` -## Entry points +## Entry point -- `@snap-engine/snapline` -- `@snap-engine/snapline/node` -- `@snap-engine/snapline/connector` -- `@snap-engine/snapline/line` -- `@snap-engine/snapline/select` -- `@snap-engine/snapline/group` -- `@snap-engine/snapline/placement` -- `@snap-engine/snapline/query` -- `@snap-engine/snapline/graph-mirror` -- `@snap-engine/snapline/line-reconciler` -- `@snap-engine/snapline/geometry` +`@snap-engine/snapline` — one entry point, no per-module subpaths. Everything +public is re-exported from the package root; `core/src/internal/` is +implementation detail and must not be deep-imported. ```ts import { GroupNodeMirror, NodeMirror, + ResizeRegionMirror, getParentGroup, setGroupMembershipResolver, } from "@snap-engine/snapline"; @@ -39,6 +32,10 @@ import { After assigning a Vanilla-rendered element, call `remeasureDomGeometry()`. Svelte and React adapters perform that synchronization automatically. +Resizing is opt-in: create a `ResizeRegionMirror` child and assign its DOM +element. The application owns that element's hit area, position, cursor, +hover behavior, and visuals. + 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 @@ -47,17 +44,17 @@ separate. A custom line renderer should mount its SVG/Canvas structure once, bind a writer, and call the returned cleanup function when it unmounts. When another interaction system applies transient transforms inside a node, -call `connector.requestDomGeometrySync()` for each affected connector. The -request is coalesced into the next read/write cycle and updates every connected -line without coupling SnapLine to the external system. - -Surface strategies decouple connection hit testing from visible connector -elements. They can activate from a node border, rank shape-specific target -hits, and resolve preview and settled anchors from cached geometry. Symmetric -connector rules (`maxOutgoing`/`maxIncoming`, `"unlimited"` explicit) let the -same logical surface start and accept connections. `onPointerDown` runs when a connector claims the primary pointer, -before the drag threshold, so consumers can preserve click selection or other -gesture-start UI for headless surfaces. +call `node.remeasureDomGeometry()`. The remeasure is coalesced into the next +read/write cycle and re-glues every connected line without coupling SnapLine to +the external system. + +Connector roots own gesture initiation, while target-hit and anchor strategies +can rank shape-specific collision candidates and resolve preview and settled +anchors from cached geometry. Symmetric connector rules +(`maxOutgoing`/`maxIncoming`, `"unlimited"` explicit) let the same logical +connector start and accept connections. `onPointerDown` runs when a connector +claims the primary pointer, before the drag threshold, so consumers can +preserve click selection or other gesture-start UI on custom HTML or SVG roots. Call `connector.updateConfig(...)` to change callbacks, metadata, policy, surface strategies, collider radius, or edge-pan behavior diff --git a/assets/snapline/core/package.json b/assets/snapline/core/package.json index 37da1a5..74abcf9 100644 --- a/assets/snapline/core/package.json +++ b/assets/snapline/core/package.json @@ -11,17 +11,7 @@ "main": "./src/index.ts", "types": "./src/index.ts", "exports": { - ".": "./src/index.ts", - "./node": "./src/node.ts", - "./connector": "./src/connector.ts", - "./line": "./src/line.ts", - "./select": "./src/select.ts", - "./group": "./src/group.ts", - "./placement": "./src/placement.ts", - "./query": "./src/query.ts", - "./graph-mirror": "./src/graph-mirror.ts", - "./line-reconciler": "./src/line-reconciler.ts", - "./geometry": "./src/geometry.ts" + ".": "./src/index.ts" }, "files": [ "src", diff --git a/assets/snapline/core/src/connector.ts b/assets/snapline/core/src/connector.ts index b9f2c3a..4a1f84a 100644 --- a/assets/snapline/core/src/connector.ts +++ b/assets/snapline/core/src/connector.ts @@ -1,4 +1,4 @@ -import { ElementObject, BaseObject } from "@snap-engine/core"; +import { ElementObject, BaseObject, type DomElement } from "@snap-engine/core"; import type { dragEndProp, dragProp, @@ -9,11 +9,10 @@ import type { } from "@snap-engine/core"; import { CircleCollider } from "@snap-engine/core/collision"; import type { NodeMirror } from "./node"; -import { LineMirror, type LineMirrorPhase } from "./line"; -import { getGraphMirror } from "./snapline-globals"; -import { getSourceSurfaces } from "./snapline-globals"; -import { mintDomainId } from "./graph-mirror"; -import type { LineChangeRequest } from "./line-reconciler"; +import { LineMirror, cloneAnchor, type LineMirrorPhase } from "./line"; +import { getGraphRegistry } from "./internal/shared-data"; +import { mintDomainId } from "./internal/graph-registry"; +import type { LineChangeRequest } from "./types"; export type SnapLineMetadata = Record; export type ConnectionOrigin = "gesture" | "hydration"; @@ -24,6 +23,21 @@ export type DisconnectReason = | "teardown"; export type ConnectorRole = "source" | "target"; +/** + * How a line drag ended. "empty-space" and "refused" were previously + * indistinguishable — both simply reported `connected: false` — but only the + * first means the user gestured toward a node that does not exist yet. + */ +export type DragEndOutcome = + /** Settled onto an admitting target. */ + | "connected" + /** Dropped over a target that declined (capacity, predicate, or roles). */ + | "refused" + /** Dropped where no connector was resolved at all. */ + | "empty-space" + /** The gesture was cancelled (pointer cancel, teardown). */ + | "cancelled"; + export interface ConnectorPoint { x: number; y: number; @@ -87,9 +101,6 @@ export interface ConnectorAnchorEvent { } export interface ConnectorSurfaceStrategy { - sourceHitTest?: ( - event: ConnectorSurfaceHitTestEvent, - ) => ConnectorHit | null | false | void; targetHitTest?: ( event: ConnectorSurfaceHitTestEvent, ) => ConnectorHit | null | false | void; @@ -175,13 +186,22 @@ export interface ConnectorPointerEvent extends ConnectorDragEvent { } export interface ConnectorCallbacks { + /** Overrides the parent node's `resolveNewLine` for this connector only. */ + resolveNewLine?: NewLineResolver; /** Fires when this connector claims a primary pointer, before drag threshold. */ onPointerDown?: (event: ConnectorPointerEvent) => void; onDragStart?: (event: ConnectorDragEvent) => void; onCandidateChange?: (event: ConnectorCandidateEvent) => void; onConnect?: (event: ConnectorConnectionEvent) => void; onDisconnect?: (event: ConnectorDisconnectionEvent) => void; - onDragEnd?: (event: ConnectorDragEvent & { connected: boolean }) => void; + onDragEnd?: ( + event: ConnectorDragEvent & { + connected: boolean; + /** Why the drag ended — distinguishes an empty-space drop from a + * refusal, which `connected` alone cannot. */ + outcome: DragEndOutcome; + }, + ) => void; } enum ConnectorState { @@ -207,6 +227,27 @@ export interface ConnectorConfig { edgePan?: boolean; } +/** Context for seeding a brand-new line at drag start. */ +export interface NewLineEvent { + connector: ConnectorMirror; + node: NodeMirror; +} + +/** + * Seeds application data onto a line the moment a drag creates it. + * + * Runs **once per gesture, only for genuinely new lines** — never for a + * reconnect, whose payload the application already owns. Must be synchronous. + * Whatever it returns becomes `LineMirror.payload`, so a renderer can style + * the preview, and it rides into `request.add` at drop, so the settled record + * carries the same data without the reducer deriving it again. + * + * Keep the value **serializable**: it round-trips through your document. + * Store a discriminator like `{ kind: "data" }` and map that to a component + * in `resolveLineComponent`, never a component reference itself. + */ +export type NewLineResolver = (event: NewLineEvent) => unknown; + export type ConnectorConfigUpdate = Partial>; export interface ConnectorResolvedHit { @@ -222,7 +263,7 @@ interface ArmedConnection { reconnectLine: LineMirror | null; } -class ConnectorMirror extends ElementObject { +class ConnectorMirror extends ElementObject { /** Stable domain identity — supplied via `ConnectorConfig.id` or minted. * Never the engine-internal `BaseObject.id`. */ readonly connectorId: string; @@ -234,7 +275,6 @@ class ConnectorMirror extends ElementObject { #state: ConnectorState = ConnectorState.IDLE; #hitCircle: CircleCollider; - #targetConnector: ConnectorMirror | null = null; #candidate: ConnectorResolvedHit | null = null; #dragLine: LineMirror | null = null; #edgePanPointerId: number | null = null; @@ -243,6 +283,7 @@ class ConnectorMirror extends ElementObject { #armed: ArmedConnection | null = null; #gestureOrigin: "new" | "reconnect" | null = null; #cancelledPointers = new Set(); + #dragDelegate: ConnectorMirror | null = null; #callbacks: ConnectorCallbacks; get parent(): NodeMirror { @@ -280,8 +321,7 @@ class ConnectorMirror extends ElementObject { this.#config.colliderRadius ?? 30, ); this.addCollider(this.#hitCircle); - this.#syncSourceSurfaceRegistration(); - getGraphMirror(this.engine).registerConnector(this); + getGraphRegistry(this.engine).registerConnector(this); this.event.dom.onAssignDom = () => { this.schedule( @@ -328,12 +368,8 @@ class ConnectorMirror extends ElementObject { return this.#callbacks; } - set callbacks(callbacks: ConnectorCallbacks) { - this.updateConfig({ callbacks }); - } - // Snapshots, never the internal arrays — topology mutation goes through - // connectToConnector/deleteLine/disconnectFromConnector. + // the reconciler-only lifecycle operations, never these accessors. get outgoingLines(): readonly LineMirror[] { return [...this.#outgoingLines]; } @@ -342,35 +378,6 @@ class ConnectorMirror extends ElementObject { return [...this.#incomingLines]; } - get targetConnector(): ConnectorMirror | null { - return this.#targetConnector; - } - - set targetConnector(value: ConnectorMirror | null) { - const resolved = value - ? { - candidate: { - connector: value, - hit: { - anchor: value.center, - distance: 0, - }, - }, - strategy: value.#defaultAnchorStrategy(), - strategyIndex: -1, - } - : null; - this.#setCandidate(resolved); - } - - get numIncomingLines(): number { - return this.#incomingLines.length; - } - - get numOutgoingLines(): number { - return this.#outgoingLines.length; - } - /** * Updates runtime connector policy without replacing the connector or its * existing topology. `name` remains construction-only because it keys the @@ -381,15 +388,14 @@ class ConnectorMirror extends ElementObject { this.#callbacks = this.#config.callbacks ?? {}; this.#rules = Object.freeze(resolveRules(this.#config)); this.#hitCircle.radius = this.#config.colliderRadius ?? 30; - this.#syncSourceSurfaceRegistration(); this.scheduleAllLineWrites(); } /** - * Attaches or detaches the optional visible port without destroying the - * logical connector. Framework adapters use this for live `virtual` changes. + * Attaches or detaches the developer-rendered HTML or SVG input root without + * destroying the logical connector. */ - bindElement(element: HTMLElement | null): void { + bindElement(element: DomElement | null): void { if (this.element === element) return; if (this.element) { this.destroyDom(false); @@ -479,38 +485,9 @@ class ConnectorMirror extends ElementObject { }; } - requestDomGeometrySync(): boolean { - if (!this.element?.isConnected || !this.parent) return false; - - this.schedule( - () => { - if (!this.element?.isConnected || !this.parent) return; - this.measureLocalCenter("READ_1"); - }, - { - stage: "READ_1", - queueId: `${this.id}-dom-geometry`, - }, - ); - for (const line of [...this.#outgoingLines, ...this.#incomingLines]) { - line.schedule( - () => { - line.moveLineToConnectorTransform(); - line.writeTransform(); - }, - { - stage: "WRITE_1", - queueId: `${line.id}-dom-geometry`, - }, - ); - } - return true; - } - onCursorDown(prop: pointerDownProp): void { if (prop.event.button !== 0) return; - const sourceHit = this.#resolveOwnSourceHit(prop.position, "source-start"); - this.armSurfaceGesture(prop, sourceHit); + this.armSurfaceGesture(prop, null); } armSurfaceGesture( @@ -522,7 +499,7 @@ class ConnectorMirror extends ElementObject { if (this.#rules.reconnect && currentIncomingLines.length > 0) { const line = currentIncomingLines[0]; const source = line.start; - this.engine.input.setPointerDragOwner(prop.event.pointerId, source); + this.#dragDelegate = source; source.#arm(prop, { sourceHit: null, sourceStrategy: null, @@ -563,8 +540,17 @@ class ConnectorMirror extends ElementObject { #onPointerUp(prop: pointerUpProp): void { const pointerId = prop.event.pointerId; + const delegate = this.#dragDelegate; + this.#dragDelegate = null; + if ( + delegate && + delegate.#armed?.pointerId === pointerId && + delegate.#state === ConnectorState.ARMED + ) { + delegate.#resetGesture(); + } if (this.#armed?.pointerId !== pointerId) return; - if (prop.event.type === "pointercancel") { + if (prop.cancelled) { this.#cancelledPointers.add(pointerId); } if (this.#state === ConnectorState.ARMED) { @@ -573,6 +559,13 @@ class ConnectorMirror extends ElementObject { } #onDragStart(prop: dragStartProp): void { + const delegate = this.#dragDelegate; + if (delegate && delegate.#armed?.pointerId === prop.pointerId) { + this.#dragDelegate = null; + prop.handoffTo(delegate); + delegate.#onDragStart(prop); + return; + } if ( this.#state !== ConnectorState.ARMED || this.#armed?.pointerId !== prop.pointerId @@ -587,8 +580,14 @@ class ConnectorMirror extends ElementObject { this.#detachLineForReconnect(line); line.clearTarget(); } else { - line = this.createLine(); + line = this.#createLine(); line.setSourceSurfaceContext(armed.sourceStrategy, armed.sourceHit); + // Seed app data onto a genuinely new line. This branch structurally + // cannot run for a reconnect, so it can never clobber payload the + // application already owns. The payload rides into request.add at drop, + // so preview and settled line agree without deriving it twice. + const seed = this.#resolveNewLine(); + if (seed) line.setPayload(seed({ connector: this, node: this.parent })); this.#outgoingLines.unshift(line); } @@ -642,30 +641,14 @@ class ConnectorMirror extends ElementObject { scheduleAllLineWrites(): void { for (const line of [...this.#outgoingLines, ...this.#incomingLines]) { - line.schedule( - () => { - line.moveLineToConnectorTransform(); - line.writeTransform(); - }, - { - stage: "WRITE_2", - queueId: `${line.id}-transform`, - }, - ); - } - } - - writeAllLinesNow(): void { - for (const line of [...this.#outgoingLines, ...this.#incomingLines]) { - line.moveLineToConnectorTransform(); - line.writeTransform(); + line.invalidateGeometry(); } } assignToNode(parent: NodeMirror): void { this.parent = parent; const parentRef = this.parent; - parentRef._connectors[this.#name] = this; + parentRef.attachConnector(this); this.#outgoingLines = []; this.#incomingLines = []; if (parentRef.global && this.global == null) { @@ -673,35 +656,14 @@ class ConnectorMirror extends ElementObject { } } - /** @internal Gesture/reconciler-only: lines exist because canonical + /** Gesture/reconciler-only: lines exist because canonical * records (or in-flight gestures) say so. */ - createLine(config: { id?: string } = {}): LineMirror { + #createLine(config: { id?: string } = {}): LineMirror { const line = new LineMirror(this.engine, this, config); line.setSourceSurfaceContext(this.#defaultAnchorStrategy(), null); return line; } - findClosestConnector(): void { - if (!this.#dragLine) { - this.#setCandidate(null); - return; - } - const position = { - ...this.#dragLine.endAnchor, - cameraX: this.#dragLine.endAnchor.x, - cameraY: this.#dragLine.endAnchor.y, - screenX: this.#dragLine.endAnchor.x, - screenY: this.#dragLine.endAnchor.y, - }; - this.#setCandidate(this.#resolveTargetAtPoint(position, "preview-target")); - } - - findClosestConnectorAtPoint( - position: ConnectorPoint, - ): ConnectorMirror | null { - return this.findCandidateAtPoint(position)?.connector ?? null; - } - findCandidateAtPoint( position: ConnectorPoint, phase: "preview-target" | "drop" = "preview-target", @@ -712,10 +674,6 @@ class ConnectorMirror extends ElementObject { ); } - resolveSourceHit(position: eventPosition): ConnectorResolvedHit | null { - return this.#resolveOwnSourceHit(position, "source-start"); - } - /** * Imperative admission query. Structural rules always apply; the * line-aware isValidConnection predicates run only when a line is given. @@ -821,24 +779,6 @@ class ConnectorMirror extends ElementObject { }); } - hoverWhileDragging( - targetConnector: ConnectorMirror, - ): [number, number] | void { - if (!(targetConnector instanceof ConnectorMirror) || !this.#dragLine) { - return; - } - const anchor = targetConnector.resolveAnchor({ - line: this.#dragLine, - role: "target", - phase: "preview-target", - peer: this, - position: this.geometry.center, - hit: this.#candidate?.candidate.hit ?? null, - strategy: this.#candidate?.strategy ?? null, - }); - return [anchor.x, anchor.y]; - } - endDragOutLine(prop: dragEndProp): void { if ( this.#state !== ConnectorState.DRAGGING || @@ -852,8 +792,8 @@ class ConnectorMirror extends ElementObject { return; } - if (this.#cancelledPointers.has(prop.pointerId)) { - this.#discardDraggedLine(line, prop, false); + if (prop.cancelled || this.#cancelledPointers.has(prop.pointerId)) { + this.#discardDraggedLine(line, prop, false, "cancelled"); return; } @@ -862,7 +802,7 @@ class ConnectorMirror extends ElementObject { line.setPhase("drop"); line.setPreviewPosition(prop.end); - const mirror = getGraphMirror(this.engine); + const mirror = getGraphRegistry(this.engine); if (typeof mirror.reconciler?.dispatchLineChangeRequest === "function") { this.#endControlledDrop(line, candidate, prop); return; @@ -870,9 +810,9 @@ class ConnectorMirror extends ElementObject { // 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.", + "SnapLine: gesture on an engine with no ControlledGraph attached — mount (or call attachControlledGraph) so gestures have a document to propose to.", ); - this.#discardDraggedLine(line, prop, false); + this.#discardDraggedLine(line, prop, false, "cancelled"); } /** @@ -887,12 +827,19 @@ class ConnectorMirror extends ElementObject { prop: dragEndProp, ): void { const target = candidate?.candidate.connector ?? null; - - if ( - !candidate || - !target || - !this.#admitsConnection(target, line, "drop") - ) { + // "Nothing under the pointer" and "something under the pointer that said + // no" are different answers, and only the first one means the user asked + // for a node that does not exist yet. + const outcome: DragEndOutcome = + !candidate || !target + ? "empty-space" + : this.#admitsConnection(target, line, "drop") + ? "connected" + : "refused"; + + // The candidate/target checks are redundant with `outcome` but keep the + // non-null narrowing for everything below. + if (outcome !== "connected" || !candidate || !target) { if (this.#gestureOrigin === "reconnect") { // Gesture disconnect: propose the removal; the line stays visibly // detached until the decision. A rejected removal re-glues it from @@ -911,11 +858,12 @@ class ConnectorMirror extends ElementObject { position: prop.end, pointerId: prop.pointerId, connected: false, + outcome, }); this.#resetGesture(); return; } - this.#discardDraggedLine(line, prop, false); + this.#discardDraggedLine(line, prop, false, outcome); return; } @@ -964,23 +912,27 @@ class ConnectorMirror extends ElementObject { position: prop.end, pointerId: prop.pointerId, connected: true, + outcome: "connected", }); this.#resetGesture(); } + /** Connector-level override, else the parent node's resolver. */ + #resolveNewLine(): NewLineResolver | null { + return ( + this.#callbacks.resolveNewLine ?? + this.parent?.callbacks.resolveNewLine ?? + null + ); + } + #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(); + // The dispatch itself adopts the application's returned line list, so the + // decisive pass this schedules always sees the app's answer — including + // rejection, which returns the list unchanged. + const registry = getGraphRegistry(this.engine); + registry.reconciler?.dispatchLineChangeRequest?.(request); + registry.scheduleReconciliation(); } /** @internal Reconciler-only: settle a staged gesture line onto its @@ -1008,15 +960,6 @@ class ConnectorMirror extends ElementObject { this.parent?.updateNodeLineList(); } - startPickUpLine(line: LineMirror, prop: pointerDownProp): void { - this.engine.input.setPointerDragOwner(prop.event.pointerId, line.start); - line.start.#arm(prop, { - sourceHit: null, - sourceStrategy: null, - reconnectLine: line, - }); - } - /** * Settle a line that already sits in this connector's outgoing list onto * its target: run the explicit replacement policy, detach any previous @@ -1057,17 +1000,6 @@ class ConnectorMirror extends ElementObject { this.#emitConnect(target, line, origin); } - /** @internal Reconciler/teardown-only. */ - disconnectFromConnector( - connector: ConnectorMirror, - reason: DisconnectReason = "programmatic", - ): void { - const line = this.#outgoingLines.find( - (outgoingLine) => outgoingLine.target === connector, - ); - if (line) this.deleteLine(line, reason); - } - resolveAnchor({ line, role, @@ -1118,57 +1050,34 @@ class ConnectorMirror extends ElementObject { } this.#resetGesture(); this.deleteAllLines("teardown"); - getGraphMirror(this.engine).unregisterConnector(this); - if (this.parent?._connectors[this.#name] === this) { - delete this.parent._connectors[this.#name]; - } - this.#removeSourceSurfaceRegistration(); + getGraphRegistry(this.engine).unregisterConnector(this); + this.parent?.detachConnector(this); this.globalInput.pointerUp = null; super.destroy(removeElement); } - #resolveOwnSourceHit( - position: eventPosition, - phase: LineMirrorPhase, - ): ConnectorResolvedHit | null { - const hits: ConnectorResolvedHit[] = []; - for (const [strategyIndex, strategy] of this.surfaceStrategies.entries()) { - const hit = normalizeHit( - strategy.sourceHitTest?.({ - connector: this, - position, - geometry: this.geometry, - phase, - }), - ); - if (hit) { - hits.push({ - candidate: { connector: this, hit }, - strategy, - strategyIndex, - }); - } - } - return pickResolvedHit(hits); - } - #resolveTargetAtPoint( position: eventPosition, phase: "preview-target" | "drop", ): ConnectorResolvedHit | null { const hits: ConnectorResolvedHit[] = []; - for (const connector of registeredConnectors(this.engine)) { + for (const collider of this.engine.collisionEngine?.queryPoint(position) ?? + []) { + const connector = collider.parent; if ( + !(connector instanceof ConnectorMirror) || + collider !== connector.#hitCircle || connector.engine !== this.engine || + connector.isDeleteRequested || !this.#admitsConnection(connector, this.#dragLine, "candidate") ) { continue; } - for (const [ - strategyIndex, - strategy, - ] of connector.surfaceStrategies.entries()) { + const targetStrategies = connector.surfaceStrategies + .map((strategy, strategyIndex) => ({ strategy, strategyIndex })) + .filter(({ strategy }) => strategy.targetHitTest != null); + for (const { strategyIndex, strategy } of targetStrategies) { const hit = normalizeHit( strategy.targetHitTest?.({ connector, @@ -1186,22 +1095,25 @@ class ConnectorMirror extends ElementObject { } } - if (connector.#hasOrdinaryPortGeometry()) { + if ( + targetStrategies.length === 0 && + connector.#hasOrdinaryPortGeometry() + ) { const center = connector.center; - const distance = Math.hypot( - center.x - position.x, - center.y - position.y, - ); - if (distance <= (connector.#config.colliderRadius ?? 30)) { - hits.push({ - candidate: { - connector, - hit: { anchor: center, distance }, + hits.push({ + candidate: { + connector, + hit: { + anchor: center, + distance: Math.hypot( + center.x - position.x, + center.y - position.y, + ), }, - strategy: connector.#defaultAnchorStrategy(), - strategyIndex: Number.MAX_SAFE_INTEGER, - }); - } + }, + strategy: connector.#defaultAnchorStrategy(), + strategyIndex: Number.MAX_SAFE_INTEGER, + }); } } return pickResolvedHit(hits); @@ -1221,7 +1133,6 @@ class ConnectorMirror extends ElementObject { return; } this.#candidate = candidate; - this.#targetConnector = candidate?.candidate.connector ?? null; this.#dragLine?.setCandidate( candidate?.candidate ?? null, candidate?.strategy ?? null, @@ -1248,6 +1159,7 @@ class ConnectorMirror extends ElementObject { line: LineMirror, prop: dragEndProp, connected: boolean, + outcome: DragEndOutcome, ): void { if (this.#outgoingLines.includes(line)) this.deleteLine(line, "gesture"); this.#callbacks.onDragEnd?.({ @@ -1255,6 +1167,7 @@ class ConnectorMirror extends ElementObject { position: prop.end, pointerId: prop.pointerId, connected, + outcome, }); this.#resetGesture(); } @@ -1282,25 +1195,6 @@ class ConnectorMirror extends ElementObject { ); } - #syncSourceSurfaceRegistration(): void { - const sourceSurfaces = getSourceSurfaces(this.global); - const index = sourceSurfaces.indexOf(this); - const shouldRegister = - this.isSource && - this.surfaceStrategies.some((strategy) => strategy.sourceHitTest); - if (shouldRegister && index === -1) { - sourceSurfaces.push(this); - } else if (!shouldRegister && index !== -1) { - sourceSurfaces.splice(index, 1); - } - } - - #removeSourceSurfaceRegistration(): void { - const sourceSurfaces = getSourceSurfaces(this.global); - const index = sourceSurfaces.indexOf(this); - if (index !== -1) sourceSurfaces.splice(index, 1); - } - #hasOrdinaryPortGeometry(): boolean { return this.element != null || this.#hasMeasuredCenter; } @@ -1317,7 +1211,7 @@ class ConnectorMirror extends ElementObject { const structural = this.#admitsEndpoints(target, null, false); if (structural !== true) return structural; - const line = this.createLine({ id: record.id }); + const line = this.#createLine({ id: record.id }); if (!this.#predicatesAdmit(target, line, "drop")) { line.destroy(false); return "connection-rejected"; @@ -1346,9 +1240,6 @@ class ConnectorMirror extends ElementObject { return true; } - /** Strict structural admission for canonical records: roles, capacity - * without replacement, and the parallel rule. */ - #liveIncomingLines(): LineMirror[] { return this.#incomingLines.filter((line) => !line.isDeleteRequested); } @@ -1441,16 +1332,6 @@ function isFinitePoint(point: ConnectorPoint): boolean { return Number.isFinite(point.x) && Number.isFinite(point.y); } -function cloneAnchor(anchor: ConnectorAnchor): ConnectorAnchor { - return { - x: anchor.x, - y: anchor.y, - ...(anchor.normal - ? { normal: { x: anchor.normal.x, y: anchor.normal.y } } - : {}), - }; -} - function asEventPosition(position: ConnectorPoint): eventPosition { const value = position as Partial; return { @@ -1486,33 +1367,4 @@ function pickResolvedHit( return hits[0] ?? null; } -export function resolveConnectorSourceAtPoint( - engine: any, - position: eventPosition, - node?: NodeMirror, -): ConnectorResolvedHit | null { - const hits: ConnectorResolvedHit[] = []; - for (const surface of getSourceSurfaces(engine.global)) { - const connector = surface as ConnectorMirror; - if ( - connector.engine !== engine || - (node && connector.parent !== node) || - !connector.isSource - ) { - continue; - } - const resolved = connector.resolveSourceHit(position); - if (resolved) hits.push(resolved); - } - return pickResolvedHit(hits); -} - -function registeredConnectors(engine: any): ConnectorMirror[] { - // The registry is the one connector source — no engine-object-table scan - // (this runs per pointer move during a connection drag). - return getGraphMirror(engine).connectors.filter( - (connector) => !connector.isDeleteRequested, - ); -} - export { ConnectorMirror }; diff --git a/assets/snapline/core/src/controlled-graph.ts b/assets/snapline/core/src/controlled-graph.ts new file mode 100644 index 0000000..a63d87c --- /dev/null +++ b/assets/snapline/core/src/controlled-graph.ts @@ -0,0 +1,73 @@ +import { LineReconciler } from "./internal/line-reconciler"; +import { getGraphRegistry } from "./internal/shared-data"; +import type { + ControlledGraphCallbacks, + ControlledGraphHandle, + LineChangeRequest, + LineRecord, +} from "./types"; + +/** + * Apply one atomic {@link LineChangeRequest} to a line list, returning a new + * list. This is the whole body of a typical `onLineChangeRequest`: + * + * ```ts + * onLineChangeRequest={(r) => (lines = applyLineChange(lines, r))} + * ``` + * + * Removals are dropped, endpoint updates are applied, and additions are + * appended — in that order, as one commit. `intent` is not consulted: it + * describes the gesture for logging, and the three lists already say what to + * do. + * + * **Adopt the proposed ids.** `request.add` carries SnapLine-minted ids; + * keeping them settles the dragged line in place with no flicker. Substituting + * your own id works, but recreates the mirror. + * + * Consumer-owned fields survive an update (the record is spread), but cannot + * be invented for an addition — seed those at drag start with + * `node.callbacks.resolveNewLine`, and they ride into `request.add` for you. + * + * Replace-not-mutate by construction: every call builds a new array, which is + * what makes `$state.raw` safe for the list in Svelte. + */ +export function applyLineChange( + lines: readonly T[], + request: LineChangeRequest, +): T[] { + const removed = new Set(request.remove); + const next: T[] = []; + for (const record of lines) { + if (removed.has(record.id)) continue; + const update = request.update.find((entry) => entry.id === record.id); + next.push( + update ? { ...record, toConnectorId: update.toConnectorId } : record, + ); + } + for (const addition of request.add) next.push(addition as unknown as T); + return next; +} + +/** + * Attach the controlled-graph bridge: declares "controlled" authority, + * installs the line reconciler, and returns the handle the application (or + * adapter) pushes canonical snapshots through. + */ +export function attachControlledGraph( + engine: { global: { data: any } | null }, + callbacks: ControlledGraphCallbacks, +): ControlledGraphHandle { + const registry = getGraphRegistry(engine); + if (registry.reconciler) { + console.warn( + "SnapLine: replacing this engine's existing controlled-graph bridge.", + ); + } + const reconciler = new LineReconciler(registry, callbacks); + registry.reconciler = reconciler; + return { + setCanonicalGraph: (snapshot) => reconciler.setCanonicalGraph(snapshot), + flush: () => registry.flush(), + dispose: () => reconciler.dispose(), + }; +} diff --git a/assets/snapline/core/src/geometry.ts b/assets/snapline/core/src/geometry.ts deleted file mode 100644 index 30e6ed7..0000000 --- a/assets/snapline/core/src/geometry.ts +++ /dev/null @@ -1,2 +0,0 @@ -/** Imperative presentation sink for high-frequency geometry. */ -export type GeometryWriter = (geometry: Readonly) => void; diff --git a/assets/snapline/core/src/group.ts b/assets/snapline/core/src/group.ts index 728cdb0..2e91e2c 100644 --- a/assets/snapline/core/src/group.ts +++ b/assets/snapline/core/src/group.ts @@ -1,10 +1,11 @@ -import type { - BaseObject, - Engine, - eventPosition, +import { + mergeDefined, + type BaseObject, + type Engine, + type eventPosition, } from "@snap-engine/core"; -import { NodeMirror, mergeConfig, type NodeConfig } from "./node"; -import { getGraphMirror } from "./snapline-globals"; +import { NodeMirror, type NodeConfig } from "./node"; +import { getGraphRegistry } from "./internal/shared-data"; export interface GroupConfig extends NodeConfig { width?: number; @@ -51,13 +52,13 @@ const DEFAULT_GROUP_CONFIG = { minHeight: 120, } satisfies GroupConfig; -type Bounds = ReturnType< - NodeMirror["hitBox"]["getWorldBoundsSnapshot"] ->; +type Bounds = ReturnType; function boundsArea(bounds: Bounds): number { - return Math.max(0, bounds.right - bounds.left) * - Math.max(0, bounds.bottom - bounds.top); + return ( + Math.max(0, bounds.right - bounds.left) * + Math.max(0, bounds.bottom - bounds.top) + ); } function containsBounds(container: Bounds, child: Bounds): boolean { @@ -80,11 +81,11 @@ function stableGroupOrder( } function groupsForEngine(group: GroupNodeMirror): GroupNodeMirror[] { - return [...getGraphMirror(group.engine).groups]; + return [...getGraphRegistry(group.engine).groups]; } function nodesForEngine(group: GroupNodeMirror): NodeMirror[] { - return [...getGraphMirror(group.engine).nodes]; + return [...getGraphRegistry(group.engine).nodes]; } function resolveParent( @@ -121,95 +122,9 @@ function wouldCreateGroupCycle( return false; } -function reconcileMembership( - source: GroupNodeMirror, - fireDelta: boolean, -): void { - const mirror = getGraphMirror(source.engine); - if (mirror.reconcilingMembership) return; - mirror.reconcilingMembership = true; - - try { - const groups = groupsForEngine(source); - const nextMembers = new Map< - GroupNodeMirror, - Set - >(groups.map((group) => [group, new Set()])); - const nextParents = new Map(); - - const nodes = nodesForEngine(source); - const groupNodes = [...groups].sort(stableGroupOrder); - const ordinaryNodes = nodes.filter( - (node) => !(node instanceof GroupNodeMirror), - ); - - // Resolve the group forest first. A proposed edge can point at a group that - // has already chosen another parent, so walking the partial parent map is - // enough to reject the edge that would close any cycle. - for (const node of groupNodes) { - const candidates = groups.filter( - (group) => - group !== node && - group.allowsMembership(node) && - !wouldCreateGroupCycle(node, group, nextParents), - ); - const parent = resolveParent(node, candidates, mirror.membershipResolver); - if (!parent) continue; - nextMembers.get(parent)?.add(node); - nextParents.set(node, parent); - } - - // Ordinary nodes cannot form membership cycles. They choose the innermost - // eligible group after the group hierarchy is settled. - for (const node of ordinaryNodes) { - const candidates = groups.filter((group) => - group.allowsMembership(node) - ); - const parent = resolveParent(node, candidates, mirror.membershipResolver); - if (!parent) continue; - nextMembers.get(parent)?.add(node); - nextParents.set(node, parent); - } - - const deltas = groups.map((group) => { - const previous = group.members; - const next = nextMembers.get(group) ?? new Set(); - return { - group, - next, - added: [...next].filter((node) => !previous.has(node)), - removed: [...previous].filter((node) => !next.has(node)), - }; - }); - - for (const node of nodes) { - const parent = nextParents.get(node); - if (parent) mirror.parentGroups.set(node, parent); - else mirror.parentGroups.delete(node); - } - for (const { group, next } of deltas) group.setResolvedMembers(next); - - if (fireDelta) { - for (const { group, next, added, removed } of deltas) { - if (!added.length && !removed.length) continue; - group.groupCallbacks.onMembershipChange?.({ - group, - added, - removed, - members: [...next], - }); - } - } - } finally { - mirror.reconcilingMembership = false; - } -} - /** Return the node's settled, exclusive direct parent group. */ -export function getParentGroup( - node: NodeMirror, -): GroupNodeMirror | null { - return getGraphMirror(node.engine).parentGroups.get(node) ?? null; +export function getParentGroup(node: NodeMirror): GroupNodeMirror | null { + return getGraphRegistry(node.engine).parentGroups.get(node) ?? null; } /** @@ -220,7 +135,7 @@ export function setGroupMembershipResolver( engine: Engine, resolver: GroupMembershipResolver, ): () => void { - const mirror = getGraphMirror(engine); + const mirror = getGraphRegistry(engine); mirror.membershipResolver = resolver; const refresh = () => { // Any live group re-derives the whole engine's membership forest. @@ -235,8 +150,8 @@ export function setGroupMembershipResolver( }; } -// A resizable box with settled geometric membership. Membership is exclusive: -// each node has one direct parent, while nested groups form a recursive tree. +// A box with settled geometric membership. Membership is exclusive: each node +// has one direct parent, while nested groups form a recursive tree. class GroupNodeMirror extends NodeMirror { #members: Set = new Set(); #carry: NodeMirror[] = []; @@ -245,15 +160,110 @@ class GroupNodeMirror extends NodeMirror { #groupCallbacks: GroupCallbacks; #groupConfig: GroupConfig; - constructor(engine: any, parent: BaseObject | null, config: GroupConfig = {}) { - const merged = mergeConfig( + static #reconcileMembership( + source: GroupNodeMirror, + fireDelta: boolean, + ): void { + const mirror = getGraphRegistry(source.engine); + if (mirror.reconcilingMembership) return; + mirror.reconcilingMembership = true; + + try { + const groups = groupsForEngine(source); + const nextMembers = new Map>( + groups.map((group) => [group, new Set()]), + ); + const nextParents = new Map(); + + const nodes = nodesForEngine(source); + const groupNodes = [...groups].sort(stableGroupOrder); + const ordinaryNodes = nodes.filter( + (node) => !(node instanceof GroupNodeMirror), + ); + + // Resolve the group forest first. A proposed edge can point at a group + // that has already chosen another parent, so walking the partial parent + // map is enough to reject the edge that would close any cycle. + for (const node of groupNodes) { + const candidates = groups.filter( + (group) => + group !== node && + group.allowsMembership(node) && + !wouldCreateGroupCycle(node, group, nextParents), + ); + const parent = resolveParent( + node, + candidates, + mirror.membershipResolver, + ); + if (!parent) continue; + nextMembers.get(parent)?.add(node); + nextParents.set(node, parent); + } + + // Ordinary nodes cannot form membership cycles. They choose the + // innermost eligible group after the group hierarchy is settled. + for (const node of ordinaryNodes) { + const candidates = groups.filter((group) => + group.allowsMembership(node), + ); + const parent = resolveParent( + node, + candidates, + mirror.membershipResolver, + ); + if (!parent) continue; + nextMembers.get(parent)?.add(node); + nextParents.set(node, parent); + } + + const deltas = groups.map((group) => { + const previous = group.#members; + const next = nextMembers.get(group) ?? new Set(); + return { + group, + next, + added: [...next].filter((node) => !previous.has(node)), + removed: [...previous].filter((node) => !next.has(node)), + }; + }); + + for (const node of nodes) { + const parent = nextParents.get(node); + if (parent) mirror.parentGroups.set(node, parent); + else mirror.parentGroups.delete(node); + } + for (const { group, next } of deltas) group.#members = next; + + if (fireDelta) { + for (const { group, next, added, removed } of deltas) { + if (!added.length && !removed.length) continue; + group.#groupCallbacks.onMembershipChange?.({ + group, + added, + removed, + members: [...next], + }); + } + } + } finally { + mirror.reconcilingMembership = false; + } + } + + constructor( + engine: any, + parent: BaseObject | null, + config: GroupConfig = {}, + ) { + const merged = mergeDefined( { ...DEFAULT_GROUP_CONFIG }, config, ); - super(engine, parent, { ...merged, resizable: true }); + super(engine, parent, merged); this.#groupConfig = merged; this.#groupCallbacks = merged.groupCallbacks ?? {}; - getGraphMirror(this.engine).groups.push(this); + getGraphRegistry(this.engine).groups.push(this); } get groupCallbacks(): GroupCallbacks { @@ -283,15 +293,6 @@ class GroupNodeMirror extends NodeMirror { return getParentGroup(this); } - /** @internal Used by the engine-wide exclusive-membership reconciliation. */ - setResolvedMembers(members: Set): void { - this.#members = members; - } - - writeTransformAndLines(): void { - super.writeTransformAndLines(); - } - allowsMembership(node: NodeMirror): boolean { const box = this.hitBox.getWorldBoundsSnapshot(); const nodeBounds = node.hitBox.getWorldBoundsSnapshot(); @@ -304,9 +305,7 @@ class GroupNodeMirror extends NodeMirror { // Ordinary nodes use center containment. A nested group must fit completely // so partially overlapping peers cannot become a parent/child pair. - if ( - node instanceof GroupNodeMirror ? !boundsContained : !centerContained - ) { + if (node instanceof GroupNodeMirror ? !boundsContained : !centerContained) { return false; } @@ -321,7 +320,7 @@ class GroupNodeMirror extends NodeMirror { } refreshMembership(fireDelta: boolean): void { - reconcileMembership(this, fireDelta); + GroupNodeMirror.#reconcileMembership(this, fireDelta); } setSizeState(width: number, height: number): void { @@ -329,7 +328,7 @@ class GroupNodeMirror extends NodeMirror { this.refreshMembership(true); } - beginSelectionDrag(position: eventPosition): void { + protected override beginSelectionDrag(position: eventPosition): void { super.beginSelectionDrag(position); this.#carryGroupOrigin = { x: this.worldTransform.x, @@ -346,15 +345,15 @@ class GroupNodeMirror extends NodeMirror { } } - containsSelectionDragNode(node: NodeMirror): boolean { + protected override containsSelectionDragNode(node: NodeMirror): boolean { return this.descendants.has(node); } - selectionDragNodes(): NodeMirror[] { + protected override selectionDragNodes(): NodeMirror[] { return [...new Set([this, ...this.#carry])]; } - finishSelectionDrag(): void { + protected override finishSelectionDrag(): void { const dx = this.worldTransform.x - this.#carryGroupOrigin.x; const dy = this.worldTransform.y - this.#carryGroupOrigin.y; for (const member of this.#carry) { @@ -373,7 +372,7 @@ class GroupNodeMirror extends NodeMirror { } destroy(removeElement: boolean = true): void { - const mirror = getGraphMirror(this.engine); + const mirror = getGraphRegistry(this.engine); const index = mirror.groups.indexOf(this); if (index >= 0) mirror.groups.splice(index, 1); for (const member of this.#carry) member.detachTransformFromGroup(); diff --git a/assets/snapline/core/src/index.ts b/assets/snapline/core/src/index.ts index 7c985b0..739875d 100644 --- a/assets/snapline/core/src/index.ts +++ b/assets/snapline/core/src/index.ts @@ -1,9 +1,4 @@ -export { - NodeMirror, - DEFAULT_RESIZE_CURSORS, - DEFAULT_RESIZE_HANDLE_THICKNESS, - RESIZE_HANDLES, -} from "./node"; +export { NodeMirror, ResizeRegionMirror, RESIZE_HANDLES } from "./node"; export type { NodeConfig, NodeCallbacks, @@ -14,13 +9,13 @@ export type { NodeGeometry, ResolvedNodeDragPosition, NodeResizeEvent, - NodeResizeHandleEvent, NodeSelectionEvent, NodeSelectionModeEvent, ResizeHandle, + ResolvedNodeConfig, SelectionMode, } from "./node"; -export { ConnectorMirror, resolveConnectorSourceAtPoint } from "./connector"; +export { ConnectorMirror } from "./connector"; export type { ConnectionLimit, ConnectionOrigin, @@ -47,6 +42,9 @@ export type { ConnectorSurfaceHitTestEvent, ConnectorSurfaceStrategy, DisconnectReason, + DragEndOutcome, + NewLineEvent, + NewLineResolver, ResolvedConnectorRules, SnapLineMetadata, } from "./connector"; @@ -56,7 +54,6 @@ export type { LineMirrorPhase, LineStateSnapshot, } from "./line"; -export type { GeometryWriter } from "./geometry"; export { GroupNodeMirror, getParentGroup, @@ -78,13 +75,7 @@ export type { SelectRect, SelectStartEvent, } from "./select"; -export { - getConnectors, - getGroupNodes, - getNodes, - getSelectedNodes, - query, -} from "./query"; +export { query } from "./query"; export type { GraphQuery } from "./query"; export { PlacementController } from "./placement"; export type { @@ -98,20 +89,21 @@ export type { PlacementSize, PlacementSnapshot, } from "./placement"; -export type { - NodeId, - ConnectorId, - LineId, - ReconciliationError, - GraphBatch, -} from "./graph-mirror"; -export { attachControlledGraph } from "./snapline-globals"; +export { applyLineChange, attachControlledGraph } from "./controlled-graph"; +export { getGraphRegistry } from "./internal/shared-data"; export type { CanonicalGraphSnapshot, + ConnectorId, ControlledGraphCallbacks, ControlledGraphHandle, + GeometryInvalidationObserver, + GeometryWriter, + GraphBatch, LineChangeRequest, LineEndpointUpdate, + LineId, LineRecord, + NodeId, ProposedLine, -} from "./line-reconciler"; + ReconciliationError, +} from "./types"; diff --git a/assets/snapline/core/src/graph-mirror.ts b/assets/snapline/core/src/internal/graph-registry.ts similarity index 83% rename from assets/snapline/core/src/graph-mirror.ts rename to assets/snapline/core/src/internal/graph-registry.ts index 9055558..3dc363a 100644 --- a/assets/snapline/core/src/graph-mirror.ts +++ b/assets/snapline/core/src/internal/graph-registry.ts @@ -1,53 +1,28 @@ -import type { ConnectorMirror } from "./connector"; -import type { NodeMirror } from "./node"; -import type { LineMirror } from "./line"; -import type { GroupNodeMirror, GroupMembershipResolver } from "./group"; -import type { LineChangeRequest } from "./line-reconciler"; - -/** Stable application-facing identity of a canonical node. */ -export type NodeId = string; -/** Stable application-facing identity of a canonical connector (graph-global). */ -export type ConnectorId = string; -/** Stable application-facing identity of a canonical line. */ -export type LineId = string; - -/** - * Structured, non-throwing report of a graph state the mirror cannot - * represent. Derived state: entries drop out when their cause resolves. - */ -export interface ReconciliationError { - code: - | "duplicate-id" - | "missing-node" - | "missing-connector" - | "capacity-exceeded" - | "connection-rejected" - | "identity-changed" - | "unrepresentable-line"; - lineId?: LineId; - nodeId?: NodeId; - connectorId?: ConnectorId; - message: string; - cause?: unknown; -} +import type { ConnectorMirror } from "../connector"; +import type { NodeMirror } from "../node"; +import type { LineMirror } from "../line"; +import type { GroupNodeMirror, GroupMembershipResolver } from "../group"; +import type { + ConnectorId, + GraphBatch, + LineChangeRequest, + LineId, + NodeId, + ReconciliationError, +} from "../types"; // Structural reconciler contract so the registry (and the connector emit // sites that reach it) never value-import the reconciler module — it imports // the registry accessor, not the reverse. export interface GraphReconcilerLike { /** Run one reconciliation pass against the latest canonical state. Invoked - * by the mirror's coalescing, batch-aware scheduler. */ + * by the registry's coalescing, batch-aware scheduler. */ reconcile?(): void; /** Forward a gesture's atomic proposal to the application. Present only on * the controlled bridge; its presence routes gesture drops. */ dispatchLineChangeRequest?(request: LineChangeRequest): void; } -/** A batch token from `beginBatch()`; `end()` is idempotent. */ -export interface GraphBatch { - end(): void; -} - /** Mint a domain ID for a mirror created without an application-supplied id. */ export function mintDomainId( kind: "node" | "connector" | "line", @@ -56,10 +31,10 @@ export function mintDomainId( return `${kind}-${global.createId()}`; } -// Engine-scoped registry of every live SnapLine runtime mirror — the mirror -// of the whole graph (nodes, connectors, settled lines, previews). +// Engine-scoped registry of every live SnapLine runtime mirror: nodes, +// connectors, settled lines, and previews. // -// Created lazily by `getGraphMirror(engine)` the first time any SnapLine +// Created lazily by `getGraphRegistry(engine)` the first time any SnapLine // mirror registers, so it exists exactly when SnapLine is in use — no adapter // wiring required, vanilla consumers included. Mirrors register in their // constructors and unregister in `destroy()`. @@ -68,7 +43,7 @@ export function mintDomainId( // duplicate ID is never silently allowed to steal the index entry; it stays // unindexed and is reported through `diagnostics()` until the conflict // resolves (either mirror unregisters). -export class GraphMirror { +export class GraphRegistry { readonly engine: unknown; #nodes = new Set(); #connectors = new Set(); @@ -84,15 +59,6 @@ export class GraphMirror { // reach it through this slot. reconciler: GraphReconcilerLike | null = null; - /** @internal True while a reconciler pass mutates topology on the - * canonical document's behalf — those mutations bypass the authority gate - * on imperative commands. */ - reconcilerActive = false; - - /** @internal One in-flight gesture request per engine (gestures are - * serial); cleared by the next reconciliation pass. */ - pendingGestureRequest = false; - #reconciliationQueued = false; #batchDepth = 0; #batchDirty = false; @@ -182,7 +148,13 @@ export class GraphMirror { unregisterNode(node: NodeMirror): void { this.#nodes.delete(node); - this.#unindex(this.#nodesById, node.nodeId, node, this.#nodes, (n) => n.nodeId); + this.#unindex( + this.#nodesById, + node.nodeId, + node, + this.#nodes, + (n) => n.nodeId, + ); } registerConnector(connector: ConnectorMirror): void { @@ -330,5 +302,4 @@ export class GraphMirror { } } } - } diff --git a/assets/snapline/core/src/line-reconciler.ts b/assets/snapline/core/src/internal/line-reconciler.ts similarity index 76% rename from assets/snapline/core/src/line-reconciler.ts rename to assets/snapline/core/src/internal/line-reconciler.ts index a50f0c6..932d67e 100644 --- a/assets/snapline/core/src/line-reconciler.ts +++ b/assets/snapline/core/src/internal/line-reconciler.ts @@ -1,60 +1,13 @@ -import type { LineMirror } from "./line"; +import type { LineMirror } from "../line"; import type { - ConnectorId, - GraphMirror, + CanonicalGraphSnapshot, + ControlledGraphCallbacks, + LineChangeRequest, LineId, + LineRecord, ReconciliationError, -} from "./graph-mirror"; - -/** Canonical committed relationship, owned by the application. */ -export interface LineRecord { - id: LineId; - fromConnectorId: ConnectorId; - toConnectorId: ConnectorId; - payload?: unknown; -} - -/** The application-pushed canonical document. Node and connector existence - * stays framework-mount-led; the snapshot carries the line records. */ -export interface CanonicalGraphSnapshot { - lines: readonly LineRecord[]; -} - -/** A gesture-created line proposed to the canonical owner. The `id` is - * minted by SnapLine; adopting it settles the staged mirror in place. */ -export interface ProposedLine { - id: LineId; - fromConnectorId: ConnectorId; - toConnectorId: ConnectorId; - payload?: unknown; -} - -export interface LineEndpointUpdate { - id: LineId; - toConnectorId: ConnectorId; -} - -/** One atomic proposal for the application to change canonical records. */ -export interface LineChangeRequest { - intent: "connect" | "disconnect" | "replace" | "reconnect"; - add: readonly ProposedLine[]; - remove: readonly LineId[]; - update: readonly LineEndpointUpdate[]; - 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; -} +} from "../types"; +import type { GraphRegistry } from "./graph-registry"; // Converges the engine's line mirrors onto the cached canonical snapshot. // Read-only with respect to canonical state: reconciliation never emits a @@ -63,13 +16,13 @@ export interface ControlledGraphHandle { // endpoints not mounted; silent) or errored (rules violation; structured // diagnostic), and is retried when relevant state changes. export class LineReconciler { - #mirror: GraphMirror; + #mirror: GraphRegistry; #callbacks: ControlledGraphCallbacks; #snapshot: CanonicalGraphSnapshot = { lines: [] }; #reconciling = false; #disposed = false; - constructor(mirror: GraphMirror, callbacks: ControlledGraphCallbacks) { + constructor(mirror: GraphRegistry, callbacks: ControlledGraphCallbacks) { this.#mirror = mirror; this.#callbacks = callbacks; } @@ -86,9 +39,19 @@ export class LineReconciler { if (this.#mirror.reconciler === this) this.#mirror.reconciler = null; } - /** Forward a gesture's atomic proposal to the application. */ + /** + * Forward a gesture's atomic proposal to the application and adopt whatever + * it returns as the new canonical state. + * + * Synchronous and unconditional, which is what makes the protocol's + * invariants structural rather than timed: exactly one decisive pass per + * request (including a rejected one, since the end-of-pass sweep is + * snapshot-driven, not flag-driven), and no dependence on when a framework + * happens to flush its state. + */ dispatchLineChangeRequest(request: LineChangeRequest): void { - this.#callbacks.onLineChangeRequest(request); + const lines = this.#callbacks.onLineChangeRequest(request); + this.setCanonicalGraph({ lines }); } /** One pass: prune, preserve/retarget by stable id, create, report. */ @@ -96,7 +59,6 @@ export class LineReconciler { if (this.#reconciling || this.#disposed) return; this.#reconciling = true; const mirror = this.#mirror; - mirror.reconcilerActive = true; const errors: ReconciliationError[] = []; try { // Canonical records by id — duplicates never silently collapse. @@ -204,8 +166,6 @@ export class LineReconciler { } } finally { this.#reconciling = false; - mirror.reconcilerActive = false; - mirror.pendingGestureRequest = false; } if (mirror.setReconciliationErrors(errors)) { diff --git a/assets/snapline/core/src/internal/shared-data.ts b/assets/snapline/core/src/internal/shared-data.ts new file mode 100644 index 0000000..ee097e6 --- /dev/null +++ b/assets/snapline/core/src/internal/shared-data.ts @@ -0,0 +1,51 @@ +import { GraphRegistry } from "./graph-registry"; + +/** + * The shape of everything SnapLine stores on the engine's shared `global.data` + * bag. This is the single declaration site for these cross-module contracts — + * every reader/writer goes through the typed accessors below instead of + * re-deriving the shape inline. + * + */ +export interface SnapLineSharedData { + /** + * @deprecated Legacy camera-control boolean (last-writer-wins), read by the + * camera for third-party writers only. In-repo gesture owners block the + * camera at the input-dispatch layer instead: `engine.input.claimPointer()` + * (claims auto-release when the gesture ends). + */ + allowCameraControl?: boolean; + /** + * Per-engine SnapLine registries. GlobalManager is application-wide, so the + * map is keyed by engine; `getGraphRegistry` lazy-creates entries the first + * time a SnapLine mirror registers on that engine. WeakMap so a destroyed + * engine releases its registry (and every mirror it indexes) — nothing ever + * enumerates this map. + */ + graphRegistries?: WeakMap; +} + +/** Typed view over the untyped global data bag (cast at the boundary). */ +export function snapData(global: { data: any }): SnapLineSharedData { + return global.data as SnapLineSharedData; +} + +/** The per-engine registry, lazy-created on first access. */ +export function getGraphRegistry(engine: { + global: { data: any } | null; +}): GraphRegistry { + if (!engine.global) { + throw new Error( + "SnapLine: getGraphRegistry requires an initialized engine.", + ); + } + const data = snapData(engine.global); + if (!data.graphRegistries) data.graphRegistries = new WeakMap(); + const key = engine as unknown as object; + let registry = data.graphRegistries.get(key); + if (!registry) { + registry = new GraphRegistry(engine); + data.graphRegistries.set(key, registry); + } + return registry; +} diff --git a/assets/snapline/core/src/line.ts b/assets/snapline/core/src/line.ts index 5e409b4..6f9bbd4 100644 --- a/assets/snapline/core/src/line.ts +++ b/assets/snapline/core/src/line.ts @@ -7,9 +7,9 @@ import type { ConnectorPoint, ConnectorSurfaceStrategy, } from "./connector"; -import type { GeometryWriter } from "./geometry"; -import { getGraphMirror } from "./snapline-globals"; -import { mintDomainId } from "./graph-mirror"; +import type { GeometryInvalidationObserver, GeometryWriter } from "./types"; +import { getGraphRegistry } from "./internal/shared-data"; +import { mintDomainId } from "./internal/graph-registry"; /** * Explicit line lifetime. "staged" is a gesture that completed locally and @@ -51,6 +51,7 @@ class LineMirror extends ElementObject { #phase: LineMirrorPhase = "source-start"; #candidate: ConnectorCandidate | null = null; #geometryWriter: GeometryWriter | null = null; + #geometryObservers = new Set>(); #stateCallbacks = new Set<(state: LineStateSnapshot) => void>(); #sourceStrategy: ConnectorSurfaceStrategy | null = null; #sourceHit: ConnectorHit | null = null; @@ -63,7 +64,7 @@ class LineMirror extends ElementObject { this.#start = parent as unknown as ConnectorMirror; this.transformMode = "direct"; this.lineId = config.id ?? mintDomainId("line", this.global); - getGraphMirror(this.engine).registerLine(this); + getGraphRegistry(this.engine).registerLine(this); } // Read-only outside the mirror's own lifecycle operations. @@ -87,14 +88,6 @@ class LineMirror extends ElementObject { return this.#endAnchor; } - get endWorldX(): number { - return this.#endAnchor.x; - } - - get endWorldY(): number { - return this.#endAnchor.y; - } - get phase(): LineMirrorPhase { return this.#phase; } @@ -135,13 +128,11 @@ class LineMirror extends ElementObject { } override destroy(removeElement: boolean = true): void { - getGraphMirror(this.engine).unregisterLine(this); + getGraphRegistry(this.engine).unregisterLine(this); super.destroy(removeElement); } - bindGeometryWriter( - writer: GeometryWriter, - ): () => void { + bindGeometryWriter(writer: GeometryWriter): () => void { this.#geometryWriter = writer; writer(this.geometrySnapshot()); return () => { @@ -149,6 +140,39 @@ class LineMirror extends ElementObject { }; } + /** + * Subscribe to "this line's geometry is about to change". + * + * Fires **synchronously, during input dispatch, before any frame task is + * queued** — not at paint time. It deliberately hands over no geometry: + * schedule your own task at whatever stage suits you and read + * `geometrySnapshot()` there. + * + * ```ts + * const stop = line.onGeometryInvalidated(() => + * line.schedule(place, { stage: "WRITE_3", queueId: "label" }), + * ); + * ``` + * + * The line itself paints at `WRITE_2` (and resolves its anchors inside that + * same task), so `WRITE_3` sees this frame's position while `WRITE_1` and + * `READ_2` still see the previous frame's. Choosing that is the caller's + * job, which is why no stage is implied here. + * + * Unlike {@link bindGeometryWriter} — the single owner that paints the line — + * any number of observers may subscribe. There is no priming call: nothing + * has been invalidated at subscribe time, so read `geometrySnapshot()` + * directly for the initial position. + * + * @returns an unsubscribe function. + */ + onGeometryInvalidated( + observer: GeometryInvalidationObserver, + ): () => void { + this.#geometryObservers.add(observer); + return () => this.#geometryObservers.delete(observer); + } + onStateChange(callback: (state: LineStateSnapshot) => void): () => void { this.#stateCallbacks.add(callback); callback(this.stateSnapshot()); @@ -227,7 +251,7 @@ class LineMirror extends ElementObject { this.#targetHit = candidate?.hit ?? this.#targetHit; this.#candidate = null; this.#phase = "connected"; - getGraphMirror(this.engine).settleLine(this); + getGraphRegistry(this.engine).settleLine(this); this.updateAnchors(); this.#emitStateChange(); } @@ -242,50 +266,10 @@ class LineMirror extends ElementObject { this.#targetStrategy = null; this.#targetHit = null; this.#phase = "preview-free"; - getGraphMirror(this.engine).unsettleLine(this); + getGraphRegistry(this.engine).unsettleLine(this); if (changed) this.#emitStateChange(); } - setLineStartAtConnector(): void { - const peer = this.target ?? this.candidate?.connector ?? null; - const peerGeometry = peer?.geometry ?? null; - const position = - peerGeometry?.center ?? this.#previewPosition ?? this.endAnchor; - const anchor = this.start.resolveAnchor({ - line: this, - role: "source", - phase: this.phase, - peer, - position, - hit: this.#sourceHit, - strategy: this.#sourceStrategy, - }); - this.setLineStartAnchor(anchor); - } - - setLineEndAtConnector(): void { - const target = this.target ?? this.candidate?.connector ?? null; - if (!target) return; - const anchor = target.resolveAnchor({ - line: this, - role: "target", - phase: this.phase, - peer: this.start, - position: this.start.geometry.center, - hit: this.#targetHit, - strategy: this.#targetStrategy, - }); - this.setLineEndAnchor(anchor); - } - - setLineStart(startPositionX: number, startPositionY: number): void { - this.setLineStartAnchor({ x: startPositionX, y: startPositionY }); - } - - setLineEnd(endWorldX: number, endWorldY: number): void { - this.setLineEndAnchor({ x: endWorldX, y: endWorldY }); - } - setLineStartAnchor(anchor: ConnectorAnchor): void { this.#startAnchor = cloneAnchor(anchor); this.worldTransform = { x: anchor.x, y: anchor.y }; @@ -295,14 +279,44 @@ class LineMirror extends ElementObject { this.#endAnchor = cloneAnchor(anchor); } - setLinePosition( - startWorldX: number, - startWorldY: number, - endWorldX: number, - endWorldY: number, - ): void { - this.setLineStart(startWorldX, startWorldY); - this.setLineEnd(endWorldX, endWorldY); + /** + * The deferred re-glue: notify observers now, paint next WRITE_2. + * + * Coalesces on `(objectId, queueId)`, so many invalidations in one frame + * collapse to a single write task. + */ + invalidateGeometry(): void { + this.#notifyGeometryInvalidated(); + this.schedule( + () => { + this.updateAnchors(); + this.writeTransform(); + }, + { stage: "WRITE_2", queueId: `${this.id}-transform` }, + ); + } + + /** + * The synchronous re-glue, for callers already inside a WRITE stage + * (settle, prop-driven node moves). Observers still fire first, so a + * subscriber's own scheduled task is queued before the paint happens. + */ + invalidateGeometryNow(): void { + this.#notifyGeometryInvalidated(); + this.updateAnchors(); + this.writeTransform(); + } + + #notifyGeometryInvalidated(): void { + for (const observer of this.#geometryObservers) { + // A third-party observer must never be able to stop the line painting + // or starve its peers. + try { + observer(this); + } catch (error) { + console.error("SnapLine: a line geometry observer threw.", error); + } + } } updateAnchors(): void { @@ -347,16 +361,12 @@ class LineMirror extends ElementObject { this.setLineEndAnchor(targetAnchor); } - moveLineToConnectorTransform(): void { - this.updateAnchors(); - } - writeTransform(): void { this.#geometryWriter?.(this.geometrySnapshot()); } } -function cloneAnchor(anchor: ConnectorAnchor): ConnectorAnchor { +export function cloneAnchor(anchor: ConnectorAnchor): ConnectorAnchor { return { x: anchor.x, y: anchor.y, diff --git a/assets/snapline/core/src/node.ts b/assets/snapline/core/src/node.ts index 7bd7a70..da21792 100644 --- a/assets/snapline/core/src/node.ts +++ b/assets/snapline/core/src/node.ts @@ -1,5 +1,5 @@ -import { BaseObject, ElementObject } from "@snap-engine/core"; -import { ConnectorMirror, resolveConnectorSourceAtPoint } from "./connector"; +import { BaseObject, ElementObject, mergeDefined } from "@snap-engine/core"; +import { ConnectorMirror } from "./connector"; import { LineMirror } from "./line"; import type { pointerUpProp, @@ -8,12 +8,12 @@ import type { dragProp, dragEndProp, eventPosition, - pointerMoveProp, } from "@snap-engine/core"; import { RectCollider } from "@snap-engine/core/collision"; -import { getGraphMirror, getResizeHandles, snapData } from "./snapline-globals"; -import { mintDomainId } from "./graph-mirror"; -import type { SnapLineMetadata } from "./connector"; +import { getGraphRegistry } from "./internal/shared-data"; +import { mintDomainId } from "./internal/graph-registry"; +import type { NewLineResolver, SnapLineMetadata } from "./connector"; +import type { GeometryInvalidationObserver } from "./types"; export type ResizeHandle = "n" | "ne" | "e" | "se" | "s" | "sw" | "w" | "nw"; @@ -28,79 +28,32 @@ export const RESIZE_HANDLES: readonly ResizeHandle[] = [ "nw", ]; -export const DEFAULT_RESIZE_CURSORS: Readonly> = { - n: "ns-resize", - ne: "nesw-resize", - e: "ew-resize", - se: "nwse-resize", - s: "ns-resize", - sw: "nesw-resize", - w: "ew-resize", - nw: "nwse-resize", -}; - 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; minWidth?: number; minHeight?: number; - /** - * Total thickness of each virtual edge/corner resize hitbox. Half of the - * hitbox sits inside the node boundary and half outside. - */ - resizeHandleThickness?: number; - /** Enabled handles. `true` means all eight; an array enables only those handles. */ - resizeHandles?: true | readonly ResizeHandle[]; - /** Per-handle CSS cursor overrides. */ - resizeCursors?: Partial>; metadata?: SnapLineMetadata; callbacks?: NodeCallbacks; - /** Allows this node gesture to use the engine's configured edge pan. */ edgePan?: boolean; } -/** Shared by core hitboxes and adapter resize-handle visuals. */ -export const DEFAULT_RESIZE_HANDLE_THICKNESS = 14; +/** Config with every defaultable field resolved. */ +export type ResolvedNodeConfig = Required>; -// `id` is identity, not configuration — read once in the constructor, never -// defaulted or merged. -const DEFAULT_NODE_CONFIG: Required> = { +const DEFAULT_NODE_CONFIG: ResolvedNodeConfig = { lockPosition: false, - resizable: false, minWidth: 0, minHeight: 0, - resizeHandleThickness: DEFAULT_RESIZE_HANDLE_THICKNESS, - resizeHandles: true, - resizeCursors: {}, metadata: {}, callbacks: {}, edgePan: true, }; -/** Object-spread merge that ignores undefined values (adapters forward - * possibly-undefined props, which must not shadow the defaults). */ -export function mergeConfig( - defaults: T, - config: Partial, -): T { - const merged = { ...defaults }; - for (const key of Object.keys(config) as (keyof T)[]) { - const value = config[key]; - if (value !== undefined) merged[key] = value as T[keyof T]; - } - return merged; -} - -/** Consumer policy and lifecycle surfaces. Callbacks receive event objects so - * new context can be added without growing positional signatures. */ -/** One node's settled geometry — the app persists what it wants. */ +/** + * Node geometry maintained by SnapLine during drag or resize. + * After that, this is committed to the frontend framework. + */ export interface NodeGeometry { node: NodeMirror; x: number; @@ -109,8 +62,7 @@ export interface NodeGeometry { height: number; } -/** Batched geometry observation: a group/multi-select drag stays one event - * (every moved node in `nodes`); a resize reports a single entry. */ +/** Batched geometry observation for group/multi-select drag. */ export interface GeometryChangeEvent { nodes: readonly NodeGeometry[]; } @@ -145,12 +97,6 @@ export interface NodeResizeEvent { height: number; } -export interface NodeResizeHandleEvent { - node: NodeMirror; - handle: ResizeHandle | null; - cursor: string | null; -} - export interface NodeSelectionEvent { node: NodeMirror; selected: boolean; @@ -172,217 +118,60 @@ export interface NodeLinesEvent { } export interface NodeCallbacks { + /** + * Seeds application data onto a line a drag from any of this node's + * connectors creates. A connector-level resolver may override it. + */ + resolveNewLine?: NewLineResolver; + /** Determines if a drag gesture should start. */ canStartDrag?: (event: NodePointerEvent) => boolean; - /** Resolves a proposed live node position before its world transform changes. */ + /** Allow node position to be overridden during drag, e.g. to implement snap-to-grid. */ resolveDragPosition?: ( event: NodeDragPositionEvent, ) => ResolvedNodeDragPosition; - /** Consumer-defined pointer selection policy; SnapLine owns no modifier keys. */ + /** Override if and how nodes get selected or deselected. */ resolveSelectionMode?: (event: NodeSelectionModeEvent) => SelectionMode; + /** Called when selected status of a node changes. */ + onSelectionChange?: (event: NodeSelectionEvent) => void; onDragStart?: (event: NodePointerEvent) => void; onDrag?: (event: NodePointerEvent) => 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; /** Observes live size updates; core writes the retained element geometry. */ onSizeChange?: (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. */ + /** + * Called when drag or resize finishes. + * Frontend frameworks should read the committed geometry from the event + * and update its state accordingly. + */ + onGeometryCommit?: (event: GeometryChangeEvent) => void; + /** + * Called when a line was added, removed, or changed. + * This differs from handleLineChangeRequest in that it can also be called + * for temporary lines that are not committed to application state. + */ onLinesChanged?: (event: NodeLinesEvent) => void; } -const CORNER_HANDLES = new Set(["ne", "se", "sw", "nw"]); -class ResizeHandleCollider extends RectCollider { - readonly handle: ResizeHandle; - readonly cursor: string; - - constructor( - engine: any, - parent: NodeMirror, - handle: ResizeHandle, - cursor: string, - ) { - super(engine, parent, 0, 0, 0, 0); - this.handle = handle; - this.cursor = cursor; - } -} - -class ResizeHoverController extends BaseObject { - #count = 0; - #node: NodeMirror | null = null; - #handle: ResizeHandleCollider | null = null; - #target: HTMLElement | null = null; - #previousNodeCursor = ""; - #previousContainerCursor = ""; - #previousTargetCursor = ""; - // Whether this controller currently owns the cursor it wrote. Restoration is - // gated on it so clear() is idempotent and never clobbers a cursor the - // application set on the container itself. - #applied = false; - - constructor(engine: any) { - super(engine, null); - this.event.global.pointerMove = this.#onPointerMove; - } - - retain(): void { - this.#count++; - } - - release(node: NodeMirror): void { - this.#count--; - if (this.#node === node) this.clear(); - if (this.#count <= 0) { - resizeHoverControllers.delete(this.engine); - // destroy() clears, so a cursor written for some other node cannot be - // stranded on the container when the last node releases. - this.destroy(); - } - } - - activate(handle: ResizeHandleCollider, target?: EventTarget | null): void { - const node = handle.parent as NodeMirror; - const element = target instanceof HTMLElement ? target : null; - if (this.#handle === handle && this.#target === element) return; - this.#restoreCss(); - const nodeElement = node.element; - const container = this.engine.containerElement as HTMLElement | null; - this.#node = node; - this.#handle = handle; - this.#target = element; - this.#previousNodeCursor = nodeElement?.style.cursor ?? ""; - this.#previousContainerCursor = container?.style.cursor ?? ""; - this.#previousTargetCursor = element?.style.cursor ?? ""; - nodeElement?.setAttribute("data-snapline-resize-handle", handle.handle); - if (nodeElement) nodeElement.style.cursor = handle.cursor; - if (container) container.style.cursor = handle.cursor; - if (element) element.style.cursor = handle.cursor; - this.#applied = true; - node.callbacks.onResizeHandleChange?.({ - node, - handle: handle.handle, - cursor: handle.cursor, - }); - } - - clear(): void { - const node = this.#node; - // Not gated on `node`: the container cursor outlives any single node, so - // restoring it must not depend on one still being tracked. - this.#restoreCss(); - this.#node = null; - this.#handle = null; - this.#target = null; - node?.callbacks.onResizeHandleChange?.({ - node, - handle: null, - cursor: null, - }); - } - - #restoreCss(): void { - if (!this.#applied) return; - this.#applied = false; - const nodeElement = this.#node?.element; - const container = this.engine.containerElement as HTMLElement | null; - nodeElement?.removeAttribute("data-snapline-resize-handle"); - if (nodeElement) nodeElement.style.cursor = this.#previousNodeCursor; - if (container) container.style.cursor = this.#previousContainerCursor; - if (this.#target) this.#target.style.cursor = this.#previousTargetCursor; - } - - #onPointerMove(prop: pointerMoveProp): void { - const handle = findResizeHandle(this.engine, prop.position); - if (!handle) { - this.clear(); - return; - } - this.activate(handle, prop.event?.target); - } - - destroy(): void { - // BaseObject.destroy does not unsubscribe global callbacks. Left attached, - // a destroyed controller keeps handling pointerMove, so a remount (HMR, - // React StrictMode) leaves two controllers writing the cursor — and the - // second captures the first's write as its "previous" value, permanently - // poisoning restoration. - this.event.global.pointerMove = null; - this.clear(); - super.destroy(); - } -} - -const resizeHoverControllers = new WeakMap(); - -function hoverController(engine: any): ResizeHoverController { - let controller = resizeHoverControllers.get(engine); - if (!controller) { - controller = new ResizeHoverController(engine); - resizeHoverControllers.set(engine, controller); - } - return controller; -} - -function findResizeHandle( - engine: any, - position: eventPosition, - node?: NodeMirror, -): ResizeHandleCollider | null { - let winner: ResizeHandleCollider | null = null; - for (const collider of getResizeHandles(engine.global)) { - if ( - !(collider instanceof ResizeHandleCollider) || - collider.engine !== engine - ) - continue; - if (node && collider.parent !== node) continue; - if (!collider.containsWorldPoint(position.x, position.y)) continue; - if ( - !winner || - CORNER_HANDLES.has(collider.handle) || - !CORNER_HANDLES.has(winner.handle) - ) { - winner = collider; - } - } - return winner; -} - 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 }; + #config: ResolvedNodeConfig; + #connectors: { [key: string]: ConnectorMirror }; #dragStartX = 0; #dragStartY = 0; - _nodeStyle: any; #hitBox: RectCollider; - #mouseDownX: number; - #mouseDownY: number; - _hasMoved: boolean; - #resizeHitBoxes = new Map(); - #resizeHandles: readonly ResizeHandle[]; - #resizeHandleThickness: number; - #resizeHoverController: ResizeHoverController | null = null; + #pointerReferenceX: number; + #pointerReferenceY: number; + #hasMoved: boolean; + #resizeRegions = new Set(); #activeResizeHandle: ResizeHandle | null = null; - /** Read by GroupNodeMirror to distinguish a resize from a move drag. */ - #resizing = false; - // The size last authored through setSizeState, kept apart from #hitBox (which - // tracks what the browser actually rendered) so a write always paints the - // value its own tick authored. + #isResizing = false; + #geometryObservers = new Set>(); #authoredWidth = 0; #authoredHeight = 0; #hasAuthoredSize = false; - #resizeArmed = false; - #resizeStartW = 0; - #resizeStartH = 0; + #isResizeArmed = false; + #resizeStartWidth = 0; + #resizeStartHeight = 0; #resizeStartX = 0; #resizeStartY = 0; #callbacks: NodeCallbacks; @@ -393,31 +182,20 @@ class NodeMirror extends ElementObject { #dragCommitNodes: NodeMirror[] = []; #lastDragPosition: eventPosition | null = null; #pointerSelectionMode: SelectionMode = "replace"; - #selectedAtPointerDown = false; + #wasSelectedAtPointerDown = false; constructor(engine: any, parent: BaseObject | null, config: NodeConfig = {}) { super(engine, parent); - this.#config = mergeConfig(DEFAULT_NODE_CONFIG, config); + this.#config = mergeDefined(DEFAULT_NODE_CONFIG, config); this.#callbacks = this.#config.callbacks; this.nodeId = config.id ?? mintDomainId("node", this.global); - getGraphMirror(this.engine).registerNode(this); - const resizeEnabled = - config.resizable === true || config.resizeHandles !== undefined; - this.#resizeHandles = !resizeEnabled - ? [] - : config.resizeHandles !== undefined - ? config.resizeHandles === true - ? RESIZE_HANDLES - : [...new Set(config.resizeHandles)] - : RESIZE_HANDLES; - this.#resizeHandleThickness = - config.resizeHandleThickness ?? DEFAULT_RESIZE_HANDLE_THICKNESS; - - this._connectors = {}; + getGraphRegistry(this.engine).registerNode(this); + + this.#connectors = {}; this.#dragStartX = this.worldTransform.x; this.#dragStartY = this.worldTransform.y; - this.#mouseDownX = 0; - this.#mouseDownY = 0; + this.#pointerReferenceX = 0; + this.#pointerReferenceY = 0; this.transformMode = "direct"; this.event.input.pointerDown = this.onCursorDown; @@ -428,37 +206,12 @@ class NodeMirror extends ElementObject { this.#hitBox = new RectCollider(this.engine, this, 0, 0, 0, 0); this.addCollider(this.#hitBox); - // Resize surfaces are virtual colliders. Engine input resolves them before - // DOM ownership, so handles can straddle the element and work for groups - // whose body intentionally has pointer-events:none. - if (this.#resizeHandles.length > 0) { - this.#resizeHoverController = hoverController(this.engine); - this.#resizeHoverController.retain(); - for (const handle of this.#resizeHandles) { - const collider = new ResizeHandleCollider( - this.engine, - this, - handle, - this.#config.resizeCursors[handle] ?? DEFAULT_RESIZE_CURSORS[handle], - ); - this.#resizeHitBoxes.set(handle, collider); - this.addCollider(collider); - getResizeHandles(this.global).push(collider); - } - this.#positionResizeHitBoxes(0, 0); - } - - this._hasMoved = false; + this.#hasMoved = false; - // Whenever the DOM box changes size (ResizeObserver) re-measure + re-glue. this.event.dom.onResize = () => this.remeasureDomGeometry(); - - // Base positioning styles are framework-owned: adapters must render the - // element with `position: absolute; transform-origin: top left` (see the - // ownership note in assets/snapline/AGENTS.md). } - get config(): Required> { + get config(): ResolvedNodeConfig { return this.#config; } @@ -466,20 +219,13 @@ class NodeMirror extends ElementObject { return this.#callbacks; } - set callbacks(callbacks: NodeCallbacks) { - this.#callbacks = callbacks; - } - get metadata(): SnapLineMetadata { return this.#config.metadata; } - get resizeHandles(): readonly ResizeHandle[] { - return this.#resizeHandles; - } - - get resizeHandleThickness(): number { - return this.#resizeHandleThickness; + /** The node's collision footprint (world = worldTransform + width/height). */ + get hitBox(): RectCollider { + return this.#hitBox; } registerDragHandle(element: HTMLElement): () => void { @@ -487,10 +233,14 @@ class NodeMirror extends ElementObject { return () => this.#dragHandles.delete(element); } - /** The node's collision footprint (world = worldTransform + width/height). - * Read by groups for geometric membership. */ - get hitBox(): RectCollider { - return this.#hitBox; + /** @internal Tracks resize-region children for deterministic node teardown. */ + attachResizeRegion(region: ResizeRegionMirror): void { + this.#resizeRegions.add(region); + } + + /** @internal Removes a resize-region child that is being destroyed. */ + detachResizeRegion(region: ResizeRegionMirror): void { + this.#resizeRegions.delete(region); } setStartPositions() { @@ -501,9 +251,9 @@ class NodeMirror extends ElementObject { setSelected(selected: boolean) { this.dataAttribute = { selected: String(selected), - "snapline-state": selected ? "focus" : "idle", + "snapline-state": selected ? "focus" : "idle", // TODO: Not used? }; - const selectList = getGraphMirror(this.engine).selection; + const selectList = getGraphRegistry(this.engine).selection; if (selected) { if (!selectList.includes(this)) { selectList.push(this); @@ -519,26 +269,27 @@ class NodeMirror extends ElementObject { this.#callbacks.onSelectionChange?.({ node: this, selected, - selection: [...getGraphMirror(this.engine).selection], + selection: [...getGraphRegistry(this.engine).selection], }); } - /** Schedules a WRITE_2 write for every line on every connector of this node. */ + /** Schedules a WRITE_2 for every line on every connector of this node. */ scheduleLineWrites(): void { - for (const connector of Object.values(this._connectors)) { + for (const connector of Object.values(this.#connectors)) { connector.scheduleAllLineWrites(); } } /** Synchronously writes every line on every connector (call inside a WRITE stage). */ + // TODO: We should have some kind of guard for these typed of functions that + // need to be called in specific stages. writeLinesNow(): void { const lines = new Set([ ...this.getAllOutgoingLines(), ...this.getAllIncomingLines(), ]); for (const line of lines) { - line.moveLineToConnectorTransform(); - line.writeTransform(); + line.invalidateGeometryNow(); } } @@ -559,15 +310,16 @@ class NodeMirror extends ElementObject { stage: "WRITE_2", queueId: `${this.id}-transform`, }); - for (const node of this.#transformNodeTree()) node.scheduleLineWrites(); + for (const node of this.#transformNodeTree()) { + node.#notifyGeometryInvalidated(); + node.scheduleLineWrites(); + } } - // Re-measure the node box + each connector's local center (READ_1) and re-glue - // every incoming/outgoing line (WRITE_2). This is the "same handling as a move - // plus a size re-measure": moving a node keeps connector local centers valid, - // but resizing invalidates them, so they must be re-read. Shared by the - // ResizeObserver and the JS-driven setSize; stable queueIds collapse a - // same-frame double-fire (idempotent when it runs twice across frames). + /** + * Re-measure the node box + each connector's local center (READ_1) and re-glue + * every incoming/outgoing line (WRITE_2). + */ remeasureDomGeometry(): void { if (!this.element) { throw new Error( @@ -581,56 +333,74 @@ class NodeMirror extends ElementObject { this.scheduleLineWrites(); } - // Reconciles state with what the browser actually rendered: the node box, the - // resize hitboxes derived from it, and each connector's local center. Shared - // by the ResizeObserver (READ_1) and the post-write re-measure (READ_2) so the - // two cannot drift apart. + /* Reconciles state with what the browser actually rendered */ #syncMeasuredGeometry(stage: "READ_1" | "READ_2"): void { if (!this.element) return; const property = this.readDom({ unapplyTransform: false }, stage); - // While a gesture is authoring the size, the authored box is the truth and - // the rendered box is one frame behind: the ResizeObserver fires for frame - // N's paint, so this read lands in frame N+1 AFTER that frame's pointermove - // already advanced both the size and worldTransform. Adopting it here would - // make the WRITE_1 paint a stale height beside a fresh transform — a - // one-frame jump of the anchored edge on every north/west drag. - if (!this.#resizing) { + // During resize, setSizeState sets the correct dimensions. + if (!this.#isResizing) { this.#hitBox.width = property.width; this.#hitBox.height = property.height; - this.#positionResizeHitBoxes(property.width, property.height); } - for (const connector of Object.values(this._connectors)) { + for (const connector of Object.values(this.#connectors)) { connector.measureLocalCenter(stage); } } - // State-only half of a size change: clamps to min and synchronously updates - // the collision footprint + resize hitbox so hit testing and group - // containment stay correct mid-drag. `setSize` adds the scheduled DOM write; - // adapters can use this method alone when seeding external dimensions. + /** + * Subscribe to "this node's geometry is about to change". + * @returns an unsubscribe function. + */ + // TODO: Unify semantics of subscribe/unsubscribe functions + // (e.g. with global input callback) + onGeometryInvalidated( + observer: GeometryInvalidationObserver, + ): () => void { + this.#geometryObservers.add(observer); + return () => this.#geometryObservers.delete(observer); + } + + /** Fired by the scheduling entry points, before they queue. */ + #notifyGeometryInvalidated(): void { + for (const observer of this.#geometryObservers) { + try { + observer(this); + } catch (error) { + console.error("SnapLine: a node geometry observer threw.", error); + } + } + } + + geometrySnapshot(): { x: number; y: number; width: number; height: number } { + return { + x: this.worldTransform.x, + y: this.worldTransform.y, + width: this.#authoredWidth, + height: this.#authoredHeight, + }; + } + setSizeState(width: number, height: number): void { const w = Math.max(this.#config.minWidth, width); const h = Math.max(this.#config.minHeight, height); - // The authored size is captured here and painted verbatim, so whatever - // reconciles #hitBox with the DOM in between cannot desynchronize the size - // from the worldTransform authored in the same tick. this.#authoredWidth = w; this.#authoredHeight = h; this.#hasAuthoredSize = true; this.#hitBox.width = w; this.#hitBox.height = h; - this.#positionResizeHitBoxes(w, h); } - // Drives live resize geometry directly. The framework observes and persists - // the result, but it is not part of the pointer-move paint path. + /** + * Set the size of a node, and request DOM update to + * render the new size. + */ setSize( width: number, height: number, handle: ResizeHandle | null = null, ): void { this.setSizeState(width, height); - this.#scheduleSizeGeometryWrite(); + this.scheduleGeometryWrite(); this.#callbacks.onSizeChange?.({ node: this, handle, @@ -642,18 +412,11 @@ class NodeMirror extends ElementObject { } /** - * Schedules the one task that paints size and transform together, plus the - * re-measure and line re-glue. Adapters mutate `worldTransform` and - * `setSizeState(...)` and then call this, so a prop-driven position+size - * change lands in one frame, in one stage-legal task. Unlike `setSize` it - * emits no `onSizeChange`, which would echo a controlled app's own value - * back at it. + * Schedules the task that paints size and transform together, + * plus the re-measure and line re-glue. */ scheduleGeometryWrite(): void { - this.#scheduleSizeGeometryWrite(); - } - - #scheduleSizeGeometryWrite(): void { + this.#notifyGeometryInvalidated(); this.schedule(() => this.#writeSizeGeometry(), { stage: "WRITE_1", queueId: `${this.id}-size`, @@ -665,10 +428,6 @@ class NodeMirror extends ElementObject { this.scheduleLineWrites(); } - // The single atomic geometry commit: size and transform are painted in one - // synchronous block, in one task, in one stage — and from values authored in - // the same tick, never re-read from state that a later measurement may have - // moved underneath them. #writeSizeGeometry(): void { if (this.element && this.#hasAuthoredSize) { this.element.style.width = `${this.#authoredWidth}px`; @@ -677,30 +436,6 @@ class NodeMirror extends ElementObject { this.writeTransformRecursive(); } - #positionResizeHitBoxes(width: number, height: number): void { - const t = Math.max(0, this.#resizeHandleThickness); - const half = t / 2; - const horizontalLength = Math.max(0, width - t); - const verticalLength = Math.max(0, height - t); - const geometry: Record = { - n: [half, -half, horizontalLength, t], - ne: [width - half, -half, t, t], - e: [width - half, half, t, verticalLength], - se: [width - half, height - half, t, t], - s: [half, height - half, horizontalLength, t], - sw: [-half, height - half, t, t], - w: [-half, half, t, verticalLength], - nw: [-half, -half, t, t], - }; - for (const [handle, collider] of this.#resizeHitBoxes) { - const [x, y, colliderWidth, colliderHeight] = geometry[handle]; - collider.localTransform = { x, y }; - collider.width = colliderWidth; - collider.height = colliderHeight; - } - } - - // Applies one side/corner resize while keeping the opposite edges fixed. #applyResizeDrag(dx: number, dy: number): void { const handle = this.#activeResizeHandle; if (!handle) return; @@ -709,22 +444,22 @@ class NodeMirror extends ElementObject { const north = handle === "n" || handle === "ne" || handle === "nw"; const south = handle === "s" || handle === "se" || handle === "sw"; const proposedWidth = west - ? this.#resizeStartW - dx + ? this.#resizeStartWidth - dx : east - ? this.#resizeStartW + dx - : this.#resizeStartW; + ? this.#resizeStartWidth + dx + : this.#resizeStartWidth; const proposedHeight = north - ? this.#resizeStartH - dy + ? this.#resizeStartHeight - dy : south - ? this.#resizeStartH + dy - : this.#resizeStartH; + ? this.#resizeStartHeight + dy + : this.#resizeStartHeight; const width = Math.max(this.#config.minWidth, proposedWidth); const height = Math.max(this.#config.minHeight, proposedHeight); const x = west - ? this.#resizeStartX + (this.#resizeStartW - width) + ? this.#resizeStartX + (this.#resizeStartWidth - width) : this.#resizeStartX; const y = north - ? this.#resizeStartY + (this.#resizeStartH - height) + ? this.#resizeStartY + (this.#resizeStartHeight - height) : this.#resizeStartY; this.worldTransform = { x, y }; this.setSize(width, height, handle); @@ -735,16 +470,6 @@ class NodeMirror extends ElementObject { this.writeLinesNow(); } - // Called when a parent (e.g. a group) cascades a transform write down the - // transform graph: paint this node + recurse to its transform-children, then - // re-glue this node's own lines (the pure-transform cascade can't, since a - // line's two ends live on two different nodes). - writeTransformRecursive(): void { - super.writeTransformRecursive(); - } - - // Transform-only (re)parenting used by group carry: the public/DOM graph is - // left alone, so members stay flat siblings in the adapter's node list. attachTransformToGroup(group: NodeMirror): void { this.setTransformParent(group, true); } @@ -759,6 +484,11 @@ class NodeMirror extends ElementObject { } onCursorDown(e: pointerDownProp): void { + // Child input bubbles. A connector or resize region owns its own + // pointerdown and must not reset or start a node drag. + // TODO: Prevent bubbling in child object + if (e.objectId !== this.id) return; + // Authorization belongs to one pointer gesture. Clear any stale permission // before evaluating this pointerdown (including non-primary buttons). this.#dragPointerId = null; @@ -766,73 +496,62 @@ class NodeMirror extends ElementObject { return; } - // Resize is a separate primitive from node dragging and remains available - // even when the consumer registered a narrow drag handle. - const resizeHandle = findResizeHandle(this.engine, e.position, this); - this.#resizeArmed = resizeHandle != null; - this.#activeResizeHandle = resizeHandle?.handle ?? null; - if (resizeHandle) { - this.#resizeHoverController?.activate(resizeHandle, e.event.target); - } else { - const source = resolveConnectorSourceAtPoint( - this.engine, - e.position, - this, - ); - if (source) { - this.engine.input.setPointerDragOwner( - e.event.pointerId, - source.candidate.connector, - ); - source.candidate.connector.armSurfaceGesture(e, source); - return; - } - } + // TODO: Replace draghandle with inputAlias const target = e.event.target as Node | null; const dragAllowed = - this.#resizeArmed || - ((this.#dragHandles.size === 0 || + (this.#dragHandles.size === 0 || [...this.#dragHandles].some( (handle) => target && handle.contains(target), )) && - this.#callbacks.canStartDrag?.({ - node: this, - pointerId: e.event.pointerId, - position: e.position, - originalEvent: e.event, - }) !== false); + this.#callbacks.canStartDrag?.({ + node: this, + pointerId: e.event.pointerId, + position: e.position, + originalEvent: e.event, + }) !== false; if (!dragAllowed) return; + this.#authorizePointer(e); + } + + /** @internal Called by a ResizeRegionMirror on pointer down. */ + armResize(e: pointerDownProp, handle: ResizeHandle): void { + this.#dragPointerId = null; + if (e.event.button !== 0) return; + this.#isResizeArmed = true; + this.#activeResizeHandle = handle; + this.#authorizePointer(e); + } + + #authorizePointer(e: pointerDownProp): void { this.#dragPointerId = e.event.pointerId; - // Claim the pointer from the very first pointer event: waiting for the - // drag-start threshold would let the camera pan by one move event before - // onDragStart runs. The claim auto-releases when the gesture ends, so no - // paired release is needed anywhere. + // Claim the pointer from the very first pointer event to + // prevent camera pan. this.engine.input.claimPointer(e.event.pointerId); - this._hasMoved = false; - const selection = [...getGraphMirror(this.engine).selection]; - this.#selectedAtPointerDown = selection.includes(this); + this.#hasMoved = false; + const selection = [...getGraphRegistry(this.engine).selection]; + this.#wasSelectedAtPointerDown = selection.includes(this); this.#pointerSelectionMode = this.#callbacks.resolveSelectionMode?.({ node: this, - selected: this.#selectedAtPointerDown, + selected: this.#wasSelectedAtPointerDown, selection, originalEvent: e.event, }) ?? "replace"; if ( this.#pointerSelectionMode === "replace" && - !this.#selectedAtPointerDown + !this.#wasSelectedAtPointerDown ) { - for (const node of [...getGraphMirror(this.engine).selection]) { + for (const node of [...getGraphRegistry(this.engine).selection]) { node.setSelected(false); } this.setSelected(true); } else if ( (this.#pointerSelectionMode === "add" || this.#pointerSelectionMode === "toggle") && - !this.#selectedAtPointerDown + !this.#wasSelectedAtPointerDown ) { this.setSelected(true); } @@ -840,28 +559,30 @@ class NodeMirror extends ElementObject { onDragStart(prop: dragStartProp): void { if (this.#dragPointerId !== prop.pointerId) return; - if (this.#resizeArmed) { - this.#resizing = true; - this.#resizeStartW = this.#hitBox.width; - this.#resizeStartH = this.#hitBox.height; + if (this.#isResizeArmed) { + this.#isResizing = true; + this.#resizeStartWidth = this.#hitBox.width; + this.#resizeStartHeight = this.#hitBox.height; this.#resizeStartX = this.worldTransform.x; this.#resizeStartY = this.worldTransform.y; - this.#mouseDownX = prop.start.x; - this.#mouseDownY = prop.start.y; - this._hasMoved = true; + this.#pointerReferenceX = prop.start.x; + this.#pointerReferenceY = prop.start.y; + this.#hasMoved = true; // Guard so releasing a resize over another node doesn't click-select it. - getGraphMirror(this.engine).resizingNode = this; + // TODO: Not needed since input now captures pointer on original DOM? + getGraphRegistry(this.engine).resizingNode = this; return; } if (!this.#config.lockPosition && this.#config.edgePan) { this.#edgePanPointerId = prop.pointerId; + // TODO: edgePanController should be part of SnapZap this.engine.edgePanController?.startEdgePan( prop.pointerId, prop.start, (position) => this.#moveSelectionToPointer(position), ); } - const selected = [...getGraphMirror(this.engine).selection]; + const selected = [...getGraphRegistry(this.engine).selection]; this.#dragRoots = selected.filter( (node) => !selected.some( @@ -876,7 +597,7 @@ class NodeMirror extends ElementObject { this.#dragCommitNodes = [ ...new Set(this.#dragRoots.flatMap((node) => node.selectionDragNodes())), ]; - this._hasMoved = true; + this.#hasMoved = true; this.#callbacks.onDragStart?.({ node: this, pointerId: prop.pointerId, @@ -890,10 +611,10 @@ class NodeMirror extends ElementObject { console.error("Global stats is null"); return; } - if (this.#resizing) { + if (this.#isResizing) { this.#applyResizeDrag( - prop.position.x - this.#mouseDownX, - prop.position.y - this.#mouseDownY, + prop.position.x - this.#pointerReferenceX, + prop.position.y - this.#pointerReferenceY, ); return; } @@ -919,29 +640,33 @@ class NodeMirror extends ElementObject { } } - /** @internal Hook used to build one deduplicated multi-selection drag session. */ - beginSelectionDrag(position: eventPosition): void { + /** Hook used to build one deduplicated multi-selection drag session. */ + protected beginSelectionDrag(position: eventPosition): void { this.setStartPositions(); - this.#mouseDownX = position.x; - this.#mouseDownY = position.y; + this.#pointerReferenceX = position.x; + this.#pointerReferenceY = position.y; } - /** @internal Whether this node's drag behavior already carries `node`. */ - containsSelectionDragNode(_node: NodeMirror): boolean { + /** + * Whether this node's drag already carries another nodeId + * within it. Needed when checking if a selected group also has a child + * node that is selected, otherwise we may apply drag twice to the child. + .*/ + protected containsSelectionDragNode(_node: NodeMirror): boolean { return false; } - /** @internal Nodes whose final positions belong to this drag root's commit. */ - selectionDragNodes(): NodeMirror[] { + /** Nodes whose final positions belong to this drag root's commit. */ + protected selectionDragNodes(): NodeMirror[] { return [this]; } - /** @internal Finalize any temporary carry state owned by this drag root. */ - finishSelectionDrag(): void {} + /** Finalize any temporary carry state owned by this drag root. */ + protected finishSelectionDrag(): void {} setDragPosition(prop: dragProp) { - const dx = prop.position.x - this.#mouseDownX; - const dy = prop.position.y - this.#mouseDownY; + const dx = prop.position.x - this.#pointerReferenceX; + const dy = prop.position.y - this.#pointerReferenceY; const x = this.#dragStartX + dx; const y = this.#dragStartY + dy; const resolved = this.#callbacks.resolveDragPosition?.({ @@ -958,58 +683,40 @@ class NodeMirror extends ElementObject { } onDragEnd(prop: dragEndProp) { - // A pointerdown rejected by a drag handle/predicate still becomes a generic - // input drag gesture once it crosses the engine threshold. It must not - // commit stale selection coordinates on release. + // this.#dragPointerId will be undefined if drag was canceled + // earlier for any reason, in which case we should not proceed. if (this.#dragPointerId !== prop.pointerId) return; if (this.#edgePanPointerId != null) { this.engine.edgePanController?.stopEdgePan(this.#edgePanPointerId); this.#edgePanPointerId = null; } - if (this.#resizing) { - // The teardown runs in `finally` because everything above it calls out: - // #writeSizeGeometry touches the DOM and onGeometryChanged is consumer - // code. A throw that skipped these resets would strand `resizingNode`, - // which permanently disables click-selection (see onUp), and would leave - // the resize cursor pinned with no path back to a hover recompute. + if (this.#isResizing) { + // The teardown runs in `finally` because everything above it calls out. try { this.#applyResizeDrag( - prop.end.x - this.#mouseDownX, - prop.end.y - this.#mouseDownY, + prop.end.x - this.#pointerReferenceX, + prop.end.y - this.#pointerReferenceY, ); - // Pointer-up is a synchronization boundary for consumers that immediately - // query the committed handle/box. Keep the coalesced frame write for the - // hot path, but make the final retained geometry observable now. this.#writeSizeGeometry(); - this.#callbacks.onGeometryChanged?.({ + this.#callbacks.onGeometryCommit?.({ nodes: [this.#geometryOf(this)], }); } finally { - this.#resizing = false; - this.#resizeArmed = false; + this.#isResizing = false; + this.#isResizeArmed = false; this.#activeResizeHandle = null; - getGraphMirror(this.engine).resizingNode = null; + getGraphRegistry(this.engine).resizingNode = null; this.#dragPointerId = null; - // No target element: dragEndProp carries no originating event. activate() - // still writes the node and container cursors, and the next pointermove - // re-activates with a target because #target differs. - this.#refreshResizeHover(prop.end); } - // Settle #hitBox against what actually rendered. The gesture suppressed - // that reconciliation, and the release write often authors the previous - // frame's values, so no ResizeObserver would fire to trigger it — without - // this an authored size the stylesheet refused would stick. + // Settle #hitBox against what actually rendered. this.remeasureDomGeometry(); // A resized node's center may have moved into/out of a group. - for (const group of getGraphMirror(this.engine).groups) { + for (const group of getGraphRegistry(this.engine).groups) { if ((group as unknown) !== this) group.refreshMembership(true); } return; } - // The live position is authoritative. Recomputing from the raw pointer-up - // coordinate would discard edge-pan compensation and can make the release - // frame jump away from what the user was dragging. if (this.#lastDragPosition) { this.#moveSelectionToPointer(this.#lastDragPosition); } @@ -1018,11 +725,8 @@ class NodeMirror extends ElementObject { node.scheduleTransformAndLines(); } - // A settled node may have entered or left a group; groups re-evaluate - // membership on settle (never at group-drag-start), so the maintained set is - // current before the next group drag. The graph mirror's type-only group - // reference keeps node.ts free of any group value import. - for (const group of getGraphMirror(this.engine).groups) { + // A settled node may have entered or left a group + for (const group of getGraphRegistry(this.engine).groups) { group.refreshMembership(true); } this.emitGeometryChange(); @@ -1033,7 +737,7 @@ class NodeMirror extends ElementObject { } protected emitGeometryChange(): void { - this.#callbacks.onGeometryChanged?.({ + this.#callbacks.onGeometryCommit?.({ nodes: this.getDragCommitNodes().map((node) => this.#geometryOf(node)), }); } @@ -1051,108 +755,130 @@ class NodeMirror extends ElementObject { protected getDragCommitNodes(): NodeMirror[] { return this.#dragCommitNodes.length ? [...this.#dragCommitNodes] - : [...getGraphMirror(this.engine).selection]; - } - - setUpPosition(prop: dragEndProp) { - const [dx, dy] = [ - prop.end.x - this.#mouseDownX, - prop.end.y - this.#mouseDownY, - ]; - this.worldTransform = { - x: this.#dragStartX + dx, - y: this.#dragStartY + dy, - }; - this.scheduleTransformAndLines(); + : [...getGraphRegistry(this.engine).selection]; } onUp(prop: pointerUpProp) { if (this.#dragPointerId !== prop.event.pointerId) return; - // pointerUp is dispatched to whatever is under the release point, which for a - // resize may be a DIFFERENT node than the one being resized. Skip click-select - // while any resize is settling so releasing a resize doesn't select this node. - if (getGraphMirror(this.engine).resizingNode) return; - if (this.#resizeArmed) { - this.#resizeArmed = false; + // InputControl dispatches pointerUp before dragEnd. Keep active resize state + // intact so onDragEnd can commit the geometry and perform teardown. + // TODO: onUp should not fire if drag event fired? + if (getGraphRegistry(this.engine).resizingNode) return; + if (this.#isResizeArmed) { + this.#isResizeArmed = false; this.#activeResizeHandle = null; - this.#refreshResizeHover(prop.position, prop.event.target); + this.#dragPointerId = null; return; } - if (this._hasMoved == false) { + if (!this.#hasMoved) { if (this.#pointerSelectionMode === "replace") { - for (const node of [...getGraphMirror(this.engine).selection]) { + for (const node of [...getGraphRegistry(this.engine).selection]) { if (node !== this) node.setSelected(false); } this.setSelected(true); } else if (this.#pointerSelectionMode === "add") { this.setSelected(true); } else { - this.setSelected(!this.#selectedAtPointerDown); + this.setSelected(!this.#wasSelectedAtPointerDown); } this.#dragPointerId = null; } - this._hasMoved = false; + this.#hasMoved = false; } getConnector(name: string): ConnectorMirror | null { - if (!(name in this._connectors)) { + if (!(name in this.#connectors)) { console.error(`Connector ${name} does not exist in node ${this.id}`); return null; } - return this._connectors[name]; + return this.#connectors[name]; } addConnectorObject(connector: ConnectorMirror) { connector.assignToNode(this); } + /** @internal Registers a connector assigned to this node. */ + attachConnector(connector: ConnectorMirror): void { + this.#connectors[connector.name] = connector; + } + + /** @internal Removes a connector only if it is still the registered value. */ + detachConnector(connector: ConnectorMirror): void { + if (this.#connectors[connector.name] === connector) { + delete this.#connectors[connector.name]; + } + } + getAllOutgoingLines(): LineMirror[] { - return Object.values(this._connectors).flatMap( + return Object.values(this.#connectors).flatMap( (connector) => connector.outgoingLines, ); } getAllIncomingLines(): LineMirror[] { - return Object.values(this._connectors).flatMap( + return Object.values(this.#connectors).flatMap( (connector) => connector.incomingLines, ); } - #refreshResizeHover( - position: eventPosition, - target?: EventTarget | null, - ): void { - this.#resizeHoverController?.clear(); - const handle = findResizeHandle(this.engine, position); - if (handle) this.#resizeHoverController?.activate(handle, target); - } - destroy(removeElement: boolean = true) { if (this.#edgePanPointerId != null) { this.engine.edgePanController?.stopEdgePan(this.#edgePanPointerId); this.#edgePanPointerId = null; } - for (const connector of Object.values(this._connectors)) { + for (const connector of Object.values(this.#connectors)) { // A node unmount is a teardown, not a deliberate programmatic // disconnect — keep the reason contract honest for intent consumers. connector.deleteAllLines("teardown"); } - getGraphMirror(this.engine).unregisterNode(this); + getGraphRegistry(this.engine).unregisterNode(this); this.setSelected(false); - if (this.#resizeHitBoxes.size > 0) { - const ownedHandles = new Set(this.#resizeHitBoxes.values()); - snapData(this.global).resizeHandles = getResizeHandles( - this.global, - ).filter((handle) => !ownedHandles.has(handle as ResizeHandleCollider)); - this.#resizeHoverController?.release(this); - this.#resizeHoverController = null; - this.#resizeHitBoxes.clear(); + for (const region of [...this.#resizeRegions]) region.destroy(false); + this.#resizeRegions.clear(); + this.#connectors = {}; + super.destroy(removeElement); + } +} + +/** + * A developer-owned DOM resize surface represented as a normal SnapEngine + * child object. Assign its `element`; CSS and native hover behavior remain + * entirely application-owned. + */ +class ResizeRegionMirror extends ElementObject { + readonly handle: ResizeHandle; + readonly node: NodeMirror; + + constructor(engine: any, node: NodeMirror, handle: ResizeHandle) { + if (!RESIZE_HANDLES.includes(handle)) { + throw new Error(`Unknown resize handle: ${handle}`); } - this._connectors = {}; + super(engine, node); + this.node = node; + this.handle = handle; + this.transformMode = "none"; + this.event.input.pointerDown = this.#onPointerDown; + this.event.input.dragStart = this.#onDragStart; + node.attachResizeRegion(this); + } + + #onPointerDown(prop: pointerDownProp): void { + if (prop.event.button !== 0) return; + this.node.armResize(prop, this.handle); + } + + #onDragStart(prop: dragStartProp): void { + prop.handoffTo(this.node); + } + + destroy(removeElement: boolean = true): void { + if (this.isDeleteRequested) return; + this.node.detachResizeRegion(this); super.destroy(removeElement); } } -export { NodeMirror }; +export { NodeMirror, ResizeRegionMirror }; diff --git a/assets/snapline/core/src/placement.ts b/assets/snapline/core/src/placement.ts index cbda0d7..a6f6abf 100644 --- a/assets/snapline/core/src/placement.ts +++ b/assets/snapline/core/src/placement.ts @@ -1,4 +1,4 @@ -import type { GeometryWriter } from "./geometry"; +import type { GeometryWriter } from "./types"; export interface PlacementPoint { x: number; @@ -76,9 +76,7 @@ export class PlacementController { #callbacks: PlacementCallbacks; #snapshot: PlacementSnapshot; #geometryWriter: GeometryWriter | null = null; - #stateCallbacks = new Set< - (snapshot: PlacementSnapshot) => void - >(); + #stateCallbacks = new Set<(snapshot: PlacementSnapshot) => void>(); constructor(config: PlacementConfig) { this.#config = config; diff --git a/assets/snapline/core/src/query.ts b/assets/snapline/core/src/query.ts index 6c15f49..173bbbf 100644 --- a/assets/snapline/core/src/query.ts +++ b/assets/snapline/core/src/query.ts @@ -2,13 +2,8 @@ import type { ConnectorMirror } from "./connector"; import type { GroupNodeMirror } from "./group"; import type { LineMirror } from "./line"; import type { NodeMirror } from "./node"; -import type { - ConnectorId, - LineId, - NodeId, - ReconciliationError, -} from "./graph-mirror"; -import { getGraphMirror } from "./snapline-globals"; +import type { ConnectorId, LineId, NodeId, ReconciliationError } from "./types"; +import { getGraphRegistry } from "./internal/shared-data"; type EngineLike = { global: { @@ -29,6 +24,8 @@ export interface GraphQuery { groups(): readonly GroupNodeMirror[]; /** Settled lines; gesture previews are not part of the settled graph. */ lines(): readonly LineMirror[]; + /** The settled selection, in selection order. */ + selection(): readonly NodeMirror[]; node(id: NodeId): NodeMirror | null; connector(id: ConnectorId): ConnectorMirror | null; line(id: LineId): LineMirror | null; @@ -37,41 +34,16 @@ export interface GraphQuery { /** The read-only query facade for one engine's graph. */ export function query(engine: EngineLike): GraphQuery { - const mirror = getGraphMirror(engine); + const mirror = getGraphRegistry(engine); return { nodes: () => mirror.nodes, connectors: () => mirror.connectors, groups: () => [...mirror.groups], lines: () => mirror.lines, + selection: () => [...mirror.selection], node: (id) => mirror.node(id), connector: (id) => mirror.connector(id), line: (id) => mirror.line(id), diagnostics: () => mirror.diagnostics(), }; } - -// Enumeration delegates to the per-engine GraphMirror registry (components -// register in their constructors), replacing the old engine-object-table -// scans. Public signatures unchanged. - -export function getNodes(engine: EngineLike): readonly NodeMirror[] { - return getGraphMirror(engine).nodes; -} - -export function getConnectors( - engine: EngineLike, -): readonly ConnectorMirror[] { - return getGraphMirror(engine).connectors; -} - -export function getGroupNodes( - engine: EngineLike, -): readonly GroupNodeMirror[] { - return [...getGraphMirror(engine).groups]; -} - -export function getSelectedNodes( - engine: EngineLike, -): readonly NodeMirror[] { - return [...getGraphMirror(engine).selection]; -} diff --git a/assets/snapline/core/src/select.ts b/assets/snapline/core/src/select.ts index d8e303c..20ec729 100644 --- a/assets/snapline/core/src/select.ts +++ b/assets/snapline/core/src/select.ts @@ -6,8 +6,8 @@ import type { } from "@snap-engine/core"; import { RectCollider, Collider } from "@snap-engine/core/collision"; import { NodeMirror, type SelectionMode } from "./node"; -import { getGraphMirror } from "./snapline-globals"; -import type { GeometryWriter } from "./geometry"; +import { getGraphRegistry } from "./internal/shared-data"; +import type { GeometryWriter } from "./types"; /** World-space rectangle delivered to the registered geometry writer. */ export interface SelectRect { @@ -80,12 +80,11 @@ class RectSelectController extends ElementObject { this.#selectHitBox = new RectCollider(engine, this, 0, 0, 0, 0); this.#selectHitBox.localTransform = { x: 0, y: 0 }; - this.#selectHitBox.event.collider.onCollide = this.onCollideNode; this.addCollider(this.#selectHitBox); // A fresh selection controller starts its engine from an empty selection. - getGraphMirror(this.engine).selection.length = 0; + getGraphRegistry(this.engine).selection.length = 0; this.#callbacks = config.callbacks ?? {}; } @@ -115,13 +114,10 @@ class RectSelectController extends ElementObject { visible, }; this.#callbacks.onRectChange?.({ ...this.#rect }); - this.schedule( - () => this.#geometryWriter?.({ ...this.#rect }), - { - stage: "WRITE_2", - queueId: `${this.id}-geometry`, - }, - ); + this.schedule(() => this.#geometryWriter?.({ ...this.#rect }), { + stage: "WRITE_2", + queueId: `${this.id}-geometry`, + }); } onGlobalCursorDown(prop: pointerDownProp): void { @@ -136,10 +132,10 @@ class RectSelectController extends ElementObject { if (this.#callbacks.canStart?.(startEvent) === false) return; this.#selectionMode = this.#callbacks.resolveSelectionMode?.(startEvent) ?? "replace"; - this.#baselineSelection = new Set(getGraphMirror(this.engine).selection); + this.#baselineSelection = new Set(getGraphRegistry(this.engine).selection); if (this.#selectionMode === "replace") { // setSelected(false) removes each node from the engine's selection. - for (let node of [...getGraphMirror(this.engine).selection]) { + for (const node of [...getGraphRegistry(this.engine).selection]) { node.setSelected(false); } } @@ -155,7 +151,7 @@ class RectSelectController extends ElementObject { this.#fireRect(0, 0, true); this.#callbacks.onSelectionChange?.({ select: this, - selection: [...getGraphMirror(this.engine).selection], + selection: [...getGraphRegistry(this.engine).selection], }); this.#selectHitBox.event.collider.onBeginContact = ( @@ -163,7 +159,7 @@ class RectSelectController extends ElementObject { otherObject: Collider, ) => { if (otherObject.parent instanceof NodeMirror) { - let node = otherObject.parent as NodeMirror; + const node = otherObject.parent as NodeMirror; node.setSelected( this.#selectionMode === "toggle" ? !this.#baselineSelection.has(node) @@ -171,7 +167,7 @@ class RectSelectController extends ElementObject { ); this.#callbacks.onSelectionChange?.({ select: this, - selection: [...getGraphMirror(this.engine).selection], + selection: [...getGraphRegistry(this.engine).selection], }); } }; @@ -180,11 +176,11 @@ class RectSelectController extends ElementObject { otherObject: Collider, ) => { if (otherObject.parent instanceof NodeMirror) { - let node = otherObject.parent as NodeMirror; + const node = otherObject.parent as NodeMirror; node.setSelected(this.#baselineSelection.has(node)); this.#callbacks.onSelectionChange?.({ select: this, - selection: [...getGraphMirror(this.engine).selection], + selection: [...getGraphRegistry(this.engine).selection], }); } }; @@ -192,11 +188,11 @@ class RectSelectController extends ElementObject { onGlobalCursorMove(prop: pointerMoveProp): void { if (this.#state === "dragging") { - let [boxOriginX, boxOriginY] = [ + const [boxOriginX, boxOriginY] = [ Math.min(this.#mouseDownX, prop.position.x), Math.min(this.#mouseDownY, prop.position.y), ]; - let [boxWidth, boxHeight] = [ + const [boxWidth, boxHeight] = [ Math.abs(prop.position.x - this.#mouseDownX), Math.abs(prop.position.y - this.#mouseDownY), ]; @@ -216,8 +212,6 @@ class RectSelectController extends ElementObject { this.#selectHitBox.event.collider.onEndContact = null; if (wasDragging) this.#fireRect(0, 0, false); } - - onCollideNode(_hitBox: Collider, _node: Collider): void {} } export { RectSelectController }; diff --git a/assets/snapline/core/src/snapline-globals.ts b/assets/snapline/core/src/snapline-globals.ts deleted file mode 100644 index c58b0e2..0000000 --- a/assets/snapline/core/src/snapline-globals.ts +++ /dev/null @@ -1,122 +0,0 @@ -import type { RectCollider } from "@snap-engine/core/collision"; -import type { eventPosition } from "@snap-engine/core"; -import { GraphMirror } from "./graph-mirror"; -import { - LineReconciler, - type ControlledGraphCallbacks, - type ControlledGraphHandle, -} from "./line-reconciler"; - -/** - * Structural source-surface contract shared with engine input. Keeping this - * shape here avoids an engine-core -> SnapLine dependency while still allowing - * a headless connector to own pointer input outside its parent's DOM bounds. - */ -export interface SourceSurfaceOwner { - id: string; - engine: unknown; - isDeleteRequested: boolean; - resolveSourceHit(position: eventPosition): { - candidate: { - hit: { - distance: number; - priority?: number; - }; - }; - strategyIndex: number; - } | null; -} - -/** - * The shape of everything SnapLine stores on the engine's shared `global.data` - * bag. This is the single declaration site for these cross-module contracts — - * every reader/writer goes through the typed accessors below instead of - * re-deriving the shape inline. - * - * NOTE for engine core: `input.ts#resolveResizeOwner` reads `resizeHandles` - * and `input.ts#resolveSourceSurfaceOwner` reads `sourceSurfaces` duck-typed - * (engine core cannot import snapline); keep their structural types in sync - * with this declaration. - */ -export interface SnapLineSharedData { - /** Registered resize hitboxes; input.ts routes pointerdowns over them. */ - resizeHandles?: RectCollider[]; - /** Registered headless source surfaces; input.ts routes pointerdowns to them. */ - sourceSurfaces?: SourceSurfaceOwner[]; - /** - * @deprecated Legacy camera-control boolean (last-writer-wins), read by the - * camera for third-party writers only. In-repo gesture owners block the - * camera at the input-dispatch layer instead: `engine.input.claimPointer()` - * (claims auto-release when the gesture ends). - */ - allowCameraControl?: boolean; - /** - * Per-engine SnapLine registries. GlobalManager is application-wide, so the - * 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. - */ - graphMirrors?: WeakMap; -} - -/** Typed view over the untyped global data bag (cast at the boundary). */ -export function snapData(global: { data: any }): SnapLineSharedData { - return global.data as SnapLineSharedData; -} - -export function getResizeHandles(global: { data: any }): RectCollider[] { - const data = snapData(global); - if (!data.resizeHandles) data.resizeHandles = []; - return data.resizeHandles; -} - -export function getSourceSurfaces(global: { - data: any; -}): SourceSurfaceOwner[] { - const data = snapData(global); - if (!data.sourceSurfaces) data.sourceSurfaces = []; - return data.sourceSurfaces; -} - - -export function getGraphMirror(engine: { - global: { data: any } | null; -}): GraphMirror { - if (!engine.global) { - throw new Error("SnapLine: getGraphMirror requires an initialized engine."); - } - const data = snapData(engine.global); - 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.", - ); - } - 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/core/src/types.ts b/assets/snapline/core/src/types.ts new file mode 100644 index 0000000..2f7fc9c --- /dev/null +++ b/assets/snapline/core/src/types.ts @@ -0,0 +1,117 @@ +/** + * Pure type declarations shared across SnapLine — identity, diagnostics, the + * geometry-writer signature, and the controlled-graph contract the application + * negotiates through. + * + * This module deliberately imports nothing. Everything here is part of the + * public surface, so it stays at the top level while the machinery that + * consumes it lives under `internal/`. + */ + +/** Stable application-facing identity of a canonical node. */ +export type NodeId = string; +/** Stable application-facing identity of a canonical connector (graph-global). */ +export type ConnectorId = string; +/** Stable application-facing identity of a canonical line. */ +export type LineId = string; + +/** + * Sink for high-frequency geometry: core hands over numbers, the consumer owns + * the element. Used by lines, selection, and placement. + */ +export type GeometryWriter = (geometry: Readonly) => void; + +/** + * Notified the moment core determines an object's geometry will change this + * frame — synchronously during input dispatch, before anything is queued. + * + * It carries no geometry on purpose. The subscriber schedules its own task at + * the stage it wants (`schedule(cb, { stage, queueId })`) and reads the + * object's `geometrySnapshot()` there, rather than having core pick a write + * phase on its behalf. + */ +export type GeometryInvalidationObserver = (source: T) => void; + +/** + * Structured, non-throwing report of a graph state the registry cannot + * represent. Derived state: entries drop out when their cause resolves. + */ +export interface ReconciliationError { + code: "duplicate-id" | "capacity-exceeded" | "connection-rejected"; + lineId?: LineId; + nodeId?: NodeId; + connectorId?: ConnectorId; + message: string; + cause?: unknown; +} + +/** A batch token from `beginBatch()`; `end()` is idempotent. */ +export interface GraphBatch { + end(): void; +} + +/** Canonical committed relationship, owned by the application. */ +export interface LineRecord { + id: LineId; + fromConnectorId: ConnectorId; + toConnectorId: ConnectorId; + payload?: unknown; +} + +/** The application-pushed canonical document. Node and connector existence + * stays framework-mount-led; the snapshot carries the line records. */ +export interface CanonicalGraphSnapshot { + lines: readonly LineRecord[]; +} + +/** A gesture-created line proposed to the canonical owner. The `id` is + * minted by SnapLine; adopting it settles the staged mirror in place. */ +export interface ProposedLine { + id: LineId; + fromConnectorId: ConnectorId; + toConnectorId: ConnectorId; + payload?: unknown; +} + +export interface LineEndpointUpdate { + id: LineId; + toConnectorId: ConnectorId; +} + +/** One atomic proposal for the application to change canonical records. */ +export interface LineChangeRequest { + intent: "connect" | "disconnect" | "replace" | "reconnect"; + add: readonly ProposedLine[]; + remove: readonly LineId[]; + update: readonly LineEndpointUpdate[]; +} + +export interface ControlledGraphCallbacks { + /** + * One atomic proposal per gesture. **Return the line list that should now be + * canonical** — the bridge hands it straight to the reconciler, so exactly + * one decisive pass runs per request whether you accept, normalize, or + * reject. + * + * ```ts + * onLineChangeRequest: (r) => (lines = applyLineChange(lines, r)) + * ``` + * + * To reject, return the list unchanged (`return lines`). The return type is + * non-optional on purpose: "I reject" and "I forgot to return anything" used + * to be the same code, and this makes the second one a type error. + * + * Must be synchronous — the staged preview line is resolved by the pass that + * follows this call. + */ + onLineChangeRequest(request: LineChangeRequest): readonly LineRecord[]; + onDiagnosticsChanged?(diagnostics: readonly ReconciliationError[]): void; +} + +/** What `attachControlledGraph()` hands the adapter. */ +export interface ControlledGraphHandle { + setCanonicalGraph(snapshot: CanonicalGraphSnapshot): void; + /** Run any pending reconciliation synchronously (vanilla/tests). */ + flush(): void; + dispose(): void; +} diff --git a/assets/snapline/react/README.md b/assets/snapline/react/README.md index d5685e1..46d460d 100644 --- a/assets/snapline/react/README.md +++ b/assets/snapline/react/README.md @@ -11,9 +11,9 @@ npm install react react-dom @snap-engine/core \ ## Components -The package exports `Engine`, `Node`, `Group`, `Connector`, `Line`, `Select`, -`Placement`, and `ControlledGraph`. Each component is also available from a -named subpath. +The package exports `Engine`, `Node`, `Group`, `ResizeRegion`, `Connector`, +`Line`, `Select`, `Placement`, and `ControlledGraph`. Each component is also +available from a named subpath. ```tsx import { Engine, Group, Node, Select } from "@snap-engine/snapline-react"; @@ -36,14 +36,18 @@ resynchronize after mount, and callback props remain live across renders. Pass native ARIA attributes or DOM event handlers to the outer node element 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 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. +Render explicit `ResizeRegion` children to opt into resizing. Their CSS owns +the hit area, position, cursor, hover behavior, and visuals. -Connector policy, metadata, callbacks, strategies, and `virtual` stay live -across renders. Toggling `virtual` removes or remounts only the visible port; -the logical connector and existing lines are preserved. +Pass a render function to `Connector` when the input root should be custom +HTML or SVG. Attach its callback ref to exactly one element; for an SVG path, +use `pointerEvents="stroke"` to make the painted stroke the source hit area. +`surfaceStrategies` customize target admission and endpoint anchors. Keep +domain edges in React state and use an opaque line payload as the stable link +from a custom renderer. + +Connector policy, metadata, callbacks, strategies, and collider radius stay +live across renders. `name` and an adopted `connectorObject` are +construction-time identities. Full documentation: https://snapengine.dev/docs/snapline/introduction diff --git a/assets/snapline/react/package.json b/assets/snapline/react/package.json index b92e69a..71d19e2 100644 --- a/assets/snapline/react/package.json +++ b/assets/snapline/react/package.json @@ -14,6 +14,7 @@ ".": "./src/index.ts", "./Engine": "./src/Engine.tsx", "./Node": "./src/Node.tsx", + "./ResizeRegion": "./src/ResizeRegion.tsx", "./Group": "./src/Group.tsx", "./Connector": "./src/Connector.tsx", "./Line": "./src/Line.tsx", diff --git a/assets/snapline/react/src/Connector.tsx b/assets/snapline/react/src/Connector.tsx index 7bd32c3..7b82285 100644 --- a/assets/snapline/react/src/Connector.tsx +++ b/assets/snapline/react/src/Connector.tsx @@ -6,6 +6,7 @@ import { useImperativeHandle, useRef, type CSSProperties, + type ReactNode, } from "react"; import { ConnectorMirror, @@ -28,8 +29,13 @@ export interface ConnectorProps { edgePan?: boolean; rules?: Partial; surfaceStrategies?: readonly ConnectorSurfaceStrategy[]; - /** Keep the logical connector without rendering a visible port element. */ - virtual?: boolean; + /** + * Render a custom HTML or SVG connector root. Attach the supplied callback + * ref to the one element that should receive pointer input. + */ + children?: (bind: { + ref: (element: HTMLElement | SVGElement | null) => void; + }) => ReactNode; colliderRadius?: number; connectorObject?: ConnectorMirror | null; data?: Record; @@ -51,7 +57,7 @@ export const Connector = forwardRef( edgePan = true, rules, surfaceStrategies = [], - virtual = false, + children, colliderRadius, connectorObject = null, data = {}, @@ -105,7 +111,7 @@ export const Connector = forwardRef( ]); const bindConnectorElement = useCallback( - (element: HTMLDivElement | null) => { + (element: HTMLElement | SVGElement | null) => { connector.bindElement(element); }, [connector], @@ -117,7 +123,9 @@ export const Connector = forwardRef( }; }, [connector]); - if (virtual) return null; + if (children) { + return children({ ref: bindConnectorElement }); + } return (
void; + /** One atomic proposal per gesture. Return the list that should now be + * canonical — adopting a proposed id settles the line in place; returning + * the list unchanged rejects. Must be synchronous. */ + onLineChangeRequest: (request: LineChangeRequest) => readonly LineRecord[]; onDiagnosticsChanged?: (diagnostics: readonly ReconciliationError[]) => void; } -export function ControlledGraph({ - lines, - onLineChangeRequest, - onDiagnosticsChanged, -}: ControlledGraphProps) { +/** + * The app↔SnapLine bridge. Renders nothing. + * + * Gesture-driven changes flow through `onLineChangeRequest`'s return value. + * For records no request asked for — hydration/load, undo/redo, a + * collaborator's edit — take a ref and call `setLines`. + */ +export const ControlledGraph = forwardRef< + ControlledGraphHandle, + ControlledGraphProps +>(function ControlledGraph({ onLineChangeRequest, onDiagnosticsChanged }, ref) { const engine = useSnapLineEngine(); // Written during render so the bridge's closures always read fresh props. - const propsRef = useRef({ lines, onLineChangeRequest, onDiagnosticsChanged }); - propsRef.current = { lines, onLineChangeRequest, onDiagnosticsChanged }; + const propsRef = useRef({ onLineChangeRequest, onDiagnosticsChanged }); + propsRef.current = { onLineChangeRequest, onDiagnosticsChanged }; const handleRef = useRef(null); useEffect(() => { const handle = attachControlledGraph(engine, { - onLineChangeRequest: (request) => { - propsRef.current.onLineChangeRequest(request); - // Guaranteed post-request push: React flushes the handler's state - // update (and re-renders propsRef) before this microtask runs, so - // the decisive pass sees the app's decision — including - // rejection-by-inaction, where the unchanged records come through. - queueMicrotask(() => - handleRef.current?.setCanonicalGraph({ - lines: propsRef.current.lines, - }), - ); - }, + onLineChangeRequest: (request) => + propsRef.current.onLineChangeRequest(request), onDiagnosticsChanged: (diagnostics) => propsRef.current.onDiagnosticsChanged?.(diagnostics), }); handleRef.current = handle; - handle.setCanonicalGraph({ lines: propsRef.current.lines }); return () => { handle.dispose(); handleRef.current = null; }; }, [engine]); - useEffect(() => { - handleRef.current?.setCanonicalGraph({ lines }); - }, [lines]); + useImperativeHandle( + ref, + () => ({ + setCanonicalGraph: (snapshot) => + handleRef.current?.setCanonicalGraph(snapshot), + flush: () => handleRef.current?.flush(), + dispose: () => handleRef.current?.dispose(), + }), + [], + ); return null; -} +}); diff --git a/assets/snapline/react/src/Group.tsx b/assets/snapline/react/src/Group.tsx index 0ab0e68..b837016 100644 --- a/assets/snapline/react/src/Group.tsx +++ b/assets/snapline/react/src/Group.tsx @@ -7,18 +7,16 @@ import { type ReactNode, } from "react"; import { - DEFAULT_RESIZE_HANDLE_THICKNESS, GroupNodeMirror, type GroupCallbacks, type GroupContainEvent, type GroupMembershipEvent, type NodeCallbacks, type GeometryChangeEvent, - type NodeResizeEvent, - type ResizeHandle, type SnapLineMetadata, } from "@snap-engine/snapline"; import { useSnapLineEngine } from "./Engine"; +import { NodeMirrorContext } from "./Node"; export interface GroupProps { /** Stable domain identity; minted when omitted (supply for persistence). */ @@ -36,16 +34,13 @@ export interface GroupProps { height?: number; minWidth?: number; minHeight?: number; - resizeHandleThickness?: number; - resizeHandles?: true | readonly ResizeHandle[]; - resizeCursors?: Partial>; metadata?: SnapLineMetadata; callbacks?: NodeCallbacks; groupCallbacks?: GroupCallbacks; canContain?: (event: GroupContainEvent) => boolean; edgePan?: boolean; onMembershipChange?: (event: GroupMembershipEvent) => void; - onGeometryChanged?: (event: GeometryChangeEvent) => void; + onGeometryCommit?: (event: GeometryChangeEvent) => void; } export const Group = forwardRef(function Group( @@ -63,16 +58,13 @@ export const Group = forwardRef(function Group( height = 300, minWidth, minHeight, - resizeHandleThickness, - resizeHandles, - resizeCursors, metadata = {}, callbacks = {}, groupCallbacks = {}, canContain, edgePan = true, onMembershipChange, - onGeometryChanged, + onGeometryCommit, }, ref, ) { @@ -88,9 +80,6 @@ export const Group = forwardRef(function Group( height, minWidth, minHeight, - resizeHandleThickness, - resizeHandles, - resizeCursors, metadata, callbacks: {}, groupCallbacks: {}, @@ -108,13 +97,13 @@ export const Group = forwardRef(function Group( callbacks, groupCallbacks, onMembershipChange, - onGeometryChanged, + onGeometryCommit, }); latestRef.current = { callbacks, groupCallbacks, onMembershipChange, - onGeometryChanged, + onGeometryCommit, }; useImperativeHandle(ref, () => group, [group]); @@ -154,6 +143,12 @@ export const Group = forwardRef(function Group( latestRef.current.callbacks.resolveSelectionMode?.(event) ?? originalCallbacks.resolveSelectionMode?.(event) ?? "replace"; + group.callbacks.resolveNewLine = (event) => { + const resolver = + latestRef.current.callbacks.resolveNewLine ?? + originalCallbacks.resolveNewLine; + return resolver?.(event); + }; group.callbacks.onDragStart = (event) => invoke( event, @@ -172,12 +167,6 @@ export const Group = forwardRef(function Group( originalCallbacks.onSelectionChange, latestRef.current.callbacks.onSelectionChange, ); - group.callbacks.onResizeHandleChange = (event) => - invoke( - event, - originalCallbacks.onResizeHandleChange, - latestRef.current.callbacks.onResizeHandleChange, - ); group.groupCallbacks.onMembershipChange = (event) => invoke( event, @@ -185,12 +174,12 @@ export const Group = forwardRef(function Group( latestRef.current.groupCallbacks.onMembershipChange, latestRef.current.onMembershipChange, ); - group.callbacks.onGeometryChanged = (event) => + group.callbacks.onGeometryCommit = (event) => invoke( event, - originalCallbacks.onGeometryChanged, - latestRef.current.callbacks.onGeometryChanged, - latestRef.current.onGeometryChanged, + originalCallbacks.onGeometryCommit, + latestRef.current.callbacks.onGeometryCommit, + latestRef.current.onGeometryCommit, ); group.callbacks.onSizeChange = (event) => { invoke( @@ -216,12 +205,11 @@ export const Group = forwardRef(function Group( group.callbacks.canStartDrag = originalCallbacks.canStartDrag; group.callbacks.resolveSelectionMode = originalCallbacks.resolveSelectionMode; + group.callbacks.resolveNewLine = originalCallbacks.resolveNewLine; group.callbacks.onDragStart = originalCallbacks.onDragStart; group.callbacks.onDrag = originalCallbacks.onDrag; - group.callbacks.onGeometryChanged = originalCallbacks.onGeometryChanged; + group.callbacks.onGeometryCommit = originalCallbacks.onGeometryCommit; group.callbacks.onSelectionChange = originalCallbacks.onSelectionChange; - group.callbacks.onResizeHandleChange = - originalCallbacks.onResizeHandleChange; group.callbacks.onSizeChange = originalCallbacks.onSizeChange; group.groupCallbacks.onMembershipChange = originalGroupCallbacks.onMembershipChange; @@ -243,55 +231,32 @@ export const Group = forwardRef(function Group( group.scheduleGeometryWrite(); }, [group, x, y, width, height]); - const handleSize = resizeHandleThickness ?? DEFAULT_RESIZE_HANDLE_THICKNESS; return ( -
-
+
- {headerContent ?? {title}} -
-
{children}
- {group.resizeHandles.map((handle) => ( -
- ))} -
+
+ {headerContent ?? {title}} +
+
{children}
+
+ ); }); diff --git a/assets/snapline/react/src/Node.tsx b/assets/snapline/react/src/Node.tsx index 7e09312..e7516b7 100644 --- a/assets/snapline/react/src/Node.tsx +++ b/assets/snapline/react/src/Node.tsx @@ -13,10 +13,8 @@ import { type ReactNode, } from "react"; import { - DEFAULT_RESIZE_HANDLE_THICKNESS, LineMirror, NodeMirror, - type ResizeHandle, type NodeCallbacks, type GeometryChangeEvent, type NodeResizeEvent, @@ -32,23 +30,35 @@ export interface NodeProps { id?: string; children: ReactNode; className?: string; + /** One renderer for every line leaving this node. */ lineComponent?: ComponentType<{ line: LineMirror }>; + /** + * Picks a renderer per line, so a data edge and a control edge leaving the + * same node can look different. Falls back to `lineComponent` when it + * returns nothing. + * + * Resolved at render time, not at line creation: hydration never runs the + * creation callback (a reloaded graph builds its lines through the + * reconciler), so resolving from the line is what makes a line you just drew + * and the same line after a refresh render identically. Branch on + * serializable data you put in the payload — never store a component + * reference in a record. + */ + resolveLineComponent?: ( + line: LineMirror, + ) => ComponentType<{ line: LineMirror }> | null | undefined; nodeObject?: NodeMirror | null; style?: CSSProperties; x?: number; y?: number; width?: number; height?: number; - resizable?: boolean; minWidth?: number; minHeight?: number; - resizeHandleThickness?: number; - resizeHandles?: true | readonly ResizeHandle[]; - resizeCursors?: Partial>; metadata?: SnapLineMetadata; callbacks?: NodeCallbacks; edgePan?: boolean; - onGeometryChanged?: (event: GeometryChangeEvent) => void; + onGeometryCommit?: (event: GeometryChangeEvent) => void; onSizeChange?: (event: NodeResizeEvent) => void; /** Framework-native attributes and events for the outer node element. */ elementProps?: HTMLAttributes; @@ -60,22 +70,19 @@ export const Node = forwardRef(function Node( children, className = "", lineComponent: LineRenderer = Line, + resolveLineComponent, nodeObject = null, style, x = 0, y = 0, width, height, - resizable = false, minWidth, minHeight, - resizeHandleThickness, - resizeHandles, - resizeCursors, metadata = {}, callbacks = {}, edgePan = true, - onGeometryChanged, + onGeometryCommit, onSizeChange, elementProps, }, @@ -88,12 +95,8 @@ export const Node = forwardRef(function Node( if (!nodeRef.current) { nodeRef.current = new NodeMirror(engine, null, { id, - resizable, minWidth, minHeight, - resizeHandleThickness, - resizeHandles, - resizeCursors, metadata, callbacks: {}, edgePan, @@ -105,12 +108,12 @@ export const Node = forwardRef(function Node( ); const latestRef = useRef({ callbacks, - onGeometryChanged, + onGeometryCommit, onSizeChange, }); latestRef.current = { callbacks, - onGeometryChanged, + onGeometryCommit, onSizeChange, }; @@ -148,12 +151,16 @@ export const Node = forwardRef(function Node( }; node.callbacks.resolveDragPosition = (event) => latestRef.current.callbacks.resolveDragPosition?.(event) ?? - original.resolveDragPosition?.(event) ?? - { x: event.x, y: event.y }; + original.resolveDragPosition?.(event) ?? { x: event.x, y: event.y }; node.callbacks.resolveSelectionMode = (event) => latestRef.current.callbacks.resolveSelectionMode?.(event) ?? original.resolveSelectionMode?.(event) ?? "replace"; + node.callbacks.resolveNewLine = (event) => { + const resolver = + latestRef.current.callbacks.resolveNewLine ?? original.resolveNewLine; + return resolver?.(event); + }; node.callbacks.onDragStart = (event) => invoke( event, @@ -168,12 +175,6 @@ export const Node = forwardRef(function Node( original.onSelectionChange, latestRef.current.callbacks.onSelectionChange, ); - node.callbacks.onResizeHandleChange = (event) => - invoke( - event, - original.onResizeHandleChange, - latestRef.current.callbacks.onResizeHandleChange, - ); node.callbacks.onLinesChanged = (event) => { invoke( event, @@ -190,12 +191,12 @@ export const Node = forwardRef(function Node( latestRef.current.onSizeChange, ); }; - node.callbacks.onGeometryChanged = (event) => + node.callbacks.onGeometryCommit = (event) => invoke( event, - original.onGeometryChanged, - latestRef.current.callbacks.onGeometryChanged, - latestRef.current.onGeometryChanged, + original.onGeometryCommit, + latestRef.current.callbacks.onGeometryCommit, + latestRef.current.onGeometryCommit, ); setLineList([...node.getAllOutgoingLines()]); const boundElement = nodeDomRef.current; @@ -204,11 +205,11 @@ export const Node = forwardRef(function Node( node.callbacks.canStartDrag = original.canStartDrag; node.callbacks.resolveDragPosition = original.resolveDragPosition; node.callbacks.resolveSelectionMode = original.resolveSelectionMode; + node.callbacks.resolveNewLine = original.resolveNewLine; node.callbacks.onDragStart = original.onDragStart; node.callbacks.onDrag = original.onDrag; - node.callbacks.onGeometryChanged = original.onGeometryChanged; + node.callbacks.onGeometryCommit = original.onGeometryCommit; node.callbacks.onSelectionChange = original.onSelectionChange; - node.callbacks.onResizeHandleChange = original.onResizeHandleChange; node.callbacks.onLinesChanged = original.onLinesChanged; node.callbacks.onSizeChange = original.onSizeChange; if (ownsNodeRef.current) { @@ -234,12 +235,15 @@ export const Node = forwardRef(function Node( node.remeasureDomGeometry(); }, [node, width, height]); - const handleSize = - resizeHandleThickness ?? DEFAULT_RESIZE_HANDLE_THICKNESS; return ( {lineList.map((line) => ( - + ))}
(function Node( }} > {children} - {node.resizeHandles.map((handle) => ( -
- ))}
); @@ -294,3 +274,19 @@ export function useNodeHandle(): RefCallback { cleanup.current = element && node ? node.registerDragHandle(element) : null; }; } + +/** Per-line renderer resolution, evaluated at render time. */ +function LineForLine({ + line, + resolve, + fallback: Fallback, +}: { + line: LineMirror; + resolve?: ( + line: LineMirror, + ) => ComponentType<{ line: LineMirror }> | null | undefined; + fallback: ComponentType<{ line: LineMirror }>; +}) { + const Resolved = resolve?.(line) ?? Fallback; + return ; +} diff --git a/assets/snapline/react/src/ResizeRegion.tsx b/assets/snapline/react/src/ResizeRegion.tsx new file mode 100644 index 0000000..97918ff --- /dev/null +++ b/assets/snapline/react/src/ResizeRegion.tsx @@ -0,0 +1,64 @@ +import { + forwardRef, + useContext, + useImperativeHandle, + useLayoutEffect, + useRef, + useState, + type HTMLAttributes, + type ReactNode, +} from "react"; +import { ResizeRegionMirror, type ResizeHandle } from "@snap-engine/snapline"; +import { NodeMirrorContext } from "./Node"; + +export interface ResizeRegionProps + extends Omit, "children"> { + handle: ResizeHandle; + children?: ReactNode; +} + +export const ResizeRegion = forwardRef( + function ResizeRegion({ handle, children, style, ...elementProps }, ref) { + const node = useContext(NodeMirrorContext); + if (!node) { + throw new Error("ResizeRegion must be rendered inside a Node or Group."); + } + + const elementRef = useRef(null); + const [region] = useState( + () => new ResizeRegionMirror(node.engine, node, handle), + ); + if (region.node !== node || region.handle !== handle) { + throw new Error( + "ResizeRegion owner and handle cannot change after mounting; remount it with a new key.", + ); + } + + useImperativeHandle(ref, () => region, [region]); + + useLayoutEffect(() => { + if (elementRef.current) region.element = elementRef.current; + const boundElement = elementRef.current; + return () => { + if (boundElement) region.detachElement(boundElement); + region.destroy(false); + }; + }, [region]); + + return ( +
+ {children} +
+ ); + }, +); diff --git a/assets/snapline/react/src/index.ts b/assets/snapline/react/src/index.ts index fb96913..a549f0c 100644 --- a/assets/snapline/react/src/index.ts +++ b/assets/snapline/react/src/index.ts @@ -1,6 +1,11 @@ export { Connector } from "./Connector"; export type { ConnectorProps, ConnectorRef } from "./Connector"; -export { Engine, EngineContext, SnapLineEngine, useSnapLineEngine } from "./Engine"; +export { + Engine, + EngineContext, + SnapLineEngine, + useSnapLineEngine, +} from "./Engine"; export type { EngineProps } from "./Engine"; export { Group } from "./Group"; export type { GroupProps } from "./Group"; @@ -8,6 +13,8 @@ export { Line } from "./Line"; export type { LineProps } from "./Line"; export { Node, NodeMirrorContext, useNodeHandle } from "./Node"; export type { NodeProps } from "./Node"; +export { ResizeRegion } from "./ResizeRegion"; +export type { ResizeRegionProps } from "./ResizeRegion"; export { Select } from "./Select"; export type { SelectProps } from "./Select"; export { Placement } from "./Placement"; diff --git a/assets/snapline/svelte/README.md b/assets/snapline/svelte/README.md index d3f0074..b48f610 100644 --- a/assets/snapline/svelte/README.md +++ b/assets/snapline/svelte/README.md @@ -11,10 +11,11 @@ npm install @snap-engine/core @snap-engine/snapline \ ## Components -`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`, `Placement.svelte`, and `ControlledGraph.svelte`. +`Node`, `Group`, `ResizeRegion`, `Connector`, `Line`, `Select`, `Placement`, +and `ControlledGraph` are exported from the package root. Component subpaths +are also available as `Node.svelte`, `Group.svelte`, `ResizeRegion.svelte`, +`Connector.svelte`, `Line.svelte`, `Select.svelte`, `Placement.svelte`, and +`ControlledGraph.svelte`. ```svelte -{#if !virtual} +{#if children} + {@render children(bindConnectorElement)} +{:else}
void; + /** One atomic proposal per gesture. Return the list that should now be + * canonical — adopting a proposed id settles the line in place; returning + * the list unchanged rejects. Must be synchronous. */ + onLineChangeRequest: (request: LineChangeRequest) => readonly LineRecord[]; onDiagnosticsChanged?: ( diagnostics: readonly ReconciliationError[], ) => void; @@ -25,20 +23,25 @@ const engine: Engine = getContext("engine"); const handle = attachControlledGraph(engine, { - onLineChangeRequest: (request) => { - onLineChangeRequest(request); - // Guaranteed post-request push: reads the live prop after the app's - // synchronous document update, queued ahead of the decisive - // reconciliation pass so acceptance and rejection both resolve - // without timing inference. - queueMicrotask(() => handle.setCanonicalGraph({ lines })); - }, + onLineChangeRequest: (request) => onLineChangeRequest(request), onDiagnosticsChanged: (diagnostics) => onDiagnosticsChanged?.(diagnostics), }); - $effect(() => { + /** + * Push records that no originating request asked for: hydration/load, + * undo/redo, or a collaborator's edit. Reach it with `bind:this`. + * + * Gesture-driven changes need none of this — returning the list from + * `onLineChangeRequest` already delivers them. + */ + export function setLines(lines: readonly LineRecord[]): void { handle.setCanonicalGraph({ lines }); - }); + } + + /** Run any pending reconciliation synchronously (tests, imperative flows). */ + export function flush(): void { + handle.flush(); + } onDestroy(() => handle.dispose()); diff --git a/assets/snapline/svelte/src/Group.svelte b/assets/snapline/svelte/src/Group.svelte index 884c1b2..cdb02d1 100644 --- a/assets/snapline/svelte/src/Group.svelte +++ b/assets/snapline/svelte/src/Group.svelte @@ -1,7 +1,8 @@ + +
+ {@render children?.()} +
diff --git a/assets/snapline/svelte/src/index.ts b/assets/snapline/svelte/src/index.ts index 7831b53..a445150 100644 --- a/assets/snapline/svelte/src/index.ts +++ b/assets/snapline/svelte/src/index.ts @@ -1,4 +1,5 @@ export { default as Node } from "./Node.svelte"; +export { default as ResizeRegion } from "./ResizeRegion.svelte"; export { default as Group } from "./Group.svelte"; export { default as Connector } from "./Connector.svelte"; export { default as Line } from "./Line.svelte"; diff --git a/assets/snapline/svelte/src/resize-region-context.ts b/assets/snapline/svelte/src/resize-region-context.ts new file mode 100644 index 0000000..3739f08 --- /dev/null +++ b/assets/snapline/svelte/src/resize-region-context.ts @@ -0,0 +1,4 @@ +import type { NodeMirror } from "@snap-engine/snapline"; + +export const resizeRegionOwnerContext = Symbol("snapline-resize-region-owner"); +export type ResizeRegionOwner = NodeMirror; diff --git a/assets/snapsort/core/src/drag/flow-ghost.ts b/assets/snapsort/core/src/drag/flow-ghost.ts index 831b36c..d513671 100644 --- a/assets/snapsort/core/src/drag/flow-ghost.ts +++ b/assets/snapsort/core/src/drag/flow-ghost.ts @@ -362,7 +362,19 @@ async function startCopyHandoff(session: DragSession): Promise { throw error; } - session.handoff(cloneItems); + try { + session.handoff(cloneItems); + } catch (error) { + // Native capture can reject a destination that disappeared between the + // framework flush and the handoff. Retire those transient clones through + // framework state before propagating the input failure. + for (const { clone, container } of boundClones) { + fireItemRemove(container, [clone], session); + } + await settleMutation(); + for (const clone of cloneItems) clone.destroy(false); + throw error; + } return true; } diff --git a/assets/snapsort/core/src/drag/session.ts b/assets/snapsort/core/src/drag/session.ts index 2e40b2a..31be4e5 100644 --- a/assets/snapsort/core/src/drag/session.ts +++ b/assets/snapsort/core/src/drag/session.ts @@ -1,5 +1,9 @@ import type { AnimationObject } from "@snap-engine/core/animation"; -import type { dragStartProp, dragProp } from "@snap-engine/core"; +import type { + GestureHandoffControl, + dragStartProp, + dragProp, +} from "@snap-engine/core"; import type { Container } from "../container"; import type { Item } from "../item"; import { @@ -55,8 +59,9 @@ function reportDragSessionError(error: unknown): void { */ export class DragSession { readonly root: Container; - /** Pointer id driving this drag (from `dragStartProp`). Needed to transfer pointer ownership on `handoff`. */ + /** Pointer id driving this drag (from `dragStartProp`). */ readonly pointerId: number; + readonly #handoffTo: GestureHandoffControl["handoffTo"]; /** * The items being dragged. Stable for the whole gesture EXCEPT across a * `handoff` (copy), which replaces the originals with freshly-created clone @@ -162,6 +167,7 @@ export class DragSession { this.itemSet = new Set(items); this.strategy = strategy; this.pointerId = prop.pointerId; + this.#handoffTo = prop.handoffTo; this.start = { x: prop.start.x, y: prop.start.y }; this.pointer = { x: prop.start.x, y: prop.start.y }; } @@ -187,6 +193,7 @@ export class DragSession { } const origins = this.items; const pressedIndex = origins.indexOf(this.pressedItem); + const nextPressedItem = clones[pressedIndex === -1 ? 0 : pressedIndex]; // Each clone reuses its original's frozen drag snapshot for geometry — the // clone visually replaces the original at the pointer, so "as if dragging @@ -195,16 +202,13 @@ export class DragSession { clone.adoptDragSnapshotFrom(origins[i]); }); + // Transfer input first. A rejected destination leaves this session owned + // by the originals instead of exposing a half-switched copy lifecycle. + this.#handoffTo(nextPressedItem); this.handoffOrigins = origins; this.items = clones; this.itemSet = new Set(clones); - this.pressedItem = clones[pressedIndex === -1 ? 0 : pressedIndex]; - - // Retarget the input pointer so drag/dragEnd now dispatch to the clone. - this.root.engine.input.setPointerDragOwner( - this.pointerId, - this.pressedItem, - ); + this.pressedItem = nextPressedItem; } /** The run head — lowest original index, first element of `items`. Used as the singular `item` in backwards-compatible event fields. */ @@ -254,6 +258,7 @@ export class DragSession { item.schedule( () => { + if (this.#isEnded()) return; this.pointer = { x: prop.start.x, y: prop.start.y }; this.start = { x: prop.start.x, y: prop.start.y }; this.offset = { @@ -269,6 +274,7 @@ export class DragSession { item.schedule( async () => { + if (this.#isEnded()) return; const primary = this.primaryItem; let vetoed = false; try { @@ -348,9 +354,18 @@ export class DragSession { #cancelAfterError(error: unknown): void { reportDragSessionError(error); + this.cancel(); + } + + /** @internal Unwinds an input-cancelled drag without committing a drop. */ + cancel(): void { if (this.status === "dropping" || this.status === "ended") return; this.cancelled = true; + if (this.status === "pending") { + this.#clearSessionState(); + return; + } this.status = "dropping"; this.dragTransformSyncAnimation?.cancel(); this.dragTransformSyncAnimation = null; @@ -491,9 +506,7 @@ export class DragSession { this.dropTarget = null; await lifecycle.removeGhost(this, "target"); this.#invalidateVisualGeometry( - previousGhostLocation - ? [previousGhostLocation.container] - : [], + previousGhostLocation ? [previousGhostLocation.container] : [], "ghost", ); this.fireDropTargetChange(previousGhostLocation, null); diff --git a/assets/snapsort/core/src/item.ts b/assets/snapsort/core/src/item.ts index c0e3122..c3074a2 100644 --- a/assets/snapsort/core/src/item.ts +++ b/assets/snapsort/core/src/item.ts @@ -1690,6 +1690,7 @@ export class Item extends ElementObject { * @returns */ dragStart(prop: dragStartProp) { + if (prop.objectId !== this.id) return; if (this.#locked) return; // Take a snapshot of the current state. @@ -1742,20 +1743,27 @@ export class Item extends ElementObject { * @param prop Drag position property containing mouse coordinates. */ drag(prop: dragProp) { + if (prop.objectId !== this.id) return; const session = this.rootContainer.dragSession; if (!session || session.status !== "active") return; session.pointerMove(prop); } dragEnd(prop: dragEndProp) { + if (prop.objectId !== this.id) return; const session = this.rootContainer.dragSession; - if (!session || session.status !== "active") { + if (!session) { // Defensive cleanup in case a veto or stale session left visual state // behind (e.g. onDragStart returned false after the dataset flag was // set on a previous, unrelated gesture). if (this.element) delete this.element.dataset.snapsortDragging; return; } + if (prop.cancelled || session.status === "pending") { + session.cancel(); + return; + } + if (session.status !== "active") return; session.status = "dropping"; session.dragTransformSyncAnimation?.cancel(); session.dragTransformSyncAnimation = null; diff --git a/demo/react/src/App.jsx b/demo/react/src/App.jsx index b3a9c58..008c773 100644 --- a/demo/react/src/App.jsx +++ b/demo/react/src/App.jsx @@ -3,9 +3,11 @@ import { ControlledGraph, Group, Node, + ResizeRegion, Select, } from "@snap-engine/snapline-react"; -import { useCallback, useState } from "react"; +import { applyLineChange, RESIZE_HANDLES } from "@snap-engine/snapline"; +import { useCallback, useRef, useState } from "react"; import { Engine as SnapEngine } from "@snap-engine/asset-base-react"; import { DropSnapNestedDemo, @@ -20,21 +22,16 @@ import AssetBaseReactDemo from "./AssetBaseReactDemo"; // The demo's canonical line document: topology is always controlled, so // even a sandbox owns its lines and accepts every atomic proposal. function DemoGraph() { - const [lines, setLines] = useState([]); - const applyRequest = useCallback((request) => { - setLines((current) => [ - ...current - .filter((record) => !request.remove.includes(record.id)) - .map((record) => { - const update = request.update.find((u) => u.id === record.id); - return update - ? { ...record, toConnectorId: update.toConnectorId } - : record; - }), - ...request.add, - ]); - }, []); - return ; + // The handler must return the next list synchronously, so the document lives + // in a ref: a functional setState would not have produced it in time. Nothing + // here renders the records, so no component state is needed at all. + const linesRef = useRef([]); + const applyRequest = useCallback( + (request) => + (linesRef.current = applyLineChange(linesRef.current, request)), + [], + ); + return ; } function ResizableNode({ title, id = title, x, y }) { @@ -43,7 +40,6 @@ function ResizableNode({ title, id = title, x, y }) { className="card node" x={x} y={y} - resizable minWidth={140} minHeight={90} style={{ display: "flex", flexDirection: "column" }} @@ -54,21 +50,82 @@ function ResizableNode({ title, id = title, x, y }) {
- +
Input
Output
- +
+ ); } +function ResizeRegions({ handles = RESIZE_HANDLES }) { + const thickness = 14; + return handles.map((handle) => { + const north = handle.startsWith("n"); + const south = handle.startsWith("s"); + const east = handle.endsWith("e"); + const west = handle.endsWith("w"); + const corner = handle.length === 2; + return ( + + ); + }); +} + function SnapLineResizeDemo() { return (
@@ -110,7 +167,9 @@ function SnapLineGroupDemo() { borderRadius: "8px", }} onMembershipChange={updateMembers} - /> + > + + @@ -129,14 +188,26 @@ function SimpleNode({ title, id = title, x, y }) {
- +
Input
Output
- +
@@ -152,24 +223,15 @@ export default function App() { return ; } - if ( - path === "/snapsort-insertion" || - demo === "snapsort_insertion" - ) { + if (path === "/snapsort-insertion" || demo === "snapsort_insertion") { return ; } - if ( - path === "/snapsort-website-core" || - demo === "snapsort_website_core" - ) { + if (path === "/snapsort-website-core" || demo === "snapsort_website_core") { return ; } - if ( - path === "/snapsort-components" || - demo === "snapsort_components" - ) { + if (path === "/snapsort-components" || demo === "snapsort_components") { return ; } @@ -215,7 +277,11 @@ function GraphNode({ nodeId, title, x, y, maxIncoming = 1 }) {
Input @@ -239,22 +305,34 @@ function SnapLineEdgesDemo() { const [lines, setLines] = useState([]); const [connectIntents, setConnectIntents] = useState(0); const [intentLog, setIntentLog] = useState([]); + // The document also drives rendering (edge-count), so it is mirrored into a + // ref the synchronous handler can read. + const linesRef = useRef(lines); + const graphRef = useRef(null); - const addDocLine = (record) => - setLines((current) => - current.some((existing) => existing.id === record.id) - ? current - : [ - ...current.filter( - (existing) => existing.toConnectorId !== record.toConnectorId, - ), - record, - ], - ); + const commit = useCallback((next) => { + linesRef.current = next; + setLines(next); + return next; + }, []); - const handleRequest = useCallback((request) => { - const nodeOf = (connectorId) => connectorId.split(":")[0]; - setLines((current) => { + // No originating request, so this one has to be pushed through the handle. + const addDocLine = (record) => { + const current = linesRef.current; + if (current.some((existing) => existing.id === record.id)) return; + const next = commit([ + ...current.filter( + (existing) => existing.toConnectorId !== record.toConnectorId, + ), + record, + ]); + graphRef.current?.setCanonicalGraph({ lines: next }); + }; + + const handleRequest = useCallback( + (request) => { + const nodeOf = (connectorId) => connectorId.split(":")[0]; + const current = linesRef.current; const byId = new Map(current.map((record) => [record.id, record])); const label = (record) => `${nodeOf(record.fromConnectorId)}->${nodeOf(record.toConnectorId)}`; @@ -265,7 +343,10 @@ function SnapLineEdgesDemo() { const updated = request.update .map((update) => byId.has(update.id) - ? label({ ...byId.get(update.id), toConnectorId: update.toConnectorId }) + ? label({ + ...byId.get(update.id), + toConnectorId: update.toConnectorId, + }) : update.id, ) .join(","); @@ -281,19 +362,10 @@ function SnapLineEdgesDemo() { if (request.add.length > 0) setConnectIntents((count) => count + 1); // Accept the atomic proposal — adopting the proposed ids settles the // staged lines in place. - return [ - ...current - .filter((record) => !request.remove.includes(record.id)) - .map((record) => { - const update = request.update.find((u) => u.id === record.id); - return update - ? { ...record, toConnectorId: update.toConnectorId } - : record; - }), - ...request.add, - ]; - }); - }, []); + return commit(applyLineChange(current, request)); + }, + [commit], + ); return (
@@ -318,9 +390,15 @@ function SnapLineEdgesDemo() {
- + (lines = applyLineChange(lines, request))} + /> Source @@ -65,7 +57,7 @@ npm install react react-dom @snap-engine/snapline-react ``` ```tsx framework=react -import { useCallback, useState } from "react"; +import { useCallback, useRef } from "react"; import { Connector, ControlledGraph, @@ -73,28 +65,24 @@ import { Node, Select, } from "@snap-engine/snapline-react"; +import { applyLineChange } from "@snap-engine/snapline"; import type { LineChangeRequest, LineRecord } from "@snap-engine/snapline"; export function Graph() { // Your document owns the lines; each gesture proposes one atomic change. - const [lines, setLines] = useState([]); - const applyRequest = useCallback((request: LineChangeRequest) => { - setLines((current) => [ - ...current - .filter((record) => !request.remove.includes(record.id)) - .map((record) => { - const update = request.update.find((u) => u.id === record.id); - return update - ? { ...record, toConnectorId: update.toConnectorId } - : record; - }), - ...request.add, - ]); - }, []); + // The handler must return the next list synchronously, so keep the records + // in a ref (a functional setState would not have produced it in time) and + // mirror them into component state only if you render them. + const linesRef = useRef([]); + const applyRequest = useCallback( + (request: LineChangeRequest) => + (linesRef.current = applyLineChange(linesRef.current, request)), + [], + ); return ( - One - Two - Three + One{@render resizeRegions()} + Two{@render resizeRegions()} + Three{@render resizeRegions()} {:else if mode === "groups"} @@ -219,6 +215,40 @@ min-height: 54px; } + :global(.doc-resize-region) { + --resize-size: 12px; + position: absolute; + } + :global(.doc-resize-region[data-handle="n"]), + :global(.doc-resize-region[data-handle="s"]) { + left: var(--resize-size); + right: var(--resize-size); + height: var(--resize-size); + cursor: ns-resize; + } + :global(.doc-resize-region[data-handle="e"]), + :global(.doc-resize-region[data-handle="w"]) { + top: var(--resize-size); + bottom: var(--resize-size); + width: var(--resize-size); + cursor: ew-resize; + } + :global(.doc-resize-region[data-handle^="n"]) { top: -6px; } + :global(.doc-resize-region[data-handle^="s"]) { bottom: -6px; } + :global(.doc-resize-region[data-handle$="e"]) { right: -6px; } + :global(.doc-resize-region[data-handle$="w"]) { left: -6px; } + :global(.doc-resize-region[data-handle="ne"]), + :global(.doc-resize-region[data-handle="se"]), + :global(.doc-resize-region[data-handle="sw"]), + :global(.doc-resize-region[data-handle="nw"]) { + width: var(--resize-size); + height: var(--resize-size); + } + :global(.doc-resize-region[data-handle="ne"]), + :global(.doc-resize-region[data-handle="sw"]) { cursor: nesw-resize; } + :global(.doc-resize-region[data-handle="nw"]), + :global(.doc-resize-region[data-handle="se"]) { cursor: nwse-resize; } + :global(.doc-node[data-selected="true"]) { outline: 3px solid color-mix(in srgb, var(--color-primary) 38%, transparent); outline-offset: 3px;