@elixpo/lixsketch 5.5.20 → 5.5.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. package/dist/react/{CopyPaste-KJZ34MIP.js → CopyPaste-FJN7K5LS.js} +39 -11
  2. package/dist/react/CopyPaste-FJN7K5LS.js.map +7 -0
  3. package/dist/react/{EventDispatcher-XDRQS7BD.js → EventDispatcher-O7HCQQNM.js} +13 -13
  4. package/dist/react/Frame-RLSMJJSK.js +11 -0
  5. package/dist/react/{GraphEngine-KQXHNTZQ.js → GraphEngine-MM6ALA2N.js} +105 -148
  6. package/dist/react/{GraphEngine-KQXHNTZQ.js.map → GraphEngine-MM6ALA2N.js.map} +2 -2
  7. package/dist/react/IconShape-7OPXAKLK.js +10 -0
  8. package/dist/react/ImageShape-LRNJQCF3.js +10 -0
  9. package/dist/react/Line-WWQFBNX5.js +10 -0
  10. package/dist/react/{SceneSerializer-VY3HY4PF.js → SceneSerializer-GSJR7ZHC.js} +32 -8
  11. package/dist/react/SceneSerializer-GSJR7ZHC.js.map +7 -0
  12. package/dist/react/{Selection-U2AR6NQS.js → Selection-ZL3G6IAO.js} +4 -4
  13. package/dist/react/{SketchEngine-ZFYUTKET.js → SketchEngine-BFLHJPCG.js} +21 -21
  14. package/dist/react/{UndoRedo-BZ6W4QTV.js → UndoRedo-NL7KLR5B.js} +2 -2
  15. package/dist/react/{arrowTool-LKFSZKXC.js → arrowTool-CNVGM6RX.js} +3 -3
  16. package/dist/react/{chunk-RFHMFWVR.js → chunk-2FMFOPGW.js} +5 -6
  17. package/dist/react/{chunk-RFHMFWVR.js.map → chunk-2FMFOPGW.js.map} +2 -2
  18. package/dist/react/{chunk-SBSM3GQD.js → chunk-2THKDSWW.js} +4 -4
  19. package/dist/react/{chunk-JOPQ27YH.js → chunk-4BLMNGTR.js} +3 -3
  20. package/dist/react/{chunk-C62SXFBL.js → chunk-4TAS67SL.js} +23 -22
  21. package/dist/react/chunk-4TAS67SL.js.map +7 -0
  22. package/dist/react/{chunk-7VBI7S7T.js → chunk-AH4PWNEB.js} +10 -11
  23. package/dist/react/chunk-AH4PWNEB.js.map +7 -0
  24. package/dist/react/{chunk-R5QXEVZ5.js → chunk-GC3KTQDA.js} +74 -15
  25. package/dist/react/chunk-GC3KTQDA.js.map +7 -0
  26. package/dist/react/{chunk-2V7G2PLX.js → chunk-GPIYXREE.js} +3 -3
  27. package/dist/react/{chunk-73VLPCBC.js → chunk-HFQZYCHJ.js} +3 -3
  28. package/dist/react/{chunk-C72OHQLE.js → chunk-ILGAFR56.js} +3 -3
  29. package/dist/react/{chunk-75G7XSZU.js → chunk-KVAQPCUH.js} +17 -3
  30. package/dist/react/chunk-KVAQPCUH.js.map +7 -0
  31. package/dist/react/{chunk-WEWMB35W.js → chunk-OFGJJKPH.js} +3 -3
  32. package/dist/react/{chunk-6G3ITAS5.js → chunk-SZD7DKYJ.js} +18 -9
  33. package/dist/react/chunk-SZD7DKYJ.js.map +7 -0
  34. package/dist/react/{chunk-MYYBFZD3.js → chunk-U3TB4KRS.js} +45 -6
  35. package/dist/react/chunk-U3TB4KRS.js.map +7 -0
  36. package/dist/react/{chunk-UQBT7F4K.js → chunk-V2QTYSBK.js} +2 -2
  37. package/dist/react/{chunk-2I3GH65Z.js → chunk-XFQECTTF.js} +44 -19
  38. package/dist/react/chunk-XFQECTTF.js.map +7 -0
  39. package/dist/react/{chunk-O64N5JPO.js → chunk-YF3TK7SC.js} +2 -2
  40. package/dist/react/{circleTool-RDOIMKKV.js → circleTool-KICNRQCI.js} +4 -4
  41. package/dist/react/{codeTool-VBDB2ES6.js → codeTool-NCQ73R6M.js} +4 -4
  42. package/dist/react/{frameTool-Z5ZDCX6H.js → frameTool-Y6KB67DO.js} +4 -4
  43. package/dist/react/{freehandTool-AAQCTMVM.js → freehandTool-MMULHCQ7.js} +4 -4
  44. package/dist/react/{iconTool-5WNDHI5T.js → iconTool-LSW2DYGB.js} +4 -4
  45. package/dist/react/{imageTool-ECPMT3BL.js → imageTool-2DSR32RX.js} +4 -4
  46. package/dist/react/index.js +141 -66
  47. package/dist/react/index.js.map +3 -3
  48. package/dist/react/{lineTool-XLOOZE3E.js → lineTool-N5I7IGT7.js} +4 -4
  49. package/dist/react/{rectangleTool-AEYDDJCA.js → rectangleTool-YUT7ZK2E.js} +4 -4
  50. package/dist/react/{textTool-5IJKTWVQ.js → textTool-IQOYPELP.js} +5 -5
  51. package/package.json +1 -1
  52. package/src/core/CopyPaste.js +34 -4
  53. package/src/core/GraphEngine.js +129 -185
  54. package/src/core/SceneSerializer.js +26 -0
  55. package/src/core/Selection.js +12 -11
  56. package/src/core/UndoRedo.js +47 -3
  57. package/src/react/components/canvas/MultiSelectActions.jsx +4 -1
  58. package/src/react/components/sidebars/IconSidebar.jsx +14 -23
  59. package/src/react/components/sidebars/ImageSidebar.jsx +47 -24
  60. package/src/react/store/useUIStore.js +71 -14
  61. package/src/shapes/Frame.js +19 -2
  62. package/src/shapes/IconShape.js +23 -25
  63. package/src/shapes/ImageShape.js +6 -2
  64. package/src/tools/frameTool.js +19 -9
  65. package/src/tools/iconTool.js +60 -25
  66. package/src/tools/imageTool.js +77 -13
  67. package/dist/react/CopyPaste-KJZ34MIP.js.map +0 -7
  68. package/dist/react/Frame-QLBAVLGW.js +0 -11
  69. package/dist/react/IconShape-32EYI2MA.js +0 -10
  70. package/dist/react/ImageShape-MYP2DVVH.js +0 -10
  71. package/dist/react/Line-FCCF2BOB.js +0 -10
  72. package/dist/react/SceneSerializer-VY3HY4PF.js.map +0 -7
  73. package/dist/react/chunk-2I3GH65Z.js.map +0 -7
  74. package/dist/react/chunk-6G3ITAS5.js.map +0 -7
  75. package/dist/react/chunk-75G7XSZU.js.map +0 -7
  76. package/dist/react/chunk-7VBI7S7T.js.map +0 -7
  77. package/dist/react/chunk-C62SXFBL.js.map +0 -7
  78. package/dist/react/chunk-MYYBFZD3.js.map +0 -7
  79. package/dist/react/chunk-R5QXEVZ5.js.map +0 -7
  80. /package/dist/react/{EventDispatcher-XDRQS7BD.js.map → EventDispatcher-O7HCQQNM.js.map} +0 -0
  81. /package/dist/react/{Frame-QLBAVLGW.js.map → Frame-RLSMJJSK.js.map} +0 -0
  82. /package/dist/react/{IconShape-32EYI2MA.js.map → IconShape-7OPXAKLK.js.map} +0 -0
  83. /package/dist/react/{ImageShape-MYP2DVVH.js.map → ImageShape-LRNJQCF3.js.map} +0 -0
  84. /package/dist/react/{Line-FCCF2BOB.js.map → Line-WWQFBNX5.js.map} +0 -0
  85. /package/dist/react/{Selection-U2AR6NQS.js.map → Selection-ZL3G6IAO.js.map} +0 -0
  86. /package/dist/react/{SketchEngine-ZFYUTKET.js.map → SketchEngine-BFLHJPCG.js.map} +0 -0
  87. /package/dist/react/{UndoRedo-BZ6W4QTV.js.map → UndoRedo-NL7KLR5B.js.map} +0 -0
  88. /package/dist/react/{arrowTool-LKFSZKXC.js.map → arrowTool-CNVGM6RX.js.map} +0 -0
  89. /package/dist/react/{chunk-SBSM3GQD.js.map → chunk-2THKDSWW.js.map} +0 -0
  90. /package/dist/react/{chunk-JOPQ27YH.js.map → chunk-4BLMNGTR.js.map} +0 -0
  91. /package/dist/react/{chunk-2V7G2PLX.js.map → chunk-GPIYXREE.js.map} +0 -0
  92. /package/dist/react/{chunk-73VLPCBC.js.map → chunk-HFQZYCHJ.js.map} +0 -0
  93. /package/dist/react/{chunk-C72OHQLE.js.map → chunk-ILGAFR56.js.map} +0 -0
  94. /package/dist/react/{chunk-WEWMB35W.js.map → chunk-OFGJJKPH.js.map} +0 -0
  95. /package/dist/react/{chunk-UQBT7F4K.js.map → chunk-V2QTYSBK.js.map} +0 -0
  96. /package/dist/react/{chunk-O64N5JPO.js.map → chunk-YF3TK7SC.js.map} +0 -0
  97. /package/dist/react/{circleTool-RDOIMKKV.js.map → circleTool-KICNRQCI.js.map} +0 -0
  98. /package/dist/react/{codeTool-VBDB2ES6.js.map → codeTool-NCQ73R6M.js.map} +0 -0
  99. /package/dist/react/{frameTool-Z5ZDCX6H.js.map → frameTool-Y6KB67DO.js.map} +0 -0
  100. /package/dist/react/{freehandTool-AAQCTMVM.js.map → freehandTool-MMULHCQ7.js.map} +0 -0
  101. /package/dist/react/{iconTool-5WNDHI5T.js.map → iconTool-LSW2DYGB.js.map} +0 -0
  102. /package/dist/react/{imageTool-ECPMT3BL.js.map → imageTool-2DSR32RX.js.map} +0 -0
  103. /package/dist/react/{lineTool-XLOOZE3E.js.map → lineTool-N5I7IGT7.js.map} +0 -0
  104. /package/dist/react/{rectangleTool-AEYDDJCA.js.map → rectangleTool-YUT7ZK2E.js.map} +0 -0
  105. /package/dist/react/{textTool-5IJKTWVQ.js.map → textTool-IQOYPELP.js.map} +0 -0
