@canvus/core 0.1.1 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/LICENSE +21 -0
  2. package/dist/drop-zone.d.ts.map +1 -1
  3. package/dist/drop-zone.js +16 -0
  4. package/dist/drop-zone.js.map +1 -1
  5. package/dist/handlers/clipboard.handler.d.ts +22 -0
  6. package/dist/handlers/clipboard.handler.d.ts.map +1 -0
  7. package/dist/handlers/clipboard.handler.js +349 -0
  8. package/dist/handlers/clipboard.handler.js.map +1 -0
  9. package/dist/handlers/command.handler.d.ts +18 -0
  10. package/dist/handlers/command.handler.d.ts.map +1 -0
  11. package/dist/handlers/command.handler.js +430 -0
  12. package/dist/handlers/command.handler.js.map +1 -0
  13. package/dist/handlers/drag.handler.d.ts +22 -0
  14. package/dist/handlers/drag.handler.d.ts.map +1 -0
  15. package/dist/handlers/drag.handler.js +669 -0
  16. package/dist/handlers/drag.handler.js.map +1 -0
  17. package/dist/handlers/draw.handler.d.ts +37 -0
  18. package/dist/handlers/draw.handler.d.ts.map +1 -0
  19. package/dist/handlers/draw.handler.js +210 -0
  20. package/dist/handlers/draw.handler.js.map +1 -0
  21. package/dist/handlers/index.d.ts +10 -0
  22. package/dist/handlers/index.d.ts.map +1 -0
  23. package/dist/handlers/index.js +12 -0
  24. package/dist/handlers/index.js.map +1 -0
  25. package/dist/handlers/pan.handler.d.ts +34 -0
  26. package/dist/handlers/pan.handler.d.ts.map +1 -0
  27. package/dist/handlers/pan.handler.js +95 -0
  28. package/dist/handlers/pan.handler.js.map +1 -0
  29. package/dist/handlers/resize.handler.d.ts +26 -0
  30. package/dist/handlers/resize.handler.d.ts.map +1 -0
  31. package/dist/handlers/resize.handler.js +487 -0
  32. package/dist/handlers/resize.handler.js.map +1 -0
  33. package/dist/handlers/selection.handler.d.ts +22 -0
  34. package/dist/handlers/selection.handler.d.ts.map +1 -0
  35. package/dist/handlers/selection.handler.js +259 -0
  36. package/dist/handlers/selection.handler.js.map +1 -0
  37. package/dist/handlers/spacing.handler.d.ts +29 -0
  38. package/dist/handlers/spacing.handler.d.ts.map +1 -0
  39. package/dist/handlers/spacing.handler.js +326 -0
  40. package/dist/handlers/spacing.handler.js.map +1 -0
  41. package/dist/handlers/types.d.ts +204 -0
  42. package/dist/handlers/types.d.ts.map +1 -0
  43. package/dist/handlers/types.js +10 -0
  44. package/dist/handlers/types.js.map +1 -0
  45. package/dist/index.d.ts +1 -0
  46. package/dist/index.d.ts.map +1 -1
  47. package/dist/shadow-mount.d.ts.map +1 -1
  48. package/dist/shadow-mount.js +51 -2
  49. package/dist/shadow-mount.js.map +1 -1
  50. package/dist/workspace.d.ts +149 -68
  51. package/dist/workspace.d.ts.map +1 -1
  52. package/dist/workspace.js +349 -2208
  53. package/dist/workspace.js.map +1 -1
  54. package/package.json +4 -1
package/dist/workspace.js CHANGED
@@ -14,60 +14,19 @@
14
14
  // callbacks; everything else is handled internally.
15
15
  // ─────────────────────────────────────────────────────────────
16
16
  import { createDefaultViewport, resolveNode } from "./types.js";
17
- import { applyPan, applyWheelZoom, hitTestElements, screenToCanvas, rectsIntersect, isPointInElement, } from "./matrix.js";
17
+ import { applyPan, applyWheelZoom, hitTestElements, screenToCanvas, isPointInElement, } from "./matrix.js";
18
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
- }
19
+ import { NodeTree } from "./tree.js";
20
+ import { OverlayRenderer, anchorCursor, isContainerNode, } from "./renderer.js";
21
+ import { detectLayout, getLayoutLabel, parseGridTracks } from "./layout.js";
22
+ import { PanHandler } from "./handlers/pan.handler.js";
23
+ import { DrawHandler } from "./handlers/draw.handler.js";
24
+ import { ClipboardHandler } from "./handlers/clipboard.handler.js";
25
+ import { CommandHandler } from "./handlers/command.handler.js";
26
+ import { SpacingHandler } from "./handlers/spacing.handler.js";
27
+ import { ResizeHandler, getLockedPropertiesForAnchor } from "./handlers/resize.handler.js";
28
+ import { DragHandler } from "./handlers/drag.handler.js";
29
+ import { SelectionHandler } from "./handlers/selection.handler.js";
71
30
  // ── Workspace Class ─────────────────────────────────────────
