@canvus/core 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.
@@ -0,0 +1,1120 @@
1
+ // ─────────────────────────────────────────────────────────────
2
+ // canvus/src/shadow-mount.ts
3
+ // Shadow DOM Projection Layer — Lifecycle, Observer, and
4
+ // Geometry Extraction Engine.
5
+ //
6
+ // This module owns the open ShadowRoot that hosts all user HTML
7
+ // fragments. It keeps the shadow layer visually synchronized
8
+ // with the canvas viewport via CSS transforms, drives a
9
+ // ResizeObserver for reflow detection, and exposes the "flat
10
+ // string bridge" for clean HTML extraction.
11
+ // ─────────────────────────────────────────────────────────────
12
+ // ── Reset Stylesheet ────────────────────────────────────────
13
+ /**
14
+ * Injected into the ShadowRoot to isolate user content from the
15
+ * host application's styles. Resets the `:host` display context
16
+ * and enforces `border-box` sizing on all user elements.
17
+ */
18
+ const SHADOW_RESET_CSS = `
19
+ :host(.canvus-no-transitions) * {
20
+ transition: none !important;
21
+ animation: none !important;
22
+ }
23
+
24
+ :host {
25
+ all: initial;
26
+ display: block;
27
+ position: absolute;
28
+ top: 0;
29
+ left: 0;
30
+ width: 0;
31
+ height: 0;
32
+ overflow: visible;
33
+ transform-origin: 0 0;
34
+ pointer-events: none;
35
+ }
36
+
37
+ .canvus-node-wrapper {
38
+ position: absolute;
39
+ pointer-events: auto;
40
+ transform-origin: 0 0;
41
+ overflow: visible;
42
+ display: flex;
43
+ flex-direction: column;
44
+ user-select: none;
45
+ -webkit-user-select: none;
46
+ }
47
+
48
+ .canvus-node-wrapper.canvus-editing {
49
+ user-select: text !important;
50
+ -webkit-user-select: text !important;
51
+ }
52
+
53
+ /* Flow-positioned children inherit their parent's layout mode. */
54
+ .canvus-node-wrapper.canvus-flow-child {
55
+ display: contents;
56
+ }
57
+
58
+ .canvus-node-wrapper > * {
59
+ flex: 1 0 auto;
60
+ min-width: 0;
61
+ min-height: 0;
62
+ }
63
+
64
+ .canvus-node-wrapper * {
65
+ box-sizing: border-box;
66
+ }
67
+ `;
68
+ // ── ShadowMount Class ───────────────────────────────────────
69
+ /**
70
+ * Manages the Shadow DOM projection layer lifecycle.
71
+ *
72
+ * ### Responsibilities
73
+ * 1. Creates a host `<div>` and attaches an open `ShadowRoot`.
74
+ * 2. Mounts user HTML fragments as isolated, absolutely-positioned
75
+ * wrapper nodes inside the shadow tree.
76
+ * 3. Applies viewport CSS transforms to keep the shadow layer
77
+ * visually synchronized with the canvas overlay.
78
+ * 4. Runs a `ResizeObserver` on every mounted wrapper to detect
79
+ * content reflow and fire `RectChangeCallback` notifications.
80
+ * 5. Provides geometry extraction (`.measureNode`, `.measureAll`)
81
+ * and the flat string bridge (`.extractHTML`).
82
+ *
83
+ * ### Coordinate Convention
84
+ * All positions stored and returned by this class are in
85
+ * **canvas-space** (world coordinates). The viewport CSS transform
86
+ * on the host element handles the canvas→screen projection.
87
+ */
88
+ export class ShadowMount {
89
+ // ── Private State ───────────────────────────────────────
90
+ /** The host element appended to the user's container. */
91
+ host;
92
+ /** The open ShadowRoot attached to the host. */
93
+ shadow;
94
+ /** Map of node ID → internal metadata (wrapper + position). */
95
+ nodes = new Map();
96
+ /** Uniform scale applied to host via applied viewport transform. */
97
+ currentScale = 1;
98
+ /**
99
+ * Reverse lookup: wrapper Element → node ID.
100
+ * Required because `ResizeObserver` callbacks receive the
101
+ * observed `Element`, not our application-level ID.
102
+ */
103
+ elementToId = new Map();
104
+ /** Single shared observer watching all mounted wrappers. */
105
+ resizeObserver;
106
+ /** External rect-change callback, or `null` if none provided. */
107
+ onRectChange;
108
+ /**
109
+ * Guard flag to suppress ResizeObserver notifications during
110
+ * our own programmatic style mutations (e.g. `setNodeSize`).
111
+ * Prevents feedback loops where our write triggers an
112
+ * observer read that triggers another write.
113
+ */
114
+ suppressObserver = false;
115
+ /** Whether `dispose()` has been called. */
116
+ disposed = false;
117
+ // ── Constructor ─────────────────────────────────────────
118
+ /**
119
+ * @param container - The parent DOM element to mount into.
120
+ * Typically the workspace root `<div>`.
121
+ * @param onRectChange - Optional callback fired whenever a
122
+ * mounted node's bounding rect changes.
123
+ */
124
+ constructor(container, onRectChange) {
125
+ this.onRectChange = onRectChange ?? null;
126
+ // ── Host Element ──────────────────────────────────────
127
+ this.host = document.createElement("div");
128
+ this.host.setAttribute("data-canvus-shadow-host", "");
129
+ // ── Shadow Root ───────────────────────────────────────
130
+ this.shadow = this.host.attachShadow({ mode: "open" });
131
+ // Inject the isolation reset stylesheet.
132
+ const style = document.createElement("style");
133
+ style.textContent = SHADOW_RESET_CSS;
134
+ this.shadow.appendChild(style);
135
+ // ── ResizeObserver ────────────────────────────────────
136
+ this.resizeObserver = new ResizeObserver((entries) => {
137
+ if (this.suppressObserver)
138
+ return;
139
+ this.handleResizeEntries(entries);
140
+ });
141
+ // Attach to the DOM tree.
142
+ container.appendChild(this.host);
143
+ }
144
+ // ── Node Lifecycle ──────────────────────────────────────
145
+ /**
146
+ * Mounts a `WebHTMLNode` into the shadow tree.
147
+ *
148
+ * Creates an absolutely-positioned wrapper `<div>`, injects
149
+ * the raw markup via `innerHTML`, positions it in canvas-space,
150
+ * and starts observing it for size changes.
151
+ *
152
+ * @param node - The node descriptor to mount.
153
+ * @returns The initial canvas-space bounding rect after the
154
+ * browser has performed synchronous layout.
155
+ * @throws If a node with the same `id` is already mounted.
156
+ */
157
+ addNode(node) {
158
+ this.assertNotDisposed();
159
+ if (this.nodes.has(node.id)) {
160
+ throw new Error(`[ShadowMount] Node "${node.id}" is already mounted. ` +
161
+ `Call removeNode() first or use updateMarkup().`);
162
+ }
163
+ // Check if the wrapper is already present in the shadow tree (e.g. from document importer)
164
+ let wrapper = this.shadow.querySelector(`.canvus-node-wrapper[data-canvus-id="${node.id}"]`);
165
+ const isPreMounted = !!wrapper;
166
+ if (!wrapper) {
167
+ // ── Create Wrapper ──────────────────────────────────
168
+ wrapper = document.createElement("div");
169
+ wrapper.className = "canvus-node-wrapper";
170
+ wrapper.setAttribute("data-canvus-id", node.id);
171
+ // Inject user HTML.
172
+ wrapper.innerHTML = node.rawMarkup;
173
+ // ── Position in Canvas-Space ────────────────────────
174
+ const cx = node.currentRect?.x ?? 0;
175
+ const cy = node.currentRect?.y ?? 0;
176
+ wrapper.style.left = `${cx}px`;
177
+ wrapper.style.top = `${cy}px`;
178
+ // ── Mount to Shadow Tree ────────────────────────────
179
+ this.shadow.appendChild(wrapper);
180
+ }
181
+ // Apply explicit width and height if provided (applies to both pre-mounted and new nodes)
182
+ if (node.currentRect) {
183
+ if (node.currentRect.width > 0) {
184
+ wrapper.style.width = `${node.currentRect.width}px`;
185
+ }
186
+ if (node.currentRect.height > 0) {
187
+ wrapper.style.height = `${node.currentRect.height}px`;
188
+ }
189
+ }
190
+ // ── Position in Canvas-Space ────────────────────────
191
+ const cx = node.currentRect?.x ?? (isPreMounted ? wrapper.offsetLeft : 0);
192
+ const cy = node.currentRect?.y ?? (isPreMounted ? wrapper.offsetTop : 0);
193
+ // ── Sync Grid Styles ────────────────────────────────
194
+ const contentRoot = wrapper.firstElementChild;
195
+ if (contentRoot) {
196
+ const cs = getComputedStyle(contentRoot);
197
+ const gridProps = [
198
+ "grid-column-start",
199
+ "grid-column-end",
200
+ "grid-row-start",
201
+ "grid-row-end",
202
+ "grid-area",
203
+ "grid-column",
204
+ "grid-row",
205
+ ];
206
+ for (const prop of gridProps) {
207
+ const val = cs.getPropertyValue(prop);
208
+ if (val && val !== "auto" && val !== "normal" && val !== "none") {
209
+ wrapper.style.setProperty(prop, val);
210
+ }
211
+ }
212
+ }
213
+ // ── Register Tracking ───────────────────────────────
214
+ const mounted = { wrapper, canvasX: cx, canvasY: cy };
215
+ this.nodes.set(node.id, mounted);
216
+ const targetToObserve = wrapper.firstElementChild || wrapper;
217
+ this.elementToId.set(targetToObserve, node.id);
218
+ // ── Start Observing Reflow ──────────────────────────
219
+ this.resizeObserver.observe(targetToObserve);
220
+ const dims = this.getBoundingBoxCanvasSpace(targetToObserve);
221
+ const rect = {
222
+ x: cx,
223
+ y: cy,
224
+ width: dims.width,
225
+ height: dims.height,
226
+ };
227
+ return rect;
228
+ }
229
+ /**
230
+ * Mounts a node as a child of another mounted node.
231
+ *
232
+ * The child wrapper is inserted inside the parent's wrapper
233
+ * (not at the shadow root) and uses `position: relative` so
234
+ * it participates in the parent's CSS layout flow (flex, grid,
235
+ * block).
236
+ *
237
+ * @param node - The node descriptor to mount.
238
+ * @param parentId - The ID of the parent node.
239
+ * @param index - Optional insertion index within the parent's
240
+ * DOM children. Defaults to appending at the end.
241
+ * @returns The initial canvas-space bounding rect.
242
+ * @throws If the parent is not mounted or the node ID already exists.
243
+ */
244
+ addChildNode(node, parentId, index) {
245
+ this.assertNotDisposed();
246
+ if (this.nodes.has(node.id)) {
247
+ throw new Error(`[ShadowMount] Node "${node.id}" is already mounted.`);
248
+ }
249
+ const parent = this.nodes.get(parentId);
250
+ if (!parent) {
251
+ throw new Error(`[ShadowMount] Parent node "${parentId}" is not mounted.`);
252
+ }
253
+ // ── Locate or create the child element ──────────────────
254
+ // Priority 1: pre-mounted wrapper (legacy path)
255
+ let wrapper = this.shadow.querySelector(`.canvus-node-wrapper[data-canvus-id="${node.id}"]`);
256
+ let isDirect = false;
257
+ if (!wrapper) {
258
+ // Priority 2: direct element marked by the importer (no wrapper div)
259
+ const directEl = this.shadow.querySelector(`[data-canvus-id="${node.id}"]:not(.canvus-node-wrapper)`);
260
+ if (directEl) {
261
+ wrapper = directEl;
262
+ isDirect = true;
263
+ }
264
+ }
265
+ if (!wrapper) {
266
+ // Fallback: programmatic addChildNode — insert raw markup directly
267
+ // as a child of the parent's content root, no wrapper div.
268
+ const parentContentRoot = this.getContentRootInternal(parent);
269
+ const insertTarget = parentContentRoot ?? parent.wrapper;
270
+ const temp = document.createElement("div");
271
+ temp.innerHTML = node.rawMarkup;
272
+ const newElement = temp.firstElementChild;
273
+ if (newElement) {
274
+ newElement.setAttribute("data-canvus-id", node.id);
275
+ // Insert at the specified index if provided.
276
+ const existingChildren = insertTarget.querySelectorAll(":scope > [data-canvus-id]");
277
+ if (index !== undefined && index >= 0 && index < existingChildren.length) {
278
+ insertTarget.insertBefore(newElement, existingChildren[index] ?? null);
279
+ }
280
+ else {
281
+ insertTarget.appendChild(newElement);
282
+ }
283
+ wrapper = newElement;
284
+ isDirect = true;
285
+ }
286
+ else {
287
+ // Fallback to wrapper-based approach for text-only nodes
288
+ const wrapperDiv = document.createElement("div");
289
+ wrapperDiv.className = "canvus-node-wrapper canvus-flow-child";
290
+ wrapperDiv.setAttribute("data-canvus-id", node.id);
291
+ wrapperDiv.innerHTML = node.rawMarkup;
292
+ insertTarget.appendChild(wrapperDiv);
293
+ wrapper = wrapperDiv;
294
+ }
295
+ }
296
+ // Apply explicit dimensions if provided.
297
+ if (node.currentRect) {
298
+ if (node.currentRect.width > 0) {
299
+ wrapper.style.width = `${node.currentRect.width}px`;
300
+ }
301
+ if (node.currentRect.height > 0) {
302
+ wrapper.style.height = `${node.currentRect.height}px`;
303
+ }
304
+ }
305
+ // Grid style sync only needed for wrapper-based nodes (the wrapper
306
+ // needs grid placement copied from the content root). Direct elements
307
+ // already participate in the parent grid natively.
308
+ if (!isDirect) {
309
+ const contentRoot = wrapper.firstElementChild;
310
+ if (contentRoot) {
311
+ const cs = getComputedStyle(contentRoot);
312
+ const gridProps = [
313
+ "grid-column-start",
314
+ "grid-column-end",
315
+ "grid-row-start",
316
+ "grid-row-end",
317
+ "grid-area",
318
+ "grid-column",
319
+ "grid-row",
320
+ ];
321
+ for (const prop of gridProps) {
322
+ const val = cs.getPropertyValue(prop);
323
+ if (val && val !== "auto" && val !== "normal" && val !== "none") {
324
+ wrapper.style.setProperty(prop, val);
325
+ }
326
+ }
327
+ }
328
+ }
329
+ // Register tracking.
330
+ const mounted = { wrapper, canvasX: 0, canvasY: 0, isDirect };
331
+ this.nodes.set(node.id, mounted);
332
+ const targetToObserve = isDirect
333
+ ? wrapper
334
+ : (wrapper.firstElementChild || wrapper);
335
+ this.elementToId.set(targetToObserve, node.id);
336
+ this.resizeObserver.observe(targetToObserve);
337
+ // Measure canvas-space rect (accounts for nesting).
338
+ const rect = this.measureNodeCanvasSpace(node.id) ?? {
339
+ x: 0, y: 0,
340
+ width: this.getBoundingBoxCanvasSpace(targetToObserve).width,
341
+ height: this.getBoundingBoxCanvasSpace(targetToObserve).height,
342
+ };
343
+ // Update tracked position.
344
+ mounted.canvasX = rect.x;
345
+ mounted.canvasY = rect.y;
346
+ return rect;
347
+ }
348
+ /**
349
+ * Unmounts and destroys a node by ID.
350
+ *
351
+ * Stops observing, removes the wrapper from the shadow tree,
352
+ * and cleans up all internal references.
353
+ *
354
+ * @param id - The node ID to remove.
355
+ * @returns `true` if the node existed and was removed.
356
+ */
357
+ removeNode(id) {
358
+ const mounted = this.nodes.get(id);
359
+ if (!mounted)
360
+ return false;
361
+ // Clean up dynamic scripts appended for this node
362
+ const scriptElements = this.shadow.querySelectorAll(`script[data-canvus-script-id^="${id}:"]`);
363
+ for (const el of Array.from(scriptElements)) {
364
+ el.remove();
365
+ }
366
+ const targetToObserve = mounted.isDirect
367
+ ? mounted.wrapper
368
+ : (mounted.wrapper.firstElementChild || mounted.wrapper);
369
+ this.resizeObserver.unobserve(targetToObserve);
370
+ this.elementToId.delete(targetToObserve);
371
+ mounted.wrapper.remove();
372
+ this.nodes.delete(id);
373
+ return true;
374
+ }
375
+ /**
376
+ * Registers an existing DOM element for tracking without modifying
377
+ * the DOM structure. Used for lazy child registration: when the user
378
+ * drills into a node, its immediate children are tracked so they
379
+ * get hover states, selection handles, resize, and drag.
380
+ *
381
+ * The element receives a `data-canvus-id` attribute for identity,
382
+ * but NO wrapper div is added — CSS selectors remain intact.
383
+ *
384
+ * @param id - The node ID to assign.
385
+ * @param element - The existing DOM element to track.
386
+ * @returns The element's canvas-space bounding rect, or null.
387
+ */
388
+ trackExistingElement(id, element) {
389
+ this.assertNotDisposed();
390
+ if (this.nodes.has(id)) {
391
+ return this.measureNodeCanvasSpace(id);
392
+ }
393
+ // Tag the element for identity (non-destructive — just a data attribute)
394
+ element.setAttribute("data-canvus-id", id);
395
+ // Register tracking
396
+ const mounted = {
397
+ wrapper: element,
398
+ canvasX: 0,
399
+ canvasY: 0,
400
+ isDirect: true,
401
+ };
402
+ this.nodes.set(id, mounted);
403
+ this.elementToId.set(element, id);
404
+ this.resizeObserver.observe(element);
405
+ // Measure canvas-space rect
406
+ const rect = this.measureNodeCanvasSpace(id) ?? {
407
+ x: 0,
408
+ y: 0,
409
+ width: this.getBoundingBoxCanvasSpace(element).width,
410
+ height: this.getBoundingBoxCanvasSpace(element).height,
411
+ };
412
+ mounted.canvasX = rect.x;
413
+ mounted.canvasY = rect.y;
414
+ return rect;
415
+ }
416
+ /**
417
+ * Stops tracking a node without removing the DOM element.
418
+ * The inverse of `trackExistingElement` — cleans up the
419
+ * `data-canvus-id` attribute, ResizeObserver, and internal maps,
420
+ * but leaves the element in the DOM untouched.
421
+ *
422
+ * Used for lazy deregistration when the user drills back up
423
+ * or deselects a parent node.
424
+ *
425
+ * @param id - The node ID to stop tracking.
426
+ * @returns `true` if the node was being tracked and was untracked.
427
+ */
428
+ untrackNode(id) {
429
+ const mounted = this.nodes.get(id);
430
+ if (!mounted)
431
+ return false;
432
+ // Only untrack direct (wrapper-less) nodes.
433
+ // Wrapper-based nodes should use removeNode() instead.
434
+ if (!mounted.isDirect)
435
+ return false;
436
+ // Stop observing
437
+ this.resizeObserver.unobserve(mounted.wrapper);
438
+ this.elementToId.delete(mounted.wrapper);
439
+ // Clean up the data attribute
440
+ mounted.wrapper.removeAttribute("data-canvus-id");
441
+ // Remove from tracking
442
+ this.nodes.delete(id);
443
+ return true;
444
+ }
445
+ /**
446
+ * Moves a node's DOM wrapper from its current parent into a
447
+ * new parent's wrapper at the specified index.
448
+ *
449
+ * If `newParentId` is `null`, the node is moved to the shadow
450
+ * root and becomes absolutely positioned (root-level node).
451
+ *
452
+ * @param id - The node to move.
453
+ * @param newParentId - The new parent ID, or `null` for root.
454
+ * @param index - Insertion index in the new parent.
455
+ */
456
+ reparentNodeDOM(id, newParentId, index) {
457
+ const mounted = this.nodes.get(id);
458
+ if (!mounted)
459
+ return;
460
+ // Suppress observer during reparenting to avoid stale callbacks.
461
+ this.suppressObserver = true;
462
+ // Detach from current location.
463
+ mounted.wrapper.remove();
464
+ if (newParentId === null) {
465
+ // Move to shadow root — become absolutely positioned.
466
+ mounted.wrapper.classList.remove("canvus-flow-child");
467
+ mounted.wrapper.style.position = "absolute";
468
+ this.shadow.appendChild(mounted.wrapper);
469
+ }
470
+ else {
471
+ const newParent = this.nodes.get(newParentId);
472
+ if (!newParent) {
473
+ this.suppressObserver = false;
474
+ throw new Error(`[ShadowMount] New parent "${newParentId}" is not mounted.`);
475
+ }
476
+ // Become a flow child.
477
+ if (!mounted.isDirect) {
478
+ mounted.wrapper.classList.add("canvus-flow-child");
479
+ }
480
+ mounted.wrapper.style.position = "";
481
+ mounted.wrapper.style.left = "auto";
482
+ mounted.wrapper.style.top = "auto";
483
+ // Insert into parent's CONTENT ROOT (user's markup root).
484
+ const parentContentRoot = this.getContentRootInternal(newParent);
485
+ const insertTarget = parentContentRoot ?? newParent.wrapper;
486
+ const parentChildren = insertTarget.querySelectorAll(":scope > .canvus-node-wrapper, :scope > [data-canvus-id]");
487
+ if (index !== undefined && index >= 0 && index < parentChildren.length) {
488
+ insertTarget.insertBefore(mounted.wrapper, parentChildren[index] ?? null);
489
+ }
490
+ else {
491
+ insertTarget.appendChild(mounted.wrapper);
492
+ }
493
+ }
494
+ this.suppressObserver = false;
495
+ }
496
+ /**
497
+ * Replaces the inner HTML content of an already-mounted node.
498
+ *
499
+ * Preserves the wrapper's position and size constraints.
500
+ * After the markup swap, forces a synchronous layout read
501
+ * and fires the rect-change callback if dimensions changed.
502
+ *
503
+ * @param id - The mounted node's ID.
504
+ * @param markup - The new raw HTML fragment string.
505
+ * @returns The new canvas-space bounding rect, or `null` if
506
+ * the node is not mounted.
507
+ */
508
+ updateMarkup(id, markup) {
509
+ const mounted = this.nodes.get(id);
510
+ if (!mounted)
511
+ return null;
512
+ // Suppress observer during our own mutation to avoid
513
+ // a redundant callback before we've finished measuring.
514
+ this.suppressObserver = true;
515
+ mounted.wrapper.innerHTML = markup;
516
+ this.suppressObserver = false;
517
+ // Sync layout read.
518
+ const rect = this.readWrapperRect(mounted);
519
+ // Notify consumer.
520
+ this.onRectChange?.(id, rect);
521
+ return rect;
522
+ }
523
+ /**
524
+ * Returns whether a node with the given ID is currently mounted.
525
+ */
526
+ hasNode(id) {
527
+ return this.nodes.has(id);
528
+ }
529
+ /**
530
+ * Returns an array of all currently mounted node IDs.
531
+ */
532
+ getNodeIds() {
533
+ return Array.from(this.nodes.keys());
534
+ }
535
+ // ── Viewport Synchronization ────────────────────────────
536
+ /**
537
+ * Applies a CSS transform to the shadow host so that all
538
+ * child wrappers (positioned in canvas-space) are projected
539
+ * correctly onto the screen in sync with the canvas overlay.
540
+ *
541
+ * Must be called every time the viewport changes (pan/zoom).
542
+ *
543
+ * The transform maps canvas-space → screen-space:
544
+ * `translate(offsetX, offsetY) scale(scale)`
545
+ *
546
+ * @param viewport - The current viewport matrix state.
547
+ */
548
+ applyViewportTransform(viewport) {
549
+ this.assertNotDisposed();
550
+ this.currentScale = viewport.scale;
551
+ this.host.style.transform =
552
+ `translate(${viewport.offsetX}px, ${viewport.offsetY}px) scale(${viewport.scale})`;
553
+ }
554
+ // ── Geometry Extraction ─────────────────────────────────
555
+ /**
556
+ * Returns the wrapper DOM element for a mounted node.
557
+ * Useful for layout introspection (reading getComputedStyle).
558
+ *
559
+ * @param id - The node ID.
560
+ * @returns The wrapper element, or `null` if not mounted.
561
+ */
562
+ getWrapper(id) {
563
+ return this.nodes.get(id)?.wrapper ?? null;
564
+ }
565
+ /**
566
+ * Returns the content root element for a mounted node by its ID.
567
+ * For wrapper-based nodes, this is `wrapper.firstElementChild`.
568
+ * For direct (wrapper-less) nodes, the wrapper IS the content root.
569
+ *
570
+ * @param id - The node ID.
571
+ * @returns The content root element, or `null` if not mounted.
572
+ */
573
+ getContentRoot(id) {
574
+ const mounted = this.nodes.get(id);
575
+ if (!mounted)
576
+ return null;
577
+ return this.getContentRootInternal(mounted);
578
+ }
579
+ /**
580
+ * Temporarily disables or re-enables all CSS transitions and animations
581
+ * inside the shadow DOM (useful to avoid layout lag during drag-and-drop).
582
+ */
583
+ setTransitionsEnabled(enabled) {
584
+ if (enabled) {
585
+ this.host.classList.remove("canvus-no-transitions");
586
+ }
587
+ else {
588
+ this.host.classList.add("canvus-no-transitions");
589
+ }
590
+ }
591
+ /**
592
+ * Reads the current canvas-space bounding rect of a mounted
593
+ * node by performing a synchronous layout query.
594
+ *
595
+ * Uses `offsetWidth` / `offsetHeight` (which return pre-transform
596
+ * layout dimensions) combined with our tracked canvas-space
597
+ * position to avoid inverse-transform math.
598
+ *
599
+ * @param id - The node ID to measure.
600
+ * @returns The canvas-space bounding rect, or `null` if not mounted.
601
+ */
602
+ measureNode(id) {
603
+ return this.measureNodeCanvasSpace(id);
604
+ }
605
+ /**
606
+ * Batch-measures all mounted nodes in a single pass.
607
+ *
608
+ * Returns a `Map<id, Rect>` of canvas-space bounding rects.
609
+ * Triggers a single synchronous reflow for the entire batch.
610
+ *
611
+ * This is the "Geometry Extraction Loop" from the architecture
612
+ * spec — a fast initialization sweep to populate state caches.
613
+ */
614
+ measureAll() {
615
+ const results = new Map();
616
+ for (const id of this.nodes.keys()) {
617
+ const rect = this.measureNodeCanvasSpace(id);
618
+ if (rect)
619
+ results.set(id, rect);
620
+ }
621
+ return results;
622
+ }
623
+ // ── Style Surgery (Direct Mutation) ─────────────────────
624
+ /**
625
+ * Moves a node to a new canvas-space position by directly
626
+ * mutating its inline `left` / `top` styles.
627
+ *
628
+ * This is the "Transient Style Surgery Pass" for drag-node
629
+ * interactions — no async message bus, just a direct write.
630
+ *
631
+ * @param id - The node ID to reposition.
632
+ * @param x - New canvas-space X position.
633
+ * @param y - New canvas-space Y position.
634
+ */
635
+ setNodePosition(id, x, y) {
636
+ const mounted = this.nodes.get(id);
637
+ if (!mounted)
638
+ return;
639
+ mounted.canvasX = x;
640
+ mounted.canvasY = y;
641
+ mounted.wrapper.style.left = `${x}px`;
642
+ mounted.wrapper.style.top = `${y}px`;
643
+ }
644
+ /**
645
+ * Sets explicit width and/or height on a node's wrapper.
646
+ *
647
+ * This is the "Transient Style Surgery Pass" for resize-node
648
+ * interactions. The browser will reflow the inner content
649
+ * (e.g. text wrapping) synchronously, and the ResizeObserver
650
+ * will fire a rect-change callback with the new dimensions.
651
+ *
652
+ * Pass `null` for either dimension to leave it unchanged.
653
+ * Pass `"auto"` to clear an explicit dimension and let content
654
+ * determine the size.
655
+ *
656
+ * @param id - The node ID to resize.
657
+ * @param width - New width in canvas-space pixels, `"auto"`, or `null`.
658
+ * @param height - New height in canvas-space pixels, `"auto"`, or `null`.
659
+ */
660
+ setNodeSize(id, width, height) {
661
+ const mounted = this.nodes.get(id);
662
+ if (!mounted)
663
+ return;
664
+ if (width !== null) {
665
+ mounted.wrapper.style.width =
666
+ width === "auto" ? "auto" : `${width}px`;
667
+ }
668
+ if (height !== null) {
669
+ mounted.wrapper.style.height =
670
+ height === "auto" ? "auto" : `${height}px`;
671
+ }
672
+ }
673
+ /**
674
+ * Convenience: sets both position and size in a single call.
675
+ * Useful during resize-from-anchor operations where both
676
+ * origin and dimensions change simultaneously.
677
+ */
678
+ setNodeRect(id, rect) {
679
+ this.setNodePosition(id, rect.x, rect.y);
680
+ this.setNodeSize(id, rect.width, rect.height);
681
+ }
682
+ /**
683
+ * Sets a single CSS style property directly on the node's content element
684
+ * (the first child element of the wrapper), and synchronizes width/height
685
+ * wrapper bounds if applicable.
686
+ */
687
+ setNodeStyle(id, property, value) {
688
+ const mounted = this.nodes.get(id);
689
+ if (!mounted)
690
+ return;
691
+ const contentRoot = this.getContentRootInternal(mounted);
692
+ if (!contentRoot)
693
+ return;
694
+ if (value === null || value === "") {
695
+ contentRoot.style.removeProperty(property);
696
+ }
697
+ else {
698
+ contentRoot.style.setProperty(property, value);
699
+ }
700
+ // Synchronize geometry styling with SDK wrapper chrome
701
+ // (only needed for wrapper-based nodes)
702
+ if (!mounted.isDirect) {
703
+ if (property === "width") {
704
+ if (value === null || value === "" || value === "auto") {
705
+ this.setNodeSize(id, "auto", null);
706
+ }
707
+ else if (value.endsWith("px")) {
708
+ const val = parseFloat(value);
709
+ if (!isNaN(val))
710
+ this.setNodeSize(id, val, null);
711
+ }
712
+ }
713
+ else if (property === "height") {
714
+ if (value === null || value === "" || value === "auto") {
715
+ this.setNodeSize(id, null, "auto");
716
+ }
717
+ else if (value.endsWith("px")) {
718
+ const val = parseFloat(value);
719
+ if (!isNaN(val))
720
+ this.setNodeSize(id, null, val);
721
+ }
722
+ }
723
+ // Synchronize grid placement styles with the wrapper
724
+ if (property.startsWith("grid-") ||
725
+ property === "grid" ||
726
+ property === "grid-area") {
727
+ if (value === null || value === "") {
728
+ mounted.wrapper.style.removeProperty(property);
729
+ }
730
+ else {
731
+ mounted.wrapper.style.setProperty(property, value);
732
+ }
733
+ }
734
+ }
735
+ }
736
+ /**
737
+ * Sets multiple CSS style properties directly on the node's content element
738
+ * (the first child element of the wrapper) in a single batch.
739
+ */
740
+ setNodeStyles(id, styles) {
741
+ const mounted = this.nodes.get(id);
742
+ if (!mounted)
743
+ return;
744
+ const contentRoot = this.getContentRootInternal(mounted);
745
+ if (!contentRoot)
746
+ return;
747
+ for (const [property, value] of Object.entries(styles)) {
748
+ if (value === null || value === "") {
749
+ contentRoot.style.removeProperty(property);
750
+ }
751
+ else {
752
+ contentRoot.style.setProperty(property, value);
753
+ }
754
+ // Synchronize geometry styling with SDK wrapper chrome
755
+ // (only needed for wrapper-based nodes)
756
+ if (!mounted.isDirect) {
757
+ if (property === "width") {
758
+ if (value === null || value === "" || value === "auto") {
759
+ this.setNodeSize(id, "auto", null);
760
+ }
761
+ else if (value.endsWith("px")) {
762
+ const val = parseFloat(value);
763
+ if (!isNaN(val))
764
+ this.setNodeSize(id, val, null);
765
+ }
766
+ }
767
+ else if (property === "height") {
768
+ if (value === null || value === "" || value === "auto") {
769
+ this.setNodeSize(id, null, "auto");
770
+ }
771
+ else if (value.endsWith("px")) {
772
+ const val = parseFloat(value);
773
+ if (!isNaN(val))
774
+ this.setNodeSize(id, null, val);
775
+ }
776
+ }
777
+ // Synchronize grid placement styles with the wrapper
778
+ if (property.startsWith("grid-") ||
779
+ property === "grid" ||
780
+ property === "grid-area") {
781
+ if (value === null || value === "") {
782
+ mounted.wrapper.style.removeProperty(property);
783
+ }
784
+ else {
785
+ mounted.wrapper.style.setProperty(property, value);
786
+ }
787
+ }
788
+ }
789
+ }
790
+ }
791
+ /**
792
+ * Computes the canvas-space bounding rect of a node by walking
793
+ * the `offsetLeft`/`offsetTop` chain up to the shadow host.
794
+ *
795
+ * This handles arbitrarily nested elements — each child's offset
796
+ * is accumulated relative to its offsetParent until we reach the
797
+ * shadow host (the transform origin).
798
+ *
799
+ * The result is in **canvas-space** (pre-viewport-transform),
800
+ * consistent with all other rect measurements in the SDK.
801
+ */
802
+ measureNodeCanvasSpace(id) {
803
+ const mounted = this.nodes.get(id);
804
+ if (!mounted)
805
+ return null;
806
+ const wrapper = mounted.wrapper;
807
+ const target = mounted.isDirect
808
+ ? wrapper
809
+ : (wrapper.firstElementChild || wrapper);
810
+ const rect = this.getBoundingBoxCanvasSpace(target);
811
+ // Update the tracked position.
812
+ mounted.canvasX = rect.x;
813
+ mounted.canvasY = rect.y;
814
+ return rect;
815
+ }
816
+ /**
817
+ * Computes the accumulated CSS zoom and transform scale factors of an element
818
+ * relative to the shadow root host.
819
+ */
820
+ getElementScale(element) {
821
+ this.assertNotDisposed();
822
+ let scale = 1;
823
+ let curr = element;
824
+ while (curr && curr !== this.host) {
825
+ const cs = getComputedStyle(curr);
826
+ // 1. Account for CSS zoom
827
+ const zoom = parseFloat(cs.zoom) || 1;
828
+ scale *= zoom;
829
+ // 2. Account for CSS transform scale (2D matrix)
830
+ const transform = cs.transform;
831
+ if (transform && transform !== "none") {
832
+ const match = transform.match(/^matrix\(([^,]+),\s*([^,]+),\s*([^,]+),\s*([^,]+)/);
833
+ if (match) {
834
+ const a = parseFloat(match[1]);
835
+ const b = parseFloat(match[2]);
836
+ const s = Math.sqrt(a * a + b * b);
837
+ scale *= s;
838
+ }
839
+ }
840
+ // Traverse up parent chain, crossing Shadow DOM boundaries if necessary.
841
+ let parent = curr.parentElement || curr.parentNode;
842
+ if (parent && parent.host) {
843
+ parent = parent.host;
844
+ }
845
+ if (parent === curr)
846
+ break;
847
+ curr = parent;
848
+ }
849
+ return scale;
850
+ }
851
+ // ── Flat String Bridge ──────────────────────────────────
852
+ /**
853
+ * Extracts the pristine semantic HTML string from a mounted
854
+ * node's wrapper. Returns the `.innerHTML` of the wrapper,
855
+ * which is the user's manipulated HTML fragment without any
856
+ * SDK wrapper chrome.
857
+ *
858
+ * This is the "Flat String Bridge" output described in the
859
+ * architecture spec — clean HTML ready for AST commit.
860
+ *
861
+ * @param id - The node ID to extract HTML from.
862
+ * @returns The inner HTML string, or `null` if not mounted.
863
+ */
864
+ extractHTML(id) {
865
+ const mounted = this.nodes.get(id);
866
+ if (!mounted)
867
+ return null;
868
+ // Get the user's content element.
869
+ const contentRoot = this.getContentRootInternal(mounted);
870
+ if (!contentRoot) {
871
+ return mounted.wrapper.innerHTML;
872
+ }
873
+ // Clone the content element to avoid modifying the active DOM.
874
+ const clone = contentRoot.cloneNode(true);
875
+ // Remove SDK tracking attribute from the clone.
876
+ if (mounted.isDirect) {
877
+ clone.removeAttribute("data-canvus-id");
878
+ }
879
+ // Clean up forced state classes if present
880
+ clone.classList.remove("canvus-state-hover", "canvus-state-active", "canvus-state-focus");
881
+ const descendantsWithStates = clone.querySelectorAll(".canvus-state-hover, .canvus-state-active, .canvus-state-focus");
882
+ for (const el of descendantsWithStates) {
883
+ el.classList.remove("canvus-state-hover", "canvus-state-active", "canvus-state-focus");
884
+ }
885
+ // Find all child markers (both wrapper-based and direct elements).
886
+ const childMarkers = clone.querySelectorAll(".canvus-node-wrapper[data-canvus-id], [data-canvus-id]");
887
+ for (const marker of childMarkers) {
888
+ // Skip the clone root itself (relevant for direct elements).
889
+ if (marker === clone)
890
+ continue;
891
+ const childId = marker.getAttribute("data-canvus-id");
892
+ if (childId) {
893
+ // Recursively extract the clean HTML for this child.
894
+ const cleanChildHTML = this.extractHTML(childId);
895
+ if (cleanChildHTML !== null) {
896
+ const temp = document.createElement("div");
897
+ temp.innerHTML = cleanChildHTML;
898
+ const cleanChildNode = temp.firstElementChild;
899
+ if (cleanChildNode) {
900
+ marker.replaceWith(cleanChildNode);
901
+ }
902
+ else {
903
+ marker.remove();
904
+ }
905
+ }
906
+ else {
907
+ marker.remove();
908
+ }
909
+ }
910
+ else {
911
+ marker.remove();
912
+ }
913
+ }
914
+ return clone.outerHTML;
915
+ }
916
+ /**
917
+ * Extracts the outer HTML of the wrapper (includes the wrapper
918
+ * `<div>` itself). Useful for debugging or serialization that
919
+ * needs the positioning context.
920
+ *
921
+ * @param id - The node ID to extract.
922
+ * @returns The outer HTML string, or `null` if not mounted.
923
+ */
924
+ extractOuterHTML(id) {
925
+ const mounted = this.nodes.get(id);
926
+ if (!mounted)
927
+ return null;
928
+ return mounted.wrapper.outerHTML;
929
+ }
930
+ // ── Direct Wrapper Access ───────────────────────────────
931
+ /**
932
+ * Returns the `ShadowRoot` reference.
933
+ * Useful for injecting additional stylesheets (e.g. user theme
934
+ * CSS, Google Fonts `@import`, Tailwind resets).
935
+ */
936
+ getShadowRoot() {
937
+ return this.shadow;
938
+ }
939
+ // ── Stylesheet Injection ────────────────────────────────
940
+ /**
941
+ * Injects an additional `<style>` element into the shadow root.
942
+ * Returns the created element so it can be removed later.
943
+ *
944
+ * @param css - Raw CSS text to inject.
945
+ * @returns The created `HTMLStyleElement`.
946
+ */
947
+ injectStylesheet(css) {
948
+ this.assertNotDisposed();
949
+ const el = document.createElement("style");
950
+ el.textContent = rewriteForShadowDOM(css);
951
+ this.shadow.appendChild(el);
952
+ return el;
953
+ }
954
+ /**
955
+ * Injects a `<link rel="stylesheet">` into the shadow root
956
+ * for loading external CSS (e.g. Google Fonts, Tailwind CDN).
957
+ *
958
+ * @param href - The stylesheet URL.
959
+ * @returns A promise that resolves when the stylesheet loads,
960
+ * or rejects on error.
961
+ */
962
+ injectStylesheetLink(href) {
963
+ this.assertNotDisposed();
964
+ const link = document.createElement("link");
965
+ link.rel = "stylesheet";
966
+ link.href = href;
967
+ const promise = new Promise((resolve, reject) => {
968
+ link.onload = () => resolve(link);
969
+ link.onerror = () => reject(new Error(`[ShadowMount] Failed to load stylesheet: ${href}`));
970
+ });
971
+ this.shadow.appendChild(link);
972
+ return promise;
973
+ }
974
+ /**
975
+ * Evaluates a script string inside a scoped closure where 'document' and 'window'
976
+ * are proxied to target the ShadowRoot.
977
+ */
978
+ executeScopedScript(code, context) {
979
+ this.assertNotDisposed();
980
+ const shadowRoot = this.shadow;
981
+ const callContext = context ?? shadowRoot.firstElementChild ?? shadowRoot;
982
+ const documentProxy = new Proxy(document, {
983
+ get(target, prop, receiver) {
984
+ if (prop === "querySelector" ||
985
+ prop === "querySelectorAll" ||
986
+ prop === "getElementById" ||
987
+ prop === "getElementsByClassName" ||
988
+ prop === "getElementsByTagName") {
989
+ const shadowMethod = shadowRoot[prop];
990
+ if (typeof shadowMethod === "function") {
991
+ return (...args) => {
992
+ return shadowMethod.apply(shadowRoot, args);
993
+ };
994
+ }
995
+ }
996
+ if (prop === "body") {
997
+ return shadowRoot.firstElementChild || shadowRoot;
998
+ }
999
+ const val = Reflect.get(target, prop, receiver);
1000
+ if (typeof val === "function") {
1001
+ return val.bind(target);
1002
+ }
1003
+ return val;
1004
+ }
1005
+ });
1006
+ const windowProxy = new Proxy(window, {
1007
+ get(target, prop, receiver) {
1008
+ if (prop === "document") {
1009
+ return documentProxy;
1010
+ }
1011
+ const val = Reflect.get(target, prop, receiver);
1012
+ if (typeof val === "function") {
1013
+ return val.bind(target);
1014
+ }
1015
+ return val;
1016
+ }
1017
+ });
1018
+ try {
1019
+ const fn = new Function("document", "window", code);
1020
+ fn.call(callContext, documentProxy, windowProxy);
1021
+ }
1022
+ catch (err) {
1023
+ console.error(`[ShadowMount] Error executing scoped script:`, err);
1024
+ }
1025
+ }
1026
+ // ── Disposal ────────────────────────────────────────────
1027
+ /**
1028
+ * Tears down the entire shadow mount.
1029
+ *
1030
+ * Disconnects the ResizeObserver, removes all wrappers,
1031
+ * detaches the host element from the DOM, and clears all
1032
+ * internal maps. After calling `dispose()`, the instance
1033
+ * is inert — all mutating methods will throw.
1034
+ */
1035
+ dispose() {
1036
+ if (this.disposed)
1037
+ return;
1038
+ this.disposed = true;
1039
+ this.resizeObserver.disconnect();
1040
+ this.elementToId.clear();
1041
+ this.nodes.clear();
1042
+ this.host.remove();
1043
+ }
1044
+ // ── Private Helpers ─────────────────────────────────────
1045
+ /**
1046
+ * Reads the canvas-space bounding rect of a mounted wrapper
1047
+ * using pre-transform layout dimensions.
1048
+ */
1049
+ readWrapperRect(mounted) {
1050
+ return this.getBoundingBoxCanvasSpace(mounted.wrapper);
1051
+ }
1052
+ /**
1053
+ * Computes the bounding box of an element in canvas-space relative to the shadow host.
1054
+ * Handles scale adjustments correctly and is robust for all elements including SVGs.
1055
+ */
1056
+ getBoundingBoxCanvasSpace(el) {
1057
+ const elRect = el.getBoundingClientRect();
1058
+ const hostRect = this.host.getBoundingClientRect();
1059
+ const scale = this.currentScale || 1;
1060
+ return {
1061
+ x: (elRect.left - hostRect.left) / scale,
1062
+ y: (elRect.top - hostRect.top) / scale,
1063
+ width: elRect.width / scale,
1064
+ height: elRect.height / scale,
1065
+ };
1066
+ }
1067
+ /**
1068
+ * Returns the content root element for a mounted node.
1069
+ * For wrapper-based nodes, this is `wrapper.firstElementChild`.
1070
+ * For direct (wrapper-less) nodes, the wrapper IS the content root.
1071
+ */
1072
+ getContentRootInternal(mounted) {
1073
+ if (mounted.isDirect) {
1074
+ return mounted.wrapper;
1075
+ }
1076
+ return mounted.wrapper.firstElementChild;
1077
+ }
1078
+ /**
1079
+ * Processes a batch of `ResizeObserverEntry` records, resolving
1080
+ * each observed element back to its node ID and firing the
1081
+ * external `onRectChange` callback.
1082
+ */
1083
+ handleResizeEntries(entries) {
1084
+ if (!this.onRectChange)
1085
+ return;
1086
+ for (const entry of entries) {
1087
+ const id = this.elementToId.get(entry.target);
1088
+ if (!id)
1089
+ continue;
1090
+ const rect = this.measureNodeCanvasSpace(id);
1091
+ if (rect) {
1092
+ this.onRectChange(id, rect);
1093
+ }
1094
+ }
1095
+ }
1096
+ /** Throws if `dispose()` has been called. */
1097
+ assertNotDisposed() {
1098
+ if (this.disposed) {
1099
+ throw new Error("[ShadowMount] Instance has been disposed. " +
1100
+ "Create a new ShadowMount to continue.");
1101
+ }
1102
+ }
1103
+ }
1104
+ // ── Minimal CSS Rewriting for Shadow DOM ───────────────────
1105
+ /**
1106
+ * Performs minimal CSS rewriting for Shadow DOM compatibility.
1107
+ * Only rewrites `body`, `html`, and `:root` selectors to `:host`
1108
+ * so that page-level styles work correctly inside the shadow tree.
1109
+ *
1110
+ * This is intentionally minimal — forced-state duplication,
1111
+ * @-rule handling, and advanced CSS transforms are the
1112
+ * host application's responsibility.
1113
+ */
1114
+ function rewriteForShadowDOM(css) {
1115
+ return css
1116
+ .replace(/(?<![.\-\w])body(?![.\-\w])/g, ":host")
1117
+ .replace(/(?<![.\-\w])html(?![.\-\w])/g, ":host")
1118
+ .replace(/(^|[\s,]):root\b/gm, "$1:host");
1119
+ }
1120
+ //# sourceMappingURL=shadow-mount.js.map