sketchmark 2.1.6 → 2.1.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/bin/editor-ui.cjs CHANGED
@@ -1,51 +1,82 @@
1
- "use strict";
2
-
3
- const fs = require("node:fs");
4
- const path = require("node:path");
5
-
1
+ "use strict";
2
+
3
+ const fs = require("node:fs");
4
+ const path = require("node:path");
5
+
6
6
  function editorHtml(title, options = {}) {
7
7
  const apiBase = normalizeApiBase(options.apiBase || "/api");
8
8
  const mp4MuxerUrl = options.mp4MuxerUrl || "";
9
9
  const mp4MuxerSource = mp4MuxerUrl ? "" : resolveMp4MuxerSource(options.mp4MuxerSource);
10
10
  const serverExportFallback = options.serverExportFallback !== false;
11
+ const localDocumentControls = options.localDocumentControls === true;
12
+ const bootstrapScript = typeof options.bootstrapScript === "string" ? options.bootstrapScript : "";
11
13
  return `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Sketchmark Editor - ${escapeHtml(title)}</title><style>
12
14
  @font-face{font-family:'Roboto';src:url('/fonts/Roboto-Light.ttf') format('truetype');font-weight:300;font-style:normal;font-display:swap}
13
15
  @font-face{font-family:'Roboto';src:url('/fonts/Roboto-Regular.ttf') format('truetype');font-weight:400;font-style:normal;font-display:swap}
14
16
  @font-face{font-family:'Roboto';src:url('/fonts/Roboto-Bold.ttf') format('truetype');font-weight:700;font-style:normal;font-display:swap}
15
- html,body{margin:0;width:100%;height:100%;font:13px Roboto,Arial,sans-serif;background:#c0c0c0;color:#000;overflow:hidden}
16
- body{display:grid;grid-template-columns:240px 1fr 300px;grid-template-rows:1fr 165px;min-width:900px;overflow:hidden}
17
+ html,body{margin:0;width:100%;height:100%;font:13px Roboto,Arial,sans-serif;background:#c0c0c0;color:#000;overflow:hidden}
18
+ body{--tree-width:240px;display:grid;grid-template-columns:var(--tree-width) 6px minmax(0,1fr) 300px;grid-template-rows:1fr 165px;min-width:900px;overflow:hidden}
19
+ body.resizingSidebar{cursor:col-resize;user-select:none}
17
20
  button,input,select,textarea{font:13px Roboto,Arial,sans-serif}
18
21
  button{padding:3px 8px}
19
22
  input,select,textarea{box-sizing:border-box;width:100%}
20
23
  textarea{min-height:88px;padding:4px;resize:vertical}
21
- #tree,#inspector,#timeline{background:#f3f4f6;border:1px solid #c6ccd6;overflow:auto;padding:6px;scrollbar-width:none;-ms-overflow-style:none}
22
- #tree{grid-row:1/3}
23
- #stageWrap{position:relative;display:grid;place-items:center;min-width:0;min-height:0;padding:0;background:#fff;overflow:hidden;cursor:default}
24
- #stageWrap.panning{cursor:grabbing}
25
- #stage{display:grid;place-items:center;min-width:0;min-height:0}
26
- #stage svg{max-width:100%;max-height:calc(100vh - 190px);background:white;border:1px solid #333;overflow:visible}
27
- #viewportHud{position:absolute;right:12px;bottom:12px;display:flex;flex-direction:column;gap:6px;align-items:stretch;padding:6px;background:rgba(238,238,238,.95);border:1px solid #8f96a3;z-index:3}
24
+ #tree,#inspector,#timeline{background:#f3f4f6;border:1px solid #c6ccd6;overflow:auto;padding:6px;scrollbar-width:none;-ms-overflow-style:none}
25
+ #tree{grid-column:1;grid-row:1/3;display:flex;flex-direction:column;min-height:0;overflow:hidden}
26
+ #treeResizeHandle{grid-column:2;grid-row:1/3;background:#d1d5db;border-left:1px solid #aab2bf;border-right:1px solid #aab2bf;cursor:col-resize;z-index:5}
27
+ #treeResizeHandle:hover,body.resizingSidebar #treeResizeHandle{background:#9ca3af}
28
+ #stageWrap{position:relative;display:grid;place-items:center;min-width:0;min-height:0;padding:0;background:#fff;overflow:hidden;cursor:default}
29
+ #stageWrap{grid-column:3;grid-row:1}
30
+ #stageWrap.panning{cursor:grabbing}
31
+ #stage{display:grid;place-items:center;min-width:0;min-height:0;position:relative}
32
+ #stage svg{max-width:100%;max-height:calc(100vh - 190px);background:white;border:1px solid #333;overflow:visible}
33
+ ${localDocumentControls ? `.browserFileGrid{display:grid;gap:6px}
34
+ .browserFileActions{display:grid;grid-template-columns:1fr 1fr;gap:4px}
35
+ .browserFileInput{font-size:12px}
36
+ .browserStatus{font-size:11px;color:#374151;min-height:14px}` : ""}
37
+ #viewportHud{position:absolute;right:12px;bottom:12px;display:flex;flex-direction:column;gap:6px;align-items:stretch;padding:6px;background:rgba(238,238,238,.95);border:1px solid #8f96a3;z-index:3}
28
38
  #viewportHud button{padding:1px 8px;min-width:42px}
29
39
  #zoomLabel{min-width:42px;text-align:center;font-weight:bold;color:#111827}
30
- #timeline{grid-column:2/4}
40
+ #inspector{grid-column:4;grid-row:1}
41
+ #timeline{grid-column:3/5;grid-row:2}
31
42
  .row{display:grid;grid-template-columns:1fr 1fr;gap:6px;margin:4px 0}
32
43
  .row label{display:block}
33
44
  .colorField{display:grid;grid-template-columns:34px 1fr;gap:6px;align-items:center}
34
45
  .colorField input[type=color]{width:34px;height:28px;padding:0;border:1px solid #8f96a3;background:#fff;cursor:pointer}
35
46
  .stack{display:grid;gap:5px}.section{margin:0 0 10px}.label{display:block;font-weight:bold;margin:0 0 3px}
36
- .panelGroup{margin:0 0 8px;border:1px solid #c9cfdb;background:#f8fafc}
37
- .panelGroup > summary{list-style:none;cursor:pointer;padding:6px 8px;font-weight:bold;display:flex;justify-content:space-between;align-items:center;gap:8px}
38
- .panelGroup > summary::-webkit-details-marker{display:none}
39
- .panelGroup > summary::after{content:"+";font-weight:bold;color:#4b5563}
40
- .panelGroup[open] > summary::after{content:"-"}
41
- .panelGroup > summary .summaryMeta{font-size:11px;color:#4b5563;font-weight:normal;margin-left:auto}
42
- .panelBody{padding:6px 8px;border-top:1px solid #d8dee8}
43
- .subhead{display:block;font-size:11px;font-weight:bold;color:#374151;margin:6px 0 2px}
44
- .treeRow{display:grid;grid-template-columns:16px 20px 20px 1fr;gap:3px;align-items:center;margin:1px 0}
45
- .treePad{display:block;width:16px;height:20px}
46
- .treeCtl{height:20px;padding:0;border:1px solid #9aa1ad;background:#f8fafc;cursor:pointer;line-height:18px;font-size:11px}
47
- .treeCtl.active{background:#003399;color:#fff;border-color:#003399}
48
- .canvasError{color:#900;font-size:11px;min-height:14px}
47
+ .panelGroup{margin:0 0 8px;border:1px solid #c9cfdb;background:#f8fafc}
48
+ .panelGroup > summary{list-style:none;cursor:pointer;padding:6px 8px;font-weight:bold;display:flex;justify-content:space-between;align-items:center;gap:8px}
49
+ .panelGroup > summary::-webkit-details-marker{display:none}
50
+ .panelGroup > summary::after{content:"+";font-weight:bold;color:#4b5563}
51
+ .panelGroup[open] > summary::after{content:"-"}
52
+ .panelGroup > summary .summaryMeta{font-size:11px;color:#4b5563;font-weight:normal;margin-left:auto}
53
+ .panelBody{padding:6px 8px;border-top:1px solid #d8dee8}
54
+ #tree > .panelGroup{flex:0 0 auto}
55
+ #tree > .panelGroup[data-panel='tree-elements']{display:flex;flex-direction:column;flex:1 1 auto;min-height:0;margin-bottom:0}
56
+ #tree > .panelGroup[data-panel='tree-elements']:not([open]){flex:0 0 auto}
57
+ #tree > .panelGroup[data-panel='tree-elements'] > summary{flex:0 0 auto}
58
+ #tree > .panelGroup[data-panel='tree-elements'] > .panelBody{display:flex;flex-direction:column;flex:1 1 auto;min-height:0;overflow:hidden}
59
+ .elementsFixed{flex:0 0 auto}
60
+ .elementsTreeList{flex:1 1 auto;min-height:0;overflow:auto;padding-right:2px;scrollbar-width:none;-ms-overflow-style:none}
61
+ .subhead{display:block;font-size:11px;font-weight:bold;color:#374151;margin:6px 0 2px}
62
+ .treeRow{display:grid;grid-template-columns:18px 20px 20px 20px 1fr;gap:3px;align-items:center;margin:1px 0}
63
+ .treePad{display:block;width:18px;height:20px}
64
+ .treeCtl{height:20px;padding:0;border:1px solid #9aa1ad;background:#f8fafc;cursor:pointer;line-height:18px;font-size:11px}
65
+ .treeCtl.active{background:#003399;color:#fff;border-color:#003399}
66
+ .treeCtl:disabled{opacity:.35;cursor:default}
67
+ .treeFold{position:relative;border-color:transparent;background:transparent}
68
+ .treeFold:hover{background:#e5e7eb;border-color:#cbd5e1}
69
+ .treeFold::before{content:"";position:absolute;left:6px;top:5px;border-style:solid;border-width:5px 0 5px 7px;border-color:transparent transparent transparent #374151}
70
+ .treeFold.expanded::before{left:4px;top:7px;border-width:7px 5px 0 5px;border-color:#374151 transparent transparent transparent}
71
+ .layerBar{display:grid;gap:4px;margin:0 0 6px;padding:5px;border:1px solid #cbd5e1;background:#fff}
72
+ .layerMeta{font-size:11px;color:#374151;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
73
+ .layerActions{display:grid;grid-template-columns:repeat(5,1fr);gap:4px}
74
+ .layerActions button{padding:2px 0;min-width:0}
75
+ .treeDanger{color:#9f1239}
76
+ .treeDanger:hover:not(:disabled){background:#fee2e2;border-color:#f43f5e}
77
+ .insertBar{display:grid;grid-template-columns:1fr 34px;gap:4px;margin-bottom:6px}
78
+ .treeEmpty{font-size:11px;color:#555;padding:4px 6px}
79
+ .canvasError{color:#900;font-size:11px;min-height:14px}
49
80
  .treeBtn{display:block;width:100%;text-align:left;margin:0;border:1px solid transparent;background:#f8fafc;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;padding:2px 6px}
50
81
  .treeBtn.dim{opacity:0.6}
51
82
  .treeBtn.selected{background:#003399;color:white}.muted{color:#555}.track{border:1px solid #888;background:#ddd;padding:5px;margin:4px 0}
@@ -72,21 +103,22 @@ textarea{min-height:88px;padding:4px;resize:vertical}
72
103
  .curveModal{width:min(760px,calc(100vw - 32px));max-height:calc(100vh - 32px);overflow:auto;border:2px outset #ddd;background:#ececec;padding:8px;scrollbar-width:none;-ms-overflow-style:none}
73
104
  .curveModalBar{display:flex;justify-content:space-between;align-items:center;gap:8px;margin-bottom:8px}
74
105
  .curveModalContent .curvePanel{margin-top:0}
75
- #tree::-webkit-scrollbar,#inspector::-webkit-scrollbar,#timeline::-webkit-scrollbar,.curveModal::-webkit-scrollbar{width:0;height:0}
76
- #error{color:#900;min-height:18px;margin-top:6px}.tiny{font-size:11px;color:#444}.toolbar{display:grid;grid-template-columns:auto 1fr auto auto;gap:6px;align-items:center}
77
- .exportButtons{display:grid;grid-template-columns:1fr 1fr;gap:4px}
78
- .exportButtons button{width:100%;text-align:left;padding:4px 6px}
79
- .exportButtons button.exportWide{grid-column:1/3}
80
- </style></head><body><aside id="tree"></aside><main id="stageWrap"><div id="stage"></div><div id="viewportHud"><button id="zoomOut" type="button" title="Zoom out">-</button><button id="zoomIn" type="button" title="Zoom in">+</button><button id="zoomFit" type="button" title="Reset zoom and pan">Fit</button><span id="zoomLabel">100%</span></div></main><aside id="inspector"></aside><section id="timeline"></section><div id="curveModalBackdrop" class="modalBackdrop hidden"><div id="curveModal" class="curveModal" role="dialog" aria-modal="true" aria-label="Interpolation Graph"><div class="curveModalBar"><strong>Interpolation Graph</strong><button id="curveModalClose" type="button">Close</button></div><div id="curveModalContent" class="curveModalContent"></div></div></div><script>
81
- const tree = document.getElementById("tree");
82
- const stageWrap = document.getElementById("stageWrap");
106
+ #tree::-webkit-scrollbar,#elementsTree::-webkit-scrollbar,#inspector::-webkit-scrollbar,#timeline::-webkit-scrollbar,.curveModal::-webkit-scrollbar{width:0;height:0}
107
+ #error{color:#900;min-height:18px;margin-top:6px}.tiny{font-size:11px;color:#444}.toolbar{display:grid;grid-template-columns:auto 1fr auto auto;gap:6px;align-items:center}
108
+ .exportButtons{display:grid;grid-template-columns:1fr 1fr;gap:4px}
109
+ .exportButtons button{width:100%;text-align:left;padding:4px 6px}
110
+ .exportButtons button.exportWide{grid-column:1/3}
111
+ </style></head><body><aside id="tree"></aside><div id="treeResizeHandle" title="Resize elements panel"></div><main id="stageWrap"><div id="stage"></div><div id="viewportHud"><button id="zoomOut" type="button" title="Zoom out">-</button><button id="zoomIn" type="button" title="Zoom in">+</button><button id="zoomFit" type="button" title="Reset zoom and pan">Fit</button><span id="zoomLabel">100%</span></div></main><aside id="inspector"></aside><section id="timeline"></section><div id="curveModalBackdrop" class="modalBackdrop hidden"><div id="curveModal" class="curveModal" role="dialog" aria-modal="true" aria-label="Interpolation Graph"><div class="curveModalBar"><strong>Interpolation Graph</strong><button id="curveModalClose" type="button">Close</button></div><div id="curveModalContent" class="curveModalContent"></div></div></div>${bootstrapScript ? `<script>\n${bootstrapScript}\n</script>` : ""}<script>
112
+ const tree = document.getElementById("tree");
113
+ const treeResizeHandle = document.getElementById("treeResizeHandle");
114
+ const stageWrap = document.getElementById("stageWrap");
83
115
  const stage = document.getElementById("stage");
84
116
  const zoomOut = document.getElementById("zoomOut");
85
117
  const zoomIn = document.getElementById("zoomIn");
86
118
  const zoomFit = document.getElementById("zoomFit");
87
119
  const zoomLabel = document.getElementById("zoomLabel");
88
120
  const inspector = document.getElementById("inspector");
89
- const timeline = document.getElementById("timeline");
121
+ const timeline = document.getElementById("timeline");
90
122
  const curveModalBackdrop = document.getElementById("curveModalBackdrop");
91
123
  const curveModal = document.getElementById("curveModal");
92
124
  const curveModalContent = document.getElementById("curveModalContent");
@@ -98,46 +130,186 @@ let currentTime = 0;
98
130
  let playing = false;
99
131
  let lastTick = 0;
100
132
  let playHandle = 0;
101
- let resolvedDoc = null;
102
- let drawScheduled = false;
103
- let drawInFlight = false;
104
- let drawQueued = false;
105
- let drag = null;
106
- let suppressClick = false;
133
+ let resolvedDoc = null;
134
+ let drawScheduled = false;
135
+ let drawInFlight = false;
136
+ let drawQueued = false;
137
+ let drag = null;
138
+ let suppressClick = false;
107
139
  const FONT_FAMILY_OPTIONS = [
108
140
  { label: "Roboto (Local)", value: "Roboto, Arial, sans-serif" }
109
141
  ];
110
- const FONT_WEIGHT_OPTIONS = [
111
- { label: "300", value: "300" },
112
- { label: "400", value: "400" },
113
- { label: "500", value: "500" },
114
- { label: "600", value: "600" },
115
- { label: "700", value: "700" }
116
- ];
117
- let collapsedGroups = new Set();
142
+ const FONT_WEIGHT_OPTIONS = [
143
+ { label: "300", value: "300" },
144
+ { label: "400", value: "400" },
145
+ { label: "500", value: "500" },
146
+ { label: "600", value: "600" },
147
+ { label: "700", value: "700" }
148
+ ];
149
+ const ELEMENT_PRESET_OPTIONS = [
150
+ { label: "Text", value: "text" },
151
+ { label: "Rectangle", value: "rectangle" },
152
+ { label: "Circle", value: "circle" },
153
+ { label: "Line", value: "line" },
154
+ { label: "Path", value: "path" },
155
+ { label: "Point", value: "point" },
156
+ { label: "Group", value: "group" }
157
+ ];
158
+ let collapsedGroups = new Set();
118
159
  let hiddenIds = new Set();
119
- let lockedIds = new Set();
120
- let parentById = Object.create(null);
121
- let childIdsById = Object.create(null);
122
- let typeById = Object.create(null);
123
- let sidebarCommitTimers = Object.create(null);
124
- let selectedSegment = null;
125
- let panelOpenState = Object.create(null);
126
- let viewport = { initialized: false, baseWidth: 1, baseHeight: 1, x: 0, y: 0, width: 1, height: 1 };
127
- let viewportPan = null;
128
- let spacePanActive = false;
129
- const API_BASE = ${scriptJson(apiBase)};
130
- const EDITOR_TITLE = ${scriptJson(title || "sketchmark")};
160
+ let lockedIds = new Set();
161
+ let parentById = Object.create(null);
162
+ let childIdsById = Object.create(null);
163
+ let layerIndexById = Object.create(null);
164
+ let layerCountById = Object.create(null);
165
+ let typeById = Object.create(null);
166
+ let sidebarCommitTimers = Object.create(null);
167
+ let selectedSegment = null;
168
+ let panelOpenState = Object.create(null);
169
+ let insertPresetKind = "rectangle";
170
+ let viewport = { initialized: false, baseWidth: 1, baseHeight: 1, x: 0, y: 0, width: 1, height: 1 };
171
+ let viewportPan = null;
172
+ let spacePanActive = false;
173
+ const API_BASE = ${scriptJson(apiBase)};
174
+ const EDITOR_TITLE = ${scriptJson(title || "sketchmark")};
131
175
  const MP4_MUXER_URL = ${scriptJson(mp4MuxerUrl)};
132
176
  const MP4_MUXER_SOURCE = ${scriptJson(mp4MuxerSource)};
133
177
  const SERVER_EXPORT_FALLBACK = ${scriptJson(serverExportFallback)};
178
+ const LOCAL_DOCUMENT_CONTROLS = ${scriptJson(localDocumentControls)};
179
+ const TREE_WIDTH_KEY = "sketchmark.editor.treeWidth";
180
+ const DEFAULT_TREE_WIDTH = 240;
181
+ const MIN_TREE_WIDTH = 180;
182
+ const MAX_TREE_WIDTH = 620;
134
183
  let mp4MuxerObjectUrl = "";
184
+ let sidebarResize = null;
185
+
186
+ function browserStoragePanel() {
187
+ if (!LOCAL_DOCUMENT_CONTROLS) return "";
188
+ const api = window.__SKETCHMARK_BROWSER_API__;
189
+ const status = api && api.storageStatus ? api.storageStatus() : "Browser-local document";
190
+ const body =
191
+ "<div class='browserFileGrid'>" +
192
+ "<input id='browserImportFile' class='browserFileInput' type='file' accept='.json,.visual.json,application/json'>" +
193
+ "<div class='browserFileActions'><button id='browserSaveLocal' type='button'>Save local</button><button id='browserResetDocument' type='button'>Reset</button></div>" +
194
+ "<div id='browserStatus' class='browserStatus'>" + escapeText(status) + "</div>" +
195
+ "</div>";
196
+ return panelDetails("tree-browser", "Browser", body, { defaultOpen: true, meta: "local" });
197
+ }
198
+
199
+ function bindBrowserStoragePanel() {
200
+ if (!LOCAL_DOCUMENT_CONTROLS) return;
201
+ const api = window.__SKETCHMARK_BROWSER_API__;
202
+ const file = document.getElementById("browserImportFile");
203
+ if (file) {
204
+ file.onchange = async () => {
205
+ const selectedFile = file.files && file.files[0];
206
+ if (!selectedFile || !api || !api.replaceDocument) return;
207
+ try {
208
+ const text = await selectedFile.text();
209
+ await Promise.resolve(api.replaceDocument(JSON.parse(text), selectedFile.name.replace(/\\.visual\\.json$/i, "").replace(/\\.json$/i, "")));
210
+ selectedId = "";
211
+ currentTime = 0;
212
+ await load();
213
+ } catch (error) {
214
+ showError(error);
215
+ } finally {
216
+ file.value = "";
217
+ }
218
+ };
219
+ }
220
+ const save = document.getElementById("browserSaveLocal");
221
+ if (save) {
222
+ save.onclick = () => {
223
+ if (api && api.saveDocument) api.saveDocument();
224
+ showBrowserStorageStatus();
225
+ };
226
+ }
227
+ const reset = document.getElementById("browserResetDocument");
228
+ if (reset) {
229
+ reset.onclick = async () => {
230
+ if (!api || !api.resetDocument) return;
231
+ if (!window.confirm("Reset the local Sketchmark document?")) return;
232
+ try {
233
+ await Promise.resolve(api.resetDocument());
234
+ selectedId = "";
235
+ currentTime = 0;
236
+ await load();
237
+ } catch (error) {
238
+ showError(error);
239
+ }
240
+ };
241
+ }
242
+ showBrowserStorageStatus();
243
+ }
244
+
245
+ function showBrowserStorageStatus() {
246
+ const api = window.__SKETCHMARK_BROWSER_API__;
247
+ const box = document.getElementById("browserStatus");
248
+ if (box && api && api.storageStatus) box.textContent = api.storageStatus();
249
+ }
250
+
251
+ function initTreeResize() {
252
+ setTreeWidth(loadTreeWidth(), false);
253
+ if (!treeResizeHandle) return;
254
+ treeResizeHandle.addEventListener("pointerdown", beginTreeResize);
255
+ treeResizeHandle.addEventListener("dblclick", () => setTreeWidth(DEFAULT_TREE_WIDTH, true));
256
+ }
257
+
258
+ function beginTreeResize(event) {
259
+ sidebarResize = {
260
+ pointerId: event.pointerId,
261
+ startX: event.clientX,
262
+ startWidth: tree.getBoundingClientRect().width
263
+ };
264
+ document.body.classList.add("resizingSidebar");
265
+ treeResizeHandle.setPointerCapture?.(event.pointerId);
266
+ event.preventDefault();
267
+ }
268
+
269
+ function updateTreeResize(event) {
270
+ if (!sidebarResize) return;
271
+ setTreeWidth(sidebarResize.startWidth + event.clientX - sidebarResize.startX, false);
272
+ }
273
+
274
+ function endTreeResize() {
275
+ if (!sidebarResize) return;
276
+ sidebarResize = null;
277
+ document.body.classList.remove("resizingSidebar");
278
+ setTreeWidth(tree.getBoundingClientRect().width, true);
279
+ }
280
+
281
+ function loadTreeWidth() {
282
+ try {
283
+ const stored = Number(window.localStorage.getItem(TREE_WIDTH_KEY));
284
+ return Number.isFinite(stored) && stored > 0 ? stored : DEFAULT_TREE_WIDTH;
285
+ } catch {
286
+ return DEFAULT_TREE_WIDTH;
287
+ }
288
+ }
289
+
290
+ function setTreeWidth(width, persist) {
291
+ const next = clampTreeWidth(width);
292
+ document.body.style.setProperty("--tree-width", next + "px");
293
+ if (persist) {
294
+ try {
295
+ window.localStorage.setItem(TREE_WIDTH_KEY, String(Math.round(next)));
296
+ } catch {}
297
+ }
298
+ resizeElementsTree();
299
+ }
300
+
301
+ function clampTreeWidth(width) {
302
+ const value = Number(width);
303
+ const availableMax = Math.max(MIN_TREE_WIDTH, window.innerWidth - 520);
304
+ const max = Math.min(MAX_TREE_WIDTH, availableMax);
305
+ return Math.max(MIN_TREE_WIDTH, Math.min(max, Number.isFinite(value) ? value : DEFAULT_TREE_WIDTH));
306
+ }
135
307
 
136
308
  function apiPath(path) {
137
309
  return API_BASE + path;
138
310
  }
139
-
140
- curveModalClose.onclick = closeCurveModal;
311
+
312
+ curveModalClose.onclick = closeCurveModal;
141
313
  curveModalBackdrop.onclick = (event) => {
142
314
  if (event.target === curveModalBackdrop) closeCurveModal();
143
315
  };
@@ -145,16 +317,16 @@ curveModal.onclick = (event) => event.stopPropagation();
145
317
  zoomOut.onclick = () => zoomBy(1.12);
146
318
  zoomIn.onclick = () => zoomBy(1 / 1.12);
147
319
  zoomFit.onclick = () => resetViewport(true);
148
- async function api(path, options) {
320
+ async function api(path, options) {
149
321
  const response = await fetch(path, options || { cache: "no-store" });
150
322
  const data = await response.json();
151
323
  if (!data.ok) throw new Error(data.error || "Request failed.");
152
324
  return data;
153
325
  }
154
326
 
155
- async function load() {
156
- clearSidebarCommitTimers();
157
- const data = await api(apiPath("/document"));
327
+ async function load() {
328
+ clearSidebarCommitTimers();
329
+ const data = await api(apiPath("/document"));
158
330
  doc = data.document;
159
331
  refs = data.elements;
160
332
  rebuildElementIndex();
@@ -166,25 +338,25 @@ async function load() {
166
338
  requestDraw();
167
339
  }
168
340
 
169
- async function draw() {
170
- if (drawInFlight) {
171
- drawQueued = true;
172
- return;
173
- }
174
- drawInFlight = true;
175
- const time = currentTime;
176
- try {
341
+ async function draw() {
342
+ if (drawInFlight) {
343
+ drawQueued = true;
344
+ return;
345
+ }
346
+ drawInFlight = true;
347
+ const time = currentTime;
348
+ try {
177
349
  const data = await api(apiPath("/frame") + "?time=" + encodeURIComponent(time));
178
- resolvedDoc = data.resolved || null;
179
- stage.innerHTML = data.svg;
180
- const svg = stage.querySelector("svg");
181
- if (svg) {
182
- svg.style.overflow = "visible";
183
- applyViewportToSvg(svg, data.canvas || (doc && doc.canvas));
184
- } else {
185
- updateZoomLabel();
186
- }
187
- applyEditorFlagsToStage();
350
+ resolvedDoc = data.resolved || null;
351
+ stage.innerHTML = data.svg;
352
+ const svg = currentSvg();
353
+ if (svg) {
354
+ svg.style.overflow = "visible";
355
+ applyEditorFlagsToStage();
356
+ applyViewportToSvg(svg, data.canvas || (doc && doc.canvas));
357
+ } else {
358
+ updateZoomLabel();
359
+ }
188
360
  const selected = selectedId ? stage.querySelector("#" + cssId(selectedId)) : null;
189
361
  if (selected && !isElementHidden(selectedId) && !isElementLocked(selectedId)) {
190
362
  drawHandles(selected);
@@ -201,18 +373,18 @@ async function draw() {
201
373
  }
202
374
  }
203
375
 
204
- function requestDraw() {
205
- if (drawInFlight) {
206
- drawQueued = true;
207
- return;
376
+ function requestDraw() {
377
+ if (drawInFlight) {
378
+ drawQueued = true;
379
+ return;
208
380
  }
209
381
  if (drawScheduled) return;
210
382
  drawScheduled = true;
211
- requestAnimationFrame(() => {
212
- drawScheduled = false;
213
- draw().catch(showError);
214
- });
215
- }
383
+ requestAnimationFrame(() => {
384
+ drawScheduled = false;
385
+ draw().catch(showError);
386
+ });
387
+ }
216
388
 
217
389
  function canvasSize(canvas) {
218
390
  const width = Math.max(1, Number(canvas && canvas.width || 1));
@@ -237,13 +409,13 @@ function ensureViewportState(canvas, forceReset) {
237
409
  clampViewport();
238
410
  }
239
411
 
240
- function applyViewportToSvg(svg, canvas) {
241
- if (!svg) return;
242
- ensureViewportState(canvas, false);
243
- svg.setAttribute("viewBox", viewport.x.toFixed(3) + " " + viewport.y.toFixed(3) + " " + viewport.width.toFixed(3) + " " + viewport.height.toFixed(3));
244
- svg.setAttribute("preserveAspectRatio", "xMidYMid meet");
245
- updateZoomLabel();
246
- }
412
+ function applyViewportToSvg(svg, canvas) {
413
+ if (!svg) return;
414
+ ensureViewportState(canvas, false);
415
+ svg.setAttribute("viewBox", viewport.x.toFixed(3) + " " + viewport.y.toFixed(3) + " " + viewport.width.toFixed(3) + " " + viewport.height.toFixed(3));
416
+ svg.setAttribute("preserveAspectRatio", "xMidYMid meet");
417
+ updateZoomLabel();
418
+ }
247
419
 
248
420
  function updateZoomLabel() {
249
421
  if (!zoomLabel) return;
@@ -282,9 +454,9 @@ function zoomFactorFromWheel(event) {
282
454
  return Math.exp(dy * 0.002);
283
455
  }
284
456
 
285
- function currentSvg() {
286
- return stage.querySelector("svg");
287
- }
457
+ function currentSvg() {
458
+ return stage.querySelector("svg");
459
+ }
288
460
 
289
461
  function svgPointFromClient(svg, clientX, clientY) {
290
462
  if (!svg || !svg.getScreenCTM) return null;
@@ -416,49 +588,156 @@ function panelDetails(panelId, title, body, options) {
416
588
  "</details>";
417
589
  }
418
590
 
419
- function bindPanelStates(scope) {
420
- const root = scope || document;
421
- const panels = root.querySelectorAll("details[data-panel]");
422
- for (const panel of panels) {
423
- const panelId = panel.getAttribute("data-panel");
424
- if (!panelId) continue;
425
- panelOpenState[panelId] = panel.open;
426
- panel.ontoggle = () => {
427
- panelOpenState[panelId] = panel.open;
428
- };
429
- }
430
- }
431
-
432
- function renderTree() {
433
- const canvas = doc && doc.canvas ? doc.canvas : {};
591
+ function bindPanelStates(scope) {
592
+ const root = scope || document;
593
+ const panels = root.querySelectorAll("details[data-panel]");
594
+ for (const panel of panels) {
595
+ const panelId = panel.getAttribute("data-panel");
596
+ if (!panelId) continue;
597
+ panelOpenState[panelId] = panel.open;
598
+ panel.ontoggle = () => {
599
+ panelOpenState[panelId] = panel.open;
600
+ resizeElementsTree();
601
+ };
602
+ }
603
+ }
604
+
605
+ function elementInsertPanel() {
606
+ return "<div class='insertBar'><select id='insertPreset'>" + ELEMENT_PRESET_OPTIONS.map((item) =>
607
+ "<option value='" + escapeAttr(item.value) + "'" + (item.value === insertPresetKind ? " selected" : "") + ">" + escapeText(item.label) + "</option>"
608
+ ).join("") + "</select><button id='insertTopElement' type='button' title='Add element'>+</button></div>" + selectedLayerPanel();
609
+ }
610
+
611
+ function bindElementInsertControls() {
612
+ const preset = document.getElementById("insertPreset");
613
+ if (preset) {
614
+ preset.onchange = () => {
615
+ insertPresetKind = String(preset.value || "rectangle");
616
+ };
617
+ }
618
+ const add = document.getElementById("insertTopElement");
619
+ if (add) add.onclick = () => insertElement("");
620
+ bindLayerButton("layerBack", "back");
621
+ bindLayerButton("layerBackward", "backward");
622
+ bindLayerButton("layerForward", "forward");
623
+ bindLayerButton("layerFront", "front");
624
+ const remove = document.getElementById("layerDelete");
625
+ if (remove) remove.onclick = () => {
626
+ if (selectedId) deleteTreeElement(selectedId);
627
+ };
628
+ }
629
+
630
+ async function insertElement(parentId) {
631
+ try {
632
+ const data = await api(apiPath("/element"), {
633
+ method: "POST",
634
+ headers: { "content-type": "application/json" },
635
+ body: JSON.stringify({ preset: insertPresetKind, parentId: parentId || null })
636
+ });
637
+ doc = data.document;
638
+ refs = data.elements;
639
+ rebuildElementIndex();
640
+ if (parentId) collapsedGroups.delete(parentId);
641
+ selectedId = typeof data.insertedId === "string" ? data.insertedId : selectedId;
642
+ renderTree();
643
+ renderInspector();
644
+ renderTimeline();
645
+ requestDraw();
646
+ } catch (error) {
647
+ showError(error);
648
+ }
649
+ }
650
+
651
+ function selectedLayerPanel() {
652
+ const id = selectedId && findElement(selectedId) ? selectedId : "";
653
+ const index = id ? Number(layerIndexById[id]) : -1;
654
+ const count = id ? Number(layerCountById[id]) : 0;
655
+ const locked = id ? isElementLocked(id) : true;
656
+ const canMove = Boolean(id) && !locked && Number.isFinite(index) && Number.isFinite(count) && count > 1;
657
+ const label = id ? "Layer: " + id : "Layer";
658
+ return "<div class='layerBar'>" +
659
+ "<div class='layerMeta' title='" + escapeAttr(label) + "'>" + escapeText(label) + "</div>" +
660
+ "<div class='layerActions'>" +
661
+ layerActionButton("layerBack", "<<", "Send to back", !canMove || index <= 0) +
662
+ layerActionButton("layerBackward", "<", "Send backward", !canMove || index <= 0) +
663
+ layerActionButton("layerForward", ">", "Bring forward", !canMove || index >= count - 1) +
664
+ layerActionButton("layerFront", ">>", "Bring to front", !canMove || index >= count - 1) +
665
+ layerActionButton("layerDelete", "X", "Delete element/group", !id || locked, " treeDanger") +
666
+ "</div></div>";
667
+ }
668
+
669
+ function layerActionButton(id, label, title, disabled, className) {
670
+ return "<button id='" + escapeAttr(id) + "' class='treeCtl" + (className || "") + "' type='button' title='" + escapeAttr(title) + "'" + (disabled ? " disabled" : "") + ">" + escapeText(label) + "</button>";
671
+ }
672
+
673
+ function bindLayerButton(id, direction) {
674
+ const button = document.getElementById(id);
675
+ if (button) button.onclick = () => {
676
+ if (selectedId) reorderLayer(selectedId, direction);
677
+ };
678
+ }
679
+
680
+ async function reorderLayer(id, direction) {
681
+ try {
682
+ if (!ensureElementEditable(id)) return;
683
+ await mutate(apiPath("/reorder"), { id, direction }, { refreshTimeline: false });
684
+ } catch (error) {
685
+ showError(error);
686
+ }
687
+ }
688
+
689
+ async function deleteTreeElement(id) {
690
+ try {
691
+ if (!ensureElementEditable(id)) return;
692
+ const isGroup = typeById[id] === "group";
693
+ const message = "Delete '" + id + "'" + (isGroup ? " and its children" : "") + "?";
694
+ if (!window.confirm(message)) return;
695
+ await mutate(apiPath("/delete-element"), { id });
696
+ } catch (error) {
697
+ showError(error);
698
+ }
699
+ }
700
+
701
+ function renderTree() {
702
+ const canvas = doc && doc.canvas ? doc.canvas : {};
434
703
  const canvasSummary = Math.round(valueOr(canvas.width, 1)) + "x" + Math.round(valueOr(canvas.height, 1));
435
704
  const canvasBody =
436
705
  "<div class='row'><label>Width<input id='canvasWidth' type='number' step='1' min='1' value='" + escapeAttr(valueOr(canvas.width, 1)) + "'></label><label>Height<input id='canvasHeight' type='number' step='1' min='1' value='" + escapeAttr(valueOr(canvas.height, 1)) + "'></label></div>" +
437
706
  "<div class='row'>" + colorTextInput("Background", "canvasBackground", "", valueOr(canvas.background, ""), "", "#ffffff") + "<div></div></div>" +
438
707
  "<div class='row'><label>Duration<input id='canvasDuration' type='number' step='0.1' min='0' value='" + escapeAttr(valueOr(canvas.duration, "")) + "'></label><label>FPS<input id='canvasFps' type='number' step='1' min='1' value='" + escapeAttr(valueOr(canvas.fps, "")) + "'></label></div>" +
439
708
  "<div id='canvasError' class='canvasError'></div>";
440
- tree.innerHTML =
441
- panelDetails("tree-canvas", "Canvas", canvasBody, { defaultOpen: false, meta: canvasSummary }) +
442
- panelDetails("tree-elements", "Elements", "<div id='elementsTree'></div>", { defaultOpen: false, meta: refs.length + " items" });
443
- bindPanelStates(tree);
444
- bindCanvasInputs();
445
- const treeRoot = document.getElementById("elementsTree");
446
- if (!treeRoot) return;
447
- for (const ref of refs) {
448
- if (isInCollapsedBranch(ref.id)) continue;
449
- const row = document.createElement("div");
709
+ tree.innerHTML =
710
+ browserStoragePanel() +
711
+ panelDetails("tree-canvas", "Canvas", canvasBody, { defaultOpen: false, meta: canvasSummary }) +
712
+ panelDetails("tree-elements", "Elements", "<div class='elementsFixed'>" + elementInsertPanel() + "</div><div id='elementsTree' class='elementsTreeList'></div>", { defaultOpen: false, meta: refs.length + " items" });
713
+ bindPanelStates(tree);
714
+ bindCanvasInputs();
715
+ bindBrowserStoragePanel();
716
+ bindElementInsertControls();
717
+ const treeRoot = document.getElementById("elementsTree");
718
+ if (!treeRoot) return;
719
+ if (!refs.length) {
720
+ const empty = document.createElement("div");
721
+ empty.className = "treeEmpty";
722
+ empty.textContent = "No elements yet.";
723
+ treeRoot.appendChild(empty);
724
+ }
725
+ for (const ref of refs) {
726
+ if (isInCollapsedBranch(ref.id)) continue;
727
+ const row = document.createElement("div");
450
728
  row.className = "treeRow";
451
729
  row.style.paddingLeft = 8 + ref.depth * 14 + "px";
452
- const hasChildren = hasTreeChildren(ref.id) && typeById[ref.id] === "group";
453
- if (hasChildren) {
454
- const fold = document.createElement("button");
455
- fold.className = "treeCtl";
456
- fold.textContent = collapsedGroups.has(ref.id) ? "+" : "-";
457
- fold.title = collapsedGroups.has(ref.id) ? "Expand group" : "Collapse group";
458
- fold.onclick = (event) => {
459
- event.stopPropagation();
460
- toggleCollapse(ref.id);
461
- };
730
+ const hasChildren = hasTreeChildren(ref.id) && typeById[ref.id] === "group";
731
+ if (hasChildren) {
732
+ const isCollapsed = collapsedGroups.has(ref.id);
733
+ const fold = document.createElement("button");
734
+ fold.className = "treeCtl treeFold" + (isCollapsed ? " collapsed" : " expanded");
735
+ fold.title = isCollapsed ? "Expand group" : "Collapse group";
736
+ fold.setAttribute("aria-label", fold.title);
737
+ fold.onclick = (event) => {
738
+ event.stopPropagation();
739
+ toggleCollapse(ref.id);
740
+ };
462
741
  row.appendChild(fold);
463
742
  } else {
464
743
  const pad = document.createElement("span");
@@ -481,16 +760,48 @@ function renderTree() {
481
760
  lock.onclick = (event) => {
482
761
  event.stopPropagation();
483
762
  toggleLocked(ref.id);
484
- };
485
- row.appendChild(lock);
486
- const button = document.createElement("button");
487
- button.className = "treeBtn" + (ref.id === selectedId ? " selected" : "") + (isElementHidden(ref.id) ? " dim" : "");
763
+ };
764
+ row.appendChild(lock);
765
+ if (ref.type === "group") {
766
+ const add = document.createElement("button");
767
+ add.className = "treeCtl";
768
+ add.textContent = "+";
769
+ add.title = "Add element to group";
770
+ add.onclick = (event) => {
771
+ event.stopPropagation();
772
+ insertElement(ref.id);
773
+ };
774
+ row.appendChild(add);
775
+ } else {
776
+ const pad = document.createElement("span");
777
+ pad.className = "treePad";
778
+ row.appendChild(pad);
779
+ }
780
+ const button = document.createElement("button");
781
+ button.className = "treeBtn" + (ref.id === selectedId ? " selected" : "") + (isElementHidden(ref.id) ? " dim" : "");
488
782
  button.textContent = ref.id + " " + ref.type;
489
783
  button.onclick = () => select(ref.id);
490
- row.appendChild(button);
491
- treeRoot.appendChild(row);
492
- }
493
- }
784
+ row.appendChild(button);
785
+ treeRoot.appendChild(row);
786
+ }
787
+ resizeElementsTree();
788
+ }
789
+
790
+ function resizeElementsTree() {
791
+ const treeRoot = document.getElementById("elementsTree");
792
+ const panel = document.querySelector("[data-panel='tree-elements']");
793
+ if (!treeRoot || !panel || !panel.open) return;
794
+ const body = panel.querySelector(".panelBody");
795
+ const fixed = panel.querySelector(".elementsFixed");
796
+ if (!body || !fixed) return;
797
+ const treeRect = tree.getBoundingClientRect();
798
+ const bodyRect = body.getBoundingClientRect();
799
+ const fixedRect = fixed.getBoundingClientRect();
800
+ const styles = window.getComputedStyle(body);
801
+ const paddingBottom = Number.parseFloat(styles.paddingBottom || "0") || 0;
802
+ const available = treeRect.bottom - bodyRect.top - fixedRect.height - paddingBottom - 6;
803
+ treeRoot.style.maxHeight = Math.max(72, Math.floor(available)) + "px";
804
+ }
494
805
 
495
806
  function bindCanvasInputs() {
496
807
  const bind = (id, callback) => {
@@ -516,7 +827,7 @@ function scheduleCanvasCommit(property, reader) {
516
827
  const value = reader();
517
828
  if (value === undefined) return;
518
829
  await mutate(
519
- apiPath("/canvas"),
830
+ apiPath("/canvas"),
520
831
  { [property]: value },
521
832
  { refreshTree: false, refreshInspector: false, refreshTimeline: true }
522
833
  );
@@ -554,22 +865,28 @@ function showCanvasError(message) {
554
865
  if (box) box.textContent = message || "";
555
866
  }
556
867
 
557
- function rebuildElementIndex() {
558
- parentById = Object.create(null);
559
- childIdsById = Object.create(null);
560
- typeById = Object.create(null);
561
- const visit = (elements, parentId) => {
562
- for (const element of elements || []) {
563
- const id = element && element.id;
564
- const nextParent = id || parentId;
565
- if (id) {
566
- typeById[id] = element.type;
567
- parentById[id] = parentId || "";
568
- childIdsById[id] = childIdsById[id] || [];
569
- if (parentId) {
570
- childIdsById[parentId] = childIdsById[parentId] || [];
571
- childIdsById[parentId].push(id);
572
- }
868
+ function rebuildElementIndex() {
869
+ parentById = Object.create(null);
870
+ childIdsById = Object.create(null);
871
+ layerIndexById = Object.create(null);
872
+ layerCountById = Object.create(null);
873
+ typeById = Object.create(null);
874
+ const visit = (elements, parentId) => {
875
+ const list = elements || [];
876
+ for (let index = 0; index < list.length; index += 1) {
877
+ const element = list[index];
878
+ const id = element && element.id;
879
+ const nextParent = id || parentId;
880
+ if (id) {
881
+ typeById[id] = element.type;
882
+ parentById[id] = parentId || "";
883
+ layerIndexById[id] = index;
884
+ layerCountById[id] = list.length;
885
+ childIdsById[id] = childIdsById[id] || [];
886
+ if (parentId) {
887
+ childIdsById[parentId] = childIdsById[parentId] || [];
888
+ childIdsById[parentId].push(id);
889
+ }
573
890
  }
574
891
  if (element && element.type === "group") visit(element.children || [], nextParent);
575
892
  }
@@ -682,15 +999,15 @@ function deselect() {
682
999
  requestDraw();
683
1000
  }
684
1001
 
685
- function renderInspector() {
686
- const element = findElement(selectedId);
687
- const exportPanel = renderExportPanel();
688
- if (!element) {
689
- inspector.innerHTML = "<div class='muted'>Select an element.</div>" + exportPanel;
690
- bindPanelStates(inspector);
691
- bindExportButtons();
692
- return;
693
- }
1002
+ function renderInspector() {
1003
+ const element = findElement(selectedId);
1004
+ const exportPanel = renderExportPanel();
1005
+ if (!element) {
1006
+ inspector.innerHTML = "<div class='muted'>Select an element.</div>" + exportPanel;
1007
+ bindPanelStates(inspector);
1008
+ bindExportButtons();
1009
+ return;
1010
+ }
694
1011
  const displayElement = findResolvedElement(selectedId) || element;
695
1012
  const supportsPosition = ["path","point","text","image","group"].includes(element.type);
696
1013
  const supportsOrigin = ["path","text","image","group"].includes(element.type);
@@ -739,10 +1056,10 @@ function renderInspector() {
739
1056
  const sourceRows = element.type === "image" ? renderImageSourceRows(displayElement, lockDisabled) : "";
740
1057
  const structuredPaintRows = supportsPaint ? renderStructuredPaintRows(displayElement, "fill", lockDisabled) + renderStructuredPaintRows(displayElement, "stroke", lockDisabled) : "";
741
1058
  const selectedMeta = escapeText(element.type) + (hidden ? " | hidden" : "") + (locked ? " | locked" : "");
742
- const selectedRows =
743
- "<strong>" + escapeText(element.id || "") + "</strong>" +
744
- "<div class='muted'>" + selectedMeta + "</div>" +
745
- (locked ? "<div class='tiny'>Locked elements and groups cannot be edited from canvas or inspector.</div>" : "");
1059
+ const selectedRows =
1060
+ "<strong>" + escapeText(element.id || "") + "</strong>" +
1061
+ "<div class='muted'>" + selectedMeta + "</div>" +
1062
+ (locked ? "<div class='tiny'>Locked elements and groups cannot be edited from canvas or inspector.</div>" : "");
746
1063
  const transformRows =
747
1064
  "<div class='row'><label>X<input id='propX' type='number' step='1' value='" + valueOr(displayElement.x, 0) + "' " + positionDisabled + "></label><label>Y<input id='propY' type='number' step='1' value='" + valueOr(displayElement.y, 0) + "' " + positionDisabled + "></label></div>" +
748
1065
  "<div class='row'><label>Rotation<input id='propRotation' type='number' step='1' value='" + valueOr(displayElement.rotation, 0) + "' " + lockDisabled + "></label><label>Scale<input id='propScale' type='number' step='0.05' value='" + valueOr(displayElement.scale, 1) + "' " + lockDisabled + "></label></div>" +
@@ -753,22 +1070,22 @@ function renderInspector() {
753
1070
  paintRows +
754
1071
  paintHint;
755
1072
  const contentRows = pathRows + textRows + sourceRows;
756
- const keyframeRows =
757
- "<div class='row'><label>Time<input id='kfTime' type='number' step='0.05' value='" + currentTime.toFixed(2) + "'></label><div></div></div>" +
1073
+ const keyframeRows =
1074
+ "<div class='row'><label>Time<input id='kfTime' type='number' step='0.05' value='" + currentTime.toFixed(2) + "'></label><div></div></div>" +
758
1075
  "<p class='tiny'>Changing sidebar values updates keyframes at the current time.</p>" +
759
1076
  "<p class='tiny'>Interpolation curves are edited from timeline badges.</p>" +
760
- "<p class='tiny'>Drag to move. Use the square to scale and the round handle to rotate.</p>";
761
- inspector.innerHTML =
762
- panelDetails("inspector-selected", "Selected", selectedRows, { defaultOpen: true, meta: element.type }) +
763
- panelDetails("inspector-transform", "Transform", transformRows, { defaultOpen: false }) +
764
- panelDetails("inspector-appearance", "Appearance", appearanceRows, { defaultOpen: false }) +
765
- (contentRows ? panelDetails("inspector-content", "Path / Content", contentRows, { defaultOpen: false }) : "") +
766
- (supportsEffects ? panelDetails("inspector-effects", "Effects", effectsRows, { defaultOpen: false }) : "") +
767
- (structuredPaintRows ? panelDetails("inspector-structured-paint", "Structured Paint", structuredPaintRows, { defaultOpen: false }) : "") +
768
- panelDetails("inspector-keyframe", "Keyframe", keyframeRows, { defaultOpen: false }) +
769
- exportPanel;
770
- bindPanelStates(inspector);
771
- bindExportButtons();
1077
+ "<p class='tiny'>Drag to move. Use the square to scale and the round handle to rotate.</p>";
1078
+ inspector.innerHTML =
1079
+ panelDetails("inspector-selected", "Selected", selectedRows, { defaultOpen: true, meta: element.type }) +
1080
+ panelDetails("inspector-transform", "Transform", transformRows, { defaultOpen: false }) +
1081
+ panelDetails("inspector-appearance", "Appearance", appearanceRows, { defaultOpen: false }) +
1082
+ (contentRows ? panelDetails("inspector-content", "Path / Content", contentRows, { defaultOpen: false }) : "") +
1083
+ (supportsEffects ? panelDetails("inspector-effects", "Effects", effectsRows, { defaultOpen: false }) : "") +
1084
+ (structuredPaintRows ? panelDetails("inspector-structured-paint", "Structured Paint", structuredPaintRows, { defaultOpen: false }) : "") +
1085
+ panelDetails("inspector-keyframe", "Keyframe", keyframeRows, { defaultOpen: false }) +
1086
+ exportPanel;
1087
+ bindPanelStates(inspector);
1088
+ bindExportButtons();
772
1089
  if (supportsPaint) {
773
1090
  setInput("propFill", typeof displayElement.fill === "string" ? displayElement.fill : "");
774
1091
  setInput("propStroke", typeof displayElement.stroke === "string" ? displayElement.stroke : "");
@@ -828,39 +1145,39 @@ function renderInspector() {
828
1145
  }
829
1146
  bindDynamicInspectorInputs(bindAutoKeyframe);
830
1147
  }
831
- bindColorPickersInScope(inspector);
832
- }
833
-
834
- function renderExportPanel() {
835
- const rows =
836
- "<div class='exportButtons'>" +
837
- "<button id='exportSvg' type='button' title='Export current frame as SVG'>SVG</button>" +
838
- "<button id='exportPng' type='button' title='Export current frame as PNG'>PNG</button>" +
839
- "<button id='exportJpg' type='button' title='Export current frame as JPG'>JPG</button>" +
840
- "<button id='exportHtml' type='button' title='Export current frame as HTML'>HTML</button>" +
841
- "<button id='exportJson' type='button' title='Export kernel JSON'>JSON</button>" +
842
- "<button id='exportMp4' type='button' title='Export full animation as MP4'>MP4</button>" +
843
- "</div>" +
844
- "<div id='error'></div>" +
845
- "<p class='tiny'>MP4 exports in the browser. Chrome or Edge is recommended.</p>";
846
- return panelDetails("inspector-export", "Export", rows, { defaultOpen: true });
847
- }
848
-
849
- function bindExportButtons() {
850
- bindExportButton("exportSvg", "svg");
851
- bindExportButton("exportPng", "png");
852
- bindExportButton("exportJpg", "jpg");
853
- bindExportButton("exportHtml", "html");
854
- bindExportButton("exportJson", "json");
855
- bindExportButton("exportMp4", "mp4");
856
- }
857
-
858
- function bindExportButton(id, format) {
859
- const button = document.getElementById(id);
860
- if (button) button.onclick = () => exportDocument(format, button);
861
- }
862
-
863
- function fontFamilyOptionsHtml(currentValue) {
1148
+ bindColorPickersInScope(inspector);
1149
+ }
1150
+
1151
+ function renderExportPanel() {
1152
+ const rows =
1153
+ "<div class='exportButtons'>" +
1154
+ "<button id='exportSvg' type='button' title='Export current frame as SVG'>SVG</button>" +
1155
+ "<button id='exportPng' type='button' title='Export current frame as PNG'>PNG</button>" +
1156
+ "<button id='exportJpg' type='button' title='Export current frame as JPG'>JPG</button>" +
1157
+ "<button id='exportHtml' type='button' title='Export current frame as HTML'>HTML</button>" +
1158
+ "<button id='exportJson' type='button' title='Export kernel JSON'>JSON</button>" +
1159
+ "<button id='exportMp4' type='button' title='Export full animation as MP4'>MP4</button>" +
1160
+ "</div>" +
1161
+ "<div id='error'></div>" +
1162
+ "<p class='tiny'>MP4 exports in the browser. Chrome or Edge is recommended.</p>";
1163
+ return panelDetails("inspector-export", "Export", rows, { defaultOpen: true });
1164
+ }
1165
+
1166
+ function bindExportButtons() {
1167
+ bindExportButton("exportSvg", "svg");
1168
+ bindExportButton("exportPng", "png");
1169
+ bindExportButton("exportJpg", "jpg");
1170
+ bindExportButton("exportHtml", "html");
1171
+ bindExportButton("exportJson", "json");
1172
+ bindExportButton("exportMp4", "mp4");
1173
+ }
1174
+
1175
+ function bindExportButton(id, format) {
1176
+ const button = document.getElementById(id);
1177
+ if (button) button.onclick = () => exportDocument(format, button);
1178
+ }
1179
+
1180
+ function fontFamilyOptionsHtml(currentValue) {
864
1181
  const current = String(valueOr(currentValue, "")).trim();
865
1182
  const seen = new Set();
866
1183
  const options = [];
@@ -1072,16 +1389,17 @@ function bindDynamicInspectorInputs(bindAutoKeyframe) {
1072
1389
  }
1073
1390
  }
1074
1391
 
1075
- function renderTimeline() {
1076
- const element = findElement(selectedId);
1077
- const tracks = element && element.timeline && element.timeline.tracks ? element.timeline.tracks : {};
1078
- timeline.innerHTML = "<div class='toolbar'><button id='play'>" + (playing ? "Pause" : "Play") + "</button><input id='scrub' type='range' min='0' max='" + Math.max(Number(doc.canvas.duration || 0), 0.01) + "' step='0.005' value='" + currentTime + "'><strong id='timeLabel'>" + currentTime.toFixed(2) + "s</strong><button id='refresh'>Refresh</button></div>";
1392
+ function renderTimeline() {
1393
+ const element = findElement(selectedId);
1394
+ const tracks = element && element.timeline && element.timeline.tracks ? element.timeline.tracks : {};
1395
+ timeline.innerHTML = "<div class='toolbar'><button id='play'>" + (playing ? "Pause" : "Play") + "</button><input id='scrub' type='range' min='0' max='" + Math.max(Number(doc.canvas.duration || 0), 0.01) + "' step='0.005' value='" + currentTime + "'><strong id='timeLabel'>" + currentTime.toFixed(2) + "s</strong><button id='refresh'>Refresh</button></div>";
1079
1396
  document.getElementById("play").onclick = togglePlay;
1080
1397
  document.getElementById("refresh").onclick = load;
1081
- document.getElementById("scrub").oninput = (event) => {
1082
- setCurrentTime(event.target.value);
1083
- };
1084
- const properties = Object.keys(tracks);
1398
+ const scrub = document.getElementById("scrub");
1399
+ scrub.oninput = (event) => {
1400
+ setCurrentTime(event.target.value);
1401
+ };
1402
+ const properties = Object.keys(tracks);
1085
1403
  if (!properties.length) {
1086
1404
  selectedSegment = null;
1087
1405
  closeCurveModal();
@@ -1140,229 +1458,229 @@ function renderTimeline() {
1140
1458
  if (isCurveModalOpen()) refreshCurveModal(tracks);
1141
1459
  }
1142
1460
 
1143
- async function exportDocument(format, triggerButton) {
1144
- const button = triggerButton || document.getElementById("exportMenuBtn");
1145
- const label = button ? button.textContent : "";
1146
- try {
1147
- if (button) {
1148
- button.disabled = true;
1149
- button.textContent = format === "mp4" ? "Exporting..." : "Export...";
1150
- }
1151
- if (format === "json") {
1152
- downloadBlob(
1153
- new Blob([JSON.stringify(doc, null, 2)], { type: "application/json;charset=utf-8" }),
1154
- safeFileName(EDITOR_TITLE) + ".json"
1155
- );
1156
- return;
1157
- }
1158
- if (format === "svg" || format === "png" || format === "jpg" || format === "html") {
1159
- await exportCurrentFrameInBrowser(format);
1160
- return;
1161
- }
1162
- if (format === "mp4") {
1163
- try {
1164
- await exportMp4InBrowser(button);
1165
- return;
1166
- } catch (error) {
1167
- if (!SERVER_EXPORT_FALLBACK) throw error;
1168
- }
1169
- }
1170
- await exportViaServer(format);
1171
- } catch (error) {
1172
- showError(error);
1173
- } finally {
1461
+ async function exportDocument(format, triggerButton) {
1462
+ const button = triggerButton || document.getElementById("exportMenuBtn");
1463
+ const label = button ? button.textContent : "";
1464
+ try {
1465
+ if (button) {
1466
+ button.disabled = true;
1467
+ button.textContent = format === "mp4" ? "Exporting..." : "Export...";
1468
+ }
1469
+ if (format === "json") {
1470
+ downloadBlob(
1471
+ new Blob([JSON.stringify(doc, null, 2)], { type: "application/json;charset=utf-8" }),
1472
+ safeFileName(EDITOR_TITLE) + ".json"
1473
+ );
1474
+ return;
1475
+ }
1476
+ if (format === "svg" || format === "png" || format === "jpg" || format === "html") {
1477
+ await exportCurrentFrameInBrowser(format);
1478
+ return;
1479
+ }
1480
+ if (format === "mp4") {
1481
+ try {
1482
+ await exportMp4InBrowser(button);
1483
+ return;
1484
+ } catch (error) {
1485
+ if (!SERVER_EXPORT_FALLBACK) throw error;
1486
+ }
1487
+ }
1488
+ await exportViaServer(format);
1489
+ } catch (error) {
1490
+ showError(error);
1491
+ } finally {
1174
1492
  if (button) {
1175
1493
  button.disabled = false;
1176
1494
  button.textContent = label;
1177
1495
  }
1178
- }
1179
- }
1180
-
1181
- async function exportCurrentFrameInBrowser(format) {
1182
- const data = await api(apiPath("/frame") + "?time=" + encodeURIComponent(currentTime));
1183
- const width = Math.max(1, Number(data.canvas && data.canvas.width || 1));
1184
- const height = Math.max(1, Number(data.canvas && data.canvas.height || 1));
1185
- const baseName = safeFileName(EDITOR_TITLE);
1186
- const frameName = baseName + "-t" + Number(currentTime).toFixed(2).replace(".", "-");
1187
- if (format === "svg") {
1188
- downloadBlob(new Blob([data.svg], { type: "image/svg+xml;charset=utf-8" }), frameName + ".svg");
1189
- return;
1190
- }
1191
- if (format === "html") {
1192
- const html = "<!doctype html><html><head><meta charset='utf-8'><meta name='viewport' content='width=device-width,initial-scale=1'><title>" + escapeText(EDITOR_TITLE) + "</title></head><body style='margin:0'>" + data.svg + "</body></html>";
1193
- downloadBlob(new Blob([html], { type: "text/html;charset=utf-8" }), frameName + ".html");
1194
- return;
1195
- }
1196
- const mimeType = format === "jpg" ? "image/jpeg" : "image/png";
1197
- const blob = await rasterizeSvgBlob(data.svg, width, height, mimeType, format === "jpg" ? 0.92 : undefined);
1198
- downloadBlob(blob, frameName + "." + format);
1199
- }
1200
-
1201
- async function exportViaServer(format) {
1202
- const response = await fetch(apiPath("/export") + "?format=" + encodeURIComponent(format) + "&time=" + encodeURIComponent(currentTime), { cache: "no-store" });
1203
- if (!response.ok) {
1204
- let message = "Export failed.";
1205
- try {
1206
- const data = await response.json();
1207
- message = data.error || message;
1208
- } catch {}
1209
- throw new Error(message);
1210
- }
1211
- const blob = await response.blob();
1212
- downloadBlob(blob, filenameFromDisposition(response.headers.get("content-disposition")) || ("sketchmark." + format));
1213
- }
1214
-
1215
- async function exportMp4InBrowser(button) {
1216
- const VideoEncoderCtor = window.VideoEncoder;
1217
- const VideoFrameCtor = window.VideoFrame;
1218
- if (!VideoEncoderCtor || !VideoFrameCtor) {
1219
- throw new Error("Browser MP4 export requires WebCodecs. Try Chrome or Edge.");
1220
- }
1221
- if (!MP4_MUXER_URL && !MP4_MUXER_SOURCE) {
1222
- throw new Error("Browser MP4 export is not configured for this editor.");
1223
- }
1224
- const duration = Number(doc && doc.canvas && doc.canvas.duration || 0);
1225
- if (!Number.isFinite(duration) || duration <= 0) {
1226
- throw new Error("MP4 export requires a positive canvas.duration.");
1227
- }
1228
- const fps = Math.max(1, Math.round(Number(doc && doc.canvas && doc.canvas.fps || 30) || 30));
1229
- const sourceWidth = Math.max(1, Number(doc && doc.canvas && doc.canvas.width || 1));
1230
- const sourceHeight = Math.max(1, Number(doc && doc.canvas && doc.canvas.height || 1));
1231
- const width = evenDimension(sourceWidth);
1232
- const height = evenDimension(sourceHeight);
1233
- const totalFrames = Math.max(1, Math.ceil(duration * fps));
1234
- const muxerModule = await importMp4Muxer();
1235
- const target = new muxerModule.ArrayBufferTarget();
1236
- const muxer = new muxerModule.Muxer({
1237
- target,
1238
- video: { codec: "avc", width, height },
1239
- fastStart: "in-memory"
1240
- });
1241
- let encoderError = null;
1242
- const encoder = new VideoEncoderCtor({
1243
- output: (chunk, meta) => muxer.addVideoChunk(chunk, meta),
1244
- error: (error) => { encoderError = error; }
1245
- });
1246
- encoder.configure({
1247
- codec: "avc1.640028",
1248
- width,
1249
- height,
1250
- bitrate: 5000000,
1251
- framerate: fps
1252
- });
1253
- const canvas = document.createElement("canvas");
1254
- canvas.width = width;
1255
- canvas.height = height;
1256
- try {
1257
- for (let frameIndex = 0; frameIndex < totalFrames; frameIndex += 1) {
1258
- const time = Math.min(duration, frameIndex / fps);
1259
- const data = await api(apiPath("/frame") + "?time=" + encodeURIComponent(time));
1260
- await drawSvgToCanvas(data.svg, canvas, width, height);
1261
- const frame = new VideoFrameCtor(canvas, {
1262
- timestamp: Math.round((frameIndex / fps) * 1000000),
1263
- duration: Math.round((1 / fps) * 1000000)
1264
- });
1265
- encoder.encode(frame, { keyFrame: frameIndex % Math.max(1, fps * 2) === 0 });
1266
- frame.close();
1267
- if (encoderError) throw encoderError;
1268
- if (frameIndex % 5 === 0 || frameIndex === totalFrames - 1) {
1269
- const progress = Math.round(((frameIndex + 1) / totalFrames) * 100);
1270
- if (button) button.textContent = "Exporting " + progress + "%";
1271
- await yieldToBrowser();
1272
- }
1273
- }
1274
- await encoder.flush();
1275
- if (encoderError) throw encoderError;
1276
- encoder.close();
1277
- muxer.finalize();
1278
- downloadBlob(new Blob([target.buffer], { type: "video/mp4" }), safeFileName(EDITOR_TITLE) + ".mp4");
1279
- } catch (error) {
1280
- try { encoder.close(); } catch {}
1281
- throw error;
1282
- }
1283
- }
1284
-
1285
- async function importMp4Muxer() {
1286
- if (MP4_MUXER_URL) return import(MP4_MUXER_URL);
1287
- if (MP4_MUXER_SOURCE) {
1288
- if (!mp4MuxerObjectUrl) {
1289
- mp4MuxerObjectUrl = URL.createObjectURL(new Blob([MP4_MUXER_SOURCE], { type: "text/javascript" }));
1290
- }
1291
- return import(mp4MuxerObjectUrl);
1292
- }
1293
- throw new Error("Browser MP4 export is not available in this editor build.");
1294
- }
1295
-
1296
- function filenameFromDisposition(header) {
1297
- const match = /filename="([^"]+)"/.exec(header || "");
1298
- return match ? match[1] : "";
1299
- }
1300
-
1301
- function rasterizeSvgBlob(svg, width, height, mimeType, quality) {
1302
- const canvas = document.createElement("canvas");
1303
- canvas.width = width;
1304
- canvas.height = height;
1305
- return drawSvgToCanvas(svg, canvas, width, height).then(() => canvasToBlob(canvas, mimeType || "image/png", quality));
1306
- }
1307
-
1308
- function drawSvgToCanvas(svg, canvas, width, height) {
1309
- return new Promise((resolve, reject) => {
1310
- const context = canvas.getContext("2d");
1311
- if (!context) {
1312
- reject(new Error("Could not create canvas context."));
1313
- return;
1314
- }
1315
- const url = URL.createObjectURL(new Blob([svg], { type: "image/svg+xml;charset=utf-8" }));
1316
- const image = new Image();
1317
- image.onload = () => {
1318
- URL.revokeObjectURL(url);
1319
- context.clearRect(0, 0, width, height);
1320
- context.drawImage(image, 0, 0, width, height);
1321
- resolve();
1322
- };
1323
- image.onerror = () => {
1324
- URL.revokeObjectURL(url);
1325
- reject(new Error("Could not rasterize current SVG frame."));
1326
- };
1327
- image.src = url;
1328
- });
1329
- }
1330
-
1331
- function canvasToBlob(canvas, mimeType, quality) {
1332
- return new Promise((resolve, reject) => {
1333
- canvas.toBlob((blob) => {
1334
- if (blob) resolve(blob);
1335
- else reject(new Error("Could not export the canvas frame. Cross-origin image assets can block browser raster export."));
1336
- }, mimeType, quality);
1337
- });
1338
- }
1339
-
1340
- function downloadBlob(blob, filename) {
1341
- const link = document.createElement("a");
1342
- link.href = URL.createObjectURL(blob);
1343
- link.download = filename;
1344
- document.body.appendChild(link);
1345
- link.click();
1346
- link.remove();
1347
- setTimeout(() => URL.revokeObjectURL(link.href), 1000);
1348
- }
1349
-
1350
- function evenDimension(value) {
1351
- const rounded = Math.max(2, Math.round(Number(value) || 2));
1352
- return rounded % 2 === 0 ? rounded : rounded + 1;
1353
- }
1354
-
1355
- function safeFileName(value) {
1356
- return String(value || "sketchmark").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 80) || "sketchmark";
1357
- }
1358
-
1359
- function yieldToBrowser() {
1360
- return new Promise((resolve) => window.setTimeout(resolve, 0));
1361
- }
1362
-
1363
- function isCurveModalOpen() {
1364
- return !curveModalBackdrop.classList.contains("hidden");
1365
- }
1496
+ }
1497
+ }
1498
+
1499
+ async function exportCurrentFrameInBrowser(format) {
1500
+ const data = await api(apiPath("/frame") + "?time=" + encodeURIComponent(currentTime));
1501
+ const width = Math.max(1, Number(data.canvas && data.canvas.width || 1));
1502
+ const height = Math.max(1, Number(data.canvas && data.canvas.height || 1));
1503
+ const baseName = safeFileName(EDITOR_TITLE);
1504
+ const frameName = baseName + "-t" + Number(currentTime).toFixed(2).replace(".", "-");
1505
+ if (format === "svg") {
1506
+ downloadBlob(new Blob([data.svg], { type: "image/svg+xml;charset=utf-8" }), frameName + ".svg");
1507
+ return;
1508
+ }
1509
+ if (format === "html") {
1510
+ const html = "<!doctype html><html><head><meta charset='utf-8'><meta name='viewport' content='width=device-width,initial-scale=1'><title>" + escapeText(EDITOR_TITLE) + "</title></head><body style='margin:0'>" + data.svg + "</body></html>";
1511
+ downloadBlob(new Blob([html], { type: "text/html;charset=utf-8" }), frameName + ".html");
1512
+ return;
1513
+ }
1514
+ const mimeType = format === "jpg" ? "image/jpeg" : "image/png";
1515
+ const blob = await rasterizeSvgBlob(data.svg, width, height, mimeType, format === "jpg" ? 0.92 : undefined);
1516
+ downloadBlob(blob, frameName + "." + format);
1517
+ }
1518
+
1519
+ async function exportViaServer(format) {
1520
+ const response = await fetch(apiPath("/export") + "?format=" + encodeURIComponent(format) + "&time=" + encodeURIComponent(currentTime), { cache: "no-store" });
1521
+ if (!response.ok) {
1522
+ let message = "Export failed.";
1523
+ try {
1524
+ const data = await response.json();
1525
+ message = data.error || message;
1526
+ } catch {}
1527
+ throw new Error(message);
1528
+ }
1529
+ const blob = await response.blob();
1530
+ downloadBlob(blob, filenameFromDisposition(response.headers.get("content-disposition")) || ("sketchmark." + format));
1531
+ }
1532
+
1533
+ async function exportMp4InBrowser(button) {
1534
+ const VideoEncoderCtor = window.VideoEncoder;
1535
+ const VideoFrameCtor = window.VideoFrame;
1536
+ if (!VideoEncoderCtor || !VideoFrameCtor) {
1537
+ throw new Error("Browser MP4 export requires WebCodecs. Try Chrome or Edge.");
1538
+ }
1539
+ if (!MP4_MUXER_URL && !MP4_MUXER_SOURCE) {
1540
+ throw new Error("Browser MP4 export is not configured for this editor.");
1541
+ }
1542
+ const duration = Number(doc && doc.canvas && doc.canvas.duration || 0);
1543
+ if (!Number.isFinite(duration) || duration <= 0) {
1544
+ throw new Error("MP4 export requires a positive canvas.duration.");
1545
+ }
1546
+ const fps = Math.max(1, Math.round(Number(doc && doc.canvas && doc.canvas.fps || 30) || 30));
1547
+ const sourceWidth = Math.max(1, Number(doc && doc.canvas && doc.canvas.width || 1));
1548
+ const sourceHeight = Math.max(1, Number(doc && doc.canvas && doc.canvas.height || 1));
1549
+ const width = evenDimension(sourceWidth);
1550
+ const height = evenDimension(sourceHeight);
1551
+ const totalFrames = Math.max(1, Math.ceil(duration * fps));
1552
+ const muxerModule = await importMp4Muxer();
1553
+ const target = new muxerModule.ArrayBufferTarget();
1554
+ const muxer = new muxerModule.Muxer({
1555
+ target,
1556
+ video: { codec: "avc", width, height },
1557
+ fastStart: "in-memory"
1558
+ });
1559
+ let encoderError = null;
1560
+ const encoder = new VideoEncoderCtor({
1561
+ output: (chunk, meta) => muxer.addVideoChunk(chunk, meta),
1562
+ error: (error) => { encoderError = error; }
1563
+ });
1564
+ encoder.configure({
1565
+ codec: "avc1.640028",
1566
+ width,
1567
+ height,
1568
+ bitrate: 5000000,
1569
+ framerate: fps
1570
+ });
1571
+ const canvas = document.createElement("canvas");
1572
+ canvas.width = width;
1573
+ canvas.height = height;
1574
+ try {
1575
+ for (let frameIndex = 0; frameIndex < totalFrames; frameIndex += 1) {
1576
+ const time = Math.min(duration, frameIndex / fps);
1577
+ const data = await api(apiPath("/frame") + "?time=" + encodeURIComponent(time));
1578
+ await drawSvgToCanvas(data.svg, canvas, width, height);
1579
+ const frame = new VideoFrameCtor(canvas, {
1580
+ timestamp: Math.round((frameIndex / fps) * 1000000),
1581
+ duration: Math.round((1 / fps) * 1000000)
1582
+ });
1583
+ encoder.encode(frame, { keyFrame: frameIndex % Math.max(1, fps * 2) === 0 });
1584
+ frame.close();
1585
+ if (encoderError) throw encoderError;
1586
+ if (frameIndex % 5 === 0 || frameIndex === totalFrames - 1) {
1587
+ const progress = Math.round(((frameIndex + 1) / totalFrames) * 100);
1588
+ if (button) button.textContent = "Exporting " + progress + "%";
1589
+ await yieldToBrowser();
1590
+ }
1591
+ }
1592
+ await encoder.flush();
1593
+ if (encoderError) throw encoderError;
1594
+ encoder.close();
1595
+ muxer.finalize();
1596
+ downloadBlob(new Blob([target.buffer], { type: "video/mp4" }), safeFileName(EDITOR_TITLE) + ".mp4");
1597
+ } catch (error) {
1598
+ try { encoder.close(); } catch {}
1599
+ throw error;
1600
+ }
1601
+ }
1602
+
1603
+ async function importMp4Muxer() {
1604
+ if (MP4_MUXER_URL) return import(MP4_MUXER_URL);
1605
+ if (MP4_MUXER_SOURCE) {
1606
+ if (!mp4MuxerObjectUrl) {
1607
+ mp4MuxerObjectUrl = URL.createObjectURL(new Blob([MP4_MUXER_SOURCE], { type: "text/javascript" }));
1608
+ }
1609
+ return import(mp4MuxerObjectUrl);
1610
+ }
1611
+ throw new Error("Browser MP4 export is not available in this editor build.");
1612
+ }
1613
+
1614
+ function filenameFromDisposition(header) {
1615
+ const match = /filename="([^"]+)"/.exec(header || "");
1616
+ return match ? match[1] : "";
1617
+ }
1618
+
1619
+ function rasterizeSvgBlob(svg, width, height, mimeType, quality) {
1620
+ const canvas = document.createElement("canvas");
1621
+ canvas.width = width;
1622
+ canvas.height = height;
1623
+ return drawSvgToCanvas(svg, canvas, width, height).then(() => canvasToBlob(canvas, mimeType || "image/png", quality));
1624
+ }
1625
+
1626
+ function drawSvgToCanvas(svg, canvas, width, height) {
1627
+ return new Promise((resolve, reject) => {
1628
+ const context = canvas.getContext("2d");
1629
+ if (!context) {
1630
+ reject(new Error("Could not create canvas context."));
1631
+ return;
1632
+ }
1633
+ const url = URL.createObjectURL(new Blob([svg], { type: "image/svg+xml;charset=utf-8" }));
1634
+ const image = new Image();
1635
+ image.onload = () => {
1636
+ URL.revokeObjectURL(url);
1637
+ context.clearRect(0, 0, width, height);
1638
+ context.drawImage(image, 0, 0, width, height);
1639
+ resolve();
1640
+ };
1641
+ image.onerror = () => {
1642
+ URL.revokeObjectURL(url);
1643
+ reject(new Error("Could not rasterize current SVG frame."));
1644
+ };
1645
+ image.src = url;
1646
+ });
1647
+ }
1648
+
1649
+ function canvasToBlob(canvas, mimeType, quality) {
1650
+ return new Promise((resolve, reject) => {
1651
+ canvas.toBlob((blob) => {
1652
+ if (blob) resolve(blob);
1653
+ else reject(new Error("Could not export the canvas frame. Cross-origin image assets can block browser raster export."));
1654
+ }, mimeType, quality);
1655
+ });
1656
+ }
1657
+
1658
+ function downloadBlob(blob, filename) {
1659
+ const link = document.createElement("a");
1660
+ link.href = URL.createObjectURL(blob);
1661
+ link.download = filename;
1662
+ document.body.appendChild(link);
1663
+ link.click();
1664
+ link.remove();
1665
+ setTimeout(() => URL.revokeObjectURL(link.href), 1000);
1666
+ }
1667
+
1668
+ function evenDimension(value) {
1669
+ const rounded = Math.max(2, Math.round(Number(value) || 2));
1670
+ return rounded % 2 === 0 ? rounded : rounded + 1;
1671
+ }
1672
+
1673
+ function safeFileName(value) {
1674
+ return String(value || "sketchmark").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 80) || "sketchmark";
1675
+ }
1676
+
1677
+ function yieldToBrowser() {
1678
+ return new Promise((resolve) => window.setTimeout(resolve, 0));
1679
+ }
1680
+
1681
+ function isCurveModalOpen() {
1682
+ return !curveModalBackdrop.classList.contains("hidden");
1683
+ }
1366
1684
 
1367
1685
  function openCurveModal() {
1368
1686
  refreshCurveModal();
@@ -1705,7 +2023,7 @@ async function applySegmentPreset(property, segmentIndex, preset) {
1705
2023
  if (segmentIndex < 0 || segmentIndex >= frames.length - 1) return;
1706
2024
  const start = frames[segmentIndex];
1707
2025
  await mutate(
1708
- apiPath("/keyframe"),
2026
+ apiPath("/keyframe"),
1709
2027
  { id: selectedId, property, value: start.value, time: start.time, curvePreset: preset },
1710
2028
  { refreshTree: false, refreshInspector: false, refreshTimeline: true }
1711
2029
  );
@@ -1720,24 +2038,24 @@ async function applySegmentCurve(property, segmentIndex, curve) {
1720
2038
  if (segmentIndex < 0 || segmentIndex >= frames.length - 1) return;
1721
2039
  const start = frames[segmentIndex];
1722
2040
  await mutate(
1723
- apiPath("/keyframe"),
2041
+ apiPath("/keyframe"),
1724
2042
  { id: selectedId, property, value: start.value, time: start.time, curve },
1725
2043
  { refreshTree: false, refreshInspector: false, refreshTimeline: true }
1726
2044
  );
1727
2045
  }
1728
2046
 
1729
- function setCurrentTime(time) {
1730
- const duration = Math.max(Number(doc && doc.canvas && doc.canvas.duration || 0), 0.01);
1731
- const next = Math.max(0, Math.min(Number(time || 0), duration));
1732
- currentTime = next;
2047
+ function setCurrentTime(time) {
2048
+ const duration = Math.max(Number(doc && doc.canvas && doc.canvas.duration || 0), 0.01);
2049
+ const next = Math.max(0, Math.min(Number(time || 0), duration));
2050
+ currentTime = next;
1733
2051
  const scrub = document.getElementById("scrub");
1734
2052
  if (scrub) scrub.value = String(next);
1735
2053
  const label = document.getElementById("timeLabel");
1736
2054
  if (label) label.textContent = next.toFixed(2) + "s";
1737
- const kfTime = document.getElementById("kfTime");
1738
- if (kfTime) kfTime.value = next.toFixed(2);
1739
- requestDraw();
1740
- }
2055
+ const kfTime = document.getElementById("kfTime");
2056
+ if (kfTime) kfTime.value = next.toFixed(2);
2057
+ requestDraw();
2058
+ }
1741
2059
 
1742
2060
  function clearSidebarCommitTimers() {
1743
2061
  for (const key of Object.keys(sidebarCommitTimers)) {
@@ -1863,7 +2181,7 @@ function scheduleSidebarKeyframe(property, valueReader) {
1863
2181
  if (value === null || value === undefined) return;
1864
2182
  try {
1865
2183
  await mutate(
1866
- apiPath("/keyframe"),
2184
+ apiPath("/keyframe"),
1867
2185
  { id: selectedId, property, value, time: currentTime, curvePreset: "linear" },
1868
2186
  { refreshTree: false, refreshInspector: false, refreshTimeline: true }
1869
2187
  );
@@ -1888,7 +2206,7 @@ function readTextInput(id) {
1888
2206
  async function removeKeyframe(property, time) {
1889
2207
  try {
1890
2208
  if (!ensureElementEditable(selectedId)) return;
1891
- await mutate(apiPath("/remove-keyframe"), { id: selectedId, property, time });
2209
+ await mutate(apiPath("/remove-keyframe"), { id: selectedId, property, time });
1892
2210
  } catch (error) {
1893
2211
  showError(error);
1894
2212
  }
@@ -1988,12 +2306,20 @@ document.addEventListener("keyup", (event) => {
1988
2306
  if (event.code === "Space") spacePanActive = false;
1989
2307
  });
1990
2308
 
1991
- window.addEventListener("blur", () => {
1992
- spacePanActive = false;
1993
- endViewportPan();
1994
- });
1995
-
1996
- stage.addEventListener("pointerdown", (event) => {
2309
+ window.addEventListener("blur", () => {
2310
+ spacePanActive = false;
2311
+ endViewportPan();
2312
+ });
2313
+
2314
+ window.addEventListener("resize", () => {
2315
+ setTreeWidth(tree.getBoundingClientRect().width || loadTreeWidth(), false);
2316
+ resizeElementsTree();
2317
+ });
2318
+ window.addEventListener("pointermove", updateTreeResize);
2319
+ window.addEventListener("pointerup", endTreeResize);
2320
+ window.addEventListener("pointercancel", endTreeResize);
2321
+
2322
+ stage.addEventListener("pointerdown", (event) => {
1997
2323
  const handle = event.target.closest("[data-handle]");
1998
2324
  if (handle && selectedId) {
1999
2325
  if (isElementLocked(selectedId) || isElementHidden(selectedId)) return;
@@ -2053,15 +2379,24 @@ stage.addEventListener("pointermove", (event) => {
2053
2379
  stage.addEventListener("pointerup", finishDrag);
2054
2380
  stage.addEventListener("pointercancel", finishDrag);
2055
2381
 
2056
- async function finishDrag() {
2057
- if (!drag) return;
2058
- const snapshot = drag;
2059
- drag = null;
2060
- suppressClick = true;
2061
- if (snapshot.changed) {
2062
- await commitDrag(snapshot);
2063
- }
2064
- }
2382
+ async function finishDrag() {
2383
+ if (!drag) return;
2384
+ const snapshot = drag;
2385
+ drag = null;
2386
+ suppressClick = true;
2387
+ let committed = false;
2388
+ try {
2389
+ if (snapshot.changed) {
2390
+ await commitDrag(snapshot);
2391
+ committed = true;
2392
+ }
2393
+ } finally {
2394
+ if (snapshot.changed && !committed && snapshot.target) {
2395
+ if (snapshot.transform) snapshot.target.setAttribute("transform", snapshot.transform);
2396
+ else snapshot.target.removeAttribute("transform");
2397
+ }
2398
+ }
2399
+ }
2065
2400
 
2066
2401
  function startDrag(event, target, mode) {
2067
2402
  if (!target || isElementLocked(target.id) || isElementHidden(target.id)) return;
@@ -2082,10 +2417,10 @@ function startDrag(event, target, mode) {
2082
2417
  changed: false,
2083
2418
  value: null
2084
2419
  };
2085
- event.preventDefault();
2086
- event.stopPropagation();
2087
- stage.setPointerCapture?.(event.pointerId);
2088
- }
2420
+ event.preventDefault();
2421
+ event.stopPropagation();
2422
+ stage.setPointerCapture?.(event.pointerId);
2423
+ }
2089
2424
 
2090
2425
  async function commitDrag(snapshot) {
2091
2426
  const element = findElement(snapshot.id);
@@ -2101,7 +2436,7 @@ async function commitDrag(snapshot) {
2101
2436
 
2102
2437
  async function commitEditedProperty(element, property, value) {
2103
2438
  if (!ensureElementEditable(element.id)) return;
2104
- await mutate(apiPath("/keyframe"), { id: element.id, property, value, time: currentTime, curvePreset: "linear" });
2439
+ await mutate(apiPath("/keyframe"), { id: element.id, property, value, time: currentTime, curvePreset: "linear" });
2105
2440
  }
2106
2441
 
2107
2442
  function ensureElementEditable(id) {
@@ -2181,10 +2516,10 @@ function parentPoint(event, target) {
2181
2516
  point.y = event.clientY;
2182
2517
  return point.matrixTransform(matrix.inverse());
2183
2518
  }
2184
- function previewDraggedTransform(prefix) {
2185
- if (!drag || !drag.target) return;
2186
- drag.target.setAttribute("transform", prefix + (drag.transform ? " " + drag.transform : ""));
2187
- }
2519
+ function previewDraggedTransform(prefix) {
2520
+ if (!drag || !drag.target) return;
2521
+ drag.target.setAttribute("transform", prefix + (drag.transform ? " " + drag.transform : ""));
2522
+ }
2188
2523
  function selectedTarget() {
2189
2524
  if (!selectedId || isElementHidden(selectedId) || isElementLocked(selectedId)) return null;
2190
2525
  return stage.querySelector("#" + cssId(selectedId));
@@ -2214,12 +2549,13 @@ function drawHandles(target) {
2214
2549
  }
2215
2550
  if (!matrix) return;
2216
2551
  const topLeft = matrixPoint(svg, matrix, box.x, box.y);
2217
- const topRight = matrixPoint(svg, matrix, box.x + box.width, box.y);
2218
- const bottomRight = matrixPoint(svg, matrix, box.x + box.width, box.y + box.height);
2219
- const bottomLeft = matrixPoint(svg, matrix, box.x, box.y + box.height);
2220
- const center = matrixPoint(svg, matrix, box.x + box.width / 2, box.y + box.height / 2);
2221
- const rotate = matrixPoint(svg, matrix, box.x + box.width / 2, box.y - 32);
2222
- const scale = matrixPoint(svg, matrix, box.x + box.width, box.y + box.height);
2552
+ const topRight = matrixPoint(svg, matrix, box.x + box.width, box.y);
2553
+ const bottomRight = matrixPoint(svg, matrix, box.x + box.width, box.y + box.height);
2554
+ const bottomLeft = matrixPoint(svg, matrix, box.x, box.y + box.height);
2555
+ const center = matrixPoint(svg, matrix, box.x + box.width / 2, box.y + box.height / 2);
2556
+ const topCenter = midpoint(topLeft, topRight);
2557
+ const rotate = offsetFromPoint(center, topCenter, 32);
2558
+ const scale = matrixPoint(svg, matrix, box.x + box.width, box.y + box.height);
2223
2559
  const group = svgNode("g");
2224
2560
  group.setAttribute("id", "__sketchmark_handles");
2225
2561
  group.setAttribute("style", "pointer-events:all");
@@ -2286,15 +2622,25 @@ function targetCenterInParent(target) {
2286
2622
  return { x: 0, y: 0 };
2287
2623
  }
2288
2624
  }
2289
- function matrixPoint(svg, matrix, x, y) {
2290
- const point = svg.createSVGPoint();
2291
- point.x = x;
2292
- point.y = y;
2293
- return point.matrixTransform(matrix);
2294
- }
2295
- function svgNode(name) {
2296
- return document.createElementNS("http://www.w3.org/2000/svg", name);
2297
- }
2625
+ function matrixPoint(svg, matrix, x, y) {
2626
+ const point = svg.createSVGPoint();
2627
+ point.x = x;
2628
+ point.y = y;
2629
+ return point.matrixTransform(matrix);
2630
+ }
2631
+ function midpoint(a, b) {
2632
+ return { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
2633
+ }
2634
+ function offsetFromPoint(origin, edge, distance) {
2635
+ const dx = edge.x - origin.x;
2636
+ const dy = edge.y - origin.y;
2637
+ const length = Math.sqrt(dx * dx + dy * dy);
2638
+ if (!Number.isFinite(length) || length < 0.000001) return { x: edge.x, y: edge.y - distance };
2639
+ return { x: edge.x + (dx / length) * distance, y: edge.y + (dy / length) * distance };
2640
+ }
2641
+ function svgNode(name) {
2642
+ return document.createElementNS("http://www.w3.org/2000/svg", name);
2643
+ }
2298
2644
  function angleAround(center, point) {
2299
2645
  return Math.atan2(point.y - center.y, point.x - center.x) * 180 / Math.PI;
2300
2646
  }
@@ -2473,58 +2819,59 @@ function cap(value) { value = String(value); return value.charAt(0).toUpperCase(
2473
2819
  function cssId(id) { return String(id).replace(/([ !"#$%&'()*+,./:;<=>?@[\\\\\\]^\\\`{|}~])/g, "\\\\$1"); }
2474
2820
  function escapeText(value) { return String(value).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;"); }
2475
2821
  function escapeAttr(value) { return escapeText(value).replace(/"/g, "&quot;").replace(/'/g, "&#39;"); }
2476
- function showError(error) { const box = document.getElementById("error"); if (box) box.textContent = error.message || String(error); }
2477
- load().catch(showError);
2478
- </script></body></html>`;
2822
+ function showError(error) { const box = document.getElementById("error"); if (box) box.textContent = error.message || String(error); }
2823
+ initTreeResize();
2824
+ load().catch(showError);
2825
+ </script></body></html>`;
2826
+ }
2827
+
2828
+
2829
+ function escapeHtml(value) {
2830
+ return String(value).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
2479
2831
  }
2480
2832
 
2833
+ function normalizeApiBase(value) {
2834
+ const text = String(value || "/api").replace(/\/+$/, "");
2835
+ return text.startsWith("/") ? text : `/${text}`;
2836
+ }
2481
2837
 
2482
- function escapeHtml(value) {
2483
- return String(value).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
2484
- }
2485
-
2486
- function normalizeApiBase(value) {
2487
- const text = String(value || "/api").replace(/\/+$/, "");
2488
- return text.startsWith("/") ? text : `/${text}`;
2489
- }
2490
-
2491
- function editorMp4MuxerSource(value) {
2492
- if (value === false) return "";
2493
- if (typeof value === "string") return value;
2494
- for (const candidate of mp4MuxerSourceCandidates()) {
2495
- try {
2496
- if (candidate && fs.existsSync(candidate)) return fs.readFileSync(candidate, "utf8");
2497
- } catch {
2498
- // Try the next candidate.
2499
- }
2500
- }
2501
- return "";
2502
- }
2503
-
2504
- function resolveMp4MuxerSource(value) {
2505
- return editorMp4MuxerSource(value);
2506
- }
2507
-
2508
- function mp4MuxerSourceCandidates() {
2509
- const candidates = [path.join(__dirname, "vendor", "mp4-muxer.mjs")];
2510
- try {
2511
- const resolved = require.resolve("mp4-muxer");
2512
- candidates.push(resolved.replace(/\.js$/, ".mjs"));
2513
- candidates.push(path.join(path.dirname(resolved), "mp4-muxer.mjs"));
2514
- } catch {
2515
- // Dependency may be bundled differently by the host app.
2516
- }
2517
- candidates.push(path.join(process.cwd(), "node_modules", "mp4-muxer", "build", "mp4-muxer.mjs"));
2518
- return candidates;
2519
- }
2520
-
2521
- function scriptJson(value) {
2522
- return JSON.stringify(value)
2523
- .replace(/</g, "\\u003c")
2524
- .replace(/>/g, "\\u003e")
2525
- .replace(/&/g, "\\u0026")
2526
- .replace(/\u2028/g, "\\u2028")
2527
- .replace(/\u2029/g, "\\u2029");
2528
- }
2529
-
2530
- module.exports = { editorHtml, editorMp4MuxerSource };
2838
+ function editorMp4MuxerSource(value) {
2839
+ if (value === false) return "";
2840
+ if (typeof value === "string") return value;
2841
+ for (const candidate of mp4MuxerSourceCandidates()) {
2842
+ try {
2843
+ if (candidate && fs.existsSync(candidate)) return fs.readFileSync(candidate, "utf8");
2844
+ } catch {
2845
+ // Try the next candidate.
2846
+ }
2847
+ }
2848
+ return "";
2849
+ }
2850
+
2851
+ function resolveMp4MuxerSource(value) {
2852
+ return editorMp4MuxerSource(value);
2853
+ }
2854
+
2855
+ function mp4MuxerSourceCandidates() {
2856
+ const candidates = [path.join(__dirname, "vendor", "mp4-muxer.mjs")];
2857
+ try {
2858
+ const resolved = require.resolve("mp4-muxer");
2859
+ candidates.push(resolved.replace(/\.js$/, ".mjs"));
2860
+ candidates.push(path.join(path.dirname(resolved), "mp4-muxer.mjs"));
2861
+ } catch {
2862
+ // Dependency may be bundled differently by the host app.
2863
+ }
2864
+ candidates.push(path.join(/*turbopackIgnore: true*/ process.cwd(), "node_modules", "mp4-muxer", "build", "mp4-muxer.mjs"));
2865
+ return candidates;
2866
+ }
2867
+
2868
+ function scriptJson(value) {
2869
+ return JSON.stringify(value)
2870
+ .replace(/</g, "\\u003c")
2871
+ .replace(/>/g, "\\u003e")
2872
+ .replace(/&/g, "\\u0026")
2873
+ .replace(/\u2028/g, "\\u2028")
2874
+ .replace(/\u2029/g, "\\u2029");
2875
+ }
2876
+
2877
+ module.exports = { editorHtml, editorMp4MuxerSource };