@elixpo/lixsketch 5.5.20 → 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 (105) hide show
  1. package/dist/react/{CopyPaste-KJZ34MIP.js → CopyPaste-FJN7K5LS.js} +39 -11
  2. package/dist/react/CopyPaste-FJN7K5LS.js.map +7 -0
  3. package/dist/react/{EventDispatcher-XDRQS7BD.js → EventDispatcher-O7HCQQNM.js} +13 -13
  4. package/dist/react/Frame-RLSMJJSK.js +11 -0
  5. package/dist/react/{GraphEngine-KQXHNTZQ.js → GraphEngine-MM6ALA2N.js} +105 -148
  6. package/dist/react/{GraphEngine-KQXHNTZQ.js.map → GraphEngine-MM6ALA2N.js.map} +2 -2
  7. package/dist/react/IconShape-7OPXAKLK.js +10 -0
  8. package/dist/react/ImageShape-LRNJQCF3.js +10 -0
  9. package/dist/react/Line-WWQFBNX5.js +10 -0
  10. package/dist/react/{SceneSerializer-VY3HY4PF.js → SceneSerializer-GSJR7ZHC.js} +32 -8
  11. package/dist/react/SceneSerializer-GSJR7ZHC.js.map +7 -0
  12. package/dist/react/{Selection-U2AR6NQS.js → Selection-ZL3G6IAO.js} +4 -4
  13. package/dist/react/{SketchEngine-ZFYUTKET.js → SketchEngine-BFLHJPCG.js} +21 -21
  14. package/dist/react/{UndoRedo-BZ6W4QTV.js → UndoRedo-NL7KLR5B.js} +2 -2
  15. package/dist/react/{arrowTool-LKFSZKXC.js → arrowTool-CNVGM6RX.js} +3 -3
  16. package/dist/react/{chunk-RFHMFWVR.js → chunk-2FMFOPGW.js} +5 -6
  17. package/dist/react/{chunk-RFHMFWVR.js.map → chunk-2FMFOPGW.js.map} +2 -2
  18. package/dist/react/{chunk-SBSM3GQD.js → chunk-2THKDSWW.js} +4 -4
  19. package/dist/react/{chunk-JOPQ27YH.js → chunk-4BLMNGTR.js} +3 -3
  20. package/dist/react/{chunk-C62SXFBL.js → chunk-4TAS67SL.js} +23 -22
  21. package/dist/react/chunk-4TAS67SL.js.map +7 -0
  22. package/dist/react/{chunk-7VBI7S7T.js → chunk-AH4PWNEB.js} +10 -11
  23. package/dist/react/chunk-AH4PWNEB.js.map +7 -0
  24. package/dist/react/{chunk-R5QXEVZ5.js → chunk-GC3KTQDA.js} +74 -15
  25. package/dist/react/chunk-GC3KTQDA.js.map +7 -0
  26. package/dist/react/{chunk-2V7G2PLX.js → chunk-GPIYXREE.js} +3 -3
  27. package/dist/react/{chunk-73VLPCBC.js → chunk-HFQZYCHJ.js} +3 -3
  28. package/dist/react/{chunk-C72OHQLE.js → chunk-ILGAFR56.js} +3 -3
  29. package/dist/react/{chunk-75G7XSZU.js → chunk-KVAQPCUH.js} +17 -3
  30. package/dist/react/chunk-KVAQPCUH.js.map +7 -0
  31. package/dist/react/{chunk-WEWMB35W.js → chunk-OFGJJKPH.js} +3 -3
  32. package/dist/react/{chunk-6G3ITAS5.js → chunk-SZD7DKYJ.js} +18 -9
  33. package/dist/react/chunk-SZD7DKYJ.js.map +7 -0
  34. package/dist/react/{chunk-MYYBFZD3.js → chunk-U3TB4KRS.js} +45 -6
  35. package/dist/react/chunk-U3TB4KRS.js.map +7 -0
  36. package/dist/react/{chunk-UQBT7F4K.js → chunk-V2QTYSBK.js} +2 -2
  37. package/dist/react/{chunk-2I3GH65Z.js → chunk-XFQECTTF.js} +44 -19
  38. package/dist/react/chunk-XFQECTTF.js.map +7 -0
  39. package/dist/react/{chunk-O64N5JPO.js → chunk-YF3TK7SC.js} +2 -2
  40. package/dist/react/{circleTool-RDOIMKKV.js → circleTool-KICNRQCI.js} +4 -4
  41. package/dist/react/{codeTool-VBDB2ES6.js → codeTool-NCQ73R6M.js} +4 -4
  42. package/dist/react/{frameTool-Z5ZDCX6H.js → frameTool-Y6KB67DO.js} +4 -4
  43. package/dist/react/{freehandTool-AAQCTMVM.js → freehandTool-MMULHCQ7.js} +4 -4
  44. package/dist/react/{iconTool-5WNDHI5T.js → iconTool-LSW2DYGB.js} +4 -4
  45. package/dist/react/{imageTool-ECPMT3BL.js → imageTool-2DSR32RX.js} +4 -4
  46. package/dist/react/index.js +141 -66
  47. package/dist/react/index.js.map +3 -3
  48. package/dist/react/{lineTool-XLOOZE3E.js → lineTool-N5I7IGT7.js} +4 -4
  49. package/dist/react/{rectangleTool-AEYDDJCA.js → rectangleTool-YUT7ZK2E.js} +4 -4
  50. package/dist/react/{textTool-5IJKTWVQ.js → textTool-IQOYPELP.js} +5 -5
  51. package/package.json +1 -1
  52. package/src/core/CopyPaste.js +34 -4
  53. package/src/core/GraphEngine.js +129 -185
  54. package/src/core/SceneSerializer.js +26 -0
  55. package/src/core/Selection.js +12 -11
  56. package/src/core/UndoRedo.js +47 -3
  57. package/src/react/components/canvas/MultiSelectActions.jsx +4 -1
  58. package/src/react/components/sidebars/IconSidebar.jsx +14 -23
  59. package/src/react/components/sidebars/ImageSidebar.jsx +47 -24
  60. package/src/react/store/useUIStore.js +71 -14
  61. package/src/shapes/Frame.js +19 -2
  62. package/src/shapes/IconShape.js +23 -25
  63. package/src/shapes/ImageShape.js +6 -2
  64. package/src/tools/frameTool.js +19 -9
  65. package/src/tools/iconTool.js +60 -25
  66. package/src/tools/imageTool.js +77 -13
  67. package/dist/react/CopyPaste-KJZ34MIP.js.map +0 -7
  68. package/dist/react/Frame-QLBAVLGW.js +0 -11
  69. package/dist/react/IconShape-32EYI2MA.js +0 -10
  70. package/dist/react/ImageShape-MYP2DVVH.js +0 -10
  71. package/dist/react/Line-FCCF2BOB.js +0 -10
  72. package/dist/react/SceneSerializer-VY3HY4PF.js.map +0 -7
  73. package/dist/react/chunk-2I3GH65Z.js.map +0 -7
  74. package/dist/react/chunk-6G3ITAS5.js.map +0 -7
  75. package/dist/react/chunk-75G7XSZU.js.map +0 -7
  76. package/dist/react/chunk-7VBI7S7T.js.map +0 -7
  77. package/dist/react/chunk-C62SXFBL.js.map +0 -7
  78. package/dist/react/chunk-MYYBFZD3.js.map +0 -7
  79. package/dist/react/chunk-R5QXEVZ5.js.map +0 -7
  80. /package/dist/react/{EventDispatcher-XDRQS7BD.js.map → EventDispatcher-O7HCQQNM.js.map} +0 -0
  81. /package/dist/react/{Frame-QLBAVLGW.js.map → Frame-RLSMJJSK.js.map} +0 -0
  82. /package/dist/react/{IconShape-32EYI2MA.js.map → IconShape-7OPXAKLK.js.map} +0 -0
  83. /package/dist/react/{ImageShape-MYP2DVVH.js.map → ImageShape-LRNJQCF3.js.map} +0 -0
  84. /package/dist/react/{Line-FCCF2BOB.js.map → Line-WWQFBNX5.js.map} +0 -0
  85. /package/dist/react/{Selection-U2AR6NQS.js.map → Selection-ZL3G6IAO.js.map} +0 -0
  86. /package/dist/react/{SketchEngine-ZFYUTKET.js.map → SketchEngine-BFLHJPCG.js.map} +0 -0
  87. /package/dist/react/{UndoRedo-BZ6W4QTV.js.map → UndoRedo-NL7KLR5B.js.map} +0 -0
  88. /package/dist/react/{arrowTool-LKFSZKXC.js.map → arrowTool-CNVGM6RX.js.map} +0 -0
  89. /package/dist/react/{chunk-SBSM3GQD.js.map → chunk-2THKDSWW.js.map} +0 -0
  90. /package/dist/react/{chunk-JOPQ27YH.js.map → chunk-4BLMNGTR.js.map} +0 -0
  91. /package/dist/react/{chunk-2V7G2PLX.js.map → chunk-GPIYXREE.js.map} +0 -0
  92. /package/dist/react/{chunk-73VLPCBC.js.map → chunk-HFQZYCHJ.js.map} +0 -0
  93. /package/dist/react/{chunk-C72OHQLE.js.map → chunk-ILGAFR56.js.map} +0 -0
  94. /package/dist/react/{chunk-WEWMB35W.js.map → chunk-OFGJJKPH.js.map} +0 -0
  95. /package/dist/react/{chunk-UQBT7F4K.js.map → chunk-V2QTYSBK.js.map} +0 -0
  96. /package/dist/react/{chunk-O64N5JPO.js.map → chunk-YF3TK7SC.js.map} +0 -0
  97. /package/dist/react/{circleTool-RDOIMKKV.js.map → circleTool-KICNRQCI.js.map} +0 -0
  98. /package/dist/react/{codeTool-VBDB2ES6.js.map → codeTool-NCQ73R6M.js.map} +0 -0
  99. /package/dist/react/{frameTool-Z5ZDCX6H.js.map → frameTool-Y6KB67DO.js.map} +0 -0
  100. /package/dist/react/{freehandTool-AAQCTMVM.js.map → freehandTool-MMULHCQ7.js.map} +0 -0
  101. /package/dist/react/{iconTool-5WNDHI5T.js.map → iconTool-LSW2DYGB.js.map} +0 -0
  102. /package/dist/react/{imageTool-ECPMT3BL.js.map → imageTool-2DSR32RX.js.map} +0 -0
  103. /package/dist/react/{lineTool-XLOOZE3E.js.map → lineTool-N5I7IGT7.js.map} +0 -0
  104. /package/dist/react/{rectangleTool-AEYDDJCA.js.map → rectangleTool-YUT7ZK2E.js.map} +0 -0
  105. /package/dist/react/{textTool-5IJKTWVQ.js.map → textTool-IQOYPELP.js.map} +0 -0
