@elixpo/lixsketch 5.6.5 → 5.6.6

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 (160) hide show
  1. package/dist/mcp/index.js +5 -1
  2. package/dist/mcp/index.js.map +2 -2
  3. package/dist/mcp/stdio.js +5 -1
  4. package/dist/mcp/stdio.js.map +2 -2
  5. package/dist/react/{AIRenderer-SRF7LU5K.js → AIRenderer-R5M2CIDO.js} +13 -6
  6. package/dist/react/AIRenderer-R5M2CIDO.js.map +7 -0
  7. package/dist/react/Circle-HMM5PKJ2.js +10 -0
  8. package/dist/react/{CopyPaste-YUDZ6DHY.js → CopyPaste-P6G35CXO.js} +11 -10
  9. package/dist/react/{CopyPaste-YUDZ6DHY.js.map → CopyPaste-P6G35CXO.js.map} +1 -1
  10. package/dist/react/{EventDispatcher-7LAK7GQ7.js → EventDispatcher-M5B4EWHJ.js} +216 -63
  11. package/dist/react/EventDispatcher-M5B4EWHJ.js.map +7 -0
  12. package/dist/react/Frame-QEUEYITT.js +11 -0
  13. package/dist/react/{FreehandStroke-U6GYZOM4.js → FreehandStroke-WXIJKD5I.js} +2 -2
  14. package/dist/react/{GraphEngine-MM6ALA2N.js → GraphEngine-XH3RPYO4.js} +9 -2
  15. package/dist/react/GraphEngine-XH3RPYO4.js.map +7 -0
  16. package/dist/react/IconShape-NHJE4YBY.js +10 -0
  17. package/dist/react/ImageShape-33VCMSDP.js +10 -0
  18. package/dist/react/Line-A6FE232Q.js +10 -0
  19. package/dist/react/{LixScriptParser-JYWJX375.js → LixScriptParser-YS3ZOTGS.js} +6 -2
  20. package/dist/react/LixScriptParser-YS3ZOTGS.js.map +7 -0
  21. package/dist/react/{MermaidFlowchartRenderer-PQEC4TMI.js → MermaidFlowchartRenderer-EL3Q2MNW.js} +6 -2
  22. package/dist/react/MermaidFlowchartRenderer-EL3Q2MNW.js.map +7 -0
  23. package/dist/react/MermaidSequenceParser-DBYXW23P.js +8 -0
  24. package/dist/react/{MermaidSequenceRenderer-KCUHO7UI.js → MermaidSequenceRenderer-K4KHHEVC.js} +10 -3
  25. package/dist/react/MermaidSequenceRenderer-K4KHHEVC.js.map +7 -0
  26. package/dist/react/{MermaidStructuredRenderer-W7UKN7BM.js → MermaidStructuredRenderer-6LN3WISP.js} +7 -2
  27. package/dist/react/MermaidStructuredRenderer-6LN3WISP.js.map +7 -0
  28. package/dist/react/Rectangle-4U4CD2ER.js +10 -0
  29. package/dist/react/{SceneSerializer-SG6SJN5X.js → SceneSerializer-JEYTYXZQ.js} +120 -46
  30. package/dist/react/SceneSerializer-JEYTYXZQ.js.map +7 -0
  31. package/dist/react/{Selection-2TSH3J6P.js → Selection-23MOLHEI.js} +4 -4
  32. package/dist/react/{SketchEngine-UD2XV2KT.js → SketchEngine-OPJON7TD.js} +26 -26
  33. package/dist/react/{UndoRedo-IN4SXDG6.js → UndoRedo-QVNRI5OB.js} +8 -2
  34. package/dist/react/{arrowTool-OYDWJGQK.js → arrowTool-VH3PJPDM.js} +3 -3
  35. package/dist/react/{chunk-LSHZYN77.js → chunk-2DZHO67H.js} +2 -2
  36. package/dist/react/{chunk-XOWNCYE4.js → chunk-5NHJJJBZ.js} +3 -3
  37. package/dist/react/chunk-6AIFXYV6.js +31 -0
  38. package/dist/react/chunk-6AIFXYV6.js.map +7 -0
  39. package/dist/react/{chunk-K5RY4EV7.js → chunk-6GPWSPJF.js} +9 -5
  40. package/dist/react/{chunk-K5RY4EV7.js.map → chunk-6GPWSPJF.js.map} +2 -2
  41. package/dist/react/{chunk-ASBC5G35.js → chunk-B6CYUZC4.js} +2 -2
  42. package/dist/react/{chunk-OP56XPWF.js → chunk-DNFPTVIP.js} +2 -2
  43. package/dist/react/{chunk-XYUFEIZR.js → chunk-DWEFPKDO.js} +2 -2
  44. package/dist/react/{chunk-GQUZPON4.js → chunk-F56XUE5S.js} +4 -4
  45. package/dist/react/{chunk-7CM5XQDN.js → chunk-G3DJQY65.js} +20 -36
  46. package/dist/react/chunk-G3DJQY65.js.map +7 -0
  47. package/dist/react/{chunk-LJR7EVW2.js → chunk-JEGLUNBC.js} +3 -3
  48. package/dist/react/{chunk-6GPEWDRB.js → chunk-M7GAB24F.js} +52 -6
  49. package/dist/react/chunk-M7GAB24F.js.map +7 -0
  50. package/dist/react/{chunk-VQVEPJSA.js → chunk-MHV5P6TS.js} +2 -1
  51. package/dist/react/chunk-MHV5P6TS.js.map +7 -0
  52. package/dist/react/{chunk-PXFPPO3R.js → chunk-NH3NVSWT.js} +18 -2
  53. package/dist/react/chunk-NH3NVSWT.js.map +7 -0
  54. package/dist/react/{chunk-XFL4R5ZF.js → chunk-OK4ROGMM.js} +16 -35
  55. package/dist/react/chunk-OK4ROGMM.js.map +7 -0
  56. package/dist/react/{chunk-APTOPAOY.js → chunk-SSFEN5CY.js} +3 -3
  57. package/dist/react/{chunk-RGMDWLRJ.js → chunk-UEL2G6D2.js} +3 -3
  58. package/dist/react/{chunk-M6TMN76K.js → chunk-ULPIHUVC.js} +3 -3
  59. package/dist/react/{chunk-FETTJLCV.js → chunk-VZPH7OAM.js} +14 -22
  60. package/dist/react/chunk-VZPH7OAM.js.map +7 -0
  61. package/dist/react/{chunk-TKXKJNTX.js → chunk-W7XUY4GR.js} +16 -19
  62. package/dist/react/chunk-W7XUY4GR.js.map +7 -0
  63. package/dist/react/{chunk-EOURRJ55.js → chunk-XSE4HQ2D.js} +2 -2
  64. package/dist/react/{chunk-K5BG5MHY.js → chunk-ZNZAICWY.js} +36 -60
  65. package/dist/react/chunk-ZNZAICWY.js.map +7 -0
  66. package/dist/react/{circleTool-LDKGZALF.js → circleTool-2UODOBCV.js} +4 -4
  67. package/dist/react/{codeTool-4YZO5W7J.js → codeTool-F4RV7SAI.js} +4 -4
  68. package/dist/react/{frameTool-MXNJEXQQ.js → frameTool-NTGA5BI6.js} +4 -4
  69. package/dist/react/{freehandTool-26D3FOOX.js → freehandTool-L55EUB5C.js} +4 -4
  70. package/dist/react/{iconTool-46O5VEUX.js → iconTool-KQQBLGR3.js} +4 -4
  71. package/dist/react/{imageTool-P2RWZRHO.js → imageTool-LDJ45AD7.js} +4 -4
  72. package/dist/react/index.js +148 -37
  73. package/dist/react/index.js.map +4 -4
  74. package/dist/react/{lineTool-ZMR27U5L.js → lineTool-CSAFD7PW.js} +4 -4
  75. package/dist/react/{rectangleTool-EJKK6UTX.js → rectangleTool-YPD2SIIZ.js} +4 -4
  76. package/dist/react/{textTool-S4XXZHZH.js → textTool-VQHMWEFJ.js} +5 -5
  77. package/package.json +5 -2
  78. package/src/core/AIRenderer.js +8 -2
  79. package/src/core/CanvasSpace.js +28 -0
  80. package/src/core/GraphRenderer.js +8 -3
  81. package/src/core/LixScriptParser.js +7 -1
  82. package/src/core/MermaidFlowchartRenderer.js +5 -1
  83. package/src/core/MermaidSequenceParser.js +1 -0
  84. package/src/core/MermaidSequenceRenderer.js +8 -3
  85. package/src/core/MermaidStructuredRenderer.js +6 -1
  86. package/src/core/SceneSerializer.js +121 -38
  87. package/src/core/Selection.js +12 -2
  88. package/src/core/UndoRedo.js +59 -5
  89. package/src/react/components/AppMenu.jsx +20 -19
  90. package/src/react/components/canvas/CanvasRulers.jsx +8 -13
  91. package/src/react/components/canvas/SVGCanvas.jsx +2 -1
  92. package/src/react/components/canvas/rulerUnits.js +28 -0
  93. package/src/react/components/sidebars/PaintBucketSidebar.jsx +1 -1
  94. package/src/react/store/useSketchStore.js +24 -3
  95. package/src/react/store/useUIStore.js +24 -1
  96. package/src/react/utils/canvasBackgrounds.js +29 -0
  97. package/src/shapes/Circle.js +12 -19
  98. package/src/shapes/Frame.js +38 -69
  99. package/src/shapes/FreehandStroke.js +24 -1
  100. package/src/shapes/Rectangle.js +10 -22
  101. package/src/tools/circleTool.js +3 -27
  102. package/src/tools/paintBucketTool.js +72 -15
  103. package/src/tools/rectangleTool.js +7 -23
  104. package/src/tools/shapeRecognition.js +270 -0
  105. package/src/tools/shapeRecognitionTool.js +36 -183
  106. package/dist/react/AIRenderer-SRF7LU5K.js.map +0 -7
  107. package/dist/react/Circle-6POZCUZD.js +0 -9
  108. package/dist/react/EventDispatcher-7LAK7GQ7.js.map +0 -7
  109. package/dist/react/Frame-7D2MXCFF.js +0 -11
  110. package/dist/react/GraphEngine-MM6ALA2N.js.map +0 -7
  111. package/dist/react/IconShape-ZADX7Y33.js +0 -10
  112. package/dist/react/ImageShape-UJQN2HI7.js +0 -10
  113. package/dist/react/Line-APEULMHE.js +0 -10
  114. package/dist/react/LixScriptParser-JYWJX375.js.map +0 -7
  115. package/dist/react/MermaidFlowchartRenderer-PQEC4TMI.js.map +0 -7
  116. package/dist/react/MermaidSequenceParser-UKG2NSZY.js +0 -8
  117. package/dist/react/MermaidSequenceRenderer-KCUHO7UI.js.map +0 -7
  118. package/dist/react/MermaidStructuredRenderer-W7UKN7BM.js.map +0 -7
  119. package/dist/react/Rectangle-S7XUKWYD.js +0 -9
  120. package/dist/react/SceneSerializer-SG6SJN5X.js.map +0 -7
  121. package/dist/react/chunk-6GPEWDRB.js.map +0 -7
  122. package/dist/react/chunk-7CM5XQDN.js.map +0 -7
  123. package/dist/react/chunk-FETTJLCV.js.map +0 -7
  124. package/dist/react/chunk-K5BG5MHY.js.map +0 -7
  125. package/dist/react/chunk-PXFPPO3R.js.map +0 -7
  126. package/dist/react/chunk-TKXKJNTX.js.map +0 -7
  127. package/dist/react/chunk-VQVEPJSA.js.map +0 -7
  128. package/dist/react/chunk-XFL4R5ZF.js.map +0 -7
  129. /package/dist/react/{Circle-6POZCUZD.js.map → Circle-HMM5PKJ2.js.map} +0 -0
  130. /package/dist/react/{Frame-7D2MXCFF.js.map → Frame-QEUEYITT.js.map} +0 -0
  131. /package/dist/react/{FreehandStroke-U6GYZOM4.js.map → FreehandStroke-WXIJKD5I.js.map} +0 -0
  132. /package/dist/react/{IconShape-ZADX7Y33.js.map → IconShape-NHJE4YBY.js.map} +0 -0
  133. /package/dist/react/{ImageShape-UJQN2HI7.js.map → ImageShape-33VCMSDP.js.map} +0 -0
  134. /package/dist/react/{Line-APEULMHE.js.map → Line-A6FE232Q.js.map} +0 -0
  135. /package/dist/react/{MermaidSequenceParser-UKG2NSZY.js.map → MermaidSequenceParser-DBYXW23P.js.map} +0 -0
  136. /package/dist/react/{Rectangle-S7XUKWYD.js.map → Rectangle-4U4CD2ER.js.map} +0 -0
  137. /package/dist/react/{Selection-2TSH3J6P.js.map → Selection-23MOLHEI.js.map} +0 -0
  138. /package/dist/react/{SketchEngine-UD2XV2KT.js.map → SketchEngine-OPJON7TD.js.map} +0 -0
  139. /package/dist/react/{UndoRedo-IN4SXDG6.js.map → UndoRedo-QVNRI5OB.js.map} +0 -0
  140. /package/dist/react/{arrowTool-OYDWJGQK.js.map → arrowTool-VH3PJPDM.js.map} +0 -0
  141. /package/dist/react/{chunk-LSHZYN77.js.map → chunk-2DZHO67H.js.map} +0 -0
  142. /package/dist/react/{chunk-XOWNCYE4.js.map → chunk-5NHJJJBZ.js.map} +0 -0
  143. /package/dist/react/{chunk-ASBC5G35.js.map → chunk-B6CYUZC4.js.map} +0 -0
  144. /package/dist/react/{chunk-OP56XPWF.js.map → chunk-DNFPTVIP.js.map} +0 -0
  145. /package/dist/react/{chunk-XYUFEIZR.js.map → chunk-DWEFPKDO.js.map} +0 -0
  146. /package/dist/react/{chunk-GQUZPON4.js.map → chunk-F56XUE5S.js.map} +0 -0
  147. /package/dist/react/{chunk-LJR7EVW2.js.map → chunk-JEGLUNBC.js.map} +0 -0
  148. /package/dist/react/{chunk-APTOPAOY.js.map → chunk-SSFEN5CY.js.map} +0 -0
  149. /package/dist/react/{chunk-RGMDWLRJ.js.map → chunk-UEL2G6D2.js.map} +0 -0
  150. /package/dist/react/{chunk-M6TMN76K.js.map → chunk-ULPIHUVC.js.map} +0 -0
  151. /package/dist/react/{chunk-EOURRJ55.js.map → chunk-XSE4HQ2D.js.map} +0 -0
  152. /package/dist/react/{circleTool-LDKGZALF.js.map → circleTool-2UODOBCV.js.map} +0 -0
  153. /package/dist/react/{codeTool-4YZO5W7J.js.map → codeTool-F4RV7SAI.js.map} +0 -0
  154. /package/dist/react/{frameTool-MXNJEXQQ.js.map → frameTool-NTGA5BI6.js.map} +0 -0
  155. /package/dist/react/{freehandTool-26D3FOOX.js.map → freehandTool-L55EUB5C.js.map} +0 -0
  156. /package/dist/react/{iconTool-46O5VEUX.js.map → iconTool-KQQBLGR3.js.map} +0 -0
  157. /package/dist/react/{imageTool-P2RWZRHO.js.map → imageTool-LDJ45AD7.js.map} +0 -0
  158. /package/dist/react/{lineTool-ZMR27U5L.js.map → lineTool-CSAFD7PW.js.map} +0 -0
  159. /package/dist/react/{rectangleTool-EJKK6UTX.js.map → rectangleTool-YPD2SIIZ.js.map} +0 -0
  160. /package/dist/react/{textTool-S4XXZHZH.js.map → textTool-VQHMWEFJ.js.map} +0 -0