@@ -3,12 +3,12 @@ import {
3
3
  handleMouseDown,
4
4
  handleMouseMove,
5
5
  handleMouseUp
6
- } from "./chunk-C72OHQLE.js";
7
- import "./chunk-O64N5JPO.js";
8
- import "./chunk-MYYBFZD3.js";
6
+ } from "./chunk-ILGAFR56.js";
7
+ import "./chunk-YF3TK7SC.js";
8
+ import "./chunk-U3TB4KRS.js";
9
9
  export {
10
10
  handleMouseDown as handleMouseDownLine,
11
11
  handleMouseMove as handleMouseMoveLine,
12
12
  handleMouseUp as handleMouseUpLine
13
13
  };
14
- //# sourceMappingURL=lineTool-XLOOZE3E.js.map
14
+ //# sourceMappingURL=lineTool-N5I7IGT7.js.map
@@ -3,12 +3,12 @@ import {
3
3
  handleMouseDownRect,
4
4
  handleMouseMoveRect,
5
5
  handleMouseUpRect
6
- } from "./chunk-JOPQ27YH.js";
7
- import "./chunk-O64N5JPO.js";
8
- import "./chunk-MYYBFZD3.js";
6
+ } from "./chunk-4BLMNGTR.js";
7
+ import "./chunk-YF3TK7SC.js";
8
+ import "./chunk-U3TB4KRS.js";
9
9
  export {
10
10
  handleMouseDownRect,
11
11
  handleMouseMoveRect,
12
12
  handleMouseUpRect
13
13
  };