72
31
  /**
73
32
  * The top-level orchestration engine for a Canvus workspace.
@@ -124,7 +83,6 @@ export class Workspace {
124
83
  active: new Set(),
125
84
  focus: new Set()
126
85
  };
127
- activeAnchor = null;
128
86
  guides = [];
129
87
  // ── Scoped Selection Scope ──────────────────────
130
88
  enteredContainerId = null;
@@ -134,50 +92,60 @@ export class Workspace {
134
92
  editAllowedOnDblClick = false;
135
93
  // ── Drag & Drop State ───────────────────────────
136
94
  activeDropTarget = null;
137
- pointerDownInsideSelection = null;
138
95
  // ── 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;
96
+ get hoveredAdjusterType() {
97
+ return this.spacingHandler ? this.spacingHandler.hoveredAdjusterType : null;
98
+ }
99
+ set hoveredAdjusterType(value) {
100
+ if (this.spacingHandler) {
101
+ this.spacingHandler.hoveredAdjusterType = value;
102
+ }
103
+ }
104
+ get hoveredRadiusCorner() {
105
+ return this.spacingHandler ? this.spacingHandler.hoveredRadiusCorner : null;
106
+ }
107
+ set hoveredRadiusCorner(value) {
108
+ if (this.spacingHandler) {
109
+ this.spacingHandler.hoveredRadiusCorner = value;
110
+ }
111
+ }
159
112
  lastCanvasPos = null;
160
- resizeStartRect = null;
161
- dragStartStyles = null;
162
113
  disposed = false;
163
114
  renderRequested = false;
164
115
  previewMode = false;
165
116
  /** Set of node IDs explicitly marked as containing JavaScript behavior. */
166
117
  jsMarkedNodes = new Set();
118
+ /** Set of node IDs explicitly locked by the host. Locked nodes are non-interactive. */
119
+ lockedNodes = new Set();
167
120
  /** Set of node IDs that were lazily registered (children discovered on selection). */
168
121
  lazyRegisteredIds = new Set();
169
122
  lazyChildCounter = 0;
170
- // ── Drawing Tool State ──────────────────────────
171
- activeTool = null;
172
- drawingTag = "div";
173
- drawingTextTag = "p";
174
- isDrawingNode = false;
175
- drawStartCanvas = null;
176
- drawCurrentCanvas = null;
123
+ // ── Shared Counter ──────────────────────────────
124
+ /** Monotonic counter for generating unique element IDs (shared across draw, clone, paste). */
177
125
  newElementCounter = 0;
178
- // ── Clipboard State ─────────────────────────────
179
- clipboardItems = [];
180
- isDragCopy = false;
126
+ // ── Handler Architecture ────────────────────────
127
+ /** Registered pointer-gesture handlers in priority order. */
128
+ interactionHandlers = [];
129
+ /** Registered keyboard handlers in priority order. */
130
+ keyboardHandlers = [];
131
+ /** The handler that currently owns the active pointer gesture. */
132
+ activeHandler = null;
133
+ /** Pan handler instance (for direct space-key delegation). */
134
+ panHandler;
135
+ /** Draw handler instance (for public API delegation). */
136
+ drawHandler;
137
+ /** Clipboard handler instance (for public API delegation). */
138
+ clipboardHandler;
139
+ /** Command handler instance (for public API delegation). */
140
+ commandHandler;
141
+ /** Spacing handler instance (for interaction delegation). */
142
+ spacingHandler;
143
+ /** Resize handler instance (for interaction delegation). */
144
+ resizeHandler;
145
+ /** Drag handler instance (for interaction delegation). */
146
+ dragHandler;
147
+ /** Selection handler instance (for interaction delegation). */
148
+ selectionHandler;
181
149
  // ── Bound Event Handlers (for cleanup) ──────────
182
150
  onWheel;
183
151
  onPointerDown;
@@ -254,6 +222,25 @@ export class Workspace {
254
222
  window.addEventListener("resize", this.onWindowResize);
255
223
  // ── Initial Sizing ────────────────────────────
256
224
  this.handleResize();
225
+ // ── Register Handlers ─────────────────────────
226
+ // Handlers receive `this` as WorkspaceContext. The Workspace
227
+ // class implements the context interface implicitly.
228
+ this.panHandler = new PanHandler(this);
229
+ this.registerInteractionHandler(this.panHandler, 0); // Highest priority
230
+ this.drawHandler = new DrawHandler(this);
231
+ this.registerInteractionHandler(this.drawHandler, 1); // After pan
232
+ this.clipboardHandler = new ClipboardHandler(this);
233
+ this.registerKeyboardHandler(this.clipboardHandler);
234
+ this.commandHandler = new CommandHandler(this);
235
+ this.registerKeyboardHandler(this.commandHandler);
236
+ this.resizeHandler = new ResizeHandler(this);
237
+ this.registerInteractionHandler(this.resizeHandler, 2);
238
+ this.spacingHandler = new SpacingHandler(this);
239
+ this.registerInteractionHandler(this.spacingHandler, 3);
240
+ this.dragHandler = new DragHandler(this);
241
+ this.registerInteractionHandler(this.dragHandler, 4);
242
+ this.selectionHandler = new SelectionHandler(this);
243
+ this.registerInteractionHandler(this.selectionHandler, 5);
257
244
  }
