@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
@@ -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-UEL2G6D2.js";
7
+ import "./chunk-DWEFPKDO.js";
8
+ import "./chunk-M7GAB24F.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-CSAFD7PW.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-G3DJQY65.js";
7
+ import "./chunk-DWEFPKDO.js";
8
+ import "./chunk-M7GAB24F.js";
9
9
  export {
10
10
  handleMouseDownRect,
11
11
  handleMouseMoveRect,
12
12
  handleMouseUpRect
13
13
  };
14
- //# sourceMappingURL=rectangleTool-EJKK6UTX.js.map
14
+ //# sourceMappingURL=rectangleTool-YPD2SIIZ.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-F56XUE5S.js";
10
+ import "./chunk-ULPIHUVC.js";
11
11
  import "./chunk-PAKA6HUX.js";
12
- import "./chunk-XYUFEIZR.js";
13
- import "./chunk-6GPEWDRB.js";
12
+ import "./chunk-DWEFPKDO.js";
13
+ import "./chunk-M7GAB24F.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-VQHMWEFJ.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.6",
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') {
@@ -1864,6 +1864,16 @@ document.addEventListener('keydown', (e) => {
1864
1864
  function deleteSelectedShapes() {
1865
1865
  if (multiSelection.selectedShapes.size === 0) return;
1866
1866
 
1867
+ if (
1868
+ Array.isArray(window.shapes) &&
1869
+ window.shapes.length > 0 &&
1870
+ multiSelection.selectedShapes.size >= window.shapes.length &&
1871
+ typeof window.__requestCanvasResetConfirmation === 'function'
1872
+ ) {
1873
+ window.__requestCanvasResetConfirmation();
1874
+ return;
1875
+ }
1876
+
1867
1877
  const shapesToDelete = Array.from(multiSelection.selectedShapes);
1868
1878
  multiSelection.clearSelection();
1869
1879
 
@@ -1938,9 +1948,9 @@ function deleteSelectedShapes() {
1938
1948
  // snapshot so undo can re-attach them.
1939
1949
  try {
1940
1950
  if (childSnapshot) {
1941
- pushDeleteAction(shape, { childSnapshot });
1951
+ pushDeleteAction(shape, { childSnapshot, shapeIndex: idx });
1942
1952
  } else {
1943
- pushDeleteAction(shape);
1953
+ pushDeleteAction(shape, { shapeIndex: idx });
1944
1954
  }
1945
1955
  } catch (err) {
1946
1956
  console.warn('[deleteSelectedShapes] pushDeleteAction failed:', err);
@@ -58,6 +58,7 @@ export function pushDeleteAction(shape, meta = null) {
58
58
  // when the frame is restored, instead of leaving an empty box.
59
59
  const action = { type: 'delete', shape };
60
60
  if (meta && meta.childSnapshot) action.childSnapshot = meta.childSnapshot;
61
+ if (Number.isInteger(meta?.shapeIndex)) action.shapeIndex = meta.shapeIndex;
61
62
  undoStack.push(action);
62
63
 
63
64
  // Clear redo stack when new action is performed
@@ -65,6 +66,30 @@ export function pushDeleteAction(shape, meta = null) {
65
66
  notifyCollaboration();
66
67
  }
67
68
 
69
+ export function pushCanvasResetAction(snapshot, restore, reset) {
70
+ if (!snapshot || typeof restore !== 'function' || typeof reset !== 'function') return;
71
+ // A reset is a new history boundary. Older per-shape actions reference
72
+ // objects that the scene loader will replace, so only the atomic reset
73
+ // snapshot remains undoable.
74
+ undoStack.length = 0;
75
+ redoStack.length = 0;
76
+ undoStack.push({ type: 'canvasReset', snapshot, restore, reset });
77
+ }
78
+
79
+ export function invalidateCanvasResetUndo() {
80
+ for (let index = undoStack.length - 1; index >= 0; index -= 1) {
81
+ if (undoStack[index]?.type === 'canvasReset') undoStack.splice(index, 1);
82
+ }
83
+ for (let index = redoStack.length - 1; index >= 0; index -= 1) {
84
+ if (redoStack[index]?.type === 'canvasReset') redoStack.splice(index, 1);
85
+ }
86
+ }
87
+
88
+ export function clearUndoHistory() {
89
+ undoStack.length = 0;
90
+ redoStack.length = 0;
91
+ }
92
+
68
93
  // Enhanced delete action to clean up attached arrows
69
94
  export function pushDeleteActionWithAttachments(shape) {
70
95
  let affectedArrows = [];
@@ -442,6 +467,15 @@ export function pushOptionsChangeAction(shape, oldOptions, newOptions = null) {
442
467
  notifyCollaboration();
443
468
  }
444
469
 
470
+ function applyOptionsSnapshot(shape, options) {
471
+ shape.options = { ...options };
472
+ if (shape.shapeName === 'frame') {
473
+ shape.fillColor = options.fillColor;
474
+ shape.fillStyle = options.fillStyle;
475
+ }
476
+ shape.draw();
477
+ }
478
+
445
479
  // Issue #34 bug #4: refresh the multi-selection outline + handles so it
446
480
  // follows the reverted geometry. Without this the selection rect stays
447
481
  // pinned to the pre-undo coordinates and the user sees a ghost rect
@@ -458,6 +492,13 @@ export function undo() {
458
492
  if (undoStack.length === 0) return;
459
493
  const action = undoStack.pop();
460
494
  try {
495
+ if (action.type === 'canvasReset') {
496
+ action.restore(action.snapshot);
497
+ redoStack.push(action);
498
+ notifyCollaboration();
499
+ return;
500
+ }
501
+
461
502
  if (action.type === 'frameTransform') {
462
503
  // Handle frame transformation undo
463
504
  action.shape.x = action.oldPos.x;
@@ -652,7 +693,12 @@ export function undo() {
652
693
  action.shape.restore();
653
694
  } else {
654
695
  // Handle other shape deletion undo
655
- shapes.push(action.shape);
696
+ if (shapes.indexOf(action.shape) === -1) {
697
+ const index = Number.isInteger(action.shapeIndex)
698
+ ? Math.min(Math.max(action.shapeIndex, 0), shapes.length)
699
+ : shapes.length;
700
+ shapes.splice(index, 0, action.shape);
701
+ }
656
702
  if (svg) {
657
703
  svg.appendChild(action.shape.group);
658
704
  }
@@ -959,8 +1005,7 @@ export function undo() {
959
1005
  action.shape.draw();
960
1006
  } else {
961
1007
  // Handle other shape options change undo
962
- action.shape.options = action.oldOptions;
963
- action.shape.draw();
1008
+ applyOptionsSnapshot(action.shape, action.oldOptions);
964
1009
  }
965
1010
  redoStack.push(action);
966
1011
  return;
@@ -975,6 +1020,13 @@ export function redo() {
975
1020
  if (redoStack.length === 0) return;
976
1021
  const action = redoStack.pop();
977
1022
  try {
1023
+ if (action.type === 'canvasReset') {
1024
+ action.reset();
1025
+ undoStack.push(action);
1026
+ notifyCollaboration();
1027
+ return;
1028
+ }
1029
+
978
1030
  if (action.type === 'frameTransform') {
979
1031
  // Handle frame transformation redo
980
1032
  action.shape.x = action.newPos.x;
@@ -1453,8 +1505,7 @@ export function redo() {
1453
1505
  action.shape.arrowCurveAmount = action.newOptions.arrowCurveAmount;
1454
1506
  action.shape.draw();
1455
1507
  } else {
1456
- action.shape.options = { ...action.newOptions };
1457
- action.shape.draw();
1508
+ applyOptionsSnapshot(action.shape, action.newOptions);
1458
1509
  }
1459
1510
  undoStack.push(action);
1460
1511
  return;
@@ -1484,6 +1535,9 @@ document.getElementById('redo')?.addEventListener('click', redo);
1484
1535
  // Expose globally for plain scripts
1485
1536
  window.pushCreateAction = pushCreateAction;
1486
1537
  window.pushDeleteAction = pushDeleteAction;
1538
+ window.pushCanvasResetAction = pushCanvasResetAction;
1539
+ window.invalidateCanvasResetUndo = invalidateCanvasResetUndo;
1540
+ window.clearUndoHistory = clearUndoHistory;
1487
1541
  window.pushDeleteActionWithAttachments = pushDeleteActionWithAttachments;
1488
1542
  window.pushTransformAction = pushTransformAction;
1489
1543
  window.pushOptionsChangeAction = pushOptionsChangeAction;