14
- //# sourceMappingURL=rectangleTool-AEYDDJCA.js.map
14
+ //# sourceMappingURL=rectangleTool-YUT7ZK2E.js.map
@@ -6,11 +6,11 @@ import {
6
6
  handleTextMouseMove,
7
7
  handleTextMouseUp,
8
8
  updateCodeToggleForShape
9
- } from "./chunk-SBSM3GQD.js";
10
- import "./chunk-73VLPCBC.js";
9
+ } from "./chunk-2THKDSWW.js";
10
+ import "./chunk-HFQZYCHJ.js";
11
11
  import "./chunk-PAKA6HUX.js";
12
- import "./chunk-O64N5JPO.js";
13
- import "./chunk-MYYBFZD3.js";
12
+ import "./chunk-YF3TK7SC.js";
13
+ import "./chunk-U3TB4KRS.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-5IJKTWVQ.js.map
22
+ //# sourceMappingURL=textTool-IQOYPELP.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elixpo/lixsketch",
3
- "version": "5.5.20",
3
+ "version": "5.5.21",
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",
@@ -147,7 +147,13 @@ function serializeShape(shape) {
147
147
  width: shape.width,
148
148
  height: shape.height,
149
149
  rotation: shape.rotation,
150
- href: el.getAttribute('href') || el.getAttributeNS('http://www.w3.org/1999/xlink', 'href') || ''
150
+ href: el.getAttribute('href') || el.getAttributeNS('http://www.w3.org/1999/xlink', 'href') || '',
151
+ storageProvider: el.getAttribute('data-storage-provider') || null,
152
+ storageCloudName: el.getAttribute('data-storage-cloud-name') || null,
153
+ frameType: shape._frameType || null,
154
+ graphData: shape._frameType === 'graph' && shape._graphData
155
+ ? cloneOptions(shape._graphData)
156
+ : null
151
157
  };
152
158
  }
153
159
 
@@ -383,11 +389,22 @@ function createShapeFromData(data, offsetX, offsetY) {
383
389
  imgEl.setAttribute('data-shape-height', data.height);
384
390
  imgEl.setAttribute('preserveAspectRatio', 'none');
385
391
  imgEl.setAttribute('style', 'cursor: pointer;');
392
+ if (data.storageProvider) imgEl.setAttribute('data-storage-provider', data.storageProvider);
393
+ if (data.storageCloudName) imgEl.setAttribute('data-storage-cloud-name', data.storageCloudName);
386
394
 
387
395
  // ImageShape constructor moves element into a group and appends to svg
388
396
  svgEl.appendChild(imgEl);
389
397
  const imageShape = new ImageShape(imgEl);
390
398
  imageShape.rotation = data.rotation;
399
+ if (data.frameType === 'graph' && data.graphData) {
400
+ if (typeof window.__hydrateGraphShape === 'function') {
401
+ window.__hydrateGraphShape(imageShape, data.graphData);
402
+ } else {
403
+ imageShape._frameType = 'graph';
404
+ imageShape._graphData = cloneOptions(data.graphData);
405
+ imageShape.element.setAttribute('data-graph-shape', 'true');
406
+ }
407
+ }
391
408
  return imageShape;
392
409
  }
393
410
 