258
245
  // ── Public API: Node Management ─────────────────
259
246
  /**
@@ -291,6 +278,7 @@ export class Workspace {
291
278
  if (resolved.parentId !== null) {
292
279
  this.remeasureSubtree(resolved.parentId);
293
280
  }
281
+ this.callbacks.onNodeAdded?.(resolved.id);
294
282
  this.render();
295
283
  return resolved.currentRect ?? rect;
296
284
  }
@@ -301,11 +289,13 @@ export class Workspace {
301
289
  for (const did of descendantIds) {
302
290
  this.mount.removeNode(did);
303
291
  this.selectedIds.delete(did);
292
+ this.callbacks.onNodeRemoved?.(did);
304
293
  }
305
294
  const removed = this.mount.removeNode(id);
306
295
  if (removed) {
307
296
  this.tree.removeNode(id); // Also removes descendants from tree.
308
297
  this.selectedIds.delete(id);
298
+ this.callbacks.onNodeRemoved?.(id);
309
299
  this.render();
310
300
  }
311
301
  return removed;
@@ -465,6 +455,23 @@ export class Workspace {
465
455
  getSelectedIds() {
466
456
  return this.selectedIds;
467
457
  }
458
+ // ── Public API: Drawing Tools ───────────────
459
+ /** Sets the active drawing tool (box, text, or null to return to selection/idle mode). */
460
+ setActiveTool(tool) {
461
+ this.drawHandler.setActiveTool(tool);
462
+ }
463
+ /** Returns the currently active drawing tool. */
464
+ getActiveTool() {
465
+ return this.drawHandler.getActiveTool();
466
+ }
467
+ /** Customizes the HTML tag type for box or text drawing. */
468
+ setDrawingTag(tag) {
469
+ this.drawHandler.setDrawingTag(tag);
470
+ }
471
+ /** Returns the active drawing tag based on the selected tool. */
472
+ getDrawingTag() {
473
+ return this.drawHandler.getDrawingTag();
474
+ }
468
475
  // ── Public API: Viewport ────────────────────────
469
476
  /** Returns the current viewport transform. */
470
477
  getViewport() {
@@ -495,15 +502,16 @@ export class Workspace {
495
502
  this.canvas.style.pointerEvents = "none";
496
503
  // Clear selection, hover, and active interactions.
497
504
  if (enabled) {
505
+ if (this.activeHandler) {
506
+ this.activeHandler.onCancel?.();
507
+ this.activeHandler = null;
508
+ }
498
509
  this.selectedIds.clear();
499
510
  this.clearDynamicHover();
500
511
  this.hoveredId = null;
501
512
  this.activeDropTarget = null;
502
- this.activeAdjusterType = null;
503
- this.isDragging = false;
504
- this.isResizing = false;
505
- this.isMarqueeSelecting = false;
506
- this.pointerDownReadyToDrag = false;
513
+ this.dragHandler.onCancel();
514
+ this.selectionHandler.onCancel();
507
515
  this.callbacks.onSelectionChange?.(this.selectedIds);
508
516
  this.callbacks.onInteractionChange?.(null);
509
517
  }
@@ -513,332 +521,27 @@ export class Workspace {
513
521
  isPreviewMode() {
514
522
  return this.previewMode;
515
523
  }
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
- }
524
+ // (Drawing tool API moved see setActiveTool/getActiveTool/setDrawingTag/getDrawingTag above)
546
525
  // ── Public API: Clipboard Operations ────────────
547
526
  /** Deletes the currently selected node from the workspace. */
548
527
  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();
528
+ this.clipboardHandler.deleteSelectedNode();
595
529
  }
596
530
  /** Duplicates the selected node right next to it as a sibling. */
597
531
  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();
532
+ this.clipboardHandler.duplicateSelectedNode();
671
533
  }
672
534
  /** Copies the selected node to the internal clipboard. */
673
535
  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
- }
536
+ this.clipboardHandler.copySelectedNode();
689
537
  }
690
538
  /** Cuts the selected node to the clipboard, removing it from the canvas. */