@@ -7,23 +7,7 @@ import useSketchStore from '../store/useSketchStore'
7
7
  import { useAuthStore } from '../hooks/inertStores'
8
8
  import { triggerCloudSync } from '../hooks/inertStores'
9
9
  import { useTranslation } from '../hooks/useTranslation'
10
-
11
- // Issue #38 follow-up: theme-paired swatches. The menu picks the
12
- // matching list at render time based on the active theme.
13
- const CANVAS_BACKGROUNDS_LIGHT = [
14
- { color: '#ffffff', label: 'menu.canvasBg.white' },
15
- { color: '#fbf9fd', label: 'menu.canvasBg.cream' },
16
- { color: '#f5f3ed', label: 'menu.canvasBg.paper' },
17
- { color: '#f0f5fb', label: 'menu.canvasBg.skyTint' },
18
- { color: '#f0f5ef', label: 'menu.canvasBg.sageTint' },
19
- ]
20
- const CANVAS_BACKGROUNDS_DARK = [
21
- { color: '#000000', label: 'menu.canvasBg.black' },
22
- { color: '#161718', label: 'menu.canvasBg.darkGray' },
23
- { color: '#15111f', label: 'menu.canvasBg.blueBlack' },
24
- { color: '#181605', label: 'menu.canvasBg.darkYellow' },
25
- { color: '#1B1615', label: 'menu.canvasBg.darkBrown' },
26
- ]
10
+ import { CANVAS_BACKGROUNDS } from '../utils/canvasBackgrounds'
27
11
 
