@elixpo/lixsketch 5.6.5 → 5.6.7

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 (161) 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-PPDS3AD6.js} +11 -10
  9. package/dist/react/{CopyPaste-YUDZ6DHY.js.map → CopyPaste-PPDS3AD6.js.map} +1 -1
  10. package/dist/react/{EventDispatcher-7LAK7GQ7.js → EventDispatcher-UV7LFSAS.js} +216 -63
  11. package/dist/react/EventDispatcher-UV7LFSAS.js.map +7 -0
  12. package/dist/react/Frame-DELCMYV6.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-PZHMZSLR.js +10 -0
  17. package/dist/react/ImageShape-ZMBZARRI.js +10 -0
  18. package/dist/react/Line-VPVPL6ET.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-H6KMQCO2.js} +120 -46
  30. package/dist/react/SceneSerializer-H6KMQCO2.js.map +7 -0
  31. package/dist/react/{Selection-2TSH3J6P.js → Selection-CFWRGVKH.js} +4 -4
  32. package/dist/react/{SketchEngine-UD2XV2KT.js → SketchEngine-UFLZJQXS.js} +26 -26
  33. package/dist/react/{UndoRedo-IN4SXDG6.js → UndoRedo-RXO7YHA2.js} +12 -2
  34. package/dist/react/{arrowTool-OYDWJGQK.js → arrowTool-DO327FKU.js} +3 -3
  35. package/dist/react/{chunk-EOURRJ55.js → chunk-2ALS5JGS.js} +2 -2
  36. package/dist/react/{chunk-LJR7EVW2.js → chunk-37PF7FG2.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-XOWNCYE4.js → chunk-6LWUR7XB.js} +3 -3
  40. package/dist/react/{chunk-ASBC5G35.js → chunk-7DD2JRBC.js} +2 -2
  41. package/dist/react/{chunk-OP56XPWF.js → chunk-BOZEDUAF.js} +2 -2
  42. package/dist/react/{chunk-RGMDWLRJ.js → chunk-CGMUXYPJ.js} +3 -3
  43. package/dist/react/{chunk-M6TMN76K.js → chunk-DODPOWNX.js} +3 -3
  44. package/dist/react/{chunk-LSHZYN77.js → chunk-GQLXUZHQ.js} +2 -2
  45. package/dist/react/{chunk-7CM5XQDN.js → chunk-I7TVGJFF.js} +20 -36
  46. package/dist/react/chunk-I7TVGJFF.js.map +7 -0
  47. package/dist/react/{chunk-K5RY4EV7.js → chunk-KC26KJW4.js} +26 -17
  48. package/dist/react/chunk-KC26KJW4.js.map +7 -0
  49. package/dist/react/{chunk-XFL4R5ZF.js → chunk-KZBKYHZO.js} +16 -35
  50. package/dist/react/chunk-KZBKYHZO.js.map +7 -0
  51. package/dist/react/{chunk-VQVEPJSA.js → chunk-MHV5P6TS.js} +2 -1
  52. package/dist/react/chunk-MHV5P6TS.js.map +7 -0
  53. package/dist/react/{chunk-PXFPPO3R.js → chunk-NH3NVSWT.js} +18 -2
  54. package/dist/react/chunk-NH3NVSWT.js.map +7 -0
  55. package/dist/react/{chunk-APTOPAOY.js → chunk-O7KXLFZN.js} +3 -3
  56. package/dist/react/{chunk-GQUZPON4.js → chunk-RK3NVAER.js} +4 -4
  57. package/dist/react/{chunk-FETTJLCV.js → chunk-VZPH7OAM.js} +14 -22
  58. package/dist/react/chunk-VZPH7OAM.js.map +7 -0
  59. package/dist/react/{chunk-6GPEWDRB.js → chunk-VZX55VQL.js} +115 -6
  60. package/dist/react/chunk-VZX55VQL.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-XYUFEIZR.js → chunk-X5ENBMGL.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-NK4RVDAR.js} +4 -4
  67. package/dist/react/{codeTool-4YZO5W7J.js → codeTool-3WF4HEOW.js} +4 -4
  68. package/dist/react/{frameTool-MXNJEXQQ.js → frameTool-VCNV2NG2.js} +4 -4
  69. package/dist/react/{freehandTool-26D3FOOX.js → freehandTool-TBAOD46K.js} +4 -4
  70. package/dist/react/{iconTool-46O5VEUX.js → iconTool-65JCAKYF.js} +4 -4
  71. package/dist/react/{imageTool-P2RWZRHO.js → imageTool-UQZUBOK2.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-762KBYOM.js} +4 -4
  75. package/dist/react/{rectangleTool-EJKK6UTX.js → rectangleTool-IQK3HZK6.js} +4 -4
  76. package/dist/react/{textTool-S4XXZHZH.js → textTool-ITG2SBJK.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 +29 -14
  88. package/src/core/UndoRedo.js +126 -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-K5RY4EV7.js.map +0 -7
  126. package/dist/react/chunk-PXFPPO3R.js.map +0 -7
  127. package/dist/react/chunk-TKXKJNTX.js.map +0 -7
  128. package/dist/react/chunk-VQVEPJSA.js.map +0 -7
  129. package/dist/react/chunk-XFL4R5ZF.js.map +0 -7
  130. /package/dist/react/{Circle-6POZCUZD.js.map → Circle-HMM5PKJ2.js.map} +0 -0
  131. /package/dist/react/{Frame-7D2MXCFF.js.map → Frame-DELCMYV6.js.map} +0 -0
  132. /package/dist/react/{FreehandStroke-U6GYZOM4.js.map → FreehandStroke-WXIJKD5I.js.map} +0 -0
  133. /package/dist/react/{IconShape-ZADX7Y33.js.map → IconShape-PZHMZSLR.js.map} +0 -0
  134. /package/dist/react/{ImageShape-UJQN2HI7.js.map → ImageShape-ZMBZARRI.js.map} +0 -0
  135. /package/dist/react/{Line-APEULMHE.js.map → Line-VPVPL6ET.js.map} +0 -0
  136. /package/dist/react/{MermaidSequenceParser-UKG2NSZY.js.map → MermaidSequenceParser-DBYXW23P.js.map} +0 -0
  137. /package/dist/react/{Rectangle-S7XUKWYD.js.map → Rectangle-4U4CD2ER.js.map} +0 -0
  138. /package/dist/react/{Selection-2TSH3J6P.js.map → Selection-CFWRGVKH.js.map} +0 -0
  139. /package/dist/react/{SketchEngine-UD2XV2KT.js.map → SketchEngine-UFLZJQXS.js.map} +0 -0
  140. /package/dist/react/{UndoRedo-IN4SXDG6.js.map → UndoRedo-RXO7YHA2.js.map} +0 -0
  141. /package/dist/react/{arrowTool-OYDWJGQK.js.map → arrowTool-DO327FKU.js.map} +0 -0
  142. /package/dist/react/{chunk-EOURRJ55.js.map → chunk-2ALS5JGS.js.map} +0 -0
  143. /package/dist/react/{chunk-LJR7EVW2.js.map → chunk-37PF7FG2.js.map} +0 -0
  144. /package/dist/react/{chunk-XOWNCYE4.js.map → chunk-6LWUR7XB.js.map} +0 -0
  145. /package/dist/react/{chunk-ASBC5G35.js.map → chunk-7DD2JRBC.js.map} +0 -0
  146. /package/dist/react/{chunk-OP56XPWF.js.map → chunk-BOZEDUAF.js.map} +0 -0
  147. /package/dist/react/{chunk-RGMDWLRJ.js.map → chunk-CGMUXYPJ.js.map} +0 -0
  148. /package/dist/react/{chunk-M6TMN76K.js.map → chunk-DODPOWNX.js.map} +0 -0
  149. /package/dist/react/{chunk-LSHZYN77.js.map → chunk-GQLXUZHQ.js.map} +0 -0
  150. /package/dist/react/{chunk-APTOPAOY.js.map → chunk-O7KXLFZN.js.map} +0 -0
  151. /package/dist/react/{chunk-GQUZPON4.js.map → chunk-RK3NVAER.js.map} +0 -0
  152. /package/dist/react/{chunk-XYUFEIZR.js.map → chunk-X5ENBMGL.js.map} +0 -0
  153. /package/dist/react/{circleTool-LDKGZALF.js.map → circleTool-NK4RVDAR.js.map} +0 -0
  154. /package/dist/react/{codeTool-4YZO5W7J.js.map → codeTool-3WF4HEOW.js.map} +0 -0
  155. /package/dist/react/{frameTool-MXNJEXQQ.js.map → frameTool-VCNV2NG2.js.map} +0 -0
  156. /package/dist/react/{freehandTool-26D3FOOX.js.map → freehandTool-TBAOD46K.js.map} +0 -0
  157. /package/dist/react/{iconTool-46O5VEUX.js.map → iconTool-65JCAKYF.js.map} +0 -0
  158. /package/dist/react/{imageTool-P2RWZRHO.js.map → imageTool-UQZUBOK2.js.map} +0 -0
  159. /package/dist/react/{lineTool-ZMR27U5L.js.map → lineTool-762KBYOM.js.map} +0 -0
  160. /package/dist/react/{rectangleTool-EJKK6UTX.js.map → rectangleTool-IQK3HZK6.js.map} +0 -0
  161. /package/dist/react/{textTool-S4XXZHZH.js.map → textTool-ITG2SBJK.js.map} +0 -0
@@ -3,12 +3,12 @@ import {
3
3
  handleMouseDown,
4
4
  handleMouseMove,
5
5
  handleMouseUp
6
- } from "./chunk-RGMDWLRJ.js";
7
- import "./chunk-XYUFEIZR.js";
8
- import "./chunk-6GPEWDRB.js";
6
+ } from "./chunk-CGMUXYPJ.js";
7
+ import "./chunk-X5ENBMGL.js";
8
+ import "./chunk-VZX55VQL.js";
9
9
  export {
10
10
  handleMouseDown as handleMouseDownLine,
11
11
  handleMouseMove as handleMouseMoveLine,
12
12
  handleMouseUp as handleMouseUpLine
13
13
  };