691
539
  cutSelectedNode() {
692
- this.copySelectedNode();
693
- this.deleteSelectedNode();
540
+ this.clipboardHandler.cutSelectedNode();
694
541
  }
695
542
  /** Pastes the node currently in the clipboard into the canvas. */
696
543
  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();
544
+ this.clipboardHandler.pasteNode();
842
545
  }
843
546
  // ── Public API: State Forcing ───────────────────
844
547
  /** Forces a pseudo-class state (hover, active, focus) on the specified node element. */
@@ -876,6 +579,87 @@ export class Workspace {
876
579
  hasJSMark(nodeId) {
877
580
  return this.jsMarkedNodes.has(nodeId);
878
581
  }
582
+ // ── Public API: Layer Locking ───────────────────
583
+ /**
584
+ * Locks a node, making it non-interactive on the canvas.
585
+ * Locked nodes cannot be selected, dragged, resized, or hovered
586
+ * via user pointer/keyboard interaction. If the node is currently
587
+ * selected, it will be deselected. Locking a parent node also
588
+ * locks all of its descendants.
589
+ */
590
+ lockNode(nodeId) {
591
+ this.lockedNodes.add(nodeId);
592
+ // Deselect this node and any locked descendants
593
+ let changed = false;
594
+ for (const selId of [...this.selectedIds]) {
595
+ if (this.isNodeLocked(selId)) {
596
+ this.selectedIds.delete(selId);
597
+ changed = true;
598
+ }
599
+ }
600
+ if (changed) {
601
+ this.callbacks.onSelectionChange?.(this.selectedIds);
602
+ this.updateBreadcrumb();
603
+ }
604
+ this.render();
605
+ }
606
+ /**
607
+ * Unlocks a previously locked node, restoring interactivity.
608
+ */
609
+ unlockNode(nodeId) {
610
+ this.lockedNodes.delete(nodeId);
611
+ this.render();
612
+ }
613
+ /**
614
+ * Returns whether a node is currently locked (directly or via a locked ancestor).
615
+ */
616
+ isNodeLocked(nodeId) {
617
+ if (this.lockedNodes.has(nodeId))
618
+ return true;
619
+ // Walk up the tree checking ancestors
620
+ let currentId = this.tree.get(nodeId)?.parentId ?? null;
621
+ while (currentId !== null) {
622
+ if (this.lockedNodes.has(currentId))
623
+ return true;
624
+ currentId = this.tree.get(currentId)?.parentId ?? null;
625
+ }
626
+ return false;
627
+ }
628
+ /**
629
+ * Returns the set of directly locked node IDs.
630
+ * Does not include nodes that are only locked via ancestor inheritance.
631
+ */
632
+ getLockedNodeIds() {
633
+ return this.lockedNodes;
634
+ }
635
+ // ── Public API: Property Lock Queries ───────────
636
+ /**
637
+ * Checks whether a CSS property on a node is locked by the host.
638
+ * Delegates to the `isPropertyLocked` callback if provided.
639
+ * Returns `false` (unlocked) when no callback is registered.
640
+ */
641
+ isPropertyLocked(nodeId, property) {
642
+ return this.callbacks.isPropertyLocked?.(nodeId, property) ?? false;
643
+ }
644
+ /**
645
+ * Notifies the host that the user attempted to adjust a locked property.
646
+ * Reads the current computed value from the node's content root and
647
+ * fires the `onPropertyLockInteraction` callback.
648
+ * No-op when the callback is not registered.
649
+ */
650
+ notifyPropertyLockInteraction(nodeId, property) {
651
+ if (!this.callbacks.onPropertyLockInteraction)
652
+ return;
653
+ const contentRoot = this.mount.getContentRoot(nodeId);
654
+ let currentValue = "";
655
+ if (contentRoot) {
656
+ currentValue =
657
+ contentRoot.style.getPropertyValue(property) ||
658
+ window.getComputedStyle(contentRoot).getPropertyValue(property) ||
659
+ "";
660
+ }
661
+ this.callbacks.onPropertyLockInteraction(nodeId, property, currentValue);
662
+ }
879
663
  // ── Public API: Synthetic Interaction ───────────
880
664
  /** Dispatches a synthetic pointer/mouse event (e.g. mouseenter, mouseleave, click) to a node. */
881
665
  dispatchInteractionEvent(nodeId, eventName) {
@@ -1132,6 +916,48 @@ export class Workspace {
1132
916
  injectCSSLink(href) {
1133
917
  return this.mount.injectStylesheetLink(href);
1134
918
  }
919
+ // ── Handler Architecture API ─────────────────────
920
+ /**
921
+ * Registers a pointer-gesture handler at the specified priority position.
922
+ * Lower index = higher priority (checked first on pointerdown).
923
+ * If no index is given, the handler is appended (lowest priority).
924
+ */
925
+ registerInteractionHandler(handler, index) {
926
+ if (index !== undefined) {
927
+ this.interactionHandlers.splice(index, 0, handler);
928
+ }
929
+ else {
930
+ this.interactionHandlers.push(handler);
931
+ }
932
+ }
933
+ /**
934
+ * Registers a keyboard handler at the specified priority position.
935
+ * Lower index = higher priority (checked first on keydown).
936
+ * If no index is given, the handler is appended (lowest priority).
937
+ */
938
+ registerKeyboardHandler(handler, index) {
939
+ if (index !== undefined) {
940
+ this.keyboardHandlers.splice(index, 0, handler);
941
+ }
942
+ else {
943
+ this.keyboardHandlers.push(handler);
944
+ }
945
+ }
946
+ /**
947
+ * Emit an interaction mode change to the host.
948
+ * Enriches the existing `onInteractionChange` callback with
949
+ * optional `InteractionDetail` for richer host observability.
950
+ */
951
+ emitInteraction(mode, _detail) {
952
+ this.callbacks.onInteractionChange?.(mode);
953
+ }
954
+ /**
955
+ * Increment and return a unique counter for generating element IDs.
956
+ * Used by handlers that create new nodes (DrawHandler, ClipboardHandler, etc.).
957
+ */
958
+ nextElementId() {
959
+ return ++this.newElementCounter;
960
+ }
1135
961
  // ── Disposal ────────────────────────────────────
1136
962
  /** Tears down the workspace completely. */
1137
963
  dispose() {
@@ -1156,11 +982,26 @@ export class Workspace {
1156
982
  this.selectedIds.clear();
1157
983
  }
1158
984
  // ── Event Handlers ──────────────────────────────
1159
- /** Cursor-anchored zoom on scroll wheel. */
985
+ /**
986
+ * Handles wheel events with Figma-style behavior:
987
+ * - **Trackpad two-finger scroll** → pans the canvas
988
+ * - **Trackpad pinch-to-zoom** → zooms (browsers report this with ctrlKey=true)
989
+ * - **Ctrl + mouse wheel** → zooms
990
+ */
1160
991
  handleWheel(e) {
1161
992
  e.preventDefault();
1162
993
  const rect = this.getContainerRect();
1163
- this.viewport = applyWheelZoom(e.clientX, e.clientY, e.deltaY, this.viewport, rect);
994
+ // Browsers report trackpad pinch gestures as wheel events with ctrlKey=true.
995
+ // Regular two-finger scrolling does NOT set ctrlKey.
996
+ const isPinchOrCtrlWheel = e.ctrlKey || e.metaKey;
997
+ if (isPinchOrCtrlWheel) {
998
+ // Pinch-to-zoom or Ctrl+scroll → zoom anchored at cursor
999
+ this.viewport = applyWheelZoom(e.clientX, e.clientY, e.deltaY, this.viewport, rect, true);
1000
+ }
1001
+ else {
1002
+ // Regular two-finger scroll → pan the canvas
1003
+ this.viewport = applyPan(-e.deltaX, -e.deltaY, this.viewport);
1004
+ }
1164
1005
  this.mount.applyViewportTransform(this.viewport);
1165
1006
  this.callbacks.onViewportChange?.(this.viewport);
1166
1007
  this.render();
@@ -1170,321 +1011,21 @@ export class Workspace {
1170
1011
  const rect = this.getContainerRect();
1171
1012
  const canvasPos = screenToCanvas(e.clientX, e.clientY, this.viewport, rect);
1172
1013
  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();
1014
+ // ── Handler Dispatch: try registered handlers first ──────
1015
+ if (this.interactionHandlers.length > 0) {
1016
+ const nodeList = this.getOrderedNodeList();
1017
+ const hitId = hitTestElements(canvasPos.x, canvasPos.y, nodeList);
1018
+ for (const handler of this.interactionHandlers) {
1019
+ if (handler.claim(e, canvasPos, hitId, rect)) {
1020
+ this.activeHandler = handler;
1021
+ const isDoubleClick = (Date.now() - this.lastPointerDownTime < 350);
1022
+ this.lastPointerDownTime = isDoubleClick ? 0 : Date.now();
1023
+ this.lastPointerDownId = isDoubleClick ? null : hitId;
1024
+ this.lastPointerDownTarget = isDoubleClick ? null : (e.composedPath()[0] || null);
1251
1025
  return;
1252
1026
  }
1253
1027
  }
1254
1028
  }
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
1029
  }
1489
1030
  /**
1490
1031
  * The core **Synchronous Reflow Loop**.
@@ -1502,257 +1043,17 @@ export class Workspace {
1502
1043
  const rect = this.getContainerRect();
1503
1044
  const canvasPos = screenToCanvas(e.clientX, e.clientY, this.viewport, rect);
1504
1045
  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();
1046
+ // ── Handler Dispatch: route to active handler ────────────
1047
+ if (this.activeHandler) {
1048
+ this.activeHandler.onPointerMove?.(e, canvasPos, rect);
1628
1049
  return;
1629
1050
  }
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();
1051
+ if (this.previewMode) {
1668
1052
  return;
1669
1053
  }
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
- }
1054
+ // (Drawing tool dragging now handled by DrawHandler via dispatch loop)
1754
1055
  // ── Hover tracking ────────────────────────────
1755
- if (!this.isPanning && !this.isDragging && !this.isResizing && !this.isAdjustingRadius) {
1056
+ if (!this.panHandler.isActive && !this.dragHandler.isDragging && !this.resizeHandler.isResizing && !this.spacingHandler.isAdjustingRadius) {
1756
1057
  this.updateHover(e.metaKey || e.ctrlKey);
1757
1058
  // Handle hover cursor for multiple elements.
1758
1059
  let hoveredSelectedId = null;
@@ -1773,14 +1074,19 @@ export class Workspace {
1773
1074
  }
1774
1075
  if (hitRadiusCorner) {
1775
1076
  this.hoveredRadiusCorner = hitRadiusCorner;
1776
- this.container.style.cursor = "pointer";
1077
+ // ── Lock check: suppress radius cursor if locked ──
1078
+ const radiusLocked = this.isPropertyLocked(hoveredSelectedId, "border-radius");
1079
+ this.container.style.cursor = radiusLocked ? "default" : "pointer";
1777
1080
  this.hoveredAdjusterType = null;
1778
1081
  }
1779
1082
  else {
1780
1083
  this.hoveredRadiusCorner = null;
1781
1084
  const anchor = this.renderer.hitTestHandle(localX, localY, selNode.currentRect, this.viewport);
1782
1085
  if (anchor) {
1783
- this.container.style.cursor = anchorCursor(anchor);
1086
+ // ── Lock check: suppress resize cursor if locked ──
1087
+ const affectedProps = getLockedPropertiesForAnchor(anchor);
1088
+ const anyLocked = affectedProps.some(p => this.isPropertyLocked(hoveredSelectedId, p));
1089
+ this.container.style.cursor = anyLocked ? "default" : anchorCursor(anchor);
1784
1090
  this.hoveredAdjusterType = null;
1785
1091
  }
1786
1092
  else {
@@ -1791,249 +1097,37 @@ export class Workspace {
1791
1097
  canvasPos.y >= adj.rect.y &&
1792
1098
  canvasPos.y <= adj.rect.y + adj.rect.height);
1793
1099
  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;
1100
+ // ── Lock check: suppress adjuster cursor if locked ──
1101
+ if (this.isPropertyLocked(hoveredSelectedId, hoveredAdj.type)) {
1102
+ this.hoveredAdjusterType = null;
1103
+ this.container.style.cursor = "default";
1104
+ }
1105
+ else {
1106
+ this.hoveredAdjusterType = hoveredAdj.type;
1107
+ const isVertical = hoveredAdj.type.includes("top") || hoveredAdj.type.includes("bottom");
1108
+ this.container.style.cursor = isVertical ? "ns-resize" : "ew-resize";
1911
1109
  }
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
1110
  }
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
- };
1111
+ else {
1112
+ this.hoveredAdjusterType = null;
1113
+ this.container.style.cursor = "default";
2005
1114
  }
2006
1115
  }
2007
1116
  }
2008
1117
  }
2009
1118
  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
- }
1119
+ this.hoveredRadiusCorner = null;
1120
+ this.hoveredAdjusterType = null;
1121
+ this.container.style.cursor = "default";
2035
1122
  }
2036
1123
  this.render();
1124
+ return;
1125
+ }
1126
+ // ── Pan ────────────────────────────────────────
1127
+ // (Pan is now handled by PanHandler via dispatch loop.
1128
+ // This block is kept as a guard in case of stale state.)
1129
+ if (this.panHandler.isActive) {
1130
+ return;
2037
1131
  }
2038
1132
  }
2039
1133
  /**
@@ -2043,533 +1137,18 @@ export class Workspace {
2043
1137
  * extracts the clean HTML and fires `onHTMLCommit`.
2044
1138
  */
2045
1139
  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
- }
1140
+ // ── Handler Dispatch: route to active handler ────────────
1141
+ if (this.activeHandler) {
1142
+ const rect = this.getContainerRect();
1143
+ const canvasPos = screenToCanvas(e.clientX, e.clientY, this.viewport, rect);
1144
+ this.activeHandler.onPointerUp?.(e, canvasPos, rect);
1145
+ this.activeHandler = null;
2054
1146
  return;
2055
1147
  }
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();
1148
+ if (this.previewMode) {
2150
1149
  return;
2151
1150
  }
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.
1151
+ // (Drawing tool completion now handled by DrawHandler via dispatch loop)
2573
1152
  this.guides = [];
2574
1153
  // Release pointer capture.
2575
1154
  try {
@@ -2578,62 +1157,22 @@ export class Workspace {
2578
1157
  catch {
2579
1158
  // Ignore if capture was already released or lost
2580
1159
  }
2581
- if (operations.length > 0) {
2582
- this.callbacks.onOperationsGenerated?.(operations);
2583
- }
2584
1160
  this.canvas.style.pointerEvents = "none";
2585
1161
  this.callbacks.onInteractionChange?.(null);
2586
1162
  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
1163
  }
2628
- /** Spacebar tracking for pan mode. */
2629
1164
  handleKeyDown(e) {
2630
1165
  const target = e.composedPath()[0] || null;
2631
1166
  if (isEditableTarget(target))
2632
1167
  return;
1168
+ for (const handler of this.keyboardHandlers) {
1169
+ if (handler.onKeyDown?.(e)) {
1170
+ return;
1171
+ }
1172
+ }
2633
1173
  if (e.code === "Space" && !e.repeat) {
2634
1174
  e.preventDefault();
2635
- this.spaceDown = true;
2636
- this.container.classList.add("canvus-panning");
1175
+ this.panHandler.onSpaceDown();
2637
1176
  }
2638
1177
  else if (e.code === "Escape") {
2639
1178
  this.handleEscapeKey();
@@ -2641,326 +1180,23 @@ export class Workspace {
2641
1180
  else if (e.key === "Meta" || e.key === "Control") {
2642
1181
  this.updateHover(true);
2643
1182
  }
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
1183
  }
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();
1184
+ /** Registers a custom keyboard command shortcut. */
1185
+ registerCommand(cmd) {
1186
+ this.commandHandler.registerCommand(cmd);
2953
1187
  }
2954
1188
  handleKeyUp(e) {
2955
1189
  const target = e.composedPath()[0] || null;
2956
1190
  if (isEditableTarget(target))
2957
1191
  return;
2958
- if (e.code === "Space") {
2959
- this.spaceDown = false;
2960
- if (!this.isPanning) {
2961
- this.container.classList.remove("canvus-panning");
1192
+ for (const handler of this.keyboardHandlers) {
1193
+ if (handler.onKeyUp?.(e)) {
1194
+ return;
2962
1195
  }
2963
1196
  }
1197
+ if (e.code === "Space") {
1198
+ this.panHandler.onSpaceUp();
1199
+ }
2964
1200
  else if (e.key === "Meta" || e.key === "Control") {
2965
1201
  this.updateHover(e.metaKey || e.ctrlKey);
2966
1202
  }
@@ -3015,9 +1251,15 @@ export class Workspace {
3015
1251
  if (!node)
3016
1252
  return;
3017
1253
  const wrapper = this.mount.getWrapper(nodeId);
3018
- const contentRoot = this.mount.getContentRoot(nodeId);
1254
+ const contentRoot = this.mount.getContentRoot(nodeId) || wrapper;
3019
1255
  if (!wrapper || !contentRoot)
3020
1256
  return;
1257
+ // Disallow inline text editing for React nodes (marked with data-canvus-react)
1258
+ const isReact = contentRoot.hasAttribute("data-canvus-react") || contentRoot.querySelector("[data-canvus-react]") !== null;
1259
+ if (isReact) {
1260
+ this.editAllowedOnDblClick = false;
1261
+ return;
1262
+ }
3021
1263
  const path = getDOMPath(contentRoot, targetEl);
3022
1264
  const originalHTML = targetEl.innerHTML;
3023
1265
  // Option B: Custom Editor Mount Escape Hatch
@@ -3210,7 +1452,7 @@ export class Workspace {
3210
1452
  }
3211
1453
  // Compute spacing adjusters if a single node is selected
3212
1454
  let spacingAdjusters;
3213
- if (this.selectedIds.size === 1 && !this.isMarqueeSelecting) {
1455
+ if (this.selectedIds.size === 1 && !this.selectionHandler.isMarqueeSelecting) {
3214
1456
  const selId = this.selectedIds.values().next().value;
3215
1457
  spacingAdjusters = this.computeSpacingAdjusters(selId);
3216
1458
  }
@@ -3219,18 +1461,18 @@ export class Workspace {
3219
1461
  nodes: this.getOrderedNodeList(),
3220
1462
  selectedIds: this.selectedIds,
3221
1463
  hoveredId: this.hoveredId,
3222
- activeAnchor: this.activeAnchor,
1464
+ activeAnchor: this.resizeHandler.activeAnchor,
3223
1465
  guides: this.guides,
3224
1466
  layoutBadges: layoutBadges.length > 0 ? layoutBadges : undefined,
3225
1467
  gridOverlays: gridOverlays.length > 0 ? gridOverlays : undefined,
3226
1468
  activeDropTarget: this.activeDropTarget,
3227
1469
  marqueeRect: this.getMarqueeRect(),
3228
1470
  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,
1471
+ draggedNodeId: this.dragHandler.isDragging && this.selectedIds.size === 1 ? this.selectedIds.values().next().value : null,
1472
+ resizedNodeId: this.resizeHandler.isResizing && this.selectedIds.size === 1 ? this.selectedIds.values().next().value : null,
1473
+ drawingRect: this.drawHandler.getDrawingRect(),
1474
+ drawingTag: this.drawHandler.isDrawing ? this.drawHandler.getDrawingTag() : null,
1475
+ activeRadiusCorner: this.spacingHandler.isAdjustingRadius ? this.spacingHandler.activeRadiusCorner : this.hoveredRadiusCorner,
3234
1476
  });
3235
1477
  }
3236
1478
  // ── Private Helpers ─────────────────────────────
@@ -3285,7 +1527,16 @@ export class Workspace {
3285
1527
  if (!tag || tag === "script" || tag === "style" || tag === "link")
3286
1528
  continue;
3287
1529
  // Use existing id or generate a stable one
3288
- const existingId = child.getAttribute("id");
1530
+ let existingId = child.getAttribute("data-canvus-id") || child.getAttribute("id");
1531
+ if (existingId) {
1532
+ const hasNodeInTree = !!this.tree.get(existingId);
1533
+ const trackedWrapper = this.mount.getWrapper(existingId);
1534
+ const trackedContentRoot = this.mount.getContentRoot(existingId);
1535
+ // If the ID is tracked but points to a different DOM element, we have an ID conflict.
1536
+ if (hasNodeInTree && trackedWrapper !== child && trackedContentRoot !== child) {
1537
+ existingId = null;
1538
+ }
1539
+ }
3289
1540
  const id = existingId || `${parentId}__child-${++this.lazyChildCounter}`;
3290
1541
  if (!existingId) {
3291
1542
  child.setAttribute("id", id);
@@ -3509,7 +1760,7 @@ export class Workspace {
3509
1760
  }
3510
1761
  /** Updates the hovered node ID based on current pointer position and Cmd/Ctrl modifier. */
3511
1762
  updateHover(isCmdPressed) {
3512
- if (!this.lastCanvasPos || this.isPanning || this.isDragging || this.isResizing) {
1763
+ if (!this.lastCanvasPos || this.panHandler.isActive || this.dragHandler.isDragging || this.resizeHandler.isResizing) {
3513
1764
  this.clearDynamicHover();
3514
1765
  this.hoveredId = null;
3515
1766
  return;
@@ -3518,7 +1769,11 @@ export class Workspace {
3518
1769
  const hitId = hitTestElements(this.lastCanvasPos.x, this.lastCanvasPos.y, nodeList);
3519
1770
  let nextHoveredId = null;
3520
1771
  if (hitId) {
3521
- if (isCmdPressed) {
1772
+ // Skip hover on locked nodes
1773
+ if (this.isNodeLocked(hitId)) {
1774
+ nextHoveredId = null;
1775
+ }
1776
+ else if (isCmdPressed) {
3522
1777
  nextHoveredId = hitId;
3523
1778
  }
3524
1779
  else {
@@ -3591,27 +1846,9 @@ export class Workspace {
3591
1846
  }
3592
1847
  }
3593
1848
  }
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
- }
1849
+ // (getDrawingRect moved to DrawHandler)
3605
1850
  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
- };
1851
+ return this.selectionHandler ? this.selectionHandler.getMarqueeRect() : null;
3615
1852
  }
3616
1853
  computeSpacingAdjusters(id) {
3617
1854
  const node = this.tree.get(id);
@@ -3636,14 +1873,14 @@ export class Workspace {
3636
1873
  const thickness = 10;
3637
1874
  const adjusters = [];
3638
1875
  const addAdjuster = (type, rect, visualRect, value) => {
3639
- if (value > 0 || this.activeAdjusterType === type) {
1876
+ if (value > 0 || this.spacingHandler.activeAdjusterType === type) {
3640
1877
  adjusters.push({
3641
1878
  type,
3642
1879
  rect,
3643
1880
  visualRect,
3644
1881
  value,
3645
1882
  isHovered: this.hoveredAdjusterType === type,
3646
- isActive: this.activeAdjusterType === type,
1883
+ isActive: this.spacingHandler.activeAdjusterType === type,
3647
1884
  });
3648
1885
  }
3649
1886
  };
@@ -3823,100 +2060,4 @@ function isEditableTarget(target) {
3823
2060
  tagName === "SELECT" ||
3824
2061
  isContentEditable);
3825
2062
  }
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
2063
  //# sourceMappingURL=workspace.js.map