@elixpo/lixsketch 5.6.9 → 5.6.11

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 (165) hide show
  1. package/dist/react/{AIRenderer-R5M2CIDO.js → AIRenderer-JNEZYDYQ.js} +4 -4
  2. package/dist/react/Arrow-QMR77LGQ.js +9 -0
  3. package/dist/react/Circle-5WL5HZA5.js +11 -0
  4. package/dist/react/{CopyPaste-PPDS3AD6.js → CopyPaste-V7AY56H3.js} +74 -30
  5. package/dist/react/CopyPaste-V7AY56H3.js.map +7 -0
  6. package/dist/react/{EventDispatcher-UV7LFSAS.js → EventDispatcher-E4OJCMY6.js} +154 -57
  7. package/dist/react/{EventDispatcher-UV7LFSAS.js.map → EventDispatcher-E4OJCMY6.js.map} +3 -3
  8. package/dist/react/Frame-OZCDVPMI.js +11 -0
  9. package/dist/react/FreehandStroke-QSSYWBFK.js +9 -0
  10. package/dist/react/IconShape-HXUV4VRT.js +10 -0
  11. package/dist/react/ImageShape-JGLT2UGG.js +10 -0
  12. package/dist/react/Line-YONPLUYT.js +11 -0
  13. package/dist/react/{MermaidFlowchartRenderer-EL3Q2MNW.js → MermaidFlowchartRenderer-FSH43RUP.js} +26 -7
  14. package/dist/react/{MermaidFlowchartRenderer-EL3Q2MNW.js.map → MermaidFlowchartRenderer-FSH43RUP.js.map} +2 -2
  15. package/dist/react/{MermaidSequenceRenderer-K4KHHEVC.js → MermaidSequenceRenderer-FRPNOF2V.js} +11 -9
  16. package/dist/react/{MermaidSequenceRenderer-K4KHHEVC.js.map → MermaidSequenceRenderer-FRPNOF2V.js.map} +2 -2
  17. package/dist/react/{MermaidStructuredRenderer-6LN3WISP.js → MermaidStructuredRenderer-BZGQLRIH.js} +13 -6
  18. package/dist/react/{MermaidStructuredRenderer-6LN3WISP.js.map → MermaidStructuredRenderer-BZGQLRIH.js.map} +2 -2
  19. package/dist/react/Rectangle-7KMA3SC6.js +11 -0
  20. package/dist/react/{SceneSerializer-H6KMQCO2.js → SceneSerializer-7O72LEZP.js} +13 -12
  21. package/dist/react/{SceneSerializer-H6KMQCO2.js.map → SceneSerializer-7O72LEZP.js.map} +1 -1
  22. package/dist/react/{Selection-CFWRGVKH.js → Selection-S4FYSZIF.js} +5 -5
  23. package/dist/react/{SketchEngine-UFLZJQXS.js → SketchEngine-VTWURP2K.js} +26 -26
  24. package/dist/react/{UndoRedo-RXO7YHA2.js → UndoRedo-YLQJ6DXW.js} +8 -2
  25. package/dist/react/{ZoomPan-RR7KUYZT.js → ZoomPan-AEKWKUCH.js} +2 -2
  26. package/dist/react/{arrowTool-DO327FKU.js → arrowTool-C4RDWQEX.js} +3 -3
  27. package/dist/react/{chunk-X5ENBMGL.js → chunk-2U47AU6Y.js} +3 -3
  28. package/dist/react/{chunk-X5ENBMGL.js.map → chunk-2U47AU6Y.js.map} +1 -1
  29. package/dist/react/{chunk-RK3NVAER.js → chunk-75OPJOWN.js} +7 -7
  30. package/dist/react/chunk-75OPJOWN.js.map +7 -0
  31. package/dist/react/{chunk-DODPOWNX.js → chunk-CWE5IFG7.js} +5 -5
  32. package/dist/react/{chunk-DODPOWNX.js.map → chunk-CWE5IFG7.js.map} +1 -1
  33. package/dist/react/{chunk-O7KXLFZN.js → chunk-IS2MKWGH.js} +8 -7
  34. package/dist/react/chunk-IS2MKWGH.js.map +7 -0
  35. package/dist/react/{chunk-ZNZAICWY.js → chunk-JEHOEQ6Z.js} +13 -5
  36. package/dist/react/chunk-JEHOEQ6Z.js.map +7 -0
  37. package/dist/react/{chunk-BOZEDUAF.js → chunk-JYBJXTLM.js} +40 -15
  38. package/dist/react/chunk-JYBJXTLM.js.map +7 -0
  39. package/dist/react/{chunk-6LWUR7XB.js → chunk-LXEHYCKH.js} +4 -4
  40. package/dist/react/{chunk-6LWUR7XB.js.map → chunk-LXEHYCKH.js.map} +1 -1
  41. package/dist/react/chunk-M7OYYJC6.js +17 -0
  42. package/dist/react/chunk-M7OYYJC6.js.map +7 -0
  43. package/dist/react/chunk-MS4TIQ6Z.js +131 -0
  44. package/dist/react/chunk-MS4TIQ6Z.js.map +7 -0
  45. package/dist/react/{chunk-2ALS5JGS.js → chunk-OCJN32IL.js} +2 -2
  46. package/dist/react/{chunk-KZBKYHZO.js → chunk-P7VIYR6R.js} +8 -5
  47. package/dist/react/chunk-P7VIYR6R.js.map +7 -0
  48. package/dist/react/{chunk-KC26KJW4.js → chunk-PX2635H5.js} +32 -10
  49. package/dist/react/chunk-PX2635H5.js.map +7 -0
  50. package/dist/react/{chunk-37PF7FG2.js → chunk-SDG4U7TP.js} +7 -6
  51. package/dist/react/chunk-SDG4U7TP.js.map +7 -0
  52. package/dist/react/{chunk-CGMUXYPJ.js → chunk-STJERZ25.js} +8 -4
  53. package/dist/react/chunk-STJERZ25.js.map +7 -0
  54. package/dist/react/{chunk-VZPH7OAM.js → chunk-TCWCC4Z4.js} +7 -3
  55. package/dist/react/chunk-TCWCC4Z4.js.map +7 -0
  56. package/dist/react/{chunk-VZX55VQL.js → chunk-TFCBCWL5.js} +90 -33
  57. package/dist/react/chunk-TFCBCWL5.js.map +7 -0
  58. package/dist/react/{chunk-GQLXUZHQ.js → chunk-TWHJ6PJK.js} +5 -3
  59. package/dist/react/chunk-TWHJ6PJK.js.map +7 -0
  60. package/dist/react/{chunk-NH3NVSWT.js → chunk-UCOK4V5Z.js} +4 -3
  61. package/dist/react/{chunk-NH3NVSWT.js.map → chunk-UCOK4V5Z.js.map} +2 -2
  62. package/dist/react/{chunk-W7XUY4GR.js → chunk-VJDNKJBX.js} +56 -4
  63. package/dist/react/chunk-VJDNKJBX.js.map +7 -0
  64. package/dist/react/{chunk-BT2EIJD4.js → chunk-WCA3FGWE.js} +6 -3
  65. package/dist/react/chunk-WCA3FGWE.js.map +7 -0
  66. package/dist/react/{chunk-I7TVGJFF.js → chunk-Y3FJMYPX.js} +4 -4
  67. package/dist/react/{chunk-I7TVGJFF.js.map → chunk-Y3FJMYPX.js.map} +1 -1
  68. package/dist/react/{chunk-7DD2JRBC.js → chunk-YCGLGY4C.js} +2 -2
  69. package/dist/react/{circleTool-NK4RVDAR.js → circleTool-QQV2QEVF.js} +4 -4
  70. package/dist/react/{codeTool-3WF4HEOW.js → codeTool-PTCPSZ4L.js} +5 -5
  71. package/dist/react/{frameTool-VCNV2NG2.js → frameTool-7GQPLU4Y.js} +4 -4
  72. package/dist/react/{freehandTool-TBAOD46K.js → freehandTool-3RJXTVAK.js} +4 -4
  73. package/dist/react/{iconTool-65JCAKYF.js → iconTool-DW5YJX7P.js} +5 -5
  74. package/dist/react/{imageTool-UQZUBOK2.js → imageTool-7GZQKWYT.js} +5 -5
  75. package/dist/react/index.js +68 -63
  76. package/dist/react/index.js.map +2 -2
  77. package/dist/react/{lineTool-762KBYOM.js → lineTool-5CY7BHND.js} +4 -4
  78. package/dist/react/{rectangleTool-IQK3HZK6.js → rectangleTool-DK4QL4QW.js} +4 -4
  79. package/dist/react/styles.css +4 -0
  80. package/dist/react/{textTool-ITG2SBJK.js → textTool-GP5QQYF3.js} +6 -6
  81. package/package.json +1 -1
  82. package/src/core/CopyPaste.js +66 -18
  83. package/src/core/EventDispatcher.js +71 -47
  84. package/src/core/MermaidFlowchartRenderer.js +34 -8
  85. package/src/core/MermaidSequenceRenderer.js +10 -8
  86. package/src/core/MermaidStructuredRenderer.js +12 -5
  87. package/src/core/ScreenSpaceControls.js +102 -13
  88. package/src/core/Selection.js +26 -7
  89. package/src/core/SelectionGesture.js +50 -0
  90. package/src/core/UndoRedo.js +108 -35
  91. package/src/react/components/sidebars/CircleSidebar.jsx +11 -5
  92. package/src/react/components/sidebars/IconSidebar.jsx +10 -4
  93. package/src/react/components/sidebars/RectangleSidebar.jsx +11 -5
  94. package/src/react/components/sidebars/ShapeSidebar.jsx +29 -37
  95. package/src/react/components/sidebars/TextSidebar.jsx +10 -4
  96. package/src/react/store/useUIStore.js +8 -1
  97. package/src/react/styles.css +13 -0
  98. package/src/react/utils/canvasBackgrounds.js +11 -2
  99. package/src/shapes/Arrow.js +4 -3
  100. package/src/shapes/Circle.js +58 -2
  101. package/src/shapes/Frame.js +11 -4
  102. package/src/shapes/FreehandStroke.js +2 -1
  103. package/src/shapes/Line.js +47 -20
  104. package/src/shapes/Rectangle.js +3 -1
  105. package/src/tools/arrowTool.js +1 -1
  106. package/src/tools/circleTool.js +5 -2
  107. package/src/tools/codeTool.js +1 -1
  108. package/src/tools/frameTool.js +3 -2
  109. package/src/tools/freehandTool.js +1 -1
  110. package/src/tools/iconTool.js +4 -3
  111. package/src/tools/imageTool.js +3 -2
  112. package/src/tools/lineTool.js +5 -1
  113. package/src/tools/rectangleTool.js +1 -1
  114. package/src/tools/shapeRecognitionTool.js +1 -1
  115. package/src/tools/textTool.js +2 -2
  116. package/src/utils/themeColors.js +13 -0
  117. package/dist/react/Arrow-EF7ALH2Y.js +0 -8
  118. package/dist/react/Circle-HMM5PKJ2.js +0 -10
  119. package/dist/react/CopyPaste-PPDS3AD6.js.map +0 -7
  120. package/dist/react/Frame-DELCMYV6.js +0 -11
  121. package/dist/react/FreehandStroke-WXIJKD5I.js +0 -9
  122. package/dist/react/IconShape-PZHMZSLR.js +0 -10
  123. package/dist/react/ImageShape-ZMBZARRI.js +0 -10
  124. package/dist/react/Line-VPVPL6ET.js +0 -10
  125. package/dist/react/Rectangle-4U4CD2ER.js +0 -10
  126. package/dist/react/chunk-37PF7FG2.js.map +0 -7
  127. package/dist/react/chunk-BOZEDUAF.js.map +0 -7
  128. package/dist/react/chunk-BT2EIJD4.js.map +0 -7
  129. package/dist/react/chunk-CGMUXYPJ.js.map +0 -7
  130. package/dist/react/chunk-GQLXUZHQ.js.map +0 -7
  131. package/dist/react/chunk-KC26KJW4.js.map +0 -7
  132. package/dist/react/chunk-KZBKYHZO.js.map +0 -7
  133. package/dist/react/chunk-O7KXLFZN.js.map +0 -7
  134. package/dist/react/chunk-PAKA6HUX.js +0 -59
  135. package/dist/react/chunk-PAKA6HUX.js.map +0 -7
  136. package/dist/react/chunk-RK3NVAER.js.map +0 -7
  137. package/dist/react/chunk-VZPH7OAM.js.map +0 -7
  138. package/dist/react/chunk-VZX55VQL.js.map +0 -7
  139. package/dist/react/chunk-W7XUY4GR.js.map +0 -7
  140. package/dist/react/chunk-ZNZAICWY.js.map +0 -7
  141. /package/dist/react/{AIRenderer-R5M2CIDO.js.map → AIRenderer-JNEZYDYQ.js.map} +0 -0
  142. /package/dist/react/{Arrow-EF7ALH2Y.js.map → Arrow-QMR77LGQ.js.map} +0 -0
  143. /package/dist/react/{Circle-HMM5PKJ2.js.map → Circle-5WL5HZA5.js.map} +0 -0
  144. /package/dist/react/{Frame-DELCMYV6.js.map → Frame-OZCDVPMI.js.map} +0 -0
  145. /package/dist/react/{FreehandStroke-WXIJKD5I.js.map → FreehandStroke-QSSYWBFK.js.map} +0 -0
  146. /package/dist/react/{IconShape-PZHMZSLR.js.map → IconShape-HXUV4VRT.js.map} +0 -0
  147. /package/dist/react/{ImageShape-ZMBZARRI.js.map → ImageShape-JGLT2UGG.js.map} +0 -0
  148. /package/dist/react/{Line-VPVPL6ET.js.map → Line-YONPLUYT.js.map} +0 -0
  149. /package/dist/react/{Rectangle-4U4CD2ER.js.map → Rectangle-7KMA3SC6.js.map} +0 -0
  150. /package/dist/react/{Selection-CFWRGVKH.js.map → Selection-S4FYSZIF.js.map} +0 -0
  151. /package/dist/react/{SketchEngine-UFLZJQXS.js.map → SketchEngine-VTWURP2K.js.map} +0 -0
  152. /package/dist/react/{UndoRedo-RXO7YHA2.js.map → UndoRedo-YLQJ6DXW.js.map} +0 -0
  153. /package/dist/react/{ZoomPan-RR7KUYZT.js.map → ZoomPan-AEKWKUCH.js.map} +0 -0
  154. /package/dist/react/{arrowTool-DO327FKU.js.map → arrowTool-C4RDWQEX.js.map} +0 -0
  155. /package/dist/react/{chunk-2ALS5JGS.js.map → chunk-OCJN32IL.js.map} +0 -0
  156. /package/dist/react/{chunk-7DD2JRBC.js.map → chunk-YCGLGY4C.js.map} +0 -0
  157. /package/dist/react/{circleTool-NK4RVDAR.js.map → circleTool-QQV2QEVF.js.map} +0 -0
  158. /package/dist/react/{codeTool-3WF4HEOW.js.map → codeTool-PTCPSZ4L.js.map} +0 -0
  159. /package/dist/react/{frameTool-VCNV2NG2.js.map → frameTool-7GQPLU4Y.js.map} +0 -0
  160. /package/dist/react/{freehandTool-TBAOD46K.js.map → freehandTool-3RJXTVAK.js.map} +0 -0
  161. /package/dist/react/{iconTool-65JCAKYF.js.map → iconTool-DW5YJX7P.js.map} +0 -0
  162. /package/dist/react/{imageTool-UQZUBOK2.js.map → imageTool-7GZQKWYT.js.map} +0 -0
  163. /package/dist/react/{lineTool-762KBYOM.js.map → lineTool-5CY7BHND.js.map} +0 -0
  164. /package/dist/react/{rectangleTool-IQK3HZK6.js.map → rectangleTool-DK4QL4QW.js.map} +0 -0
  165. /package/dist/react/{textTool-ITG2SBJK.js.map → textTool-GP5QQYF3.js.map} +0 -0