14
- //# sourceMappingURL=lineTool-ZMR27U5L.js.map
14
+ //# sourceMappingURL=lineTool-762KBYOM.js.map
@@ -3,12 +3,12 @@ import {
3
3
  handleMouseDownRect,
4
4
  handleMouseMoveRect,
5
5
  handleMouseUpRect
6
- } from "./chunk-7CM5XQDN.js";
7
- import "./chunk-XYUFEIZR.js";
8
- import "./chunk-6GPEWDRB.js";
6
+ } from "./chunk-I7TVGJFF.js";
7
+ import "./chunk-X5ENBMGL.js";
8
+ import "./chunk-VZX55VQL.js";
9
9
  export {
10
10
  handleMouseDownRect,
11
11
  handleMouseMoveRect,
12
12
  handleMouseUpRect
13
13
  };
14
- //# sourceMappingURL=rectangleTool-EJKK6UTX.js.map
14
+ //# sourceMappingURL=rectangleTool-IQK3HZK6.js.map
@@ -6,11 +6,11 @@ import {
6
6
  handleTextMouseMove,
7
7
  handleTextMouseUp,
8
8
  updateCodeToggleForShape
9
- } from "./chunk-GQUZPON4.js";
10
- import "./chunk-M6TMN76K.js";
9
+ } from "./chunk-RK3NVAER.js";
10
+ import "./chunk-DODPOWNX.js";
11
11
  import "./chunk-PAKA6HUX.js";