@@ -605,15 +622,25 @@ function handlePasteEvent(e) {
605
622
  const rawDataUrl = ev.target.result;
606
623
  const isSvg = (blob.type || '').toLowerCase() === 'image/svg+xml';
607
624
  let placedDataUrl = rawDataUrl;
625
+ let placedSize = blob.size;
608
626
  if (!isSvg) {
609
627
  try {
610
628
  const compressed = await compressImage(rawDataUrl);
611
- if (compressed?.dataUrl) placedDataUrl = compressed.dataUrl;
629
+ if (compressed?.dataUrl) {
630
+ placedDataUrl = compressed.dataUrl;
631
+ placedSize = compressed.compressedSize || placedSize;
632
+ }
612
633
  } catch (err) {
613
634
  console.warn('[CopyPaste] Pre-placement compression failed, using raw:', err);
614
635
  }
615
636
  }
616
- placeImageFromDataUrl(placedDataUrl);
637
+ const limit = Number(window.__roomImageLimitBytes) || 2 * 1024 * 1024;
638
+ const personalStorage = window.__personalCloudinary?.connected && window.__personalCloudinary?.useForUploads;
639
+ if (!personalStorage && (window.__roomImageBytesUsed || 0) + placedSize > limit) {
640
+ alert(`Workspace image limit reached (${Math.round(limit / (1024 * 1024))} MB).`);
641
+ return;
642
+ }
643
+ placeImageFromDataUrl(placedDataUrl, placedSize);
617
644
  };
618
645
  reader.readAsDataURL(blob);
619
646
  return; // only handle first image
@@ -621,7 +648,7 @@ function handlePasteEvent(e) {
621
648
  }
622
649
  }
623
650
 
