@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,3922 @@
1
+ // ─────────────────────────────────────────────────────────────
2
+ // canvus/src/workspace.ts
3
+ // Unified Interaction Controller — Milestone 4.
4
+ //
5
+ // Orchestrates the complete Synchronous Reflow Loop:
6
+ // pointerdown → mode detection
7
+ // pointermove → delta → style surgery → sync reflow →
8
+ // measurement → guide computation → render
9
+ // pointerup → flat string bridge → commit callback
10
+ //
11
+ // This is the single public entry point for consumers.
12
+ // It owns the ShadowMount, OverlayRenderer, and all event
13
+ // bindings. The consumer provides a container element and
14
+ // callbacks; everything else is handled internally.
15
+ // ─────────────────────────────────────────────────────────────
16
+ import { createDefaultViewport, resolveNode } from "./types.js";
17
+ import { applyPan, applyWheelZoom, hitTestElements, screenToCanvas, rectsIntersect, isPointInElement, } from "./matrix.js";
18
+ import { ShadowMount } from "./shadow-mount.js";
19
+ import { NodeTree, computeAggregateBounds } from "./tree.js";
20
+ import { findDropTarget } from "./drop-zone.js";
21
+ import { OverlayRenderer, anchorCursor, computeAlignmentGuides, computeSnappedPosition, isContainerNode, } from "./renderer.js";
22
+ import { detectLayout, getLayoutLabel, parseGridTracks, getFlowAxis } from "./layout.js";
23
+ // ── Resize Math ─────────────────────────────────────────────
24
+ /**
25
+ * Computes a new bounding rect after applying a resize delta
26
+ * from a given anchor direction. Enforces a minimum size.
27
+ */
28
+ function computeResizedRect(start, anchor, dx, dy, minSize, symmetrical) {
29
+ let { x, y, width, height } = start;
30
+ const affectsLeft = anchor === "nw" || anchor === "w" || anchor === "sw";
31
+ const affectsRight = anchor === "ne" || anchor === "e" || anchor === "se";
32
+ const affectsTop = anchor === "nw" || anchor === "n" || anchor === "ne";
33
+ const affectsBottom = anchor === "sw" || anchor === "s" || anchor === "se";
34
+ if (symmetrical) {
35
+ const centerX = start.x + start.width / 2;
36
+ const centerY = start.y + start.height / 2;
37
+ if (affectsRight) {
38
+ width = Math.max(minSize, start.width + 2 * dx);
39
+ }
40
+ else if (affectsLeft) {
41
+ width = Math.max(minSize, start.width - 2 * dx);
42
+ }
43
+ if (affectsBottom) {
44
+ height = Math.max(minSize, start.height + 2 * dy);
45
+ }
46
+ else if (affectsTop) {
47
+ height = Math.max(minSize, start.height - 2 * dy);
48
+ }
49
+ x = centerX - width / 2;
50
+ y = centerY - height / 2;
51
+ return { x, y, width, height };
52
+ }
53
+ if (affectsRight) {
54
+ width = Math.max(minSize, width + dx);
55
+ }
56
+ if (affectsLeft) {
57
+ const newWidth = Math.max(minSize, width - dx);
58
+ x = x + (width - newWidth); // Shift origin to compensate.
59
+ width = newWidth;
60
+ }
61
+ if (affectsBottom) {
62
+ height = Math.max(minSize, height + dy);
63
+ }
64
+ if (affectsTop) {
65
+ const newHeight = Math.max(minSize, height - dy);
66
+ y = y + (height - newHeight);
67
+ height = newHeight;
68
+ }
69
+ return { x, y, width, height };
70
+ }
71
+ // ── Workspace Class ─────────────────────────────────────────
72
+ /**
73
+ * The top-level orchestration engine for a Canvus workspace.
74
+ *
75
+ * ### What it owns
76
+ * - A `ShadowMount` for the HTML projection layer.
77
+ * - An `OverlayRenderer` for the canvas affordance layer.
78
+ * - All pointer, wheel, and keyboard event bindings.
79
+ * - The complete interaction state machine (pan / drag / resize).
80
+ *
81
+ * ### Synchronous Reflow Loop (per pointermove frame)
82
+ * ```
83
+ * pointer delta
84
+ * → style surgery (setNodeRect / setNodePosition)
85
+ * → browser synchronous reflow
86
+ * → measureNode() reads updated layout
87
+ * → rect cache updated
88
+ * → alignment guides computed
89
+ * → OverlayRenderer.render()
90
+ * ```
91
+ *
92
+ * ### Flat String Bridge
93
+ * On `pointerup` after any mutating gesture, calls
94
+ * `ShadowMount.extractHTML()` and fires `onHTMLCommit`
95
+ * with the pristine semantic HTML string.
96
+ *
97
+ * ### Usage
98
+ * ```ts
99
+ * const ws = new Workspace(document.getElementById('editor')!, {
100
+ * onHTMLCommit: (id, html) => console.log(id, html),
101
+ * });
102
+ * ws.addNode({ id: 'card-1', rawMarkup: '<div>Hello</div>', currentRect: null });
103
+ * ```
104
+ */
105
+ export class Workspace {
106
+ // ── Internal Subsystems ─────────────────────────
107
+ mount;
108
+ renderer;
109
+ container;
110
+ canvas;
111
+ // ── Configuration ───────────────────────────────
112
+ callbacks;
113
+ snapThreshold;
114
+ minResizeSize;
115
+ enableSnapGuides;
116
+ // ── Workspace State ─────────────────────────────
117
+ viewport;
118
+ tree = new NodeTree();
119
+ selectedIds = new Set();
120
+ hoveredId = null;
121
+ dynamicHoveredId = null;
122
+ forcedStates = {
123
+ hover: new Set(),
124
+ active: new Set(),
125
+ focus: new Set()
126
+ };
127
+ activeAnchor = null;
128
+ guides = [];
129
+ // ── Scoped Selection Scope ──────────────────────
130
+ enteredContainerId = null;
131
+ lastPointerDownTime = 0;
132
+ lastPointerDownId = null;
133
+ lastPointerDownTarget = null;
134
+ editAllowedOnDblClick = false;
135
+ // ── Drag & Drop State ───────────────────────────
136
+ activeDropTarget = null;
137
+ pointerDownInsideSelection = null;
138
+ // ── Interaction State Machine ───────────────────
139
+ spaceDown = false;
140
+ isAdjustingRadius = false;
141
+ activeRadiusCorner = null;
142
+ hoveredRadiusCorner = null;
143
+ radiusTargetNodeId = null;
144
+ radiusStartValues = new Map();
145
+ dragStartNodes = new Map();
146
+ isPanning = false;
147
+ isDragging = false;
148
+ pointerDownReadyToDrag = false;
149
+ isResizing = false;
150
+ isMarqueeSelecting = false;
151
+ marqueeStartCanvas = null;
152
+ marqueeCurrentCanvas = null;
153
+ preMarqueeSelectedIds = new Set();
154
+ hoveredAdjusterType = null;
155
+ activeAdjusterType = null;
156
+ adjusterStartValue = 0;
157
+ adjusterStartValueStr = null;
158
+ dragStartCanvas = null;
159
+ lastCanvasPos = null;
160
+ resizeStartRect = null;
161
+ dragStartStyles = null;
162
+ disposed = false;
163
+ renderRequested = false;
164
+ previewMode = false;
165
+ /** Set of node IDs explicitly marked as containing JavaScript behavior. */
166
+ jsMarkedNodes = new Set();
167
+ /** Set of node IDs that were lazily registered (children discovered on selection). */
168
+ lazyRegisteredIds = new Set();
169
+ lazyChildCounter = 0;
170
+ // ── Drawing Tool State ──────────────────────────
171
+ activeTool = null;
172
+ drawingTag = "div";
173
+ drawingTextTag = "p";
174
+ isDrawingNode = false;
175
+ drawStartCanvas = null;
176
+ drawCurrentCanvas = null;
177
+ newElementCounter = 0;
178
+ // ── Clipboard State ─────────────────────────────
179
+ clipboardItems = [];
180
+ isDragCopy = false;
181
+ // ── Bound Event Handlers (for cleanup) ──────────
182
+ onWheel;
183
+ onPointerDown;
184
+ onPointerMove;
185
+ onPointerUp;
186
+ onKeyDown;
187
+ onKeyUp;
188
+ onWindowResize;
189
+ onDblClick;
190
+ onDragStart;
191
+ // ── Constructor ─────────────────────────────────
192
+ constructor(container, callbacks = {}, config = {}) {
193
+ this.container = container;
194
+ this.callbacks = callbacks;
195
+ this.snapThreshold = config.snapThreshold ?? 5;
196
+ this.minResizeSize = config.minResizeSize ?? 40;
197
+ this.enableSnapGuides = config.enableSnapGuides ?? true;
198
+ this.viewport = createDefaultViewport();
199
+ // ── Ensure container is positioned ────────────
200
+ const pos = getComputedStyle(container).position;
201
+ if (pos === "static") {
202
+ container.style.position = "relative";
203
+ }
204
+ container.style.overflow = "hidden";
205
+ // ── Create Canvas Overlay ─────────────────────
206
+ this.canvas = document.createElement("canvas");
207
+ this.canvas.style.cssText =
208
+ "position:absolute;inset:0;pointer-events:none;z-index:10;";
209
+ container.appendChild(this.canvas);
210
+ // ── Initialize Subsystems ─────────────────────
211
+ this.renderer = new OverlayRenderer(this.canvas, config.overlayStyle);
212
+ this.mount = new ShadowMount(container, (id, rect) => {
213
+ // ResizeObserver callback — update cache and re-render.
214
+ const node = this.tree.get(id);
215
+ if (node) {
216
+ node.currentRect = rect;
217
+ this.callbacks.onNodeRectChange?.(id, rect);
218
+ this.render();
219
+ }
220
+ });
221
+ this.mount.applyViewportTransform(this.viewport);
222
+ // Intercept and prevent click and submit events inside Shadow DOM when in Edit Mode
223
+ const shadowRoot = this.mount.getShadowRoot();
224
+ shadowRoot.addEventListener("click", (e) => {
225
+ if (!this.previewMode) {
226
+ e.stopPropagation();
227
+ e.preventDefault();
228
+ }
229
+ }, { capture: true });
230
+ shadowRoot.addEventListener("submit", (e) => {
231
+ if (!this.previewMode) {
232
+ e.stopPropagation();
233
+ e.preventDefault();
234
+ }
235
+ }, { capture: true });
236
+ // ── Bind Events ───────────────────────────────
237
+ this.onWheel = this.handleWheel.bind(this);
238
+ this.onPointerDown = this.handlePointerDown.bind(this);
239
+ this.onPointerMove = this.handlePointerMove.bind(this);
240
+ this.onPointerUp = this.handlePointerUp.bind(this);
241
+ this.onKeyDown = this.handleKeyDown.bind(this);
242
+ this.onKeyUp = this.handleKeyUp.bind(this);
243
+ this.onWindowResize = this.handleResize.bind(this);
244
+ this.onDblClick = this.handleDblClick.bind(this);
245
+ this.onDragStart = (e) => e.preventDefault();
246
+ container.addEventListener("wheel", this.onWheel, { passive: false });
247
+ container.addEventListener("pointerdown", this.onPointerDown);
248
+ container.addEventListener("pointermove", this.onPointerMove);
249
+ container.addEventListener("pointerup", this.onPointerUp);
250
+ container.addEventListener("dblclick", this.onDblClick);
251
+ container.addEventListener("dragstart", this.onDragStart);
252
+ window.addEventListener("keydown", this.onKeyDown);
253
+ window.addEventListener("keyup", this.onKeyUp);
254
+ window.addEventListener("resize", this.onWindowResize);
255
+ // ── Initial Sizing ────────────────────────────
256
+ this.handleResize();
257
+ }
258
+ // ── Public API: Node Management ─────────────────
259
+ /**
260
+ * Mounts a new HTML node into the workspace.
261
+ *
262
+ * Performs the **Geometry Extraction Loop**: injects the markup
263
+ * into the Shadow DOM, forces a synchronous layout read, and
264
+ * returns the measured canvas-space bounding rect.
265
+ *
266
+ * @param node - The node descriptor.
267
+ * @param parentId - Optional parent node ID for nested mounting.
268
+ * @param index - Optional insertion index within the parent's children.
269
+ * @returns The initial bounding rect after browser layout.
270
+ */
271
+ addNode(node, parentId, index) {
272
+ this.assertNotDisposed();
273
+ // Resolve to internal representation.
274
+ const resolved = resolveNode(node);
275
+ resolved.parentId = parentId ?? null;
276
+ // Mount into shadow DOM.
277
+ let rect;
278
+ if (resolved.parentId !== null) {
279
+ rect = this.mount.addChildNode(node, resolved.parentId, index);
280
+ }
281
+ else {
282
+ rect = this.mount.addNode(node);
283
+ }
284
+ resolved.currentRect = rect;
285
+ // Add to tree.
286
+ this.tree.addNode(resolved, index);
287
+ // If this node has a parent, register it in the parent's childIds.
288
+ // (NodeTree.addNode already handles this via the tree structure.)
289
+ // Synchronously measure and detect layout mode on mount.
290
+ this.remeasureSubtree(resolved.id);
291
+ if (resolved.parentId !== null) {
292
+ this.remeasureSubtree(resolved.parentId);
293
+ }
294
+ this.render();
295
+ return resolved.currentRect ?? rect;
296
+ }
297
+ /** Removes a node and all its descendants from the workspace. */
298
+ removeNode(id) {
299
+ // Remove all descendants first (depth-first).
300
+ const descendantIds = this.tree.getDescendantIds(id);
301
+ for (const did of descendantIds) {
302
+ this.mount.removeNode(did);
303
+ this.selectedIds.delete(did);
304
+ }
305
+ const removed = this.mount.removeNode(id);
306
+ if (removed) {
307
+ this.tree.removeNode(id); // Also removes descendants from tree.
308
+ this.selectedIds.delete(id);
309
+ this.render();
310
+ }
311
+ return removed;
312
+ }
313
+ /** Hot-swaps the inner HTML of a mounted node. */
314
+ updateMarkup(id, markup) {
315
+ const rect = this.mount.updateMarkup(id, markup);
316
+ if (rect) {
317
+ const node = this.tree.get(id);
318
+ if (node) {
319
+ node.rawMarkup = markup;
320
+ node.currentRect = rect;
321
+ }
322
+ this.render();
323
+ }
324
+ return rect;
325
+ }
326
+ // ── Public API: Tree Operations ─────────────────
327
+ /**
328
+ * Moves a node to a new parent (or to root level).
329
+ * Handles both DOM reparenting and tree model update.
330
+ * Fires `onHTMLCommit` with the new parent's HTML.
331
+ */
332
+ reparentNode(nodeId, newParentId, index) {
333
+ const node = this.tree.get(nodeId);
334
+ const oldParentId = node?.parentId ?? null;
335
+ // DOM reparenting.
336
+ this.mount.reparentNodeDOM(nodeId, newParentId, index);
337
+ // Tree model update.
338
+ this.tree.reparentNode(nodeId, newParentId, index);
339
+ // Re-measure affected nodes.
340
+ this.remeasureSubtree(nodeId);
341
+ if (newParentId)
342
+ this.remeasureSubtree(newParentId);
343
+ if (oldParentId)
344
+ this.remeasureSubtree(oldParentId);
345
+ this.render();
346
+ // Flat string bridge: commit the old parent's HTML if it existed.
347
+ if (oldParentId) {
348
+ const oldHtml = this.mount.extractHTML(oldParentId);
349
+ if (oldHtml) {
350
+ this.callbacks.onHTMLCommit?.(oldParentId, oldHtml);
351
+ }
352
+ }
353
+ // Flat string bridge: commit the new parent's HTML.
354
+ const commitTarget = newParentId ?? nodeId;
355
+ const html = this.mount.extractHTML(commitTarget);
356
+ if (html) {
357
+ this.callbacks.onHTMLCommit?.(commitTarget, html);
358
+ }
359
+ }
360
+ /**
361
+ * Reorders a child within its current parent.
362
+ */
363
+ reorderChild(nodeId, newIndex) {
364
+ const node = this.tree.get(nodeId);
365
+ if (!node?.parentId)
366
+ return;
367
+ // DOM reorder: remove and re-insert at new index.
368
+ this.mount.reparentNodeDOM(nodeId, node.parentId, newIndex);
369
+ // Tree model update.
370
+ this.tree.reorderChild(nodeId, newIndex);
371
+ // Re-measure the parent's children.
372
+ this.remeasureSubtree(node.parentId);
373
+ this.render();
374
+ }
375
+ /** Returns the NodeTree for advanced tree queries. */
376
+ getNodeTree() {
377
+ return this.tree;
378
+ }
379
+ /** Returns the wrapper DOM element for a node ID. */
380
+ getWrapper(id) {
381
+ return this.mount.getWrapper(id);
382
+ }
383
+ /** Returns the user's content root element for a node ID. */
384
+ getContentRoot(id) {
385
+ return this.mount.getContentRoot(id);
386
+ }
387
+ /**
388
+ * Mutates a single CSS style property on the specified node's content element.
389
+ * Automatically triggers browser reflow, updates internal tree boundaries,
390
+ * re-renders visual overlays, and commits clean HTML back to AST.
391
+ */
392
+ setNodeStyle(id, property, value) {
393
+ const node = this.tree.get(id);
394
+ if (!node)
395
+ return;
396
+ // Apply the style change
397
+ this.mount.setNodeStyle(id, property, value);
398
+ // Sync layout display mode changes
399
+ if (property === "display") {
400
+ const contentRoot = this.mount.getContentRoot(id);
401
+ node.layoutMode = contentRoot ? detectLayout(contentRoot).mode : (value ?? "none");
402
+ }
403
+ // Remeasure layout subtree boundaries
404
+ this.remeasureSubtree(id);
405
+ if (node.parentId) {
406
+ this.remeasureSubtree(node.parentId);
407
+ }
408
+ this.render();
409
+ // Commit html changes via flat string bridge
410
+ const commitTarget = node.parentId ?? id;
411
+ const html = this.mount.extractHTML(commitTarget);
412
+ if (html) {
413
+ this.callbacks.onHTMLCommit?.(commitTarget, html);
414
+ }
415
+ }
416
+ /**
417
+ * Mutates multiple CSS style properties on the specified node's content element.
418
+ * Batch-updates styles, triggers a single reflow/remeasure loop, and commits changes.
419
+ */
420
+ setNodeStyles(id, styles) {
421
+ const node = this.tree.get(id);
422
+ if (!node)
423
+ return;
424
+ // Batch apply styles
425
+ this.mount.setNodeStyles(id, styles);
426
+ // Sync layout display mode changes if any
427
+ for (const [prop, val] of Object.entries(styles)) {
428
+ if (prop === "display") {
429
+ const contentRoot = this.mount.getContentRoot(id);
430
+ node.layoutMode = contentRoot ? detectLayout(contentRoot).mode : (val ?? "none");
431
+ }
432
+ }
433
+ // Remeasure layout subtree boundaries
434
+ this.remeasureSubtree(id);
435
+ if (node.parentId) {
436
+ this.remeasureSubtree(node.parentId);
437
+ }
438
+ this.render();
439
+ // Commit html changes via flat string bridge
440
+ const commitTarget = node.parentId ?? id;
441
+ const html = this.mount.extractHTML(commitTarget);
442
+ if (html) {
443
+ this.callbacks.onHTMLCommit?.(commitTarget, html);
444
+ }
445
+ }
446
+ // ── Public API: Selection ───────────────────────
447
+ /** Selects a node by ID, clearing previous selection. */
448
+ selectNode(id) {
449
+ const prev = new Set(this.selectedIds);
450
+ this.selectedIds.clear();
451
+ this.selectedIds.add(id);
452
+ this.syncLazyChildren(prev, this.selectedIds);
453
+ this.callbacks.onSelectionChange?.(this.selectedIds);
454
+ this.render();
455
+ }
456
+ /** Clears all selection. */
457
+ deselectAll() {
458
+ const prev = new Set(this.selectedIds);
459
+ this.selectedIds.clear();
460
+ this.syncLazyChildren(prev, this.selectedIds);
461
+ this.callbacks.onSelectionChange?.(this.selectedIds);
462
+ this.render();
463
+ }
464
+ /** Returns the current selection set (read-only view). */
465
+ getSelectedIds() {
466
+ return this.selectedIds;
467
+ }
468
+ // ── Public API: Viewport ────────────────────────
469
+ /** Returns the current viewport transform. */
470
+ getViewport() {
471
+ return this.viewport;
472
+ }
473
+ /** Programmatically sets the viewport (e.g. for "fit to content"). */
474
+ setViewport(vp) {
475
+ this.viewport = vp;
476
+ this.mount.applyViewportTransform(vp);
477
+ // Re-measure all nodes since the scale/transform has changed
478
+ const roots = this.tree.getRoots();
479
+ for (const root of roots) {
480
+ this.remeasureSubtree(root.id);
481
+ }
482
+ this.callbacks.onViewportChange?.(vp);
483
+ this.render();
484
+ }
485
+ /** Resets viewport to 1:1 scale, zero offset. */
486
+ resetViewport() {
487
+ this.setViewport(createDefaultViewport());
488
+ }
489
+ // ── Public API: Preview Mode ────────────────────
490
+ /** Sets whether the workspace is in Preview Mode (disables editing overlays and events). */
491
+ setPreviewMode(enabled) {
492
+ if (this.previewMode === enabled)
493
+ return;
494
+ this.previewMode = enabled;
495
+ this.canvas.style.pointerEvents = "none";
496
+ // Clear selection, hover, and active interactions.
497
+ if (enabled) {
498
+ this.selectedIds.clear();
499
+ this.clearDynamicHover();
500
+ this.hoveredId = null;
501
+ this.activeDropTarget = null;
502
+ this.activeAdjusterType = null;
503
+ this.isDragging = false;
504
+ this.isResizing = false;
505
+ this.isMarqueeSelecting = false;
506
+ this.pointerDownReadyToDrag = false;
507
+ this.callbacks.onSelectionChange?.(this.selectedIds);
508
+ this.callbacks.onInteractionChange?.(null);
509
+ }
510
+ this.render();
511
+ }
512
+ /** Returns whether the workspace is currently in Preview Mode. */
513
+ isPreviewMode() {
514
+ return this.previewMode;
515
+ }
516
+ // ── Public API: Drawing Tools ───────────────────
517
+ /** Sets the active drawing tool (box, text, or null to return to selection/idle mode). */
518
+ setActiveTool(tool) {
519
+ this.activeTool = tool;
520
+ this.container.style.cursor = tool ? "crosshair" : "default";
521
+ if (tool !== null) {
522
+ this.deselectAll();
523
+ }
524
+ this.callbacks.onInteractionChange?.(tool ? `draw-${tool}` : null);
525
+ this.render();
526
+ }
527
+ /** Returns the currently active drawing tool. */
528
+ getActiveTool() {
529
+ return this.activeTool;
530
+ }
531
+ /** Customizes the HTML tag type for box or text drawing. */
532
+ setDrawingTag(tag) {
533
+ const lower = tag.toLowerCase().trim();
534
+ const textTags = ["p", "h1", "h2", "h3", "h4", "h5", "h6", "span", "a", "strong", "em", "li", "ul", "ol"];
535
+ if (textTags.includes(lower)) {
536
+ this.drawingTextTag = lower;
537
+ }
538
+ else {
539
+ this.drawingTag = lower;
540
+ }
541
+ }
542
+ /** Returns the active drawing tag based on the selected tool. */
543
+ getDrawingTag() {
544
+ return this.activeTool === "text" ? this.drawingTextTag : this.drawingTag;
545
+ }
546
+ // ── Public API: Clipboard Operations ────────────
547
+ /** Deletes the currently selected node from the workspace. */
548
+ deleteSelectedNode() {
549
+ const topLevelIds = this.getTopLevelSelectedIds();
550
+ if (topLevelIds.length === 0)
551
+ return;
552
+ this.mount.setTransitionsEnabled(false);
553
+ const ops = [];
554
+ const parentsToRemeasure = new Set();
555
+ for (const id of topLevelIds) {
556
+ const node = this.tree.get(id);
557
+ if (!node)
558
+ continue;
559
+ const parentId = node.parentId;
560
+ const rawMarkup = node.rawMarkup;
561
+ const rect = node.currentRect;
562
+ const removed = this.removeNode(id);
563
+ if (removed) {
564
+ ops.push({
565
+ type: "delete-node",
566
+ nodeId: id,
567
+ payload: { parentId },
568
+ undoPayload: { parentId, rawMarkup, rect }
569
+ });
570
+ if (parentId) {
571
+ parentsToRemeasure.add(parentId);
572
+ }
573
+ }
574
+ }
575
+ if (ops.length > 0) {
576
+ this.callbacks.onOperationsGenerated?.(ops);
577
+ // Commit HTML for affected parent containers or root
578
+ for (const parentId of parentsToRemeasure) {
579
+ this.remeasureSubtree(parentId);
580
+ const html = this.mount.extractHTML(parentId);
581
+ if (html) {
582
+ this.callbacks.onHTMLCommit?.(parentId, html);
583
+ }
584
+ }
585
+ // If any deleted node was a root node, commit HTML for it
586
+ for (const op of ops) {
587
+ if (!op.payload.parentId) {
588
+ this.callbacks.onHTMLCommit?.(op.nodeId, "");
589
+ }
590
+ }
591
+ this.deselectAll();
592
+ }
593
+ this.mount.setTransitionsEnabled(true);
594
+ this.render();
595
+ }
596
+ /** Duplicates the selected node right next to it as a sibling. */
597
+ duplicateSelectedNode() {
598
+ const topLevelIds = this.getTopLevelSelectedIds();
599
+ if (topLevelIds.length === 0)
600
+ return;
601
+ this.mount.setTransitionsEnabled(false);
602
+ const newSelectedIds = [];
603
+ const ops = [];
604
+ const parentsToCommit = new Set();
605
+ const rootsToCommit = [];
606
+ for (const originalId of topLevelIds) {
607
+ const originalNode = this.tree.get(originalId);
608
+ if (!originalNode)
609
+ continue;
610
+ const rawMarkup = this.mount.extractHTML(originalId);
611
+ if (!rawMarkup)
612
+ continue;
613
+ this.newElementCounter++;
614
+ const duplicateId = `cloned-${this.newElementCounter}-${Date.now().toString(36)}`;
615
+ const parentId = originalNode.parentId;
616
+ let rect = originalNode.currentRect ? { ...originalNode.currentRect } : null;
617
+ let index;
618
+ if (parentId !== null) {
619
+ index = this.tree.getChildIndex(originalId) + 1;
620
+ }
621
+ else if (rect) {
622
+ rect.x += 20;
623
+ rect.y += 20;
624
+ }
625
+ this.addNode({
626
+ id: duplicateId,
627
+ rawMarkup,
628
+ currentRect: rect
629
+ }, parentId, index);
630
+ if (this.jsMarkedNodes.has(originalId)) {
631
+ this.markNodeHasJS(duplicateId);
632
+ }
633
+ newSelectedIds.push(duplicateId);
634
+ const finalIndex = parentId !== null ? this.tree.getChildIndex(duplicateId) : -1;
635
+ ops.push({
636
+ type: "create-node",
637
+ nodeId: duplicateId,
638
+ payload: { parentId, index: finalIndex, rawMarkup, rect },
639
+ undoPayload: { parentId }
640
+ });
641
+ if (parentId) {
642
+ parentsToCommit.add(parentId);
643
+ }
644
+ else {
645
+ rootsToCommit.push(duplicateId);
646
+ }
647
+ }
648
+ if (ops.length > 0) {
649
+ this.selectedIds.clear();
650
+ for (const id of newSelectedIds) {
651
+ this.selectedIds.add(id);
652
+ }
653
+ this.callbacks.onSelectionChange?.(this.selectedIds);
654
+ this.updateBreadcrumb();
655
+ this.callbacks.onOperationsGenerated?.(ops);
656
+ for (const parentId of parentsToCommit) {
657
+ const html = this.mount.extractHTML(parentId);
658
+ if (html) {
659
+ this.callbacks.onHTMLCommit?.(parentId, html);
660
+ }
661
+ }
662
+ for (const rootId of rootsToCommit) {
663
+ const html = this.mount.extractHTML(rootId);
664
+ if (html) {
665
+ this.callbacks.onHTMLCommit?.(rootId, html);
666
+ }
667
+ }
668
+ }
669
+ this.mount.setTransitionsEnabled(true);
670
+ this.render();
671
+ }
672
+ /** Copies the selected node to the internal clipboard. */
673
+ copySelectedNode() {
674
+ const topLevelIds = this.getTopLevelSelectedIds();
675
+ if (topLevelIds.length === 0)
676
+ return;
677
+ this.clipboardItems = [];
678
+ for (const id of topLevelIds) {
679
+ const node = this.tree.get(id);
680
+ const markup = this.mount.extractHTML(id);
681
+ if (node && markup) {
682
+ this.clipboardItems.push({
683
+ rawMarkup: markup,
684
+ rect: node.currentRect ? { ...node.currentRect } : null,
685
+ hasJS: this.jsMarkedNodes.has(id),
686
+ });
687
+ }
688
+ }
689
+ }
690
+ /** Cuts the selected node to the clipboard, removing it from the canvas. */
691
+ cutSelectedNode() {
692
+ this.copySelectedNode();
693
+ this.deleteSelectedNode();
694
+ }
695
+ /** Pastes the node currently in the clipboard into the canvas. */
696
+ pasteNode() {
697
+ if (this.clipboardItems.length === 0)
698
+ return;
699
+ this.mount.setTransitionsEnabled(false);
700
+ const newSelectedIds = [];
701
+ const ops = [];
702
+ const parentsToCommit = new Set();
703
+ const rootsToCommit = [];
704
+ const targets = this.selectedIds.size > 0 ? this.getTopLevelSelectedIds() : [];
705
+ if (targets.length === 0) {
706
+ // Paste all items at root level
707
+ for (const item of this.clipboardItems) {
708
+ this.newElementCounter++;
709
+ const id = `pasted-${this.newElementCounter}-${Date.now().toString(36)}`;
710
+ let rect;
711
+ if (item.rect) {
712
+ rect = {
713
+ x: item.rect.x + 20,
714
+ y: item.rect.y + 20,
715
+ width: item.rect.width,
716
+ height: item.rect.height,
717
+ };
718
+ item.rect = {
719
+ x: item.rect.x + 20,
720
+ y: item.rect.y + 20,
721
+ width: item.rect.width,
722
+ height: item.rect.height,
723
+ };
724
+ }
725
+ else {
726
+ rect = { x: 100, y: 100, width: 120, height: 120 };
727
+ }
728
+ this.addNode({
729
+ id,
730
+ rawMarkup: item.rawMarkup,
731
+ currentRect: rect,
732
+ }, null);
733
+ if (item.hasJS) {
734
+ this.markNodeHasJS(id);
735
+ }
736
+ newSelectedIds.push(id);
737
+ ops.push({
738
+ type: "create-node",
739
+ nodeId: id,
740
+ payload: { parentId: null, index: undefined, rawMarkup: item.rawMarkup, rect },
741
+ undoPayload: { parentId: null }
742
+ });
743
+ rootsToCommit.push(id);
744
+ }
745
+ }
746
+ else {
747
+ // Paste next to or inside each target
748
+ for (const targetId of targets) {
749
+ const targetNode = this.tree.get(targetId);
750
+ if (!targetNode)
751
+ continue;
752
+ const isContainer = this.tree.isContainer(targetId);
753
+ const parentId = isContainer ? targetId : targetNode.parentId;
754
+ let startIndex = isContainer ? 0 : this.tree.getChildIndex(targetId) + 1;
755
+ for (const item of this.clipboardItems) {
756
+ this.newElementCounter++;
757
+ const id = `pasted-${this.newElementCounter}-${Date.now().toString(36)}`;
758
+ let rect;
759
+ if (parentId === null) {
760
+ if (item.rect) {
761
+ rect = {
762
+ x: item.rect.x + 20,
763
+ y: item.rect.y + 20,
764
+ width: item.rect.width,
765
+ height: item.rect.height,
766
+ };
767
+ item.rect = {
768
+ x: item.rect.x + 20,
769
+ y: item.rect.y + 20,
770
+ width: item.rect.width,
771
+ height: item.rect.height,
772
+ };
773
+ }
774
+ else {
775
+ rect = { x: 100, y: 100, width: 120, height: 120 };
776
+ }
777
+ }
778
+ else {
779
+ if (item.rect) {
780
+ rect = {
781
+ x: 0,
782
+ y: 0,
783
+ width: item.rect.width,
784
+ height: item.rect.height,
785
+ };
786
+ }
787
+ else {
788
+ rect = { x: 0, y: 0, width: 120, height: 120 };
789
+ }
790
+ }
791
+ this.addNode({
792
+ id,
793
+ rawMarkup: item.rawMarkup,
794
+ currentRect: rect,
795
+ }, parentId, startIndex);
796
+ if (parentId !== null) {
797
+ startIndex++;
798
+ }
799
+ if (item.hasJS) {
800
+ this.markNodeHasJS(id);
801
+ }
802
+ newSelectedIds.push(id);
803
+ const finalIndex = parentId !== null ? this.tree.getChildIndex(id) : -1;
804
+ ops.push({
805
+ type: "create-node",
806
+ nodeId: id,
807
+ payload: { parentId, index: finalIndex, rawMarkup: item.rawMarkup, rect },
808
+ undoPayload: { parentId }
809
+ });
810
+ if (parentId) {
811
+ parentsToCommit.add(parentId);
812
+ }
813
+ else {
814
+ rootsToCommit.push(id);
815
+ }
816
+ }
817
+ }
818
+ }
819
+ if (ops.length > 0) {
820
+ this.selectedIds.clear();
821
+ for (const id of newSelectedIds) {
822
+ this.selectedIds.add(id);
823
+ }
824
+ this.callbacks.onSelectionChange?.(this.selectedIds);
825
+ this.updateBreadcrumb();
826
+ this.callbacks.onOperationsGenerated?.(ops);
827
+ for (const parentId of parentsToCommit) {
828
+ const html = this.mount.extractHTML(parentId);
829
+ if (html) {
830
+ this.callbacks.onHTMLCommit?.(parentId, html);
831
+ }
832
+ }
833
+ for (const rootId of rootsToCommit) {
834
+ const html = this.mount.extractHTML(rootId);
835
+ if (html) {
836
+ this.callbacks.onHTMLCommit?.(rootId, html);
837
+ }
838
+ }
839
+ }
840
+ this.mount.setTransitionsEnabled(true);
841
+ this.render();
842
+ }
843
+ // ── Public API: State Forcing ───────────────────
844
+ /** Forces a pseudo-class state (hover, active, focus) on the specified node element. */
845
+ forceNodeState(nodeId, state, enabled) {
846
+ if (enabled) {
847
+ this.forcedStates[state].add(nodeId);
848
+ }
849
+ else {
850
+ this.forcedStates[state].delete(nodeId);
851
+ }
852
+ this.setNodeStateClass(nodeId, state, enabled);
853
+ this.render();
854
+ }
855
+ // ── Public API: JS Badge Marking ────────────────
856
+ /**
857
+ * Explicitly marks a node as containing JavaScript behavior.
858
+ * Renders the ⚡️ JS badge on the canvas overlay when the node is selected.
859
+ * The host application calls this based on its own analysis (static analysis,
860
+ * CDP, source maps, etc.) rather than the SDK auto-detecting scripts.
861
+ */
862
+ markNodeHasJS(nodeId) {
863
+ this.jsMarkedNodes.add(nodeId);
864
+ this.render();
865
+ }
866
+ /**
867
+ * Clears the JS badge from a node.
868
+ */
869
+ unmarkNodeHasJS(nodeId) {
870
+ this.jsMarkedNodes.delete(nodeId);
871
+ this.render();
872
+ }
873
+ /**
874
+ * Returns whether a node is marked as containing JavaScript behavior.
875
+ */
876
+ hasJSMark(nodeId) {
877
+ return this.jsMarkedNodes.has(nodeId);
878
+ }
879
+ // ── Public API: Synthetic Interaction ───────────
880
+ /** Dispatches a synthetic pointer/mouse event (e.g. mouseenter, mouseleave, click) to a node. */
881
+ dispatchInteractionEvent(nodeId, eventName) {
882
+ const contentRoot = this.mount.getContentRoot(nodeId);
883
+ if (!contentRoot)
884
+ return;
885
+ let event;
886
+ if (eventName === "click" || eventName === "dblclick" || eventName.startsWith("mouse") || eventName.startsWith("pointer")) {
887
+ event = new MouseEvent(eventName, {
888
+ bubbles: true,
889
+ cancelable: true,
890
+ view: window,
891
+ });
892
+ }
893
+ else {
894
+ event = new CustomEvent(eventName, {
895
+ bubbles: true,
896
+ cancelable: true,
897
+ });
898
+ }
899
+ contentRoot.dispatchEvent(event);
900
+ }
901
+ // ── Public API: State Accessors ─────────────────
902
+ /** Returns a snapshot of all tracked nodes (depth-first order). */
903
+ getNodes() {
904
+ return this.tree.flatten();
905
+ }
906
+ /** Returns the underlying ShadowMount for advanced access. */
907
+ getShadowMount() {
908
+ return this.mount;
909
+ }
910
+ /** Returns the underlying OverlayRenderer for advanced access. */
911
+ getOverlayRenderer() {
912
+ return this.renderer;
913
+ }
914
+ /**
915
+ * Extracts the clean inner HTML of a node.
916
+ * This is the **Flat String Bridge** — call it at any time
917
+ * to read the current semantic HTML string.
918
+ */
919
+ extractHTML(id) {
920
+ return this.mount.extractHTML(id);
921
+ }
922
+ /**
923
+ * Programmatically replays an Operation (mutation payload) onto the workspace.
924
+ * This is the core API used for Undo/Redo replay and collaboration sync.
925
+ */
926
+ applyOperation(op) {
927
+ this.assertNotDisposed();
928
+ if (op.type === "create-node" || op.type === "delete-node") {
929
+ const payload = op.payload;
930
+ if (payload && typeof payload.rawMarkup === "string") {
931
+ const { parentId, index, rawMarkup, rect } = payload;
932
+ this.addNode({ id: op.nodeId, rawMarkup, currentRect: rect }, parentId, index);
933
+ }
934
+ else {
935
+ this.removeNode(op.nodeId);
936
+ this.deselectAll();
937
+ }
938
+ this.render();
939
+ return;
940
+ }
941
+ const node = this.tree.get(op.nodeId);
942
+ if (!node)
943
+ return;
944
+ switch (op.type) {
945
+ case "reparent": {
946
+ const { newParentId, index } = op.payload;
947
+ this.reparentNode(op.nodeId, newParentId, index);
948
+ break;
949
+ }
950
+ case "reorder": {
951
+ const { index } = op.payload;
952
+ this.reorderChild(op.nodeId, index);
953
+ break;
954
+ }
955
+ case "update-style": {
956
+ const styles = op.payload;
957
+ const contentRoot = this.mount.getContentRoot(op.nodeId);
958
+ if (!contentRoot)
959
+ break;
960
+ const stylesToApply = {};
961
+ for (const [prop, val] of Object.entries(styles)) {
962
+ const value = val;
963
+ // Check if it's wrapper geometric positioning styles for root elements
964
+ if (node.parentId === null && (prop === "left" || prop === "top" || prop === "width" || prop === "height")) {
965
+ if (prop === "left" || prop === "top") {
966
+ const currentX = node.currentRect ? node.currentRect.x : 0;
967
+ const currentY = node.currentRect ? node.currentRect.y : 0;
968
+ const parsedVal = value ? parseFloat(value) : 0;
969
+ const newX = prop === "left" ? parsedVal : currentX;
970
+ const newY = prop === "top" ? parsedVal : currentY;
971
+ this.mount.setNodePosition(op.nodeId, newX, newY);
972
+ }
973
+ else {
974
+ const parsedVal = value ? (value === "auto" ? "auto" : parseFloat(value)) : "auto";
975
+ const newW = prop === "width" ? parsedVal : null;
976
+ const newH = prop === "height" ? parsedVal : null;
977
+ this.mount.setNodeSize(op.nodeId, newW, newH);
978
+ }
979
+ }
980
+ else {
981
+ // Apply property directly to content root stylesheet
982
+ stylesToApply[prop] = value;
983
+ }
984
+ }
985
+ if (Object.keys(stylesToApply).length > 0) {
986
+ this.mount.setNodeStyles(op.nodeId, stylesToApply);
987
+ }
988
+ this.remeasureSubtree(op.nodeId);
989
+ if (node.parentId) {
990
+ this.remeasureSubtree(node.parentId);
991
+ }
992
+ this.render();
993
+ break;
994
+ }
995
+ case "update-classes": {
996
+ const { add, remove } = op.payload;
997
+ const contentRoot = this.mount.getContentRoot(op.nodeId);
998
+ if (!contentRoot)
999
+ break;
1000
+ if (Array.isArray(remove)) {
1001
+ for (const cls of remove) {
1002
+ contentRoot.classList.remove(cls);
1003
+ }
1004
+ }
1005
+ if (Array.isArray(add)) {
1006
+ for (const cls of add) {
1007
+ contentRoot.classList.add(cls);
1008
+ }
1009
+ }
1010
+ this.remeasureSubtree(op.nodeId);
1011
+ if (node.parentId) {
1012
+ this.remeasureSubtree(node.parentId);
1013
+ }
1014
+ this.render();
1015
+ break;
1016
+ }
1017
+ case "update-text": {
1018
+ const { path, html } = op.payload;
1019
+ const contentRoot = this.mount.getContentRoot(op.nodeId);
1020
+ if (!contentRoot)
1021
+ break;
1022
+ const targetEl = getDOMElementByPath(contentRoot, path);
1023
+ if (targetEl) {
1024
+ targetEl.innerHTML = html;
1025
+ }
1026
+ this.remeasureSubtree(op.nodeId);
1027
+ if (node.parentId) {
1028
+ this.remeasureSubtree(node.parentId);
1029
+ }
1030
+ this.render();
1031
+ break;
1032
+ }
1033
+ }
1034
+ }
1035
+ /** Adds a CSS class name directly to the content root of a node. */
1036
+ addClass(id, className) {
1037
+ const node = this.tree.get(id);
1038
+ if (!node)
1039
+ return;
1040
+ const contentRoot = this.mount.getContentRoot(id);
1041
+ if (!contentRoot)
1042
+ return;
1043
+ if (contentRoot.classList.contains(className))
1044
+ return;
1045
+ contentRoot.classList.add(className);
1046
+ this.remeasureSubtree(id);
1047
+ if (node.parentId) {
1048
+ this.remeasureSubtree(node.parentId);
1049
+ }
1050
+ this.render();
1051
+ const commitTarget = node.parentId ?? id;
1052
+ const html = this.mount.extractHTML(commitTarget);
1053
+ if (html) {
1054
+ this.callbacks.onHTMLCommit?.(commitTarget, html);
1055
+ }
1056
+ this.callbacks.onOperationsGenerated?.([{
1057
+ type: "update-classes",
1058
+ nodeId: id,
1059
+ payload: { add: [className], remove: [] },
1060
+ undoPayload: { add: [], remove: [className] }
1061
+ }]);
1062
+ }
1063
+ /** Removes a CSS class name directly from the content root of a node. */
1064
+ removeClass(id, className) {
1065
+ const node = this.tree.get(id);
1066
+ if (!node)
1067
+ return;
1068
+ const contentRoot = this.mount.getContentRoot(id);
1069
+ if (!contentRoot)
1070
+ return;
1071
+ if (!contentRoot.classList.contains(className))
1072
+ return;
1073
+ contentRoot.classList.remove(className);
1074
+ this.remeasureSubtree(id);
1075
+ if (node.parentId) {
1076
+ this.remeasureSubtree(node.parentId);
1077
+ }
1078
+ this.render();
1079
+ const commitTarget = node.parentId ?? id;
1080
+ const html = this.mount.extractHTML(commitTarget);
1081
+ if (html) {
1082
+ this.callbacks.onHTMLCommit?.(commitTarget, html);
1083
+ }
1084
+ this.callbacks.onOperationsGenerated?.([{
1085
+ type: "update-classes",
1086
+ nodeId: id,
1087
+ payload: { add: [], remove: [className] },
1088
+ undoPayload: { add: [className], remove: [] }
1089
+ }]);
1090
+ }
1091
+ /** Toggles a CSS class name directly on the content root of a node. */
1092
+ toggleClass(id, className) {
1093
+ const node = this.tree.get(id);
1094
+ if (!node)
1095
+ return;
1096
+ const contentRoot = this.mount.getContentRoot(id);
1097
+ if (!contentRoot)
1098
+ return;
1099
+ const hasClass = contentRoot.classList.contains(className);
1100
+ if (hasClass) {
1101
+ this.removeClass(id, className);
1102
+ }
1103
+ else {
1104
+ this.addClass(id, className);
1105
+ }
1106
+ }
1107
+ /**
1108
+ * Forces a synchronous geometry measurement of all nodes
1109
+ * and updates the internal rect cache.
1110
+ */
1111
+ measureAll() {
1112
+ const rects = this.mount.measureAll();
1113
+ for (const [id, rect] of rects) {
1114
+ const node = this.tree.get(id);
1115
+ if (node) {
1116
+ node.currentRect = rect;
1117
+ const contentRoot = this.mount.getContentRoot(id);
1118
+ if (contentRoot) {
1119
+ node.layoutMode = detectLayout(contentRoot).mode;
1120
+ }
1121
+ }
1122
+ }
1123
+ this.render();
1124
+ return rects;
1125
+ }
1126
+ // ── Public API: Stylesheet Injection ────────────
1127
+ /** Injects a CSS string into the shadow root. */
1128
+ injectCSS(css) {
1129
+ return this.mount.injectStylesheet(css);
1130
+ }
1131
+ /** Loads an external stylesheet into the shadow root. */
1132
+ injectCSSLink(href) {
1133
+ return this.mount.injectStylesheetLink(href);
1134
+ }
1135
+ // ── Disposal ────────────────────────────────────
1136
+ /** Tears down the workspace completely. */
1137
+ dispose() {
1138
+ if (this.disposed)
1139
+ return;
1140
+ this.disposed = true;
1141
+ // Remove event listeners.
1142
+ this.container.removeEventListener("wheel", this.onWheel);
1143
+ this.container.removeEventListener("pointerdown", this.onPointerDown);
1144
+ this.container.removeEventListener("pointermove", this.onPointerMove);
1145
+ this.container.removeEventListener("pointerup", this.onPointerUp);
1146
+ this.container.removeEventListener("dblclick", this.onDblClick);
1147
+ this.container.removeEventListener("dragstart", this.onDragStart);
1148
+ window.removeEventListener("keydown", this.onKeyDown);
1149
+ window.removeEventListener("keyup", this.onKeyUp);
1150
+ window.removeEventListener("resize", this.onWindowResize);
1151
+ // Tear down subsystems.
1152
+ this.mount.dispose();
1153
+ this.canvas.remove();
1154
+ // Clear state.
1155
+ this.tree.clear();
1156
+ this.selectedIds.clear();
1157
+ }
1158
+ // ── Event Handlers ──────────────────────────────
1159
+ /** Cursor-anchored zoom on scroll wheel. */
1160
+ handleWheel(e) {
1161
+ e.preventDefault();
1162
+ const rect = this.getContainerRect();
1163
+ this.viewport = applyWheelZoom(e.clientX, e.clientY, e.deltaY, this.viewport, rect);
1164
+ this.mount.applyViewportTransform(this.viewport);
1165
+ this.callbacks.onViewportChange?.(this.viewport);
1166
+ this.render();
1167
+ }
1168
+ /** Interaction mode detection on pointer down. */
1169
+ handlePointerDown(e) {
1170
+ const rect = this.getContainerRect();
1171
+ const canvasPos = screenToCanvas(e.clientX, e.clientY, this.viewport, rect);
1172
+ console.log('DEBUG WORKSPACE DOWN: viewport scale:', this.viewport.scale, 'canvasPos:', canvasPos, 'clientX:', e.clientX, 'clientY:', e.clientY);
1173
+ if (this.previewMode) {
1174
+ if (this.spaceDown || e.button === 1) {
1175
+ if (e.button === 1) {
1176
+ e.preventDefault();
1177
+ }
1178
+ this.isPanning = true;
1179
+ this.container.classList.add("canvus-panning");
1180
+ this.safeSetPointerCapture(e.pointerId);
1181
+ this.callbacks.onInteractionChange?.("pan");
1182
+ return;
1183
+ }
1184
+ return;
1185
+ }
1186
+ this.pointerDownInsideSelection = null;
1187
+ // ── Drawing Tool Interception ─────────────────
1188
+ if (this.activeTool !== null && e.button === 0) {
1189
+ this.isDrawingNode = true;
1190
+ this.drawStartCanvas = canvasPos;
1191
+ this.drawCurrentCanvas = canvasPos;
1192
+ this.activeDropTarget = null;
1193
+ this.guides = [];
1194
+ this.safeSetPointerCapture(e.pointerId);
1195
+ this.callbacks.onInteractionChange?.("draw-node");
1196
+ this.render();
1197
+ return;
1198
+ }
1199
+ // ── Space + pointer = Pan ─────────────────────
1200
+ if (this.spaceDown || e.button === 1) {
1201
+ if (e.button === 1) {
1202
+ e.preventDefault();
1203
+ }
1204
+ this.isPanning = true;
1205
+ this.container.classList.add("canvus-panning");
1206
+ this.safeSetPointerCapture(e.pointerId);
1207
+ this.callbacks.onInteractionChange?.("pan");
1208
+ return;
1209
+ }
1210
+ // Calculate isDoubleClick early to prevent handles/adjusters from intercepting double-clicks on small/nested nodes
1211
+ const nodeList = this.getOrderedNodeList();
1212
+ const hitId = hitTestElements(canvasPos.x, canvasPos.y, nodeList);
1213
+ const targetEl = e.composedPath()[0];
1214
+ const now = Date.now();
1215
+ const isSameTarget = targetEl !== null && this.lastPointerDownTarget !== null &&
1216
+ (targetEl === this.lastPointerDownTarget || this.lastPointerDownTarget.contains(targetEl) || targetEl.contains(this.lastPointerDownTarget));
1217
+ const isDoubleClick = (now - this.lastPointerDownTime < 350) && (hitId !== null &&
1218
+ this.lastPointerDownId !== null &&
1219
+ (hitId === this.lastPointerDownId || isSameTarget || this.tree.isAncestor(this.lastPointerDownId, hitId)));
1220
+ this.lastPointerDownTime = now;
1221
+ this.lastPointerDownId = hitId;
1222
+ this.lastPointerDownTarget = targetEl;
1223
+ // ── Handle hit-test (resize) ──────────────────
1224
+ if (!isDoubleClick && this.selectedIds.size === 1) {
1225
+ const selId = this.selectedIds.values().next().value;
1226
+ const selNode = this.tree.get(selId);
1227
+ if (selNode?.currentRect) {
1228
+ const localX = e.clientX - rect.x;
1229
+ const localY = e.clientY - rect.y;
1230
+ const anchor = this.renderer.hitTestHandle(localX, localY, selNode.currentRect, this.viewport);
1231
+ if (anchor) {
1232
+ this.isResizing = true;
1233
+ this.activeAnchor = anchor;
1234
+ this.dragStartCanvas = canvasPos;
1235
+ this.resizeStartRect = { ...selNode.currentRect };
1236
+ const contentRoot = this.mount.getContentRoot(selId);
1237
+ if (contentRoot) {
1238
+ this.dragStartStyles = {
1239
+ "grid-column-start": contentRoot.style.gridColumnStart || null,
1240
+ "grid-column-end": contentRoot.style.gridColumnEnd || null,
1241
+ "grid-row-start": contentRoot.style.gridRowStart || null,
1242
+ "grid-row-end": contentRoot.style.gridRowEnd || null,
1243
+ "position": contentRoot.style.position || null,
1244
+ "left": contentRoot.style.left || null,
1245
+ "top": contentRoot.style.top || null,
1246
+ "width": contentRoot.style.width || null,
1247
+ "height": contentRoot.style.height || null,
1248
+ };
1249
+ }
1250
+ this.render();
1251
+ return;
1252
+ }
1253
+ }
1254
+ }
1255
+ // ── Corner Radius handles hit-test ────────────
1256
+ if (!isDoubleClick && this.selectedIds.size > 0) {
1257
+ const localX = e.clientX - rect.x;
1258
+ const localY = e.clientY - rect.y;
1259
+ let hitRadiusCorner = null;
1260
+ let targetNodeId = null;
1261
+ for (const selId of this.selectedIds) {
1262
+ const selNode = this.tree.get(selId);
1263
+ if (selNode && isContainerNode(selNode) && selNode.currentRect) {
1264
+ const hit = this.hitTestRadiusHandle(localX, localY, selNode.currentRect, this.viewport);
1265
+ if (hit) {
1266
+ hitRadiusCorner = hit;
1267
+ targetNodeId = selId;
1268
+ break;
1269
+ }
1270
+ }
1271
+ }
1272
+ if (hitRadiusCorner && targetNodeId) {
1273
+ this.isAdjustingRadius = true;
1274
+ this.activeRadiusCorner = hitRadiusCorner;
1275
+ this.radiusTargetNodeId = targetNodeId;
1276
+ this.radiusStartValues.clear();
1277
+ for (const selId of this.selectedIds) {
1278
+ const selNode = this.tree.get(selId);
1279
+ if (selNode && isContainerNode(selNode)) {
1280
+ const contentRoot = this.mount.getContentRoot(selId);
1281
+ let initialRadiusStr = "0px";
1282
+ if (contentRoot) {
1283
+ initialRadiusStr = contentRoot.style.borderRadius || window.getComputedStyle(contentRoot).borderRadius || "0px";
1284
+ }
1285
+ this.radiusStartValues.set(selId, initialRadiusStr);
1286
+ }
1287
+ }
1288
+ this.dragStartCanvas = canvasPos;
1289
+ this.safeSetPointerCapture(e.pointerId);
1290
+ this.callbacks.onInteractionChange?.("resize-radius");
1291
+ this.render();
1292
+ return;
1293
+ }
1294
+ }
1295
+ // ── Spacing Adjusters hit-test ────────────────
1296
+ if (!isDoubleClick && this.selectedIds.size === 1) {
1297
+ const selId = this.selectedIds.values().next().value;
1298
+ const adjusters = this.computeSpacingAdjusters(selId);
1299
+ const hitAdjuster = adjusters.find(adj => canvasPos.x >= adj.rect.x &&
1300
+ canvasPos.x <= adj.rect.x + adj.rect.width &&
1301
+ canvasPos.y >= adj.rect.y &&
1302
+ canvasPos.y <= adj.rect.y + adj.rect.height);
1303
+ if (hitAdjuster) {
1304
+ this.activeAdjusterType = hitAdjuster.type;
1305
+ this.adjusterStartValue = hitAdjuster.value;
1306
+ const contentRoot = this.mount.getContentRoot(selId);
1307
+ this.adjusterStartValueStr = contentRoot ? (contentRoot.style.getPropertyValue(hitAdjuster.type) || null) : null;
1308
+ this.dragStartCanvas = canvasPos;
1309
+ this.render();
1310
+ return;
1311
+ }
1312
+ }
1313
+ // ── Node hit-test (select + drag) ─────────────
1314
+ let targetSelectId = null;
1315
+ let clickInsideSelection = false;
1316
+ const hasModifier = e.shiftKey || e.metaKey || e.ctrlKey;
1317
+ if (this.selectedIds.size > 0 && !hasModifier && !isDoubleClick) {
1318
+ for (const selId of this.selectedIds) {
1319
+ const selNode = this.tree.get(selId);
1320
+ if (selNode?.currentRect && isPointInElement(canvasPos.x, canvasPos.y, selNode.currentRect)) {
1321
+ clickInsideSelection = true;
1322
+ targetSelectId = selId;
1323
+ this.pointerDownInsideSelection = selId;
1324
+ break;
1325
+ }
1326
+ }
1327
+ }
1328
+ if (!clickInsideSelection) {
1329
+ this.pointerDownInsideSelection = null;
1330
+ if (hitId) {
1331
+ const isCmdClick = e.metaKey || e.ctrlKey;
1332
+ if (isCmdClick) {
1333
+ // Cmd+Click: deep select the hit element directly
1334
+ targetSelectId = hitId;
1335
+ this.enteredContainerId = this.tree.get(hitId)?.parentId ?? null;
1336
+ }
1337
+ else if (isDoubleClick) {
1338
+ // Double click: Figma-like drill down
1339
+ const path = this.tree.getPath(hitId);
1340
+ let foundSelectedIdx = -1;
1341
+ for (let i = 0; i < path.length; i++) {
1342
+ if (this.selectedIds.has(path[i].id)) {
1343
+ foundSelectedIdx = i;
1344
+ break;
1345
+ }
1346
+ }
1347
+ if (foundSelectedIdx !== -1 && foundSelectedIdx < path.length - 1) {
1348
+ // Drill down one level
1349
+ const nextParent = path[foundSelectedIdx];
1350
+ const nextSelect = path[foundSelectedIdx + 1];
1351
+ this.enteredContainerId = nextParent.id;
1352
+ targetSelectId = nextSelect.id;
1353
+ }
1354
+ else if (foundSelectedIdx === path.length - 1) {
1355
+ // Leaf is already selected: keep selection on leaf to trigger text editing
1356
+ targetSelectId = path[path.length - 1].id;
1357
+ this.enteredContainerId = path[path.length - 2]?.id ?? null;
1358
+ }
1359
+ else {
1360
+ // Nothing in the path is selected
1361
+ if (path.length > 0) {
1362
+ targetSelectId = path[0].id;
1363
+ this.enteredContainerId = null;
1364
+ }
1365
+ else {
1366
+ targetSelectId = hitId;
1367
+ }
1368
+ }
1369
+ }
1370
+ else {
1371
+ // Single Click: resolve based on current entered scope
1372
+ const resolvedId = this.findSelectableNode(hitId, this.enteredContainerId);
1373
+ if (resolvedId) {
1374
+ targetSelectId = resolvedId;
1375
+ const node = this.tree.get(resolvedId);
1376
+ this.enteredContainerId = node?.parentId ?? null;
1377
+ }
1378
+ else {
1379
+ // Clicked outside currently entered container: exit scope, select root ancestor
1380
+ this.enteredContainerId = null;
1381
+ targetSelectId = this.findSelectableNode(hitId, null);
1382
+ }
1383
+ }
1384
+ }
1385
+ if (isDoubleClick && targetSelectId && this.selectedIds.has(targetSelectId)) {
1386
+ this.editAllowedOnDblClick = true;
1387
+ }
1388
+ else {
1389
+ this.editAllowedOnDblClick = false;
1390
+ }
1391
+ }
1392
+ if (targetSelectId) {
1393
+ if (!clickInsideSelection) {
1394
+ const prevSelection = new Set(this.selectedIds);
1395
+ const isShift = e.shiftKey;
1396
+ if (isShift) {
1397
+ if (this.selectedIds.has(targetSelectId)) {
1398
+ this.selectedIds.delete(targetSelectId);
1399
+ }
1400
+ else {
1401
+ this.selectedIds.add(targetSelectId);
1402
+ }
1403
+ }
1404
+ else {
1405
+ this.selectedIds.clear();
1406
+ this.selectedIds.add(targetSelectId);
1407
+ }
1408
+ this.syncLazyChildren(prevSelection, this.selectedIds);
1409
+ this.callbacks.onSelectionChange?.(this.selectedIds);
1410
+ this.updateBreadcrumb();
1411
+ }
1412
+ this.isDragging = false;
1413
+ this.pointerDownReadyToDrag = true;
1414
+ this.dragStartCanvas = canvasPos;
1415
+ this.dragStartNodes.clear();
1416
+ const topLevelIds = this.getTopLevelSelectedIds();
1417
+ for (const selId of topLevelIds) {
1418
+ const selNode = this.tree.get(selId);
1419
+ if (selNode && selNode.currentRect) {
1420
+ const contentRoot = this.mount.getContentRoot(selId);
1421
+ let startStyles = null;
1422
+ if (contentRoot) {
1423
+ startStyles = {
1424
+ "grid-column-start": contentRoot.style.gridColumnStart || null,
1425
+ "grid-column-end": contentRoot.style.gridColumnEnd || null,
1426
+ "grid-row-start": contentRoot.style.gridRowStart || null,
1427
+ "grid-row-end": contentRoot.style.gridRowEnd || null,
1428
+ "position": contentRoot.style.position || null,
1429
+ "left": contentRoot.style.left || null,
1430
+ "top": contentRoot.style.top || null,
1431
+ "width": contentRoot.style.width || null,
1432
+ "height": contentRoot.style.height || null,
1433
+ };
1434
+ }
1435
+ this.dragStartNodes.set(selId, {
1436
+ startPos: { x: selNode.currentRect.x, y: selNode.currentRect.y },
1437
+ startParentId: selNode.parentId,
1438
+ startIndex: this.tree.getChildIndex(selId),
1439
+ startStyles,
1440
+ });
1441
+ }
1442
+ }
1443
+ let primaryId = targetSelectId;
1444
+ if (!topLevelIds.includes(targetSelectId)) {
1445
+ const path = this.tree.getPath(targetSelectId);
1446
+ for (const node of path) {
1447
+ if (topLevelIds.includes(node.id)) {
1448
+ primaryId = node.id;
1449
+ break;
1450
+ }
1451
+ }
1452
+ }
1453
+ const contentRoot = this.mount.getContentRoot(primaryId);
1454
+ if (contentRoot) {
1455
+ this.dragStartStyles = {
1456
+ "grid-column-start": contentRoot.style.gridColumnStart || null,
1457
+ "grid-column-end": contentRoot.style.gridColumnEnd || null,
1458
+ "grid-row-start": contentRoot.style.gridRowStart || null,
1459
+ "grid-row-end": contentRoot.style.gridRowEnd || null,
1460
+ "position": contentRoot.style.position || null,
1461
+ "left": contentRoot.style.left || null,
1462
+ "top": contentRoot.style.top || null,
1463
+ "width": contentRoot.style.width || null,
1464
+ "height": contentRoot.style.height || null,
1465
+ };
1466
+ }
1467
+ }
1468
+ else {
1469
+ // Click on empty space — start marquee selection
1470
+ const isShift = e.shiftKey;
1471
+ if (!isShift) {
1472
+ const prevSelection = new Set(this.selectedIds);
1473
+ this.selectedIds.clear();
1474
+ this.enteredContainerId = null;
1475
+ this.guides = [];
1476
+ this.syncLazyChildren(prevSelection, this.selectedIds);
1477
+ this.callbacks.onSelectionChange?.(this.selectedIds);
1478
+ this.updateBreadcrumb();
1479
+ }
1480
+ this.preMarqueeSelectedIds = new Set(this.selectedIds);
1481
+ this.isMarqueeSelecting = true;
1482
+ this.marqueeStartCanvas = canvasPos;
1483
+ this.marqueeCurrentCanvas = canvasPos;
1484
+ this.safeSetPointerCapture(e.pointerId);
1485
+ this.callbacks.onInteractionChange?.("select-marquee");
1486
+ }
1487
+ this.render();
1488
+ }
1489
+ /**
1490
+ * The core **Synchronous Reflow Loop**.
1491
+ *
1492
+ * On each pointer move during an active gesture:
1493
+ * 1. Compute canvas-space delta.
1494
+ * 2. Style surgery (setNodeRect / setNodePosition).
1495
+ * 3. Browser reflows synchronously.
1496
+ * 4. measureNode() reads updated dimensions.
1497
+ * 5. Rect cache updated.
1498
+ * 6. Alignment guides computed.
1499
+ * 7. Overlay re-rendered.
1500
+ */
1501
+ handlePointerMove(e) {
1502
+ const rect = this.getContainerRect();
1503
+ const canvasPos = screenToCanvas(e.clientX, e.clientY, this.viewport, rect);
1504
+ this.lastCanvasPos = canvasPos;
1505
+ if (this.isDragging) {
1506
+ console.log('DEBUG WORKSPACE MOVE: viewport scale:', this.viewport.scale, 'canvasPos:', canvasPos, 'dragStartCanvas:', this.dragStartCanvas, 'clientX:', e.clientX, 'clientY:', e.clientY);
1507
+ }
1508
+ if (this.previewMode) {
1509
+ if (this.isPanning) {
1510
+ this.viewport = applyPan(e.movementX, e.movementY, this.viewport);
1511
+ this.mount.applyViewportTransform(this.viewport);
1512
+ this.callbacks.onViewportChange?.(this.viewport);
1513
+ this.render();
1514
+ }
1515
+ return;
1516
+ }
1517
+ // ── Drawing Tool Dragging ─────────────────────
1518
+ if (this.isDrawingNode && this.drawStartCanvas) {
1519
+ this.drawCurrentCanvas = canvasPos;
1520
+ // Dynamically resolve target container and placement index to show guidelines preview
1521
+ this.activeDropTarget = findDropTarget("__new_node__", canvasPos, this.tree, (id) => this.mount.getWrapper(id), (id) => this.mount.getContentRoot(id));
1522
+ this.render();
1523
+ return;
1524
+ }
1525
+ // ── Corner Radius Adjusting ───────────────────
1526
+ if (this.isAdjustingRadius && this.dragStartCanvas && this.radiusTargetNodeId) {
1527
+ const targetNode = this.tree.get(this.radiusTargetNodeId);
1528
+ if (targetNode && targetNode.currentRect) {
1529
+ this.safeSetPointerCapture(e.pointerId);
1530
+ this.container.style.cursor = "pointer";
1531
+ this.canvas.style.pointerEvents = "auto";
1532
+ this.callbacks.onInteractionChange?.("resize-radius");
1533
+ const bounds = targetNode.currentRect;
1534
+ const s = this.viewport.scale;
1535
+ const ox = this.viewport.offsetX;
1536
+ const oy = this.viewport.offsetY;
1537
+ const left = bounds.x * s + ox;
1538
+ const top = bounds.y * s + oy;
1539
+ const right = (bounds.x + bounds.width) * s + ox;
1540
+ const bottom = (bounds.y + bounds.height) * s + oy;
1541
+ let dragX = 0;
1542
+ let dragY = 0;
1543
+ if (this.activeRadiusCorner === "tl") {
1544
+ dragX = e.clientX - rect.x - left;
1545
+ dragY = e.clientY - rect.y - top;
1546
+ }
1547
+ else if (this.activeRadiusCorner === "tr") {
1548
+ dragX = right - (e.clientX - rect.x);
1549
+ dragY = e.clientY - rect.y - top;
1550
+ }
1551
+ else if (this.activeRadiusCorner === "bl") {
1552
+ dragX = e.clientX - rect.x - left;
1553
+ dragY = bottom - (e.clientY - rect.y);
1554
+ }
1555
+ else if (this.activeRadiusCorner === "br") {
1556
+ dragX = right - (e.clientX - rect.x);
1557
+ dragY = bottom - (e.clientY - rect.y);
1558
+ }
1559
+ const dragDistScreen = (dragX + dragY) / 2;
1560
+ const dragDistCanvas = dragDistScreen / s;
1561
+ // Apply to all selected containers
1562
+ for (const selId of this.selectedIds) {
1563
+ const selNode = this.tree.get(selId);
1564
+ if (selNode && isContainerNode(selNode) && selNode.currentRect) {
1565
+ const maxRadius = Math.min(selNode.currentRect.width, selNode.currentRect.height) / 2;
1566
+ const newRadius = Math.max(0, Math.min(maxRadius, Math.round(dragDistCanvas)));
1567
+ this.mount.setNodeStyle(selId, "border-radius", `${newRadius}px`);
1568
+ this.remeasureSubtree(selId);
1569
+ }
1570
+ }
1571
+ this.render();
1572
+ }
1573
+ return;
1574
+ }
1575
+ // ── Spacing Adjusters Dragging ────────────────
1576
+ if (this.activeAdjusterType && this.dragStartCanvas) {
1577
+ const selId = this.selectedIds.values().next().value;
1578
+ const node = this.tree.get(selId);
1579
+ if (!node)
1580
+ return;
1581
+ this.safeSetPointerCapture(e.pointerId);
1582
+ this.canvas.style.pointerEvents = "auto";
1583
+ this.callbacks.onInteractionChange?.("adjust-spacing");
1584
+ const isVertical = this.activeAdjusterType.includes("top") || this.activeAdjusterType.includes("bottom");
1585
+ this.container.style.cursor = isVertical ? "ns-resize" : "ew-resize";
1586
+ this.canvas.style.pointerEvents = "auto";
1587
+ const dx = canvasPos.x - this.dragStartCanvas.x;
1588
+ const dy = canvasPos.y - this.dragStartCanvas.y;
1589
+ let delta = 0;
1590
+ switch (this.activeAdjusterType) {
1591
+ case "padding-top":
1592
+ delta = dy;
1593
+ break;
1594
+ case "padding-bottom":
1595
+ delta = dy;
1596
+ break;
1597
+ case "padding-left":
1598
+ delta = dx;
1599
+ break;
1600
+ case "padding-right":
1601
+ delta = -dx;
1602
+ break;
1603
+ case "margin-top":
1604
+ delta = -dy;
1605
+ break;
1606
+ case "margin-bottom":
1607
+ delta = dy;
1608
+ break;
1609
+ case "margin-left":
1610
+ delta = -dx;
1611
+ break;
1612
+ case "margin-right":
1613
+ delta = -dx;
1614
+ break;
1615
+ }
1616
+ const contentRoot = this.mount.getContentRoot(selId);
1617
+ const internalScale = contentRoot ? this.mount.getElementScale(contentRoot) : 1;
1618
+ const safeScale = internalScale && !isNaN(internalScale) ? internalScale : 1;
1619
+ const newValue = Math.max(0, Math.round(this.adjusterStartValue + delta / safeScale));
1620
+ // Style surgery - direct DOM mutation
1621
+ this.mount.setNodeStyle(selId, this.activeAdjusterType, `${newValue}px`);
1622
+ // Synchronous reflow + measurement
1623
+ this.remeasureSubtree(selId);
1624
+ if (node.parentId) {
1625
+ this.remeasureSubtree(node.parentId);
1626
+ }
1627
+ this.render();
1628
+ return;
1629
+ }
1630
+ // ── Marquee Selection ──────────────────────────
1631
+ if (this.isMarqueeSelecting && this.marqueeStartCanvas) {
1632
+ this.marqueeCurrentCanvas = canvasPos;
1633
+ const mRect = this.getMarqueeRect();
1634
+ // Find all selectable nodes inside or intersecting the marquee rect
1635
+ const selectableNodes = this.getOrderedNodeList();
1636
+ const currentMarqueeSelection = new Set();
1637
+ for (const node of selectableNodes) {
1638
+ if (!node.currentRect)
1639
+ continue;
1640
+ const treeNode = this.tree.get(node.id);
1641
+ if (!treeNode)
1642
+ continue;
1643
+ // Scoping constraint
1644
+ if (this.enteredContainerId !== null) {
1645
+ if (treeNode.parentId !== this.enteredContainerId)
1646
+ continue;
1647
+ }
1648
+ else {
1649
+ if (treeNode.parentId !== null)
1650
+ continue;
1651
+ }
1652
+ if (rectsIntersect(node.currentRect, mRect)) {
1653
+ currentMarqueeSelection.add(node.id);
1654
+ }
1655
+ }
1656
+ this.selectedIds.clear();
1657
+ if (e.shiftKey) {
1658
+ for (const id of this.preMarqueeSelectedIds) {
1659
+ this.selectedIds.add(id);
1660
+ }
1661
+ }
1662
+ for (const id of currentMarqueeSelection) {
1663
+ this.selectedIds.add(id);
1664
+ }
1665
+ this.callbacks.onSelectionChange?.(this.selectedIds);
1666
+ this.updateBreadcrumb();
1667
+ this.render();
1668
+ return;
1669
+ }
1670
+ // ── Drag initiation ───────────────────────────
1671
+ if (this.pointerDownReadyToDrag && this.dragStartCanvas) {
1672
+ const dx = canvasPos.x - this.dragStartCanvas.x;
1673
+ const dy = canvasPos.y - this.dragStartCanvas.y;
1674
+ const dist = Math.sqrt(dx * dx + dy * dy);
1675
+ if (dist >= 3) {
1676
+ if (e.altKey && this.selectedIds.size > 0) {
1677
+ const topLevelIds = this.getTopLevelSelectedIds();
1678
+ this.mount.setTransitionsEnabled(false);
1679
+ const newSelectedIds = [];
1680
+ this.dragStartNodes.clear();
1681
+ for (const originalId of topLevelIds) {
1682
+ const originalNode = this.tree.get(originalId);
1683
+ if (originalNode && originalNode.currentRect) {
1684
+ const rawMarkup = this.mount.extractHTML(originalId);
1685
+ if (rawMarkup) {
1686
+ this.newElementCounter++;
1687
+ const duplicateId = `cloned-${this.newElementCounter}-${Date.now().toString(36)}`;
1688
+ const parentId = originalNode.parentId;
1689
+ const index = parentId !== null ? this.tree.getChildIndex(originalId) + 1 : undefined;
1690
+ this.addNode({
1691
+ id: duplicateId,
1692
+ rawMarkup,
1693
+ currentRect: { ...originalNode.currentRect }
1694
+ }, parentId, index);
1695
+ if (this.jsMarkedNodes.has(originalId)) {
1696
+ this.markNodeHasJS(duplicateId);
1697
+ }
1698
+ newSelectedIds.push(duplicateId);
1699
+ const duplicateContentRoot = this.mount.getContentRoot(duplicateId);
1700
+ let startStyles = null;
1701
+ if (duplicateContentRoot) {
1702
+ startStyles = {
1703
+ "grid-column-start": duplicateContentRoot.style.gridColumnStart || null,
1704
+ "grid-column-end": duplicateContentRoot.style.gridColumnEnd || null,
1705
+ "grid-row-start": duplicateContentRoot.style.gridRowStart || null,
1706
+ "grid-row-end": duplicateContentRoot.style.gridRowEnd || null,
1707
+ "position": duplicateContentRoot.style.position || null,
1708
+ "left": duplicateContentRoot.style.left || null,
1709
+ "top": duplicateContentRoot.style.top || null,
1710
+ "width": duplicateContentRoot.style.width || null,
1711
+ "height": duplicateContentRoot.style.height || null,
1712
+ };
1713
+ }
1714
+ this.dragStartNodes.set(duplicateId, {
1715
+ startPos: { x: originalNode.currentRect.x, y: originalNode.currentRect.y },
1716
+ startParentId: parentId,
1717
+ startIndex: parentId !== null ? this.tree.getChildIndex(duplicateId) : -1,
1718
+ startStyles,
1719
+ });
1720
+ }
1721
+ }
1722
+ }
1723
+ if (newSelectedIds.length > 0) {
1724
+ this.selectedIds.clear();
1725
+ for (const id of newSelectedIds) {
1726
+ this.selectedIds.add(id);
1727
+ }
1728
+ this.callbacks.onSelectionChange?.(this.selectedIds);
1729
+ this.updateBreadcrumb();
1730
+ const primaryId = newSelectedIds[0];
1731
+ const contentRoot = this.mount.getContentRoot(primaryId);
1732
+ if (contentRoot) {
1733
+ this.dragStartStyles = {
1734
+ "grid-column-start": contentRoot.style.gridColumnStart || null,
1735
+ "grid-column-end": contentRoot.style.gridColumnEnd || null,
1736
+ "grid-row-start": contentRoot.style.gridRowStart || null,
1737
+ "grid-row-end": contentRoot.style.gridRowEnd || null,
1738
+ "position": contentRoot.style.position || null,
1739
+ "left": contentRoot.style.left || null,
1740
+ "top": contentRoot.style.top || null,
1741
+ "width": contentRoot.style.width || null,
1742
+ "height": contentRoot.style.height || null,
1743
+ };
1744
+ }
1745
+ this.isDragCopy = true;
1746
+ }
1747
+ }
1748
+ this.isDragging = true;
1749
+ this.pointerDownReadyToDrag = false;
1750
+ this.callbacks.onInteractionChange?.("drag-node");
1751
+ this.safeSetPointerCapture(e.pointerId);
1752
+ }
1753
+ }
1754
+ // ── Hover tracking ────────────────────────────
1755
+ if (!this.isPanning && !this.isDragging && !this.isResizing && !this.isAdjustingRadius) {
1756
+ this.updateHover(e.metaKey || e.ctrlKey);
1757
+ // Handle hover cursor for multiple elements.
1758
+ let hoveredSelectedId = null;
1759
+ for (const selId of this.selectedIds) {
1760
+ const selNode = this.tree.get(selId);
1761
+ if (selNode?.currentRect && isPointInElement(canvasPos.x, canvasPos.y, selNode.currentRect)) {
1762
+ hoveredSelectedId = selId;
1763
+ break;
1764
+ }
1765
+ }
1766
+ if (hoveredSelectedId) {
1767
+ const selNode = this.tree.get(hoveredSelectedId);
1768
+ const localX = e.clientX - rect.x;
1769
+ const localY = e.clientY - rect.y;
1770
+ let hitRadiusCorner = null;
1771
+ if (isContainerNode(selNode) && selNode.currentRect) {
1772
+ hitRadiusCorner = this.hitTestRadiusHandle(localX, localY, selNode.currentRect, this.viewport);
1773
+ }
1774
+ if (hitRadiusCorner) {
1775
+ this.hoveredRadiusCorner = hitRadiusCorner;
1776
+ this.container.style.cursor = "pointer";
1777
+ this.hoveredAdjusterType = null;
1778
+ }
1779
+ else {
1780
+ this.hoveredRadiusCorner = null;
1781
+ const anchor = this.renderer.hitTestHandle(localX, localY, selNode.currentRect, this.viewport);
1782
+ if (anchor) {
1783
+ this.container.style.cursor = anchorCursor(anchor);
1784
+ this.hoveredAdjusterType = null;
1785
+ }
1786
+ else {
1787
+ // Spacing adjusters check
1788
+ const adjusters = this.computeSpacingAdjusters(hoveredSelectedId);
1789
+ const hoveredAdj = adjusters.find(adj => canvasPos.x >= adj.rect.x &&
1790
+ canvasPos.x <= adj.rect.x + adj.rect.width &&
1791
+ canvasPos.y >= adj.rect.y &&
1792
+ canvasPos.y <= adj.rect.y + adj.rect.height);
1793
+ if (hoveredAdj) {
1794
+ this.hoveredAdjusterType = hoveredAdj.type;
1795
+ const isVertical = hoveredAdj.type.includes("top") || hoveredAdj.type.includes("bottom");
1796
+ this.container.style.cursor = isVertical ? "ns-resize" : "ew-resize";
1797
+ }
1798
+ else {
1799
+ this.hoveredAdjusterType = null;
1800
+ this.container.style.cursor = "default";
1801
+ }
1802
+ }
1803
+ }
1804
+ }
1805
+ else {
1806
+ this.hoveredRadiusCorner = null;
1807
+ this.hoveredAdjusterType = null;
1808
+ this.container.style.cursor = "default";
1809
+ }
1810
+ this.render();
1811
+ return;
1812
+ }
1813
+ // ── Pan ────────────────────────────────────────
1814
+ if (this.isPanning) {
1815
+ this.viewport = applyPan(e.movementX, e.movementY, this.viewport);
1816
+ this.mount.applyViewportTransform(this.viewport);
1817
+ this.callbacks.onViewportChange?.(this.viewport);
1818
+ this.render();
1819
+ return;
1820
+ }
1821
+ // ── Resize (Synchronous Reflow Loop) ──────────
1822
+ if (this.isResizing && this.activeAnchor && this.dragStartCanvas && this.resizeStartRect) {
1823
+ const selId = this.selectedIds.values().next().value;
1824
+ const node = this.tree.get(selId);
1825
+ if (!node)
1826
+ return;
1827
+ this.safeSetPointerCapture(e.pointerId);
1828
+ this.container.style.cursor = anchorCursor(this.activeAnchor);
1829
+ this.canvas.style.pointerEvents = "auto";
1830
+ this.callbacks.onInteractionChange?.("resize-node");
1831
+ const dx = canvasPos.x - this.dragStartCanvas.x;
1832
+ const dy = canvasPos.y - this.dragStartCanvas.y;
1833
+ const wrapper = this.mount.getWrapper(selId);
1834
+ let parentIsGrid = false;
1835
+ let gridInfo = null;
1836
+ let parentRect = null;
1837
+ let padLeft = 0;
1838
+ let padTop = 0;
1839
+ if (node.parentId !== null) {
1840
+ const parentContent = this.mount.getContentRoot(node.parentId);
1841
+ if (parentContent) {
1842
+ gridInfo = detectLayout(parentContent);
1843
+ if (gridInfo.mode === "grid" || gridInfo.mode === "inline-grid") {
1844
+ parentIsGrid = true;
1845
+ const parentNode = this.tree.get(node.parentId);
1846
+ parentRect = parentNode?.currentRect ?? null;
1847
+ const cs = getComputedStyle(parentContent);
1848
+ padLeft = parseFloat(cs.paddingLeft) || 0;
1849
+ padTop = parseFloat(cs.paddingTop) || 0;
1850
+ }
1851
+ }
1852
+ }
1853
+ if (parentIsGrid && gridInfo && parentRect && wrapper) {
1854
+ const colTracks = parseGridTracks(gridInfo.gridTemplateColumns || "", gridInfo.gap.column);
1855
+ const rowTracks = parseGridTracks(gridInfo.gridTemplateRows || "", gridInfo.gap.row);
1856
+ const contentRoot = this.mount.getContentRoot(selId);
1857
+ if (contentRoot) {
1858
+ const colStart = getGridStart(contentRoot, "column");
1859
+ const rowStart = getGridStart(contentRoot, "row");
1860
+ const colSpan = getGridSpan(contentRoot, "column");
1861
+ const rowSpan = getGridSpan(contentRoot, "row");
1862
+ const cx = canvasPos.x - parentRect.x - padLeft;
1863
+ const cy = canvasPos.y - parentRect.y - padTop;
1864
+ let newColStart = colStart;
1865
+ let newColSpan = colSpan;
1866
+ let newRowStart = rowStart;
1867
+ let newRowSpan = rowSpan;
1868
+ console.log('DEBUG WORKSPACE RESIZE GRID templateRows:', gridInfo.gridTemplateRows, 'templateCols:', gridInfo.gridTemplateColumns);
1869
+ console.log('DEBUG WORKSPACE RESIZE GRID: colTracks:', JSON.stringify(colTracks), 'rowTracks:', JSON.stringify(rowTracks), 'colStart:', colStart, 'colSpan:', colSpan, 'rowStart:', rowStart, 'rowSpan:', rowSpan, 'cx:', cx, 'cy:', cy);
1870
+ const anchor = this.activeAnchor;
1871
+ // West / East column resizing
1872
+ if (anchor.includes("w")) {
1873
+ const colEndIndex = colStart + colSpan;
1874
+ for (let i = 0; i < colTracks.length; i++) {
1875
+ const c = colTracks[i];
1876
+ if (cx <= c.start + c.size + gridInfo.gap.column / 2) {
1877
+ newColStart = Math.min(i + 1, colEndIndex - 1);
1878
+ newColSpan = colEndIndex - newColStart;
1879
+ break;
1880
+ }
1881
+ }
1882
+ }
1883
+ else if (anchor.includes("e")) {
1884
+ for (let i = 0; i < colTracks.length; i++) {
1885
+ const c = colTracks[i];
1886
+ if (cx <= c.start + c.size + gridInfo.gap.column / 2) {
1887
+ newColSpan = Math.max(1, (i + 1) - colStart + 1);
1888
+ break;
1889
+ }
1890
+ newColSpan = Math.max(1, (i + 1) - colStart + 1);
1891
+ }
1892
+ }
1893
+ // North / South row resizing
1894
+ if (anchor.includes("n")) {
1895
+ const rowEndIndex = rowStart + rowSpan;
1896
+ for (let i = 0; i < rowTracks.length; i++) {
1897
+ const r = rowTracks[i];
1898
+ if (cy <= r.start + r.size + gridInfo.gap.row / 2) {
1899
+ newRowStart = Math.min(i + 1, rowEndIndex - 1);
1900
+ newRowSpan = rowEndIndex - newRowStart;
1901
+ break;
1902
+ }
1903
+ }
1904
+ }
1905
+ else if (anchor.includes("s")) {
1906
+ for (let i = 0; i < rowTracks.length; i++) {
1907
+ const r = rowTracks[i];
1908
+ if (cy <= r.start + r.size + gridInfo.gap.row / 2) {
1909
+ newRowSpan = Math.max(1, (i + 1) - rowStart + 1);
1910
+ break;
1911
+ }
1912
+ newRowSpan = Math.max(1, (i + 1) - rowStart + 1);
1913
+ }
1914
+ }
1915
+ console.log('DEBUG WORKSPACE RESIZE GRID result:', 'colStart:', newColStart, 'colSpan:', newColSpan, 'rowStart:', newRowStart, 'rowSpan:', newRowSpan);
1916
+ this.mount.setNodeStyles(selId, {
1917
+ "grid-column-start": `${newColStart}`,
1918
+ "grid-column-end": `span ${newColSpan}`,
1919
+ "grid-row-start": `${newRowStart}`,
1920
+ "grid-row-end": `span ${newRowSpan}`,
1921
+ });
1922
+ this.remeasureSubtree(selId);
1923
+ if (node.parentId) {
1924
+ this.remeasureSubtree(node.parentId);
1925
+ }
1926
+ }
1927
+ }
1928
+ else {
1929
+ // 1. Compute new rect from anchor delta.
1930
+ const newRect = computeResizedRect(this.resizeStartRect, this.activeAnchor, dx, dy, this.minResizeSize, e.altKey);
1931
+ // 2. Style surgery — direct DOM mutation.
1932
+ this.mount.setNodeRect(selId, newRect);
1933
+ // 3. Synchronous reflow + measurement.
1934
+ // Reading dimensions forces the browser to reflow NOW.
1935
+ this.remeasureSubtree(selId);
1936
+ }
1937
+ // 4. Compute alignment guides.
1938
+ if (this.enableSnapGuides && node.currentRect) {
1939
+ const otherRects = this.getOtherRects(selId);
1940
+ this.guides = computeAlignmentGuides(node.currentRect, otherRects, this.snapThreshold);
1941
+ }
1942
+ // 5. Notify.
1943
+ if (node.currentRect) {
1944
+ this.callbacks.onNodeRectChange?.(selId, node.currentRect);
1945
+ }
1946
+ // 6. Render overlay.
1947
+ this.container.style.cursor = anchorCursor(this.activeAnchor);
1948
+ this.canvas.style.pointerEvents = "auto";
1949
+ this.render();
1950
+ return;
1951
+ }
1952
+ // ── Drag (Synchronous Reflow Loop) ────────────
1953
+ if (this.isDragging && this.dragStartCanvas && this.dragStartNodes.size > 0) {
1954
+ const topLevelIds = this.getTopLevelSelectedIds();
1955
+ const primaryId = this.dragStartNodes.keys().next().value;
1956
+ const primaryStart = this.dragStartNodes.get(primaryId);
1957
+ const dx = canvasPos.x - this.dragStartCanvas.x;
1958
+ const dy = canvasPos.y - this.dragStartCanvas.y;
1959
+ let snapDx = dx;
1960
+ let snapDy = dy;
1961
+ if (primaryStart.startParentId === null) {
1962
+ // Absolute Root dragging
1963
+ let newX = primaryStart.startPos.x + dx;
1964
+ let newY = primaryStart.startPos.y + dy;
1965
+ // Snap-to-align
1966
+ if (this.enableSnapGuides) {
1967
+ const primaryNode = this.tree.get(primaryId);
1968
+ if (primaryNode && primaryNode.currentRect) {
1969
+ const candidateRect = {
1970
+ x: newX, y: newY,
1971
+ width: primaryNode.currentRect.width,
1972
+ height: primaryNode.currentRect.height,
1973
+ };
1974
+ const otherRects = this.getOtherRectsMultiple(topLevelIds);
1975
+ const snapped = computeSnappedPosition(candidateRect, otherRects, this.snapThreshold);
1976
+ snapDx = snapped.x - primaryStart.startPos.x;
1977
+ snapDy = snapped.y - primaryStart.startPos.y;
1978
+ const snappedRect = {
1979
+ x: snapped.x, y: snapped.y,
1980
+ width: primaryNode.currentRect.width,
1981
+ height: primaryNode.currentRect.height,
1982
+ };
1983
+ this.guides = computeAlignmentGuides(snappedRect, otherRects, this.snapThreshold);
1984
+ }
1985
+ }
1986
+ // Apply translations on all dragged nodes
1987
+ for (const [id, start] of this.dragStartNodes.entries()) {
1988
+ if (start.startParentId === null) {
1989
+ this.mount.setNodePosition(id, start.startPos.x + snapDx, start.startPos.y + snapDy);
1990
+ this.remeasureSubtree(id);
1991
+ }
1992
+ else {
1993
+ const wrapper = this.mount.getWrapper(id);
1994
+ if (wrapper) {
1995
+ wrapper.style.transform = `translate3d(${snapDx}px, ${snapDy}px, 0)`;
1996
+ }
1997
+ const node = this.tree.get(id);
1998
+ if (node && node.currentRect) {
1999
+ node.currentRect = {
2000
+ x: start.startPos.x + snapDx,
2001
+ y: start.startPos.y + snapDy,
2002
+ width: node.currentRect.width,
2003
+ height: node.currentRect.height,
2004
+ };
2005
+ }
2006
+ }
2007
+ }
2008
+ }
2009
+ else {
2010
+ // Flow child dragging (visual translation)
2011
+ for (const [id, start] of this.dragStartNodes.entries()) {
2012
+ const wrapper = this.mount.getWrapper(id);
2013
+ if (wrapper) {
2014
+ wrapper.style.transform = `translate3d(${dx}px, ${dy}px, 0)`;
2015
+ }
2016
+ const node = this.tree.get(id);
2017
+ if (node && node.currentRect) {
2018
+ node.currentRect = {
2019
+ x: start.startPos.x + dx,
2020
+ y: start.startPos.y + dy,
2021
+ width: node.currentRect.width,
2022
+ height: node.currentRect.height,
2023
+ };
2024
+ }
2025
+ }
2026
+ }
2027
+ // Detect active drop target container & flow position based on the primary node / canvasPos
2028
+ this.activeDropTarget = findDropTarget(primaryId, canvasPos, this.tree, (id) => this.mount.getWrapper(id), (id) => this.mount.getContentRoot(id));
2029
+ // Notify node rect changes
2030
+ for (const id of this.selectedIds) {
2031
+ const node = this.tree.get(id);
2032
+ if (node?.currentRect) {
2033
+ this.callbacks.onNodeRectChange?.(id, node.currentRect);
2034
+ }
2035
+ }
2036
+ this.render();
2037
+ }
2038
+ }
2039
+ /**
2040
+ * Gesture completion.
2041
+ *
2042
+ * **Flat String Bridge**: on mouseup after a mutating gesture,
2043
+ * extracts the clean HTML and fires `onHTMLCommit`.
2044
+ */
2045
+ handlePointerUp(e) {
2046
+ if (this.isDragging) {
2047
+ console.log('DEBUG WORKSPACE UP: viewport scale:', this.viewport.scale, 'dragStartNodes:', Array.from(this.dragStartNodes.entries()).map(([id, s]) => ({ id, startPos: s.startPos, startParentId: s.startParentId })), 'clientX:', e.clientX, 'clientY:', e.clientY);
2048
+ }
2049
+ if (this.previewMode) {
2050
+ if (this.isPanning) {
2051
+ this.isPanning = false;
2052
+ this.container.classList.remove("canvus-panning");
2053
+ }
2054
+ return;
2055
+ }
2056
+ // ── Drawing Tool Completion ──────────────────
2057
+ if (this.isDrawingNode && this.drawStartCanvas && this.drawCurrentCanvas) {
2058
+ this.isDrawingNode = false;
2059
+ const start = this.drawStartCanvas;
2060
+ const end = this.drawCurrentCanvas;
2061
+ this.drawStartCanvas = null;
2062
+ this.drawCurrentCanvas = null;
2063
+ try {
2064
+ this.container.releasePointerCapture(e.pointerId);
2065
+ }
2066
+ catch { }
2067
+ // Calculate drawn dimensions
2068
+ let x = Math.min(start.x, end.x);
2069
+ let y = Math.min(start.y, end.y);
2070
+ let width = Math.abs(start.x - end.x);
2071
+ let height = Math.abs(start.y - end.y);
2072
+ // Apply defaults if users did a simple click-to-draw instead of drag-to-draw
2073
+ if (width < 8 && height < 8) {
2074
+ if (this.activeTool === "box") {
2075
+ width = 120;
2076
+ height = 120;
2077
+ }
2078
+ else {
2079
+ width = 180;
2080
+ height = 40; // Let text height be auto/placeholder size
2081
+ }
2082
+ }
2083
+ const parentTarget = this.activeDropTarget;
2084
+ this.activeDropTarget = null;
2085
+ // Determine final placement
2086
+ let parentId = parentTarget?.parentId ?? null;
2087
+ let index = parentTarget?.insertionIndex;
2088
+ this.newElementCounter++;
2089
+ const id = `${this.activeTool || "node"}-${this.newElementCounter}-${Date.now().toString(36)}`;
2090
+ let rawMarkup = "";
2091
+ if (this.activeTool === "box") {
2092
+ const tag = this.drawingTag;
2093
+ rawMarkup = `<${tag} style="background:rgba(99, 102, 241, 0.05);border:1.5px dashed #6366f1;border-radius:8px;box-sizing:border-box;width:100%;height:100%;min-width:40px;min-height:40px;"></${tag}>`;
2094
+ }
2095
+ else {
2096
+ const tag = this.drawingTextTag;
2097
+ let fontSize = "16px";
2098
+ let fontWeight = "400";
2099
+ if (tag.match(/^h[1-6]$/)) {
2100
+ fontWeight = "700";
2101
+ if (tag === "h1")
2102
+ fontSize = "28px";
2103
+ else if (tag === "h2")
2104
+ fontSize = "24px";
2105
+ else if (tag === "h3")
2106
+ fontSize = "20px";
2107
+ else
2108
+ fontSize = "18px";
2109
+ }
2110
+ rawMarkup = `<${tag} style="margin:0;font-family:sans-serif;font-size:${fontSize};font-weight:${fontWeight};color:#e8e8f0;line-height:1.5;outline:none;min-width:100px;">Double-click to edit text</${tag}>`;
2111
+ }
2112
+ let rect = { x, y, width, height };
2113
+ // Temporary disable transitions during mount
2114
+ this.mount.setTransitionsEnabled(false);
2115
+ // Perform addition
2116
+ if (parentId !== null && parentTarget?.gridPlacement) {
2117
+ const gp = parentTarget.gridPlacement;
2118
+ // Construct grid position styles directly
2119
+ const gridStyles = {
2120
+ "grid-column-start": `${gp.colStart}`,
2121
+ "grid-column-end": `span ${gp.colSpan}`,
2122
+ "grid-row-start": `${gp.rowStart}`,
2123
+ "grid-row-end": `span ${gp.rowSpan}`,
2124
+ };
2125
+ this.addNode({ id, rawMarkup, currentRect: gp.rect }, parentId, 0);
2126
+ this.setNodeStyles(id, gridStyles);
2127
+ rect = gp.rect;
2128
+ }
2129
+ else {
2130
+ this.addNode({ id, rawMarkup, currentRect: rect }, parentId, index);
2131
+ }
2132
+ this.selectNode(id);
2133
+ // Operations
2134
+ this.callbacks.onOperationsGenerated?.([{
2135
+ type: "create-node",
2136
+ nodeId: id,
2137
+ payload: { parentId, index, rawMarkup, rect },
2138
+ undoPayload: { parentId }
2139
+ }]);
2140
+ // HTML commit
2141
+ const commitTarget = parentId ?? id;
2142
+ const html = this.mount.extractHTML(commitTarget);
2143
+ if (html) {
2144
+ this.callbacks.onHTMLCommit?.(commitTarget, html);
2145
+ }
2146
+ // Clear active tool (resets back to selection/idle mode)
2147
+ this.setActiveTool(null);
2148
+ this.mount.setTransitionsEnabled(true);
2149
+ this.render();
2150
+ return;
2151
+ }
2152
+ // Identify the node that was being manipulated.
2153
+ let commitId = null;
2154
+ const operations = [];
2155
+ if (this.isDragging || this.isResizing || this.isAdjustingRadius) {
2156
+ if (this.selectedIds.size === 1) {
2157
+ commitId = this.selectedIds.values().next().value;
2158
+ }
2159
+ }
2160
+ if (this.activeAdjusterType) {
2161
+ if (this.selectedIds.size === 1) {
2162
+ const selId = this.selectedIds.values().next().value;
2163
+ const contentRoot = this.mount.getContentRoot(selId);
2164
+ if (contentRoot && this.activeAdjusterType) {
2165
+ const finalValueStr = contentRoot.style.getPropertyValue(this.activeAdjusterType) || null;
2166
+ if (finalValueStr !== this.adjusterStartValueStr) {
2167
+ operations.push({
2168
+ type: "update-style",
2169
+ nodeId: selId,
2170
+ payload: { [this.activeAdjusterType]: finalValueStr },
2171
+ undoPayload: { [this.activeAdjusterType]: this.adjusterStartValueStr }
2172
+ });
2173
+ }
2174
+ }
2175
+ const node = this.tree.get(selId);
2176
+ commitId = (node && node.parentId !== null) ? node.parentId : selId;
2177
+ }
2178
+ this.activeAdjusterType = null;
2179
+ this.dragStartCanvas = null;
2180
+ this.container.style.cursor = "default";
2181
+ this.adjusterStartValueStr = null;
2182
+ }
2183
+ if (this.isAdjustingRadius) {
2184
+ const parentsToCommit = new Set();
2185
+ for (const selId of this.selectedIds) {
2186
+ const selNode = this.tree.get(selId);
2187
+ if (selNode && isContainerNode(selNode)) {
2188
+ const contentRoot = this.mount.getContentRoot(selId);
2189
+ if (contentRoot) {
2190
+ const finalRadiusStr = contentRoot.style.borderRadius || "";
2191
+ const initialRadiusStr = this.radiusStartValues.get(selId) || "0px";
2192
+ if (finalRadiusStr !== initialRadiusStr) {
2193
+ operations.push({
2194
+ type: "update-style",
2195
+ nodeId: selId,
2196
+ payload: { "border-radius": finalRadiusStr },
2197
+ undoPayload: { "border-radius": initialRadiusStr }
2198
+ });
2199
+ if (selNode.parentId) {
2200
+ parentsToCommit.add(selNode.parentId);
2201
+ }
2202
+ else {
2203
+ parentsToCommit.add(selId);
2204
+ }
2205
+ }
2206
+ }
2207
+ }
2208
+ }
2209
+ for (const commitId of parentsToCommit) {
2210
+ const html = this.mount.extractHTML(commitId);
2211
+ if (html) {
2212
+ this.callbacks.onHTMLCommit?.(commitId, html);
2213
+ }
2214
+ }
2215
+ this.isAdjustingRadius = false;
2216
+ this.activeRadiusCorner = null;
2217
+ this.radiusTargetNodeId = null;
2218
+ this.radiusStartValues.clear();
2219
+ this.dragStartCanvas = null;
2220
+ this.container.style.cursor = "default";
2221
+ }
2222
+ if (this.isMarqueeSelecting) {
2223
+ this.isMarqueeSelecting = false;
2224
+ this.marqueeStartCanvas = null;
2225
+ this.marqueeCurrentCanvas = null;
2226
+ this.preMarqueeSelectedIds.clear();
2227
+ }
2228
+ // Reset interaction state.
2229
+ if (this.isPanning) {
2230
+ this.isPanning = false;
2231
+ this.container.classList.remove("canvus-panning");
2232
+ }
2233
+ if (this.isDragging) {
2234
+ this.isDragging = false;
2235
+ this.dragStartCanvas = null;
2236
+ this.mount.setTransitionsEnabled(false);
2237
+ if (this.dragStartNodes.size > 0) {
2238
+ if (this.isDragCopy) {
2239
+ this.isDragCopy = false;
2240
+ const parentsToCommit = new Set();
2241
+ const rootsToCommit = [];
2242
+ for (const clonedId of this.dragStartNodes.keys()) {
2243
+ const node = this.tree.get(clonedId);
2244
+ if (!node || !node.currentRect)
2245
+ continue;
2246
+ const wrapper = this.mount.getWrapper(clonedId);
2247
+ if (wrapper) {
2248
+ wrapper.style.transform = "";
2249
+ }
2250
+ const rawMarkup = this.mount.extractHTML(clonedId) || "";
2251
+ let rect = { ...node.currentRect };
2252
+ if (this.activeDropTarget) {
2253
+ const { parentId, gridPlacement } = this.activeDropTarget;
2254
+ if (gridPlacement) {
2255
+ const gridStyles = {
2256
+ "grid-column-start": `${gridPlacement.colStart}`,
2257
+ "grid-column-end": `span ${gridPlacement.colSpan}`,
2258
+ "grid-row-start": `${gridPlacement.rowStart}`,
2259
+ "grid-row-end": `span ${gridPlacement.rowSpan}`,
2260
+ };
2261
+ this.setNodeStyles(clonedId, gridStyles);
2262
+ rect = gridPlacement.rect;
2263
+ }
2264
+ const insertionIndex = this.activeDropTarget.insertionIndex;
2265
+ if (node.parentId !== parentId) {
2266
+ this.reparentNode(clonedId, parentId, insertionIndex !== undefined ? insertionIndex : 0);
2267
+ }
2268
+ operations.push({
2269
+ type: "create-node",
2270
+ nodeId: clonedId,
2271
+ payload: { parentId, index: this.tree.getChildIndex(clonedId), rawMarkup, rect },
2272
+ undoPayload: { parentId }
2273
+ });
2274
+ if (parentId) {
2275
+ parentsToCommit.add(parentId);
2276
+ }
2277
+ }
2278
+ else {
2279
+ if (node.parentId !== null) {
2280
+ this.reparentNode(clonedId, null);
2281
+ this.mount.setNodePosition(clonedId, rect.x, rect.y);
2282
+ }
2283
+ operations.push({
2284
+ type: "create-node",
2285
+ nodeId: clonedId,
2286
+ payload: { parentId: null, index: -1, rawMarkup, rect },
2287
+ undoPayload: { parentId: null }
2288
+ });
2289
+ rootsToCommit.push(clonedId);
2290
+ }
2291
+ }
2292
+ this.activeDropTarget = null;
2293
+ this.dragStartNodes.clear();
2294
+ this.dragStartStyles = null;
2295
+ this.mount.setTransitionsEnabled(true);
2296
+ if (operations.length > 0) {
2297
+ this.callbacks.onOperationsGenerated?.(operations);
2298
+ }
2299
+ for (const id of this.selectedIds) {
2300
+ this.remeasureSubtree(id);
2301
+ const node = this.tree.get(id);
2302
+ if (node?.currentRect) {
2303
+ this.callbacks.onNodeRectChange?.(id, node.currentRect);
2304
+ }
2305
+ }
2306
+ for (const parentId of parentsToCommit) {
2307
+ const html = this.mount.extractHTML(parentId);
2308
+ if (html) {
2309
+ this.callbacks.onHTMLCommit?.(parentId, html);
2310
+ }
2311
+ }
2312
+ for (const rootId of rootsToCommit) {
2313
+ const html = this.mount.extractHTML(rootId);
2314
+ if (html) {
2315
+ this.callbacks.onHTMLCommit?.(rootId, html);
2316
+ }
2317
+ }
2318
+ this.canvas.style.pointerEvents = "none";
2319
+ this.callbacks.onInteractionChange?.(null);
2320
+ this.render();
2321
+ return;
2322
+ }
2323
+ if (this.activeDropTarget) {
2324
+ const { parentId, insertionIndex, gridPlacement } = this.activeDropTarget;
2325
+ let currentInsertion = insertionIndex !== undefined ? insertionIndex : 0;
2326
+ for (const [id, start] of this.dragStartNodes.entries()) {
2327
+ const node = this.tree.get(id);
2328
+ if (!node)
2329
+ continue;
2330
+ const oldParentId = start.startParentId;
2331
+ const oldIndex = start.startIndex;
2332
+ const wrapper = this.mount.getWrapper(id);
2333
+ if (wrapper) {
2334
+ wrapper.style.transform = "";
2335
+ }
2336
+ if (gridPlacement) {
2337
+ const payloadStyles = {
2338
+ "grid-column-start": `${gridPlacement.colStart}`,
2339
+ "grid-column-end": `span ${gridPlacement.colSpan}`,
2340
+ "grid-row-start": `${gridPlacement.rowStart}`,
2341
+ "grid-row-end": `span ${gridPlacement.rowSpan}`,
2342
+ "position": null, "left": null, "top": null, "width": null, "height": null,
2343
+ };
2344
+ this.mount.setNodeStyles(id, payloadStyles);
2345
+ const undoPayloadStyles = {};
2346
+ for (const prop of Object.keys(payloadStyles)) {
2347
+ undoPayloadStyles[prop] = (start.startStyles && start.startStyles[prop] !== undefined) ? start.startStyles[prop] : null;
2348
+ }
2349
+ operations.push({
2350
+ type: "update-style",
2351
+ nodeId: id,
2352
+ payload: payloadStyles,
2353
+ undoPayload: undoPayloadStyles
2354
+ });
2355
+ if (parentId !== node.parentId) {
2356
+ this.reparentNode(id, parentId, 0);
2357
+ operations.push({
2358
+ type: "reparent",
2359
+ nodeId: id,
2360
+ payload: { newParentId: parentId, index: 0 },
2361
+ undoPayload: { newParentId: oldParentId, index: oldIndex }
2362
+ });
2363
+ }
2364
+ else {
2365
+ this.remeasureSubtree(parentId);
2366
+ const html = this.mount.extractHTML(parentId);
2367
+ if (html) {
2368
+ this.callbacks.onHTMLCommit?.(parentId, html);
2369
+ }
2370
+ }
2371
+ }
2372
+ else {
2373
+ let styleChanged = false;
2374
+ const payloadStyles = {};
2375
+ const undoPayloadStyles = {};
2376
+ for (const prop of ["grid-column-start", "grid-column-end", "grid-row-start", "grid-row-end"]) {
2377
+ const orig = start.startStyles ? start.startStyles[prop] : null;
2378
+ if (orig !== null) {
2379
+ payloadStyles[prop] = null;
2380
+ undoPayloadStyles[prop] = orig;
2381
+ styleChanged = true;
2382
+ }
2383
+ }
2384
+ if (styleChanged) {
2385
+ this.mount.setNodeStyles(id, payloadStyles);
2386
+ operations.push({
2387
+ type: "update-style",
2388
+ nodeId: id,
2389
+ payload: payloadStyles,
2390
+ undoPayload: undoPayloadStyles
2391
+ });
2392
+ }
2393
+ if (parentId === node.parentId) {
2394
+ this.reorderChild(id, currentInsertion);
2395
+ const newIndex = this.tree.getChildIndex(id);
2396
+ if (newIndex !== oldIndex) {
2397
+ operations.push({
2398
+ type: "reorder",
2399
+ nodeId: id,
2400
+ payload: { index: newIndex },
2401
+ undoPayload: { index: oldIndex }
2402
+ });
2403
+ }
2404
+ currentInsertion = newIndex + 1;
2405
+ }
2406
+ else {
2407
+ this.reparentNode(id, parentId, currentInsertion);
2408
+ const newIndex = this.tree.getChildIndex(id);
2409
+ operations.push({
2410
+ type: "reparent",
2411
+ nodeId: id,
2412
+ payload: { newParentId: parentId, index: newIndex },
2413
+ undoPayload: { newParentId: oldParentId, index: oldIndex }
2414
+ });
2415
+ currentInsertion = newIndex + 1;
2416
+ }
2417
+ }
2418
+ }
2419
+ }
2420
+ else {
2421
+ for (const [id, start] of this.dragStartNodes.entries()) {
2422
+ const node = this.tree.get(id);
2423
+ if (!node)
2424
+ continue;
2425
+ const oldParentId = start.startParentId;
2426
+ const oldIndex = start.startIndex;
2427
+ const oldPos = start.startPos;
2428
+ const wrapper = this.mount.getWrapper(id);
2429
+ if (wrapper) {
2430
+ wrapper.style.transform = "";
2431
+ }
2432
+ if (node.parentId !== null) {
2433
+ this.reparentNode(id, null);
2434
+ if (node.currentRect) {
2435
+ this.mount.setNodePosition(id, node.currentRect.x, node.currentRect.y);
2436
+ this.remeasureSubtree(id);
2437
+ }
2438
+ operations.push({
2439
+ type: "reparent",
2440
+ nodeId: id,
2441
+ payload: { newParentId: null, index: -1 },
2442
+ undoPayload: { newParentId: oldParentId, index: oldIndex }
2443
+ });
2444
+ let styleChanged = false;
2445
+ const payloadStyles = {};
2446
+ const undoPayloadStyles = {};
2447
+ for (const prop of ["grid-column-start", "grid-column-end", "grid-row-start", "grid-row-end"]) {
2448
+ const orig = start.startStyles ? start.startStyles[prop] : null;
2449
+ if (orig !== null) {
2450
+ payloadStyles[prop] = null;
2451
+ undoPayloadStyles[prop] = orig;
2452
+ styleChanged = true;
2453
+ }
2454
+ }
2455
+ if (styleChanged) {
2456
+ this.mount.setNodeStyles(id, payloadStyles);
2457
+ operations.push({
2458
+ type: "update-style",
2459
+ nodeId: id,
2460
+ payload: payloadStyles,
2461
+ undoPayload: undoPayloadStyles
2462
+ });
2463
+ }
2464
+ }
2465
+ else if (oldParentId === null && oldPos) {
2466
+ const newX = node.currentRect ? node.currentRect.x : oldPos.x;
2467
+ const newY = node.currentRect ? node.currentRect.y : oldPos.y;
2468
+ if (newX !== oldPos.x || newY !== oldPos.y) {
2469
+ operations.push({
2470
+ type: "update-style",
2471
+ nodeId: id,
2472
+ payload: { left: `${newX}px`, top: `${newY}px` },
2473
+ undoPayload: { left: `${oldPos.x}px`, top: `${oldPos.y}px` }
2474
+ });
2475
+ }
2476
+ }
2477
+ }
2478
+ }
2479
+ }
2480
+ this.activeDropTarget = null;
2481
+ this.dragStartStyles = null;
2482
+ this.dragStartNodes.clear();
2483
+ for (const id of this.selectedIds) {
2484
+ this.remeasureSubtree(id);
2485
+ const node = this.tree.get(id);
2486
+ if (node?.currentRect) {
2487
+ this.callbacks.onNodeRectChange?.(id, node.currentRect);
2488
+ }
2489
+ }
2490
+ this.mount.setTransitionsEnabled(true);
2491
+ }
2492
+ this.pointerDownReadyToDrag = false;
2493
+ if (this.isResizing) {
2494
+ this.isResizing = false;
2495
+ this.activeAnchor = null;
2496
+ this.dragStartCanvas = null;
2497
+ if (commitId && this.resizeStartRect) {
2498
+ const node = this.tree.get(commitId);
2499
+ if (node?.currentRect) {
2500
+ let parentIsGrid = false;
2501
+ if (node.parentId !== null) {
2502
+ const parentContent = this.mount.getContentRoot(node.parentId);
2503
+ if (parentContent) {
2504
+ const info = detectLayout(parentContent);
2505
+ parentIsGrid = info.mode === "grid" || info.mode === "inline-grid";
2506
+ }
2507
+ }
2508
+ if (parentIsGrid) {
2509
+ const contentRoot = this.mount.getContentRoot(commitId);
2510
+ if (contentRoot && this.dragStartStyles) {
2511
+ const payload = {};
2512
+ const undoPayload = {};
2513
+ let styleChanged = false;
2514
+ const styleProps = [
2515
+ "grid-column-start",
2516
+ "grid-column-end",
2517
+ "grid-row-start",
2518
+ "grid-row-end",
2519
+ ];
2520
+ for (const prop of styleProps) {
2521
+ const val = contentRoot.style.getPropertyValue(prop) || null;
2522
+ const origVal = this.dragStartStyles[prop] || null;
2523
+ if (val !== origVal) {
2524
+ payload[prop] = val;
2525
+ undoPayload[prop] = origVal;
2526
+ styleChanged = true;
2527
+ }
2528
+ }
2529
+ if (styleChanged) {
2530
+ operations.push({
2531
+ type: "update-style",
2532
+ nodeId: commitId,
2533
+ payload,
2534
+ undoPayload
2535
+ });
2536
+ }
2537
+ }
2538
+ }
2539
+ else {
2540
+ const finalRect = node.currentRect;
2541
+ const startRect = this.resizeStartRect;
2542
+ if (finalRect.width !== startRect.width || finalRect.height !== startRect.height ||
2543
+ finalRect.x !== startRect.x || finalRect.y !== startRect.y) {
2544
+ const payload = {
2545
+ width: `${finalRect.width}px`,
2546
+ height: `${finalRect.height}px`
2547
+ };
2548
+ const undoPayload = {
2549
+ width: `${startRect.width}px`,
2550
+ height: `${startRect.height}px`
2551
+ };
2552
+ if (node.parentId === null) {
2553
+ payload.left = `${finalRect.x}px`;
2554
+ payload.top = `${finalRect.y}px`;
2555
+ undoPayload.left = `${startRect.x}px`;
2556
+ undoPayload.top = `${startRect.y}px`;
2557
+ }
2558
+ operations.push({
2559
+ type: "update-style",
2560
+ nodeId: commitId,
2561
+ payload,
2562
+ undoPayload
2563
+ });
2564
+ }
2565
+ }
2566
+ this.callbacks.onNodeRectChange?.(commitId, node.currentRect);
2567
+ }
2568
+ }
2569
+ this.resizeStartRect = null;
2570
+ this.dragStartStyles = null;
2571
+ }
2572
+ // Clear guides.
2573
+ this.guides = [];
2574
+ // Release pointer capture.
2575
+ try {
2576
+ this.container.releasePointerCapture(e.pointerId);
2577
+ }
2578
+ catch {
2579
+ // Ignore if capture was already released or lost
2580
+ }
2581
+ if (operations.length > 0) {
2582
+ this.callbacks.onOperationsGenerated?.(operations);
2583
+ }
2584
+ this.canvas.style.pointerEvents = "none";
2585
+ this.callbacks.onInteractionChange?.(null);
2586
+ this.render();
2587
+ // ── Flat String Bridge ────────────────────────
2588
+ // Extract clean HTML and fire commit callback.
2589
+ if (commitId) {
2590
+ const node = this.tree.get(commitId);
2591
+ const commitTarget = (node && node.parentId !== null) ? node.parentId : commitId;
2592
+ const html = this.mount.extractHTML(commitTarget);
2593
+ if (html) {
2594
+ this.callbacks.onHTMLCommit?.(commitTarget, html);
2595
+ }
2596
+ }
2597
+ // Cycle overlapping elements on simple click inside selection
2598
+ if (!this.isDragging && !this.isResizing && !this.isPanning && this.pointerDownInsideSelection) {
2599
+ const rect = this.getContainerRect();
2600
+ const canvasPos = screenToCanvas(e.clientX, e.clientY, this.viewport, rect);
2601
+ const nodeList = this.getOrderedNodeList();
2602
+ // Find all selectable nodes under the cursor in the current selection scope
2603
+ const hitNodes = nodeList.filter(n => {
2604
+ if (!n.currentRect || !isPointInElement(canvasPos.x, canvasPos.y, n.currentRect)) {
2605
+ return false;
2606
+ }
2607
+ const treeNode = this.tree.get(n.id);
2608
+ return treeNode && treeNode.parentId === this.enteredContainerId;
2609
+ });
2610
+ if (hitNodes.length > 1) {
2611
+ const idx = hitNodes.findIndex(n => n.id === this.pointerDownInsideSelection);
2612
+ if (idx !== -1) {
2613
+ const nextIdx = (idx - 1 + hitNodes.length) % hitNodes.length;
2614
+ const nextNode = hitNodes[nextIdx];
2615
+ if (nextNode) {
2616
+ const nextId = nextNode.id;
2617
+ this.selectedIds.clear();
2618
+ this.selectedIds.add(nextId);
2619
+ this.callbacks.onSelectionChange?.(this.selectedIds);
2620
+ this.updateBreadcrumb();
2621
+ this.render();
2622
+ }
2623
+ }
2624
+ }
2625
+ }
2626
+ this.pointerDownInsideSelection = null;
2627
+ }
2628
+ /** Spacebar tracking for pan mode. */
2629
+ handleKeyDown(e) {
2630
+ const target = e.composedPath()[0] || null;
2631
+ if (isEditableTarget(target))
2632
+ return;
2633
+ if (e.code === "Space" && !e.repeat) {
2634
+ e.preventDefault();
2635
+ this.spaceDown = true;
2636
+ this.container.classList.add("canvus-panning");
2637
+ }
2638
+ else if (e.code === "Escape") {
2639
+ this.handleEscapeKey();
2640
+ }
2641
+ else if (e.key === "Meta" || e.key === "Control") {
2642
+ this.updateHover(true);
2643
+ }
2644
+ else if (e.key === "Delete" || e.key === "Backspace") {
2645
+ if (e.metaKey || e.ctrlKey) {
2646
+ e.preventDefault();
2647
+ this.ungroupSelectedOrParent();
2648
+ }
2649
+ else {
2650
+ this.deleteSelectedNode();
2651
+ }
2652
+ }
2653
+ else if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "d") {
2654
+ e.preventDefault();
2655
+ this.duplicateSelectedNode();
2656
+ }
2657
+ else if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "c") {
2658
+ this.copySelectedNode();
2659
+ }
2660
+ else if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "x") {
2661
+ this.cutSelectedNode();
2662
+ }
2663
+ else if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "v") {
2664
+ this.pasteNode();
2665
+ }
2666
+ else if (e.shiftKey && e.key.toLowerCase() === "a") {
2667
+ e.preventDefault();
2668
+ this.wrapSelectedInFlex();
2669
+ }
2670
+ else if (["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(e.key)) {
2671
+ if (this.selectedIds.size > 0) {
2672
+ e.preventDefault();
2673
+ this.nudgeOrReorderSelected(e.key, e.shiftKey);
2674
+ }
2675
+ }
2676
+ }
2677
+ nudgeOrReorderSelected(key, shiftKey) {
2678
+ const topLevelIds = this.getTopLevelSelectedIds();
2679
+ if (topLevelIds.length === 0)
2680
+ return;
2681
+ const rootNodes = [];
2682
+ const groupedByParent = new Map();
2683
+ for (const id of topLevelIds) {
2684
+ const node = this.tree.get(id);
2685
+ if (!node)
2686
+ continue;
2687
+ if (node.parentId === null) {
2688
+ rootNodes.push(node);
2689
+ }
2690
+ else {
2691
+ if (!groupedByParent.has(node.parentId)) {
2692
+ groupedByParent.set(node.parentId, []);
2693
+ }
2694
+ groupedByParent.get(node.parentId).push(node);
2695
+ }
2696
+ }
2697
+ this.mount.setTransitionsEnabled(false);
2698
+ const ops = [];
2699
+ // ── Absolute Nudging (Root Nodes) ─────────────
2700
+ if (rootNodes.length > 0) {
2701
+ const nudgeAmount = shiftKey ? 10 : 1;
2702
+ for (const node of rootNodes) {
2703
+ const currentX = node.currentRect ? node.currentRect.x : 0;
2704
+ const currentY = node.currentRect ? node.currentRect.y : 0;
2705
+ let newX = currentX;
2706
+ let newY = currentY;
2707
+ if (key === "ArrowLeft")
2708
+ newX -= nudgeAmount;
2709
+ if (key === "ArrowRight")
2710
+ newX += nudgeAmount;
2711
+ if (key === "ArrowUp")
2712
+ newY -= nudgeAmount;
2713
+ if (key === "ArrowDown")
2714
+ newY += nudgeAmount;
2715
+ if (newX !== currentX || newY !== currentY) {
2716
+ const payload = { left: `${newX}px`, top: `${newY}px` };
2717
+ const undoPayload = { left: `${currentX}px`, top: `${currentY}px` };
2718
+ this.setNodeStyles(node.id, payload);
2719
+ ops.push({
2720
+ type: "update-style",
2721
+ nodeId: node.id,
2722
+ payload,
2723
+ undoPayload
2724
+ });
2725
+ if (node.currentRect) {
2726
+ this.callbacks.onNodeRectChange?.(node.id, node.currentRect);
2727
+ }
2728
+ }
2729
+ }
2730
+ }
2731
+ // ── Flow Child Reordering (Grouped by Parent) ──
2732
+ for (const [parentId, nodes] of groupedByParent.entries()) {
2733
+ const parentContent = this.mount.getContentRoot(parentId);
2734
+ if (!parentContent)
2735
+ continue;
2736
+ const layoutInfo = detectLayout(parentContent);
2737
+ const flowAxis = getFlowAxis(layoutInfo); // "x" or "y"
2738
+ const siblings = this.tree.getChildren(parentId);
2739
+ const maxIndex = siblings.length - 1;
2740
+ let direction = 0;
2741
+ if (layoutInfo.mode === "grid" || layoutInfo.mode === "inline-grid") {
2742
+ if (key === "ArrowLeft" || key === "ArrowUp")
2743
+ direction = -1;
2744
+ else if (key === "ArrowRight" || key === "ArrowDown")
2745
+ direction = 1;
2746
+ }
2747
+ else if (flowAxis === "x") {
2748
+ if (key === "ArrowLeft")
2749
+ direction = -1;
2750
+ else if (key === "ArrowRight")
2751
+ direction = 1;
2752
+ }
2753
+ else {
2754
+ if (key === "ArrowUp")
2755
+ direction = -1;
2756
+ else if (key === "ArrowDown")
2757
+ direction = 1;
2758
+ }
2759
+ if (direction !== 0) {
2760
+ const sortedNodes = nodes.slice().sort((a, b) => {
2761
+ return this.tree.getChildIndex(a.id) - this.tree.getChildIndex(b.id);
2762
+ });
2763
+ if (direction === -1) {
2764
+ for (const node of sortedNodes) {
2765
+ const currentIndex = this.tree.getChildIndex(node.id);
2766
+ const oldIndex = currentIndex;
2767
+ const newIndex = Math.max(0, currentIndex - 1);
2768
+ if (newIndex !== currentIndex) {
2769
+ this.reorderChild(node.id, newIndex);
2770
+ ops.push({
2771
+ type: "reorder",
2772
+ nodeId: node.id,
2773
+ payload: { index: newIndex },
2774
+ undoPayload: { index: oldIndex }
2775
+ });
2776
+ }
2777
+ }
2778
+ }
2779
+ else {
2780
+ for (let i = sortedNodes.length - 1; i >= 0; i--) {
2781
+ const node = sortedNodes[i];
2782
+ const currentIndex = this.tree.getChildIndex(node.id);
2783
+ const oldIndex = currentIndex;
2784
+ const newIndex = Math.min(maxIndex, currentIndex + 1);
2785
+ if (newIndex !== currentIndex) {
2786
+ this.reorderChild(node.id, newIndex);
2787
+ ops.push({
2788
+ type: "reorder",
2789
+ nodeId: node.id,
2790
+ payload: { index: newIndex },
2791
+ undoPayload: { index: oldIndex }
2792
+ });
2793
+ }
2794
+ }
2795
+ }
2796
+ const html = this.mount.extractHTML(parentId);
2797
+ if (html) {
2798
+ this.callbacks.onHTMLCommit?.(parentId, html);
2799
+ }
2800
+ }
2801
+ }
2802
+ if (ops.length > 0) {
2803
+ this.callbacks.onOperationsGenerated?.(ops);
2804
+ }
2805
+ this.mount.setTransitionsEnabled(true);
2806
+ this.render();
2807
+ }
2808
+ ungroupSelectedOrParent() {
2809
+ const targetContainers = new Set();
2810
+ for (const id of this.selectedIds) {
2811
+ const node = this.tree.get(id);
2812
+ if (!node)
2813
+ continue;
2814
+ if (this.tree.isContainer(id)) {
2815
+ if (node.parentId !== null) {
2816
+ targetContainers.add(id);
2817
+ }
2818
+ }
2819
+ else {
2820
+ if (node.parentId !== null) {
2821
+ targetContainers.add(node.parentId);
2822
+ }
2823
+ }
2824
+ }
2825
+ if (targetContainers.size === 0)
2826
+ return;
2827
+ this.mount.setTransitionsEnabled(false);
2828
+ const ops = [];
2829
+ const parentsToCommit = new Set();
2830
+ const rootsToCommit = new Set();
2831
+ for (const containerId of targetContainers) {
2832
+ const containerNode = this.tree.get(containerId);
2833
+ if (!containerNode)
2834
+ continue;
2835
+ const parentId = containerNode.parentId;
2836
+ const index = parentId !== null ? this.tree.getChildIndex(containerId) : -1;
2837
+ const children = this.tree.getChildren(containerId);
2838
+ let childIndexOffset = 0;
2839
+ for (const child of children) {
2840
+ const childId = child.id;
2841
+ const oldParentId = containerId;
2842
+ const oldIndex = this.tree.getChildIndex(childId);
2843
+ const newIndex = parentId !== null ? index + childIndexOffset : undefined;
2844
+ this.mount.reparentNodeDOM(childId, parentId, newIndex);
2845
+ this.tree.reparentNode(childId, parentId, newIndex);
2846
+ this.remeasureSubtree(childId);
2847
+ ops.push({
2848
+ type: "reparent",
2849
+ nodeId: childId,
2850
+ payload: { newParentId: parentId, index: newIndex !== undefined ? this.tree.getChildIndex(childId) : undefined },
2851
+ undoPayload: { newParentId: oldParentId, index: oldIndex }
2852
+ });
2853
+ childIndexOffset++;
2854
+ }
2855
+ const rawMarkup = this.mount.extractHTML(containerId);
2856
+ const rect = containerNode.currentRect;
2857
+ this.removeNode(containerId);
2858
+ ops.push({
2859
+ type: "delete-node",
2860
+ nodeId: containerId,
2861
+ payload: { parentId },
2862
+ undoPayload: { parentId, rawMarkup, rect }
2863
+ });
2864
+ if (parentId) {
2865
+ parentsToCommit.add(parentId);
2866
+ this.remeasureSubtree(parentId);
2867
+ }
2868
+ else {
2869
+ rootsToCommit.add(containerId);
2870
+ }
2871
+ }
2872
+ if (ops.length > 0) {
2873
+ this.deselectAll();
2874
+ this.callbacks.onOperationsGenerated?.(ops);
2875
+ for (const parentId of parentsToCommit) {
2876
+ const html = this.mount.extractHTML(parentId);
2877
+ if (html) {
2878
+ this.callbacks.onHTMLCommit?.(parentId, html);
2879
+ }
2880
+ }
2881
+ for (const rootId of rootsToCommit) {
2882
+ this.callbacks.onHTMLCommit?.(rootId, "");
2883
+ }
2884
+ }
2885
+ this.mount.setTransitionsEnabled(true);
2886
+ this.render();
2887
+ }
2888
+ wrapSelectedInFlex() {
2889
+ const topLevelIds = this.getTopLevelSelectedIds();
2890
+ if (topLevelIds.length === 0)
2891
+ return;
2892
+ this.mount.setTransitionsEnabled(false);
2893
+ const firstId = topLevelIds[0];
2894
+ const firstNode = this.tree.get(firstId);
2895
+ if (!firstNode) {
2896
+ this.mount.setTransitionsEnabled(true);
2897
+ return;
2898
+ }
2899
+ const parentId = firstNode.parentId;
2900
+ const index = parentId !== null ? this.tree.getChildIndex(firstId) : -1;
2901
+ const nodesToWrap = topLevelIds.map(id => this.tree.get(id)).filter((n) => n !== undefined);
2902
+ const bounds = computeAggregateBounds(nodesToWrap);
2903
+ this.newElementCounter++;
2904
+ const wrapperId = `flex-wrapper-${this.newElementCounter}-${Date.now().toString(36)}`;
2905
+ const rawMarkup = `<div style="display: flex; justify-content: center; align-items: center; gap: 10px; flex-direction: row; box-sizing: border-box;"></div>`;
2906
+ let rect = bounds ? { ...bounds } : null;
2907
+ this.addNode({
2908
+ id: wrapperId,
2909
+ rawMarkup,
2910
+ currentRect: rect
2911
+ }, parentId, index === -1 ? undefined : index);
2912
+ const ops = [];
2913
+ ops.push({
2914
+ type: "create-node",
2915
+ nodeId: wrapperId,
2916
+ payload: { parentId, index: index === -1 ? undefined : this.tree.getChildIndex(wrapperId), rawMarkup, rect },
2917
+ undoPayload: { parentId }
2918
+ });
2919
+ let childIdx = 0;
2920
+ for (const nodeId of topLevelIds) {
2921
+ const node = this.tree.get(nodeId);
2922
+ if (!node)
2923
+ continue;
2924
+ const oldParentId = node.parentId;
2925
+ const oldIndex = this.tree.getChildIndex(nodeId);
2926
+ this.mount.reparentNodeDOM(nodeId, wrapperId, childIdx);
2927
+ this.tree.reparentNode(nodeId, wrapperId, childIdx);
2928
+ this.remeasureSubtree(nodeId);
2929
+ ops.push({
2930
+ type: "reparent",
2931
+ nodeId: nodeId,
2932
+ payload: { newParentId: wrapperId, index: childIdx },
2933
+ undoPayload: { newParentId: oldParentId, index: oldIndex }
2934
+ });
2935
+ childIdx++;
2936
+ }
2937
+ this.remeasureSubtree(wrapperId);
2938
+ if (parentId) {
2939
+ this.remeasureSubtree(parentId);
2940
+ }
2941
+ this.selectedIds.clear();
2942
+ this.selectedIds.add(wrapperId);
2943
+ this.callbacks.onSelectionChange?.(this.selectedIds);
2944
+ this.updateBreadcrumb();
2945
+ this.callbacks.onOperationsGenerated?.(ops);
2946
+ const commitTarget = parentId ?? wrapperId;
2947
+ const html = this.mount.extractHTML(commitTarget);
2948
+ if (html) {
2949
+ this.callbacks.onHTMLCommit?.(commitTarget, html);
2950
+ }
2951
+ this.mount.setTransitionsEnabled(true);
2952
+ this.render();
2953
+ }
2954
+ handleKeyUp(e) {
2955
+ const target = e.composedPath()[0] || null;
2956
+ if (isEditableTarget(target))
2957
+ return;
2958
+ if (e.code === "Space") {
2959
+ this.spaceDown = false;
2960
+ if (!this.isPanning) {
2961
+ this.container.classList.remove("canvus-panning");
2962
+ }
2963
+ }
2964
+ else if (e.key === "Meta" || e.key === "Control") {
2965
+ this.updateHover(e.metaKey || e.ctrlKey);
2966
+ }
2967
+ }
2968
+ /** Resize canvas to match container dimensions. */
2969
+ handleResize() {
2970
+ this.renderer.resize(this.container.clientWidth, this.container.clientHeight);
2971
+ this.render();
2972
+ }
2973
+ /** Double-click text editing handler. */
2974
+ handleDblClick(e) {
2975
+ if (this.previewMode)
2976
+ return;
2977
+ const targetEl = e.composedPath()[0];
2978
+ if (!targetEl)
2979
+ return;
2980
+ // Ensure we only edit text-like or leaf elements, rather than entire layout containers
2981
+ const textTags = new Set([
2982
+ "h1", "h2", "h3", "h4", "h5", "h6",
2983
+ "p", "span", "strong", "em", "b", "i", "u",
2984
+ "a", "button", "label", "li", "code", "pre", "td", "th"
2985
+ ]);
2986
+ const ignoredTags = new Set([
2987
+ "img", "svg", "canvas", "video", "audio",
2988
+ "iframe", "input", "select", "textarea", "br", "hr",
2989
+ "object", "embed", "path", "g", "rect", "circle"
2990
+ ]);
2991
+ const tag = targetEl.tagName?.toLowerCase() || "";
2992
+ const isTextLike = textTags.has(tag) || (targetEl.children.length === 0 && !ignoredTags.has(tag));
2993
+ if (!isTextLike) {
2994
+ this.editAllowedOnDblClick = false;
2995
+ return;
2996
+ }
2997
+ // Find the enclosing node wrapper (both wrapper-based and direct nodes have data-canvus-id)
2998
+ let curr = targetEl;
2999
+ let nodeId = null;
3000
+ while (curr && curr !== this.container) {
3001
+ if (curr.hasAttribute("data-canvus-id")) {
3002
+ nodeId = curr.getAttribute("data-canvus-id");
3003
+ break;
3004
+ }
3005
+ curr = curr.parentElement;
3006
+ }
3007
+ if (!nodeId)
3008
+ return;
3009
+ if (!this.editAllowedOnDblClick || !this.selectedIds.has(nodeId)) {
3010
+ this.editAllowedOnDblClick = false;
3011
+ return;
3012
+ }
3013
+ this.editAllowedOnDblClick = false;
3014
+ const node = this.tree.get(nodeId);
3015
+ if (!node)
3016
+ return;
3017
+ const wrapper = this.mount.getWrapper(nodeId);
3018
+ const contentRoot = this.mount.getContentRoot(nodeId);
3019
+ if (!wrapper || !contentRoot)
3020
+ return;
3021
+ const path = getDOMPath(contentRoot, targetEl);
3022
+ const originalHTML = targetEl.innerHTML;
3023
+ // Option B: Custom Editor Mount Escape Hatch
3024
+ if (this.callbacks.onTextEditRequest) {
3025
+ this.callbacks.onTextEditRequest(nodeId, targetEl, (newHTML) => {
3026
+ targetEl.innerHTML = newHTML;
3027
+ this.remeasureSubtree(nodeId);
3028
+ if (node.parentId) {
3029
+ this.remeasureSubtree(node.parentId);
3030
+ }
3031
+ this.render();
3032
+ const commitTarget = node.parentId ?? nodeId;
3033
+ const htmlStr = this.mount.extractHTML(commitTarget);
3034
+ if (htmlStr) {
3035
+ this.callbacks.onHTMLCommit?.(commitTarget, htmlStr);
3036
+ }
3037
+ this.callbacks.onOperationsGenerated?.([{
3038
+ type: "update-text",
3039
+ nodeId: nodeId,
3040
+ payload: { path, html: newHTML },
3041
+ undoPayload: { path, html: originalHTML }
3042
+ }]);
3043
+ });
3044
+ return;
3045
+ }
3046
+ // Option A: Plain-Text Inline Editor
3047
+ // Restrict to plaintext only to prevent formatting tag injection
3048
+ wrapper.classList.add("canvus-editing");
3049
+ targetEl.setAttribute("contenteditable", "plaintext-only");
3050
+ targetEl.focus();
3051
+ // Select all text natively for easier editing
3052
+ const selection = window.getSelection();
3053
+ if (selection) {
3054
+ const range = document.createRange();
3055
+ range.selectNodeContents(targetEl);
3056
+ selection.removeAllRanges();
3057
+ selection.addRange(range);
3058
+ }
3059
+ const handleKey = (ev) => {
3060
+ // Space -> Insert space for BUTTON element (bypass browser default click trigger)
3061
+ if ((ev.key === " " || ev.code === "Space") && targetEl.tagName === "BUTTON") {
3062
+ ev.preventDefault();
3063
+ ev.stopPropagation();
3064
+ document.execCommand("insertText", false, " ");
3065
+ return;
3066
+ }
3067
+ // Escape -> Cancel
3068
+ if (ev.key === "Escape") {
3069
+ ev.preventDefault();
3070
+ targetEl.innerHTML = originalHTML;
3071
+ targetEl.blur();
3072
+ return;
3073
+ }
3074
+ // Enter -> Save for single line tags
3075
+ const isSingleLine = /^(H[1-6]|BUTTON|A|SPAN|LABEL)$/i.test(targetEl.tagName);
3076
+ if (isSingleLine && ev.key === "Enter") {
3077
+ ev.preventDefault();
3078
+ targetEl.blur();
3079
+ return;
3080
+ }
3081
+ // Block rich text hotkeys (Cmd+B, Cmd+I, etc.)
3082
+ const isCmdOrCtrl = ev.metaKey || ev.ctrlKey;
3083
+ if (isCmdOrCtrl && (ev.key.toLowerCase() === "b" || ev.key.toLowerCase() === "i" || ev.key.toLowerCase() === "u")) {
3084
+ ev.preventDefault();
3085
+ }
3086
+ };
3087
+ const handleBlur = () => {
3088
+ wrapper.classList.remove("canvus-editing");
3089
+ targetEl.removeAttribute("contenteditable");
3090
+ targetEl.removeEventListener("keydown", handleKey);
3091
+ targetEl.removeEventListener("blur", handleBlur);
3092
+ const finalHTML = targetEl.innerHTML;
3093
+ if (finalHTML !== originalHTML) {
3094
+ this.remeasureSubtree(nodeId);
3095
+ if (node.parentId) {
3096
+ this.remeasureSubtree(node.parentId);
3097
+ }
3098
+ this.render();
3099
+ const commitTarget = node.parentId ?? nodeId;
3100
+ const htmlStr = this.mount.extractHTML(commitTarget);
3101
+ if (htmlStr) {
3102
+ this.callbacks.onHTMLCommit?.(commitTarget, htmlStr);
3103
+ }
3104
+ this.callbacks.onOperationsGenerated?.([{
3105
+ type: "update-text",
3106
+ nodeId: nodeId,
3107
+ payload: { path, html: finalHTML },
3108
+ undoPayload: { path, html: originalHTML }
3109
+ }]);
3110
+ }
3111
+ };
3112
+ targetEl.addEventListener("keydown", handleKey);
3113
+ targetEl.addEventListener("blur", handleBlur);
3114
+ }
3115
+ // ── Render ──────────────────────────────────────
3116
+ /** Throttles redrawing using requestAnimationFrame to prevent layout thrashing. */
3117
+ render() {
3118
+ if (this.renderRequested)
3119
+ return;
3120
+ this.renderRequested = true;
3121
+ requestAnimationFrame(() => {
3122
+ this.renderRequested = false;
3123
+ this.renderSync();
3124
+ });
3125
+ }
3126
+ /** Pushes a complete frame to the overlay renderer immediately. */
3127
+ renderSync() {
3128
+ if (this.previewMode) {
3129
+ this.renderer.render({
3130
+ viewport: this.viewport,
3131
+ nodes: [],
3132
+ selectedIds: new Set(),
3133
+ hoveredId: null,
3134
+ activeAnchor: null,
3135
+ guides: [],
3136
+ layoutBadges: undefined,
3137
+ gridOverlays: undefined,
3138
+ activeDropTarget: null,
3139
+ marqueeRect: null,
3140
+ spacingAdjusters: undefined,
3141
+ draggedNodeId: null,
3142
+ resizedNodeId: null,
3143
+ drawingRect: null,
3144
+ drawingTag: null,
3145
+ activeRadiusCorner: null,
3146
+ });
3147
+ return;
3148
+ }
3149
+ // Compute layout badges for selected containers.
3150
+ const layoutBadges = [];
3151
+ const gridOverlays = [];
3152
+ for (const selId of this.selectedIds) {
3153
+ const node = this.tree.get(selId);
3154
+ if (!node?.currentRect)
3155
+ continue;
3156
+ // Detect layout mode from the shadow DOM element.
3157
+ const wrapper = this.mount.getWrapper(selId);
3158
+ if (!wrapper)
3159
+ continue;
3160
+ // Inspect the user's content root.
3161
+ const contentRoot = this.mount.getContentRoot(selId);
3162
+ if (!contentRoot)
3163
+ continue;
3164
+ // JS Badge (⚡️ JS) — uses explicit markNodeHasJS() tracking
3165
+ if (this.jsMarkedNodes.has(selId)) {
3166
+ layoutBadges.push({
3167
+ rect: node.currentRect,
3168
+ label: "⚡️ JS",
3169
+ isJS: true,
3170
+ });
3171
+ }
3172
+ const info = detectLayout(contentRoot);
3173
+ node.layoutMode = info.mode;
3174
+ // Only show badges for containers with children.
3175
+ if (node.childIds.length > 0 || info.mode === "flex" || info.mode === "grid" ||
3176
+ info.mode === "inline-flex" || info.mode === "inline-grid") {
3177
+ const label = getLayoutLabel(info);
3178
+ layoutBadges.push({ rect: node.currentRect, label });
3179
+ // Grid track overlays.
3180
+ if ((info.mode === "grid" || info.mode === "inline-grid") &&
3181
+ info.gridTemplateColumns && info.gridTemplateRows) {
3182
+ gridOverlays.push({
3183
+ rect: node.currentRect,
3184
+ columns: parseGridTracks(info.gridTemplateColumns, info.gap.column),
3185
+ rows: parseGridTracks(info.gridTemplateRows, info.gap.row),
3186
+ });
3187
+ }
3188
+ }
3189
+ }
3190
+ // Draw active drop target grid overlay even if it is not selected
3191
+ if (this.activeDropTarget) {
3192
+ const dropParentId = this.activeDropTarget.parentId;
3193
+ const dropParentContent = this.mount.getContentRoot(dropParentId);
3194
+ if (dropParentContent) {
3195
+ const dropParentInfo = detectLayout(dropParentContent);
3196
+ if ((dropParentInfo.mode === "grid" || dropParentInfo.mode === "inline-grid") &&
3197
+ dropParentInfo.gridTemplateColumns && dropParentInfo.gridTemplateRows) {
3198
+ const dropParentNode = this.tree.get(dropParentId);
3199
+ if (dropParentNode?.currentRect) {
3200
+ if (!gridOverlays.some(g => g.rect === dropParentNode.currentRect)) {
3201
+ gridOverlays.push({
3202
+ rect: dropParentNode.currentRect,
3203
+ columns: parseGridTracks(dropParentInfo.gridTemplateColumns, dropParentInfo.gap.column),
3204
+ rows: parseGridTracks(dropParentInfo.gridTemplateRows, dropParentInfo.gap.row),
3205
+ });
3206
+ }
3207
+ }
3208
+ }
3209
+ }
3210
+ }
3211
+ // Compute spacing adjusters if a single node is selected
3212
+ let spacingAdjusters;
3213
+ if (this.selectedIds.size === 1 && !this.isMarqueeSelecting) {
3214
+ const selId = this.selectedIds.values().next().value;
3215
+ spacingAdjusters = this.computeSpacingAdjusters(selId);
3216
+ }
3217
+ this.renderer.render({
3218
+ viewport: this.viewport,
3219
+ nodes: this.getOrderedNodeList(),
3220
+ selectedIds: this.selectedIds,
3221
+ hoveredId: this.hoveredId,
3222
+ activeAnchor: this.activeAnchor,
3223
+ guides: this.guides,
3224
+ layoutBadges: layoutBadges.length > 0 ? layoutBadges : undefined,
3225
+ gridOverlays: gridOverlays.length > 0 ? gridOverlays : undefined,
3226
+ activeDropTarget: this.activeDropTarget,
3227
+ marqueeRect: this.getMarqueeRect(),
3228
+ spacingAdjusters,
3229
+ draggedNodeId: this.isDragging && this.selectedIds.size === 1 ? this.selectedIds.values().next().value : null,
3230
+ resizedNodeId: this.isResizing && this.selectedIds.size === 1 ? this.selectedIds.values().next().value : null,
3231
+ drawingRect: this.getDrawingRect(),
3232
+ drawingTag: this.isDrawingNode ? this.getDrawingTag() : null,
3233
+ activeRadiusCorner: this.isAdjustingRadius ? this.activeRadiusCorner : this.hoveredRadiusCorner,
3234
+ });
3235
+ }
3236
+ // ── Private Helpers ─────────────────────────────
3237
+ /** Returns the container's bounding rect as our `Rect`. */
3238
+ getContainerRect() {
3239
+ const b = this.container.getBoundingClientRect();
3240
+ return { x: b.x, y: b.y, width: b.width, height: b.height };
3241
+ }
3242
+ // ── Lazy Child Registration ──────────────────────
3243
+ /**
3244
+ * Orchestrates lazy child registration on selection changes.
3245
+ * When a node is newly selected, its immediate DOM children are
3246
+ * registered for tracking. When deselected, its lazy children
3247
+ * are unregistered (DOM left untouched).
3248
+ */
3249
+ syncLazyChildren(prev, next) {
3250
+ // Deregister children of nodes that were deselected —
3251
+ // BUT keep siblings alive if a child of that node is now selected
3252
+ // (user drilled down, not backed out).
3253
+ for (const id of prev) {
3254
+ if (!next.has(id)) {
3255
+ const children = this.tree.getChildren(id);
3256
+ const hasSelectedChild = children.some(c => next.has(c.id));
3257
+ if (!hasSelectedChild) {
3258
+ this.deregisterLazyChildren(id);
3259
+ }
3260
+ }
3261
+ }
3262
+ // Register children of newly selected nodes
3263
+ for (const id of next) {
3264
+ if (!prev.has(id)) {
3265
+ this.registerImmediateChildren(id);
3266
+ }
3267
+ }
3268
+ }
3269
+ /**
3270
+ * Registers the immediate DOM children of a node as tracked
3271
+ * workspace nodes. Uses `trackExistingElement` — no wrapper
3272
+ * divs, no DOM structure changes. Children get hover states,
3273
+ * selection handles, resize, and drag for free.
3274
+ */
3275
+ registerImmediateChildren(parentId) {
3276
+ const wrapper = this.mount.getWrapper(parentId);
3277
+ if (!wrapper)
3278
+ return;
3279
+ const contentRoot = this.mount.getContentRoot(parentId);
3280
+ if (!contentRoot)
3281
+ return;
3282
+ const children = Array.from(contentRoot.children);
3283
+ for (const child of children) {
3284
+ const tag = child.tagName?.toLowerCase();
3285
+ if (!tag || tag === "script" || tag === "style" || tag === "link")
3286
+ continue;
3287
+ // Use existing id or generate a stable one
3288
+ const existingId = child.getAttribute("id");
3289
+ const id = existingId || `${parentId}__child-${++this.lazyChildCounter}`;
3290
+ if (!existingId) {
3291
+ child.setAttribute("id", id);
3292
+ }
3293
+ // Skip if already tracked
3294
+ if (this.tree.get(id))
3295
+ continue;
3296
+ // Track the existing DOM element (adds data-canvus-id + ResizeObserver)
3297
+ const rect = this.mount.trackExistingElement(id, child);
3298
+ // Add to the workspace tree as a child of the parent
3299
+ const resolved = resolveNode({
3300
+ id,
3301
+ rawMarkup: child.outerHTML,
3302
+ currentRect: rect,
3303
+ });
3304
+ resolved.parentId = parentId;
3305
+ if (rect)
3306
+ resolved.currentRect = rect;
3307
+ this.tree.addNode(resolved);
3308
+ this.lazyRegisteredIds.add(id);
3309
+ // Detect layout mode
3310
+ resolved.layoutMode = detectLayout(child).mode;
3311
+ }
3312
+ // Enter the parent scope so the next click targets children
3313
+ if (children.length > 0) {
3314
+ this.enteredContainerId = parentId;
3315
+ this.updateBreadcrumb();
3316
+ }
3317
+ }
3318
+ /**
3319
+ * Deregisters all lazily-registered children of a node.
3320
+ * Removes tracking (ResizeObserver, data-canvus-id attribute,
3321
+ * tree entry) but leaves the DOM element in place.
3322
+ */
3323
+ deregisterLazyChildren(parentId) {
3324
+ const childNodes = this.tree.getChildren(parentId);
3325
+ for (const child of childNodes) {
3326
+ if (!this.lazyRegisteredIds.has(child.id))
3327
+ continue;
3328
+ // Skip children that are currently selected — they're being
3329
+ // drilled into and must stay alive in the tree.
3330
+ if (this.selectedIds.has(child.id))
3331
+ continue;
3332
+ // Recursively deregister grandchildren first
3333
+ this.deregisterLazyChildren(child.id);
3334
+ // Remove selection state
3335
+ this.selectedIds.delete(child.id);
3336
+ // Untrack from ShadowMount (removes data-canvus-id, ResizeObserver)
3337
+ this.mount.untrackNode(child.id);
3338
+ // Remove from tree
3339
+ this.tree.removeNode(child.id);
3340
+ // Clean up lazy tracking
3341
+ this.lazyRegisteredIds.delete(child.id);
3342
+ }
3343
+ }
3344
+ /** Returns nodes in depth-first order for hit testing and rendering. */
3345
+ getOrderedNodeList() {
3346
+ return this.tree.flatten();
3347
+ }
3348
+ getTopLevelSelectedIds() {
3349
+ const list = [];
3350
+ for (const id of this.selectedIds) {
3351
+ let currentId = id;
3352
+ let hasSelectedAncestor = false;
3353
+ while (currentId !== null) {
3354
+ const node = this.tree.get(currentId);
3355
+ if (!node)
3356
+ break;
3357
+ const parentId = node.parentId;
3358
+ if (parentId !== null && this.selectedIds.has(parentId)) {
3359
+ hasSelectedAncestor = true;
3360
+ break;
3361
+ }
3362
+ currentId = parentId;
3363
+ }
3364
+ if (!hasSelectedAncestor) {
3365
+ list.push(id);
3366
+ }
3367
+ }
3368
+ return list;
3369
+ }
3370
+ hitTestRadiusHandle(screenX, screenY, bounds, viewport) {
3371
+ const s = viewport.scale;
3372
+ const ox = viewport.offsetX;
3373
+ const oy = viewport.offsetY;
3374
+ const left = bounds.x * s + ox;
3375
+ const top = bounds.y * s + oy;
3376
+ const right = (bounds.x + bounds.width) * s + ox;
3377
+ const bottom = (bounds.y + bounds.height) * s + oy;
3378
+ const sw = right - left;
3379
+ const sh = bottom - top;
3380
+ if (sw < 64 || sh < 64) {
3381
+ return null;
3382
+ }
3383
+ const inset = 16;
3384
+ const handles = [
3385
+ { type: "tl", hx: left + inset, hy: top + inset },
3386
+ { type: "tr", hx: right - inset, hy: top + inset },
3387
+ { type: "bl", hx: left + inset, hy: bottom - inset },
3388
+ { type: "br", hx: right - inset, hy: bottom - inset },
3389
+ ];
3390
+ const r = 8;
3391
+ for (const h of handles) {
3392
+ const dx = screenX - h.hx;
3393
+ const dy = screenY - h.hy;
3394
+ if (dx * dx + dy * dy <= r * r) {
3395
+ return h.type;
3396
+ }
3397
+ }
3398
+ return null;
3399
+ }
3400
+ /** Returns canvas-space rects of all nodes except the given ID. */
3401
+ getOtherRects(excludeId) {
3402
+ const rects = [];
3403
+ for (const node of this.tree.values()) {
3404
+ if (node.id !== excludeId && node.currentRect) {
3405
+ rects.push(node.currentRect);
3406
+ }
3407
+ }
3408
+ return rects;
3409
+ }
3410
+ getOtherRectsMultiple(excludeIds) {
3411
+ const excludeSet = new Set(excludeIds);
3412
+ const rects = [];
3413
+ for (const node of this.tree.values()) {
3414
+ if (!excludeSet.has(node.id) && node.currentRect) {
3415
+ rects.push(node.currentRect);
3416
+ }
3417
+ }
3418
+ return rects;
3419
+ }
3420
+ /**
3421
+ * Re-measures a node and all its descendants using
3422
+ * canvas-space coordinate extraction.
3423
+ */
3424
+ remeasureSubtree(id) {
3425
+ const rect = this.mount.measureNodeCanvasSpace(id);
3426
+ const node = this.tree.get(id);
3427
+ if (node) {
3428
+ if (rect)
3429
+ node.currentRect = rect;
3430
+ const contentRoot = this.mount.getContentRoot(id);
3431
+ if (contentRoot) {
3432
+ node.layoutMode = detectLayout(contentRoot).mode;
3433
+ }
3434
+ }
3435
+ const descendants = this.tree.getDescendantIds(id);
3436
+ for (const did of descendants) {
3437
+ const dRect = this.mount.measureNodeCanvasSpace(did);
3438
+ const dNode = this.tree.get(did);
3439
+ if (dNode) {
3440
+ if (dRect)
3441
+ dNode.currentRect = dRect;
3442
+ const dContentRoot = this.mount.getContentRoot(did);
3443
+ if (dContentRoot) {
3444
+ dNode.layoutMode = detectLayout(dContentRoot).mode;
3445
+ }
3446
+ }
3447
+ }
3448
+ }
3449
+ /** Ascends selection and scope when Escape key is pressed. */
3450
+ handleEscapeKey() {
3451
+ if (this.selectedIds.size === 1) {
3452
+ const selId = this.selectedIds.values().next().value;
3453
+ const node = this.tree.get(selId);
3454
+ if (node && node.parentId !== null) {
3455
+ this.selectedIds.clear();
3456
+ this.selectedIds.add(node.parentId);
3457
+ this.enteredContainerId = this.tree.get(node.parentId)?.parentId ?? null;
3458
+ this.callbacks.onSelectionChange?.(this.selectedIds);
3459
+ }
3460
+ else {
3461
+ this.deselectAll();
3462
+ this.enteredContainerId = null;
3463
+ }
3464
+ }
3465
+ else if (this.enteredContainerId) {
3466
+ const parent = this.tree.get(this.enteredContainerId);
3467
+ this.enteredContainerId = parent?.parentId ?? null;
3468
+ }
3469
+ else {
3470
+ this.deselectAll();
3471
+ this.enteredContainerId = null;
3472
+ }
3473
+ this.updateBreadcrumb();
3474
+ this.render();
3475
+ }
3476
+ /** Resolves which node is selectable based on click position and scope depth. */
3477
+ findSelectableNode(hitId, scopeId) {
3478
+ const path = this.tree.getPath(hitId);
3479
+ if (path.length === 0)
3480
+ return null;
3481
+ if (scopeId === null) {
3482
+ return path[0]?.id ?? null;
3483
+ }
3484
+ const scopePath = this.tree.getPath(scopeId);
3485
+ let deepestCommonIdxInPath = -1;
3486
+ let deepestCommonIdxInScope = -1;
3487
+ for (let i = 0; i < path.length; i++) {
3488
+ const idx = scopePath.findIndex(n => n.id === path[i].id);
3489
+ if (idx !== -1) {
3490
+ deepestCommonIdxInPath = i;
3491
+ deepestCommonIdxInScope = idx;
3492
+ }
3493
+ }
3494
+ if (deepestCommonIdxInPath !== -1) {
3495
+ if (deepestCommonIdxInScope === scopePath.length - 1) {
3496
+ if (deepestCommonIdxInPath < path.length - 1) {
3497
+ return path[deepestCommonIdxInPath + 1]?.id ?? null;
3498
+ }
3499
+ return scopeId;
3500
+ }
3501
+ else {
3502
+ if (deepestCommonIdxInPath < path.length - 1) {
3503
+ return path[deepestCommonIdxInPath + 1]?.id ?? null;
3504
+ }
3505
+ return path[deepestCommonIdxInPath]?.id ?? null;
3506
+ }
3507
+ }
3508
+ return path[0]?.id ?? null;
3509
+ }
3510
+ /** Updates the hovered node ID based on current pointer position and Cmd/Ctrl modifier. */
3511
+ updateHover(isCmdPressed) {
3512
+ if (!this.lastCanvasPos || this.isPanning || this.isDragging || this.isResizing) {
3513
+ this.clearDynamicHover();
3514
+ this.hoveredId = null;
3515
+ return;
3516
+ }
3517
+ const nodeList = this.getOrderedNodeList();
3518
+ const hitId = hitTestElements(this.lastCanvasPos.x, this.lastCanvasPos.y, nodeList);
3519
+ let nextHoveredId = null;
3520
+ if (hitId) {
3521
+ if (isCmdPressed) {
3522
+ nextHoveredId = hitId;
3523
+ }
3524
+ else {
3525
+ nextHoveredId = this.findSelectableNode(hitId, this.enteredContainerId);
3526
+ }
3527
+ }
3528
+ if (nextHoveredId !== this.dynamicHoveredId) {
3529
+ if (this.dynamicHoveredId && !this.forcedStates.hover.has(this.dynamicHoveredId)) {
3530
+ this.setNodeStateClass(this.dynamicHoveredId, "hover", false);
3531
+ }
3532
+ if (nextHoveredId) {
3533
+ this.setNodeStateClass(nextHoveredId, "hover", true);
3534
+ }
3535
+ this.dynamicHoveredId = nextHoveredId;
3536
+ }
3537
+ this.hoveredId = nextHoveredId;
3538
+ this.render();
3539
+ }
3540
+ clearDynamicHover() {
3541
+ if (this.dynamicHoveredId) {
3542
+ if (!this.forcedStates.hover.has(this.dynamicHoveredId)) {
3543
+ this.setNodeStateClass(this.dynamicHoveredId, "hover", false);
3544
+ }
3545
+ this.dynamicHoveredId = null;
3546
+ }
3547
+ }
3548
+ setNodeStateClass(nodeId, state, enabled) {
3549
+ const wrapper = this.mount.getWrapper(nodeId);
3550
+ if (!wrapper)
3551
+ return;
3552
+ const contentRoot = this.mount.getContentRoot(nodeId);
3553
+ const className = `canvus-state-${state}`;
3554
+ if (enabled) {
3555
+ wrapper.classList.add(className);
3556
+ if (contentRoot && contentRoot !== wrapper) {
3557
+ contentRoot.classList.add(className);
3558
+ }
3559
+ }
3560
+ else {
3561
+ wrapper.classList.remove(className);
3562
+ if (contentRoot && contentRoot !== wrapper) {
3563
+ contentRoot.classList.remove(className);
3564
+ }
3565
+ }
3566
+ this.remeasureSubtree(nodeId);
3567
+ // Delegate pseudo-state forcing if callback or electronAPI is available
3568
+ if (this.callbacks.onForcePseudoState) {
3569
+ this.callbacks.onForcePseudoState(nodeId, state, enabled);
3570
+ }
3571
+ else if (typeof window !== "undefined" && window.electronAPI?.forcePseudoState) {
3572
+ window.electronAPI.forcePseudoState(nodeId, state, enabled).catch((err) => {
3573
+ console.error(`[Workspace] Failed to force pseudo state ${state} on ${nodeId} via electronAPI:`, err);
3574
+ });
3575
+ }
3576
+ }
3577
+ /** Updates the active breadcrumbs and calls external callback. */
3578
+ updateBreadcrumb() {
3579
+ if (this.callbacks.onBreadcrumbChange) {
3580
+ if (this.selectedIds.size === 1) {
3581
+ const selId = this.selectedIds.values().next().value;
3582
+ const path = this.tree.getPath(selId).map(n => n.id);
3583
+ this.callbacks.onBreadcrumbChange(path);
3584
+ }
3585
+ else if (this.enteredContainerId) {
3586
+ const path = this.tree.getPath(this.enteredContainerId).map(n => n.id);
3587
+ this.callbacks.onBreadcrumbChange(path);
3588
+ }
3589
+ else {
3590
+ this.callbacks.onBreadcrumbChange([]);
3591
+ }
3592
+ }
3593
+ }
3594
+ getDrawingRect() {
3595
+ if (!this.isDrawingNode || !this.drawStartCanvas || !this.drawCurrentCanvas) {
3596
+ return null;
3597
+ }
3598
+ return {
3599
+ x: Math.min(this.drawStartCanvas.x, this.drawCurrentCanvas.x),
3600
+ y: Math.min(this.drawStartCanvas.y, this.drawCurrentCanvas.y),
3601
+ width: Math.abs(this.drawStartCanvas.x - this.drawCurrentCanvas.x),
3602
+ height: Math.abs(this.drawStartCanvas.y - this.drawCurrentCanvas.y),
3603
+ };
3604
+ }
3605
+ getMarqueeRect() {
3606
+ if (!this.isMarqueeSelecting || !this.marqueeStartCanvas || !this.marqueeCurrentCanvas) {
3607
+ return null;
3608
+ }
3609
+ return {
3610
+ x: Math.min(this.marqueeStartCanvas.x, this.marqueeCurrentCanvas.x),
3611
+ y: Math.min(this.marqueeStartCanvas.y, this.marqueeCurrentCanvas.y),
3612
+ width: Math.abs(this.marqueeStartCanvas.x - this.marqueeCurrentCanvas.x),
3613
+ height: Math.abs(this.marqueeStartCanvas.y - this.marqueeCurrentCanvas.y),
3614
+ };
3615
+ }
3616
+ computeSpacingAdjusters(id) {
3617
+ const node = this.tree.get(id);
3618
+ if (!node || !node.currentRect)
3619
+ return [];
3620
+ const contentRoot = this.mount.getContentRoot(id);
3621
+ if (!contentRoot)
3622
+ return [];
3623
+ const cs = getComputedStyle(contentRoot);
3624
+ // Compute the accumulated internal CSS zoom/scale factor.
3625
+ const internalScale = this.mount.getElementScale(contentRoot);
3626
+ const safeScale = internalScale && !isNaN(internalScale) ? internalScale : 1;
3627
+ const padTop = (parseFloat(cs.paddingTop) || 0) * safeScale;
3628
+ const padRight = (parseFloat(cs.paddingRight) || 0) * safeScale;
3629
+ const padBottom = (parseFloat(cs.paddingBottom) || 0) * safeScale;
3630
+ const padLeft = (parseFloat(cs.paddingLeft) || 0) * safeScale;
3631
+ const marTop = (parseFloat(cs.marginTop) || 0) * safeScale;
3632
+ const marRight = (parseFloat(cs.marginRight) || 0) * safeScale;
3633
+ const marBottom = (parseFloat(cs.marginBottom) || 0) * safeScale;
3634
+ const marLeft = (parseFloat(cs.marginLeft) || 0) * safeScale;
3635
+ const { x, y, width, height } = node.currentRect;
3636
+ const thickness = 10;
3637
+ const adjusters = [];
3638
+ const addAdjuster = (type, rect, visualRect, value) => {
3639
+ if (value > 0 || this.activeAdjusterType === type) {
3640
+ adjusters.push({
3641
+ type,
3642
+ rect,
3643
+ visualRect,
3644
+ value,
3645
+ isHovered: this.hoveredAdjusterType === type,
3646
+ isActive: this.activeAdjusterType === type,
3647
+ });
3648
+ }
3649
+ };
3650
+ // Calculate content bounds (use direct border box bounds as currentRect doesn't include margins)
3651
+ const cx = x;
3652
+ const cy = y;
3653
+ const cw = width;
3654
+ const ch = height;
3655
+ // Padding adjusters (drawn inside the content bounds)
3656
+ // Pad top
3657
+ const pth = Math.max(thickness, padTop);
3658
+ addAdjuster("padding-top", {
3659
+ x: cx + padLeft,
3660
+ y: cy,
3661
+ width: Math.max(10, cw - padLeft - padRight),
3662
+ height: pth,
3663
+ }, {
3664
+ x: cx + padLeft,
3665
+ y: cy,
3666
+ width: Math.max(10, cw - padLeft - padRight),
3667
+ height: padTop,
3668
+ }, parseFloat(cs.paddingTop) || 0);
3669
+ // Pad bottom
3670
+ const pbh = Math.max(thickness, padBottom);
3671
+ addAdjuster("padding-bottom", {
3672
+ x: cx + padLeft,
3673
+ y: cy + ch - pbh,
3674
+ width: Math.max(10, cw - padLeft - padRight),
3675
+ height: pbh,
3676
+ }, {
3677
+ x: cx + padLeft,
3678
+ y: cy + ch - padBottom,
3679
+ width: Math.max(10, cw - padLeft - padRight),
3680
+ height: padBottom,
3681
+ }, parseFloat(cs.paddingBottom) || 0);
3682
+ // Pad left
3683
+ const plw = Math.max(thickness, padLeft);
3684
+ addAdjuster("padding-left", {
3685
+ x: cx,
3686
+ y: cy + padTop,
3687
+ width: plw,
3688
+ height: Math.max(10, ch - padTop - padBottom),
3689
+ }, {
3690
+ x: cx,
3691
+ y: cy + padTop,
3692
+ width: padLeft,
3693
+ height: Math.max(10, ch - padTop - padBottom),
3694
+ }, parseFloat(cs.paddingLeft) || 0);
3695
+ // Pad right
3696
+ const prw = Math.max(thickness, padRight);
3697
+ addAdjuster("padding-right", {
3698
+ x: cx + cw - prw,
3699
+ y: cy + padTop,
3700
+ width: prw,
3701
+ height: Math.max(10, ch - padTop - padBottom),
3702
+ }, {
3703
+ x: cx + cw - padRight,
3704
+ y: cy + padTop,
3705
+ width: padRight,
3706
+ height: Math.max(10, ch - padTop - padBottom),
3707
+ }, parseFloat(cs.paddingRight) || 0);
3708
+ // Margin adjusters (drawn inside the wrapper, outside/around the content bounds)
3709
+ // Mar top
3710
+ const mth = Math.max(thickness, marTop);
3711
+ addAdjuster("margin-top", {
3712
+ x: cx,
3713
+ y: cy - mth,
3714
+ width: cw,
3715
+ height: mth,
3716
+ }, {
3717
+ x: cx,
3718
+ y: cy - marTop,
3719
+ width: cw,
3720
+ height: marTop,
3721
+ }, parseFloat(cs.marginTop) || 0);
3722
+ // Mar bottom
3723
+ const mbh = Math.max(thickness, marBottom);
3724
+ addAdjuster("margin-bottom", {
3725
+ x: cx,
3726
+ y: cy + ch,
3727
+ width: cw,
3728
+ height: mbh,
3729
+ }, {
3730
+ x: cx,
3731
+ y: cy + ch,
3732
+ width: cw,
3733
+ height: marBottom,
3734
+ }, parseFloat(cs.marginBottom) || 0);
3735
+ // Mar left
3736
+ const mlw = Math.max(thickness, marLeft);
3737
+ addAdjuster("margin-left", {
3738
+ x: cx - mlw,
3739
+ y: cy,
3740
+ width: mlw,
3741
+ height: ch,
3742
+ }, {
3743
+ x: cx - marLeft,
3744
+ y: cy,
3745
+ width: marLeft,
3746
+ height: ch,
3747
+ }, parseFloat(cs.marginLeft) || 0);
3748
+ // Mar right
3749
+ const mrw = Math.max(thickness, marRight);
3750
+ addAdjuster("margin-right", {
3751
+ x: cx + cw,
3752
+ y: cy,
3753
+ width: mrw,
3754
+ height: ch,
3755
+ }, {
3756
+ x: cx + cw,
3757
+ y: cy,
3758
+ width: marRight,
3759
+ height: ch,
3760
+ }, parseFloat(cs.marginRight) || 0);
3761
+ return adjusters;
3762
+ }
3763
+ assertNotDisposed() {
3764
+ if (this.disposed) {
3765
+ throw new Error("[Workspace] Instance has been disposed.");
3766
+ }
3767
+ }
3768
+ safeSetPointerCapture(pointerId) {
3769
+ if (navigator.webdriver || /HeadlessChrome/.test(navigator.userAgent) || /Electron/.test(navigator.userAgent)) {
3770
+ return;
3771
+ }
3772
+ try {
3773
+ this.container.setPointerCapture(pointerId);
3774
+ }
3775
+ catch {
3776
+ // Ignore
3777
+ }
3778
+ }
3779
+ }
3780
+ // ── DOM Path Helpers ────────────────────────────────────────
3781
+ /**
3782
+ * Computes a relative DOM index path from a container root to a target element.
3783
+ */
3784
+ function getDOMPath(root, target) {
3785
+ const path = [];
3786
+ let curr = target;
3787
+ while (curr && curr !== root) {
3788
+ const parentEl = curr.parentElement;
3789
+ if (!parentEl)
3790
+ break;
3791
+ const index = Array.from(parentEl.children).indexOf(curr);
3792
+ path.unshift(index);
3793
+ curr = parentEl;
3794
+ }
3795
+ return path;
3796
+ }
3797
+ /**
3798
+ * Retrieves a descendant element inside a container root using a DOM index path.
3799
+ */
3800
+ function getDOMElementByPath(root, path) {
3801
+ let curr = root;
3802
+ for (const index of path) {
3803
+ const next = curr.children[index];
3804
+ if (!next)
3805
+ return null;
3806
+ curr = next;
3807
+ }
3808
+ return curr;
3809
+ }
3810
+ /**
3811
+ * Checks if the target element of an event is editable (input, textarea, select, or contenteditable).
3812
+ */
3813
+ function isEditableTarget(target) {
3814
+ if (!target)
3815
+ return false;
3816
+ const el = target;
3817
+ const tagName = typeof el.tagName === "string" ? el.tagName.toUpperCase() : "";
3818
+ const isContentEditable = el.isContentEditable === true ||
3819
+ (typeof el.hasAttribute === "function" && el.hasAttribute("contenteditable")) ||
3820
+ (typeof el.getAttribute === "function" && el.getAttribute("contenteditable") !== null);
3821
+ return (tagName === "INPUT" ||
3822
+ tagName === "TEXTAREA" ||
3823
+ tagName === "SELECT" ||
3824
+ isContentEditable);
3825
+ }
3826
+ // ── Layout Grid Helpers ─────────────────────────────────────
3827
+ function getGridStart(element, dimension) {
3828
+ const cs = getComputedStyle(element);
3829
+ const startVal = cs.getPropertyValue(`grid-${dimension}-start`);
3830
+ const val = cs.getPropertyValue(`grid-${dimension}`);
3831
+ const startNum = parseInt(startVal, 10);
3832
+ if (!isNaN(startNum))
3833
+ return startNum;
3834
+ if (val) {
3835
+ const match = val.match(/^\s*(\d+)/);
3836
+ if (match && match[1]) {
3837
+ return parseInt(match[1], 10);
3838
+ }
3839
+ }
3840
+ return getRealGridStart(element, dimension);
3841
+ }
3842
+ function getRealGridStart(element, dimension) {
3843
+ const parent = element.parentElement;
3844
+ if (!parent)
3845
+ return 1;
3846
+ let current = parent;
3847
+ let offset = 0;
3848
+ let gap = 0;
3849
+ let tracks = [];
3850
+ let definingGrid = null;
3851
+ while (current) {
3852
+ const cs = getComputedStyle(current);
3853
+ const display = cs.display;
3854
+ if (display.includes("grid")) {
3855
+ const template = cs.getPropertyValue(`grid-template-${dimension}s`);
3856
+ if (template && !template.includes("subgrid")) {
3857
+ definingGrid = current;
3858
+ gap = parseFloat(cs.getPropertyValue(`${dimension}-gap`)) || 0;
3859
+ tracks = parseGridTracks(template, gap);
3860
+ break;
3861
+ }
3862
+ }
3863
+ const nextParent = current.parentElement;
3864
+ if (!nextParent)
3865
+ break;
3866
+ const currentRect = current.getBoundingClientRect();
3867
+ const parentRect = nextParent.getBoundingClientRect();
3868
+ const pcs = getComputedStyle(nextParent);
3869
+ const padLeft = parseFloat(pcs.paddingLeft) || 0;
3870
+ const padTop = parseFloat(pcs.paddingTop) || 0;
3871
+ offset += (dimension === "column")
3872
+ ? (currentRect.left - parentRect.left - padLeft)
3873
+ : (currentRect.top - parentRect.top - padTop);
3874
+ current = nextParent;
3875
+ }
3876
+ if (!definingGrid || tracks.length === 0)
3877
+ return 1;
3878
+ const elRect = element.getBoundingClientRect();
3879
+ const defRect = definingGrid.getBoundingClientRect();
3880
+ const defStyle = getComputedStyle(definingGrid);
3881
+ const defPadLeft = parseFloat(defStyle.paddingLeft) || 0;
3882
+ const defPadTop = parseFloat(defStyle.paddingTop) || 0;
3883
+ const elOffset = (dimension === "column")
3884
+ ? (elRect.left - defRect.left - defPadLeft)
3885
+ : (elRect.top - defRect.top - defPadTop);
3886
+ const cellIndex = getCellIndexAtOffset(elOffset, tracks, gap);
3887
+ if (parent !== definingGrid) {
3888
+ const parentRect = parent.getBoundingClientRect();
3889
+ const parentOffset = (dimension === "column")
3890
+ ? (parentRect.left - defRect.left - defPadLeft)
3891
+ : (parentRect.top - defRect.top - defPadTop);
3892
+ const parentCellIndex = getCellIndexAtOffset(parentOffset, tracks, gap);
3893
+ return Math.max(1, cellIndex - parentCellIndex + 1);
3894
+ }
3895
+ return cellIndex;
3896
+ }
3897
+ function getCellIndexAtOffset(offset, tracks, gap) {
3898
+ for (let i = 0; i < tracks.length; i++) {
3899
+ const t = tracks[i];
3900
+ if (offset <= t.start + t.size + gap / 2) {
3901
+ return i + 1;
3902
+ }
3903
+ }
3904
+ return tracks.length;
3905
+ }
3906
+ function getGridSpan(element, dimension) {
3907
+ const cs = getComputedStyle(element);
3908
+ const startVal = cs.getPropertyValue(`grid-${dimension}-start`);
3909
+ const endVal = cs.getPropertyValue(`grid-${dimension}-end`);
3910
+ const val = cs.getPropertyValue(`grid-${dimension}`);
3911
+ const spanMatch = (startVal + " " + endVal + " " + val).match(/span\s+(\d+)/i);
3912
+ if (spanMatch && spanMatch[1]) {
3913
+ return parseInt(spanMatch[1], 10);
3914
+ }
3915
+ const startNum = parseInt(startVal, 10);
3916
+ const endNum = parseInt(endVal, 10);
3917
+ if (!isNaN(startNum) && !isNaN(endNum) && endNum > startNum) {
3918
+ return endNum - startNum;
3919
+ }
3920
+ return 1;
3921
+ }
3922
+ //# sourceMappingURL=workspace.js.map