12
- import "./chunk-XYUFEIZR.js";
13
- import "./chunk-6GPEWDRB.js";
12
+ import "./chunk-X5ENBMGL.js";
13
+ import "./chunk-VZX55VQL.js";
14
14
  export {
15
15
  deselectElement as deselectTextElement,
16
16
  enterEditMode,
@@ -19,4 +19,4 @@ export {
19
19
  handleTextMouseUp,
20
20
  updateCodeToggleForShape
21
21
  };
22
- //# sourceMappingURL=textTool-S4XXZHZH.js.map
22
+ //# sourceMappingURL=textTool-ITG2SBJK.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elixpo/lixsketch",
3
- "version": "5.6.5",
3
+ "version": "5.6.7",
4
4
  "description": "Open-source SVG whiteboard engine + React canvas component with hand-drawn aesthetics — the core of LixSketch",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -82,10 +82,13 @@
82
82
  }
83
83
  },
84
84
  "devDependencies": {
85
- "esbuild": "^0.24.0"
85
+ "esbuild": "^0.24.0",
86
+ "vitest": "^3.2.4"
86
87
  },
87
88
  "scripts": {
88
89
  "build": "node build.mjs",
90
+ "test": "npm run test:unit && npm run test:mcp",
91
+ "test:unit": "vitest run",
89
92
  "test:mcp": "node --test test/mcp-*.test.js",
90
93
  "prepublishOnly": "npm run build"
91
94
  }