624
- function placeImageFromDataUrl(dataUrl) {
651
+ function placeImageFromDataUrl(dataUrl, sizeBytes = 0) {
625
652
  const svgEl = getSVGElement();
626
653
  if (!svgEl || !window.ImageShape) return;
627
654
 
@@ -648,6 +675,9 @@ function placeImageFromDataUrl(dataUrl) {
648
675
  imgEl.setAttribute('data-shape-height', displayH);
649
676
  imgEl.setAttribute('type', 'image');
650
677
  imgEl.setAttribute('preserveAspectRatio', 'none');
678
+ imgEl.__fileSize = sizeBytes;
679
+ imgEl.setAttribute('data-file-size', String(sizeBytes));
680
+ window.__roomImageBytesUsed = (window.__roomImageBytesUsed || 0) + sizeBytes;
651
681
 
652
682
  svgEl.appendChild(imgEl);
653
683
  const imageShape = new ImageShape(imgEl);
@@ -1,7 +1,10 @@
1
1
  /* eslint-disable */
2
2
  /**
3
- * GraphEngine - Bridge between graph rendering and the sketch canvas.
4
- * Creates Frames containing rendered graph SVG elements.
3
+ * GraphEngine - bridge between graph rendering and the sketch canvas.
4
+ *
5
+ * Canvas graphs use ImageShape with an SVG data URL. This keeps the graph a
6
+ * single crisp vector element while reusing the engine's established select,
7
+ * translate, resize, rotate, copy/paste, and frame-containment behaviour.
5
8
  */
6
9
 
7
10
  import { parseExpression, isValidExpression } from './GraphMathParser.js';
@@ -10,209 +13,150 @@ import { renderGraphSVG, renderGraphPreviewSVG, GRAPH_COLORS } from './GraphRend
10
13
  const NS = 'http://www.w3.org/2000/svg';
11
14
  const GRAPH_WIDTH = 600;
12
15
  const GRAPH_HEIGHT = 420;
16
+ const MAX_PLACED_SCREEN_WIDTH = 480;
17
+ const MAX_VIEWPORT_WIDTH_RATIO = 0.68;
18
+ const MAX_VIEWPORT_HEIGHT_RATIO = 0.58;
19
+
20
+ function cloneGraphData(equations, settings) {
21
+ return {
22
+ equations: (equations || []).map((equation) => ({
23
+ expression: equation.expression,
24
+ color: equation.color,
25
+ })),
26
+ settings: { ...(settings || {}) },
27
+ };
28
+ }
29
+
30
+ function graphSvgDataUrl(svgMarkup) {
31
+ return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svgMarkup)}`;
32
+ }
33
+
34
+ function refreshGraphImage(shape) {
35
+ if (!shape?._graphData || !shape.element) return false;
36
+ const svgMarkup = renderGraphSVG(shape._graphData.equations, {
37
+ ...shape._graphData.settings,
38
+ width: GRAPH_WIDTH,
39
+ height: GRAPH_HEIGHT,
40
+ });
41
+ if (!svgMarkup) return false;
42
+ const href = graphSvgDataUrl(svgMarkup);
43
+ shape.element.setAttribute('href', href);
44
+ shape.element.setAttribute('data-href', href);
45
+ return true;
46
+ }
47
+
48
+ /** Attach graph behaviour to a regular ImageShape, including restored and
49
+ * pasted graphs. The function is intentionally idempotent. */
50
+ function hydrateGraphShape(shape, graphData) {
51
+ if (!shape?.element || !graphData) return shape;
52
+ shape._frameType = 'graph';
53
+ shape._graphData = cloneGraphData(graphData.equations, graphData.settings);
54
+ shape.uploadStatus = 'done';
55
+ shape.element.setAttribute('data-graph-shape', 'true');
56
+ shape.element.setAttribute('preserveAspectRatio', 'none');
57
+ shape.element.style.cursor = 'move';
58
+ shape._refreshGraphImage = () => refreshGraphImage(shape);
59
+ shape.adaptToBackground = () => refreshGraphImage(shape);
60
+ return shape;
61
+ }
62
+
63
+ function selectOnlyGraph(shape) {
64
+ if (!shape) return;
65
+
66
+ if (window.multiSelection?.selectedShapes?.size) {
67
+ window.multiSelection.clearSelection();
68
+ }
69
+ if (window.currentShape && window.currentShape !== shape
70
+ && typeof window.currentShape.removeSelection === 'function') {
71
+ window.currentShape.removeSelection();
72
+ }
73
+
74
+ // Generated content always lands ready to manipulate. Use the store bridge
75
+ // so the toolbar and legacy engine flags change together.
76
+ window.__sketchStoreApi?.setActiveTool?.('select');
77
+ window.currentShape = shape;
78
+ shape.selectShape?.();
79
+ }
80
+
81
+ function placedGraphSize(viewBox) {
82
+ const zoom = Math.max(0.001, Number(window.currentZoom) || 1);
83
+ const byScreen = MAX_PLACED_SCREEN_WIDTH / zoom;
84
+ const byViewportWidth = viewBox.width * MAX_VIEWPORT_WIDTH_RATIO;
85
+ const byViewportHeight = viewBox.height * MAX_VIEWPORT_HEIGHT_RATIO * (GRAPH_WIDTH / GRAPH_HEIGHT);
86
+ const width = Math.max(80 / zoom, Math.min(GRAPH_WIDTH, byScreen, byViewportWidth, byViewportHeight));
87
+ return { width, height: width * (GRAPH_HEIGHT / GRAPH_WIDTH) };
88
+ }
13
89
 
14
90
  /**
15
- * Place a graph on the canvas inside a Frame.
91
+ * Place a graph as one editable vector-image shape, or update an existing
92
+ * graph in place without losing its canvas position, size, or rotation.
16
93
  */
17
- function renderGraphOnCanvas(equations, settings) {
18
- if (!equations || equations.length === 0) return false;
19
- if (!window.svg || !window.Frame) {
94
+ function renderGraphOnCanvas(equations, settings, existingShape = null) {
95
+ const validEquations = (equations || []).filter((equation) => equation?.expression?.trim());
96
+ if (validEquations.length === 0) return false;
97
+ if (!window.svg || !window.ImageShape) {
20
98
  console.error('[GraphEngine] Engine not initialized');
21
99
  return false;
22
100
  }
23
101
 
24
- // Generate full-size SVG
25
- const svgMarkup = renderGraphSVG(equations, {
26
- ...settings,
27
- width: GRAPH_WIDTH,
28
- height: GRAPH_HEIGHT,
29
- });
30
- if (!svgMarkup) return false;
102
+ const graphData = cloneGraphData(validEquations, settings);
31
103
 
32
- // Viewport center
33
- const vb = window.currentViewBox || { x: 0, y: 0, width: window.innerWidth, height: window.innerHeight };
34
- const vcx = vb.x + vb.width / 2;
35
- const vcy = vb.y + vb.height / 2;
36
-
37
- const frameW = GRAPH_WIDTH + 40;
38
- const frameH = GRAPH_HEIGHT + 40;
39
- const frameX = vcx - frameW / 2;
40
- const frameY = vcy - frameH / 2;
41
-
42
- // Build title from equations
43
- const eqLabels = equations
44
- .filter(eq => eq.expression && eq.expression.trim())
45
- .map(eq => eq.expression.trim())
46
- .slice(0, 3);
47
- const title = eqLabels.length > 0
48
- ? 'Graph: ' + eqLabels.join(', ')
49
- : 'Graph';
50
-
51
- // Phase 5 (issue #22): no wrapper frame. The graph is conceptually a
52
- // single rendering — axes, grid, curves all interlocked — so we don't
53
- // split it into per-curve shapes. Instead the graph becomes ONE
54
- // first-class shape on the canvas: a `graphShape` that implements the
55
- // shape API the engine relies on (contains / selectShape / etc.) so
56
- // it can be selected, dragged, attached to by arrows, picked up by
57
- // the rect-drag — exactly like a user-drawn shape.
58
- try {
59
- const parser = new DOMParser();
60
- const doc = parser.parseFromString(svgMarkup, 'image/svg+xml');
61
- const svgEl = doc.querySelector('svg');
62
- if (!svgEl) return false;
63
-
64
- const graphGroup = document.createElementNS(NS, 'g');
65
- graphGroup.setAttribute('data-type', 'graph-group');
66
- graphGroup.setAttribute('transform', `translate(${frameX + 20}, ${frameY + 20})`);
67
-
68
- // Copy defs (clip paths)
69
- const defs = svgEl.querySelector('defs');
70
- if (defs) {
71
- const defsClone = defs.cloneNode(true);
72
- window.svg.querySelector('defs')?.appendChild(defsClone.firstChild) ||
73
- window.svg.insertBefore(defsClone, window.svg.firstChild);
74
- }
104
+ if (existingShape?._frameType === 'graph' && existingShape.element) {
105
+ hydrateGraphShape(existingShape, graphData);
106
+ if (!refreshGraphImage(existingShape)) return false;
107
+ selectOnlyGraph(existingShape);
108
+ window.__adaptCanvasContrast?.(
109
+ window.getComputedStyle(window.svg).backgroundColor || '#15111f'
110
+ );
111
+ return true;
112
+ }
75
113
 
76
- while (svgEl.childNodes.length > 0) {
77
- const child = svgEl.childNodes[0];
78
- if (child.nodeName === 'defs') { svgEl.removeChild(child); continue; }
79
- graphGroup.appendChild(child);
114
+ const viewBox = window.currentViewBox || {
115
+ x: 0,
116
+ y: 0,
117
+ width: window.innerWidth,
118
+ height: window.innerHeight,
119
+ };
120
+ const size = placedGraphSize(viewBox);
121
+ const x = viewBox.x + (viewBox.width - size.width) / 2;
122
+ const y = viewBox.y + (viewBox.height - size.height) / 2;
123
+
124
+ try {
125
+ const image = document.createElementNS(NS, 'image');
126
+ image.setAttribute('x', x);
127
+ image.setAttribute('y', y);
128
+ image.setAttribute('width', size.width);
129
+ image.setAttribute('height', size.height);
130
+ image.setAttribute('data-shape-x', x);
131
+ image.setAttribute('data-shape-y', y);
132
+ image.setAttribute('data-shape-width', size.width);
133
+ image.setAttribute('data-shape-height', size.height);
134
+
135
+ const graphShape = hydrateGraphShape(new window.ImageShape(image), graphData);
136
+ if (!refreshGraphImage(graphShape)) {
137
+ graphShape.group?.remove();
138
+ return false;
80
139
  }
81
- window.svg.appendChild(graphGroup);
82
-
83
- const graphShape = {
84
- shapeName: 'graph', // first-class shapeName
85
- shapeID: `graph-${Date.now().toString(36)}-${Math.floor(Math.random() * 10000)}`,
86
- group: graphGroup,
87
- element: graphGroup,
88
- x: frameX + 20,
89
- y: frameY + 20,
90
- width: GRAPH_WIDTH,
91
- height: GRAPH_HEIGHT,
92
- rotation: 0,
93
- isSelected: false,
94
- _selectionRect: null,
95
- _graphData: {
96
- equations: equations.map(eq => ({ expression: eq.expression, color: eq.color })),
97
- settings: { ...settings },
98
- },
99
-
100
- // ── Shape API the engine relies on ────────────────────────
101
- contains(px, py) {
102
- return px >= this.x && px <= this.x + this.width
103
- && py >= this.y && py <= this.y + this.height;
104
- },
105
- move(dx, dy) {
106
- this.x += dx; this.y += dy;
107
- this.group.setAttribute('transform', `translate(${this.x}, ${this.y})`);
108
- this._updateSelectionRect();
109
- },
110
- selectShape() {
111
- this.isSelected = true;
112
- if (this._selectionRect) return;
113
- const r = document.createElementNS(NS, 'rect');
114
- r.setAttribute('fill', 'none');
115
- r.setAttribute('stroke', '#9b7bf7');
116
- r.setAttribute('stroke-width', '1.5');
117
- r.setAttribute('stroke-dasharray', '4 3');
118
- r.setAttribute('pointer-events', 'none');
119
- window.svg.appendChild(r);
120
- this._selectionRect = r;
121
- this._updateSelectionRect();
122
- },
123
- removeSelection() {
124
- this.isSelected = false;
125
- if (this._selectionRect && this._selectionRect.parentNode) {
126
- this._selectionRect.parentNode.removeChild(this._selectionRect);
127
- }
128
- this._selectionRect = null;
129
- },
130
- _updateSelectionRect() {
131
- if (!this._selectionRect) return;
132
- const pad = 4;
133
- this._selectionRect.setAttribute('x', this.x - pad);
134
- this._selectionRect.setAttribute('y', this.y - pad);
135
- this._selectionRect.setAttribute('width', this.width + pad * 2);
136
- this._selectionRect.setAttribute('height', this.height + pad * 2);
137
- },
138
- updateAttachedArrows() {
139
- if (typeof window.updateAttachedArrows === 'function') {
140
- window.updateAttachedArrows(this);
141
- }
142
- },
143
- adaptToBackground(background, foreground, muted) {
144
- const ensureContrast = window.__ensureCanvasContrast || ((color) => color);
145
-
146
- this.group.querySelectorAll('rect').forEach((rect) => {
147
- const fill = rect.getAttribute('fill');
148
- const stroke = rect.getAttribute('stroke');
149
- if (fill && fill !== 'none') rect.setAttribute('fill', background);
150
- if (stroke && stroke !== 'none') {
151
- rect.setAttribute('stroke', muted);
152
- rect.setAttribute('stroke-opacity', '0.45');
153
- }
154
- });
155
-
156
- this.group.querySelectorAll('line').forEach((line) => {
157
- const width = Number.parseFloat(line.getAttribute('stroke-width') || '1');
158
- line.setAttribute('stroke', foreground);
159
- line.setAttribute('stroke-opacity', width <= 0.5 ? '0.18' : '0.5');
160
- });
161
-
162
- this.group.querySelectorAll('path[stroke]').forEach((path) => {
163
- const original = path.dataset.graphColor || path.getAttribute('stroke');
164
- if (!original || original === 'none') return;
165
- path.dataset.graphColor = original;
166
- path.setAttribute('stroke', ensureContrast(original, background, 3));
167
- });
168
-
169
- this.group.querySelectorAll('circle[fill]').forEach((circle) => {
170
- const original = circle.dataset.graphColor || circle.getAttribute('fill');
171
- if (!original || original === 'none') return;
172
- circle.dataset.graphColor = original;
173
- circle.setAttribute('fill', ensureContrast(original, background, 3));
174
- });
175
-
176
- this.group.querySelectorAll('text').forEach((text) => {
177
- const original = text.dataset.graphColor || text.getAttribute('fill');
178
- if (original) text.dataset.graphColor = original;
179
- const isAxisLabel = !original || ['#8b949e', '#62627a'].includes(original.toLowerCase());
180
- text.setAttribute('fill', isAxisLabel
181
- ? muted
182
- : ensureContrast(original, background, 4.5));
183
- });
184
- },
185
- };
186
140
 
187
141
  window.shapes.push(graphShape);
188
- if (window.pushCreateAction) window.pushCreateAction(graphShape);
189
-
190
- // Auto-select so the user sees something landed.
191
- window.currentShape = graphShape;
192
- graphShape.selectShape();
142
+ window.pushCreateAction?.(graphShape);
143
+ selectOnlyGraph(graphShape);
193
144
  window.__adaptCanvasContrast?.(
194
- window.getComputedStyle(window.svg).backgroundColor || '#13171C'
145
+ window.getComputedStyle(window.svg).backgroundColor || '#15111f'
195
146
  );
196
- } catch (err) {
197
- console.error('[GraphEngine] SVG insertion failed:', err);
147
+ return true;
148
+ } catch (error) {
149
+ console.error('[GraphEngine] SVG insertion failed:', error);
198
150
  return false;
199
151
  }
200
-
201
- return true;
202
152
  }
203
153
 
204
- /**
205
- * Initialize the graph engine — expose bridge functions on window.
206
- */
154
+ /** Initialize graph bridges used by the modal and scene restoration. */
207
155
  export function initGraphEngine() {
208
- window.__graphPreview = (equations, settings) => {
209
- return renderGraphPreviewSVG(equations, settings);
210
- };
156
+ window.__graphPreview = (equations, settings) => renderGraphPreviewSVG(equations, settings);
211
157
  window.__graphRenderer = renderGraphOnCanvas;
212
- window.__graphParser = (expr) => {
213
- const fn = parseExpression(expr);
214
- return fn ? true : false;
215
- };
158
+ window.__hydrateGraphShape = hydrateGraphShape;
159
+ window.__graphParser = (expression) => Boolean(parseExpression(expression));
216
160
  window.__graphValidate = isValidExpression;
217
161
  window.__graphColors = GRAPH_COLORS;
218
162
  }
@@ -180,6 +180,14 @@ function serializeShape(shape) {
180
180
  width: shape.width, height: shape.height,
181
181
  rotation: shape.rotation,
182
182
  href: el.getAttribute('href') || el.getAttributeNS('http://www.w3.org/1999/xlink', 'href') || '',
183
+ sizeBytes: Number(el.__fileSize || el.getAttribute('data-file-size')) || 0,
184
+ cloudinaryId: el.getAttribute('data-cloudinary-id') || null,
185
+ storageProvider: el.getAttribute('data-storage-provider') || 'platform_cloudinary',
186
+ storageCloudName: el.getAttribute('data-storage-cloud-name') || null,
187
+ frameType: shape._frameType || null,
188
+ graphData: shape._frameType === 'graph' && shape._graphData
189
+ ? cloneOptions(shape._graphData)
190
+ : null,
183
191
  };
184
192
  }
185
193
 
@@ -300,10 +308,27 @@ function deserializeShape(data) {
300
308
  imgEl.setAttribute('height', data.height);
301
309
  imgEl.setAttribute('href', data.href);
302
310
  imgEl.setAttribute('preserveAspectRatio', 'none');
311
+ if (data.sizeBytes) {
312
+ imgEl.__fileSize = Number(data.sizeBytes) || 0;
313
+ imgEl.setAttribute('data-file-size', String(imgEl.__fileSize));
314
+ window.__roomImageBytesUsed = (window.__roomImageBytesUsed || 0) + imgEl.__fileSize;
315
+ }
316
+ if (data.cloudinaryId) imgEl.setAttribute('data-cloudinary-id', data.cloudinaryId);
317
+ if (data.storageProvider) imgEl.setAttribute('data-storage-provider', data.storageProvider);
318
+ if (data.storageCloudName) imgEl.setAttribute('data-storage-cloud-name', data.storageCloudName);
303
319
  svgEl.appendChild(imgEl);
304
320
  const shape = new ImageShape(imgEl);
305
321
  if (data.rotation) shape.rotation = data.rotation;
306
322
  if (data.shapeID) shape.shapeID = data.shapeID;
323
+ if (data.frameType === 'graph' && data.graphData) {
324
+ if (typeof window.__hydrateGraphShape === 'function') {
325
+ window.__hydrateGraphShape(shape, data.graphData);
326
+ } else {
327
+ shape._frameType = 'graph';
328
+ shape._graphData = cloneOptions(data.graphData);
329
+ shape.element.setAttribute('data-graph-shape', 'true');
330
+ }
331
+ }
307
332
  return shape;
308
333
  }
309
334
 
@@ -387,6 +412,7 @@ export function loadScene(sceneData) {
387
412
  // Clear current scene
388
413
  const svgEl = window.svg;
389
414
  if (!svgEl) return false;
415
+ window.__roomImageBytesUsed = 0;
390
416
 
391
417
  // Remove all existing shape DOM elements
392
418
  const existingShapes = window.shapes || [];
@@ -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 { pushTransformAction, pushFrameAttachmentAction, pushDeleteAction } from './UndoRedo.js';
5
+ import { pushCreateAction, pushTransformAction, pushFrameAttachmentAction, pushDeleteAction } from './UndoRedo.js';
6
6
  import { calculateSnap, clearSnapGuides } from './SnapGuides.js';
7
7
  import { registerRotationAnchor } from './ScreenSpaceControls.js';
8
8
 
@@ -1943,6 +1943,11 @@ function deleteSelectedShapes() {
1943
1943
  function frameSelectedShapes() {
1944
1944
  if (multiSelection.selectedShapes.size < 2) return;
1945
1945
 
1946
+ const shapesToFrame = Array.from(multiSelection.selectedShapes);
1947
+ // Frames cannot be nested through the Frame it action, and objects that
1948
+ // already belong to a frame must first be detached explicitly.
1949
+ if (shapesToFrame.some(shape => shape?.shapeName === 'frame' || shape?.parentFrame)) return;
1950
+
1946
1951
  const bounds = multiSelection.getBounds();
1947
1952
  if (!bounds) return;
1948
1953
 
@@ -1962,17 +1967,7 @@ function frameSelectedShapes() {
1962
1967
  const frame = new FrameClass(fx, fy, fw, fh);
1963
1968
  shapes.push(frame);
1964
1969
 
1965
- // Push undo action for frame creation
1966
- if (window.historyStack) {
1967
- window.historyStack.push({
1968
- type: window.ACTION_CREATE || 'create',
1969
- shape: frame,
1970
- shapeName: 'frame'
1971
- });
1972
- }
1973
-
1974
1970
  // Add each selected shape into the frame
1975
- const shapesToFrame = Array.from(multiSelection.selectedShapes);
1976
1971
  multiSelection.clearSelection();
1977
1972
 
1978
1973
  for (const shape of shapesToFrame) {
@@ -1996,6 +1991,12 @@ function frameSelectedShapes() {
1996
1991
  }
1997
1992
  }
1998
1993
 
1994
+ // Record after reordering so redo restores the same hit-test/layer index.
1995
+ pushCreateAction(frame, {
1996
+ frameCreation: true,
1997
+ containedShapes: shapesToFrame
1998
+ });
1999
+
1999
2000
  // Select the new frame
2000
2001
  if (typeof frame.selectFrame === 'function') {
2001
2002
  frame.selectFrame();
@@ -34,11 +34,17 @@ export function updateSelectedElement(element) {
34
34
  }
35
35
 
36
36
  // Call this after creating a new shape (e.g., Rectangle)
37
- export function pushCreateAction(shape) {
38
- undoStack.push({
37
+ export function pushCreateAction(shape, meta = null) {
38
+ const action = {
39
39
  type: 'create',
40
40
  shape: shape
41
- });
41
+ };
42
+ if (meta?.frameCreation && shape?.shapeName === 'frame') {
43
+ action.frameCreation = true;
44
+ action.containedShapes = [...(meta.containedShapes || shape.containedShapes || [])];
45
+ action.shapeIndex = Array.isArray(shapes) ? shapes.indexOf(shape) : -1;
46
+ }
47
+ undoStack.push(action);
42
48
 
43
49
  // Clear redo stack when new action is performed
44
50
  redoStack.length = 0;
@@ -567,6 +573,23 @@ export function undo() {
567
573
  }
568
574
 
569
575
  if (action.type === 'create') {
576
+ if (action.frameCreation && action.shape.shapeName === 'frame') {
577
+ // Treat a manually-created frame and its initial membership as one
578
+ // atomic action. Children remain on the canvas when the frame is
579
+ // undone and are clipped back into it on redo.
580
+ for (const child of action.containedShapes || []) {
581
+ if (child?.parentFrame === action.shape) action.shape.removeShapeFromFrame(child);
582
+ }
583
+ if (currentShape === action.shape) {
584
+ action.shape.removeSelection?.();
585
+ currentShape = null;
586
+ }
587
+ const idx = shapes.indexOf(action.shape);
588
+ if (idx !== -1) shapes.splice(idx, 1);
589
+ action.shape.group?.remove();
590
+ action.shape.clipGroup?.remove();
591
+ action.shape.clipPath?.remove();
592
+ } else
570
593
  if (action.shape.type === 'text') {
571
594
  // Handle text creation undo
572
595
  if (action.shape.element && action.shape.element.parentNode) {
@@ -1049,6 +1072,27 @@ export function redo() {
1049
1072
  }
1050
1073
 
1051
1074
  if (action.type === 'create') {
1075
+ if (action.frameCreation && action.shape.shapeName === 'frame') {
1076
+ const frame = action.shape;
1077
+ let defs = svg?.querySelector('defs');
1078
+ if (!defs && svg) {
1079
+ defs = document.createElementNS('http://www.w3.org/2000/svg', 'defs');
1080
+ svg.appendChild(defs);
1081
+ }
1082
+ if (defs && frame.clipPath && !frame.clipPath.parentNode) defs.appendChild(frame.clipPath);
1083
+ if (svg && frame.group && !frame.group.parentNode) svg.appendChild(frame.group);
1084
+ if (svg && frame.clipGroup && !frame.clipGroup.parentNode) svg.appendChild(frame.clipGroup);
1085
+ if (shapes.indexOf(frame) === -1) {
1086
+ const index = action.shapeIndex >= 0 ? Math.min(action.shapeIndex, shapes.length) : shapes.length;
1087
+ shapes.splice(index, 0, frame);
1088
+ }
1089
+ frame.containedShapes = [];
1090
+ frame.draw();
1091
+ frame.updateClipPath();
1092
+ for (const child of action.containedShapes || []) {
1093
+ if (child && shapes.includes(child)) frame.addShapeToFrame(child);
1094
+ }
1095
+ } else
1052
1096
  if (action.shape.type === 'text') {
1053
1097
  // Handle text creation redo
1054
1098
  if (svg) {