@lupinum/board-core 0.1.0 → 1.0.0-beta.2

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/dist/index.js CHANGED
@@ -5,8 +5,15 @@ import {
5
5
  BoardInputError,
6
6
  BoardNotFoundError,
7
7
  assertInternalBoardPlugin,
8
- registerBoardInteractionAdapter
9
- } from "./chunk-5JZXHZWR.js";
8
+ collectJsonObjectExtras,
9
+ createJsonValueBudget,
10
+ freezeClone,
11
+ freezeJsonObject,
12
+ readonlyMapView,
13
+ readonlySetView,
14
+ registerBoardInteractionAdapter,
15
+ sameArray
16
+ } from "./chunk-A7SXOVDB.js";
10
17
 
11
18
  // src/math.ts
12
19
  function clamp(value, min, max) {
@@ -528,112 +535,6 @@ function snapPositionToEdges(bounds, otherEdges, threshold, excludeIds) {
528
535
  return { dx, dy, guides };
529
536
  }
530
537
 
531
- // src/helpers/clone.ts
532
- function freezeClone(value) {
533
- if (Array.isArray(value)) {
534
- for (const entry of value) {
535
- freezeClone(entry);
536
- }
537
- return Object.freeze(value);
538
- }
539
- if (value && typeof value === "object") {
540
- for (const child of Object.values(value)) {
541
- freezeClone(child);
542
- }
543
- return Object.freeze(value);
544
- }
545
- return value;
546
- }
547
- function sameArray(a, b) {
548
- if (a.length !== b.length) {
549
- return false;
550
- }
551
- return a.every((value, index) => value === b[index]);
552
- }
553
- function readonlyMapView(source) {
554
- let view;
555
- view = Object.freeze({
556
- get size() {
557
- return source.size;
558
- },
559
- get: (key) => source.get(key),
560
- has: (key) => source.has(key),
561
- entries: () => source.entries(),
562
- keys: () => source.keys(),
563
- values: () => source.values(),
564
- forEach: (callback, thisArg) => {
565
- source.forEach((value, key) => callback.call(thisArg, value, key, view));
566
- },
567
- [Symbol.iterator]: () => source[Symbol.iterator]()
568
- });
569
- return view;
570
- }
571
- function readonlySetView(source) {
572
- let view;
573
- view = Object.freeze({
574
- get size() {
575
- return source.size;
576
- },
577
- has: (value) => source.has(value),
578
- entries: () => source.entries(),
579
- keys: () => source.keys(),
580
- values: () => source.values(),
581
- forEach: (callback, thisArg) => {
582
- source.forEach((value) => callback.call(thisArg, value, value, view));
583
- },
584
- [Symbol.iterator]: () => source[Symbol.iterator](),
585
- union: (other) => {
586
- const result = new Set(source);
587
- for (const value of iteratorValues(other.keys())) result.add(value);
588
- return result;
589
- },
590
- intersection: (other) => {
591
- const result = /* @__PURE__ */ new Set();
592
- for (const value of source) {
593
- if (other.has(value)) result.add(value);
594
- }
595
- return result;
596
- },
597
- difference: (other) => {
598
- const result = /* @__PURE__ */ new Set();
599
- for (const value of source) {
600
- if (!other.has(value)) result.add(value);
601
- }
602
- return result;
603
- },
604
- symmetricDifference: (other) => {
605
- const result = new Set(source);
606
- for (const value of iteratorValues(other.keys())) {
607
- if (source.has(value)) result.delete(value);
608
- else result.add(value);
609
- }
610
- return result;
611
- },
612
- isSubsetOf: (other) => {
613
- for (const value of source) if (!other.has(value)) return false;
614
- return true;
615
- },
616
- isSupersetOf: (other) => {
617
- for (const value of iteratorValues(other.keys())) {
618
- if (!source.has(value)) return false;
619
- }
620
- return true;
621
- },
622
- isDisjointFrom: (other) => {
623
- for (const value of source) if (other.has(value)) return false;
624
- return true;
625
- }
626
- });
627
- return view;
628
- }
629
- function* iteratorValues(iterator) {
630
- while (true) {
631
- const result = iterator.next();
632
- if (result.done) return;
633
- yield result.value;
634
- }
635
- }
636
-
637
538
  // src/helpers/ids.ts
638
539
  function createNodeId() {
639
540
  return crypto.randomUUID();
@@ -799,6 +700,7 @@ function validateState(state, grid, context) {
799
700
  }
800
701
  zIndexes.add(node.zIndex);
801
702
  }
703
+ validateParentCycles(state, push);
802
704
  for (const id of state.selection.values()) {
803
705
  if (!state.nodes.has(id)) {
804
706
  push("selection.exists", `Selected node ${id} does not exist.`);
@@ -855,28 +757,26 @@ function validateNodeParent(node, state, push) {
855
757
  `Node ${node.id} parent must be type "group", got "${parent.type}".`
856
758
  );
857
759
  }
858
- let walk = parent;
859
- const seen = /* @__PURE__ */ new Set();
860
- while (walk) {
861
- if (seen.has(walk.id)) {
862
- push(
863
- "node.parentId",
864
- `Cycle detected in parent chain for node ${node.id}.`
865
- );
866
- return;
867
- }
868
- seen.add(walk.id);
869
- if (walk.id === node.id) {
870
- push(
871
- "node.parentId",
872
- `Node ${node.id} would create a cycle in the parent chain.`
873
- );
874
- return;
875
- }
876
- if (!walk.parentId) {
877
- break;
760
+ }
761
+ function validateParentCycles(state, push) {
762
+ const complete = /* @__PURE__ */ new Set();
763
+ for (const start of state.nodes.keys()) {
764
+ if (complete.has(start)) continue;
765
+ const path = [];
766
+ const pathIndexes = /* @__PURE__ */ new Map();
767
+ let current = start;
768
+ while (current !== void 0 && !complete.has(current)) {
769
+ const cycleStart = pathIndexes.get(current);
770
+ if (cycleStart !== void 0) {
771
+ const cycle = [...path.slice(cycleStart), current].join(" -> ");
772
+ push("node.parentId", `Cycle detected in parent chain: ${cycle}.`);
773
+ break;
774
+ }
775
+ pathIndexes.set(current, path.length);
776
+ path.push(current);
777
+ current = state.nodes.get(current)?.parentId;
878
778
  }
879
- walk = state.nodes.get(walk.parentId);
779
+ for (const id of path) complete.add(id);
880
780
  }
881
781
  }
882
782
 
@@ -1593,7 +1493,11 @@ function stagePersistentRoots(roots) {
1593
1493
  selection: new Set(roots.state.selection),
1594
1494
  interaction: cloneInteraction(roots.state.interaction),
1595
1495
  snapGuides: roots.state.snapGuides.map((guide) => ({ ...guide })),
1596
- nextZIndex: roots.state.nextZIndex
1496
+ nextZIndex: roots.state.nextZIndex,
1497
+ jsonCanvas: {
1498
+ document: roots.state.jsonCanvas.document,
1499
+ nodes: new Map(roots.state.jsonCanvas.nodes)
1500
+ }
1597
1501
  },
1598
1502
  grid: { ...roots.grid },
1599
1503
  pluginStates: new Map(
@@ -1639,6 +1543,32 @@ var JSON_CANVAS_SIDES = /* @__PURE__ */ new Set([
1639
1543
  ]);
1640
1544
  var JSON_CANVAS_EDGE_ENDS = /* @__PURE__ */ new Set(["none", "arrow"]);
1641
1545
  var JSON_CANVAS_BACKGROUND_STYLES = /* @__PURE__ */ new Set(["cover", "ratio", "repeat"]);
1546
+ var MAX_DOCUMENT_NODES = 1e4;
1547
+ var MAX_DOCUMENT_EDGES = 2e4;
1548
+ var MAX_DOCUMENT_SELECTION = 1e4;
1549
+ var LEGACY_BOARD_METADATA_KEY = "x-vue-board";
1550
+ var DOCUMENT_FIELDS = /* @__PURE__ */ new Set([
1551
+ "nodes",
1552
+ "edges",
1553
+ "x-lupinum-board",
1554
+ LEGACY_BOARD_METADATA_KEY
1555
+ ]);
1556
+ var NODE_FIELDS = /* @__PURE__ */ new Set([
1557
+ "id",
1558
+ "type",
1559
+ "x",
1560
+ "y",
1561
+ "width",
1562
+ "height",
1563
+ "color",
1564
+ "text",
1565
+ "file",
1566
+ "subpath",
1567
+ "url",
1568
+ "label",
1569
+ "background",
1570
+ "backgroundStyle"
1571
+ ]);
1642
1572
  function isJsonCanvasNodeType(value) {
1643
1573
  return typeof value === "string" && JSON_CANVAS_NODE_TYPES.has(value);
1644
1574
  }
@@ -1716,8 +1646,9 @@ function jsonNodeToBoardNode(node, meta, index) {
1716
1646
  };
1717
1647
  return withNodeFields(base, node);
1718
1648
  }
1719
- function boardNodeToJsonNode(node) {
1649
+ function boardNodeToJsonNode(node, extras) {
1720
1650
  const base = {
1651
+ ...extras ?? {},
1721
1652
  id: node.id,
1722
1653
  type: node.type,
1723
1654
  x: node.x,
@@ -1759,9 +1690,10 @@ function mergeMetadata(base, patch) {
1759
1690
  };
1760
1691
  }
1761
1692
  function getDocumentMetadata(document) {
1762
- return document["x-vue-board"];
1693
+ const record = document;
1694
+ return document["x-lupinum-board"] ?? record[LEGACY_BOARD_METADATA_KEY];
1763
1695
  }
1764
- function toPersistedDocument(snapshot, featureDocuments) {
1696
+ function toPersistedDocument(snapshot, featureDocuments, passthrough) {
1765
1697
  let metadata = {
1766
1698
  camera: snapshot.camera,
1767
1699
  grid: snapshot.grid,
@@ -1787,13 +1719,18 @@ function toPersistedDocument(snapshot, featureDocuments) {
1787
1719
  metadata = mergeMetadata(metadata, getDocumentMetadata(featureDocument));
1788
1720
  }
1789
1721
  return {
1790
- nodes: snapshot.nodes.map((node) => boardNodeToJsonNode(node)),
1722
+ ...passthrough.document,
1723
+ nodes: snapshot.nodes.map(
1724
+ (node) => boardNodeToJsonNode(node, passthrough.nodes.get(node.id))
1725
+ ),
1791
1726
  ...edges !== void 0 ? { edges } : {},
1792
- "x-vue-board": metadata
1727
+ "x-lupinum-board": metadata
1793
1728
  };
1794
1729
  }
1795
1730
  function isRecord(value) {
1796
- return Boolean(value) && typeof value === "object" && !Array.isArray(value);
1731
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
1732
+ const prototype = Object.getPrototypeOf(value);
1733
+ return prototype === Object.prototype || prototype === null;
1797
1734
  }
1798
1735
  function assertRecord(value, message) {
1799
1736
  if (!isRecord(value)) {
@@ -1925,13 +1862,27 @@ function validateDocumentMetadata(metadata) {
1925
1862
  }
1926
1863
  }
1927
1864
  function normalizeDocumentForImport(raw) {
1928
- const parsed = assertRecord(
1865
+ const parsedRecord = assertRecord(
1929
1866
  raw,
1930
1867
  "Invalid board document: document must be an object."
1931
1868
  );
1869
+ const parsed = parsedRecord;
1932
1870
  if (!Array.isArray(parsed.nodes)) {
1933
1871
  throw new BoardInputError("Invalid board document: missing nodes array.");
1934
1872
  }
1873
+ if (parsed.nodes.length > MAX_DOCUMENT_NODES) {
1874
+ throw new BoardInputError(
1875
+ `Invalid board document: nodes exceed the supported limit of ${MAX_DOCUMENT_NODES}.`
1876
+ );
1877
+ }
1878
+ if (parsed.edges !== void 0 && !Array.isArray(parsed.edges)) {
1879
+ throw new BoardInputError("Invalid board document: edges must be an array.");
1880
+ }
1881
+ if (parsed.edges && parsed.edges.length > MAX_DOCUMENT_EDGES) {
1882
+ throw new BoardInputError(
1883
+ `Invalid board document: edges exceed the supported limit of ${MAX_DOCUMENT_EDGES}.`
1884
+ );
1885
+ }
1935
1886
  for (const key of [
1936
1887
  "camera",
1937
1888
  "grid",
@@ -1942,11 +1893,35 @@ function normalizeDocumentForImport(raw) {
1942
1893
  ]) {
1943
1894
  if (key in parsed) {
1944
1895
  throw new BoardInputError(
1945
- `Invalid board document: runtime field "${key}" belongs under x-vue-board.`
1896
+ `Invalid board document: runtime field "${key}" belongs under x-lupinum-board.`
1946
1897
  );
1947
1898
  }
1948
1899
  }
1949
- validateDocumentMetadata(getDocumentMetadata(parsed));
1900
+ const rawMetadata = getDocumentMetadata(parsed);
1901
+ if (isRecord(rawMetadata)) {
1902
+ if (Array.isArray(rawMetadata.selection) && rawMetadata.selection.length > MAX_DOCUMENT_SELECTION) {
1903
+ throw new BoardInputError(
1904
+ `Invalid board document: selection exceeds the supported limit of ${MAX_DOCUMENT_SELECTION}.`
1905
+ );
1906
+ }
1907
+ if (isRecord(rawMetadata.nodes) && Reflect.ownKeys(rawMetadata.nodes).length > MAX_DOCUMENT_NODES) {
1908
+ throw new BoardInputError(
1909
+ `Invalid board document: node metadata exceeds the supported limit of ${MAX_DOCUMENT_NODES}.`
1910
+ );
1911
+ }
1912
+ if (isRecord(rawMetadata.edges) && Reflect.ownKeys(rawMetadata.edges).length > MAX_DOCUMENT_EDGES) {
1913
+ throw new BoardInputError(
1914
+ `Invalid board document: edge metadata exceeds the supported limit of ${MAX_DOCUMENT_EDGES}.`
1915
+ );
1916
+ }
1917
+ }
1918
+ validateDocumentMetadata(rawMetadata);
1919
+ const jsonBudget = createJsonValueBudget();
1920
+ const metadata = rawMetadata === void 0 ? void 0 : freezeJsonObject(
1921
+ rawMetadata,
1922
+ "Invalid board document: board metadata",
1923
+ jsonBudget
1924
+ );
1950
1925
  const seenNodes = /* @__PURE__ */ new Set();
1951
1926
  const nodes = parsed.nodes.map((node) => {
1952
1927
  if (!isRecord(node)) {
@@ -1969,7 +1944,11 @@ function normalizeDocumentForImport(raw) {
1969
1944
  `Invalid board document: node "${String(node.id)}" has unsupported backgroundStyle "${String(node.backgroundStyle)}".`
1970
1945
  );
1971
1946
  }
1972
- const normalized = { ...node };
1947
+ const normalized = freezeJsonObject(
1948
+ node,
1949
+ `Invalid board document: node "${String(node.id ?? "?")}"`,
1950
+ jsonBudget
1951
+ );
1973
1952
  validateJsonCanvasNodeFields(normalized);
1974
1953
  if (seenNodes.has(normalized.id)) {
1975
1954
  throw new BoardInputError(
@@ -1981,11 +1960,6 @@ function normalizeDocumentForImport(raw) {
1981
1960
  });
1982
1961
  let edges;
1983
1962
  if (parsed.edges !== void 0) {
1984
- if (!Array.isArray(parsed.edges)) {
1985
- throw new BoardInputError(
1986
- "Invalid board document: edges must be an array."
1987
- );
1988
- }
1989
1963
  const seenEdges = /* @__PURE__ */ new Set();
1990
1964
  edges = parsed.edges.map((edge) => {
1991
1965
  if (!isRecord(edge)) {
@@ -2037,13 +2011,43 @@ function normalizeDocumentForImport(raw) {
2037
2011
  `Invalid board document: edge "${id}" has invalid label.`
2038
2012
  );
2039
2013
  }
2040
- return { ...edge, id, fromNode, toNode };
2014
+ return freezeJsonObject(
2015
+ { ...edge, id, fromNode, toNode },
2016
+ `Invalid board document: edge "${id}"`,
2017
+ jsonBudget
2018
+ );
2041
2019
  });
2042
2020
  }
2043
2021
  return {
2022
+ ...collectJsonObjectExtras(
2023
+ parsedRecord,
2024
+ DOCUMENT_FIELDS,
2025
+ "Invalid board document",
2026
+ jsonBudget
2027
+ ),
2044
2028
  nodes,
2045
2029
  ...edges !== void 0 ? { edges } : {},
2046
- ...getDocumentMetadata(parsed) !== void 0 ? { "x-vue-board": getDocumentMetadata(parsed) } : {}
2030
+ ...metadata !== void 0 ? { "x-lupinum-board": metadata } : {}
2031
+ };
2032
+ }
2033
+ function extractJsonCanvasPassthrough(document) {
2034
+ const record = document;
2035
+ return {
2036
+ document: collectJsonObjectExtras(
2037
+ record,
2038
+ DOCUMENT_FIELDS,
2039
+ "Invalid board document"
2040
+ ),
2041
+ nodes: new Map(
2042
+ document.nodes.map((node) => [
2043
+ node.id,
2044
+ collectJsonObjectExtras(
2045
+ node,
2046
+ NODE_FIELDS,
2047
+ `Invalid board document: node "${node.id}"`
2048
+ )
2049
+ ])
2050
+ )
2047
2051
  };
2048
2052
  }
2049
2053
  function materializeSnapshotNodes(snapshot) {
@@ -2056,12 +2060,13 @@ function documentToSnapshot(document) {
2056
2060
  jsonNodeToBoardNode(node, metadata?.nodes?.[node.id], index)
2057
2061
  )
2058
2062
  );
2063
+ const nodeIds = new Set(nodes.map((node) => node.id));
2059
2064
  const gridSettings = {
2060
2065
  ...DEFAULT_GRID,
2061
2066
  ...metadata?.grid ?? {}
2062
2067
  };
2063
2068
  const selection = Array.isArray(metadata?.selection) ? metadata.selection.filter(
2064
- (id) => typeof id === "string" && nodes.some((node) => node.id === id)
2069
+ (id) => typeof id === "string" && nodeIds.has(id)
2065
2070
  ).map((id) => id) : [];
2066
2071
  const nextZIndex = metadata?.nextZIndex ?? nodes.reduce((max, node) => Math.max(max, node.zIndex), 0) + 1;
2067
2072
  const camera = { ...DEFAULT_CAMERA, ...metadata?.camera ?? {} };
@@ -2163,6 +2168,110 @@ function createCameraSession(deps) {
2163
2168
  return { animateTo, cancelAnimations, computeFit };
2164
2169
  }
2165
2170
 
2171
+ // src/engine/public-engine.ts
2172
+ function createPublicEngine(engine) {
2173
+ return {
2174
+ plugins: engine.plugins,
2175
+ $camera: engine.$camera,
2176
+ $grid: engine.$grid,
2177
+ $nodes: engine.$nodes,
2178
+ $selection: engine.$selection,
2179
+ $interaction: engine.$interaction,
2180
+ $snapGuides: engine.$snapGuides,
2181
+ destroy: engine.destroy,
2182
+ batch: engine.batch,
2183
+ getState: engine.getState,
2184
+ getGridSettings: engine.getGridSettings,
2185
+ getViewportSize: engine.getViewportSize,
2186
+ updateGridSettings: engine.updateGridSettings,
2187
+ setViewportSize: engine.setViewportSize,
2188
+ on: engine.on,
2189
+ once: engine.once,
2190
+ off: engine.off,
2191
+ exportTrace: engine.exportTrace,
2192
+ addCommandGuard: engine.addCommandGuard,
2193
+ screenToWorld: engine.screenToWorld,
2194
+ worldToScreen: engine.worldToScreen,
2195
+ getVisibleBounds: engine.getVisibleBounds,
2196
+ getNode: engine.getNode,
2197
+ findNode: engine.findNode,
2198
+ hasNode: engine.hasNode,
2199
+ getNodeAt: engine.getNodeAt,
2200
+ getNodesInBounds: engine.getNodesInBounds,
2201
+ panBy: engine.panBy,
2202
+ panTo: engine.panTo,
2203
+ zoomAt: engine.zoomAt,
2204
+ zoomTo: engine.zoomTo,
2205
+ zoomToFit: engine.zoomToFit,
2206
+ zoomToNodes: engine.zoomToNodes,
2207
+ createNode: engine.createNode,
2208
+ updateNode: engine.updateNode,
2209
+ deleteNode: engine.deleteNode,
2210
+ moveNode: engine.moveNode,
2211
+ translateSelectedNodes: engine.translateSelectedNodes,
2212
+ resizeNode: engine.resizeNode,
2213
+ bringToFront: engine.bringToFront,
2214
+ sendToBack: engine.sendToBack,
2215
+ lockNode: engine.lockNode,
2216
+ unlockNode: engine.unlockNode,
2217
+ duplicateNodes: engine.duplicateNodes,
2218
+ copySelected: engine.copySelected,
2219
+ pasteClipboard: engine.pasteClipboard,
2220
+ pasteData: engine.pasteData,
2221
+ select: engine.select,
2222
+ selectAll: engine.selectAll,
2223
+ clearSelection: engine.clearSelection,
2224
+ deleteSelected: engine.deleteSelected,
2225
+ getSelection: engine.getSelection,
2226
+ beginTextEdit: engine.beginTextEdit,
2227
+ commitTextEdit: engine.commitTextEdit,
2228
+ cancelTextEdit: engine.cancelTextEdit,
2229
+ exportDocument: engine.exportDocument,
2230
+ loadDocument: engine.loadDocument
2231
+ };
2232
+ }
2233
+
2234
+ // src/engine/json-canvas-extras.ts
2235
+ function removeNodeExtras(current, id) {
2236
+ if (!current.nodes.has(id)) return current;
2237
+ const nodes = new Map(current.nodes);
2238
+ nodes.delete(id);
2239
+ return { ...current, nodes };
2240
+ }
2241
+ function copyNodeExtras(current, idMap, source = current.nodes) {
2242
+ const nodes = new Map(current.nodes);
2243
+ for (const [sourceId, targetId] of idMap) {
2244
+ const extras = source.get(sourceId);
2245
+ if (extras) nodes.set(targetId, extras);
2246
+ }
2247
+ return { ...current, nodes };
2248
+ }
2249
+ function replaceJsonCanvasExtras(incoming) {
2250
+ return {
2251
+ document: incoming.document,
2252
+ nodes: new Map(incoming.nodes)
2253
+ };
2254
+ }
2255
+ function mergeJsonCanvasExtras(current, incoming, idMap) {
2256
+ const document = Object.freeze({
2257
+ ...current.document,
2258
+ ...incoming.document
2259
+ });
2260
+ const nodes = new Map(current.nodes);
2261
+ for (const [id, extras] of incoming.nodes) {
2262
+ nodes.set(idMap.get(id) ?? id, extras);
2263
+ }
2264
+ return { document, nodes };
2265
+ }
2266
+ function selectNodeExtras(source, ids) {
2267
+ const selected = /* @__PURE__ */ new Map();
2268
+ for (const id of ids) {
2269
+ const extras = source.get(id);
2270
+ if (extras) selected.set(id, extras);
2271
+ }
2272
+ return selected;
2273
+ }
2274
+
2166
2275
  // src/engine/node-shape.ts
2167
2276
  function assertValidNodeGeometry(id, geometry) {
2168
2277
  if (!Number.isFinite(geometry.x) || !Number.isFinite(geometry.y) || !Number.isFinite(geometry.width) || !Number.isFinite(geometry.height) || geometry.width <= 0 || geometry.height <= 0) {
@@ -2368,6 +2477,7 @@ function createBoardEngine(options = {}) {
2368
2477
  const { emit, emitImmediate, on, once, off, reportUnhandledError } = eventBus;
2369
2478
  const commandGuards = createCommandGuardRegistry();
2370
2479
  const commitProjectors = /* @__PURE__ */ new Set();
2480
+ const projectedSubscribables = /* @__PURE__ */ new Set();
2371
2481
  const nodeDeletedHooks = /* @__PURE__ */ new Set();
2372
2482
  const pluginCleanups = /* @__PURE__ */ new Map();
2373
2483
  let pluginStates = /* @__PURE__ */ new Map();
@@ -2376,6 +2486,7 @@ function createBoardEngine(options = {}) {
2376
2486
  for (const hook of nodeDeletedHooks) hook(nodeId);
2377
2487
  }
2378
2488
  const clipboard = [];
2489
+ const clipboardPassthrough = /* @__PURE__ */ new Map();
2379
2490
  const plugins = {};
2380
2491
  let viewportSize = { ...DEFAULT_VIEWPORT_SIZE };
2381
2492
  let destroyed = false;
@@ -2414,11 +2525,13 @@ function createBoardEngine(options = {}) {
2414
2525
  selection: /* @__PURE__ */ new Set(),
2415
2526
  interaction: { mode: "idle" },
2416
2527
  snapGuides: [],
2417
- nextZIndex: 1
2528
+ nextZIndex: 1,
2529
+ jsonCanvas: { document: Object.freeze({}), nodes: /* @__PURE__ */ new Map() }
2418
2530
  };
2419
2531
  const initialDocument = options.initialDocument ? normalizeDocumentForImport(options.initialDocument) : null;
2420
2532
  if (initialDocument) {
2421
2533
  const initial = documentToSnapshot(initialDocument);
2534
+ state.jsonCanvas = extractJsonCanvasPassthrough(initialDocument);
2422
2535
  state.camera = { ...initial.camera };
2423
2536
  state.selection = /* @__PURE__ */ new Set();
2424
2537
  state.nextZIndex = initial.nextZIndex;
@@ -2518,6 +2631,7 @@ function createBoardEngine(options = {}) {
2518
2631
  const checkpoint = {
2519
2632
  roots,
2520
2633
  clipboard: [...clipboard],
2634
+ clipboardPassthrough: new Map(clipboardPassthrough),
2521
2635
  nodeOverrides: new Map(nodeOverrides),
2522
2636
  activeGestureHistoryRoot,
2523
2637
  activeBoxSelectionBefore
@@ -2533,6 +2647,10 @@ function createBoardEngine(options = {}) {
2533
2647
  grid = checkpoint.roots.grid;
2534
2648
  pluginStates = checkpoint.roots.pluginStates;
2535
2649
  clipboard.splice(0, clipboard.length, ...checkpoint.clipboard);
2650
+ clipboardPassthrough.clear();
2651
+ for (const [id, extras] of checkpoint.clipboardPassthrough) {
2652
+ clipboardPassthrough.set(id, extras);
2653
+ }
2536
2654
  nodeOverrides.clear();
2537
2655
  for (const [id, node] of checkpoint.nodeOverrides) {
2538
2656
  nodeOverrides.set(id, node);
@@ -2547,6 +2665,7 @@ function createBoardEngine(options = {}) {
2547
2665
  grid: freezeClone({ ...grid }),
2548
2666
  selection: state.selection,
2549
2667
  nextZIndex: state.nextZIndex,
2668
+ jsonCanvas: state.jsonCanvas,
2550
2669
  pluginSlices: new Map(
2551
2670
  Array.from(pluginStates, ([name, pluginState]) => [
2552
2671
  name,
@@ -2557,6 +2676,11 @@ function createBoardEngine(options = {}) {
2557
2676
  }
2558
2677
  function sameHistoryRoot(left, right) {
2559
2678
  if (left.nextZIndex !== right.nextZIndex) return false;
2679
+ if (left.jsonCanvas.document !== right.jsonCanvas.document) return false;
2680
+ if (left.jsonCanvas.nodes.size !== right.jsonCanvas.nodes.size) return false;
2681
+ for (const [id, extras] of left.jsonCanvas.nodes) {
2682
+ if (right.jsonCanvas.nodes.get(id) !== extras) return false;
2683
+ }
2560
2684
  if (left.grid.size !== right.grid.size || left.grid.majorEvery !== right.grid.majorEvery || left.grid.snap !== right.grid.snap || left.grid.edgeSnap !== right.grid.edgeSnap || left.grid.edgeSnapThreshold !== right.grid.edgeSnapThreshold || left.grid.pattern !== right.grid.pattern) {
2561
2685
  return false;
2562
2686
  }
@@ -2863,7 +2987,13 @@ function createBoardEngine(options = {}) {
2863
2987
  Array.from(state.selection.values()).filter((id) => state.nodes.has(id))
2864
2988
  );
2865
2989
  }
2866
- function restoreSnapshot(snapshot, mode) {
2990
+ function deleteNodePassthrough(id) {
2991
+ state.jsonCanvas = removeNodeExtras(state.jsonCanvas, id);
2992
+ }
2993
+ function copyNodePassthrough(idMap, source = state.jsonCanvas.nodes) {
2994
+ state.jsonCanvas = copyNodeExtras(state.jsonCanvas, idMap, source);
2995
+ }
2996
+ function restoreSnapshot(snapshot, mode, passthrough) {
2867
2997
  const snapshotNodes = materializeSnapshotNodes(snapshot);
2868
2998
  const idMap = /* @__PURE__ */ new Map();
2869
2999
  if (mode === "replace") {
@@ -2874,6 +3004,7 @@ function createBoardEngine(options = {}) {
2874
3004
  continue;
2875
3005
  }
2876
3006
  state.nodes.delete(id);
3007
+ deleteNodePassthrough(id);
2877
3008
  notifyNodeDeletedPlugins(prevNode.id);
2878
3009
  emit("node:deleted", id, materializeNode2(prevNode));
2879
3010
  }
@@ -2884,6 +3015,7 @@ function createBoardEngine(options = {}) {
2884
3015
  idMap.set(rawNode.id, node.id);
2885
3016
  emit("node:created", materializeNode2(node));
2886
3017
  }
3018
+ state.jsonCanvas = replaceJsonCanvasExtras(passthrough);
2887
3019
  state.selection = new Set(
2888
3020
  snapshot.selection.filter((id) => state.nodes.has(id))
2889
3021
  );
@@ -2898,12 +3030,29 @@ function createBoardEngine(options = {}) {
2898
3030
  notifySnapGuidesChanged();
2899
3031
  return idMap;
2900
3032
  }
3033
+ const reservedIds = new Set(state.nodes.keys());
3034
+ for (const rawNode of snapshotNodes) {
3035
+ let id = rawNode.id;
3036
+ while (reservedIds.has(id)) id = createNodeId();
3037
+ reservedIds.add(id);
3038
+ idMap.set(rawNode.id, id);
3039
+ }
2901
3040
  for (const rawNode of snapshotNodes) {
2902
3041
  const node = normalizeExistingNode(rawNode);
2903
- const id = state.nodes.has(node.id) ? createNodeId() : node.id;
2904
- state.nodes.set(id, { ...node, id, zIndex: state.nextZIndex++ });
2905
- idMap.set(node.id, id);
3042
+ const id = idMap.get(node.id);
3043
+ const parentId = node.parentId ? idMap.get(node.parentId) ?? node.parentId : void 0;
3044
+ state.nodes.set(id, {
3045
+ ...node,
3046
+ id,
3047
+ parentId,
3048
+ zIndex: state.nextZIndex++
3049
+ });
2906
3050
  }
3051
+ state.jsonCanvas = mergeJsonCanvasExtras(
3052
+ state.jsonCanvas,
3053
+ passthrough,
3054
+ idMap
3055
+ );
2907
3056
  notifyNodesChanged();
2908
3057
  return idMap;
2909
3058
  }
@@ -2998,6 +3147,8 @@ function createBoardEngine(options = {}) {
2998
3147
  pluginStates.clear();
2999
3148
  pluginPersistence.clear();
3000
3149
  commitProjectors.clear();
3150
+ for (const projection of projectedSubscribables) projection.destroy();
3151
+ projectedSubscribables.clear();
3001
3152
  nodeDeletedHooks.clear();
3002
3153
  commandGuards.clear();
3003
3154
  eventBus.clear();
@@ -3011,7 +3162,7 @@ function createBoardEngine(options = {}) {
3011
3162
  },
3012
3163
  extend(key, value) {
3013
3164
  assertMutationAllowed();
3014
- plugins[key] = value;
3165
+ Reflect.set(plugins, key, value);
3015
3166
  },
3016
3167
  batch(fn) {
3017
3168
  assertCommandReady();
@@ -3120,6 +3271,27 @@ function createBoardEngine(options = {}) {
3120
3271
  commitProjectors.add(projector);
3121
3272
  return () => commitProjectors.delete(projector);
3122
3273
  },
3274
+ createCommitSubscribable(select, channel) {
3275
+ assertAlive();
3276
+ assertMutationAllowed();
3277
+ let selected = select();
3278
+ const projection = createSubscribable(
3279
+ selected,
3280
+ createBatchController(),
3281
+ (error) => reportUnhandledError(error, { source: "subscriber", channel })
3282
+ );
3283
+ const projector = () => {
3284
+ const next = select();
3285
+ if (Object.is(next, selected)) return () => void 0;
3286
+ return () => {
3287
+ selected = next;
3288
+ projection.set(next);
3289
+ };
3290
+ };
3291
+ commitProjectors.add(projector);
3292
+ projectedSubscribables.add(projection);
3293
+ return projection;
3294
+ },
3123
3295
  restoreHistoryRoot(root) {
3124
3296
  runCommand(
3125
3297
  "history:restore",
@@ -3132,6 +3304,10 @@ function createBoardEngine(options = {}) {
3132
3304
  Array.from(root.selection).filter((id) => state.nodes.has(id))
3133
3305
  );
3134
3306
  state.nextZIndex = root.nextZIndex;
3307
+ state.jsonCanvas = {
3308
+ document: root.jsonCanvas.document,
3309
+ nodes: new Map(root.jsonCanvas.nodes)
3310
+ };
3135
3311
  Object.assign(grid, root.grid);
3136
3312
  for (const [name, pluginState] of pluginStates) {
3137
3313
  if (root.pluginSlices.has(name)) {
@@ -3147,16 +3323,6 @@ function createBoardEngine(options = {}) {
3147
3323
  IGNORE_COMMAND
3148
3324
  );
3149
3325
  },
3150
- getPluginState() {
3151
- throw new Error(
3152
- "getPluginState is only available inside an internal plugin install() context."
3153
- );
3154
- },
3155
- updatePluginState(_update) {
3156
- throw new Error(
3157
- "updatePluginState is only available inside an internal plugin install() context."
3158
- );
3159
- },
3160
3326
  screenToWorld(point) {
3161
3327
  assertAlive();
3162
3328
  return screenToWorld(point, state.camera);
@@ -3344,6 +3510,7 @@ function createBoardEngine(options = {}) {
3344
3510
  continue;
3345
3511
  }
3346
3512
  state.nodes.delete(deleteId);
3513
+ deleteNodePassthrough(deleteId);
3347
3514
  notifyNodeDeletedPlugins(prevNode.id);
3348
3515
  emit("node:deleted", deleteId, materializeNode2(prevNode));
3349
3516
  }
@@ -3501,6 +3668,7 @@ function createBoardEngine(options = {}) {
3501
3668
  const forest = forestIdsFromSeeds(ids);
3502
3669
  const source = Array.from(forest).map((id) => state.nodes.get(id)).filter((node) => Boolean(node)).sort((a, b) => a.zIndex - b.zIndex);
3503
3670
  const duplicated = duplicateForest2(source, offset);
3671
+ copyNodePassthrough(duplicated.idMap);
3504
3672
  const created = duplicated.nodes;
3505
3673
  for (const node of created) {
3506
3674
  state.nodes.set(node.id, node);
@@ -3517,15 +3685,59 @@ function createBoardEngine(options = {}) {
3517
3685
  copySelected() {
3518
3686
  return runCommand("copySelected", [], () => {
3519
3687
  clipboard.length = 0;
3520
- for (const node of getCopyClosureNodes2()) {
3688
+ clipboardPassthrough.clear();
3689
+ const copied = getCopyClosureNodes2();
3690
+ for (const node of copied) {
3521
3691
  clipboard.push({ ...node });
3522
3692
  }
3693
+ for (const [id, extras] of selectNodeExtras(
3694
+ state.jsonCanvas.nodes,
3695
+ copied.map((node) => node.id)
3696
+ )) {
3697
+ clipboardPassthrough.set(id, extras);
3698
+ }
3523
3699
  return clipboard.map((node) => materializeNode2(node));
3524
3700
  });
3525
3701
  },
3526
3702
  pasteClipboard(offset = { x: grid.size, y: grid.size }) {
3527
3703
  return runCommand("pasteClipboard", [offset], () => {
3528
- const created = duplicateForest2(clipboard, offset).nodes;
3704
+ const duplicated = duplicateForest2(clipboard, offset);
3705
+ copyNodePassthrough(duplicated.idMap, clipboardPassthrough);
3706
+ const created = duplicated.nodes;
3707
+ for (const node of created) {
3708
+ state.nodes.set(node.id, node);
3709
+ emit("node:created", materializeNode2(node));
3710
+ }
3711
+ notifyNodesChanged();
3712
+ setSelection(created.map((node) => node.id));
3713
+ return created.map((node) => materializeNode2(node));
3714
+ });
3715
+ },
3716
+ pasteData(payload, offset = { x: grid.size, y: grid.size }) {
3717
+ return runCommand("pasteData", [payload, offset], () => {
3718
+ const inputs = options.clipboard?.deserialize(payload) ?? null;
3719
+ if (!inputs || inputs.length === 0) return null;
3720
+ const stagedNodes = new Map(state.nodes);
3721
+ let nextZIndex = state.nextZIndex;
3722
+ const created = inputs.map((input) => {
3723
+ const normalized = normalizeNodeInput(
3724
+ {
3725
+ ...input,
3726
+ x: input.x ?? offset.x,
3727
+ y: input.y ?? offset.y
3728
+ },
3729
+ {
3730
+ nodes: stagedNodes,
3731
+ grid,
3732
+ constraints: nodeConstraints,
3733
+ nextZIndex
3734
+ }
3735
+ );
3736
+ nextZIndex = normalized.nextZIndex;
3737
+ stagedNodes.set(normalized.node.id, normalized.node);
3738
+ return normalized.node;
3739
+ });
3740
+ state.nextZIndex = nextZIndex;
3529
3741
  for (const node of created) {
3530
3742
  state.nodes.set(node.id, node);
3531
3743
  emit("node:created", materializeNode2(node));
@@ -3600,6 +3812,7 @@ function createBoardEngine(options = {}) {
3600
3812
  continue;
3601
3813
  }
3602
3814
  state.nodes.delete(deleteId);
3815
+ deleteNodePassthrough(deleteId);
3603
3816
  notifyNodeDeletedPlugins(prevNode.id);
3604
3817
  emit("node:deleted", deleteId, materializeNode2(prevNode));
3605
3818
  }
@@ -4038,7 +4251,8 @@ function createBoardEngine(options = {}) {
4038
4251
  );
4039
4252
  return toPersistedDocument(
4040
4253
  buildSnapshot(state, grid, buildPublicNodeMap(state)),
4041
- pluginDocuments
4254
+ pluginDocuments,
4255
+ state.jsonCanvas
4042
4256
  );
4043
4257
  },
4044
4258
  loadDocument(document, options2 = {}) {
@@ -4050,10 +4264,11 @@ function createBoardEngine(options = {}) {
4050
4264
  const normalized = normalizeDocumentForImport(document);
4051
4265
  assertCanRestoreDocument(normalized);
4052
4266
  const snapshot = documentToSnapshot(normalized);
4267
+ const passthrough = extractJsonCanvasPassthrough(normalized);
4053
4268
  nodeOverrides.clear();
4054
4269
  activeGestureHistoryRoot = null;
4055
4270
  activeBoxSelectionBefore = null;
4056
- const idMap = restoreSnapshot(snapshot, mode);
4271
+ const idMap = restoreSnapshot(snapshot, mode, passthrough);
4057
4272
  restorePluginDocuments(normalized, mode, idMap);
4058
4273
  },
4059
4274
  IGNORE_COMMAND
@@ -4075,29 +4290,7 @@ function createBoardEngine(options = {}) {
4075
4290
  );
4076
4291
  }
4077
4292
  validate("createBoardEngine");
4078
- const internalKeys = /* @__PURE__ */ new Set([
4079
- "emit",
4080
- "assertActive",
4081
- "isBatching",
4082
- "extend",
4083
- "runCommand",
4084
- "projectCommit",
4085
- "restoreHistoryRoot",
4086
- "getPluginState",
4087
- "updatePluginState",
4088
- "beginPan",
4089
- "beginNodeDrag",
4090
- "beginResize",
4091
- "beginBoxSelect",
4092
- "updatePointer",
4093
- "endInteraction",
4094
- "cancelInteraction",
4095
- "getUniformTranslationTargets",
4096
- "syncGroupZOrder"
4097
- ]);
4098
- const publicEngine = Object.fromEntries(
4099
- Object.entries(engine).filter(([key]) => !internalKeys.has(key))
4100
- );
4293
+ const publicEngine = createPublicEngine(engine);
4101
4294
  registerBoardInteractionAdapter(publicEngine, engine);
4102
4295
  return publicEngine;
4103
4296
  }