28
12
  function DangerWarningDialog({ action, workspaceName, onCancel, onConfirm }) {
29
13
  useEffect(() => {
@@ -130,6 +114,8 @@ export default function AppMenu() {
130
114
  const toggleGrid = useSketchStore((s) => s.toggleGrid)
131
115
  const rulersEnabled = useSketchStore((s) => s.rulersEnabled)
132
116
  const toggleRulers = useSketchStore((s) => s.toggleRulers)
117
+ const rulerUnit = useSketchStore((s) => s.rulerUnit)
118
+ const setRulerUnit = useSketchStore((s) => s.setRulerUnit)
133
119
 
134
120
  const viewMode = useSketchStore((s) => s.viewMode)
135
121
  const zenMode = useSketchStore((s) => s.zenMode)
@@ -300,6 +286,21 @@ export default function AppMenu() {
300
286
  <option value="en">English</option>
301
287
  <option value="bg">Български</option>
302
288
  <option value="de">Deutsch</option>
289
+ <option value="hi">हिन्दी</option>
290
+ </select>
291
+ </div>
292
+
293
+ <div className="w-full flex items-center justify-between px-3 py-2 border-b border-border-light text-text-secondary text-[11px]">
294
+ <span>Ruler unit</span>
295
+ <select
296
+ aria-label="Ruler unit"
297
+ className="cursor-pointer rounded border border-border-light bg-surface-hover px-1 text-[10px] uppercase text-text-primary outline-none"
298
+ value={rulerUnit}
299
+ onChange={(event) => setRulerUnit(event.target.value)}
300
+ >
301
+ <option value="px">px</option>
302
+ <option value="cm">cm</option>
303
+ <option value="in">in</option>
303
304
  </select>
304
305
  </div>
305
306
 
@@ -453,10 +454,10 @@ export default function AppMenu() {
453
454
  {t('menu.canvasBackground')}
454
455
  </p>
455
456
  <div className="flex items-center gap-1.5">
456
- {(theme === 'dark' ? CANVAS_BACKGROUNDS_DARK : CANVAS_BACKGROUNDS_LIGHT).map((bg) => (
457
+ {CANVAS_BACKGROUNDS[theme === 'light' ? 'light' : 'dark'].map((bg) => (
457
458
  <button
458
459
  key={bg.color}
459
- onClick={() => setCanvasBackground(bg.color)}
460
+ onClick={() => setCanvasBackground(bg.color, theme === 'light' ? 'light' : 'dark')}
460
461
  title={t(bg.label)}
461
462
  className={`w-7 h-7 rounded-full border-2 cursor-pointer transition-all duration-200 ${
462
463
  canvasBackground === bg.color
@@ -1,6 +1,7 @@
1
1
  "use client"
2
2
 
3
3
  import { useEffect, useMemo, useState } from 'react'
4
+ import { formatRulerValue, normalizeRulerUnit } from './rulerUnits.js'
4
5
 
5
6
  const GRID_SIZE = 20
6
7
  const MIN_LABEL_GAP = 52
@@ -16,14 +17,6 @@ function getRulerStep(zoom) {
16
17
  return GRID_SIZE
17
18
  }
18
19
 
19
- function formatDimension(value) {
20
- const rounded = Math.round(value)
21
- const absolute = Math.abs(rounded)
22
- if (absolute >= 1000000) return `${Number((rounded / 1000000).toFixed(1))}m`
23
- if (absolute >= 10000) return `${Number((rounded / 1000).toFixed(1))}k`
24
- return String(rounded)
25
- }
26
-
27
20
  function buildTicks(start, extent, zoom, step) {
28
21
  const first = Math.floor(start / step) * step
29
22
  const last = start + extent / zoom
@@ -34,7 +27,8 @@ function buildTicks(start, extent, zoom, step) {
34
27
  return ticks
35
28
  }
36
29
 
37
- export default function CanvasRulers({ enabled, svgRef }) {
30
+ export default function CanvasRulers({ enabled, svgRef, unit = 'px' }) {
31
+ const rulerUnit = normalizeRulerUnit(unit)
38
32
  const [viewport, setViewport] = useState({ x: 0, y: 0, zoom: 1, width: 0, height: 0, rulerLeft: 64, rulerTop: 48 })
39
33
  const [pointer, setPointer] = useState({ x: 0, y: 0, visible: false })
40
34
 
@@ -113,28 +107,29 @@ export default function CanvasRulers({ enabled, svgRef }) {
113
107
  <div className="absolute right-0 overflow-hidden border-b border-border-light bg-surface/95 shadow-sm backdrop-blur-sm" style={{ height: RULER_SIZE, left: horizontalStart, top: viewport.rulerTop }}>
114
108
  {horizontalTicks.map(({ value, position }) => (
115
109
  <div key={value} className="absolute bottom-0 h-2 border-l border-text-dim" style={{ left: position - horizontalStart }}>
116
- <span className="absolute bottom-2 left-1 font-mono text-[9px] leading-none tabular-nums">{formatDimension(value)}</span>
110
+ <span className="absolute bottom-2 left-1 font-mono text-[9px] leading-none tabular-nums">{formatRulerValue(value, rulerUnit)}</span>
117
111
  </div>
118
112
  ))}
119
113
  {pointerInDrawingArea && (
120
114
  <div className="absolute inset-y-0 border-l border-accent" style={{ left: pointer.x - horizontalStart }}>
121
- <span className="absolute left-1 top-1 rounded bg-accent px-1 font-mono text-[9px] leading-4 text-white shadow-sm">{formatDimension(pointerWorldX)}</span>
115
+ <span className="absolute left-1 top-1 rounded bg-accent px-1 font-mono text-[9px] leading-4 text-white shadow-sm">{formatRulerValue(pointerWorldX, rulerUnit, true)}</span>
122
116
  </div>
123
117
  )}
124
118
  </div>
125
119
  <div className="absolute bottom-0 overflow-hidden border-r border-border-light bg-surface/95 shadow-sm backdrop-blur-sm" style={{ width: RULER_SIZE, left: viewport.rulerLeft, top: verticalStart }}>
126
120
  {verticalTicks.map(({ value, position }) => (
127
121
  <div key={value} className="absolute right-0 w-2 border-t border-text-dim" style={{ top: position - verticalStart }}>
128
- <span className="absolute right-2 top-1 font-mono text-[9px] leading-none tabular-nums" style={{ transform: 'rotate(-90deg)', transformOrigin: 'top right' }}>{formatDimension(value)}</span>
122
+ <span className="absolute right-2 top-1 font-mono text-[9px] leading-none tabular-nums" style={{ transform: 'rotate(-90deg)', transformOrigin: 'top right' }}>{formatRulerValue(value, rulerUnit)}</span>
129
123
  </div>
130
124
  ))}
131
125
  {pointerInDrawingArea && (
132
126
  <div className="absolute inset-x-0 border-t border-accent" style={{ top: pointer.y - verticalStart }}>
133
- <span className="absolute right-1 top-1 rounded bg-accent px-1 font-mono text-[9px] leading-4 text-white shadow-sm" style={{ transform: 'rotate(-90deg)', transformOrigin: 'top right' }}>{formatDimension(pointerWorldY)}</span>
127
+ <span className="absolute right-1 top-1 rounded bg-accent px-1 font-mono text-[9px] leading-4 text-white shadow-sm" style={{ transform: 'rotate(-90deg)', transformOrigin: 'top right' }}>{formatRulerValue(pointerWorldY, rulerUnit, true)}</span>
134
128
  </div>
135
129
  )}
136
130
  </div>
137
131
  <div className="absolute border-b border-r border-border-light bg-accent/20" style={{ width: RULER_SIZE, height: RULER_SIZE, left: viewport.rulerLeft, top: viewport.rulerTop }}>
132
+ <span className="absolute bottom-0.5 left-1 font-mono text-[8px] uppercase text-accent">{rulerUnit}</span>
138
133
  <span className="absolute left-1/2 top-1/2 h-2.5 -translate-x-1/2 -translate-y-1/2 border-l border-accent" />
139
134
  <span className="absolute left-1/2 top-1/2 w-2.5 -translate-x-1/2 -translate-y-1/2 border-t border-accent" />
140
135
  </div>
@@ -14,6 +14,7 @@ export default function SVGCanvas() {
14
14
  const gridEnabled = useSketchStore((s) => s.gridEnabled)
15
15
  const hydrateGrid = useSketchStore((s) => s.hydrateGrid)
16
16
  const rulersEnabled = useSketchStore((s) => s.rulersEnabled)
17
+ const rulerUnit = useSketchStore((s) => s.rulerUnit)
17
18
  const hydrateRulers = useSketchStore((s) => s.hydrateRulers)
18
19
  const getCursor = useSketchStore((s) => s.getCursor)
19
20
  const cursor = getCursor()
@@ -152,7 +153,7 @@ export default function SVGCanvas() {
152
153
  />
153
154
  )}
154
155
  </svg>
155
- <CanvasRulers enabled={rulersEnabled} svgRef={svgRef} />
156
+ <CanvasRulers enabled={rulersEnabled} svgRef={svgRef} unit={rulerUnit} />
156
157
  </>
157
158
  )
158
159
  }
@@ -0,0 +1,28 @@
1
+ export const RULER_UNITS = ['px', 'cm', 'in']
2
+
3
+ const PIXELS_PER_UNIT = { px: 1, cm: 96 / 2.54, in: 96 }
4
+
5
+ export function normalizeRulerUnit(unit) {
6
+ return RULER_UNITS.includes(unit) ? unit : 'px'
7
+ }
8
+
9
+ export function convertRulerValue(pixelValue, unit = 'px') {
10
+ return pixelValue / PIXELS_PER_UNIT[normalizeRulerUnit(unit)]
11
+ }
12
+
13
+ export function formatRulerValue(pixelValue, unit = 'px', suffix = false) {
14
+ const normalized = normalizeRulerUnit(unit)
15
+ const converted = convertRulerValue(pixelValue, normalized)
16
+ let label
17
+ if (normalized === 'px') {
18
+ const rounded = Math.round(converted)
19
+ const absolute = Math.abs(rounded)
20
+ if (absolute >= 1000000) label = `${Number((rounded / 1000000).toFixed(1))}m`
21
+ else if (absolute >= 10000) label = `${Number((rounded / 1000).toFixed(1))}k`
22
+ else label = String(rounded)
23
+ } else {
24
+ const precision = Math.abs(converted) < 10 ? 2 : Math.abs(converted) < 100 ? 1 : 0
25
+ label = String(Number(converted.toFixed(precision)))
26
+ }
27
+ return suffix ? `${label} ${normalized}` : label
28
+ }
@@ -43,7 +43,7 @@ export default function PaintBucketSidebar() {
43
43
  {FILLS.map((fill) => <button key={fill.value} type="button" onClick={() => setFillStyle(fill.value)} className={`flex cursor-pointer items-center gap-2 rounded-lg px-2.5 py-1.5 text-xs transition ${fillStyle === fill.value ? 'bg-accent text-white' : 'text-text-secondary hover:bg-surface-hover'}`}><span className="h-1.5 w-1.5 rounded-full bg-current" />{fill.label}</button>)}
44
44
  </div>
45
45
  </ToolbarButton>
46
- <div className="max-w-[185px] px-2 text-[10px] leading-4 text-text-dim">Click a rectangle or circle to apply the selected fill.</div>
46
+ <div className="max-w-[185px] px-2 text-[10px] leading-4 text-text-dim">Fill rectangles, circles, frames, and closed freehand shapes. Lines and arrows are left unchanged.</div>
47
47
  </ShapeSidebar>
48
48
  )
49
49
  }
@@ -1,7 +1,9 @@
1
1
  import { create } from 'zustand'
2
+ import { DEFAULT_CANVAS_BACKGROUNDS, canvasBackgroundStorageKey, readCanvasBackground } from '../utils/canvasBackgrounds'
2
3
 
3
4
  const GRID_STORAGE_KEY = 'lixsketch-grid-enabled'
4
5
  const RULER_STORAGE_KEY = 'lixsketch-rulers-enabled'
6
+ const RULER_UNIT_STORAGE_KEY = 'lixsketch-ruler-unit'
5
7
 
6
8
  // Tool enum replaces 15 boolean flags
7
9
  export const TOOLS = {
@@ -193,13 +195,22 @@ const useSketchStore = create((set, get) => ({
193
195
  setPanStart: (p) => set({ panStart: p }),
194
196
 
195
197
  // --- Canvas background ---
196
- canvasBackground: 'var(--lixsketch-bg, #15111f)',
197
- setCanvasBackground: (color) => {
198
- set({ canvasBackground: color })
198
+ canvasBackground: DEFAULT_CANVAS_BACKGROUNDS.dark,
199
+ canvasBackgrounds: { ...DEFAULT_CANVAS_BACKGROUNDS },
200
+ setCanvasBackground: (color, theme = 'dark') => {
201
+ const resolved = theme === 'light' ? 'light' : 'dark'
202
+ set((state) => ({ canvasBackground: color, canvasBackgrounds: { ...state.canvasBackgrounds, [resolved]: color } }))
199
203
  if (typeof window !== 'undefined') {
204
+ try { localStorage.setItem(canvasBackgroundStorageKey(resolved), color) } catch {}
200
205
  requestAnimationFrame(() => window.__adaptCanvasContrast?.(color))
201
206
  }
202
207
  },
208
+ restoreCanvasBackground: (theme) => {
209
+ const resolved = theme === 'light' ? 'light' : 'dark'
210
+ const color = readCanvasBackground(resolved, typeof window === 'undefined' ? null : localStorage)
211
+ set((state) => ({ canvasBackground: color, canvasBackgrounds: { ...state.canvasBackgrounds, [resolved]: color } }))
212
+ if (typeof window !== 'undefined') requestAnimationFrame(() => window.__adaptCanvasContrast?.(color))
213
+ },
203
214
 
204
215
  // --- Grid ---
205
216
  gridEnabled: false,
@@ -222,6 +233,7 @@ const useSketchStore = create((set, get) => ({
222
233
 
223
234
  // --- Rulers ---
224
235
  rulersEnabled: false,
236
+ rulerUnit: 'px',
225
237
  toggleRulers: () => set((s) => {
226
238
  const rulersEnabled = !s.rulersEnabled
227
239
  if (typeof window !== 'undefined') {
@@ -229,6 +241,13 @@ const useSketchStore = create((set, get) => ({
229
241
  }
230
242
  return { rulersEnabled }
231
243
  }),
244
+ setRulerUnit: (unit) => {
245
+ const rulerUnit = ['px', 'cm', 'in'].includes(unit) ? unit : 'px'
246
+ if (typeof window !== 'undefined') {
247
+ try { localStorage.setItem(RULER_UNIT_STORAGE_KEY, rulerUnit) } catch {}
248
+ }
249
+ set({ rulerUnit })
250
+ },
232
251
  hydrateRulers: () => {
233
252
  if (typeof window === 'undefined') return
234
253
  try {
@@ -236,6 +255,8 @@ const useSketchStore = create((set, get) => ({
236
255
  if (saved === 'true' || saved === 'false') {
237
256
  set({ rulersEnabled: saved === 'true' })
238
257
  }
258
+ const savedUnit = localStorage.getItem(RULER_UNIT_STORAGE_KEY)
259
+ if (['px', 'cm', 'in'].includes(savedUnit)) set({ rulerUnit: savedUnit })
239
260
  } catch {}
240
261
  },
241
262
 
@@ -1,4 +1,5 @@
1
1
  import { create } from 'zustand'
2
+ import useSketchStore from './useSketchStore'
2
3
 
3
4
  export const MAX_WORKSPACE_NAME_LENGTH = 20
4
5
 
@@ -111,6 +112,20 @@ function invertShapeColors(prevResolved, nextResolved) {
111
112
  }
112
113
  }
113
114
 
115
+ function readStoredTheme() {
116
+ if (typeof window === 'undefined') return 'dark'
117
+ try {
118
+ const theme = localStorage.getItem('theme')
119
+ if (theme && ['dark', 'light', 'system'].includes(theme)) return theme
120
+ } catch {}
121
+ try {
122
+ const prefs = JSON.parse(localStorage.getItem('lix_ui_prefs') || '{}')
123
+ return ['dark', 'light', 'system'].includes(prefs.theme) ? prefs.theme : 'dark'
124
+ } catch {
125
+ return 'dark'
126
+ }
127
+ }
128
+
114
129
  function applyTheme(theme) {
115
130
  // Issue #38 bug #1: scope theme to body classes so light mode only
116
131
  // affects the canvas, not the landing/blog pages. Token values live
@@ -215,7 +230,7 @@ const useUIStore = create((set, get) => ({
215
230
  setCanvasLoading: (loading, message) => set({ canvasLoading: loading, canvasLoadingMessage: message || 'Loading canvas...' }),
216
231
 
217
232
  // --- Theme ---
218
- theme: 'dark',
233
+ theme: readStoredTheme(),
219
234
  setTheme: (newTheme) => {
220
235
  const prev = get().theme
221
236
  const resolve = (t) => t === 'system'
@@ -224,6 +239,14 @@ const useUIStore = create((set, get) => ({
224
239
  invertShapeColors(resolve(prev), resolve(newTheme))
225
240
  applyTheme(newTheme)
226
241
  set({ theme: newTheme })
242
+ useSketchStore.getState().restoreCanvasBackground(resolve(newTheme))
243
+ if (typeof window !== 'undefined') {
244
+ try {
245
+ localStorage.setItem('theme', newTheme)
246
+ const prefs = JSON.parse(localStorage.getItem('lix_ui_prefs') || '{}')
247
+ localStorage.setItem('lix_ui_prefs', JSON.stringify({ ...prefs, theme: newTheme }))
248
+ } catch {}
249
+ }
227
250
  },
228
251
 
229
252
  // --- Language / i18n ---
@@ -0,0 +1,29 @@
1
+ export const CANVAS_BACKGROUNDS = {
2
+ light: [
3
+ { color: '#FBE4DB', label: 'menu.canvasBg.white' },
4
+ { color: '#F6DBC0', label: 'menu.canvasBg.cream' },
5
+ { color: '#F5EED2', label: 'menu.canvasBg.paper' },
6
+ { color: '#F5F2ED', label: 'menu.canvasBg.skyTint' },
7
+ { color: '#F5DABF', label: 'menu.canvasBg.sageTint' },
8
+ ],
9
+ dark: [
10
+ { color: '#000000', label: 'menu.canvasBg.black' },
11
+ { color: '#161718', label: 'menu.canvasBg.darkGray' },
12
+ { color: '#15111f', label: 'menu.canvasBg.blueBlack' },
13
+ { color: '#181605', label: 'menu.canvasBg.darkYellow' },
14
+ { color: '#1B1615', label: 'menu.canvasBg.darkBrown' },
15
+ ],
16
+ }
17
+
18
+ export const DEFAULT_CANVAS_BACKGROUNDS = { dark: '#15111f', light: '#F5F2ED' }
19
+ export const canvasBackgroundStorageKey = (theme) => `lixsketch-canvas-background-${theme}`
20
+
21
+ export function readCanvasBackground(theme, storage) {
22
+ const resolved = theme === 'light' ? 'light' : 'dark'
23
+ try {
24
+ const stored = storage?.getItem(canvasBackgroundStorageKey(resolved))
25
+ const match = CANVAS_BACKGROUNDS[resolved].find(({ color }) => color.toLowerCase() === stored?.toLowerCase())
26
+ if (match) return match.color
27
+ } catch {}
28
+ return DEFAULT_CANVAS_BACKGROUNDS[resolved]
29
+ }
@@ -1,5 +1,6 @@
1
1
  /* eslint-disable */
2
2
  import { registerRotationAnchor } from '../core/ScreenSpaceControls.js';
3
+ import { canvasToLocal, localToCanvas } from '../core/CanvasSpace.js';
3
4
  // Circle shape class - extracted from drawCircle.js
4
5
  // Depends on globals: svg, shapes, rough, currentShape, currentZoom, rc
5
6
 
@@ -432,18 +433,17 @@ class Circle {
432
433
  }
433
434
  isNearAnchor(x, y) {
434
435
  if (!this.isSelected) return null;
435
- const buffer = 10 / currentZoom; // Scale buffer by zoom level
436
- const anchorSize = 10 / currentZoom; // Scale anchor size by zoom level
436
+ const zoom = window.currentZoom || 1;
437
+ const buffer = 10 / zoom; // Scale buffer by zoom level
438
+ const anchorSize = 10 / zoom; // Scale anchor size by zoom level
439
+ const transform = { x: this.x, y: this.y, rotation: this.rotation, centerX: 0, centerY: 0 };
437
440
 
438
441
  // Iterate through anchors
439
442
  for (let i = 0; i < this.anchors.length; i++) {
440
443
  const anchor = this.anchors[i];
441
444
  const anchorLocalX = parseFloat(anchor.getAttribute('x')) + anchorSize / 2;
442
445
  const anchorLocalY = parseFloat(anchor.getAttribute('y')) + anchorSize / 2;
443
- const svgPoint = svg.createSVGPoint();
444
- svgPoint.x = anchorLocalX;
445
- svgPoint.y = anchorLocalY;
446
- const transformedPoint = svgPoint.matrixTransform(this.group.getCTM());
446
+ const transformedPoint = localToCanvas({ x: anchorLocalX, y: anchorLocalY }, transform);
447
447
 
448
448
  const anchorLeft = transformedPoint.x - anchorSize/2 - buffer;
449
449
  const anchorRight = transformedPoint.x + anchorSize/2 + buffer;
@@ -459,12 +459,9 @@ class Circle {
459
459
  if (this.rotationAnchor) {
460
460
  const rotateAnchorLocalX = parseFloat(this.rotationAnchor.getAttribute('cx'));
461
461
  const rotateAnchorLocalY = parseFloat(this.rotationAnchor.getAttribute('cy'));
462
- const svgPoint = svg.createSVGPoint();
463
- svgPoint.x = rotateAnchorLocalX;
464
- svgPoint.y = rotateAnchorLocalY;
465
- const transformedPoint = svgPoint.matrixTransform(this.group.getCTM());
462
+ const transformedPoint = localToCanvas({ x: rotateAnchorLocalX, y: rotateAnchorLocalY }, transform);
466
463
 
467
- const rotateAnchorRadius = parseFloat(this.rotationAnchor.getAttribute('r')) / currentZoom;
464
+ const rotateAnchorRadius = parseFloat(this.rotationAnchor.getAttribute('r'));
468
465
  const distSq = (x - transformedPoint.x)**2 + (y - transformedPoint.y)**2;
469
466
  if (distSq <= (rotateAnchorRadius + buffer)**2) {
470
467
  return { type: 'rotate' };
@@ -475,14 +472,10 @@ class Circle {
475
472
  }
476
473
 
477
474
  updatePosition(anchorIndex, newMouseX, newMouseY) {
478
- const CTM = this.group.getCTM();
479
- if (!CTM) return;
480
-
481
- const inverseCTM = CTM.inverse();
482
- const svgPoint = svg.createSVGPoint();
483
- svgPoint.x = newMouseX;
484
- svgPoint.y = newMouseY;
485
- const transformedPoint = svgPoint.matrixTransform(inverseCTM);
475
+ const transformedPoint = canvasToLocal(
476
+ { x: newMouseX, y: newMouseY },
477
+ { x: this.x, y: this.y, rotation: this.rotation, centerX: 0, centerY: 0 },
478
+ );
486
479
 
487
480
  const dx = transformedPoint.x;
488
481
  const dy = transformedPoint.y;
@@ -101,6 +101,36 @@ class Frame {
101
101
  let fillValue = "transparent";
102
102
  if (this.fillStyle === 'solid') {
103
103
  fillValue = this.fillColor;
104
+ } else if (['hachure', 'cross-hatch', 'dots'].includes(this.fillStyle)) {
105
+ const patternId = `frame-fill-${this.shapeID}`;
106
+ let pattern = defs.querySelector(`#${patternId}`);
107
+ if (!pattern) {
108
+ pattern = document.createElementNS('http://www.w3.org/2000/svg', 'pattern');
109
+ pattern.setAttribute('id', patternId);
110
+ pattern.setAttribute('patternUnits', 'userSpaceOnUse');
111
+ defs.appendChild(pattern);
112
+ }
113
+ pattern.setAttribute('width', 10);
114
+ pattern.setAttribute('height', 10);
115
+ while (pattern.firstChild) pattern.removeChild(pattern.firstChild);
116
+ if (this.fillStyle === 'dots') {
117
+ const dot = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
118
+ dot.setAttribute('cx', 5);
119
+ dot.setAttribute('cy', 5);
120
+ dot.setAttribute('r', 1.5);
121
+ dot.setAttribute('fill', this.fillColor);
122
+ pattern.appendChild(dot);
123
+ } else {
124
+ const hatch = document.createElementNS('http://www.w3.org/2000/svg', 'path');
125
+ hatch.setAttribute('d', this.fillStyle === 'cross-hatch'
126
+ ? 'M-2 2L2-2M0 10L10 0M8 12L12 8M-2 8L2 12M0 0L10 10M8-2L12 2'
127
+ : 'M-2 2L2-2M0 10L10 0M8 12L12 8');
128
+ hatch.setAttribute('fill', 'none');
129
+ hatch.setAttribute('stroke', this.fillColor);
130
+ hatch.setAttribute('stroke-width', 1.5);
131
+ pattern.appendChild(hatch);
132
+ }
133
+ fillValue = `url(#${patternId})`;
104
134
  } else if (this.fillStyle === 'grid') {
105
135
  // Create or update a grid pattern unique to this frame
106
136
  const patternId = `frame-grid-${this.shapeID}`;
@@ -426,6 +456,14 @@ move(dx, dy) {
426
456
  this.updateClipPath();
427
457
  }
428
458
 
459
+ finalizeMove() {
460
+ this.containedShapes.forEach(shape => {
461
+ if (shape && typeof shape.finalizeMove === 'function') {
462
+ shape.finalizeMove();
463
+ }
464
+ });
465
+ }
466
+
429
467
  handleResizeLegacy(anchorIndex, currentPos, startPos, initialFrame) {
430
468
  // Legacy version - superseded by the handleResize below with contained shapes scaling
431
469
  const dx = currentPos.x - startPos.x;
@@ -1409,75 +1447,6 @@ restoreToFrame(shape) {
1409
1447
  this.draw();
1410
1448
  }
1411
1449
 
1412
- destroy() {
1413
- const isDiagramFrame = !!this._diagramType;
1414
-
1415
- [...this.containedShapes].forEach(shape => {
1416
- if (isDiagramFrame) {
1417
- // Diagram/graph frame: destroy contained shapes with the frame
1418
- if (typeof window.cleanupAttachments === 'function') {
1419
- window.cleanupAttachments(shape);
1420
- }
1421
- const el = shape.group || shape.element;
1422
- if (el && el.parentNode) {
1423
- el.parentNode.removeChild(el);
1424
- }
1425
- if (shape.shapeName === 'frame') {
1426
- if (shape.clipGroup && shape.clipGroup.parentNode) {
1427
- shape.clipGroup.parentNode.removeChild(shape.clipGroup);
1428
- }
1429
- if (shape.clipPath && shape.clipPath.parentNode) {
1430
- shape.clipPath.parentNode.removeChild(shape.clipPath);
1431
- }
1432
- }
1433
- const idx = shapes.indexOf(shape);
1434
- if (idx > -1) {
1435
- shapes.splice(idx, 1);
1436
- }
1437
- } else {
1438
- // Regular frame: release contained shapes back to canvas
1439
- const el = shape.group || shape.element;
1440
- if (el) {
1441
- if (el.parentNode === this.clipGroup) {
1442
- this.clipGroup.removeChild(el);
1443
- }
1444
- svg.appendChild(el);
1445
- }
1446
- if (shape.shapeName === 'frame' && shape.clipGroup) {
1447
- if (shape.clipGroup.parentNode === this.clipGroup) {
1448
- this.clipGroup.removeChild(shape.clipGroup);
1449
- }
1450
- svg.appendChild(shape.clipGroup);
1451
- }
1452
- }
1453
- shape.parentFrame = null;
1454
- delete shape.isBeingMovedByFrame;
1455
- });
1456
- this.containedShapes = [];
1457
-
1458
- // Remove clip path
1459
- if (this.clipPath && this.clipPath.parentNode) {
1460
- this.clipPath.parentNode.removeChild(this.clipPath);
1461
- }
1462
-
1463
- // Remove groups (clipGroup should be empty now)
1464
- if (this.clipGroup && this.clipGroup.parentNode) {
1465
- this.clipGroup.parentNode.removeChild(this.clipGroup);
1466
- }
1467
-
1468
- if (this.group && this.group.parentNode) {
1469
- this.group.parentNode.removeChild(this.group);
1470
- }
1471
-
1472
- const index = shapes.indexOf(this);
1473
- if (index > -1) {
1474
- shapes.splice(index, 1);
1475
- }
1476
- if (currentShape === this) {
1477
- currentShape = null;
1478
- }
1479
- }
1480
-
1481
1450
  /**
1482
1451
  * Set a background image for this frame from a URL.
1483
1452
  * The image will fit/cover the frame dimensions.
@@ -69,6 +69,8 @@ class FreehandStroke {
69
69
  this.selectionOutline = null;
70
70
  this.boundingBox = { x: 0, y: 0, width: 0, height: 0 };
71
71
  this.shapeName = "freehandStroke";
72
+ this.shapeID = `freehand-${String(Date.now()).slice(0, 8)}-${Math.floor(Math.random() * 10000)}`;
73
+ this.group.setAttribute('id', this.shapeID);
72
74
  this._moveOffsetX = 0;
73
75
  this._moveOffsetY = 0;
74
76
 
@@ -304,7 +306,16 @@ class FreehandStroke {
304
306
  const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
305
307
  const pathData = this.getPathData();
306
308
  path.setAttribute('d', pathData);
307
- path.setAttribute('fill', this.options.stroke);
309
+ const closedFillStyle = this.options.fillStyle || 'solid';
310
+ const closedFill = closedFillStyle === 'none'
311
+ ? 'transparent'
312
+ : (this.options.fill || this.options.stroke);
313
+ const patternedFill = this.options.closedFill
314
+ && !['none', 'solid'].includes(closedFillStyle)
315
+ && typeof rough !== 'undefined';
316
+ path.setAttribute('fill', this.options.closedFill
317
+ ? (patternedFill ? 'transparent' : closedFill)
318
+ : this.options.stroke);
308
319
  path.setAttribute('fill-opacity', this.options.strokeOpacity);
309
320
  path.setAttribute('stroke', this.options.closedFill ? (this.options.outlineStroke || 'none') : 'none');
310
321
  if (this.options.closedFill) {
@@ -330,6 +341,18 @@ class FreehandStroke {
330
341
  this.group.appendChild(overlay);
331
342
  }
332
343
 
344
+ if (patternedFill) {
345
+ const fillGroup = rough.svg(svg).path(pathData, {
346
+ stroke: 'none',
347
+ fill: closedFill,
348
+ fillStyle: closedFillStyle,
349
+ fillWeight: Math.max(1, (this.options.strokeWidth || 2) / 2),
350
+ roughness: 0.8,
351
+ });
352
+ fillGroup.setAttribute('data-freehand-fill-pattern', closedFillStyle);
353
+ this.group.appendChild(fillGroup);
354
+ }
355
+
333
356
  this.element = path;
334
357
  this.group.appendChild(path);
335
358
 
@@ -1,5 +1,6 @@
1
1
  /* eslint-disable */
2
2
  import { registerRotationAnchor } from '../core/ScreenSpaceControls.js';
3
+ import { canvasToLocal, localToCanvas } from '../core/CanvasSpace.js';
3
4
  // Rectangle shape class - extracted from drawSquare.js
4
5
  // Depends on globals: svg, shapes, rough, currentShape, currentZoom, rc
5
6
 
@@ -640,18 +641,17 @@ class Rectangle {
640
641
  // Helper to check if a point is near an anchor
641
642
  isNearAnchor(x, y) {
642
643
  if (!this.isSelected) return null;
643
- const buffer = 10;
644
- const anchorSize = 10;
644
+ const zoom = window.currentZoom || 1;
645
+ const buffer = 10 / zoom;
646
+ const anchorSize = 10 / zoom;
647
+ const transform = { x: this.x, y: this.y, rotation: this.rotation, centerX: this.width / 2, centerY: this.height / 2 };
645
648
 
646
649
  // Iterate through anchors
647
650
  for (let i = 0; i < this.anchors.length; i++) {
648
651
  const anchor = this.anchors[i];
649
652
  const anchorLocalX = parseFloat(anchor.getAttribute('x')) + anchorSize / 2;
650
653
  const anchorLocalY = parseFloat(anchor.getAttribute('y')) + anchorSize / 2;
651
- const svgPoint = svg.createSVGPoint();
652
- svgPoint.x = anchorLocalX;
653
- svgPoint.y = anchorLocalY;
654
- const transformedPoint = svgPoint.matrixTransform(this.group.getCTM());
654
+ const transformedPoint = localToCanvas({ x: anchorLocalX, y: anchorLocalY }, transform);
655
655
  const anchorLeft = transformedPoint.x - anchorSize/2 - buffer;
656
656
  const anchorRight = transformedPoint.x + anchorSize/2 + buffer;
657
657
  const anchorTop = transformedPoint.y - anchorSize/2 - buffer;
@@ -665,10 +665,7 @@ class Rectangle {
665
665
  if (this.rotationAnchor) {
666
666
  const rotateAnchorLocalX = parseFloat(this.rotationAnchor.getAttribute('cx'));
667
667
  const rotateAnchorLocalY = parseFloat(this.rotationAnchor.getAttribute('cy'));
668
- const svgPoint = svg.createSVGPoint();
669
- svgPoint.x = rotateAnchorLocalX;
670
- svgPoint.y = rotateAnchorLocalY;
671
- const transformedPoint = svgPoint.matrixTransform(this.group.getCTM());
668
+ const transformedPoint = localToCanvas({ x: rotateAnchorLocalX, y: rotateAnchorLocalY }, transform);
672
669
  const rotateAnchorRadius = parseFloat(this.rotationAnchor.getAttribute('r'));
673
670
  const distSq = (x - transformedPoint.x)**2 + (y - transformedPoint.y)**2;
674
671
  if (distSq <= (rotateAnchorRadius + buffer)**2) {
@@ -729,16 +726,10 @@ updateFrameContainment() {
729
726
  }
730
727
 
731
728
  updatePosition(anchorIndex, newMouseX, newMouseY) {
732
- const CTM = this.group.getCTM();
733
- if (!CTM) return;
734
- const inverseCTM = CTM.inverse();
735
- const svgPoint = svg.createSVGPoint();
736
- svgPoint.x = newMouseX;
737
- svgPoint.y = newMouseY;
738
- const localMouse = svgPoint.matrixTransform(inverseCTM);
739
-
740
729
  const oldW = this.width;
741
730
  const oldH = this.height;
731
+ const transform = { x: this.x, y: this.y, rotation: this.rotation, centerX: oldW / 2, centerY: oldH / 2 };
732
+ const localMouse = canvasToLocal({ x: newMouseX, y: newMouseY }, transform);
742
733
 
743
734
  // Determine fixed point in old local coords and compute raw new dimensions
744
735
  let fixedOldX, fixedOldY, rawW, rawH;
@@ -754,10 +745,7 @@ updateFrameContainment() {
754
745
  }
755
746
 
756
747
  // Get world position of the fixed point using current CTM
757
- const fp = svg.createSVGPoint();
758
- fp.x = fixedOldX;
759
- fp.y = fixedOldY;
760
- const fixedWorld = fp.matrixTransform(CTM);
748
+ const fixedWorld = localToCanvas({ x: fixedOldX, y: fixedOldY }, transform);
761
749
 
762
750
  // Handle negative dimensions (dragged past opposite edge)
763
751
  const newW = Math.abs(rawW);