@luispm/zflow-graph 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Luis G.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,287 @@
1
+ # @luispm/zflow-graph
2
+
3
+ > WASM-powered node-edge graph editor + execution runtime. No framework. 100k nodes at 60 fps. Built-in multiplayer, WebGL, sub-flows.
4
+
5
+ [![npm version](https://img.shields.io/npm/v/@luispm/zflow-graph.svg)](https://www.npmjs.com/package/@luispm/zflow-graph)
6
+ [![license](https://img.shields.io/npm/l/@luispm/zflow-graph.svg)](LICENSE)
7
+
8
+ Most graph libraries make you choose:
9
+
10
+ - React Flow looks polished but can't pass ~5k nodes and depends on React
11
+ - ComfyUI runs flows but isn't a library
12
+ - Drawio is the size of a small OS
13
+ - tldraw is great at sketches but doesn't know what a port is
14
+
15
+ **@luispm/zflow-graph picks none of those tradeoffs.** It is a single self-contained ES module backed by a Zig→WASM core that ships in ~200 KB and runs in any browser tab, Electron, or Tauri-style desktop shell. It is an editor *and* a runtime.
16
+
17
+ ## Quick start
18
+
19
+ ```bash
20
+ npm install @luispm/zflow-graph
21
+ ```
22
+
23
+ ```js
24
+ import { ZFlow } from '@luispm/zflow-graph';
25
+
26
+ const flow = await ZFlow.create({
27
+ container: document.getElementById('app'),
28
+ wasmUrl: '/node_modules/@luispm/zflow-graph/dist/zflow.wasm',
29
+ });
30
+
31
+ // Register a kind with an executable body.
32
+ flow.registerKind({
33
+ name: 'double',
34
+ nin: 1, nout: 1,
35
+ portIn: ['value'], portOut: ['value'],
36
+ execute: (ctx, ins) => ({ value: ins.value * 2 }),
37
+ });
38
+
39
+ const a = flow.addNode({ kind: 'input', x: -200, y: 0, title: '21' });
40
+ const b = flow.addNode({ kind: 'double', x: 0, y: 0 });
41
+ const c = flow.addNode({ kind: 'output', x: 200, y: 0, title: 'Result' });
42
+
43
+ flow.addEdge({ from: a, to: b });
44
+ flow.addEdge({ from: b, to: c });
45
+
46
+ flow.setNodeInput(a, { value: 21 });
47
+ await flow.run(); // → c.value === 42
48
+ console.log(flow.getNodeValue(c));
49
+ ```
50
+
51
+ ## Why use it
52
+
53
+ | Capability | @luispm/zflow-graph | React Flow | tldraw | Drawio |
54
+ | --------------------------------------- | :---------: | :--------: | :----: | :----: |
55
+ | WASM core (no framework) | ✅ | ❌ | ❌ | ❌ |
56
+ | 100k nodes @ 60 fps | ✅ | ❌ | ⚠️ | ❌ |
57
+ | Built-in graph execution runtime | ✅ | ❌ | ❌ | ❌ |
58
+ | Real CRDT multiplayer (Yjs adapter) | ✅ | ❌ (Pro) | ✅ | ❌ |
59
+ | WebGL renderer (instanced, opt-in) | ✅ | ❌ | ✅ | ❌ |
60
+ | Touch + pinch + pen | ✅ | ✅ | ✅ | ⚠️ |
61
+ | Sub-flows reusable as kinds | ✅ | ❌ (Pro) | ❌ | ❌ |
62
+ | Streaming async generator nodes | ✅ | ❌ | ❌ | ❌ |
63
+ | Inline expressions `{{node_X.value}}` | ✅ | ❌ | ❌ | ❌ |
64
+ | Schema type validation on edges | ✅ | ✅ | ❌ | ❌ |
65
+ | Plugin lifecycle hooks | ✅ | ⚠️ React | ❌ | ✅ |
66
+ | Mermaid + DOT import | ✅ | ❌ | ❌ | ✅ |
67
+ | Critical-path / SCC / cycles | ✅ | ❌ | ❌ | ❌ |
68
+ | Bundle gz | ~140 KB | 180 KB | 320 KB | 1.1 MB |
69
+ | Framework dep | None | React | React | none |
70
+
71
+ ## Architecture in 30 seconds
72
+
73
+ ```
74
+ ┌─────────────────────────────────────────────────────────┐
75
+ │ Your app │
76
+ │ ├─ import { ZFlow } from '@luispm/zflow-graph' │
77
+ │ └─ flow.addNode(), flow.run(), flow.on(...) │
78
+ ├─────────────────────────────────────────────────────────┤
79
+ │ zflow.js (~180 KB ES module, no deps) │
80
+ │ Canvas2D renderer ◄─ overlay text/UI ─┐ │
81
+ │ WebGL renderer (opt-in, instanced) ◄──┘ │
82
+ │ Runtime: topo, async, retry, memo, streaming, debug │
83
+ │ Plugin lifecycle · Yjs adapter · expression evaluator │
84
+ ├─────────────────────────────────────────────────────────┤
85
+ │ zflow.wasm (~740 KB Zig WASM) │
86
+ │ SoA storage · spatial grid · snapshot undo │
87
+ │ Sugiyama + force layouts · SCC + critical-path │
88
+ │ Zero-copy Float32/Uint32 views into linear memory │
89
+ └─────────────────────────────────────────────────────────┘
90
+ ```
91
+
92
+ The JS holds typed-array **views** over WASM linear memory — there is no copy on read. The WASM never grows its memory after init, so views stay valid forever.
93
+
94
+ ## Concepts
95
+
96
+ ### Kinds
97
+ A kind is the type of a node: its color, shape, ports, and optionally its executable body.
98
+
99
+ ```js
100
+ flow.registerKind({
101
+ name: 'http-request',
102
+ color: '#5b8def', badge: 'H', w: 180, h: 70,
103
+ inputs: [{ name: 'url', type: 'string' }],
104
+ outputs: [{ name: 'body', type: 'string' }],
105
+ retry: { n: 3, delay: 500 },
106
+ execute: async (ctx, ins) => {
107
+ ctx.setProgress(0.3);
108
+ const res = await fetch(ins.url, { signal: ctx.signal });
109
+ return { body: await res.text() };
110
+ },
111
+ });
112
+ ```
113
+
114
+ ### The runtime
115
+ Calling `flow.run()` walks the graph in topological order, calling each node's `execute(ctx, inputs)`. Outputs flow through edges. The runtime supports:
116
+
117
+ - **Async** — `execute` may return a `Promise`
118
+ - **Streaming** — `execute` may return an `AsyncGenerator` that `yield`s multiple values
119
+ - **Retry** — declarative `retry: { n, delay }`
120
+ - **Memoization** — `flow.setMemoization(true)` skips nodes whose inputs hash matches the previous run
121
+ - **Abort** — `flow.stop()` propagates an `AbortSignal` to every `ctx.signal`
122
+ - **Breakpoints** — `flow.setBreakpoint(id)` pauses before exec; `flow.stepOver()` to advance
123
+
124
+ ### Multiplayer (Yjs)
125
+ Real-time co-editing via [Yjs](https://github.com/yjs/yjs). The adapter is opt-in — `yjs` is **not** a runtime dependency unless you import the adapter.
126
+
127
+ ```js
128
+ import { bindYjs } from '@luispm/zflow-graph/adapters/yjs';
129
+ import * as Y from 'yjs';
130
+ import { WebsocketProvider } from 'y-websocket';
131
+
132
+ const ydoc = new Y.Doc();
133
+ const provider = new WebsocketProvider('wss://demos.yjs.dev/ws', 'my-room', ydoc);
134
+ bindYjs(flow, ydoc, {
135
+ userName: 'Alice',
136
+ color: '#c062e8',
137
+ awareness: provider.awareness,
138
+ });
139
+ ```
140
+
141
+ Open two tabs of your app: nodes, edges, drags, and cursors sync at ~30 Hz.
142
+
143
+ ### Performance: opt-in WebGL
144
+ For graphs beyond ~5k nodes, enable the WebGL renderer:
145
+
146
+ ```js
147
+ await flow.enableWebGL(); // auto-enables past options.webglThreshold (default 2000)
148
+ ```
149
+
150
+ The GL path uses `ANGLE_instanced_arrays` to paint every node body in a **single draw call**. Canvas2D continues to handle text, ports, badges, and UI on top. Pan/zoom is uniform-only — zero buffer uploads.
151
+
152
+ ### Sub-flows as kinds
153
+ Wrap a group of nodes inside a frame, then turn that frame into a reusable kind:
154
+
155
+ ```js
156
+ const frameId = flow.addFrame(0, 0, 400, 200, 'auth pipeline').id;
157
+ // ... add nodes inside the frame ...
158
+ const kindName = flow.registerSubflowFromFrame(frameId, { name: 'authPipe' });
159
+
160
+ // Now you can instantiate the whole sub-flow as a single node anywhere:
161
+ flow.addNode({ kind: 'authPipe', x: 600, y: 100 });
162
+ ```
163
+
164
+ The library auto-detects inputs and outputs of the sub-flow based on which inner nodes lack inside-graph predecessors / successors.
165
+
166
+ ## API at a glance
167
+
168
+ ```js
169
+ // Lifecycle
170
+ const flow = await ZFlow.create({ container, wasmUrl });
171
+ flow.dispose();
172
+
173
+ // Mutation
174
+ flow.addNode(spec) / addEdge(spec) / deleteSelection() / moveNode(id, x, y)
175
+ flow.addNodesBulk(specs) / addEdgesBulk(specs) // batch (50k nodes in ~50ms)
176
+
177
+ // Selection
178
+ flow.setSelected(id, on) / toggleSelected(id) / clearSelection() / selectAll()
179
+ flow.getSelection()
180
+
181
+ // Rich content
182
+ flow.setNodeTitle / Description / Color / Tags / Status / Progress
183
+ flow.setNodeImage / Checked / Tasks / Icon / Links
184
+
185
+ // Runtime
186
+ flow.registerKind({ name, execute, retry, inputs, outputs, ... })
187
+ flow.run({ from?, filter?, signal? })
188
+ flow.runFrom(nodeId) / runFrame(frameId)
189
+ flow.stop() / startLoop(ms) / stopLoop()
190
+ flow.setBreakpoint(id) / stepOver() / resume() / isPaused()
191
+ flow.setNodeInput(id, value) / getNodeValue(id)
192
+ flow.setNodeParams(id, params) // for built-in kinds: const, if
193
+ flow.evalExpression('{{node_3.value}} * 2')
194
+
195
+ // Algorithms
196
+ flow.shortestPath(from, to) / criticalPath() / findSCCs() / findCycles()
197
+
198
+ // Serialization
199
+ flow.toJSON() / loadJSON(data) / exportSVG() / exportPNG()
200
+
201
+ // Imports
202
+ flow.importMermaid(text) / importDot(text)
203
+
204
+ // Layout
205
+ flow.runAutoLayout() / runForceLayout() / fitView() / zoomTo() / panTo()
206
+
207
+ // Plugins
208
+ flow.use({ init, onNodeAdd, onBeforeExec, ... })
209
+
210
+ // Multiplayer (separate import)
211
+ import { bindYjs } from '@luispm/zflow-graph/adapters/yjs'
212
+
213
+ // Performance
214
+ await flow.enableWebGL() / disableWebGL()
215
+ flow.setMemoization(true)
216
+ ```
217
+
218
+ ## Documentation
219
+
220
+ Full guides in [`docs/`](./docs):
221
+ 1. [Getting Started](./docs/01-getting-started.md) — first 15 minutes
222
+ 2. [The Runtime](./docs/02-runtime.md) — make your graph actually compute
223
+ 3. [Designing Kinds](./docs/03-kinds.md) — schemas, ports, async, streaming
224
+ 4. [Performance at Scale](./docs/04-performance.md) — WebGL, bulk, LOD, 100k nodes
225
+ 5. [Plugin System](./docs/05-plugins.md) — lifecycle hooks
226
+ 6. [Multiplayer (Yjs)](./docs/06-multiplayer.md) — real-time co-editing
227
+ 7. [Recipes](./docs/07-recipes.md) — paste-and-run examples
228
+ 8. [API Reference](./docs/08-api.md) — every method, every event
229
+
230
+ See the [examples folder](./examples) for working demos:
231
+ - `basic.html` — 3-node minimum
232
+ - `custom-kinds.html` — Plugin API
233
+ - `showcase.html` — full feature parade
234
+ - `runtime.html` — graph execution live
235
+ - `multiplayer.html` — Yjs CRDT in two tabs
236
+ - `powers.html` — schema validation + touch + WebGL
237
+ - `plugins-and-debug.html` — lifecycle hooks + breakpoints + sub-flows
238
+ - `stress.html` — 50k+ nodes WebGL benchmark
239
+
240
+ ## Loading WASM
241
+
242
+ By default, `ZFlow.create({ wasmUrl })` fetches the WASM. For inline / offline scenarios, pre-load and pass bytes:
243
+
244
+ ```js
245
+ const wasmBytes = await fetch('./zflow.wasm').then(r => r.arrayBuffer());
246
+ const flow = await ZFlow.create({ container, wasmBytes });
247
+ ```
248
+
249
+ If you bundle, copy `node_modules/@luispm/zflow-graph/dist/zflow.wasm` to your `public/` or static-asset directory and point `wasmUrl` at it.
250
+
251
+ ## Limits
252
+
253
+ - Hard cap of 100,000 nodes / 200,000 edges per instance (compile-time in the WASM core)
254
+ - Spatial grid covers ±8192 world units; nodes outside this range are still selectable but not in `queryRect` results
255
+ - Snapshot-based undo keeps the last 8 states — large graphs make snapshots costly
256
+ - The WebGL renderer requires `ANGLE_instanced_arrays` (essentially every browser since 2014); falls back to per-node draws otherwise
257
+ - Yjs adapter sync rate is throttled to 30 Hz on position changes
258
+
259
+ ## Building from source
260
+
261
+ You need Zig 0.16+ and Node 18+.
262
+
263
+ ```bash
264
+ git clone https://github.com/luisg/@luispm/zflow-graph
265
+ cd @luispm/zflow-graph
266
+ npm install
267
+ zig build # produces dist/zflow.wasm
268
+ npm run build:js # produces dist/zflow.{esm,umd}{,.min}.js
269
+ npm test # 47 tests across 6 files
270
+ ```
271
+
272
+ ## Security
273
+
274
+ See [SECURITY.md](./SECURITY.md) for the honest threat model. TL;DR:
275
+
276
+ - All user-controlled strings in DOM overlays are HTML-escaped to prevent XSS.
277
+ - Zero runtime dependencies. Yjs is opt-in.
278
+ - `flow.use(plugin)` and `kind.execute` run with full page privileges — only install plugins you trust.
279
+ - `evalExpression()` uses `new Function` — do not pass expressions from untrusted users.
280
+ - Client-side JavaScript is **always readable**. There is no technical way to hide it. Use a license, or move sensitive logic to a server.
281
+ - Minified bundles ship **without sourcemaps** to avoid leaking the source to CDN deployments.
282
+
283
+ To report a vulnerability: `luis.padre21@gmail.com` with `[@luispm/zflow-graph security]` in subject.
284
+
285
+ ## License
286
+
287
+ MIT
package/SECURITY.md ADDED
@@ -0,0 +1,67 @@
1
+ # Security model
2
+
3
+ ## What zflow-graph is
4
+
5
+ A **client-side** ES module + WASM that renders and executes node graphs. It runs entirely in the browser (or any embedded WebView). It has no server component, no network calls of its own, and no privileged APIs.
6
+
7
+ ## What zflow-graph is **not**
8
+
9
+ - Not a sandbox for untrusted code. Functions passed to `kind.execute` run with **the same privileges as your page**.
10
+ - Not a secret store. Anything you put in `flow.toJSON()`, including `setNodeParams`, is visible in the browser.
11
+ - Not obfuscated. Minified JS is readable in 2026 by any LLM in seconds. We do not pretend otherwise.
12
+
13
+ ## Threat model
14
+
15
+ | Threat | Mitigated by zflow? | Notes |
16
+ | ----------------------------------------------------- | :-----------------: | ----- |
17
+ | XSS via user-controlled node titles / descriptions | ✅ | All user strings rendered inside DOM overlays (preview popover, expression editor, context menu, autocomplete) are escaped with an internal `escapeHtml`. Canvas2D rendering is inherently text-safe (it's pixels). |
18
+ | Code IP theft (someone reads your bundle) | ❌ | **Impossible to fully prevent on the web.** Use license (MIT/GPL/proprietary) for legal recourse. Move sensitive logic to a server you control. |
19
+ | Supply-chain attack via `npm install zflow-graph` | ✅ | This package has **zero runtime dependencies**. Yjs is opt-in and only loaded if you import the adapter explicitly. Pin versions and audit with `npm audit`. |
20
+ | Untrusted plugin via `flow.use(plugin)` | ❌ | Plugins run with full access to the flow and the host page. **Only install plugins you trust.** Treat them like NPM packages. |
21
+ | Untrusted `kind.execute` body | ❌ | Same as above — anything in `execute` runs with page privileges. Don't load executors from user input. |
22
+ | Untrusted Mermaid / DOT / JSON imports | ✅ | The parsers are string-based — no `eval` or `new Function`. Worst case is malformed graph data. |
23
+ | Untrusted `evalExpression` input | ⚠️ | `evalExpression` uses `new Function` internally. **Do not pass expressions from untrusted users.** Use it for editor templates, not for user-submitted expressions. |
24
+ | CSP (Content Security Policy) compatibility | ⚠️ | `evalExpression` requires `unsafe-eval` because of `new Function`. If your CSP forbids it, disable expression evaluation or fork to use a safe expression parser. |
25
+ | WASM integrity | ✅ | The WASM is loaded by URL or pre-fetched bytes. If you want SRI-style integrity, hash the file at build time and verify before `WebAssembly.instantiate`. |
26
+
27
+ ## Recommended host-side hardening
28
+
29
+ If you embed zflow-graph in an app, **the surrounding security work is your responsibility**:
30
+
31
+ ```http
32
+ Content-Security-Policy:
33
+ default-src 'self';
34
+ script-src 'self' 'wasm-unsafe-eval'; # WASM needs wasm-unsafe-eval
35
+ worker-src 'self' blob:;
36
+ connect-src 'self' wss://your-yjs-relay.example;
37
+ img-src 'self' data: https:; # if you setNodeImage with external URLs
38
+ ```
39
+
40
+ Notes:
41
+ - `wasm-unsafe-eval` is required to load `zflow.wasm`.
42
+ - `unsafe-eval` is required if you call `evalExpression()`. If you don't, leave it out.
43
+ - For Subresource Integrity of the WASM:
44
+ ```js
45
+ const buf = await fetch('zflow.wasm').then(r => r.arrayBuffer());
46
+ const hash = await crypto.subtle.digest('SHA-256', buf);
47
+ if (toHex(hash) !== EXPECTED_HASH) throw new Error('WASM tampered');
48
+ const flow = await ZFlow.create({ container, wasmBytes: new Uint8Array(buf) });
49
+ ```
50
+
51
+ ## Sourcemaps
52
+
53
+ - Sourcemaps are **only emitted for the non-minified bundles** (`*.esm.js`, `*.umd.js`).
54
+ - The minified bundles (`*.min.js`) ship **without sourcemaps** so accidentally deploying `dist/` to a CDN does not expose the full original source.
55
+ - To opt in to maps on minified builds for your own debugging, run `ZFLOW_MAPS=1 npm run build:js`.
56
+
57
+ ## Reporting a vulnerability
58
+
59
+ Email **luis.padre21@gmail.com** with `[zflow-graph security]` in the subject. Please do not open public issues for security reports.
60
+
61
+ We aim to acknowledge within 72 hours. Critical issues get a patch within 7 days.
62
+
63
+ ## What we will *not* do
64
+
65
+ - Add code obfuscation. It is performance overhead with no real security benefit. If you need IP protection, keep that code on a server.
66
+ - Add a "license check" that calls home. zflow-graph is MIT — once you have it, you have it.
67
+ - Promise that your secrets stay secret if you ship them in `flow.toJSON()`. They won't. Use a server.
@@ -0,0 +1,238 @@
1
+ /*! @luispm/zflow-graph v0.1.0 | MIT | (c) 2026 */
2
+ // zflow ↔ Yjs adapter — real multiplayer for the graph.
3
+ //
4
+ // Usage:
5
+ // import { ZFlow } from '../zflow.js';
6
+ // import { bindYjs } from '../adapters/yjs.js';
7
+ // import * as Y from 'yjs';
8
+ // import { WebsocketProvider } from 'y-websocket';
9
+ //
10
+ // const flow = await ZFlow.create({ container, wasmUrl });
11
+ // const ydoc = new Y.Doc();
12
+ // new WebsocketProvider('wss://demos.yjs.dev', 'my-room', ydoc);
13
+ // const binding = bindYjs(flow, ydoc, { userId: 'alice', userName: 'Alice', color: '#c062e8' });
14
+ //
15
+ // What it syncs:
16
+ // • Nodes (Y.Map keyed by stable client-side uuid → { id, kind, x, y, w, h, title, color, ... })
17
+ // • Edges (Y.Map keyed by uuid → { from, to, fp, tp, label })
18
+ // • Awareness (cursor position, selection, name, color)
19
+ //
20
+ // Conflict policy: last-write-wins per field via Y.Map. Position updates are
21
+ // throttled to ~30 Hz so dragging produces ~smooth remote motion without
22
+ // flooding the wire.
23
+
24
+
25
+ function bindYjs(flow, ydoc, opts = {}) {
26
+ const userId = opts.userId || 'user-' + Math.random().toString(36).slice(2, 8);
27
+ const userName = opts.userName || userId;
28
+ const userColor = opts.color || pickColor(userId);
29
+
30
+ const ynodes = ydoc.getMap('zflow.nodes');
31
+ const yedges = ydoc.getMap('zflow.edges');
32
+ const ymeta = ydoc.getMap('zflow.meta');
33
+ const aware = opts.awareness || null; // y-protocols/awareness.Awareness, if provided
34
+
35
+ // Bidirectional mapping between local numeric ids and stable Y uuids.
36
+ const localToUuid = new Map(); // nodeId -> uuid
37
+ const uuidToLocal = new Map(); // uuid -> nodeId
38
+ const edgeLocalToUuid = new Map();
39
+ const edgeUuidToLocal = new Map();
40
+
41
+ let applyingRemote = false; // re-entrancy guard
42
+ let pendingPosFlush = null; // throttle handle
43
+
44
+ // ── Local → Remote ─────────────────────────────────────────────────
45
+ // We intercept the high-level mutators by wrapping the WASM exports so
46
+ // every change locally also writes to Yjs.
47
+ const origAddNode = flow.addNode.bind(flow);
48
+ flow.addNode = (spec = {}) => {
49
+ const id = origAddNode(spec);
50
+ if (id < 0 || applyingRemote) return id;
51
+ const uuid = newUuid();
52
+ localToUuid.set(id, uuid);
53
+ uuidToLocal.set(uuid, id);
54
+ ynodes.set(uuid, captureNode(flow, id, spec));
55
+ return id;
56
+ };
57
+
58
+ const origAddEdge = flow.addEdge.bind(flow);
59
+ flow.addEdge = (spec = {}) => {
60
+ const id = origAddEdge(spec);
61
+ if (id < 0 || applyingRemote) return id;
62
+ const uuid = newUuid();
63
+ edgeLocalToUuid.set(id, uuid);
64
+ edgeUuidToLocal.set(uuid, id);
65
+ const fromU = localToUuid.get(typeof spec.from === 'number' ? spec.from : -1);
66
+ const toU = localToUuid.get(typeof spec.to === 'number' ? spec.to : -1);
67
+ yedges.set(uuid, { from: fromU, to: toU, fp: spec.fp ?? 0, tp: spec.tp ?? 0, label: spec.label || null });
68
+ return id;
69
+ };
70
+
71
+ // Intercept deleteSelection so each removed local id pulls its uuid out of Y.
72
+ const origDelete = flow.deleteSelection.bind(flow);
73
+ flow.deleteSelection = () => {
74
+ if (applyingRemote) return origDelete();
75
+ const toRemove = [];
76
+ for (let i = 0; i < flow.w.nodeCount_(); i++) if (flow.V.selected[i]) toRemove.push(i);
77
+ origDelete();
78
+ // Local ids shift after delete; clear the affected uuid mappings by re-scan.
79
+ ydoc.transact(() => {
80
+ for (const localId of toRemove) {
81
+ const uuid = localToUuid.get(localId);
82
+ if (uuid) { ynodes.delete(uuid); localToUuid.delete(localId); uuidToLocal.delete(uuid); }
83
+ }
84
+ }, 'local-delete');
85
+ };
86
+
87
+ // Throttle dragging updates.
88
+ flow.on('change', () => {
89
+ if (applyingRemote) return;
90
+ if (pendingPosFlush) return;
91
+ pendingPosFlush = setTimeout(() => {
92
+ pendingPosFlush = null;
93
+ ydoc.transact(() => {
94
+ for (const [localId, uuid] of localToUuid) {
95
+ if (localId >= flow.w.nodeCount_()) continue;
96
+ const cur = ynodes.get(uuid);
97
+ if (!cur) continue;
98
+ const next = captureNode(flow, localId);
99
+ if (cur.x !== next.x || cur.y !== next.y || cur.w !== next.w || cur.h !== next.h ||
100
+ cur.title !== next.title || cur.color !== next.color) {
101
+ ynodes.set(uuid, { ...cur, ...next });
102
+ }
103
+ }
104
+ }, 'local-pos');
105
+ }, 33);
106
+ });
107
+
108
+ // ── Remote → Local ─────────────────────────────────────────────────
109
+ ynodes.observe((event) => {
110
+ if (event.transaction.origin === 'local-pos') return;
111
+ applyingRemote = true;
112
+ try {
113
+ event.changes.keys.forEach((change, uuid) => {
114
+ if (change.action === 'add') { addRemoteNode(uuid, ynodes.get(uuid)); }
115
+ if (change.action === 'update') { updateRemoteNode(uuid, ynodes.get(uuid)); }
116
+ if (change.action === 'delete') {
117
+ const localId = uuidToLocal.get(uuid);
118
+ if (localId !== undefined) {
119
+ flow.w.setSelected(localId, 1);
120
+ const orig = flow.deleteSelection;
121
+ flow.deleteSelection = origDelete; // bypass intercept
122
+ try { flow.deleteSelection(); }
123
+ finally { flow.deleteSelection = orig; }
124
+ localToUuid.delete(localId);
125
+ uuidToLocal.delete(uuid);
126
+ }
127
+ }
128
+ });
129
+ } finally { applyingRemote = false; }
130
+ });
131
+
132
+ yedges.observe((event) => {
133
+ applyingRemote = true;
134
+ try {
135
+ event.changes.keys.forEach((change, uuid) => {
136
+ if (change.action === 'add') {
137
+ const e = yedges.get(uuid);
138
+ const from = uuidToLocal.get(e.from), to = uuidToLocal.get(e.to);
139
+ if (from !== undefined && to !== undefined) {
140
+ const localId = origAddEdge({ from, to, fp: e.fp, tp: e.tp, label: e.label });
141
+ edgeLocalToUuid.set(localId, uuid);
142
+ edgeUuidToLocal.set(uuid, localId);
143
+ }
144
+ }
145
+ if (change.action === 'delete') {
146
+ // Local-side deletion isn't wired through a single API yet; leave as a TODO.
147
+ }
148
+ });
149
+ } finally { applyingRemote = false; }
150
+ });
151
+
152
+ // ── Awareness (cursors + selection) ────────────────────────────────
153
+ if (aware) {
154
+ aware.setLocalStateField('user', { name: userName, color: userColor });
155
+ flow.canvas.addEventListener('mousemove', (e) => {
156
+ const wp = flow._s2w(e.clientX, e.clientY);
157
+ aware.setLocalStateField('cursor', { x: wp.x, y: wp.y });
158
+ });
159
+ aware.on('change', () => {
160
+ const states = aware.getStates();
161
+ flow.clearRemoteCursors();
162
+ for (const [clientId, state] of states) {
163
+ if (clientId === aware.clientID) continue;
164
+ const u = state.user || {}, c = state.cursor;
165
+ if (c) flow.setRemoteCursor(String(clientId), c.x, c.y, u.name || String(clientId), u.color || '#5be0d0');
166
+ }
167
+ });
168
+ }
169
+
170
+ // ── Initial backfill: pull whatever's already in the Y.Doc ─────────
171
+ applyingRemote = true;
172
+ try {
173
+ for (const [uuid, spec] of ynodes.entries()) addRemoteNode(uuid, spec);
174
+ for (const [uuid, spec] of yedges.entries()) {
175
+ const from = uuidToLocal.get(spec.from), to = uuidToLocal.get(spec.to);
176
+ if (from !== undefined && to !== undefined) {
177
+ const localId = origAddEdge({ from, to, fp: spec.fp, tp: spec.tp, label: spec.label });
178
+ edgeLocalToUuid.set(localId, uuid);
179
+ edgeUuidToLocal.set(uuid, localId);
180
+ }
181
+ }
182
+ } finally { applyingRemote = false; }
183
+
184
+ // ── Public adapter handle ──────────────────────────────────────────
185
+ return {
186
+ ynodes, yedges, ymeta, ydoc,
187
+ userId, userName, userColor,
188
+ destroy() {
189
+ flow.addNode = origAddNode;
190
+ flow.addEdge = origAddEdge;
191
+ },
192
+ };
193
+
194
+ // ── helpers ────────────────────────────────────────────────────────
195
+ function addRemoteNode(uuid, spec) {
196
+ if (uuidToLocal.has(uuid)) return;
197
+ const localId = origAddNode({
198
+ kind: spec.kind, x: spec.x, y: spec.y,
199
+ w: spec.w, h: spec.h, title: spec.title, color: spec.color,
200
+ });
201
+ if (localId < 0) return;
202
+ localToUuid.set(localId, uuid);
203
+ uuidToLocal.set(uuid, localId);
204
+ }
205
+ function updateRemoteNode(uuid, spec) {
206
+ const localId = uuidToLocal.get(uuid);
207
+ if (localId === undefined) return;
208
+ if (spec.x !== undefined && spec.y !== undefined) {
209
+ flow.V.posX[localId] = spec.x; flow.V.posY[localId] = spec.y;
210
+ }
211
+ if (spec.w !== undefined) flow.V.sizeW[localId] = spec.w;
212
+ if (spec.h !== undefined) flow.V.sizeH[localId] = spec.h;
213
+ if (spec.title) flow.titles.set(localId, spec.title);
214
+ if (spec.color) flow.colors.set(localId, spec.color);
215
+ }
216
+ }
217
+
218
+ function captureNode(flow, id, spec = {}) {
219
+ const cat = flow.kinds[flow.V.kind[id]];
220
+ return {
221
+ kind: cat.name,
222
+ x: flow.V.posX[id], y: flow.V.posY[id],
223
+ w: flow.V.sizeW[id], h: flow.V.sizeH[id],
224
+ title: flow.titles.get(id) || spec.title || null,
225
+ color: flow.colors.get(id) || spec.color || null,
226
+ };
227
+ }
228
+ function newUuid() {
229
+ return 'u_' + Math.random().toString(36).slice(2, 10) + Date.now().toString(36);
230
+ }
231
+ const PALETTE = ['#5b8def', '#c062e8', '#5bd17a', '#f0b93a', '#5be0d0', '#fb923c', '#e8462b'];
232
+ function pickColor(seed) {
233
+ let h = 0; for (const ch of seed) h = (h * 31 + ch.charCodeAt(0)) | 0;
234
+ return PALETTE[Math.abs(h) % PALETTE.length];
235
+ }
236
+
237
+ export { bindYjs };
238
+ //# sourceMappingURL=yjs.esm.js.map
@@ -0,0 +1,2 @@
1
+ /*! @luispm/zflow-graph v0.1.0 | MIT | (c) 2026 */
2
+ function e(e,s,l={}){const r=l.userId||"user-"+Math.random().toString(36).slice(2,8),c=l.userName||r,i=l.color||function(e){let t=0;for(const o of e)t=31*t+o.charCodeAt(0)|0;return n[Math.abs(t)%n.length]}(r),d=s.getMap("zflow.nodes"),a=s.getMap("zflow.edges"),f=s.getMap("zflow.meta"),u=l.awareness||null,g=new Map,p=new Map,y=new Map,m=new Map;let b=!1,h=null;const w=e.addNode.bind(e);e.addNode=(n={})=>{const s=w(n);if(s<0||b)return s;const l=o();return g.set(s,l),p.set(l,s),d.set(l,t(e,s,n)),s};const v=e.addEdge.bind(e);e.addEdge=(e={})=>{const t=v(e);if(t<0||b)return t;const n=o();y.set(t,n),m.set(n,t);const s=g.get("number"==typeof e.from?e.from:-1),l=g.get("number"==typeof e.to?e.to:-1);return a.set(n,{from:s,to:l,fp:e.fp??0,tp:e.tp??0,label:e.label||null}),t};const S=e.deleteSelection.bind(e);e.deleteSelection=()=>{if(b)return S();const t=[];for(let o=0;o<e.w.nodeCount_();o++)e.V.selected[o]&&t.push(o);S(),s.transact(()=>{for(const e of t){const t=g.get(e);t&&(d.delete(t),g.delete(e),p.delete(t))}},"local-delete")},e.on("change",()=>{b||h||(h=setTimeout(()=>{h=null,s.transact(()=>{for(const[o,n]of g){if(o>=e.w.nodeCount_())continue;const s=d.get(n);if(!s)continue;const l=t(e,o);s.x===l.x&&s.y===l.y&&s.w===l.w&&s.h===l.h&&s.title===l.title&&s.color===l.color||d.set(n,{...s,...l})}},"local-pos")},33))}),d.observe(t=>{if("local-pos"!==t.transaction.origin){b=!0;try{t.changes.keys.forEach((t,o)=>{if("add"===t.action&&x(o,d.get(o)),"update"===t.action&&function(t,o){const n=p.get(t);if(void 0===n)return;void 0!==o.x&&void 0!==o.y&&(e.V.posX[n]=o.x,e.V.posY[n]=o.y);void 0!==o.w&&(e.V.sizeW[n]=o.w);void 0!==o.h&&(e.V.sizeH[n]=o.h);o.title&&e.titles.set(n,o.title);o.color&&e.colors.set(n,o.color)}(o,d.get(o)),"delete"===t.action){const t=p.get(o);if(void 0!==t){e.w.setSelected(t,1);const n=e.deleteSelection;e.deleteSelection=S;try{e.deleteSelection()}finally{e.deleteSelection=n}g.delete(t),p.delete(o)}}})}finally{b=!1}}}),a.observe(e=>{b=!0;try{e.changes.keys.forEach((e,t)=>{if("add"===e.action){const e=a.get(t),o=p.get(e.from),n=p.get(e.to);if(void 0!==o&&void 0!==n){const s=v({from:o,to:n,fp:e.fp,tp:e.tp,label:e.label});y.set(s,t),m.set(t,s)}}e.action})}finally{b=!1}}),u&&(u.setLocalStateField("user",{name:c,color:i}),e.canvas.addEventListener("mousemove",t=>{const o=e._s2w(t.clientX,t.clientY);u.setLocalStateField("cursor",{x:o.x,y:o.y})}),u.on("change",()=>{const t=u.getStates();e.clearRemoteCursors();for(const[o,n]of t){if(o===u.clientID)continue;const t=n.user||{},s=n.cursor;s&&e.setRemoteCursor(String(o),s.x,s.y,t.name||String(o),t.color||"#5be0d0")}})),b=!0;try{for(const[e,t]of d.entries())x(e,t);for(const[e,t]of a.entries()){const o=p.get(t.from),n=p.get(t.to);if(void 0!==o&&void 0!==n){const s=v({from:o,to:n,fp:t.fp,tp:t.tp,label:t.label});y.set(s,e),m.set(e,s)}}}finally{b=!1}return{ynodes:d,yedges:a,ymeta:f,ydoc:s,userId:r,userName:c,userColor:i,destroy(){e.addNode=w,e.addEdge=v}};function x(e,t){if(p.has(e))return;const o=w({kind:t.kind,x:t.x,y:t.y,w:t.w,h:t.h,title:t.title,color:t.color});o<0||(g.set(o,e),p.set(e,o))}}function t(e,t,o={}){return{kind:e.kinds[e.V.kind[t]].name,x:e.V.posX[t],y:e.V.posY[t],w:e.V.sizeW[t],h:e.V.sizeH[t],title:e.titles.get(t)||o.title||null,color:e.colors.get(t)||o.color||null}}function o(){return"u_"+Math.random().toString(36).slice(2,10)+Date.now().toString(36)}const n=["#5b8def","#c062e8","#5bd17a","#f0b93a","#5be0d0","#fb923c","#e8462b"];export{e as bindYjs};