Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion SNAPZEN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
143 changes: 109 additions & 34 deletions assets/snapline/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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/`
Expand All @@ -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
Expand All @@ -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.

Expand All @@ -64,18 +99,22 @@ 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
│ └── src/
│ ├── index.ts
│ ├── Node.svelte
│ ├── Group.svelte
│ ├── ResizeRegion.svelte
│ ├── Connector.svelte
│ ├── Line.svelte
│ ├── Select.svelte
Expand All @@ -89,6 +128,7 @@ snapline/
├── Engine.tsx
├── Node.tsx
├── Group.tsx
├── ResizeRegion.tsx
├── Connector.tsx
├── Line.tsx
├── Select.tsx
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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.
Expand All @@ -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
Expand All @@ -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`,
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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)

Expand Down Expand Up @@ -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.
Expand Down
45 changes: 21 additions & 24 deletions assets/snapline/core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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";
Expand All @@ -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
Expand All @@ -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
Expand Down
12 changes: 1 addition & 11 deletions assets/snapline/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading
Loading