@@ -95,7 +95,10 @@ export default function IconSidebar() {
95
95
 
96
96
  useEffect(() => {
97
97
  if (!visible) return
98
- const frame = requestAnimationFrame(() => searchInputRef.current?.focus())
98
+ // Focusing a transformed, previously off-screen input can make the
99
+ // browser scroll its nearest canvas pane horizontally. Keep focus for
100
+ // keyboard search without allowing it to move the viewport.
101
+ const frame = requestAnimationFrame(() => searchInputRef.current?.focus({ preventScroll: true }))
99
102
  return () => cancelAnimationFrame(frame)
100
103
  }, [visible])
101
104
 
@@ -181,11 +184,14 @@ export default function IconSidebar() {
181
184
  }
182
185
  }, [])
183
186
 
187
+ // Keep the closed picker out of the DOM instead of translating it beyond
188
+ // the canvas bounds. This guarantees it cannot affect host layout or focus
189
+ // scrolling in embedded/package consumers.
190
+ if (!visible) return null
191
+
184
192
  return (
185
193
  <div
186
- className={`absolute top-[60px] right-2 bottom-[112px] w-[300px] bg-surface border border-border-light rounded-2xl z-[999] font-[lixFont] flex flex-col transition-transform duration-200 ${
187
- visible ? 'translate-x-0' : 'translate-x-full'
188
- }`}
194
+ className="absolute top-[60px] right-2 bottom-[112px] w-[300px] bg-surface border border-border-light rounded-2xl z-[999] font-[lixFont] flex flex-col"
189
195
  >
190
196
  {/* Header */}
191
197
  <div className="px-3.5 pt-3.5 pb-2 shrink-0">
@@ -4,8 +4,9 @@ import useSketchStore, { TOOLS } from '../../store/useSketchStore'
4
4
  import ShapeSidebar, { ToolbarButton, Divider, LayerControls } from './ShapeSidebar'
5
5
  import { useState, useCallback } from 'react'
6
6
  import { useTranslation } from '../../hooks/useTranslation'
7
+ import useUIStore from '../../store/useUIStore'
7
8
 
8
- const STROKE_COLORS = ['#1a1a20', '#ffffff', '#FF8383', '#3A994C', '#56A2E8', '#FFD700', '#FF69B4', '#A855F7']
9
+ const STROKE_COLORS = ['#000000', '#ffffff', '#FF8383', '#3A994C', '#56A2E8', '#FFD700', '#FF69B4', '#A855F7']
9
10
  const BG_COLORS = ['transparent', '#f0f0f0', '#ffcccb', '#90ee90', '#add8e6', '#FFE4B5', '#DDA0DD', '#2d2d2d']
10
11
 
11
12
  const FILLS = [
@@ -46,7 +47,12 @@ export default function RectangleSidebar() {
46
47
  const { t } = useTranslation()
47
48
  const activeTool = useSketchStore((s) => s.activeTool)
48
49
  const selectedShapeSidebar = useSketchStore((s) => s.selectedShapeSidebar)
49
- const [strokeColor, setStrokeColor] = useState('#fff')
50
+ const theme = useUIStore((s) => s.theme)
51
+ const resolvedTheme = theme === 'system' && typeof window !== 'undefined'
52
+ ? (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
53
+ : theme
54
+ const [strokeColor, setStrokeColor] = useState(null)
55
+ const displayedStrokeColor = strokeColor ?? (resolvedTheme === 'light' ? '#000000' : '#ffffff')
50
56
  const [bgColor, setBgColor] = useState('transparent')
51
57
  const [thickness, setThickness] = useState(2)
52
58
  const [lineStyle, setLineStyle] = useState('solid')
@@ -68,10 +74,10 @@ export default function RectangleSidebar() {
68
74
  {/* Stroke color */}
69
75
  <ToolbarButton
70
76
  tooltip={t('sidebar.strokeColor')}
71
- preview={<span className="w-4 h-4 rounded-md border border-white/20" style={{ backgroundColor: strokeColor }} />}
77
+ preview={<span className="w-4 h-4 rounded-md border border-white/20" style={{ backgroundColor: displayedStrokeColor }} />}
72
78
  >
73
79
  <p className="text-xs text-text-muted uppercase tracking-wider mb-2">{t('sidebar.sectionHeader.stroke')}</p>
74
- <ColorGrid colors={STROKE_COLORS} selected={strokeColor} onSelect={updateStroke} />
80
+ <ColorGrid colors={STROKE_COLORS} selected={displayedStrokeColor} onSelect={updateStroke} />
75
81
  </ToolbarButton>
76
82
 
77
83
  <Divider />
@@ -130,7 +136,7 @@ export default function RectangleSidebar() {
130
136
  }`}
131
137
  >
132
138
  <svg width="28" height="4" viewBox="0 0 28 4">
133
- <line x1="0" y1="2" x2="28" y2="2" stroke="#fff" strokeWidth="2" strokeDasharray={s.d} strokeLinecap="round" />
139
+ <line x1="0" y1="2" x2="28" y2="2" stroke="currentColor" strokeWidth="2" strokeDasharray={s.d} strokeLinecap="round" />
134
140
  </svg>
135
141
  </button>
136
142
  ))}
@@ -7,7 +7,7 @@ import useSketchStore from '../../store/useSketchStore'
7
7
  * A toolbar button that opens a popover panel above it on click.
8
8
  * Shows an icon (or custom preview) in the bar, popover shows full options.
9
9
  */
10
- export function ToolbarButton({ icon, preview, children, tooltip }) {
10
+ export function ToolbarButton({ icon, preview, children, tooltip, label = tooltip }) {
11
11
  const [open, setOpen] = useState(false)
12
12
  const ref = useRef(null)
13
13
 
@@ -24,15 +24,19 @@ export function ToolbarButton({ icon, preview, children, tooltip }) {
24
24
  return (
25
25
  <div ref={ref} className="relative flex items-center">
26
26
  <button
27
+ type="button"
27
28
  onClick={() => setOpen(!open)}
28
29
  title={tooltip}
29
- className={`h-9 flex items-center gap-1.5 px-3 rounded-lg transition-all duration-100 ${
30
+ aria-expanded={open}
31
+ aria-haspopup="dialog"
32
+ className={`h-9 flex cursor-pointer items-center gap-1.5 rounded-lg border px-2.5 transition-all duration-100 ${
30
33
  open
31
- ? 'bg-accent-blue/20 text-accent-blue'
32
- : 'text-text-muted hover:text-text-primary hover:bg-surface-hover'
34
+ ? 'border-border-accent bg-accent-blue/20 text-accent-blue'
35
+ : 'border-transparent text-text-muted hover:border-border-light hover:bg-surface-hover hover:text-text-primary'
33
36
  }`}
34
37
  >
35
38
  {preview || (icon && <i className={`bx ${icon} text-base`} />)}
39
+ {label && <span className="whitespace-nowrap text-[11px] font-medium">{label}</span>}
36
40
  <svg className={`w-2 h-2 opacity-40 transition-transform duration-100 ${open ? 'rotate-180' : ''}`} viewBox="0 0 8 5" fill="none" stroke="currentColor" strokeWidth="1.5">
37
41
  <path d="M1 1l3 3 3-3" />
38
42
  </svg>
@@ -55,7 +59,7 @@ export function ToolbarButton({ icon, preview, children, tooltip }) {
55
59
  * Simple toolbar divider
56
60
  */
57
61
  function Divider() {
58
- return <div className="w-px h-5 bg-surface-hover mx-0.5 shrink-0" />
62
+ return <div className="mx-0.5 h-6 w-px shrink-0 bg-border-light" aria-hidden="true" />
59
63
  }
60
64
 
61
65
  /**
@@ -67,13 +71,13 @@ export default function ShapeSidebar({ visible, children }) {
67
71
 
68
72
  return (
69
73
  <div
70
- className={`absolute bottom-14 left-1/2 -translate-x-1/2 bg-surface border border-border-light rounded-xl px-2 py-1.5 z-[999] font-[lixFont] transition-all duration-200 ${
74
+ className={`absolute bottom-14 left-1/2 z-[999] max-w-[calc(100vw-24px)] -translate-x-1/2 overflow-x-auto rounded-xl border border-border-light bg-surface px-1.5 py-1.5 font-[lixFont] transition-all duration-200 ${
71
75
  show
72
76
  ? 'opacity-100 pointer-events-auto translate-y-0'
73
77
  : 'opacity-0 pointer-events-none translate-y-2'
74
78
  }`}
75
79
  >
76
- <div className="flex items-center gap-0.5">
80
+ <div className="flex min-w-max items-center gap-1">
77
81
  {children}
78
82
  </div>
79
83
  </div>
@@ -93,37 +97,25 @@ function LayerControls() {
93
97
  window.__layerOrder[method](shape)
94
98
  }
95
99
 
100
+ const actions = [
101
+ ['sendToBack', 'bx-chevrons-down', t('sidebar.sendToBack', { defaultValue: 'Send to back' })],
102
+ ['sendBackward', 'bx-chevron-down', t('sidebar.sendBackward')],
103
+ ['bringForward', 'bx-chevron-up', t('sidebar.bringForward')],
104
+ ['bringToFront', 'bx-chevrons-up', t('sidebar.bringToFront', { defaultValue: 'Bring to front' })],
105
+ ]
106
+
96
107
  return (
97
- <div className="flex items-center gap-0.5">
98
- <button
99
- onClick={() => doLayer('sendToBack')}
100
- title={t('sidebar.sendToBack', { defaultValue: 'Send to back' })}
101
- className="h-9 w-8 flex items-center justify-center rounded-lg text-text-muted hover:text-text-primary hover:bg-surface-hover transition-all duration-100"
102
- >
103
- <i className="bx bx-chevrons-down text-base" />
104
- </button>
105
- <button
106
- onClick={() => doLayer('sendBackward')}
107
- title={t('sidebar.sendBackward')}
108
- className="h-9 w-8 flex items-center justify-center rounded-lg text-text-muted hover:text-text-primary hover:bg-surface-hover transition-all duration-100"
109
- >
110
- <i className="bx bx-chevron-down text-base" />
111
- </button>
112
- <button
113
- onClick={() => doLayer('bringForward')}
114
- title={t('sidebar.bringForward')}
115
- className="h-9 w-8 flex items-center justify-center rounded-lg text-text-muted hover:text-text-primary hover:bg-surface-hover transition-all duration-100"
116
- >
117
- <i className="bx bx-chevron-up text-base" />
118
- </button>
119
- <button
120
- onClick={() => doLayer('bringToFront')}
121
- title={t('sidebar.bringToFront', { defaultValue: 'Bring to front' })}
122
- className="h-9 w-8 flex items-center justify-center rounded-lg text-text-muted hover:text-text-primary hover:bg-surface-hover transition-all duration-100"
123
- >
124
- <i className="bx bx-chevrons-up text-base" />
125
- </button>
126
- </div>
108
+ <ToolbarButton icon="bx-layer" tooltip="Arrange" label="Arrange">
109
+ <p className="mb-2 text-xs uppercase tracking-wider text-text-muted">Layer order</p>
110
+ <div className="grid grid-cols-2 gap-1">
111
+ {actions.map(([method, icon, label]) => (
112
+ <button key={method} type="button" onClick={() => doLayer(method)} className="flex min-w-32 cursor-pointer items-center gap-2 rounded-lg px-2.5 py-2 text-left text-xs text-text-secondary transition hover:bg-surface-hover hover:text-text-primary">
113
+ <i className={`bx ${icon} text-base`} aria-hidden="true" />
114
+ <span>{label}</span>
115
+ </button>
116
+ ))}
117
+ </div>
118
+ </ToolbarButton>
127
119
  )
128
120
  }
129
121
 
@@ -4,8 +4,9 @@ import useSketchStore, { TOOLS } from '../../store/useSketchStore'
4
4
  import ShapeSidebar, { ToolbarButton, Divider, LayerControls } from './ShapeSidebar'
5
5
  import { useState, useCallback, useEffect } from 'react'
6
6
  import { useTranslation } from '../../hooks/useTranslation'
7
+ import useUIStore from '../../store/useUIStore'
7
8
 
8
- const TEXT_COLORS = ['#1a1a20', '#ffffff', '#FF8383', '#3A994C', '#56A2E8', '#FFD700', '#FF69B4', '#A855F7']
9
+ const TEXT_COLORS = ['#000000', '#ffffff', '#FF8383', '#3A994C', '#56A2E8', '#FFD700', '#FF69B4', '#A855F7']
9
10
 
10
11
  const FONTS = [
11
12
  { value: 'lixFont', label: 'Lix' },
@@ -26,7 +27,12 @@ export default function TextSidebar() {
26
27
  const { t } = useTranslation()
27
28
  const activeTool = useSketchStore((s) => s.activeTool)
28
29
  const selectedShapeSidebar = useSketchStore((s) => s.selectedShapeSidebar)
29
- const [textColor, setTextColor] = useState('#fff')
30
+ const theme = useUIStore((s) => s.theme)
31
+ const resolvedTheme = theme === 'system' && typeof window !== 'undefined'
32
+ ? (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
33
+ : theme
34
+ const [textColor, setTextColor] = useState(null)
35
+ const displayedTextColor = textColor ?? (resolvedTheme === 'light' ? '#000000' : '#ffffff')
30
36
  const [fontSize, setFontSize] = useState('S')
31
37
  const [font, setFont] = useState('lixFont')
32
38
  const [codeMode, setCodeMode] = useState(false)
@@ -81,13 +87,13 @@ export default function TextSidebar() {
81
87
  <ShapeSidebar visible={visible}>
82
88
  {/* Color */}
83
89
  <ToolbarButton tooltip={t('sidebar.textColor')}
84
- preview={<span className="w-4 h-4 rounded-md border border-white/20" style={{ backgroundColor: textColor }} />}
90
+ preview={<span className="w-4 h-4 rounded-md border border-white/20" style={{ backgroundColor: displayedTextColor }} />}
85
91
  >
86
92
  <p className="text-xs text-text-muted uppercase tracking-wider mb-2">{t('sidebar.color')}</p>
87
93
  <div className="grid grid-cols-4 gap-1.5">
88
94
  {TEXT_COLORS.map((c) => (
89
95
  <button key={c} onClick={() => updateColor(c)}
90
- className={`w-7 h-7 rounded-md border-[1.5px] transition-all duration-100 ${textColor === c ? 'border-[#7667a8] scale-110' : 'border-white/[0.08] hover:border-white/20'}`}
96
+ className={`w-7 h-7 rounded-md border-[1.5px] transition-all duration-100 ${displayedTextColor.toLowerCase() === c.toLowerCase() ? 'border-[#7667a8] scale-110' : 'border-white/[0.08] hover:border-white/20'}`}
91
97
  style={{ backgroundColor: c }}
92
98
  />
93
99
  ))}
@@ -105,6 +105,13 @@ function invertShapeColors(prevResolved, nextResolved) {
105
105
  shape.strokeColor = to
106
106
  changed = true
107
107
  }
108
+ if (shape.labelColor !== undefined) {
109
+ const labelColor = normalize(shape.labelColor)
110
+ if (labelColor === from || labelColor === '#e0e0e0' || labelColor === '#e8e3f3') {
111
+ shape.labelColor = to
112
+ changed = true
113
+ }
114
+ }
108
115
  }
109
116
  if (changed && typeof shape.draw === 'function') {
110
117
  shape.draw()
@@ -140,7 +147,7 @@ function applyTheme(theme) {
140
147
  body.classList.add(`theme-${resolved}`)
141
148
 
142
149
  const svgEl = window.svg
143
- if (svgEl) svgEl.style.background = resolved === 'light' ? '#fbf9fd' : '#15111f'
150
+ if (svgEl) svgEl.style.background = resolved === 'light' ? '#ffffff' : '#15111f'
144
151
  }
145
152
 
146
153
  const useUIStore = create((set, get) => ({
@@ -35,6 +35,19 @@
35
35
 
36
36
  .theme-light .lixsketch-canvas-root {
37
37
  --lix-icon-filter: none;
38
+ --lixsketch-bg: #ffffff;
39
+ }
40
+
41
+ .theme-light .lixsketch-canvas-root :is(
42
+ .anchor,
43
+ .resize-anchor,
44
+ .resize-handle,
45
+ .rotate-anchor,
46
+ .rotation-anchor,
47
+ .multi-selection-anchor,
48
+ .multi-selection-rotation-anchor
49
+ ) {
50
+ fill: #ffffff !important;
38
51
  }
39
52
 
40
53
  .theme-dark .lixsketch-canvas-root {
@@ -1,6 +1,6 @@
1
1
  export const CANVAS_BACKGROUNDS = {
2
2
  light: [
3
- { color: '#FBE4DB', label: 'menu.canvasBg.white' },
3
+ { color: '#FFFFFF', label: 'menu.canvasBg.white' },
4
4
  { color: '#F6DBC0', label: 'menu.canvasBg.cream' },
5
5
  { color: '#F5EED2', label: 'menu.canvasBg.paper' },
6
6
  { color: '#F5F2ED', label: 'menu.canvasBg.skyTint' },
@@ -15,13 +15,22 @@ export const CANVAS_BACKGROUNDS = {
15
15
  ],
16
16
  }
17
17
 
18
- export const DEFAULT_CANVAS_BACKGROUNDS = { dark: '#15111f', light: '#F5F2ED' }
18
+ export const DEFAULT_CANVAS_BACKGROUNDS = { dark: '#15111f', light: '#FFFFFF' }
19
+ const LEGACY_LIGHT_DEFAULT = '#F5F2ED'
20
+ const LIGHT_DEFAULT_MIGRATION_KEY = 'lixsketch-canvas-light-default-v2'
19
21
  export const canvasBackgroundStorageKey = (theme) => `lixsketch-canvas-background-${theme}`
20
22
 
21
23
  export function readCanvasBackground(theme, storage) {
22
24
  const resolved = theme === 'light' ? 'light' : 'dark'
23
25
  try {
24
26
  const stored = storage?.getItem(canvasBackgroundStorageKey(resolved))
27
+ if (resolved === 'light' && storage?.getItem(LIGHT_DEFAULT_MIGRATION_KEY) !== '1') {
28
+ storage?.setItem?.(LIGHT_DEFAULT_MIGRATION_KEY, '1')
29
+ if (stored?.toLowerCase() === LEGACY_LIGHT_DEFAULT.toLowerCase()) {
30
+ storage?.setItem?.(canvasBackgroundStorageKey(resolved), DEFAULT_CANVAS_BACKGROUNDS.light)
31
+ return DEFAULT_CANVAS_BACKGROUNDS.light
32
+ }
33
+ }
25
34
  const match = CANVAS_BACKGROUNDS[resolved].find(({ color }) => color.toLowerCase() === stored?.toLowerCase())
26
35
  if (match) return match.color
27
36
  } catch {}
@@ -1,4 +1,5 @@
1
1
  /* eslint-disable */
2
+ import { resolveThemeLabelColor } from '../utils/themeColors.js';
2
3
  // Arrow shape class - extracted from drawArrow.js
3
4
  // Depends on globals: svg, shapes, rough, currentShape, currentZoom
4
5
 
@@ -54,7 +55,7 @@ class Arrow {
54
55
  // Embedded label support
55
56
  this.label = options.label || '';
56
57
  this.labelElement = null;
57
- this.labelColor = options.labelColor || '#e0e0e0';
58
+ this.labelColor = resolveThemeLabelColor(options.labelColor);
58
59
  this.labelFontSize = options.labelFontSize || 12;
59
60
  this.labelBg = options.labelBg !== false; // labelBg:false → no knockout rect behind label
60
61
  this.labelOffsetY = options.labelOffsetY || 0; // shift label along the perpendicular of the line, e.g. lift it clear of the stroke
@@ -784,7 +785,7 @@ class Arrow {
784
785
 
785
786
  addAnchors() {
786
787
  const anchorSize = 5 / currentZoom;
787
- const anchorStrokeWidth = 2 / currentZoom;
788
+ const anchorStrokeWidth = 2;
788
789
 
789
790
  let anchorPositions = [this.startPoint, this.endPoint];
790
791
 
@@ -2030,4 +2031,4 @@ updateFrameContainment() {
2030
2031
  }
2031
2032
  }
2032
2033
 
2033
- export { Arrow };
2034
+ export { Arrow };
@@ -1,6 +1,7 @@
1
1
  /* eslint-disable */
2
2
  import { registerRotationAnchor } from '../core/ScreenSpaceControls.js';
3
3
  import { canvasToLocal, localToCanvas } from '../core/CanvasSpace.js';
4
+ import { resolveThemeLabelColor } from '../utils/themeColors.js';
4
5
  // Circle shape class - extracted from drawCircle.js
5
6
  // Depends on globals: svg, shapes, rough, currentShape, currentZoom, rc
6
7
 
@@ -30,6 +31,11 @@ class Circle {
30
31
  strokeDasharray: "",
31
32
  ...options
32
33
  };
34
+ // RoughJS otherwise generates a different contour on every redraw.
35
+ // Keep a stable contour for later edits and the final drawing render.
36
+ if (!Number.isInteger(this.options.seed) || this.options.seed <= 0) {
37
+ this.options.seed = Math.floor(Math.random() * 0x7fffffff) + 1;
38
+ }
33
39
  this.element = null;
34
40
  this.isSelected = false;
35
41
  this.rotation = 0;
@@ -48,7 +54,7 @@ class Circle {
48
54
  // Embedded label support
49
55
  this.label = options.label || '';
50
56
  this.labelElement = null;
51
- this.labelColor = options.labelColor || '#e0e0e0';
57
+ this.labelColor = resolveThemeLabelColor(options.labelColor);
52
58
  this.labelFontSize = options.labelFontSize || 14;
53
59
  this._isEditingLabel = false;
54
60
  this._hitArea = null;
@@ -60,6 +66,9 @@ class Circle {
60
66
  this.shadeDirection = options.shadeDirection || 'bottom';
61
67
  this._shadeEllipse = null;
62
68
  this._shadeGradient = null;
69
+ this.isBeingDrawn = false;
70
+ this._drawingRenderFrame = null;
71
+ this._isLivePreviewElement = false;
63
72
 
64
73
  if(!this.group.parentNode) {
65
74
  svg.appendChild(this.group);
@@ -105,7 +114,27 @@ class Circle {
105
114
  const isInitialDraw = this.element === null;
106
115
  const sizeChanged = this.rx !== this._lastDrawn.rx || this.ry !== this._lastDrawn.ry;
107
116
  const optionsChanged = optionsString !== this._lastDrawn.options;
108
- if (isInitialDraw || optionsChanged || sizeChanged) {
117
+ if (this.isBeingDrawn) {
118
+ if (!this._isLivePreviewElement) {
119
+ if (this.element && this.element.parentNode === this.group) {
120
+ this.group.removeChild(this.element);
121
+ }
122
+ this.element = document.createElementNS('http://www.w3.org/2000/svg', 'ellipse');
123
+ this.element.setAttribute('class', 'circle-drawing-preview');
124
+ this.element.setAttribute('pointer-events', 'none');
125
+ this.group.appendChild(this.element);
126
+ this._isLivePreviewElement = true;
127
+ }
128
+
129
+ this.element.setAttribute('cx', 0);
130
+ this.element.setAttribute('cy', 0);
131
+ this.element.setAttribute('rx', this.rx);
132
+ this.element.setAttribute('ry', this.ry);
133
+ this.element.setAttribute('fill', this.options.fill || 'transparent');
134
+ this.element.setAttribute('stroke', this.options.stroke || '#fff');
135
+ this.element.setAttribute('stroke-width', this.options.strokeWidth || 2);
136
+ this.element.setAttribute('stroke-dasharray', this.options.strokeDasharray || '');
137
+ } else if (isInitialDraw || optionsChanged || sizeChanged || this._isLivePreviewElement) {
109
138
  if (this.element && this.element.parentNode === this.group) {
110
139
  this.group.removeChild(this.element);
111
140
  }
@@ -113,6 +142,7 @@ class Circle {
113
142
  const roughEllipse = rc.ellipse(0, 0, this.rx * 2, this.ry * 2, this.options);
114
143
  this.element = roughEllipse;
115
144
  this.group.appendChild(roughEllipse);
145
+ this._isLivePreviewElement = false;
116
146
 
117
147
  this._lastDrawn.rx = this.rx;
118
148
  this._lastDrawn.ry = this.ry;
@@ -151,6 +181,31 @@ class Circle {
151
181
  }
152
182
  }
153
183
 
184
+ scheduleDrawingRender() {
185
+ if (this._drawingRenderFrame !== null) return;
186
+ if (typeof requestAnimationFrame !== 'function') {
187
+ this.draw();
188
+ return;
189
+ }
190
+ this._drawingRenderFrame = requestAnimationFrame(() => {
191
+ this._drawingRenderFrame = null;
192
+ this.draw();
193
+ });
194
+ }
195
+
196
+ cancelDrawingRender() {
197
+ if (this._drawingRenderFrame !== null && typeof cancelAnimationFrame === 'function') {
198
+ cancelAnimationFrame(this._drawingRenderFrame);
199
+ }
200
+ this._drawingRenderFrame = null;
201
+ this.isBeingDrawn = false;
202
+ }
203
+
204
+ finalizeDrawing() {
205
+ this.cancelDrawingRender();
206
+ this.draw();
207
+ }
208
+
154
209
  move(dx, dy) {
155
210
  this.x += dx;
156
211
  this.y += dy;
@@ -343,6 +398,7 @@ class Circle {
343
398
  const pointsAttr = outlinePoints.map(p => p.join(',')).join(' ');
344
399
  const outline = document.createElementNS('http://www.w3.org/2000/svg', 'polyline');
345
400
  outline.setAttribute('points', pointsAttr);
401
+ outline.setAttribute('class', 'selection-outline');
346
402
  outline.setAttribute('fill', 'none');
347
403
  outline.setAttribute('stroke', '#5B57D1');
348
404
  outline.setAttribute('stroke-width', 1.5);
@@ -4,6 +4,7 @@
4
4
 
5
5
  import { cleanupAttachments } from '../tools/arrowTool.js';
6
6
  import { registerRotationAnchor } from '../core/ScreenSpaceControls.js';
7
+ import { captureFrameChildStates } from '../core/UndoRedo.js';
7
8
 
8
9
  function getSVGCoordsFromMouse(e) {
9
10
  const viewBox = svg.viewBox.baseVal;
@@ -434,8 +435,9 @@ move(dx, dy) {
434
435
  shape.y += dy;
435
436
  }
436
437
 
437
- // Force redraw for shapes whose move() doesn't auto-redraw
438
- if (typeof shape.draw === 'function') {
438
+ // Line.move() batches its own render to one animation frame.
439
+ // Calling draw() here would regenerate it immediately as well.
440
+ if (shape.shapeName !== 'line' && typeof shape.draw === 'function') {
439
441
  shape.draw();
440
442
  }
441
443
 
@@ -768,6 +770,7 @@ startLabelEdit(labelElement) {
768
770
 
769
771
  const padding = 20;
770
772
  const oldState = { x: this.x, y: this.y, width: this.width, height: this.height, rotation: this.rotation };
773
+ oldState.containedShapes = captureFrameChildStates(this);
771
774
 
772
775
  this.x = minX - padding;
773
776
  this.y = minY - padding;
@@ -795,7 +798,7 @@ startLabelEdit(labelElement) {
795
798
 
796
799
  addAnchors() {
797
800
  const anchorSize = 8 / currentZoom;
798
- const anchorStrokeWidth = 2 / currentZoom;
801
+ const anchorStrokeWidth = 2;
799
802
 
800
803
  // Calculate anchor positions (corners + midpoints + rotation handle)
801
804
  const anchorPositions = [
@@ -821,6 +824,7 @@ startLabelEdit(labelElement) {
821
824
 
822
825
  // Create selection outline first (behind anchors)
823
826
  const outline = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
827
+ outline.setAttribute('class', 'selection-outline');
824
828
  outline.setAttribute('x', this.x);
825
829
  outline.setAttribute('y', this.y);
826
830
  outline.setAttribute('width', this.width);
@@ -855,6 +859,7 @@ startLabelEdit(labelElement) {
855
859
  anchor.setAttribute("stroke", "#5B57D1");
856
860
  anchor.setAttribute("stroke-width", anchorStrokeWidth);
857
861
  anchor.setAttribute("vector-effect", "non-scaling-stroke");
862
+ anchor.setAttribute("class", "rotate-anchor");
858
863
 
859
864
  // Add rotation line
860
865
  const rotationLine = document.createElementNS("http://www.w3.org/2000/svg", "line");
@@ -891,6 +896,7 @@ startLabelEdit(labelElement) {
891
896
  anchor.setAttribute("stroke", "#5B57D1");
892
897
  anchor.setAttribute("stroke-width", anchorStrokeWidth);
893
898
  anchor.setAttribute("vector-effect", "non-scaling-stroke");
899
+ anchor.setAttribute("class", "anchor");
894
900
  }
895
901
 
896
902
  // Apply rotation to anchor if needed
@@ -918,7 +924,8 @@ startLabelEdit(labelElement) {
918
924
  y: this.y,
919
925
  width: this.width,
920
926
  height: this.height,
921
- rotation: this.rotation
927
+ rotation: this.rotation,
928
+ containedShapes: captureFrameChildStates(this),
922
929
  };
923
930
 
924
931
  const startMousePos = getSVGCoordsFromMouse(e);
@@ -573,7 +573,7 @@ class FreehandStroke {
573
573
 
574
574
  addAnchors() {
575
575
  const anchorSize = 10 / currentZoom;
576
- const anchorStrokeWidth = 2 / currentZoom;
576
+ const anchorStrokeWidth = 2;
577
577
 
578
578
  const expandedX = this.boundingBox.x - this.selectionPadding;
579
579
  const expandedY = this.boundingBox.y - this.selectionPadding;
@@ -645,6 +645,7 @@ class FreehandStroke {
645
645
  ];
646
646
 
647
647
  const outline = document.createElementNS('http://www.w3.org/2000/svg', 'polyline');
648
+ outline.setAttribute('class', 'selection-outline');
648
649
  outline.setAttribute('points', outlinePoints.map(p => p.join(',')).join(' '));
649
650
  outline.setAttribute('fill', 'none');
650
651
  outline.setAttribute('stroke', '#5B57D1');
@@ -2,14 +2,15 @@
2
2
  // Line shape class - extracted from lineTool.js
3
3
  // Depends on globals: svg, shapes, rough, currentShape, currentZoom
4
4
  import { updateAttachedArrows as updateArrowsForShape } from '../tools/arrowTool.js';
5
+ import { resolveThemeLabelColor } from '../utils/themeColors.js';
5
6
 
6
7
  const rc = rough.svg(svg);
7
8
  // Issue #38 follow-up: read the default stroke from the active theme
8
9
  // instead of pinning to a single colour. White was invisible on the new
9
- // light canvas, near-black is invisible if the user toggles dark.
10
+ // light canvas, black is invisible if the user toggles dark.
10
11
  function getThemeStroke() {
11
12
  if (typeof document === 'undefined') return '#fff';
12
- return document.body && document.body.classList.contains('theme-dark') ? '#fff' : '#1a1a2e';
13
+ return document.body && document.body.classList.contains('theme-dark') ? '#fff' : '#000000';
13
14
  }
14
15
  const lineStrokeWidth = 2;
15
16
  let hoveredFrameLine = null;
@@ -19,6 +20,12 @@ class Line {
19
20
  this.startPoint = startPoint;
20
21
  this.endPoint = endPoint;
21
22
  this.options = { ...options };
23
+ // RoughJS otherwise chooses a fresh random stroke on every redraw.
24
+ // A stable per-line seed keeps the hand-drawn contour visually fixed
25
+ // while its endpoints move.
26
+ if (!Number.isInteger(this.options.seed) || this.options.seed <= 0) {
27
+ this.options.seed = Math.floor(Math.random() * 0x7fffffff) + 1;
28
+ }
22
29
  this.group = document.createElementNS('http://www.w3.org/2000/svg', 'g');
23
30
  this.isSelected = false;
24
31
  this.anchors = [];
@@ -37,11 +44,12 @@ class Line {
37
44
  // Embedded label support
38
45
  this.label = options.label || '';
39
46
  this.labelElement = null;
40
- this.labelColor = options.labelColor || '#e0e0e0';
47
+ this.labelColor = resolveThemeLabelColor(options.labelColor);
41
48
  this.labelFontSize = options.labelFontSize || 12;
42
49
  this._isEditingLabel = false;
43
50
  this._hitArea = null;
44
51
  this._labelBg = null;
52
+ this._dragRenderFrame = null;
45
53
 
46
54
  svg.appendChild(this.group);
47
55
  this._setupLabelDblClick();
@@ -431,7 +439,7 @@ removeSelection() {
431
439
  this.anchors = [];
432
440
 
433
441
  const anchorSize = 10 / (currentZoom || 1);
434
- const anchorStrokeWidth = 2 / (currentZoom || 1);
442
+ const anchorStrokeWidth = 2;
435
443
 
436
444
  // Start point anchor
437
445
  const startAnchor = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
@@ -537,7 +545,38 @@ removeSelection() {
537
545
  };
538
546
  }
539
547
 
540
- // Full redraw to keep anchors, hit area, and label in sync
548
+ this.scheduleDragRender();
549
+ }
550
+
551
+ scheduleDragRender() {
552
+ if (this._dragRenderFrame !== null) return;
553
+ if (typeof requestAnimationFrame !== 'function') {
554
+ this.renderDragGeometry();
555
+ return;
556
+ }
557
+ this._dragRenderFrame = requestAnimationFrame(() => {
558
+ this._dragRenderFrame = null;
559
+ this.renderDragGeometry();
560
+ });
561
+ }
562
+
563
+ renderDragGeometry() {
564
+ this.updateLineElement();
565
+ this.updateAnchorPositions();
566
+ if (this._hitArea) {
567
+ const path = this.isCurved && this.controlPoint
568
+ ? `M ${this.startPoint.x} ${this.startPoint.y} Q ${this.controlPoint.x} ${this.controlPoint.y} ${this.endPoint.x} ${this.endPoint.y}`
569
+ : `M ${this.startPoint.x} ${this.startPoint.y} L ${this.endPoint.x} ${this.endPoint.y}`;
570
+ this._hitArea.setAttribute('d', path);
571
+ }
572
+ this._updateLabelElement();
573
+ }
574
+
575
+ finalizeMove() {
576
+ if (this._dragRenderFrame !== null && typeof cancelAnimationFrame === 'function') {
577
+ cancelAnimationFrame(this._dragRenderFrame);
578
+ }
579
+ this._dragRenderFrame = null;
541
580
  this.draw();
542
581
  }
543
582
 
@@ -620,21 +659,9 @@ removeSelection() {
620
659
  this.controlPoint.y += dy;
621
660
  }
622
661
 
623
- // Update without full redraw to prevent jitter
624
- this.updateLineElement();
625
- this.updateAnchorPositions();
626
-
627
- // Update hit area path
628
- if (this._hitArea) {
629
- if (this.isCurved && this.controlPoint) {
630
- this._hitArea.setAttribute('d', `M ${this.startPoint.x} ${this.startPoint.y} Q ${this.controlPoint.x} ${this.controlPoint.y} ${this.endPoint.x} ${this.endPoint.y}`);
631
- } else {
632
- this._hitArea.setAttribute('d', `M ${this.startPoint.x} ${this.startPoint.y} L ${this.endPoint.x} ${this.endPoint.y}`);
633
- }
634
- }
635
-
636
- // Update label position
637
- this._updateLabelElement();
662
+ // Multiple pointer/snap updates in one browser frame collapse into a
663
+ // single DOM + RoughJS update.
664
+ this.scheduleDragRender();
638
665
 
639
666
  // Only update frame containment if not being moved by a parent frame
640
667
  if (!this.isBeingMovedByFrame) {