@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.
@@ -0,0 +1,350 @@
1
+ // src/helpers/clone.ts
2
+ function freezeClone(value) {
3
+ if (Array.isArray(value)) {
4
+ for (const entry of value) {
5
+ freezeClone(entry);
6
+ }
7
+ return Object.freeze(value);
8
+ }
9
+ if (value && typeof value === "object") {
10
+ for (const child of Object.values(value)) {
11
+ freezeClone(child);
12
+ }
13
+ return Object.freeze(value);
14
+ }
15
+ return value;
16
+ }
17
+ function sameArray(a, b) {
18
+ if (a.length !== b.length) {
19
+ return false;
20
+ }
21
+ return a.every((value, index) => value === b[index]);
22
+ }
23
+ function readonlyMapView(source) {
24
+ let view;
25
+ view = Object.freeze({
26
+ get size() {
27
+ return source.size;
28
+ },
29
+ get: (key) => source.get(key),
30
+ has: (key) => source.has(key),
31
+ entries: () => source.entries(),
32
+ keys: () => source.keys(),
33
+ values: () => source.values(),
34
+ forEach: (callback, thisArg) => {
35
+ source.forEach((value, key) => callback.call(thisArg, value, key, view));
36
+ },
37
+ [Symbol.iterator]: () => source[Symbol.iterator]()
38
+ });
39
+ return view;
40
+ }
41
+ function readonlySetView(source) {
42
+ let view;
43
+ view = Object.freeze({
44
+ get size() {
45
+ return source.size;
46
+ },
47
+ has: (value) => source.has(value),
48
+ entries: () => source.entries(),
49
+ keys: () => source.keys(),
50
+ values: () => source.values(),
51
+ forEach: (callback, thisArg) => {
52
+ source.forEach((value) => callback.call(thisArg, value, value, view));
53
+ },
54
+ [Symbol.iterator]: () => source[Symbol.iterator](),
55
+ union: (other) => {
56
+ const result = new Set(source);
57
+ for (const value of iteratorValues(other.keys())) result.add(value);
58
+ return result;
59
+ },
60
+ intersection: (other) => {
61
+ const result = /* @__PURE__ */ new Set();
62
+ for (const value of source) {
63
+ if (other.has(value)) result.add(value);
64
+ }
65
+ return result;
66
+ },
67
+ difference: (other) => {
68
+ const result = /* @__PURE__ */ new Set();
69
+ for (const value of source) {
70
+ if (!other.has(value)) result.add(value);
71
+ }
72
+ return result;
73
+ },
74
+ symmetricDifference: (other) => {
75
+ const result = new Set(source);
76
+ for (const value of iteratorValues(other.keys())) {
77
+ if (source.has(value)) result.delete(value);
78
+ else result.add(value);
79
+ }
80
+ return result;
81
+ },
82
+ isSubsetOf: (other) => {
83
+ for (const value of source) if (!other.has(value)) return false;
84
+ return true;
85
+ },
86
+ isSupersetOf: (other) => {
87
+ for (const value of iteratorValues(other.keys())) {
88
+ if (!source.has(value)) return false;
89
+ }
90
+ return true;
91
+ },
92
+ isDisjointFrom: (other) => {
93
+ for (const value of source) if (other.has(value)) return false;
94
+ return true;
95
+ }
96
+ });
97
+ return view;
98
+ }
99
+ function* iteratorValues(iterator) {
100
+ while (true) {
101
+ const result = iterator.next();
102
+ if (result.done) return;
103
+ yield result.value;
104
+ }
105
+ }
106
+
107
+ // src/errors.ts
108
+ var BoardError = class extends Error {
109
+ constructor(message) {
110
+ super(message);
111
+ this.name = new.target.name;
112
+ }
113
+ };
114
+ var BoardInputError = class extends BoardError {
115
+ };
116
+ var BoardNotFoundError = class extends BoardError {
117
+ };
118
+ var BoardConflictError = class extends BoardError {
119
+ };
120
+ var BoardDestroyedError = class extends BoardError {
121
+ constructor() {
122
+ super("Board engine has been destroyed.");
123
+ }
124
+ };
125
+
126
+ // src/helpers/json.ts
127
+ var DEFAULT_MAX_JSON_DEPTH = 64;
128
+ var DEFAULT_MAX_JSON_VALUES = 25e4;
129
+ var DEFAULT_MAX_JSON_STRING_CHARACTERS = 8e6;
130
+ function createJsonValueBudget() {
131
+ return {
132
+ remainingValues: DEFAULT_MAX_JSON_VALUES,
133
+ remainingStringCharacters: DEFAULT_MAX_JSON_STRING_CHARACTERS
134
+ };
135
+ }
136
+ function consumeBudget(budget, path, stringCharacters = 0) {
137
+ budget.remainingValues -= 1;
138
+ budget.remainingStringCharacters -= stringCharacters;
139
+ if (budget.remainingValues < 0 || budget.remainingStringCharacters < 0) {
140
+ throw new BoardInputError(`${path} exceeds the supported JSON size.`);
141
+ }
142
+ }
143
+ function freezeJsonValue(value, path, ancestors = /* @__PURE__ */ new Set(), budget = createJsonValueBudget(), depth = 0) {
144
+ if (depth > DEFAULT_MAX_JSON_DEPTH) {
145
+ throw new BoardInputError(
146
+ `${path} exceeds the supported JSON nesting depth.`
147
+ );
148
+ }
149
+ consumeBudget(budget, path, typeof value === "string" ? value.length : 0);
150
+ if (value === null || typeof value === "string" || typeof value === "boolean") {
151
+ return value;
152
+ }
153
+ if (typeof value === "number") {
154
+ if (!Number.isFinite(value)) {
155
+ throw new BoardInputError(`${path} must contain only finite numbers.`);
156
+ }
157
+ return value;
158
+ }
159
+ if (typeof value !== "object") {
160
+ throw new BoardInputError(`${path} must contain only JSON values.`);
161
+ }
162
+ if (ancestors.has(value)) {
163
+ throw new BoardInputError(`${path} must not contain cycles.`);
164
+ }
165
+ ancestors.add(value);
166
+ try {
167
+ if (Array.isArray(value)) {
168
+ const ownKeys = Reflect.ownKeys(value);
169
+ for (const key of ownKeys) {
170
+ if (key === "length") continue;
171
+ if (typeof key === "symbol" || !/^(0|[1-9]\d*)$/.test(key)) {
172
+ throw new BoardInputError(
173
+ `${path} must contain only indexed JSON array values.`
174
+ );
175
+ }
176
+ }
177
+ const entries2 = [];
178
+ for (let index = 0; index < value.length; index += 1) {
179
+ const descriptor = Object.getOwnPropertyDescriptor(value, String(index));
180
+ if (!descriptor || !descriptor.enumerable || !("value" in descriptor)) {
181
+ throw new BoardInputError(
182
+ `${path}[${index}] must be an enumerable JSON value.`
183
+ );
184
+ }
185
+ entries2.push(
186
+ freezeJsonValue(
187
+ descriptor.value,
188
+ `${path}[${index}]`,
189
+ ancestors,
190
+ budget,
191
+ depth + 1
192
+ )
193
+ );
194
+ }
195
+ return Object.freeze(entries2);
196
+ }
197
+ const prototype = Object.getPrototypeOf(value);
198
+ if (prototype !== Object.prototype && prototype !== null) {
199
+ throw new BoardInputError(`${path} must contain only plain objects.`);
200
+ }
201
+ const entries = [];
202
+ for (const key of Reflect.ownKeys(value)) {
203
+ if (typeof key === "symbol") {
204
+ throw new BoardInputError(`${path} must not contain symbol keys.`);
205
+ }
206
+ const descriptor = Object.getOwnPropertyDescriptor(value, key);
207
+ if (!descriptor?.enumerable || !("value" in descriptor)) {
208
+ throw new BoardInputError(
209
+ `${path}.${key} must be an enumerable data property.`
210
+ );
211
+ }
212
+ budget.remainingStringCharacters -= key.length;
213
+ if (budget.remainingStringCharacters < 0) {
214
+ throw new BoardInputError(`${path} exceeds the supported JSON size.`);
215
+ }
216
+ entries.push([
217
+ key,
218
+ freezeJsonValue(
219
+ descriptor.value,
220
+ `${path}.${key}`,
221
+ ancestors,
222
+ budget,
223
+ depth + 1
224
+ )
225
+ ]);
226
+ }
227
+ return Object.freeze(Object.fromEntries(entries));
228
+ } finally {
229
+ ancestors.delete(value);
230
+ }
231
+ }
232
+ function freezeJsonObject(value, path, budget = createJsonValueBudget()) {
233
+ const cloned = freezeJsonValue(value, path, /* @__PURE__ */ new Set(), budget);
234
+ if (Array.isArray(cloned) || cloned === null || typeof cloned !== "object") {
235
+ throw new BoardInputError(`${path} must be a JSON object.`);
236
+ }
237
+ return cloned;
238
+ }
239
+ function collectJsonObjectExtras(value, knownKeys, path = "JSON object", budget = createJsonValueBudget()) {
240
+ return Object.freeze(
241
+ Object.fromEntries(
242
+ Object.entries(value).filter(([key]) => !knownKeys.has(key)).map(([key, entry]) => [
243
+ key,
244
+ freezeJsonValue(entry, `${path}.${key}`, /* @__PURE__ */ new Set(), budget)
245
+ ])
246
+ )
247
+ );
248
+ }
249
+
250
+ // src/dom-attributes.ts
251
+ var BOARD_ROOT_ATTRIBUTE = "data-board-root";
252
+ var BOARD_ROOT_SELECTOR = `[${BOARD_ROOT_ATTRIBUTE}="true"]`;
253
+ var BOARD_INTERACTIVE_ATTRIBUTE = "data-board-interactive";
254
+ var BOARD_INTERACTIVE_SELECTOR = `[${BOARD_INTERACTIVE_ATTRIBUTE}="true"]`;
255
+ var BOARD_EDITOR_ATTRIBUTE = "data-editor";
256
+ var BOARD_NODE_ID_ATTRIBUTE = "data-node-id";
257
+ var BOARD_RESIZE_ATTRIBUTE = "data-resize";
258
+ var BOARD_INTERACTIVE_TARGET_SELECTOR = [
259
+ BOARD_INTERACTIVE_SELECTOR,
260
+ `[${BOARD_EDITOR_ATTRIBUTE}="true"]`,
261
+ "input",
262
+ "textarea",
263
+ "select",
264
+ "button",
265
+ "a[href]",
266
+ '[contenteditable]:not([contenteditable="false"])'
267
+ ].join(",");
268
+ function asElement(target) {
269
+ return typeof Element !== "undefined" && target instanceof Element ? target : null;
270
+ }
271
+ function isEventOwnedByBoardRoot(target, root) {
272
+ const element = asElement(target);
273
+ return Boolean(
274
+ element && root && element.closest(BOARD_ROOT_SELECTOR) === root
275
+ );
276
+ }
277
+ function isBoardInteractiveEventTarget(target, options = {}) {
278
+ const element = asElement(target);
279
+ if (options.allowResizeHandle && element?.closest(`[${BOARD_RESIZE_ATTRIBUTE}]`)) {
280
+ return false;
281
+ }
282
+ return Boolean(element?.closest(BOARD_INTERACTIVE_TARGET_SELECTOR));
283
+ }
284
+
285
+ // src/engine/interaction-adapter.ts
286
+ var adapterKey = /* @__PURE__ */ Symbol.for("@lupinum/board-core/interaction-adapter");
287
+ function registerBoardInteractionAdapter(engine, adapter) {
288
+ Object.defineProperty(engine, adapterKey, {
289
+ configurable: true,
290
+ value: adapter,
291
+ enumerable: false
292
+ });
293
+ }
294
+ function getRegisteredBoardInteractionAdapter(engine) {
295
+ const adapter = Reflect.get(engine, adapterKey);
296
+ if (!adapter) {
297
+ throw new BoardInputError(
298
+ "The supplied engine was not created by createBoardEngine()."
299
+ );
300
+ }
301
+ return adapter;
302
+ }
303
+
304
+ // src/internal.ts
305
+ function getBoardInteractionAdapter(engine) {
306
+ return getRegisteredBoardInteractionAdapter(engine);
307
+ }
308
+ function defineInternalBoardPlugin(plugin) {
309
+ return plugin;
310
+ }
311
+ function assertInternalBoardPlugin(plugin) {
312
+ const candidate = plugin;
313
+ if (typeof candidate.name !== "string" || candidate.name.length === 0) {
314
+ throw new Error("Invalid board plugin: expected a named plugin token.");
315
+ }
316
+ if (typeof candidate.install !== "function") {
317
+ throw new Error(
318
+ `Invalid board plugin "${candidate.name}": expected a token created by a first-party plugin factory.`
319
+ );
320
+ }
321
+ }
322
+
323
+ export {
324
+ freezeClone,
325
+ sameArray,
326
+ readonlyMapView,
327
+ readonlySetView,
328
+ BoardError,
329
+ BoardInputError,
330
+ BoardNotFoundError,
331
+ BoardConflictError,
332
+ BoardDestroyedError,
333
+ createJsonValueBudget,
334
+ freezeJsonValue,
335
+ freezeJsonObject,
336
+ collectJsonObjectExtras,
337
+ BOARD_ROOT_ATTRIBUTE,
338
+ BOARD_ROOT_SELECTOR,
339
+ BOARD_INTERACTIVE_ATTRIBUTE,
340
+ BOARD_INTERACTIVE_SELECTOR,
341
+ BOARD_EDITOR_ATTRIBUTE,
342
+ BOARD_NODE_ID_ATTRIBUTE,
343
+ BOARD_RESIZE_ATTRIBUTE,
344
+ isEventOwnedByBoardRoot,
345
+ isBoardInteractiveEventTarget,
346
+ registerBoardInteractionAdapter,
347
+ getBoardInteractionAdapter,
348
+ defineInternalBoardPlugin,
349
+ assertInternalBoardPlugin
350
+ };
@@ -0,0 +1,13 @@
1
+ export declare const BOARD_ROOT_ATTRIBUTE = "data-board-root";
2
+ export declare const BOARD_ROOT_SELECTOR = "[data-board-root=\"true\"]";
3
+ export declare const BOARD_INTERACTIVE_ATTRIBUTE = "data-board-interactive";
4
+ export declare const BOARD_INTERACTIVE_SELECTOR = "[data-board-interactive=\"true\"]";
5
+ export declare const BOARD_EDITOR_ATTRIBUTE = "data-editor";
6
+ export declare const BOARD_NODE_ID_ATTRIBUTE = "data-node-id";
7
+ export declare const BOARD_RESIZE_ATTRIBUTE = "data-resize";
8
+ /** Whether a bubbled DOM event belongs to this board rather than a nested root. */
9
+ export declare function isEventOwnedByBoardRoot(target: EventTarget | null, root: HTMLElement | null): boolean;
10
+ /** Whether native editable or explicitly interactive content owns an event. */
11
+ export declare function isBoardInteractiveEventTarget(target: EventTarget | null, options?: {
12
+ allowResizeHandle?: boolean;
13
+ }): boolean;
@@ -0,0 +1,12 @@
1
+ import type { JsonCanvasPassthrough } from '../state/types.js';
2
+ import type { JsonObject, NodeId } from '../types.js';
3
+ /** Remove one canonical node's private JSON Canvas passthrough fields. */
4
+ export declare function removeNodeExtras(current: JsonCanvasPassthrough, id: NodeId): JsonCanvasPassthrough;
5
+ /** Copy private node fields through an ID remap into the canonical store. */
6
+ export declare function copyNodeExtras(current: JsonCanvasPassthrough, idMap: ReadonlyMap<NodeId, NodeId>, source?: ReadonlyMap<NodeId, JsonObject>): JsonCanvasPassthrough;
7
+ /** Replace all private JSON Canvas fields from a normalized document. */
8
+ export declare function replaceJsonCanvasExtras(incoming: JsonCanvasPassthrough): JsonCanvasPassthrough;
9
+ /** Merge normalized document fields while remapping colliding node IDs. */
10
+ export declare function mergeJsonCanvasExtras(current: JsonCanvasPassthrough, incoming: JsonCanvasPassthrough, idMap: ReadonlyMap<NodeId, NodeId>): JsonCanvasPassthrough;
11
+ /** Select private fields for nodes copied into the internal clipboard. */
12
+ export declare function selectNodeExtras(source: ReadonlyMap<NodeId, JsonObject>, ids: Iterable<NodeId>): Map<NodeId, JsonObject>;
@@ -1,3 +1,4 @@
1
+ import type { JsonCanvasPassthrough } from '../state/types.js';
1
2
  import type { BoardNode, InternalBoardSnapshot, JsonCanvasDocument, JsonCanvasNodeType } from '../types.js';
