@elixpo/lixsketch 5.5.19 → 5.5.21

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 (137) hide show
  1. package/dist/react/Circle-6POZCUZD.js +9 -0
  2. package/dist/react/{CopyPaste-JMIXWUUO.js → CopyPaste-FJN7K5LS.js} +43 -14
  3. package/dist/react/CopyPaste-FJN7K5LS.js.map +7 -0
  4. package/dist/react/{EventDispatcher-H5EA4KZN.js → EventDispatcher-O7HCQQNM.js} +14 -13
  5. package/dist/react/{EventDispatcher-H5EA4KZN.js.map → EventDispatcher-O7HCQQNM.js.map} +1 -1
  6. package/dist/react/Frame-RLSMJJSK.js +11 -0
  7. package/dist/react/FreehandStroke-U6GYZOM4.js +9 -0
  8. package/dist/react/{GraphEngine-KQXHNTZQ.js → GraphEngine-MM6ALA2N.js} +105 -148
  9. package/dist/react/{GraphEngine-KQXHNTZQ.js.map → GraphEngine-MM6ALA2N.js.map} +2 -2
  10. package/dist/react/IconShape-7OPXAKLK.js +10 -0
  11. package/dist/react/ImageShape-LRNJQCF3.js +10 -0
  12. package/dist/react/Line-WWQFBNX5.js +10 -0
  13. package/dist/react/Rectangle-S7XUKWYD.js +9 -0
  14. package/dist/react/{SceneSerializer-ARTKPZVR.js → SceneSerializer-GSJR7ZHC.js} +36 -11
  15. package/dist/react/SceneSerializer-GSJR7ZHC.js.map +7 -0
  16. package/dist/react/{Selection-YJNZRBON.js → Selection-ZL3G6IAO.js} +5 -4
  17. package/dist/react/{SketchEngine-ZGYBIZG7.js → SketchEngine-BFLHJPCG.js} +25 -25
  18. package/dist/react/{UndoRedo-BZ6W4QTV.js → UndoRedo-NL7KLR5B.js} +2 -2
  19. package/dist/react/{ZoomPan-NYPDJBD5.js → ZoomPan-FAWA5OPS.js} +5 -1
  20. package/dist/react/ZoomPan-FAWA5OPS.js.map +7 -0
  21. package/dist/react/{arrowTool-LKFSZKXC.js → arrowTool-CNVGM6RX.js} +3 -3
  22. package/dist/react/{chunk-RFHMFWVR.js → chunk-2FMFOPGW.js} +5 -6
  23. package/dist/react/{chunk-RFHMFWVR.js.map → chunk-2FMFOPGW.js.map} +2 -2
  24. package/dist/react/{chunk-QYNTPZON.js → chunk-2THKDSWW.js} +9 -5
  25. package/dist/react/chunk-2THKDSWW.js.map +7 -0
  26. package/dist/react/{chunk-JOPQ27YH.js → chunk-4BLMNGTR.js} +3 -3
  27. package/dist/react/{chunk-C62SXFBL.js → chunk-4TAS67SL.js} +23 -22
  28. package/dist/react/chunk-4TAS67SL.js.map +7 -0
  29. package/dist/react/{chunk-FV3EPR4L.js → chunk-AH4PWNEB.js} +19 -11
  30. package/dist/react/chunk-AH4PWNEB.js.map +7 -0
  31. package/dist/react/{chunk-XCJOF6BM.js → chunk-FETTJLCV.js} +5 -1
  32. package/dist/react/chunk-FETTJLCV.js.map +7 -0
  33. package/dist/react/{chunk-JJTII2UQ.js → chunk-GC3KTQDA.js} +78 -15
  34. package/dist/react/chunk-GC3KTQDA.js.map +7 -0
  35. package/dist/react/{chunk-2V7G2PLX.js → chunk-GPIYXREE.js} +3 -3
  36. package/dist/react/{chunk-3VSGFP6U.js → chunk-HFQZYCHJ.js} +8 -4
  37. package/dist/react/chunk-HFQZYCHJ.js.map +7 -0
  38. package/dist/react/{chunk-C72OHQLE.js → chunk-ILGAFR56.js} +3 -3
  39. package/dist/react/{chunk-CF7AK4ES.js → chunk-KVAQPCUH.js} +26 -3
  40. package/dist/react/chunk-KVAQPCUH.js.map +7 -0
  41. package/dist/react/{chunk-WEWMB35W.js → chunk-OFGJJKPH.js} +3 -3
  42. package/dist/react/chunk-PAKA6HUX.js +59 -0
  43. package/dist/react/chunk-PAKA6HUX.js.map +7 -0
  44. package/dist/react/{chunk-HKYUJUD7.js → chunk-PXFPPO3R.js} +5 -1
  45. package/dist/react/chunk-PXFPPO3R.js.map +7 -0
  46. package/dist/react/{chunk-6G3ITAS5.js → chunk-SZD7DKYJ.js} +18 -9
  47. package/dist/react/chunk-SZD7DKYJ.js.map +7 -0
  48. package/dist/react/{chunk-N7NZLF6P.js → chunk-TKXKJNTX.js} +5 -1
  49. package/dist/react/chunk-TKXKJNTX.js.map +7 -0
  50. package/dist/react/{chunk-MYYBFZD3.js → chunk-U3TB4KRS.js} +45 -6
  51. package/dist/react/chunk-U3TB4KRS.js.map +7 -0
  52. package/dist/react/{chunk-UQBT7F4K.js → chunk-V2QTYSBK.js} +2 -2
  53. package/dist/react/{chunk-73VA5ALZ.js → chunk-XFQECTTF.js} +48 -19
  54. package/dist/react/chunk-XFQECTTF.js.map +7 -0
  55. package/dist/react/{chunk-O64N5JPO.js → chunk-YF3TK7SC.js} +2 -2
  56. package/dist/react/{circleTool-RDOIMKKV.js → circleTool-KICNRQCI.js} +4 -4
  57. package/dist/react/{codeTool-O2Z7ZXGP.js → codeTool-NCQ73R6M.js} +5 -4
  58. package/dist/react/{frameTool-Z5ZDCX6H.js → frameTool-Y6KB67DO.js} +4 -4
  59. package/dist/react/{freehandTool-AAQCTMVM.js → freehandTool-MMULHCQ7.js} +4 -4
  60. package/dist/react/{iconTool-AUHRLOO2.js → iconTool-LSW2DYGB.js} +5 -4
  61. package/dist/react/{imageTool-DRKDCPUV.js → imageTool-2DSR32RX.js} +5 -4
  62. package/dist/react/index.js +141 -66
  63. package/dist/react/index.js.map +3 -3
  64. package/dist/react/{lineTool-XLOOZE3E.js → lineTool-N5I7IGT7.js} +4 -4
  65. package/dist/react/{rectangleTool-AEYDDJCA.js → rectangleTool-YUT7ZK2E.js} +4 -4
  66. package/dist/react/{textTool-UORBAAJE.js → textTool-IQOYPELP.js} +6 -5
  67. package/package.json +1 -1
  68. package/src/core/CopyPaste.js +34 -4
  69. package/src/core/GraphEngine.js +129 -185
  70. package/src/core/SceneSerializer.js +26 -0
  71. package/src/core/ScreenSpaceControls.js +60 -0
  72. package/src/core/Selection.js +19 -11
  73. package/src/core/UndoRedo.js +47 -3
  74. package/src/core/ZoomPan.js +3 -0
  75. package/src/react/components/canvas/MultiSelectActions.jsx +4 -1
  76. package/src/react/components/sidebars/IconSidebar.jsx +14 -23
  77. package/src/react/components/sidebars/ImageSidebar.jsx +47 -24
  78. package/src/react/store/useUIStore.js +71 -14
  79. package/src/shapes/Circle.js +2 -0
  80. package/src/shapes/Frame.js +26 -2
  81. package/src/shapes/FreehandStroke.js +2 -0
  82. package/src/shapes/IconShape.js +23 -25
  83. package/src/shapes/ImageShape.js +6 -2
  84. package/src/shapes/Rectangle.js +2 -0
  85. package/src/tools/codeTool.js +3 -2
  86. package/src/tools/frameTool.js +19 -9
  87. package/src/tools/iconTool.js +63 -26
  88. package/src/tools/imageTool.js +80 -14
  89. package/src/tools/textTool.js +3 -1
  90. package/dist/react/Circle-QQMKCKFR.js +0 -8
  91. package/dist/react/CopyPaste-JMIXWUUO.js.map +0 -7
  92. package/dist/react/Frame-VADA625S.js +0 -10
  93. package/dist/react/FreehandStroke-BOWDNAVC.js +0 -8
  94. package/dist/react/IconShape-32EYI2MA.js +0 -10
  95. package/dist/react/ImageShape-MYP2DVVH.js +0 -10
  96. package/dist/react/Line-FCCF2BOB.js +0 -10
  97. package/dist/react/Rectangle-MZ6ZB6KM.js +0 -8
  98. package/dist/react/SceneSerializer-ARTKPZVR.js.map +0 -7
  99. package/dist/react/ZoomPan-NYPDJBD5.js.map +0 -7
  100. package/dist/react/chunk-3VSGFP6U.js.map +0 -7
  101. package/dist/react/chunk-6G3ITAS5.js.map +0 -7
  102. package/dist/react/chunk-73VA5ALZ.js.map +0 -7
  103. package/dist/react/chunk-C62SXFBL.js.map +0 -7
  104. package/dist/react/chunk-CF7AK4ES.js.map +0 -7
  105. package/dist/react/chunk-FV3EPR4L.js.map +0 -7
  106. package/dist/react/chunk-HKYUJUD7.js.map +0 -7
  107. package/dist/react/chunk-JJTII2UQ.js.map +0 -7
  108. package/dist/react/chunk-MYYBFZD3.js.map +0 -7
  109. package/dist/react/chunk-N7NZLF6P.js.map +0 -7
  110. package/dist/react/chunk-QYNTPZON.js.map +0 -7
  111. package/dist/react/chunk-XCJOF6BM.js.map +0 -7
  112. /package/dist/react/{Circle-QQMKCKFR.js.map → Circle-6POZCUZD.js.map} +0 -0
  113. /package/dist/react/{Frame-VADA625S.js.map → Frame-RLSMJJSK.js.map} +0 -0
  114. /package/dist/react/{FreehandStroke-BOWDNAVC.js.map → FreehandStroke-U6GYZOM4.js.map} +0 -0
  115. /package/dist/react/{IconShape-32EYI2MA.js.map → IconShape-7OPXAKLK.js.map} +0 -0
  116. /package/dist/react/{ImageShape-MYP2DVVH.js.map → ImageShape-LRNJQCF3.js.map} +0 -0
  117. /package/dist/react/{Line-FCCF2BOB.js.map → Line-WWQFBNX5.js.map} +0 -0
  118. /package/dist/react/{Rectangle-MZ6ZB6KM.js.map → Rectangle-S7XUKWYD.js.map} +0 -0
  119. /package/dist/react/{Selection-YJNZRBON.js.map → Selection-ZL3G6IAO.js.map} +0 -0
  120. /package/dist/react/{SketchEngine-ZGYBIZG7.js.map → SketchEngine-BFLHJPCG.js.map} +0 -0
  121. /package/dist/react/{UndoRedo-BZ6W4QTV.js.map → UndoRedo-NL7KLR5B.js.map} +0 -0
  122. /package/dist/react/{arrowTool-LKFSZKXC.js.map → arrowTool-CNVGM6RX.js.map} +0 -0
  123. /package/dist/react/{chunk-JOPQ27YH.js.map → chunk-4BLMNGTR.js.map} +0 -0
  124. /package/dist/react/{chunk-2V7G2PLX.js.map → chunk-GPIYXREE.js.map} +0 -0
  125. /package/dist/react/{chunk-C72OHQLE.js.map → chunk-ILGAFR56.js.map} +0 -0
  126. /package/dist/react/{chunk-WEWMB35W.js.map → chunk-OFGJJKPH.js.map} +0 -0
  127. /package/dist/react/{chunk-UQBT7F4K.js.map → chunk-V2QTYSBK.js.map} +0 -0
  128. /package/dist/react/{chunk-O64N5JPO.js.map → chunk-YF3TK7SC.js.map} +0 -0
  129. /package/dist/react/{circleTool-RDOIMKKV.js.map → circleTool-KICNRQCI.js.map} +0 -0
  130. /package/dist/react/{codeTool-O2Z7ZXGP.js.map → codeTool-NCQ73R6M.js.map} +0 -0
  131. /package/dist/react/{frameTool-Z5ZDCX6H.js.map → frameTool-Y6KB67DO.js.map} +0 -0
  132. /package/dist/react/{freehandTool-AAQCTMVM.js.map → freehandTool-MMULHCQ7.js.map} +0 -0
  133. /package/dist/react/{iconTool-AUHRLOO2.js.map → iconTool-LSW2DYGB.js.map} +0 -0
  134. /package/dist/react/{imageTool-DRKDCPUV.js.map → imageTool-2DSR32RX.js.map} +0 -0
  135. /package/dist/react/{lineTool-XLOOZE3E.js.map → lineTool-N5I7IGT7.js.map} +0 -0
  136. /package/dist/react/{rectangleTool-AEYDDJCA.js.map → rectangleTool-YUT7ZK2E.js.map} +0 -0
  137. /package/dist/react/{textTool-UORBAAJE.js.map → textTool-IQOYPELP.js.map} +0 -0