@@ -44,8 +44,14 @@ export function parseMermaid(src) {
44
44
  const classDefs = new Map(); // classDef name -> { fill, stroke, strokeWidth }
45
45
  const classAssigns = []; // { nodeIds: [...], className }
46
46
  const nodeStyles = new Map(); // Mermaid `style A fill:...,stroke:...`
47
- const darkTheme = typeof document === 'undefined'
48
- || document.body?.classList.contains('theme-dark');
47
+ const darkTheme = (() => {
48
+ if (typeof document === 'undefined') return true;
49
+ if (document.body) {
50
+ if (document.body.classList.contains('theme-dark')) return true;
51
+ if (document.body.classList.contains('theme-light')) return false;
52
+ }
53
+ return !document.documentElement || document.documentElement.classList.contains('dark');
54
+ })();
49
55
  const palette = darkTheme
50
56
  ? [
51
57
  { fill: '#352b52', stroke: '#9d86df' },
@@ -0,0 +1,28 @@
1
+ /* eslint-disable */
2
+
3
+ // Tool pointer coordinates are already in SVG viewBox space. getCTM() also
4
+ // includes viewport pan/zoom, so controls use these helpers for shape-only
5
+ // translation and rotation.
6
+ export function localToCanvas(point, transform) {
7
+ const rotation = (Number(transform.rotation) || 0) * Math.PI / 180;
8
+ const centerX = Number(transform.centerX) || 0;
9
+ const centerY = Number(transform.centerY) || 0;
10
+ const dx = point.x - centerX;
11
+ const dy = point.y - centerY;
12
+ return {
13
+ x: (Number(transform.x) || 0) + centerX + dx * Math.cos(rotation) - dy * Math.sin(rotation),
14
+ y: (Number(transform.y) || 0) + centerY + dx * Math.sin(rotation) + dy * Math.cos(rotation),
15
+ };
16
+ }
17
+
18
+ export function canvasToLocal(point, transform) {
19
+ const rotation = -(Number(transform.rotation) || 0) * Math.PI / 180;
20
+ const centerX = Number(transform.centerX) || 0;
21
+ const centerY = Number(transform.centerY) || 0;
22
+ const dx = point.x - (Number(transform.x) || 0) - centerX;
23
+ const dy = point.y - (Number(transform.y) || 0) - centerY;
24
+ return {
25
+ x: centerX + dx * Math.cos(rotation) - dy * Math.sin(rotation),
26
+ y: centerY + dx * Math.sin(rotation) + dy * Math.cos(rotation),
27
+ };
28
+ }
@@ -17,9 +17,14 @@ const GRAPH_COLORS = [
17
17
  // labels stay readable on the warm off-white canvas. The equation
18
18
  // curves themselves keep their colours from GRAPH_COLORS.
19
19
  function graphThemeTokens() {
20
- const isDark = typeof document !== 'undefined'
21
- && document.body
22
- && document.body.classList.contains('theme-dark');
20
+ const isDark = (() => {
21
+ if (typeof document === 'undefined') return true;
22
+ if (document.body) {
23
+ if (document.body.classList.contains('theme-dark')) return true;
24
+ if (document.body.classList.contains('theme-light')) return false;
25
+ }
26
+ return !document.documentElement || document.documentElement.classList.contains('dark');
27
+ })();
23
28
  if (isDark) {
24
29
  return {
25
30
  outerBg: '#0d1117',
@@ -50,11 +50,17 @@ function tokenize(source) {
50
50
  * Returns { variables, shapes, errors }
51
51
  */
52
52
  export function parseLixScript(source) {
53
- const tokens = tokenize(source)
54
53
  const variables = {}
55
54
  const shapes = []
56
55
  const errors = []
57
56
 
57
+ if (typeof source !== 'string') {
58
+ errors.push({ line: 0, message: 'LixScript source must be a string' })
59
+ return { variables, shapes, errors }
60
+ }
61
+
62
+ const tokens = tokenize(source)
63
+
58
64
  let i = 0
59
65
 
60
66
  while (i < tokens.length) {
@@ -22,7 +22,11 @@ const FONT_FAMILY = 'lixFont, sans-serif';
22
22
  // so a single render call uses whichever palette is active.
23
23
  function isThemeDark() {
24
24
  if (typeof document === 'undefined') return true;
25
- return !!(document.body && document.body.classList.contains('theme-dark'));
25
+ if (document.body) {
26
+ if (document.body.classList.contains('theme-dark')) return true;
27
+ if (document.body.classList.contains('theme-light')) return false;
28
+ }
29
+ return !document.documentElement || document.documentElement.classList.contains('dark');
26
30
  }
27
31
  function nodeStrokeColor() { return isThemeDark() ? '#fff' : '#1a1a2e'; }
28
32
  function edgeStrokeColor() { return isThemeDark() ? '#888' : '#444'; }
@@ -28,6 +28,7 @@ const ARROW_TYPES = {
28
28
  const ARROW_PATTERNS = Object.keys(ARROW_TYPES).sort((a, b) => b.length - a.length);
29
29
 
30
30
  export function parseSequenceDiagram(src) {
31
+ if (typeof src !== 'string') return null;
31
32
  const lines = src.trim().split('\n').map(l => l.trim()).filter(l => l && !l.startsWith('%%'));
32
33
  if (lines.length === 0) return null;
33
34
 
@@ -32,9 +32,14 @@ const CODE_FONT = 'lixCode, monospace';
32
32
  // string preview path (`renderSequenceSVG`), which is rendered inside
33
33
  // the modal's preview pane.
34
34
  function themeColors() {
35
- const isDark = typeof document !== 'undefined'
36
- && document.body
37
- && document.body.classList.contains('theme-dark');
35
+ const isDark = (() => {
36
+ if (typeof document === 'undefined') return true;
37
+ if (document.body) {
38
+ if (document.body.classList.contains('theme-dark')) return true;
39
+ if (document.body.classList.contains('theme-light')) return false;
40
+ }
41
+ return !document.documentElement || document.documentElement.classList.contains('dark');
42
+ })();
38
43
  if (isDark) return DARK_THEME;
39
44
  return {
40
45
  bg: '#fbfaf6',
@@ -37,7 +37,12 @@ function contrastText(fill) {
37
37
  }
38
38
 
39
39
  function isDark() {
40
- return typeof document !== 'undefined' && document.body?.classList.contains('theme-dark');
40
+ if (typeof document === 'undefined') return true;
41
+ if (document.body) {
42
+ if (document.body.classList.contains('theme-dark')) return true;
43
+ if (document.body.classList.contains('theme-light')) return false;
44
+ }
45
+ return !document.documentElement || document.documentElement.classList.contains('dark');
41
46
  }
42
47
 
43
48
  function theme() {
@@ -17,8 +17,14 @@ import { TextShape } from '../shapes/TextShape.js';
17
17
  import { CodeShape } from '../shapes/CodeShape.js';
18
18
  import { ImageShape } from '../shapes/ImageShape.js';
19
19
  import { IconShape } from '../shapes/IconShape.js';
20
+ import { clearUndoHistory, pushCanvasResetAction } from './UndoRedo.js';
20
21
 
21
22
  const FORMAT_VERSION = 1;
23
+ const GZIP_MAGIC = [0x1f, 0x8b];
24
+ const SUPPORTED_SHAPE_TYPES = new Set([
25
+ 'rectangle', 'circle', 'line', 'arrow', 'freehandStroke',
26
+ 'frame', 'text', 'code', 'image', 'icon',
27
+ ]);
22
28
 
23
29
  // Generate a unique session ID for each scene
24
30
  let _sessionID = null;
@@ -413,8 +419,9 @@ export function saveScene(workspaceName = 'Untitled') {
413
419
  // LOAD: Deserialize .lixsketch JSON and recreate scene
414
420
  // ============================================================
415
421
  export function loadScene(sceneData) {
416
- if (!sceneData || sceneData.format !== 'lixsketch') {
417
- console.error('[SceneSerializer] Invalid scene format');
422
+ const validation = validateScene(sceneData);
423
+ if (!validation.valid) {
424
+ console.error('[SceneSerializer] Invalid scene:', validation.error);
418
425
  return false;
419
426
  }
420
427
 
@@ -525,6 +532,20 @@ export function loadScene(sceneData) {
525
532
  }
526
533
  }
527
534
 
535
+ // Shape-array order is the scene's z-order. Frames are constructed first
536
+ // so their clip groups exist before children are attached, but that is an
537
+ // implementation detail and must not change the saved stacking order.
538
+ const restoredInSceneOrder = sceneData.shapes
539
+ .map(data => data.shapeID && idMap.get(data.shapeID))
540
+ .filter(Boolean);
541
+ const restoredSet = new Set(restoredInSceneOrder);
542
+ window.shapes.splice(
543
+ 0,
544
+ window.shapes.length,
545
+ ...restoredInSceneOrder,
546
+ ...window.shapes.filter(shape => !restoredSet.has(shape)),
547
+ );
548
+
528
549
  // ── Fourth pass: frame containment reconciliation (issue #24 bug #10) ──
529
550
  //
530
551
  // The pass above attaches each child via `frame.addShapeToFrame(child)`,
@@ -608,21 +629,62 @@ export function loadScene(sceneData) {
608
629
  }
609
630
 
610
631
  // ============================================================
611
- // DOWNLOAD: Trigger browser download of .lixsketch file
632
+ // DOWNLOAD: Trigger browser download of an optimized .lixjson file
612
633
  // ============================================================
613
- export function downloadScene(workspaceName = 'Untitled') {
634
+ function sceneFileTimestamp(date = new Date()) {
635
+ const iso = date.toISOString();
636
+ return `${iso.slice(0, 10).replace(/-/g, '')}-${iso.slice(11, 19).replace(/:/g, '')}`;
637
+ }
638
+
639
+ function safeWorkspaceFileName(workspaceName) {
640
+ const safe = String(workspaceName || 'Untitled')
641
+ .trim()
642
+ .replace(/[^a-zA-Z0-9_-]+/g, '_')
643
+ .replace(/^_+|_+$/g, '');
644
+ return safe || 'Untitled';
645
+ }
646
+
647
+ export async function encodeSceneFile(scene) {
648
+ const json = JSON.stringify(scene);
649
+ if (typeof CompressionStream === 'undefined') {
650
+ return new Blob([json], { type: 'application/json' });
651
+ }
652
+
653
+ const compressed = new Blob([json])
654
+ .stream()
655
+ .pipeThrough(new CompressionStream('gzip'));
656
+ const buffer = await new Response(compressed).arrayBuffer();
657
+ return new Blob([buffer], { type: 'application/vnd.lixsketch.scene+gzip' });
658
+ }
659
+
660
+ export async function decodeSceneFile(buffer) {
661
+ const bytes = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer);
662
+ const isGzip = bytes[0] === GZIP_MAGIC[0] && bytes[1] === GZIP_MAGIC[1];
663
+ if (!isGzip) return new TextDecoder().decode(bytes);
664
+ if (typeof DecompressionStream === 'undefined') {
665
+ throw new Error('This browser cannot open compressed .lixjson files.');
666
+ }
667
+
668
+ const decompressed = new Blob([bytes])
669
+ .stream()
670
+ .pipeThrough(new DecompressionStream('gzip'));
671
+ return new Response(decompressed).text();
672
+ }
673
+
674
+ export async function downloadScene(workspaceName = 'Untitled') {
614
675
  const scene = saveScene(workspaceName);
615
- const json = JSON.stringify(scene, null, 2);
616
- const blob = new Blob([json], { type: 'application/json' });
676
+ const blob = await encodeSceneFile(scene);
617
677
  const url = URL.createObjectURL(blob);
678
+ const fileName = `${safeWorkspaceFileName(workspaceName)}-${sceneFileTimestamp()}.lixjson`;
618
679
 
619
680
  const a = document.createElement('a');
620
681
  a.href = url;
621
- a.download = `${workspaceName.replace(/[^a-zA-Z0-9_-]/g, '_')}.lixjson`;
682
+ a.download = fileName;
622
683
  document.body.appendChild(a);
623
684
  a.click();
624
685
  document.body.removeChild(a);
625
- URL.revokeObjectURL(url);
686
+ setTimeout(() => URL.revokeObjectURL(url), 0);
687
+ return { fileName, sizeBytes: blob.size, compressed: blob.type.includes('gzip') };
626
688
  }
627
689
 
628
690
  // ============================================================
@@ -634,6 +696,15 @@ export function validateScene(data) {
634
696
  if (data.format !== 'lixsketch') return { valid: false, error: 'Not a LixSketch scene file (missing format field)' };
635
697
  if (!data.version || data.version > FORMAT_VERSION) return { valid: false, error: `Unsupported version: ${data.version}` };
636
698
  if (!Array.isArray(data.shapes)) return { valid: false, error: 'Invalid scene: missing shapes array' };
699
+ for (let index = 0; index < data.shapes.length; index += 1) {
700
+ const shape = data.shapes[index];
701
+ if (!shape || typeof shape !== 'object' || Array.isArray(shape)) {
702
+ return { valid: false, error: `Invalid shape at index ${index}` };
703
+ }
704
+ if (!SUPPORTED_SHAPE_TYPES.has(shape.type)) {
705
+ return { valid: false, error: `Unsupported shape type at index ${index}: ${String(shape.type)}` };
706
+ }
707
+ }
637
708
  return {
638
709
  valid: true,
639
710
  name: data.name || 'Untitled',
@@ -648,29 +719,26 @@ export function uploadScene() {
648
719
  const input = document.createElement('input');
649
720
  input.type = 'file';
650
721
  input.accept = '.lixjson,.json';
651
- input.onchange = (e) => {
722
+ input.onchange = async (e) => {
652
723
  const file = e.target.files[0];
653
724
  if (!file) return resolve(false);
654
725
 
655
- const reader = new FileReader();
656
- reader.onload = (ev) => {
657
- try {
658
- const data = JSON.parse(ev.target.result);
659
- const validation = validateScene(data);
660
- if (!validation.valid) {
661
- console.error('[SceneSerializer] Invalid file:', validation.error);
662
- resolve({ success: false, error: validation.error });
663
- return;
664
- }
665
- resetSessionID(); // New session for loaded scene
666
- const result = loadScene(data);
667
- resolve({ success: result, validation });
668
- } catch (err) {
669
- console.error('[SceneSerializer] Failed to parse file:', err);
670
- resolve({ success: false, error: 'Failed to parse JSON file' });
726
+ try {
727
+ const json = await decodeSceneFile(await file.arrayBuffer());
728
+ const data = JSON.parse(json);
729
+ const validation = validateScene(data);
730
+ if (!validation.valid) {
731
+ console.error('[SceneSerializer] Invalid file:', validation.error);
732
+ resolve({ success: false, error: validation.error });
733
+ return;
671
734
  }
672
- };
673
- reader.readAsText(file);
735
+ resetSessionID(); // New session for loaded scene
736
+ const result = loadScene(data);
737
+ resolve({ success: result, validation });
738
+ } catch (err) {
739
+ console.error('[SceneSerializer] Failed to parse file:', err);
740
+ resolve({ success: false, error: err.message || 'Failed to parse .lixjson file' });
741
+ }
674
742
  };
675
743
  input.click();
676
744
  });
@@ -808,12 +876,15 @@ export function exportAsPDF() {
808
876
  // ============================================================
809
877
  // RESET: Clear the entire canvas
810
878
  // ============================================================
811
- export function resetCanvas() {
879
+ export function resetCanvas({ undoable = true, clearStorage = true, clearHistory = true, workspaceName = 'Untitled' } = {}) {
812
880
  const svgEl = window.svg;
813
881
  if (!svgEl) return;
814
882
 
815
883
  // Remove all shape DOM elements + frame clipGroups/clipPaths
816
884
  const existingShapes = window.shapes || [];
885
+ const resetSnapshot = undoable && existingShapes.length > 0
886
+ ? saveScene(workspaceName)
887
+ : null;
817
888
  existingShapes.forEach(shape => {
818
889
  if (shape.shapeName === 'frame') {
819
890
  if (shape.clipGroup && shape.clipGroup.parentNode) {
@@ -848,16 +919,28 @@ export function resetCanvas() {
848
919
  window.disableAllSideBars();
849
920
  }
850
921
 
851
- // Clear auto-save (both legacy and session-scoped keys)
852
- try {
853
- localStorage.removeItem('lixsketch-autosave');
854
- localStorage.removeItem('lixsketch-autosave-meta');
855
- const sid = window.__sessionID;
856
- if (sid) {
857
- localStorage.removeItem(`lixsketch-autosave-${sid}`);
858
- localStorage.removeItem(`lixsketch-autosave-meta-${sid}`);
859
- }
860
- } catch (_) {}
922
+ if (clearStorage) {
923
+ // Clear auto-save (both legacy and session-scoped keys)
924
+ try {
925
+ localStorage.removeItem('lixsketch-autosave');
926
+ localStorage.removeItem('lixsketch-autosave-meta');
927
+ const sid = window.__sessionID;
928
+ if (sid) {
929
+ localStorage.removeItem(`lixsketch-autosave-${sid}`);
930
+ localStorage.removeItem(`lixsketch-autosave-meta-${sid}`);
931
+ }
932
+ } catch (_) {}
933
+ }
934
+
935
+ if (resetSnapshot) {
936
+ pushCanvasResetAction(
937
+ resetSnapshot,
938
+ (snapshot) => loadScene(snapshot),
939
+ () => resetCanvas({ undoable: false, clearStorage: true, clearHistory: false, workspaceName }),
940
+ );
941
+ } else if (clearHistory) {
942
+ clearUndoHistory();
943
+ }
861
944
 
862
945
  console.log('[SceneSerializer] Canvas reset');
863
946
  if (typeof window.__collabSceneChanged === 'function') {
@@ -2,7 +2,7 @@
2
2
  // Multi-selection system - copied from selection.js
3
3
 
4
4
  import { cleanupAttachments } from '../tools/arrowTool.js';
5
- import { pushCreateAction, pushTransformAction, pushFrameAttachmentAction, pushDeleteAction } from './UndoRedo.js';
5
+ import { beginUndoBatch, endUndoBatch, pushCreateAction, pushTransformAction, pushFrameAttachmentAction, pushDeleteAction } from './UndoRedo.js';
6
6
  import { calculateSnap, clearSnapGuides } from './SnapGuides.js';
7
7
  import { registerRotationAnchor } from './ScreenSpaceControls.js';
8
8
 
@@ -278,19 +278,21 @@ class MultiSelection {
278
278
  endPoint: { ...shape.endPoint },
279
279
  x: shape.x, y: shape.y,
280
280
  width: shape.width || 0, height: shape.height || 0,
281
- rotation: shape.rotation || 0
281
+ rotation: shape.rotation || 0,
282
+ parentFrame: shape.parentFrame || null
282
283
  };
283
284
  case 'circle':
284
- return { x: shape.x, y: shape.y, rx: shape.rx, ry: shape.ry, rotation: shape.rotation || 0, width: shape.width, height: shape.height };
285
+ return { x: shape.x, y: shape.y, rx: shape.rx, ry: shape.ry, rotation: shape.rotation || 0, width: shape.width, height: shape.height, parentFrame: shape.parentFrame || null };
285
286
  case 'freehandStroke':
286
- return { x: shape.x, y: shape.y, width: shape.width, height: shape.height, rotation: shape.rotation || 0, points: JSON.parse(JSON.stringify(shape.points)) };
287
+ return { x: shape.x, y: shape.y, width: shape.width, height: shape.height, rotation: shape.rotation || 0, points: JSON.parse(JSON.stringify(shape.points)), parentFrame: shape.parentFrame || null };
287
288
  default:
288
- return { x: shape.x || 0, y: shape.y || 0, width: shape.width || 0, height: shape.height || 0, rotation: shape.rotation || 0 };
289
+ return { x: shape.x || 0, y: shape.y || 0, width: shape.width || 0, height: shape.height || 0, rotation: shape.rotation || 0, parentFrame: shape.parentFrame || null };
289
290
  }
290
291
  }
291
292
 
292
293
  // Push undo actions for all shapes that changed
293
- _pushUndoForAll() {
294
+ _pushUndoForAll(existingBatch = null) {
295
+ const batch = existingBatch || beginUndoBatch();
294
296
  this.selectedShapes.forEach(shape => {
295
297
  const oldState = this._undoSnapshots.get(shape);
296
298
  if (!oldState) return;
@@ -305,6 +307,7 @@ class MultiSelection {
305
307
  }
306
308
  });
307
309
  this._undoSnapshots.clear();
310
+ if (!existingBatch) endUndoBatch(batch, 'multi-transform');
308
311
  }
309
312
 
310
313
  addShape(shape) {
@@ -1392,6 +1395,8 @@ createRotatedControls(angleDiff = 0) {
1392
1395
  shape.isSelected = true;
1393
1396
  });
1394
1397
 
1398
+ const undoBatch = beginUndoBatch();
1399
+
1395
1400
  // Re-parent any selected shape that now sits inside a different frame
1396
1401
  // (Issue #22, bug #9). The per-tool drag handlers (rectangleTool, etc.)
1397
1402
  // do this for single-shape drags, but a multi-selection drag bypassed
@@ -1418,11 +1423,6 @@ createRotatedControls(angleDiff = 0) {
1418
1423
  }
1419
1424
  if (target && typeof target.addShapeToFrame === 'function') {
1420
1425
  target.addShapeToFrame(shape);
1421
- if (typeof pushFrameAttachmentAction === 'function') {
1422
- pushFrameAttachmentAction(target, shape, 'attach', current);
1423
- }
1424
- } else if (current && typeof pushFrameAttachmentAction === 'function') {
1425
- pushFrameAttachmentAction(current, shape, 'detach', current);
1426
1426
  }
1427
1427
  });
1428
1428
  }
@@ -1430,7 +1430,8 @@ createRotatedControls(angleDiff = 0) {
1430
1430
  this.initialPositions.clear();
1431
1431
 
1432
1432
  // Push undo for all moved shapes
1433
- this._pushUndoForAll();
1433
+ this._pushUndoForAll(undoBatch);
1434
+ endUndoBatch(undoBatch, 'multi-move');
1434
1435
 
1435
1436
  // Refresh bounds and controls after finalizeMove may have changed positions
1436
1437
  this.updateControls();
@@ -1864,8 +1865,19 @@ document.addEventListener('keydown', (e) => {
1864
1865
  function deleteSelectedShapes() {
1865
1866
  if (multiSelection.selectedShapes.size === 0) return;
1866
1867
 
1868
+ if (
1869
+ Array.isArray(window.shapes) &&
1870
+ window.shapes.length > 0 &&
1871
+ multiSelection.selectedShapes.size >= window.shapes.length &&
1872
+ typeof window.__requestCanvasResetConfirmation === 'function'
1873
+ ) {
1874
+ window.__requestCanvasResetConfirmation();
1875
+ return;
1876
+ }
1877
+
1867
1878
  const shapesToDelete = Array.from(multiSelection.selectedShapes);
1868
1879
  multiSelection.clearSelection();
1880
+ const undoBatch = beginUndoBatch();
1869
1881
 
1870
1882
  // Issue #24 bug #3: when deleting a (regular, non-diagram) frame, its
1871
1883
  // children are released back to the canvas — capture them here so we
@@ -1876,6 +1888,8 @@ function deleteSelectedShapes() {
1876
1888
  shapesToDelete.forEach(shape => {
1877
1889
  const isFrame = shape.shapeName === 'frame';
1878
1890
  const isDiagramFrame = isFrame && !!shape._diagramType;
1891
+ const parentFrame = shape.parentFrame || null;
1892
+ const frameIndex = parentFrame?.containedShapes?.indexOf(shape) ?? -1;
1879
1893
 
1880
1894
  // Issue #24 bug #7: for a regular (non-diagram) frame, we don't
1881
1895
  // want destroy() — it empties containedShapes and tears down the
@@ -1938,14 +1952,15 @@ function deleteSelectedShapes() {
1938
1952
  // snapshot so undo can re-attach them.
1939
1953
  try {
1940
1954
  if (childSnapshot) {
1941
- pushDeleteAction(shape, { childSnapshot });
1955
+ pushDeleteAction(shape, { childSnapshot, shapeIndex: idx, parentFrame, frameIndex });
1942
1956
  } else {
1943
- pushDeleteAction(shape);
1957
+ pushDeleteAction(shape, { shapeIndex: idx, parentFrame, frameIndex });
1944
1958
  }
1945
1959
  } catch (err) {
1946
1960
  console.warn('[deleteSelectedShapes] pushDeleteAction failed:', err);
1947
1961
  }
1948
1962
  });
1963
+ endUndoBatch(undoBatch, 'multi-delete');
1949
1964
 
1950
1965
  // Bug #3 follow-through: auto-select the released children and switch
1951
1966
  // to the pointer tool so the user can immediately keep manipulating