@elixpo/lixsketch 4.5.8
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.
- package/LICENSE +21 -0
- package/README.md +169 -0
- package/fonts/fonts.css +29 -0
- package/fonts/lixCode.ttf +0 -0
- package/fonts/lixDefault.ttf +0 -0
- package/fonts/lixDocs.ttf +0 -0
- package/fonts/lixFancy.ttf +0 -0
- package/fonts/lixFont.woff2 +0 -0
- package/package.json +49 -0
- package/src/SketchEngine.js +473 -0
- package/src/core/AIRenderer.js +1390 -0
- package/src/core/CopyPaste.js +655 -0
- package/src/core/EraserTrail.js +234 -0
- package/src/core/EventDispatcher.js +371 -0
- package/src/core/GraphEngine.js +150 -0
- package/src/core/GraphMathParser.js +231 -0
- package/src/core/GraphRenderer.js +255 -0
- package/src/core/LayerOrder.js +91 -0
- package/src/core/LixScriptParser.js +1299 -0
- package/src/core/MermaidFlowchartRenderer.js +475 -0
- package/src/core/MermaidSequenceParser.js +197 -0
- package/src/core/MermaidSequenceRenderer.js +479 -0
- package/src/core/ResizeCode.js +175 -0
- package/src/core/ResizeShapes.js +318 -0
- package/src/core/SceneSerializer.js +778 -0
- package/src/core/Selection.js +1861 -0
- package/src/core/SnapGuides.js +273 -0
- package/src/core/UndoRedo.js +1358 -0
- package/src/core/ZoomPan.js +258 -0
- package/src/core/ai-system-prompt.js +663 -0
- package/src/index.js +69 -0
- package/src/shapes/Arrow.js +1979 -0
- package/src/shapes/Circle.js +751 -0
- package/src/shapes/CodeShape.js +244 -0
- package/src/shapes/Frame.js +1460 -0
- package/src/shapes/FreehandStroke.js +724 -0
- package/src/shapes/IconShape.js +265 -0
- package/src/shapes/ImageShape.js +270 -0
- package/src/shapes/Line.js +738 -0
- package/src/shapes/Rectangle.js +794 -0
- package/src/shapes/TextShape.js +225 -0
- package/src/tools/arrowTool.js +581 -0
- package/src/tools/circleTool.js +619 -0
- package/src/tools/codeTool.js +2103 -0
- package/src/tools/eraserTool.js +131 -0
- package/src/tools/frameTool.js +241 -0
- package/src/tools/freehandTool.js +620 -0
- package/src/tools/iconTool.js +1344 -0
- package/src/tools/imageTool.js +1323 -0
- package/src/tools/laserTool.js +317 -0
- package/src/tools/lineTool.js +502 -0
- package/src/tools/rectangleTool.js +544 -0
- package/src/tools/textTool.js +1823 -0
- package/src/utils/imageCompressor.js +107 -0
|
@@ -0,0 +1,778 @@
|
|
|
1
|
+
/* eslint-disable */
|
|
2
|
+
/**
|
|
3
|
+
* SceneSerializer - Save and load .lixsketch scene files
|
|
4
|
+
*
|
|
5
|
+
* Format: JSON with metadata + serialized shapes array
|
|
6
|
+
* File extension: .lixsketch (actually JSON)
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { Rectangle } from '../shapes/Rectangle.js';
|
|
10
|
+
import { Circle } from '../shapes/Circle.js';
|
|
11
|
+
import { Line } from '../shapes/Line.js';
|
|
12
|
+
import { Arrow } from '../shapes/Arrow.js';
|
|
13
|
+
import { FreehandStroke } from '../shapes/FreehandStroke.js';
|
|
14
|
+
import { Frame } from '../shapes/Frame.js';
|
|
15
|
+
import { TextShape } from '../shapes/TextShape.js';
|
|
16
|
+
import { CodeShape } from '../shapes/CodeShape.js';
|
|
17
|
+
import { ImageShape } from '../shapes/ImageShape.js';
|
|
18
|
+
import { IconShape } from '../shapes/IconShape.js';
|
|
19
|
+
|
|
20
|
+
const FORMAT_VERSION = 1;
|
|
21
|
+
|
|
22
|
+
// Generate a unique session ID for each scene
|
|
23
|
+
let _sessionID = null;
|
|
24
|
+
export function getSessionID() {
|
|
25
|
+
if (!_sessionID) {
|
|
26
|
+
_sessionID = `lx-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
|
27
|
+
}
|
|
28
|
+
return _sessionID;
|
|
29
|
+
}
|
|
30
|
+
export function resetSessionID() {
|
|
31
|
+
_sessionID = null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function cloneOptions(options) {
|
|
35
|
+
return JSON.parse(JSON.stringify(options));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ============================================================
|
|
39
|
+
// SERIALIZE a single shape to plain data
|
|
40
|
+
// ============================================================
|
|
41
|
+
function serializeShape(shape) {
|
|
42
|
+
const base = {
|
|
43
|
+
shapeID: shape.shapeID,
|
|
44
|
+
parentFrame: shape.parentFrame ? shape.parentFrame.shapeID : null,
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
switch (shape.shapeName) {
|
|
48
|
+
case 'rectangle':
|
|
49
|
+
return {
|
|
50
|
+
...base,
|
|
51
|
+
type: 'rectangle',
|
|
52
|
+
x: shape.x, y: shape.y,
|
|
53
|
+
width: shape.width, height: shape.height,
|
|
54
|
+
rotation: shape.rotation,
|
|
55
|
+
options: cloneOptions(shape.options),
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
case 'circle':
|
|
59
|
+
return {
|
|
60
|
+
...base,
|
|
61
|
+
type: 'circle',
|
|
62
|
+
x: shape.x, y: shape.y,
|
|
63
|
+
rx: shape.rx, ry: shape.ry,
|
|
64
|
+
rotation: shape.rotation,
|
|
65
|
+
options: cloneOptions(shape.options),
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
case 'line':
|
|
69
|
+
return {
|
|
70
|
+
...base,
|
|
71
|
+
type: 'line',
|
|
72
|
+
startPoint: { x: shape.startPoint.x, y: shape.startPoint.y },
|
|
73
|
+
endPoint: { x: shape.endPoint.x, y: shape.endPoint.y },
|
|
74
|
+
controlPoint: shape.controlPoint ? { x: shape.controlPoint.x, y: shape.controlPoint.y } : null,
|
|
75
|
+
isCurved: shape.isCurved || false,
|
|
76
|
+
options: cloneOptions(shape.options),
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
case 'arrow': {
|
|
80
|
+
const data = {
|
|
81
|
+
...base,
|
|
82
|
+
type: 'arrow',
|
|
83
|
+
startPoint: { x: shape.startPoint.x, y: shape.startPoint.y },
|
|
84
|
+
endPoint: { x: shape.endPoint.x, y: shape.endPoint.y },
|
|
85
|
+
options: cloneOptions(shape.options),
|
|
86
|
+
arrowOutlineStyle: shape.arrowOutlineStyle,
|
|
87
|
+
arrowHeadStyle: shape.arrowHeadStyle,
|
|
88
|
+
arrowCurved: shape.arrowCurved,
|
|
89
|
+
arrowCurveAmount: shape.arrowCurveAmount,
|
|
90
|
+
};
|
|
91
|
+
if (shape.controlPoint1) data.controlPoint1 = { x: shape.controlPoint1.x, y: shape.controlPoint1.y };
|
|
92
|
+
if (shape.controlPoint2) data.controlPoint2 = { x: shape.controlPoint2.x, y: shape.controlPoint2.y };
|
|
93
|
+
// Serialize attachments by shapeID
|
|
94
|
+
if (shape.startAttachment) data.startAttachmentID = shape.startAttachment.shapeID;
|
|
95
|
+
if (shape.endAttachment) data.endAttachmentID = shape.endAttachment.shapeID;
|
|
96
|
+
return data;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
case 'freehandStroke':
|
|
100
|
+
return {
|
|
101
|
+
...base,
|
|
102
|
+
type: 'freehandStroke',
|
|
103
|
+
points: JSON.parse(JSON.stringify(shape.points)),
|
|
104
|
+
rotation: shape.rotation,
|
|
105
|
+
options: cloneOptions(shape.options),
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
case 'frame':
|
|
109
|
+
return {
|
|
110
|
+
...base,
|
|
111
|
+
type: 'frame',
|
|
112
|
+
x: shape.x, y: shape.y,
|
|
113
|
+
width: shape.width, height: shape.height,
|
|
114
|
+
rotation: shape.rotation,
|
|
115
|
+
frameName: shape.frameName,
|
|
116
|
+
fillStyle: shape.fillStyle || 'transparent',
|
|
117
|
+
fillColor: shape.fillColor || '#1e1e28',
|
|
118
|
+
gridSize: shape.gridSize || 20,
|
|
119
|
+
gridColor: shape.gridColor || 'rgba(255,255,255,0.06)',
|
|
120
|
+
options: cloneOptions(shape.options),
|
|
121
|
+
containedShapeIDs: shape.containedShapes
|
|
122
|
+
? Array.from(shape.containedShapes).map(s => s.shapeID)
|
|
123
|
+
: [],
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
case 'text': {
|
|
127
|
+
const group = shape.group;
|
|
128
|
+
return {
|
|
129
|
+
...base,
|
|
130
|
+
type: 'text',
|
|
131
|
+
x: shape.x, y: shape.y,
|
|
132
|
+
rotation: shape.rotation,
|
|
133
|
+
groupHTML: group.cloneNode(true).outerHTML,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
case 'code': {
|
|
138
|
+
const group = shape.group;
|
|
139
|
+
return {
|
|
140
|
+
...base,
|
|
141
|
+
type: 'code',
|
|
142
|
+
x: shape.x, y: shape.y,
|
|
143
|
+
rotation: shape.rotation,
|
|
144
|
+
groupHTML: group.cloneNode(true).outerHTML,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
case 'image': {
|
|
149
|
+
const el = shape.element;
|
|
150
|
+
return {
|
|
151
|
+
...base,
|
|
152
|
+
type: 'image',
|
|
153
|
+
x: shape.x, y: shape.y,
|
|
154
|
+
width: shape.width, height: shape.height,
|
|
155
|
+
rotation: shape.rotation,
|
|
156
|
+
href: el.getAttribute('href') || el.getAttributeNS('http://www.w3.org/1999/xlink', 'href') || '',
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
case 'icon': {
|
|
161
|
+
const el = shape.element;
|
|
162
|
+
return {
|
|
163
|
+
...base,
|
|
164
|
+
type: 'icon',
|
|
165
|
+
x: shape.x, y: shape.y,
|
|
166
|
+
width: shape.width, height: shape.height,
|
|
167
|
+
rotation: shape.rotation,
|
|
168
|
+
elementHTML: el.cloneNode(true).outerHTML,
|
|
169
|
+
viewboxWidth: parseFloat(el.getAttribute('data-viewbox-width')) || 24,
|
|
170
|
+
viewboxHeight: parseFloat(el.getAttribute('data-viewbox-height')) || 24,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
default:
|
|
175
|
+
console.warn('[SceneSerializer] Unknown shape type:', shape.shapeName);
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ============================================================
|
|
181
|
+
// DESERIALIZE: Create a shape from saved data
|
|
182
|
+
// ============================================================
|
|
183
|
+
function deserializeShape(data) {
|
|
184
|
+
const svgEl = window.svg;
|
|
185
|
+
if (!svgEl) return null;
|
|
186
|
+
const ns = 'http://www.w3.org/2000/svg';
|
|
187
|
+
|
|
188
|
+
switch (data.type) {
|
|
189
|
+
case 'rectangle': {
|
|
190
|
+
const shape = new Rectangle(data.x, data.y, data.width, data.height, data.options || {});
|
|
191
|
+
if (data.rotation) shape.rotation = data.rotation;
|
|
192
|
+
if (data.shapeID) shape.shapeID = data.shapeID;
|
|
193
|
+
return shape;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
case 'circle': {
|
|
197
|
+
const shape = new Circle(data.x, data.y, data.rx, data.ry, data.options || {});
|
|
198
|
+
if (data.rotation) shape.rotation = data.rotation;
|
|
199
|
+
if (data.shapeID) shape.shapeID = data.shapeID;
|
|
200
|
+
return shape;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
case 'line': {
|
|
204
|
+
const shape = new Line(data.startPoint, data.endPoint, data.options || {});
|
|
205
|
+
if (data.controlPoint) shape.controlPoint = data.controlPoint;
|
|
206
|
+
if (data.isCurved) shape.isCurved = data.isCurved;
|
|
207
|
+
if (data.shapeID) shape.shapeID = data.shapeID;
|
|
208
|
+
return shape;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
case 'arrow': {
|
|
212
|
+
const shape = new Arrow(data.startPoint, data.endPoint, data.options || {});
|
|
213
|
+
if (data.controlPoint1) shape.controlPoint1 = data.controlPoint1;
|
|
214
|
+
if (data.controlPoint2) shape.controlPoint2 = data.controlPoint2;
|
|
215
|
+
if (data.arrowOutlineStyle) shape.arrowOutlineStyle = data.arrowOutlineStyle;
|
|
216
|
+
if (data.arrowHeadStyle) shape.arrowHeadStyle = data.arrowHeadStyle;
|
|
217
|
+
if (data.arrowCurved) shape.arrowCurved = data.arrowCurved;
|
|
218
|
+
if (data.arrowCurveAmount) shape.arrowCurveAmount = data.arrowCurveAmount;
|
|
219
|
+
if (data.shapeID) shape.shapeID = data.shapeID;
|
|
220
|
+
return shape;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
case 'freehandStroke': {
|
|
224
|
+
const shape = new FreehandStroke(data.points, data.options || {});
|
|
225
|
+
if (data.rotation) shape.rotation = data.rotation;
|
|
226
|
+
if (data.shapeID) shape.shapeID = data.shapeID;
|
|
227
|
+
return shape;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
case 'frame': {
|
|
231
|
+
const frameOpts = { ...(data.options || {}), frameName: data.frameName || 'Frame' };
|
|
232
|
+
if (data.fillStyle) frameOpts.fillStyle = data.fillStyle;
|
|
233
|
+
if (data.fillColor) frameOpts.fillColor = data.fillColor;
|
|
234
|
+
if (data.gridSize) frameOpts.gridSize = data.gridSize;
|
|
235
|
+
if (data.gridColor) frameOpts.gridColor = data.gridColor;
|
|
236
|
+
const shape = new Frame(data.x, data.y, data.width, data.height, frameOpts);
|
|
237
|
+
if (data.rotation) shape.rotation = data.rotation;
|
|
238
|
+
if (data.shapeID) shape.shapeID = data.shapeID;
|
|
239
|
+
shape.draw();
|
|
240
|
+
return shape;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
case 'text': {
|
|
244
|
+
if (!data.groupHTML) return null;
|
|
245
|
+
const parser = new DOMParser();
|
|
246
|
+
const doc = parser.parseFromString(`<svg xmlns="${ns}">${data.groupHTML}</svg>`, 'image/svg+xml');
|
|
247
|
+
const group = doc.querySelector('g');
|
|
248
|
+
if (!group) return null;
|
|
249
|
+
const imported = svgEl.ownerDocument.importNode(group, true);
|
|
250
|
+
svgEl.appendChild(imported);
|
|
251
|
+
const shape = new TextShape(imported);
|
|
252
|
+
if (data.shapeID) shape.shapeID = data.shapeID;
|
|
253
|
+
return shape;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
case 'code': {
|
|
257
|
+
if (!data.groupHTML) return null;
|
|
258
|
+
const parser = new DOMParser();
|
|
259
|
+
const doc = parser.parseFromString(`<svg xmlns="${ns}">${data.groupHTML}</svg>`, 'image/svg+xml');
|
|
260
|
+
const group = doc.querySelector('g');
|
|
261
|
+
if (!group) return null;
|
|
262
|
+
const imported = svgEl.ownerDocument.importNode(group, true);
|
|
263
|
+
svgEl.appendChild(imported);
|
|
264
|
+
const shape = new CodeShape(imported);
|
|
265
|
+
if (data.shapeID) shape.shapeID = data.shapeID;
|
|
266
|
+
return shape;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
case 'image': {
|
|
270
|
+
const imgEl = document.createElementNS(ns, 'image');
|
|
271
|
+
imgEl.setAttribute('x', data.x);
|
|
272
|
+
imgEl.setAttribute('y', data.y);
|
|
273
|
+
imgEl.setAttribute('width', data.width);
|
|
274
|
+
imgEl.setAttribute('height', data.height);
|
|
275
|
+
imgEl.setAttribute('href', data.href);
|
|
276
|
+
imgEl.setAttribute('preserveAspectRatio', 'none');
|
|
277
|
+
svgEl.appendChild(imgEl);
|
|
278
|
+
const shape = new ImageShape(imgEl);
|
|
279
|
+
if (data.rotation) shape.rotation = data.rotation;
|
|
280
|
+
if (data.shapeID) shape.shapeID = data.shapeID;
|
|
281
|
+
return shape;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
case 'icon': {
|
|
285
|
+
if (!data.elementHTML) return null;
|
|
286
|
+
const parser = new DOMParser();
|
|
287
|
+
const doc = parser.parseFromString(data.elementHTML, 'image/svg+xml');
|
|
288
|
+
const svgIcon = doc.documentElement;
|
|
289
|
+
if (!svgIcon) return null;
|
|
290
|
+
const imported = svgEl.ownerDocument.importNode(svgIcon, true);
|
|
291
|
+
svgEl.appendChild(imported);
|
|
292
|
+
const shape = new IconShape(imported);
|
|
293
|
+
if (data.shapeID) shape.shapeID = data.shapeID;
|
|
294
|
+
return shape;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
default:
|
|
298
|
+
console.warn('[SceneSerializer] Unknown type:', data.type);
|
|
299
|
+
return null;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// ============================================================
|
|
304
|
+
// SAVE: Serialize entire scene to .lixsketch JSON
|
|
305
|
+
// ============================================================
|
|
306
|
+
export function saveScene(workspaceName = 'Untitled') {
|
|
307
|
+
const allShapes = window.shapes || [];
|
|
308
|
+
const serialized = [];
|
|
309
|
+
|
|
310
|
+
for (const shape of allShapes) {
|
|
311
|
+
const data = serializeShape(shape);
|
|
312
|
+
if (data) serialized.push(data);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const scene = {
|
|
316
|
+
format: 'lixsketch',
|
|
317
|
+
version: FORMAT_VERSION,
|
|
318
|
+
sessionID: getSessionID(),
|
|
319
|
+
name: workspaceName,
|
|
320
|
+
createdAt: new Date().toISOString(),
|
|
321
|
+
viewport: window.currentViewBox ? { ...window.currentViewBox } : null,
|
|
322
|
+
zoom: window.currentZoom || 1,
|
|
323
|
+
shapes: serialized,
|
|
324
|
+
};
|
|
325
|
+
|
|
326
|
+
return scene;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// ============================================================
|
|
330
|
+
// LOAD: Deserialize .lixsketch JSON and recreate scene
|
|
331
|
+
// ============================================================
|
|
332
|
+
export function loadScene(sceneData) {
|
|
333
|
+
if (!sceneData || sceneData.format !== 'lixsketch') {
|
|
334
|
+
console.error('[SceneSerializer] Invalid scene format');
|
|
335
|
+
return false;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// Clear current scene
|
|
339
|
+
const svgEl = window.svg;
|
|
340
|
+
if (!svgEl) return false;
|
|
341
|
+
|
|
342
|
+
// Remove all existing shape DOM elements
|
|
343
|
+
const existingShapes = window.shapes || [];
|
|
344
|
+
existingShapes.forEach(shape => {
|
|
345
|
+
// For frames, clean up clipGroup and clipPath too
|
|
346
|
+
if (shape.shapeName === 'frame') {
|
|
347
|
+
if (shape.clipGroup && shape.clipGroup.parentNode) {
|
|
348
|
+
shape.clipGroup.parentNode.removeChild(shape.clipGroup);
|
|
349
|
+
}
|
|
350
|
+
if (shape.clipPath && shape.clipPath.parentNode) {
|
|
351
|
+
shape.clipPath.parentNode.removeChild(shape.clipPath);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
if (shape.group && shape.group.parentNode) {
|
|
355
|
+
shape.group.parentNode.removeChild(shape.group);
|
|
356
|
+
} else if (shape.element && shape.element.parentNode) {
|
|
357
|
+
shape.element.parentNode.removeChild(shape.element);
|
|
358
|
+
}
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
window.shapes = [];
|
|
362
|
+
window.currentShape = null;
|
|
363
|
+
window.historyStack = [];
|
|
364
|
+
window.redoStack = [];
|
|
365
|
+
|
|
366
|
+
// Build ID -> shape map for frame containment and arrow attachments
|
|
367
|
+
const idMap = new Map();
|
|
368
|
+
|
|
369
|
+
// First pass: create all shapes (frames first to allow containment)
|
|
370
|
+
const frameData = sceneData.shapes.filter(s => s.type === 'frame');
|
|
371
|
+
const otherData = sceneData.shapes.filter(s => s.type !== 'frame');
|
|
372
|
+
|
|
373
|
+
// Create frames first
|
|
374
|
+
for (const data of frameData) {
|
|
375
|
+
const shape = deserializeShape(data);
|
|
376
|
+
if (shape) {
|
|
377
|
+
window.shapes.push(shape);
|
|
378
|
+
if (data.shapeID) idMap.set(data.shapeID, shape);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// Create all other shapes
|
|
383
|
+
for (const data of otherData) {
|
|
384
|
+
const shape = deserializeShape(data);
|
|
385
|
+
if (shape) {
|
|
386
|
+
window.shapes.push(shape);
|
|
387
|
+
if (data.shapeID) idMap.set(data.shapeID, shape);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// Second pass: restore frame containment
|
|
392
|
+
for (const data of frameData) {
|
|
393
|
+
const frame = idMap.get(data.shapeID);
|
|
394
|
+
if (frame && data.containedShapeIDs && data.containedShapeIDs.length > 0) {
|
|
395
|
+
for (const childID of data.containedShapeIDs) {
|
|
396
|
+
const child = idMap.get(childID);
|
|
397
|
+
if (!child) {
|
|
398
|
+
console.warn(`[SceneSerializer] Frame "${data.frameName}" references missing shape: ${childID}`);
|
|
399
|
+
continue;
|
|
400
|
+
}
|
|
401
|
+
try {
|
|
402
|
+
if (typeof frame.addShapeToFrame === 'function') {
|
|
403
|
+
frame.addShapeToFrame(child);
|
|
404
|
+
} else {
|
|
405
|
+
// Fallback: manually set containment
|
|
406
|
+
frame.containedShapes.push(child);
|
|
407
|
+
child.parentFrame = frame;
|
|
408
|
+
}
|
|
409
|
+
} catch (err) {
|
|
410
|
+
console.warn(`[SceneSerializer] Failed to restore containment for ${childID} in frame ${data.shapeID}:`, err);
|
|
411
|
+
// Fallback: at least set the reference
|
|
412
|
+
if (!frame.containedShapes.includes(child)) {
|
|
413
|
+
frame.containedShapes.push(child);
|
|
414
|
+
}
|
|
415
|
+
child.parentFrame = frame;
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// Third pass: restore arrow attachments
|
|
422
|
+
for (const data of sceneData.shapes) {
|
|
423
|
+
if (data.type === 'arrow') {
|
|
424
|
+
const arrow = idMap.get(data.shapeID);
|
|
425
|
+
if (!arrow) continue;
|
|
426
|
+
if (data.startAttachmentID) {
|
|
427
|
+
const target = idMap.get(data.startAttachmentID);
|
|
428
|
+
if (target && arrow.setStartAttachment) arrow.setStartAttachment(target);
|
|
429
|
+
}
|
|
430
|
+
if (data.endAttachmentID) {
|
|
431
|
+
const target = idMap.get(data.endAttachmentID);
|
|
432
|
+
if (target && arrow.setEndAttachment) arrow.setEndAttachment(target);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// Restore viewport
|
|
438
|
+
if (sceneData.viewport && svgEl) {
|
|
439
|
+
const vb = sceneData.viewport;
|
|
440
|
+
window.currentViewBox = { ...vb };
|
|
441
|
+
svgEl.setAttribute('viewBox', `${vb.x} ${vb.y} ${vb.width} ${vb.height}`);
|
|
442
|
+
}
|
|
443
|
+
if (sceneData.zoom) {
|
|
444
|
+
window.currentZoom = sceneData.zoom;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
console.log(`[SceneSerializer] Loaded ${window.shapes.length} shapes from "${sceneData.name}"`);
|
|
448
|
+
|
|
449
|
+
// Re-sync tool flags so shapes are interactable after restore
|
|
450
|
+
if (window.__sketchEngine && typeof window.__sketchEngine.setActiveTool === 'function') {
|
|
451
|
+
const store = window.__sketchStoreApi;
|
|
452
|
+
const currentTool = store ? store.getState().activeTool : 'select';
|
|
453
|
+
window.__sketchEngine.setActiveTool(currentTool);
|
|
454
|
+
} else {
|
|
455
|
+
// Fallback: ensure selection tool is active
|
|
456
|
+
window.isSelectionToolActive = true;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
return true;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// ============================================================
|
|
463
|
+
// DOWNLOAD: Trigger browser download of .lixsketch file
|
|
464
|
+
// ============================================================
|
|
465
|
+
export function downloadScene(workspaceName = 'Untitled') {
|
|
466
|
+
const scene = saveScene(workspaceName);
|
|
467
|
+
const json = JSON.stringify(scene, null, 2);
|
|
468
|
+
const blob = new Blob([json], { type: 'application/json' });
|
|
469
|
+
const url = URL.createObjectURL(blob);
|
|
470
|
+
|
|
471
|
+
const a = document.createElement('a');
|
|
472
|
+
a.href = url;
|
|
473
|
+
a.download = `${workspaceName.replace(/[^a-zA-Z0-9_-]/g, '_')}.lixjson`;
|
|
474
|
+
document.body.appendChild(a);
|
|
475
|
+
a.click();
|
|
476
|
+
document.body.removeChild(a);
|
|
477
|
+
URL.revokeObjectURL(url);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// ============================================================
|
|
481
|
+
// UPLOAD: Open file picker and load .lixsketch file
|
|
482
|
+
// ============================================================
|
|
483
|
+
// Validate scene JSON structure before loading
|
|
484
|
+
export function validateScene(data) {
|
|
485
|
+
if (!data || typeof data !== 'object') return { valid: false, error: 'Not a valid JSON object' };
|
|
486
|
+
if (data.format !== 'lixsketch') return { valid: false, error: 'Not a LixSketch scene file (missing format field)' };
|
|
487
|
+
if (!data.version || data.version > FORMAT_VERSION) return { valid: false, error: `Unsupported version: ${data.version}` };
|
|
488
|
+
if (!Array.isArray(data.shapes)) return { valid: false, error: 'Invalid scene: missing shapes array' };
|
|
489
|
+
return {
|
|
490
|
+
valid: true,
|
|
491
|
+
name: data.name || 'Untitled',
|
|
492
|
+
shapeCount: data.shapes.length,
|
|
493
|
+
sessionID: data.sessionID || null,
|
|
494
|
+
createdAt: data.createdAt || null,
|
|
495
|
+
};
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
export function uploadScene() {
|
|
499
|
+
return new Promise((resolve, reject) => {
|
|
500
|
+
const input = document.createElement('input');
|
|
501
|
+
input.type = 'file';
|
|
502
|
+
input.accept = '.lixjson,.json';
|
|
503
|
+
input.onchange = (e) => {
|
|
504
|
+
const file = e.target.files[0];
|
|
505
|
+
if (!file) return resolve(false);
|
|
506
|
+
|
|
507
|
+
const reader = new FileReader();
|
|
508
|
+
reader.onload = (ev) => {
|
|
509
|
+
try {
|
|
510
|
+
const data = JSON.parse(ev.target.result);
|
|
511
|
+
const validation = validateScene(data);
|
|
512
|
+
if (!validation.valid) {
|
|
513
|
+
console.error('[SceneSerializer] Invalid file:', validation.error);
|
|
514
|
+
resolve({ success: false, error: validation.error });
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
resetSessionID(); // New session for loaded scene
|
|
518
|
+
const result = loadScene(data);
|
|
519
|
+
resolve({ success: result, validation });
|
|
520
|
+
} catch (err) {
|
|
521
|
+
console.error('[SceneSerializer] Failed to parse file:', err);
|
|
522
|
+
resolve({ success: false, error: 'Failed to parse JSON file' });
|
|
523
|
+
}
|
|
524
|
+
};
|
|
525
|
+
reader.readAsText(file);
|
|
526
|
+
};
|
|
527
|
+
input.click();
|
|
528
|
+
});
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// ============================================================
|
|
532
|
+
// EXPORT as PNG
|
|
533
|
+
// ============================================================
|
|
534
|
+
export function exportAsPNG() {
|
|
535
|
+
const svgEl = window.svg;
|
|
536
|
+
if (!svgEl) return;
|
|
537
|
+
|
|
538
|
+
const clone = svgEl.cloneNode(true);
|
|
539
|
+
// Remove selection UI elements
|
|
540
|
+
const selectionEls = clone.querySelectorAll('[data-selection], .selection-handle, .resize-handle, .rotation-handle');
|
|
541
|
+
selectionEls.forEach(el => el.remove());
|
|
542
|
+
|
|
543
|
+
const svgData = new XMLSerializer().serializeToString(clone);
|
|
544
|
+
const canvas = document.createElement('canvas');
|
|
545
|
+
const vb = svgEl.viewBox.baseVal;
|
|
546
|
+
canvas.width = vb.width * 2; // 2x for retina
|
|
547
|
+
canvas.height = vb.height * 2;
|
|
548
|
+
const ctx = canvas.getContext('2d');
|
|
549
|
+
ctx.scale(2, 2);
|
|
550
|
+
|
|
551
|
+
const img = new Image();
|
|
552
|
+
const svgBlob = new Blob([svgData], { type: 'image/svg+xml;charset=utf-8' });
|
|
553
|
+
const url = URL.createObjectURL(svgBlob);
|
|
554
|
+
|
|
555
|
+
img.onload = () => {
|
|
556
|
+
// Draw dark background
|
|
557
|
+
ctx.fillStyle = '#121212';
|
|
558
|
+
ctx.fillRect(0, 0, vb.width, vb.height);
|
|
559
|
+
ctx.drawImage(img, 0, 0, vb.width, vb.height);
|
|
560
|
+
URL.revokeObjectURL(url);
|
|
561
|
+
|
|
562
|
+
canvas.toBlob(blob => {
|
|
563
|
+
const a = document.createElement('a');
|
|
564
|
+
a.href = URL.createObjectURL(blob);
|
|
565
|
+
a.download = 'lixsketch-export.png';
|
|
566
|
+
document.body.appendChild(a);
|
|
567
|
+
a.click();
|
|
568
|
+
document.body.removeChild(a);
|
|
569
|
+
}, 'image/png');
|
|
570
|
+
};
|
|
571
|
+
img.src = url;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// ============================================================
|
|
575
|
+
// COPY to clipboard as PNG
|
|
576
|
+
// ============================================================
|
|
577
|
+
export function copyAsPNG() {
|
|
578
|
+
const svgEl = window.svg;
|
|
579
|
+
if (!svgEl) return;
|
|
580
|
+
|
|
581
|
+
const clone = svgEl.cloneNode(true);
|
|
582
|
+
const selectionEls = clone.querySelectorAll('[data-selection], .selection-handle, .resize-handle, .rotation-handle');
|
|
583
|
+
selectionEls.forEach(el => el.remove());
|
|
584
|
+
|
|
585
|
+
const svgData = new XMLSerializer().serializeToString(clone);
|
|
586
|
+
const canvas = document.createElement('canvas');
|
|
587
|
+
const vb = svgEl.viewBox.baseVal;
|
|
588
|
+
canvas.width = vb.width * 2;
|
|
589
|
+
canvas.height = vb.height * 2;
|
|
590
|
+
const ctx = canvas.getContext('2d');
|
|
591
|
+
ctx.scale(2, 2);
|
|
592
|
+
|
|
593
|
+
const img = new Image();
|
|
594
|
+
const svgBlob = new Blob([svgData], { type: 'image/svg+xml;charset=utf-8' });
|
|
595
|
+
const url = URL.createObjectURL(svgBlob);
|
|
596
|
+
|
|
597
|
+
img.onload = () => {
|
|
598
|
+
ctx.fillStyle = '#121212';
|
|
599
|
+
ctx.fillRect(0, 0, vb.width, vb.height);
|
|
600
|
+
ctx.drawImage(img, 0, 0, vb.width, vb.height);
|
|
601
|
+
URL.revokeObjectURL(url);
|
|
602
|
+
|
|
603
|
+
canvas.toBlob(blob => {
|
|
604
|
+
if (!blob) return;
|
|
605
|
+
navigator.clipboard.write([
|
|
606
|
+
new ClipboardItem({ 'image/png': blob })
|
|
607
|
+
]).catch(err => console.warn('[SceneSerializer] Clipboard write failed:', err));
|
|
608
|
+
}, 'image/png');
|
|
609
|
+
};
|
|
610
|
+
img.src = url;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
// ============================================================
|
|
614
|
+
// COPY to clipboard as SVG
|
|
615
|
+
// ============================================================
|
|
616
|
+
export function copyAsSVG() {
|
|
617
|
+
const svgEl = window.svg;
|
|
618
|
+
if (!svgEl) return;
|
|
619
|
+
|
|
620
|
+
const clone = svgEl.cloneNode(true);
|
|
621
|
+
const selectionEls = clone.querySelectorAll('[data-selection], .selection-handle, .resize-handle, .rotation-handle');
|
|
622
|
+
selectionEls.forEach(el => el.remove());
|
|
623
|
+
|
|
624
|
+
const svgData = new XMLSerializer().serializeToString(clone);
|
|
625
|
+
navigator.clipboard.writeText(svgData)
|
|
626
|
+
.catch(err => console.warn('[SceneSerializer] Clipboard write failed:', err));
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
// ============================================================
|
|
630
|
+
// EXPORT as PDF (uses browser print)
|
|
631
|
+
// ============================================================
|
|
632
|
+
export function exportAsPDF() {
|
|
633
|
+
const svgEl = window.svg;
|
|
634
|
+
if (!svgEl) return;
|
|
635
|
+
|
|
636
|
+
const clone = svgEl.cloneNode(true);
|
|
637
|
+
const selectionEls = clone.querySelectorAll('[data-selection], .selection-handle, .resize-handle, .rotation-handle');
|
|
638
|
+
selectionEls.forEach(el => el.remove());
|
|
639
|
+
|
|
640
|
+
const svgData = new XMLSerializer().serializeToString(clone);
|
|
641
|
+
const printWindow = window.open('', '_blank');
|
|
642
|
+
printWindow.document.write(`
|
|
643
|
+
<!DOCTYPE html>
|
|
644
|
+
<html>
|
|
645
|
+
<head><title>LixSketch Export</title>
|
|
646
|
+
<style>
|
|
647
|
+
body { margin: 0; background: #121212; display: flex; align-items: center; justify-content: center; min-height: 100vh; }
|
|
648
|
+
svg { max-width: 100vw; max-height: 100vh; }
|
|
649
|
+
@media print { body { background: white; } }
|
|
650
|
+
</style>
|
|
651
|
+
</head>
|
|
652
|
+
<body>${svgData}</body>
|
|
653
|
+
</html>
|
|
654
|
+
`);
|
|
655
|
+
printWindow.document.close();
|
|
656
|
+
printWindow.focus();
|
|
657
|
+
setTimeout(() => printWindow.print(), 500);
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
// ============================================================
|
|
661
|
+
// RESET: Clear the entire canvas
|
|
662
|
+
// ============================================================
|
|
663
|
+
export function resetCanvas() {
|
|
664
|
+
const svgEl = window.svg;
|
|
665
|
+
if (!svgEl) return;
|
|
666
|
+
|
|
667
|
+
// Remove all shape DOM elements + frame clipGroups/clipPaths
|
|
668
|
+
const existingShapes = window.shapes || [];
|
|
669
|
+
existingShapes.forEach(shape => {
|
|
670
|
+
if (shape.shapeName === 'frame') {
|
|
671
|
+
if (shape.clipGroup && shape.clipGroup.parentNode) {
|
|
672
|
+
shape.clipGroup.parentNode.removeChild(shape.clipGroup);
|
|
673
|
+
}
|
|
674
|
+
if (shape.clipPath && shape.clipPath.parentNode) {
|
|
675
|
+
shape.clipPath.parentNode.removeChild(shape.clipPath);
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
if (shape.group && shape.group.parentNode) {
|
|
679
|
+
shape.group.parentNode.removeChild(shape.group);
|
|
680
|
+
} else if (shape.element && shape.element.parentNode) {
|
|
681
|
+
shape.element.parentNode.removeChild(shape.element);
|
|
682
|
+
}
|
|
683
|
+
});
|
|
684
|
+
|
|
685
|
+
// Remove selection UI
|
|
686
|
+
svgEl.querySelectorAll(
|
|
687
|
+
'.selection-outline, .resize-anchor, .rotation-anchor, [data-selection]'
|
|
688
|
+
).forEach(el => el.remove());
|
|
689
|
+
|
|
690
|
+
window.shapes = [];
|
|
691
|
+
window.currentShape = null;
|
|
692
|
+
window.historyStack = [];
|
|
693
|
+
window.redoStack = [];
|
|
694
|
+
|
|
695
|
+
if (typeof window.clearAllSelections === 'function') {
|
|
696
|
+
window.clearAllSelections();
|
|
697
|
+
}
|
|
698
|
+
if (typeof window.disableAllSideBars === 'function') {
|
|
699
|
+
window.disableAllSideBars();
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
// Clear auto-save (both legacy and session-scoped keys)
|
|
703
|
+
try {
|
|
704
|
+
localStorage.removeItem('lixsketch-autosave');
|
|
705
|
+
localStorage.removeItem('lixsketch-autosave-meta');
|
|
706
|
+
const sid = window.__sessionID;
|
|
707
|
+
if (sid) {
|
|
708
|
+
localStorage.removeItem(`lixsketch-autosave-${sid}`);
|
|
709
|
+
localStorage.removeItem(`lixsketch-autosave-meta-${sid}`);
|
|
710
|
+
}
|
|
711
|
+
} catch (_) {}
|
|
712
|
+
|
|
713
|
+
console.log('[SceneSerializer] Canvas reset');
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
// ============================================================
|
|
717
|
+
// FIND: Search for text content on the canvas
|
|
718
|
+
// ============================================================
|
|
719
|
+
export function findTextOnCanvas(query) {
|
|
720
|
+
const allShapes = window.shapes || [];
|
|
721
|
+
if (!query || query.trim() === '') return [];
|
|
722
|
+
|
|
723
|
+
const lowerQuery = query.toLowerCase();
|
|
724
|
+
const results = [];
|
|
725
|
+
|
|
726
|
+
for (const shape of allShapes) {
|
|
727
|
+
let textContent = '';
|
|
728
|
+
|
|
729
|
+
if (shape.shapeName === 'text' || shape.shapeName === 'code') {
|
|
730
|
+
const group = shape.group;
|
|
731
|
+
if (group) {
|
|
732
|
+
// Get all text content from SVG text/tspan elements
|
|
733
|
+
const textEls = group.querySelectorAll('text, tspan');
|
|
734
|
+
textEls.forEach(el => {
|
|
735
|
+
if (el.textContent) textContent += el.textContent + ' ';
|
|
736
|
+
});
|
|
737
|
+
// Also check foreignObject content
|
|
738
|
+
const foreignEls = group.querySelectorAll('foreignObject *');
|
|
739
|
+
foreignEls.forEach(el => {
|
|
740
|
+
if (el.textContent) textContent += el.textContent + ' ';
|
|
741
|
+
});
|
|
742
|
+
}
|
|
743
|
+
} else if (shape.shapeName === 'frame') {
|
|
744
|
+
textContent = shape.frameName || '';
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
textContent = textContent.trim();
|
|
748
|
+
if (textContent && textContent.toLowerCase().includes(lowerQuery)) {
|
|
749
|
+
results.push({
|
|
750
|
+
shape,
|
|
751
|
+
text: textContent,
|
|
752
|
+
type: shape.shapeName,
|
|
753
|
+
x: shape.x || 0,
|
|
754
|
+
y: shape.y || 0,
|
|
755
|
+
});
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
return results;
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
// ============================================================
|
|
763
|
+
// Initialize bridge for React components
|
|
764
|
+
// ============================================================
|
|
765
|
+
export function initSceneSerializer() {
|
|
766
|
+
window.__sceneSerializer = {
|
|
767
|
+
save: saveScene,
|
|
768
|
+
load: loadScene,
|
|
769
|
+
download: downloadScene,
|
|
770
|
+
upload: uploadScene,
|
|
771
|
+
exportPNG: exportAsPNG,
|
|
772
|
+
exportPDF: exportAsPDF,
|
|
773
|
+
copyAsPNG,
|
|
774
|
+
copyAsSVG,
|
|
775
|
+
resetCanvas,
|
|
776
|
+
findText: findTextOnCanvas,
|
|
777
|
+
};
|
|
778
|
+
}
|