2
3
  export declare function normalizeNodeType(value: unknown): JsonCanvasNodeType;
3
4
  export declare function withNodeFields(base: Pick<BoardNode, 'id' | 'x' | 'y' | 'width' | 'height' | 'color' | 'zIndex' | 'locked' | 'visible' | 'parentId'> & {
@@ -12,7 +13,8 @@ export declare function withNodeFields(base: Pick<BoardNode, 'id' | 'x' | 'y' |
12
13
  background?: string;
13
14
  backgroundStyle?: string;
14
15
  }): BoardNode;
15
- export declare function toPersistedDocument(snapshot: InternalBoardSnapshot, featureDocuments: Partial<JsonCanvasDocument>[]): JsonCanvasDocument;
16
+ export declare function toPersistedDocument(snapshot: InternalBoardSnapshot, featureDocuments: Partial<JsonCanvasDocument>[], passthrough: JsonCanvasPassthrough): JsonCanvasDocument;
16
17
  export declare function normalizeDocumentForImport(raw: unknown): JsonCanvasDocument;
18
+ export declare function extractJsonCanvasPassthrough(document: JsonCanvasDocument): JsonCanvasPassthrough;
17
19
  export declare function materializeSnapshotNodes(snapshot: InternalBoardSnapshot): BoardNode[];
18
20
  export declare function documentToSnapshot(document: JsonCanvasDocument): InternalBoardSnapshot;
@@ -0,0 +1,8 @@
1
+ import type { BoardEngine, BoardPluginApis } from '../types.js';
2
+ /**
3
+ * Build the public capability boundary explicitly.
4
+ *
5
+ * Keep this whitelist in sync with `BoardEngine`: privileged plugin and pointer
6
+ * methods must never become observable merely because the runtime gains a key.
7
+ */
8
+ export declare function createPublicEngine<TPluginApis extends BoardPluginApis, TPluginEvents>(engine: BoardEngine<TPluginApis>): BoardEngine<TPluginApis, TPluginEvents>;
@@ -0,0 +1,13 @@
1
+ import type { JsonObject, JsonValue } from '../types.js';
2
+ export interface JsonValueBudget {
3
+ remainingValues: number;
4
+ remainingStringCharacters: number;
5
+ }
6
+ /** Create one shared resource budget for an untrusted JSON input boundary. */
7
+ export declare function createJsonValueBudget(): JsonValueBudget;
8
+ /** Clone, validate, and freeze a JSON-compatible value at an input boundary. */
9
+ export declare function freezeJsonValue(value: unknown, path: string, ancestors?: Set<object>, budget?: JsonValueBudget, depth?: number): JsonValue;
10
+ /** Clone, validate, and freeze a JSON object at an input boundary. */
11
+ export declare function freezeJsonObject(value: unknown, path: string, budget?: JsonValueBudget): JsonObject;
12
+ /** Retain unrecognized JSON fields without allowing them to override known fields. */
13
+ export declare function collectJsonObjectExtras(value: Readonly<Record<string, unknown>>, knownKeys: ReadonlySet<string>, path?: string, budget?: JsonValueBudget): JsonObject;
package/dist/index.d.ts CHANGED
@@ -11,4 +11,4 @@ export { getSelectionBounds, getSelectionNodes, toggleIds, } from './selection.j
11
11
  /** Geometry helpers used by renderers and framework adapters. */
12
12
  export { boundsIntersect, clamp, getBoundsFromPoints, getVisibleBounds, } from './math.js';
13
13
  /** Core engine, document, geometry, and node model types. */
14
- export type { BoxSelectBehavior, BoxSelectMode, BoxSelectSettings, BoardColorPreset, BoardState, Bounds, Camera, CanvasColor, BoardEngine, BoardEngineOptions, BoardUnhandledErrorContext, BoardEventMap, BoardPlugin, BoardNode, EdgeId, DuplicateNodesResult, GridPattern, GridSettings, InteractionMode, InteractionState, ValidationFailure, BoardPluginApis, JsonCanvasBackgroundStyle, JsonCanvasDocument, JsonCanvasEdge, JsonCanvasEdgeEnd, JsonCanvasFileNode, JsonCanvasGroupNode, JsonCanvasLinkNode, JsonCanvasNode, JsonCanvasNodeType, JsonCanvasSide, JsonCanvasTextNode, NodeConstraints, NodeId, NodeInput, NodePatch, Point, ResizeHandle, SelectionMode, SnapAxis, SnapGuide, Subscribable, TraceEntry, Unsubscribe, VueBoardDocumentMetadata, VueBoardEdgeMetadata, VueBoardNodeMetadata, ZoomSettings, } from './types.js';
14
+ export type { BoxSelectBehavior, BoxSelectMode, BoxSelectSettings, BoardDocumentMetadata, BoardEdgeMetadata, BoardNodeMetadata, BoardColorPreset, BoardClipboardHooks, BoardState, Bounds, Camera, CanvasColor, BoardEngine, BoardEngineOptions, BoardUnhandledErrorContext, BoardEventMap, BoardPlugin, BoardNode, EdgeId, DuplicateNodesResult, GridPattern, GridSettings, InteractionMode, InteractionState, ValidationFailure, BoardPluginApis, JsonCanvasBackgroundStyle, JsonCanvasDocument, JsonCanvasEdge, JsonCanvasEdgeEnd, JsonCanvasFileNode, JsonCanvasGroupNode, JsonCanvasLinkNode, JsonCanvasNode, JsonCanvasNodeType, JsonCanvasSide, JsonCanvasTextNode, JsonObject, JsonValue, NodeConstraints, NodeId, NodeInput, NodePatch, Point, ResizeHandle, SelectionMode, SnapAxis, SnapGuide, Subscribable, TraceEntry, Unsubscribe, ZoomSettings, } from './types.js';