@@ -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
  }
@@ -480,6 +480,7 @@ move(dx, dy) {
480
480
  labelText.textContent = this.frameName || "Frame";
481
481
  labelText.style.cursor = "pointer";
482
482
  labelText.style.userSelect = "none";
483
+ labelText.style.pointerEvents = "all";
483
484
 
484
485
  if (this.rotation !== 0) {
485
486
  const centerX = this.x + this.width / 2;
@@ -487,6 +488,11 @@ move(dx, dy) {
487
488
  labelText.setAttribute("transform", `rotate(${this.rotation}, ${centerX}, ${centerY})`);
488
489
  }
489
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
+
490
496
  // Add double-click event for renaming
491
497
  labelText.addEventListener('dblclick', (e) => {
492
498
  e.stopPropagation();
@@ -498,6 +504,10 @@ move(dx, dy) {
498
504
  }
499
505
 
500
506
  startLabelEdit(labelElement) {
507
+ if (this._labelEditor) {
508
+ this._labelEditor.querySelector('input')?.focus();
509
+ return;
510
+ }
501
511
  // Create a foreignObject to hold an HTML input
502
512
  const foreignObject = document.createElementNS("http://www.w3.org/2000/svg", "foreignObject");
503
513
  foreignObject.setAttribute("x", this.x + 5);
@@ -535,6 +545,7 @@ startLabelEdit(labelElement) {
535
545
 
536
546
  foreignObject.appendChild(input);
537
547
  this.group.appendChild(foreignObject);
548
+ this._labelEditor = foreignObject;
538
549
 
539
550
  // Hide the original label
540
551
  labelElement.style.display = "none";
@@ -545,7 +556,10 @@ startLabelEdit(labelElement) {
545
556
  input.select();
546
557
  }, 10);
547
558
 
559
+ let finished = false;
548
560
  const finishEdit = () => {
561
+ if (finished) return;
562
+ finished = true;
549
563
  const newName = input.value.trim() || "Frame";
550
564
 
551
565
  // Track name change in undo system if it actually changed
@@ -573,7 +587,8 @@ startLabelEdit(labelElement) {
573
587
  this.frameName = newName;
574
588
 
575
589
  // Remove the input
576
- this.group.removeChild(foreignObject);
590
+ foreignObject.remove();
591
+ this._labelEditor = null;
577
592
 
578
593
  // Show and update the label
579
594
  labelElement.style.display = "block";
@@ -590,8 +605,10 @@ startLabelEdit(labelElement) {
590
605
  finishEdit();
591
606
  } else if (e.key === 'Escape') {
592
607
  e.preventDefault();
608
+ finished = true;
593
609
  // Cancel edit - restore original name
594
- this.group.removeChild(foreignObject);
610
+ foreignObject.remove();
611
+ this._labelEditor = null;
595
612
  labelElement.style.display = "block";
596
613
  }
597
614
  });
@@ -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
 
@@ -143,9 +143,12 @@ const handleMouseDown = (e) => {
143
143
  currentFrame = null;
144
144
  currentShape = null;
145
145
  } else {
146
- pushCreateAction(currentFrame);
147
146
  // Check for shapes that should be contained in the new frame
148
147
  currentFrame.updateContainedShapes(true); // Apply clipping immediately for new frames
148
+ pushCreateAction(currentFrame, {
149
+ frameCreation: true,
150
+ containedShapes: [...currentFrame.containedShapes]
151
+ });
149
152
 
150
153
  // Auto-select the new frame and switch to selection tool
151
154
  const placedFrame = currentFrame;
@@ -177,17 +180,24 @@ const handleMouseDown = (e) => {
177
180
  }
178
181
  }
179
182
 
180
- // Only check frame containment for shapes that aren't already in frames
183
+ // Only check frame containment for shapes that aren't already in frames.
184
+ // Record this fallback attachment too; individual shape tools normally do
185
+ // it themselves, but restored/imported shapes can reach this path.
181
186
  if (!isDragging) {
182
187
  shapes.forEach(shape => {
183
188
  if (shape.shapeName !== 'frame' && !shape.parentFrame) {
184
- shapes.forEach(frame => {
185
- if (frame.shapeName === 'frame') {
186
- if (frame.isShapeInFrame(shape)) {
187
- frame.addShapeToFrame(shape);
188
- }
189
+ let targetFrame = null;
190
+ for (let i = shapes.length - 1; i >= 0; i--) {
191
+ const frame = shapes[i];
192
+ if (frame.shapeName === 'frame' && frame.isShapeInFrame(shape)) {
193
+ targetFrame = frame;
194
+ break;
189
195
  }
190
- });
196
+ }
197
+ if (targetFrame) {
198
+ targetFrame.addShapeToFrame(shape);
199
+ pushFrameAttachmentAction(targetFrame, shape, 'attach', null);
200
+ }
191
201
  }
192
202
  });
193
203
  }
@@ -278,4 +288,4 @@ export {
278
288
  handleMouseDown as handleMouseDownFrame,
279
289
  handleMouseMove as handleMouseMoveFrame,
280
290
  handleMouseUp as handleMouseUpFrame
281
- };
291
+ };
@@ -258,6 +258,18 @@ const handleMouseDownIcon = async (e) => {
258
258
  return;
259
259
  }
260
260
 
261
+ // Helper to get inherited attribute from the SVG sub-tree
262
+ function getInheritedAttr(element, attrName) {
263
+ let curr = element;
264
+ while (curr && curr.nodeType === 1) {
265
+ if (curr.getAttribute(attrName)) {
266
+ return curr.getAttribute(attrName);
267
+ }
268
+ curr = curr.parentElement;
269
+ }
270
+ return null;
271
+ }
272
+
261
273
  if (!isDraggingIcon || !iconToPlace || !isIconToolActive) {
262
274
  return;
263
275
  }
@@ -322,29 +334,50 @@ const handleMouseDownIcon = async (e) => {
322
334
  backgroundRect.setAttribute('style', 'pointer-events: all; cursor: pointer;');
323
335
  finalIconGroup.appendChild(backgroundRect);
324
336
 
325
- const allChildren = originalSvgElement.children;
326
- for (let i = 0; i < allChildren.length; i++) {
327
- const clonedChild = allChildren[i].cloneNode(true);
337
+ const isDark = document.body && document.body.classList.contains('theme-dark');
338
+ const targetDarkColor = '#1a1a2e';
339
+ const bgDarkColor = '#15111f';
328
340
 
329
- // Apply white fill/stroke so icons are visible on dark canvas
330
- const applyWhiteStyle = (element) => {
331
- if (element.nodeType === 1) {
332
- const fill = element.getAttribute('fill');
333
- const stroke = element.getAttribute('stroke');
334
- // Replace black/dark fills with white; leave 'none'/'transparent' alone
335
- if (!fill || fill === '#000' || fill === '#000000' || fill === 'black' || fill === 'currentColor') {
336
- element.setAttribute('fill', '#ffffff');
337
- }
338
- if (stroke === '#000' || stroke === '#000000' || stroke === 'black' || stroke === 'currentColor') {
339
- element.setAttribute('stroke', '#ffffff');
340
- }
341
- for (let j = 0; j < element.children.length; j++) {
342
- applyWhiteStyle(element.children[j]);
343
- }
341
+ const applyThemeStyle = (element) => {
342
+ if (element.nodeType === 1) {
343
+ const fill = getInheritedAttr(element, 'fill');
344
+ const stroke = getInheritedAttr(element, 'stroke');
345
+
346
+ const normFill = fill ? fill.toLowerCase().trim() : null;
347
+ const normStroke = stroke ? stroke.toLowerCase().trim() : null;
348
+
349
+ // Handle fill
350
+ if (normFill === 'none' || normFill === 'transparent') {
351
+ element.setAttribute('fill', 'none');
352
+ } else if (!normFill || ['#000', '#000000', 'black', 'currentcolor', '#1a1a2e'].includes(normFill)) {
353
+ element.setAttribute('fill', isDark ? '#ffffff' : targetDarkColor);
354
+ } else if (['#fff', '#ffffff', 'white', '#15111f'].includes(normFill)) {
355
+ element.setAttribute('fill', isDark ? bgDarkColor : '#ffffff');
344
356
  }
345
- };
346
- applyWhiteStyle(clonedChild);
347
357
 
358
+ // Handle stroke
359
+ if (normStroke === 'none' || normStroke === 'transparent') {
360
+ element.setAttribute('stroke', 'none');
361
+ } else if (normStroke && ['#000', '#000000', 'black', 'currentcolor', '#1a1a2e'].includes(normStroke)) {
362
+ element.setAttribute('stroke', isDark ? '#ffffff' : targetDarkColor);
363
+ } else if (normStroke && ['#fff', '#ffffff', 'white', '#15111f'].includes(normStroke)) {
364
+ element.setAttribute('stroke', isDark ? bgDarkColor : '#ffffff');
365
+ }
366
+
367
+ for (let j = 0; j < element.children.length; j++) {
368
+ applyThemeStyle(element.children[j]);
369
+ }
370
+ }
371
+ };
372
+
373
+ // Apply theme styles to original elements while they are still in context
374
+ for (let i = 0; i < originalSvgElement.children.length; i++) {
375
+ applyThemeStyle(originalSvgElement.children[i]);
376
+ }
377
+
378
+ const allChildren = originalSvgElement.children;
379
+ for (let i = 0; i < allChildren.length; i++) {
380
+ const clonedChild = allChildren[i].cloneNode(true);
348
381
  finalIconGroup.appendChild(clonedChild);
349
382
  }
350
383
 
@@ -1250,8 +1283,8 @@ function normalizeSVGSize(svgContent, fillColor = '#fff', strokeColor = null) {
1250
1283
 
1251
1284
  // Only apply to shape elements, not container elements like 'g'
1252
1285
  if (['path', 'circle', 'rect', 'polygon', 'ellipse', 'polyline', 'line'].includes(tagName)) {
1253
- const currentFill = element.getAttribute('fill');
1254
- const currentStroke = element.getAttribute('stroke');
1286
+ const currentFill = getInheritedAttr(element, 'fill');
1287
+ const currentStroke = getInheritedAttr(element, 'stroke');
1255
1288
 
1256
1289
  // Check if parent <g> has explicit colors - if so, don't override
1257
1290
  let parentG = element.parentElement;
@@ -1266,19 +1299,21 @@ function normalizeSVGSize(svgContent, fillColor = '#fff', strokeColor = null) {
1266
1299
  }
1267
1300
 
1268
1301
  // Only apply white fill if:
1269
- // 1. Element doesn't have explicit 'none' fill
1302
+ // 1. Element doesn't have explicit/inherited 'none'/'transparent' fill
1270
1303
  // 2. Element doesn't already have a specific color (other than black/default)
1271
1304
  // 3. Parent <g> doesn't have explicit colors
1272
- if (!hasParentColor && currentFill !== 'none') {
1305
+ if (!hasParentColor && currentFill !== 'none' && currentFill !== 'transparent') {
1273
1306
  // Only override if it's black, default, or unset
1274
1307
  if (!currentFill || currentFill === '#000' || currentFill === '#000000' || currentFill === 'black' || currentFill === 'currentColor') {
1275
1308
  element.setAttribute('fill', fillColor);
1276
1309
  }
1310
+ } else if (currentFill === 'none' || currentFill === 'transparent') {
1311
+ element.setAttribute('fill', 'none');
1277
1312
  }
1278
1313
 
1279
1314
  // Handle stroke similarly
1280
1315
  if (strokeColor && !hasParentColor) {
1281
- if (currentStroke && currentStroke !== 'none') {
1316
+ if (currentStroke && currentStroke !== 'none' && currentStroke !== 'transparent') {
1282
1317
  if (!currentStroke || currentStroke === '#000' || currentStroke === '#000000' || currentStroke === 'black' || currentStroke === 'currentColor') {
1283
1318
  element.setAttribute('stroke', strokeColor);
1284
1319
  }