@@ -2,8 +2,9 @@
2
2
  // Multi-selection system - copied from selection.js
3
3
 
4
4
  import { cleanupAttachments } from '../tools/arrowTool.js';
5
- import { pushTransformAction, pushFrameAttachmentAction, pushDeleteAction } from './UndoRedo.js';
5
+ import { pushCreateAction, pushTransformAction, pushFrameAttachmentAction, pushDeleteAction } from './UndoRedo.js';
6
6
  import { calculateSnap, clearSnapGuides } from './SnapGuides.js';
7
+ import { registerRotationAnchor } from './ScreenSpaceControls.js';
7
8
 
8
9
  let isMultiSelecting = false;
9
10
  let multiSelectionStart = { x: 0, y: 0 };
@@ -545,6 +546,12 @@ class MultiSelection {
545
546
 
546
547
  this.group.appendChild(this.rotationLine);
547
548
  this.group.appendChild(this.rotationAnchor);
549
+ registerRotationAnchor(this.rotationAnchor, {
550
+ radius: 8,
551
+ edgeY: y,
552
+ line: this.rotationLine,
553
+ lineEnd: '1',
554
+ });
548
555
  }
549
556
 
550
557
  removeControls() {
@@ -1936,6 +1943,11 @@ function deleteSelectedShapes() {
1936
1943
  function frameSelectedShapes() {
1937
1944
  if (multiSelection.selectedShapes.size < 2) return;
1938
1945
 
1946
+ const shapesToFrame = Array.from(multiSelection.selectedShapes);
1947
+ // Frames cannot be nested through the Frame it action, and objects that
1948
+ // already belong to a frame must first be detached explicitly.
1949
+ if (shapesToFrame.some(shape => shape?.shapeName === 'frame' || shape?.parentFrame)) return;
1950
+
1939
1951
  const bounds = multiSelection.getBounds();
1940
1952
  if (!bounds) return;
1941
1953
 
@@ -1955,17 +1967,7 @@ function frameSelectedShapes() {
1955
1967
  const frame = new FrameClass(fx, fy, fw, fh);
1956
1968
  shapes.push(frame);
1957
1969
 
1958
- // Push undo action for frame creation
1959
- if (window.historyStack) {
1960
- window.historyStack.push({
1961
- type: window.ACTION_CREATE || 'create',
1962
- shape: frame,
1963
- shapeName: 'frame'
1964
- });
1965
- }
1966
-
1967
1970
  // Add each selected shape into the frame
1968
- const shapesToFrame = Array.from(multiSelection.selectedShapes);
1969
1971
  multiSelection.clearSelection();
1970
1972
 
1971
1973
  for (const shape of shapesToFrame) {
@@ -1989,6 +1991,12 @@ function frameSelectedShapes() {
1989
1991
  }
1990
1992
  }
1991
1993
 
1994
+ // Record after reordering so redo restores the same hit-test/layer index.
1995
+ pushCreateAction(frame, {
1996
+ frameCreation: true,
1997
+ containedShapes: shapesToFrame
1998
+ });
1999
+
1992
2000
  // Select the new frame
1993
2001
  if (typeof frame.selectFrame === 'function') {
1994
2002
  frame.selectFrame();
@@ -34,11 +34,17 @@ export function updateSelectedElement(element) {
34
34
  }
35
35
 
36
36
  // Call this after creating a new shape (e.g., Rectangle)
37
- export function pushCreateAction(shape) {
38
- undoStack.push({
37
+ export function pushCreateAction(shape, meta = null) {
38
+ const action = {
39
39
  type: 'create',
40
40
  shape: shape
41
- });
41
+ };
42
+ if (meta?.frameCreation && shape?.shapeName === 'frame') {
43
+ action.frameCreation = true;
44
+ action.containedShapes = [...(meta.containedShapes || shape.containedShapes || [])];
45
+ action.shapeIndex = Array.isArray(shapes) ? shapes.indexOf(shape) : -1;
46
+ }
47
+ undoStack.push(action);
42
48
 
43
49
  // Clear redo stack when new action is performed
44
50
  redoStack.length = 0;
@@ -567,6 +573,23 @@ export function undo() {
567
573
  }
568
574
 
569
575
  if (action.type === 'create') {
576
+ if (action.frameCreation && action.shape.shapeName === 'frame') {
577
+ // Treat a manually-created frame and its initial membership as one
578
+ // atomic action. Children remain on the canvas when the frame is
579
+ // undone and are clipped back into it on redo.
580
+ for (const child of action.containedShapes || []) {
581
+ if (child?.parentFrame === action.shape) action.shape.removeShapeFromFrame(child);
582
+ }
583
+ if (currentShape === action.shape) {
584
+ action.shape.removeSelection?.();
585
+ currentShape = null;
586
+ }
587
+ const idx = shapes.indexOf(action.shape);
588
+ if (idx !== -1) shapes.splice(idx, 1);
589
+ action.shape.group?.remove();
590
+ action.shape.clipGroup?.remove();
591
+ action.shape.clipPath?.remove();
592
+ } else
570
593
  if (action.shape.type === 'text') {
571
594
  // Handle text creation undo
572
595
  if (action.shape.element && action.shape.element.parentNode) {
@@ -1049,6 +1072,27 @@ export function redo() {
1049
1072
  }
1050
1073
 
1051
1074
  if (action.type === 'create') {
1075
+ if (action.frameCreation && action.shape.shapeName === 'frame') {
1076
+ const frame = action.shape;
1077
+ let defs = svg?.querySelector('defs');
1078
+ if (!defs && svg) {
1079
+ defs = document.createElementNS('http://www.w3.org/2000/svg', 'defs');
1080
+ svg.appendChild(defs);
1081
+ }
1082
+ if (defs && frame.clipPath && !frame.clipPath.parentNode) defs.appendChild(frame.clipPath);
1083
+ if (svg && frame.group && !frame.group.parentNode) svg.appendChild(frame.group);
1084
+ if (svg && frame.clipGroup && !frame.clipGroup.parentNode) svg.appendChild(frame.clipGroup);
1085
+ if (shapes.indexOf(frame) === -1) {
1086
+ const index = action.shapeIndex >= 0 ? Math.min(action.shapeIndex, shapes.length) : shapes.length;
1087
+ shapes.splice(index, 0, frame);
1088
+ }
1089
+ frame.containedShapes = [];
1090
+ frame.draw();
1091
+ frame.updateClipPath();
1092
+ for (const child of action.containedShapes || []) {
1093
+ if (child && shapes.includes(child)) frame.addShapeToFrame(child);
1094
+ }
1095
+ } else
1052
1096
  if (action.shape.type === 'text') {
1053
1097
  // Handle text creation redo
1054
1098
  if (svg) {
@@ -1,8 +1,11 @@
1
1
  /* eslint-disable */
2
+ import { installScreenSpaceControlSync } from './ScreenSpaceControls.js';
2
3
  // Zoom and Pan controls - combined from zoomFunction.js + panCanvas.js
3
4
  // Depends on globals: svg, freehandCanvas, currentZoom, currentViewBox, isPanningToolActive, isPanning
4
5
  // Depends on globals: minScale, maxScale, zoomInBtn, zoomOutBtn, zoomPercentSpan, panStart, startCanvasX, startCanvasY
5
6
 
7
+ installScreenSpaceControlSync(freehandCanvas);
8
+
6
9
  // === Zoom Functions ===
7
10
  let currentY = 0;
8
11
  const scrollRate = 50;
@@ -6,6 +6,7 @@ import { showToast } from '../../utils/toast'
6
6
  export default function MultiSelectActions() {
7
7
  const [count, setCount] = useState(0)
8
8
  const [groupState, setGroupState] = useState('none') // 'none' | 'partial' | 'all'
9
+ const [frameEligible, setFrameEligible] = useState(false)
9
10
 
10
11
  useEffect(() => {
11
12
  // Poll multi-selection state. Cheap — Set size + a single
@@ -18,6 +19,7 @@ export default function MultiSelectActions() {
18
19
 
19
20
  if (n < 2) {
20
21
  setGroupState('none')
22
+ setFrameEligible(false)
21
23
  return
22
24
  }
23
25
  let withId = 0
@@ -28,6 +30,7 @@ export default function MultiSelectActions() {
28
30
  groupIds.add(s.groupId)
29
31
  }
30
32
  })
33
+ setFrameEligible(Array.from(sel).every((s) => s.shapeName !== 'frame' && !s.parentFrame))
31
34
  // "all" = every shape has a groupId AND they all share the same one
32
35
  if (withId === n && groupIds.size === 1) setGroupState('all')
33
36
  else if (withId > 0) setGroupState('partial')
@@ -82,7 +85,7 @@ export default function MultiSelectActions() {
82
85
 
83
86
  {/* Frame is only useful when shapes aren't already grouped — a
84
87
  grouped set is conceptually a "frameless frame" already. */}
85
- {!grouped && (
88
+ {!grouped && frameEligible && (
86
89
  <button
87
90
  onClick={handleFrame}
88
91
  className="flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs text-accent-blue hover:bg-accent-blue/10 transition-all duration-150"
@@ -90,6 +90,14 @@ export default function IconSidebar() {
90
90
  const [icons, setIcons] = useState([])
91
91
  const [loading, setLoading] = useState(false)
92
92
  const debounceRef = useRef(null)
93
+ const searchInputRef = useRef(null)
94
+ const requestSequenceRef = useRef(0)
95
+
96
+ useEffect(() => {
97
+ if (!visible) return
98
+ const frame = requestAnimationFrame(() => searchInputRef.current?.focus())
99
+ return () => cancelAnimationFrame(frame)
100
+ }, [visible])
93
101
 
94
102
  // Close on Escape
95
103
  useEffect(() => {
@@ -115,6 +123,7 @@ export default function IconSidebar() {
115
123
  }, [visible, setActiveTool])
116
124
 
117
125
  const fetchIcons = useCallback(async (searchQuery, cat) => {
126
+ const requestSequence = ++requestSequenceRef.current
118
127
  const params = new URLSearchParams()
119
128
  if (searchQuery) params.set('q', searchQuery)
120
129
  if (cat) params.set('category', cat)
@@ -124,6 +133,7 @@ export default function IconSidebar() {
124
133
  // Return cached results instantly if available
125
134
  if (iconResultCache.has(cacheKey)) {
126
135
  setIcons(iconResultCache.get(cacheKey))
136
+ setLoading(false)
127
137
  return
128
138
  }
129
139
 
@@ -134,12 +144,12 @@ export default function IconSidebar() {
134
144
  const data = await res.json()
135
145
  const results = data.results || []
136
146
  iconResultCache.set(cacheKey, results)
137
- setIcons(results)
147
+ if (requestSequence === requestSequenceRef.current) setIcons(results)
138
148
  }
139
149
  } catch (err) {
140
150
  console.error('Icon fetch failed:', err)
141
151
  }
142
- setLoading(false)
152
+ if (requestSequence === requestSequenceRef.current) setLoading(false)
143
153
  }, [])
144
154
 
145
155
  // Fetch icons when visibility, query, or category changes (debounced for query typing)
@@ -148,30 +158,10 @@ export default function IconSidebar() {
148
158
  if (debounceRef.current) clearTimeout(debounceRef.current)
149
159
  debounceRef.current = setTimeout(() => {
150
160
  fetchIcons(query, category)
151
- }, query ? 300 : 0)
161
+ }, query ? 80 : 0)
152
162
  return () => clearTimeout(debounceRef.current)
153
163
  }, [query, visible, category, fetchIcons])
154
164
 
155
- // Preload all categories in background on first sidebar open
156
- const hasPreloaded = useRef(false)
157
- useEffect(() => {
158
- if (!visible || hasPreloaded.current) return
159
- hasPreloaded.current = true
160
- // Fire-and-forget: preload each category so switching is instant
161
- CATEGORIES.forEach((cat) => {
162
- const params = new URLSearchParams()
163
- if (cat.value) params.set('category', cat.value)
164
- params.set('inline', '1')
165
- const key = params.toString()
166
- if (!iconResultCache.has(key)) {
167
- fetch(`${getIconsBaseUrl()}/api/icons/search?${key}`)
168
- .then((r) => r.ok ? r.json() : null)
169
- .then((data) => { if (data?.results) iconResultCache.set(key, data.results) })
170
- .catch(() => {})
171
- }
172
- })
173
- }, [visible])
174
-
175
165
  const handleIconClick = useCallback((icon) => {
176
166
  if (typeof window === 'undefined') return
177
167
  const place = (svgContent) => {
@@ -214,6 +204,7 @@ export default function IconSidebar() {
214
204
  <div className="flex items-center gap-2 bg-surface-hover border border-border-light rounded-lg px-2.5 py-2">
215
205
  <i className="bx bxs-search text-text-muted text-sm" />
216
206
  <input
207
+ ref={searchInputRef}
217
208
  id="iconSearchInput"
218
209
  type="text"
219
210
  value={query}
@@ -10,6 +10,15 @@ export default function ImageSidebar() {
10
10
  const { t } = useTranslation()
11
11
  const selectedShapeSidebar = useSketchStore((s) => s.selectedShapeSidebar)
12
12
  const toggleImageGenerateModal = useUIStore((s) => s.toggleImageGenerateModal)
13
+ const toggleAIModal = useUIStore((s) => s.toggleAIModal)
14
+ const isGraph = typeof window !== 'undefined' && window.currentShape?._frameType === 'graph'
15
+
16
+ const handleEditGraph = useCallback(() => {
17
+ const shape = window.currentShape
18
+ if (!shape || shape._frameType !== 'graph') return
19
+ window.__aiEditTargetFrame = shape
20
+ toggleAIModal()
21
+ }, [toggleAIModal])
13
22
 
14
23
  const handleEditWithAI = useCallback(() => {
15
24
  const shape = window.currentShape
@@ -36,31 +45,45 @@ export default function ImageSidebar() {
36
45
 
37
46
  return (
38
47
  <ShapeSidebar visible={selectedShapeSidebar === 'image'}>
39
- {/* Edit with AI */}
40
- <button
41
- onClick={handleEditWithAI}
42
- title="Edit with AI"
43
- className="h-9 flex items-center gap-1.5 px-3 rounded-lg text-text-muted hover:text-purple-400 hover:bg-purple-500/10 transition-all duration-100"
44
- >
45
- <svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" stroke="none">
46
- <path d="M12 2l2.4 7.2L22 12l-7.6 2.8L12 22l-2.4-7.2L2 12l7.6-2.8L12 2z" />
47
- </svg>
48
- <span className="text-xs">AI Edit</span>
49
- </button>
48
+ {isGraph && (
49
+ <>
50
+ <button
51
+ onClick={handleEditGraph}
52
+ title="Edit graph"
53
+ className="h-9 flex cursor-pointer items-center gap-1.5 px-3 rounded-lg text-text-muted hover:text-accent hover:bg-accent/10 transition-all duration-100"
54
+ >
55
+ <i className="bx bx-line-chart text-base" />
56
+ <span className="text-xs">Edit graph</span>
57
+ </button>
58
+ <Divider />
59
+ </>
60
+ )}
50
61
 
51
- <Divider />
52
-
53
- {/* Replace image */}
54
- <button
55
- onClick={handleReplace}
56
- title="Replace image"
57
- className="h-9 flex items-center gap-1.5 px-3 rounded-lg text-text-muted hover:text-white hover:bg-white/[0.06] transition-all duration-100"
58
- >
59
- <i className="bx bx-upload text-base" />
60
- <span className="text-xs">Replace</span>
61
- </button>
62
-
63
- <Divider />
62
+ {/* Edit with AI */}
63
+ {!isGraph && (
64
+ <>
65
+ <button
66
+ onClick={handleEditWithAI}
67
+ title="Edit with AI"
68
+ className="h-9 flex items-center gap-1.5 px-3 rounded-lg text-text-muted hover:text-purple-400 hover:bg-purple-500/10 transition-all duration-100"
69
+ >
70
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" stroke="none">
71
+ <path d="M12 2l2.4 7.2L22 12l-7.6 2.8L12 22l-2.4-7.2L2 12l7.6-2.8L12 2z" />
72
+ </svg>
73
+ <span className="text-xs">AI Edit</span>
74
+ </button>
75
+ <Divider />
76
+ <button
77
+ onClick={handleReplace}
78
+ title="Replace image"
79
+ className="h-9 flex items-center gap-1.5 px-3 rounded-lg text-text-muted hover:text-white hover:bg-white/[0.06] transition-all duration-100"
80
+ >
81
+ <i className="bx bx-upload text-base" />
82
+ <span className="text-xs">Replace</span>
83
+ </button>
84
+ <Divider />
85
+ </>
86
+ )}
64
87
 
65
88
  {/* Layer controls */}
66
89
  <LayerControls />
@@ -29,25 +29,82 @@ function invertShapeColors(prevResolved, nextResolved) {
29
29
 
30
30
  for (const shape of shapes) {
31
31
  let changed = false
32
- if (shape.options) {
33
- if (normalize(shape.options.stroke) === from) {
34
- shape.options.stroke = to
32
+ if (shape.shapeName === 'icon' || shape.type === 'icon') {
33
+ const isDark = nextResolved === 'dark'
34
+ const targetDarkColor = '#1a1a2e'
35
+ const bgDarkColor = '#15111f'
36
+
37
+ if (shape.group) {
38
+ shape.group.querySelectorAll('*').forEach((element) => {
39
+ if (element.nodeType === 1) {
40
+ const fill = element.getAttribute('fill')
41
+ const stroke = element.getAttribute('stroke')
42
+
43
+ if (fill && fill !== 'none' && fill !== 'transparent') {
44
+ const normFill = fill.toLowerCase().trim()
45
+ if (isDark) {
46
+ if (normFill === targetDarkColor || normFill === '#000000' || normFill === '#000') {
47
+ element.setAttribute('fill', '#ffffff')
48
+ changed = true
49
+ } else if (normFill === '#ffffff' || normFill === '#fff') {
50
+ element.setAttribute('fill', bgDarkColor)
51
+ changed = true
52
+ }
53
+ } else {
54
+ if (normFill === '#ffffff' || normFill === '#fff') {
55
+ element.setAttribute('fill', targetDarkColor)
56
+ changed = true
57
+ } else if (normFill === bgDarkColor) {
58
+ element.setAttribute('fill', '#ffffff')
59
+ changed = true
60
+ }
61
+ }
62
+ }
63
+
64
+ if (stroke && stroke !== 'none' && stroke !== 'transparent') {
65
+ const normStroke = stroke.toLowerCase().trim()
66
+ if (isDark) {
67
+ if (normStroke === targetDarkColor || normStroke === '#000000' || normStroke === '#000') {
68
+ element.setAttribute('stroke', '#ffffff')
69
+ changed = true
70
+ } else if (normStroke === '#ffffff' || normStroke === '#fff') {
71
+ element.setAttribute('stroke', bgDarkColor)
72
+ changed = true
73
+ }
74
+ } else {
75
+ if (normStroke === '#ffffff' || normStroke === '#fff') {
76
+ element.setAttribute('stroke', targetDarkColor)
77
+ changed = true
78
+ } else if (normStroke === bgDarkColor) {
79
+ element.setAttribute('stroke', '#ffffff')
80
+ changed = true
81
+ }
82
+ }
83
+ }
84
+ }
85
+ })
86
+ }
87
+ } else {
88
+ if (shape.options) {
89
+ if (normalize(shape.options.stroke) === from) {
90
+ shape.options.stroke = to
91
+ changed = true
92
+ }
93
+ if (normalize(shape.options.fill) === from) {
94
+ shape.options.fill = to
95
+ changed = true
96
+ }
97
+ }
98
+ // Text shapes store color directly
99
+ if (shape.color !== undefined && normalize(shape.color) === from) {
100
+ shape.color = to
35
101
  changed = true
36
102
  }
37
- if (normalize(shape.options.fill) === from) {
38
- shape.options.fill = to
103
+ if (shape.strokeColor !== undefined && normalize(shape.strokeColor) === from) {
104
+ shape.strokeColor = to
39
105
  changed = true
40
106
  }
41
107
  }
42
- // Text shapes store color directly
43
- if (shape.color !== undefined && normalize(shape.color) === from) {
44
- shape.color = to
45
- changed = true
46
- }
47
- if (shape.strokeColor !== undefined && normalize(shape.strokeColor) === from) {
48
- shape.strokeColor = to
49
- changed = true
50
- }
51
108
  if (changed && typeof shape.draw === 'function') {
52
109
  shape.draw()
53
110
  }
@@ -1,4 +1,5 @@
1
1
  /* eslint-disable */
2
+ import { registerRotationAnchor } from '../core/ScreenSpaceControls.js';
2
3
  // Circle shape class - extracted from drawCircle.js
3
4
  // Depends on globals: svg, shapes, rough, currentShape, currentZoom, rc
4
5
 
@@ -325,6 +326,7 @@ class Circle {
325
326
  this.rotationAnchor.setAttribute('vector-effect', 'non-scaling-stroke');
326
327
  this.rotationAnchor.setAttribute('style', 'pointer-events: all;');
327
328
  this.group.appendChild(this.rotationAnchor);
329
+ registerRotationAnchor(this.rotationAnchor, { radius: 8, edgeY: expandedY });
328
330
 
329
331
  this.rotationAnchor.addEventListener('mouseover', function () {
330
332
  if (!isResizingShapeCircle && !isDraggingShapeCircle && !isRotatingShapeCircle) {
@@ -3,6 +3,7 @@
3
3
  // Depends on globals: svg, shapes, currentShape, currentZoom
4
4
 
5
5
  import { cleanupAttachments } from '../tools/arrowTool.js';
6
+ import { registerRotationAnchor } from '../core/ScreenSpaceControls.js';
6
7
 
7
8
  function getSVGCoordsFromMouse(e) {
8
9
  const viewBox = svg.viewBox.baseVal;
@@ -479,6 +480,7 @@ move(dx, dy) {
479
480
  labelText.textContent = this.frameName || "Frame";
480
481
  labelText.style.cursor = "pointer";
481
482
  labelText.style.userSelect = "none";
483
+ labelText.style.pointerEvents = "all";
482
484
 
483
485
  if (this.rotation !== 0) {
484
486
  const centerX = this.x + this.width / 2;
@@ -486,6 +488,11 @@ move(dx, dy) {
486
488
  labelText.setAttribute("transform", `rotate(${this.rotation}, ${centerX}, ${centerY})`);
487
489
  }
488
490
 
491
+ // Do not let the first click select/redraw the frame: replacing the label
492
+ // between clicks prevents the browser from ever emitting `dblclick`.
493
+ labelText.addEventListener('pointerdown', (e) => e.stopPropagation());
494
+ labelText.addEventListener('click', (e) => e.stopPropagation());
495
+
489
496
  // Add double-click event for renaming
490
497
  labelText.addEventListener('dblclick', (e) => {
491
498
  e.stopPropagation();
@@ -497,6 +504,10 @@ move(dx, dy) {
497
504
  }
498
505
 
499
506
  startLabelEdit(labelElement) {
507
+ if (this._labelEditor) {
508
+ this._labelEditor.querySelector('input')?.focus();
509
+ return;
510
+ }
500
511
  // Create a foreignObject to hold an HTML input
501
512
  const foreignObject = document.createElementNS("http://www.w3.org/2000/svg", "foreignObject");
502
513
  foreignObject.setAttribute("x", this.x + 5);
@@ -534,6 +545,7 @@ startLabelEdit(labelElement) {
534
545
 
535
546
  foreignObject.appendChild(input);
536
547
  this.group.appendChild(foreignObject);
548
+ this._labelEditor = foreignObject;
537
549
 
538
550
  // Hide the original label
539
551
  labelElement.style.display = "none";
@@ -544,7 +556,10 @@ startLabelEdit(labelElement) {
544
556
  input.select();
545
557
  }, 10);
546
558
 
559
+ let finished = false;
547
560
  const finishEdit = () => {
561
+ if (finished) return;
562
+ finished = true;
548
563
  const newName = input.value.trim() || "Frame";
549
564
 
550
565
  // Track name change in undo system if it actually changed
@@ -572,7 +587,8 @@ startLabelEdit(labelElement) {
572
587
  this.frameName = newName;
573
588
 
574
589
  // Remove the input
575
- this.group.removeChild(foreignObject);
590
+ foreignObject.remove();
591
+ this._labelEditor = null;
576
592
 
577
593
  // Show and update the label
578
594
  labelElement.style.display = "block";
@@ -589,8 +605,10 @@ startLabelEdit(labelElement) {
589
605
  finishEdit();
590
606
  } else if (e.key === 'Escape') {
591
607
  e.preventDefault();
608
+ finished = true;
592
609
  // Cancel edit - restore original name
593
- this.group.removeChild(foreignObject);
610
+ foreignObject.remove();
611
+ this._labelEditor = null;
594
612
  labelElement.style.display = "block";
595
613
  }
596
614
  });
@@ -775,6 +793,12 @@ startLabelEdit(labelElement) {
775
793
  }
776
794
 
777
795
  this.group.appendChild(rotationLine);
796
+ registerRotationAnchor(anchor, {
797
+ radius: 8,
798
+ edgeY: this.y,
799
+ line: rotationLine,
800
+ lineEnd: '2',
801
+ });
778
802
  } else {
779
803
  // Create resize handle (rectangle)
780
804
  anchor = document.createElementNS("http://www.w3.org/2000/svg", "rect");
@@ -1,5 +1,6 @@
1
1
  /* eslint-disable */
2
2
  import { getStroke } from "perfect-freehand";
3
+ import { registerRotationAnchor } from '../core/ScreenSpaceControls.js';
3
4
  // FreehandStroke shape class - extracted from canvasStroke.js
4
5
  // Depends on globals: svg, shapes, currentShape, currentZoom, currentViewBox
5
6
 
@@ -608,6 +609,7 @@ class FreehandStroke {
608
609
  rotationAnchor.style.pointerEvents = 'all';
609
610
 
610
611
  this.group.appendChild(rotationAnchor);
612
+ registerRotationAnchor(rotationAnchor, { radius: 5, edgeY: expandedY });
611
613
  this.rotationAnchor = rotationAnchor;
612
614
 
613
615
  // Create selection outline
@@ -79,6 +79,25 @@ class IconShape {
79
79
  this.element.setAttribute('data-shape-height', value);
80
80
  }
81
81
 
82
+ _transformMetrics() {
83
+ const vbWidth = parseFloat(this.element.getAttribute('data-viewbox-width')) || 24;
84
+ const vbHeight = parseFloat(this.element.getAttribute('data-viewbox-height')) || 24;
85
+ const scale = Math.min(this.width / vbWidth, this.height / vbHeight);
86
+ return {
87
+ scale,
88
+ localCenterX: this.width / 2 / scale,
89
+ localCenterY: this.height / 2 / scale,
90
+ };
91
+ }
92
+
93
+ _applyTransform() {
94
+ const { scale, localCenterX, localCenterY } = this._transformMetrics();
95
+ this.element.setAttribute(
96
+ 'transform',
97
+ `translate(${this.x}, ${this.y}) scale(${scale}) rotate(${this.rotation}, ${localCenterX}, ${localCenterY})`
98
+ );
99
+ }
100
+
82
101
  get rotation() {
83
102
  const dataRotation = this.element.getAttribute('data-shape-rotation');
84
103
  if (dataRotation) {
@@ -97,27 +116,14 @@ class IconShape {
97
116
 
98
117
  set rotation(value) {
99
118
  this.element.setAttribute('data-shape-rotation', value);
100
-
101
- const vbWidth = parseFloat(this.element.getAttribute('data-viewbox-width')) || 24;
102
- const vbHeight = parseFloat(this.element.getAttribute('data-viewbox-height')) || 24;
103
- const scale = this.width / Math.max(vbWidth, vbHeight);
104
- const localCenterX = this.width / 2 / scale;
105
- const localCenterY = this.height / 2 / scale;
106
-
107
- this.element.setAttribute('transform', `translate(${this.x}, ${this.y}) scale(${scale}) rotate(${value}, ${localCenterX}, ${localCenterY})`);
119
+ this._applyTransform();
108
120
  }
109
121
 
110
122
  move(dx, dy) {
111
123
  this.x += dx;
112
124
  this.y += dy;
113
125
 
114
- const vbWidth = parseFloat(this.element.getAttribute('data-viewbox-width')) || 24;
115
- const vbHeight = parseFloat(this.element.getAttribute('data-viewbox-height')) || 24;
116
- const scale = this.width / Math.max(vbWidth, vbHeight);
117
- const localCenterX = this.width / 2 / scale;
118
- const localCenterY = this.height / 2 / scale;
119
-
120
- this.element.setAttribute('transform', `translate(${this.x}, ${this.y}) scale(${scale}) rotate(${this.rotation}, ${localCenterX}, ${localCenterY})`);
126
+ this._applyTransform();
121
127
 
122
128
  this.element.setAttribute('x', this.x);
123
129
  this.element.setAttribute('y', this.y);
@@ -173,11 +179,7 @@ class IconShape {
173
179
  }
174
180
 
175
181
  draw() {
176
- const scale = this.width / 24;
177
- const localCenterX = this.width / 2 / scale;
178
- const localCenterY = this.height / 2 / scale;
179
-
180
- this.element.setAttribute('transform', `translate(${this.x}, ${this.y}) scale(${scale}) rotate(${this.rotation}, ${localCenterX}, ${localCenterY})`);
182
+ this._applyTransform();
181
183
 
182
184
  this.element.setAttribute('data-shape-x', this.x);
183
185
  this.element.setAttribute('data-shape-y', this.y);
@@ -246,11 +248,7 @@ class IconShape {
246
248
  pos.parentFrame.addShapeToFrame(this);
247
249
  }
248
250
 
249
- const scale = pos.width / 24;
250
- const localCenterX = pos.width / 2 / scale;
251
- const localCenterY = pos.height / 2 / scale;
252
-
253
- this.element.setAttribute('transform', `translate(${pos.x}, ${pos.y}) scale(${scale}) rotate(${pos.rotation}, ${localCenterX}, ${localCenterY})`);
251
+ this._applyTransform();
254
252
 
255
253
  this.element.setAttribute('data-shape-x', pos.x);
256
254
  this.element.setAttribute('data-shape-y', pos.y);
@@ -262,8 +262,12 @@ class ImageShape {
262
262
  }
263
263
 
264
264
  selectShape() {
265
- // Select the image
266
- selectImage({ target: this.element, stopPropagation: () => {} });
265
+ // Image interaction state lives in imageTool. Use its bridge so
266
+ // programmatically-created images (graphs, restored scenes, paste)
267
+ // receive the same drag/resize controls as pointer-selected images.
268
+ if (typeof window.__selectImageElement === 'function') {
269
+ window.__selectImageElement(this.element, { force: true });
270
+ }
267
271
  }
268
272
  }
269
273