sketchmark 2.1.6 → 2.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/editor-ui.cjs CHANGED
@@ -1,13 +1,16 @@
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 canvasStageRender = options.canvasStageRender === true;
12
+ const localDocumentControls = options.localDocumentControls === true;
13
+ const bootstrapScript = typeof options.bootstrapScript === "string" ? options.bootstrapScript : "";
11
14
  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
15
  @font-face{font-family:'Roboto';src:url('/fonts/Roboto-Light.ttf') format('truetype');font-weight:300;font-style:normal;font-display:swap}
13
16
  @font-face{font-family:'Roboto';src:url('/fonts/Roboto-Regular.ttf') format('truetype');font-weight:400;font-style:normal;font-display:swap}
@@ -22,9 +25,21 @@ textarea{min-height:88px;padding:4px;resize:vertical}
22
25
  #tree{grid-row:1/3}
23
26
  #stageWrap{position:relative;display:grid;place-items:center;min-width:0;min-height:0;padding:0;background:#fff;overflow:hidden;cursor:default}
24
27
  #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}
28
+ #stage{display:grid;place-items:center;min-width:0;min-height:0;position:relative}
29
+ #stage svg{max-width:100%;max-height:calc(100vh - 190px);background:white;border:1px solid #333;overflow:visible}
30
+ ${canvasStageRender ? `#stage.sketchmarkCanvasStage{display:block;position:relative;line-height:0;background:white;border:1px solid #333;box-sizing:content-box}
31
+ #stageCanvas{display:block;background:white}
32
+ #stage.sketchmarkCanvasStage>svg{position:absolute;left:0;top:0;width:100%;height:100%;max-width:none;max-height:none;background:transparent;border:0;box-sizing:border-box;z-index:2;pointer-events:auto}
33
+ #stage.sketchmarkCanvasStage>svg:not(#stageLiveSvg)>*:not(defs):not(#__sketchmark_handles):not(#__sketchmark_drag_preview){opacity:0}
34
+ #stage.sketchmarkCanvasStage>svg:not(#stageLiveSvg) #__sketchmark_handles,#stage.sketchmarkCanvasStage>svg:not(#stageLiveSvg) #__sketchmark_drag_preview{opacity:1}
35
+ #stage.sketchmarkCanvasStage>#stageLiveSvg{display:none;position:absolute;left:0;top:0;width:100%;height:100%;max-width:none;max-height:none;background:transparent;border:0;box-sizing:border-box;z-index:1;pointer-events:none;overflow:visible}
36
+ #stage.sketchmarkCanvasStage.liveSvgDrag>#stageCanvas{visibility:hidden}
37
+ #stage.sketchmarkCanvasStage.liveSvgDrag>#stageLiveSvg{display:block}` : ""}
38
+ ${localDocumentControls ? `.browserFileGrid{display:grid;gap:6px}
39
+ .browserFileActions{display:grid;grid-template-columns:1fr 1fr;gap:4px}
40
+ .browserFileInput{font-size:12px}
41
+ .browserStatus{font-size:11px;color:#374151;min-height:14px}` : ""}
42
+ #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
43
  #viewportHud button{padding:1px 8px;min-width:42px}
29
44
  #zoomLabel{min-width:42px;text-align:center;font-weight:bold;color:#111827}
30
45
  #timeline{grid-column:2/4}
@@ -73,11 +88,11 @@ textarea{min-height:88px;padding:4px;resize:vertical}
73
88
  .curveModalBar{display:flex;justify-content:space-between;align-items:center;gap:8px;margin-bottom:8px}
74
89
  .curveModalContent .curvePanel{margin-top:0}
75
90
  #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>
91
+ #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}
92
+ .exportButtons{display:grid;grid-template-columns:1fr 1fr;gap:4px}
93
+ .exportButtons button{width:100%;text-align:left;padding:4px 6px}
94
+ .exportButtons button.exportWide{grid-column:1/3}
95
+ </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>${bootstrapScript ? `<script>\n${bootstrapScript}\n</script>` : ""}<script>
81
96
  const tree = document.getElementById("tree");
82
97
  const stageWrap = document.getElementById("stageWrap");
83
98
  const stage = document.getElementById("stage");
@@ -86,7 +101,7 @@ const zoomIn = document.getElementById("zoomIn");
86
101
  const zoomFit = document.getElementById("zoomFit");
87
102
  const zoomLabel = document.getElementById("zoomLabel");
88
103
  const inspector = document.getElementById("inspector");
89
- const timeline = document.getElementById("timeline");
104
+ const timeline = document.getElementById("timeline");
90
105
  const curveModalBackdrop = document.getElementById("curveModalBackdrop");
91
106
  const curveModal = document.getElementById("curveModal");
92
107
  const curveModalContent = document.getElementById("curveModalContent");
@@ -124,20 +139,249 @@ let sidebarCommitTimers = Object.create(null);
124
139
  let selectedSegment = null;
125
140
  let panelOpenState = Object.create(null);
126
141
  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")};
142
+ let viewportPan = null;
143
+ let spacePanActive = false;
144
+ const API_BASE = ${scriptJson(apiBase)};
145
+ const EDITOR_TITLE = ${scriptJson(title || "sketchmark")};
131
146
  const MP4_MUXER_URL = ${scriptJson(mp4MuxerUrl)};
132
147
  const MP4_MUXER_SOURCE = ${scriptJson(mp4MuxerSource)};
133
148
  const SERVER_EXPORT_FALLBACK = ${scriptJson(serverExportFallback)};
149
+ const CANVAS_STAGE_RENDER = ${scriptJson(canvasStageRender)};
150
+ const LOCAL_DOCUMENT_CONTROLS = ${scriptJson(localDocumentControls)};
134
151
  let mp4MuxerObjectUrl = "";
135
152
 
136
- function apiPath(path) {
137
- return API_BASE + path;
153
+ let canvasStageRenderToken = 0;
154
+ let canvasStageRenderScheduled = false;
155
+ let pendingCanvasStageCanvas = null;
156
+ let liveDragPreviewPendingClear = false;
157
+
158
+ function requestVisibleCanvasStageRender(canvas) {
159
+ if (!CANVAS_STAGE_RENDER || !canvas) return;
160
+ pendingCanvasStageCanvas = canvas;
161
+ syncCanvasStageLayout(canvas);
162
+ if (canvasStageRenderScheduled) return;
163
+ canvasStageRenderScheduled = true;
164
+ requestAnimationFrame(() => {
165
+ canvasStageRenderScheduled = false;
166
+ renderVisibleCanvasStage(pendingCanvasStageCanvas);
167
+ });
168
+ }
169
+
170
+ function setStageOverlaySvg(svgMarkup) {
171
+ const template = document.createElement("template");
172
+ template.innerHTML = String(svgMarkup || "").trim();
173
+ const nextSvg = template.content.querySelector("svg");
174
+ if (!nextSvg) {
175
+ stage.innerHTML = String(svgMarkup || "");
176
+ return;
177
+ }
178
+ const previousSvg = stage.querySelector("svg:not(#stageLiveSvg)");
179
+ if (previousSvg) {
180
+ previousSvg.replaceWith(nextSvg);
181
+ return;
182
+ }
183
+ const stageCanvas = document.getElementById("stageCanvas");
184
+ if (stageCanvas && stageCanvas.parentElement === stage) stageCanvas.after(nextSvg);
185
+ else stage.appendChild(nextSvg);
186
+ }
187
+
188
+ function syncCanvasStageLayout(canvas) {
189
+ const svg = currentSvg();
190
+ if (!CANVAS_STAGE_RENDER || !svg || !canvas) return null;
191
+ const size = canvasSize(canvas);
192
+ let stageCanvas = document.getElementById("stageCanvas");
193
+ if (!stageCanvas || stageCanvas.parentElement !== stage) {
194
+ stageCanvas = document.createElement("canvas");
195
+ stageCanvas.id = "stageCanvas";
196
+ stage.insertBefore(stageCanvas, svg);
197
+ } else if (stageCanvas.nextSibling !== svg) {
198
+ stage.insertBefore(stageCanvas, svg);
199
+ }
200
+ stage.classList.add("sketchmarkCanvasStage");
201
+ const display = canvasStageDisplaySize(size);
202
+ stage.style.width = display.width + "px";
203
+ stage.style.height = display.height + "px";
204
+ stageCanvas.style.width = display.width + "px";
205
+ stageCanvas.style.height = display.height + "px";
206
+ svg.style.width = display.width + "px";
207
+ svg.style.height = display.height + "px";
208
+ svg.style.maxWidth = "none";
209
+ svg.style.maxHeight = "none";
210
+ const liveSvg = document.getElementById("stageLiveSvg");
211
+ if (liveSvg && liveSvg.parentElement === stage) {
212
+ liveSvg.style.width = display.width + "px";
213
+ liveSvg.style.height = display.height + "px";
214
+ liveSvg.style.maxWidth = "none";
215
+ liveSvg.style.maxHeight = "none";
216
+ }
217
+ const pixelRatio = Math.max(1, Math.min(3, Number(window.devicePixelRatio || 1)));
218
+ const pixelWidth = Math.max(1, Math.round(display.width * pixelRatio));
219
+ const pixelHeight = Math.max(1, Math.round(display.height * pixelRatio));
220
+ return { svg, stageCanvas, display, pixelRatio, pixelWidth, pixelHeight };
221
+ }
222
+
223
+ function renderVisibleCanvasStage(canvas) {
224
+ const layout = syncCanvasStageLayout(canvas);
225
+ if (!layout) return;
226
+ const { svg, stageCanvas, display, pixelRatio, pixelWidth, pixelHeight } = layout;
227
+ const context = stageCanvas.getContext("2d");
228
+ if (!context) return;
229
+ const serialized = serializeStageSvgForCanvas(svg);
230
+ const token = ++canvasStageRenderToken;
231
+ const image = new Image();
232
+ const url = URL.createObjectURL(new Blob([serialized], { type: "image/svg+xml;charset=utf-8" }));
233
+ image.onload = () => {
234
+ URL.revokeObjectURL(url);
235
+ if (token !== canvasStageRenderToken || !stage.contains(stageCanvas)) return;
236
+ if (stageCanvas.width !== pixelWidth) stageCanvas.width = pixelWidth;
237
+ if (stageCanvas.height !== pixelHeight) stageCanvas.height = pixelHeight;
238
+ context.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
239
+ context.clearRect(0, 0, display.width, display.height);
240
+ context.drawImage(image, 0, 0, display.width, display.height);
241
+ if (liveDragPreviewPendingClear && !drag) clearLiveDragPreview();
242
+ };
243
+ image.onerror = () => {
244
+ URL.revokeObjectURL(url);
245
+ };
246
+ image.src = url;
247
+ }
248
+
249
+ function canvasStageDisplaySize(size) {
250
+ const availableWidth = Math.max(1, Number(stageWrap && stageWrap.clientWidth || size.width) - 2);
251
+ const availableHeight = Math.max(1, Number(stageWrap && stageWrap.clientHeight || size.height) - 2);
252
+ const scale = Math.min(1, availableWidth / size.width, availableHeight / size.height);
253
+ return {
254
+ width: Math.max(1, Math.round(size.width * scale)),
255
+ height: Math.max(1, Math.round(size.height * scale))
256
+ };
257
+ }
258
+
259
+ function serializeStageSvgForCanvas(svg) {
260
+ const clone = svg.cloneNode(true);
261
+ const handles = clone.querySelector("#__sketchmark_handles");
262
+ if (handles) handles.remove();
263
+ const preview = clone.querySelector("#__sketchmark_drag_preview");
264
+ if (preview) preview.remove();
265
+ if (drag && drag.id) {
266
+ const dragged = clone.querySelector("#" + cssId(drag.id));
267
+ if (dragged) dragged.remove();
268
+ }
269
+ clone.setAttribute("xmlns", "http://www.w3.org/2000/svg");
270
+ return new XMLSerializer().serializeToString(clone);
271
+ }
272
+
273
+ function showLiveDragPreview(target) {
274
+ if (!CANVAS_STAGE_RENDER || !target) return;
275
+ const svg = target.ownerSVGElement;
276
+ if (!svg) return;
277
+ syncLiveDragSvg(svg);
278
+ stage.classList.add("liveSvgDrag");
279
+ liveDragPreviewPendingClear = false;
280
+ }
281
+
282
+ function syncLiveDragSvg(svg) {
283
+ const clone = svg.cloneNode(true);
284
+ const handles = clone.querySelector("#__sketchmark_handles");
285
+ if (handles) handles.remove();
286
+ const preview = clone.querySelector("#__sketchmark_drag_preview");
287
+ if (preview) preview.remove();
288
+ clone.setAttribute("id", "stageLiveSvg");
289
+ clone.setAttribute("aria-hidden", "true");
290
+ let liveSvg = document.getElementById("stageLiveSvg");
291
+ if (liveSvg) liveSvg.replaceWith(clone);
292
+ else svg.after(clone);
293
+ clone.style.width = svg.style.width;
294
+ clone.style.height = svg.style.height;
295
+ clone.style.maxWidth = "none";
296
+ clone.style.maxHeight = "none";
297
+ }
298
+
299
+ function releaseLiveDragPreview(waitForDraw) {
300
+ if (!CANVAS_STAGE_RENDER || !stage.classList.contains("liveSvgDrag")) return;
301
+ liveDragPreviewPendingClear = true;
302
+ if (waitForDraw) requestDraw();
303
+ else requestVisibleCanvasStageRender(doc && doc.canvas);
304
+ }
305
+
306
+ function clearLiveDragPreview() {
307
+ const preview = stage.querySelector("#__sketchmark_drag_preview");
308
+ if (preview) preview.remove();
309
+ const liveSvg = document.getElementById("stageLiveSvg");
310
+ if (liveSvg) liveSvg.remove();
311
+ stage.classList.remove("liveSvgDrag");
312
+ liveDragPreviewPendingClear = false;
313
+ }
314
+
315
+ function browserStoragePanel() {
316
+ if (!LOCAL_DOCUMENT_CONTROLS) return "";
317
+ const api = window.__SKETCHMARK_BROWSER_API__;
318
+ const status = api && api.storageStatus ? api.storageStatus() : "Browser-local document";
319
+ const body =
320
+ "<div class='browserFileGrid'>" +
321
+ "<input id='browserImportFile' class='browserFileInput' type='file' accept='.json,.visual.json,application/json'>" +
322
+ "<div class='browserFileActions'><button id='browserSaveLocal' type='button'>Save local</button><button id='browserResetDocument' type='button'>Reset</button></div>" +
323
+ "<div id='browserStatus' class='browserStatus'>" + escapeText(status) + "</div>" +
324
+ "</div>";
325
+ return panelDetails("tree-browser", "Browser", body, { defaultOpen: true, meta: "local" });
326
+ }
327
+
328
+ function bindBrowserStoragePanel() {
329
+ if (!LOCAL_DOCUMENT_CONTROLS) return;
330
+ const api = window.__SKETCHMARK_BROWSER_API__;
331
+ const file = document.getElementById("browserImportFile");
332
+ if (file) {
333
+ file.onchange = async () => {
334
+ const selectedFile = file.files && file.files[0];
335
+ if (!selectedFile || !api || !api.replaceDocument) return;
336
+ try {
337
+ const text = await selectedFile.text();
338
+ await Promise.resolve(api.replaceDocument(JSON.parse(text), selectedFile.name.replace(/\\.visual\\.json$/i, "").replace(/\\.json$/i, "")));
339
+ selectedId = "";
340
+ currentTime = 0;
341
+ await load();
342
+ } catch (error) {
343
+ showError(error);
344
+ } finally {
345
+ file.value = "";
346
+ }
347
+ };
348
+ }
349
+ const save = document.getElementById("browserSaveLocal");
350
+ if (save) {
351
+ save.onclick = () => {
352
+ if (api && api.saveDocument) api.saveDocument();
353
+ showBrowserStorageStatus();
354
+ };
355
+ }
356
+ const reset = document.getElementById("browserResetDocument");
357
+ if (reset) {
358
+ reset.onclick = async () => {
359
+ if (!api || !api.resetDocument) return;
360
+ if (!window.confirm("Reset the local Sketchmark document?")) return;
361
+ try {
362
+ await Promise.resolve(api.resetDocument());
363
+ selectedId = "";
364
+ currentTime = 0;
365
+ await load();
366
+ } catch (error) {
367
+ showError(error);
368
+ }
369
+ };
370
+ }
371
+ showBrowserStorageStatus();
372
+ }
373
+
374
+ function showBrowserStorageStatus() {
375
+ const api = window.__SKETCHMARK_BROWSER_API__;
376
+ const box = document.getElementById("browserStatus");
377
+ if (box && api && api.storageStatus) box.textContent = api.storageStatus();
138
378
  }
139
379
 
140
- curveModalClose.onclick = closeCurveModal;
380
+ function apiPath(path) {
381
+ return API_BASE + path;
382
+ }
383
+
384
+ curveModalClose.onclick = closeCurveModal;
141
385
  curveModalBackdrop.onclick = (event) => {
142
386
  if (event.target === curveModalBackdrop) closeCurveModal();
143
387
  };
@@ -145,16 +389,16 @@ curveModal.onclick = (event) => event.stopPropagation();
145
389
  zoomOut.onclick = () => zoomBy(1.12);
146
390
  zoomIn.onclick = () => zoomBy(1 / 1.12);
147
391
  zoomFit.onclick = () => resetViewport(true);
148
- async function api(path, options) {
392
+ async function api(path, options) {
149
393
  const response = await fetch(path, options || { cache: "no-store" });
150
394
  const data = await response.json();
151
395
  if (!data.ok) throw new Error(data.error || "Request failed.");
152
396
  return data;
153
397
  }
154
398
 
155
- async function load() {
156
- clearSidebarCommitTimers();
157
- const data = await api(apiPath("/document"));
399
+ async function load() {
400
+ clearSidebarCommitTimers();
401
+ const data = await api(apiPath("/document"));
158
402
  doc = data.document;
159
403
  refs = data.elements;
160
404
  rebuildElementIndex();
@@ -174,17 +418,18 @@ async function draw() {
174
418
  drawInFlight = true;
175
419
  const time = currentTime;
176
420
  try {
177
- const data = await api(apiPath("/frame") + "?time=" + encodeURIComponent(time));
421
+ const data = await api(apiPath("/frame") + "?time=" + encodeURIComponent(time));
178
422
  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();
423
+ if (CANVAS_STAGE_RENDER) setStageOverlaySvg(data.svg);
424
+ else stage.innerHTML = data.svg;
425
+ const svg = currentSvg();
426
+ if (svg) {
427
+ svg.style.overflow = "visible";
428
+ applyEditorFlagsToStage();
429
+ applyViewportToSvg(svg, data.canvas || (doc && doc.canvas));
430
+ } else {
431
+ updateZoomLabel();
432
+ }
188
433
  const selected = selectedId ? stage.querySelector("#" + cssId(selectedId)) : null;
189
434
  if (selected && !isElementHidden(selectedId) && !isElementLocked(selectedId)) {
190
435
  drawHandles(selected);
@@ -240,10 +485,11 @@ function ensureViewportState(canvas, forceReset) {
240
485
  function applyViewportToSvg(svg, canvas) {
241
486
  if (!svg) return;
242
487
  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
- }
488
+ svg.setAttribute("viewBox", viewport.x.toFixed(3) + " " + viewport.y.toFixed(3) + " " + viewport.width.toFixed(3) + " " + viewport.height.toFixed(3));
489
+ svg.setAttribute("preserveAspectRatio", "xMidYMid meet");
490
+ updateZoomLabel();
491
+ requestVisibleCanvasStageRender(canvas);
492
+ }
247
493
 
248
494
  function updateZoomLabel() {
249
495
  if (!zoomLabel) return;
@@ -282,9 +528,9 @@ function zoomFactorFromWheel(event) {
282
528
  return Math.exp(dy * 0.002);
283
529
  }
284
530
 
285
- function currentSvg() {
286
- return stage.querySelector("svg");
287
- }
531
+ function currentSvg() {
532
+ return stage.querySelector("svg:not(#stageLiveSvg)") || stage.querySelector("svg");
533
+ }
288
534
 
289
535
  function svgPointFromClient(svg, clientX, clientY) {
290
536
  if (!svg || !svg.getScreenCTM) return null;
@@ -437,11 +683,13 @@ function renderTree() {
437
683
  "<div class='row'>" + colorTextInput("Background", "canvasBackground", "", valueOr(canvas.background, ""), "", "#ffffff") + "<div></div></div>" +
438
684
  "<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
685
  "<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();
686
+ tree.innerHTML =
687
+ browserStoragePanel() +
688
+ panelDetails("tree-canvas", "Canvas", canvasBody, { defaultOpen: false, meta: canvasSummary }) +
689
+ panelDetails("tree-elements", "Elements", "<div id='elementsTree'></div>", { defaultOpen: false, meta: refs.length + " items" });
690
+ bindPanelStates(tree);
691
+ bindCanvasInputs();
692
+ bindBrowserStoragePanel();
445
693
  const treeRoot = document.getElementById("elementsTree");
446
694
  if (!treeRoot) return;
447
695
  for (const ref of refs) {
@@ -516,7 +764,7 @@ function scheduleCanvasCommit(property, reader) {
516
764
  const value = reader();
517
765
  if (value === undefined) return;
518
766
  await mutate(
519
- apiPath("/canvas"),
767
+ apiPath("/canvas"),
520
768
  { [property]: value },
521
769
  { refreshTree: false, refreshInspector: false, refreshTimeline: true }
522
770
  );
@@ -682,15 +930,15 @@ function deselect() {
682
930
  requestDraw();
683
931
  }
684
932
 
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
- }
933
+ function renderInspector() {
934
+ const element = findElement(selectedId);
935
+ const exportPanel = renderExportPanel();
936
+ if (!element) {
937
+ inspector.innerHTML = "<div class='muted'>Select an element.</div>" + exportPanel;
938
+ bindPanelStates(inspector);
939
+ bindExportButtons();
940
+ return;
941
+ }
694
942
  const displayElement = findResolvedElement(selectedId) || element;
695
943
  const supportsPosition = ["path","point","text","image","group"].includes(element.type);
696
944
  const supportsOrigin = ["path","text","image","group"].includes(element.type);
@@ -739,10 +987,10 @@ function renderInspector() {
739
987
  const sourceRows = element.type === "image" ? renderImageSourceRows(displayElement, lockDisabled) : "";
740
988
  const structuredPaintRows = supportsPaint ? renderStructuredPaintRows(displayElement, "fill", lockDisabled) + renderStructuredPaintRows(displayElement, "stroke", lockDisabled) : "";
741
989
  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>" : "");
990
+ const selectedRows =
991
+ "<strong>" + escapeText(element.id || "") + "</strong>" +
992
+ "<div class='muted'>" + selectedMeta + "</div>" +
993
+ (locked ? "<div class='tiny'>Locked elements and groups cannot be edited from canvas or inspector.</div>" : "");
746
994
  const transformRows =
747
995
  "<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
996
  "<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 +1001,22 @@ function renderInspector() {
753
1001
  paintRows +
754
1002
  paintHint;
755
1003
  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>" +
1004
+ const keyframeRows =
1005
+ "<div class='row'><label>Time<input id='kfTime' type='number' step='0.05' value='" + currentTime.toFixed(2) + "'></label><div></div></div>" +
758
1006
  "<p class='tiny'>Changing sidebar values updates keyframes at the current time.</p>" +
759
1007
  "<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();
1008
+ "<p class='tiny'>Drag to move. Use the square to scale and the round handle to rotate.</p>";
1009
+ inspector.innerHTML =
1010
+ panelDetails("inspector-selected", "Selected", selectedRows, { defaultOpen: true, meta: element.type }) +
1011
+ panelDetails("inspector-transform", "Transform", transformRows, { defaultOpen: false }) +
1012
+ panelDetails("inspector-appearance", "Appearance", appearanceRows, { defaultOpen: false }) +
1013
+ (contentRows ? panelDetails("inspector-content", "Path / Content", contentRows, { defaultOpen: false }) : "") +
1014
+ (supportsEffects ? panelDetails("inspector-effects", "Effects", effectsRows, { defaultOpen: false }) : "") +
1015
+ (structuredPaintRows ? panelDetails("inspector-structured-paint", "Structured Paint", structuredPaintRows, { defaultOpen: false }) : "") +
1016
+ panelDetails("inspector-keyframe", "Keyframe", keyframeRows, { defaultOpen: false }) +
1017
+ exportPanel;
1018
+ bindPanelStates(inspector);
1019
+ bindExportButtons();
772
1020
  if (supportsPaint) {
773
1021
  setInput("propFill", typeof displayElement.fill === "string" ? displayElement.fill : "");
774
1022
  setInput("propStroke", typeof displayElement.stroke === "string" ? displayElement.stroke : "");
@@ -828,39 +1076,39 @@ function renderInspector() {
828
1076
  }
829
1077
  bindDynamicInspectorInputs(bindAutoKeyframe);
830
1078
  }
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) {
1079
+ bindColorPickersInScope(inspector);
1080
+ }
1081
+
1082
+ function renderExportPanel() {
1083
+ const rows =
1084
+ "<div class='exportButtons'>" +
1085
+ "<button id='exportSvg' type='button' title='Export current frame as SVG'>SVG</button>" +
1086
+ "<button id='exportPng' type='button' title='Export current frame as PNG'>PNG</button>" +
1087
+ "<button id='exportJpg' type='button' title='Export current frame as JPG'>JPG</button>" +
1088
+ "<button id='exportHtml' type='button' title='Export current frame as HTML'>HTML</button>" +
1089
+ "<button id='exportJson' type='button' title='Export kernel JSON'>JSON</button>" +
1090
+ "<button id='exportMp4' type='button' title='Export full animation as MP4'>MP4</button>" +
1091
+ "</div>" +
1092
+ "<div id='error'></div>" +
1093
+ "<p class='tiny'>MP4 exports in the browser. Chrome or Edge is recommended.</p>";
1094
+ return panelDetails("inspector-export", "Export", rows, { defaultOpen: true });
1095
+ }
1096
+
1097
+ function bindExportButtons() {
1098
+ bindExportButton("exportSvg", "svg");
1099
+ bindExportButton("exportPng", "png");
1100
+ bindExportButton("exportJpg", "jpg");
1101
+ bindExportButton("exportHtml", "html");
1102
+ bindExportButton("exportJson", "json");
1103
+ bindExportButton("exportMp4", "mp4");
1104
+ }
1105
+
1106
+ function bindExportButton(id, format) {
1107
+ const button = document.getElementById(id);
1108
+ if (button) button.onclick = () => exportDocument(format, button);
1109
+ }
1110
+
1111
+ function fontFamilyOptionsHtml(currentValue) {
864
1112
  const current = String(valueOr(currentValue, "")).trim();
865
1113
  const seen = new Set();
866
1114
  const options = [];
@@ -1072,12 +1320,12 @@ function bindDynamicInspectorInputs(bindAutoKeyframe) {
1072
1320
  }
1073
1321
  }
1074
1322
 
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>";
1079
- document.getElementById("play").onclick = togglePlay;
1080
- document.getElementById("refresh").onclick = load;
1323
+ function renderTimeline() {
1324
+ const element = findElement(selectedId);
1325
+ const tracks = element && element.timeline && element.timeline.tracks ? element.timeline.tracks : {};
1326
+ 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>";
1327
+ document.getElementById("play").onclick = togglePlay;
1328
+ document.getElementById("refresh").onclick = load;
1081
1329
  document.getElementById("scrub").oninput = (event) => {
1082
1330
  setCurrentTime(event.target.value);
1083
1331
  };
@@ -1140,229 +1388,229 @@ function renderTimeline() {
1140
1388
  if (isCurveModalOpen()) refreshCurveModal(tracks);
1141
1389
  }
1142
1390
 
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 {
1391
+ async function exportDocument(format, triggerButton) {
1392
+ const button = triggerButton || document.getElementById("exportMenuBtn");
1393
+ const label = button ? button.textContent : "";
1394
+ try {
1395
+ if (button) {
1396
+ button.disabled = true;
1397
+ button.textContent = format === "mp4" ? "Exporting..." : "Export...";
1398
+ }
1399
+ if (format === "json") {
1400
+ downloadBlob(
1401
+ new Blob([JSON.stringify(doc, null, 2)], { type: "application/json;charset=utf-8" }),
1402
+ safeFileName(EDITOR_TITLE) + ".json"
1403
+ );
1404
+ return;
1405
+ }
1406
+ if (format === "svg" || format === "png" || format === "jpg" || format === "html") {
1407
+ await exportCurrentFrameInBrowser(format);
1408
+ return;
1409
+ }
1410
+ if (format === "mp4") {
1411
+ try {
1412
+ await exportMp4InBrowser(button);
1413
+ return;
1414
+ } catch (error) {
1415
+ if (!SERVER_EXPORT_FALLBACK) throw error;
1416
+ }
1417
+ }
1418
+ await exportViaServer(format);
1419
+ } catch (error) {
1420
+ showError(error);
1421
+ } finally {
1174
1422
  if (button) {
1175
1423
  button.disabled = false;
1176
1424
  button.textContent = label;
1177
1425
  }
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
- }
1426
+ }
1427
+ }
1428
+
1429
+ async function exportCurrentFrameInBrowser(format) {
1430
+ const data = await api(apiPath("/frame") + "?time=" + encodeURIComponent(currentTime));
1431
+ const width = Math.max(1, Number(data.canvas && data.canvas.width || 1));
1432
+ const height = Math.max(1, Number(data.canvas && data.canvas.height || 1));
1433
+ const baseName = safeFileName(EDITOR_TITLE);
1434
+ const frameName = baseName + "-t" + Number(currentTime).toFixed(2).replace(".", "-");
1435
+ if (format === "svg") {
1436
+ downloadBlob(new Blob([data.svg], { type: "image/svg+xml;charset=utf-8" }), frameName + ".svg");
1437
+ return;
1438
+ }
1439
+ if (format === "html") {
1440
+ 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>";
1441
+ downloadBlob(new Blob([html], { type: "text/html;charset=utf-8" }), frameName + ".html");
1442
+ return;
1443
+ }
1444
+ const mimeType = format === "jpg" ? "image/jpeg" : "image/png";
1445
+ const blob = await rasterizeSvgBlob(data.svg, width, height, mimeType, format === "jpg" ? 0.92 : undefined);
1446
+ downloadBlob(blob, frameName + "." + format);
1447
+ }
1448
+
1449
+ async function exportViaServer(format) {
1450
+ const response = await fetch(apiPath("/export") + "?format=" + encodeURIComponent(format) + "&time=" + encodeURIComponent(currentTime), { cache: "no-store" });
1451
+ if (!response.ok) {
1452
+ let message = "Export failed.";
1453
+ try {
1454
+ const data = await response.json();
1455
+ message = data.error || message;
1456
+ } catch {}
1457
+ throw new Error(message);
1458
+ }
1459
+ const blob = await response.blob();
1460
+ downloadBlob(blob, filenameFromDisposition(response.headers.get("content-disposition")) || ("sketchmark." + format));
1461
+ }
1462
+
1463
+ async function exportMp4InBrowser(button) {
1464
+ const VideoEncoderCtor = window.VideoEncoder;
1465
+ const VideoFrameCtor = window.VideoFrame;
1466
+ if (!VideoEncoderCtor || !VideoFrameCtor) {
1467
+ throw new Error("Browser MP4 export requires WebCodecs. Try Chrome or Edge.");
1468
+ }
1469
+ if (!MP4_MUXER_URL && !MP4_MUXER_SOURCE) {
1470
+ throw new Error("Browser MP4 export is not configured for this editor.");
1471
+ }
1472
+ const duration = Number(doc && doc.canvas && doc.canvas.duration || 0);
1473
+ if (!Number.isFinite(duration) || duration <= 0) {
1474
+ throw new Error("MP4 export requires a positive canvas.duration.");
1475
+ }
1476
+ const fps = Math.max(1, Math.round(Number(doc && doc.canvas && doc.canvas.fps || 30) || 30));
1477
+ const sourceWidth = Math.max(1, Number(doc && doc.canvas && doc.canvas.width || 1));
1478
+ const sourceHeight = Math.max(1, Number(doc && doc.canvas && doc.canvas.height || 1));
1479
+ const width = evenDimension(sourceWidth);
1480
+ const height = evenDimension(sourceHeight);
1481
+ const totalFrames = Math.max(1, Math.ceil(duration * fps));
1482
+ const muxerModule = await importMp4Muxer();
1483
+ const target = new muxerModule.ArrayBufferTarget();
1484
+ const muxer = new muxerModule.Muxer({
1485
+ target,
1486
+ video: { codec: "avc", width, height },
1487
+ fastStart: "in-memory"
1488
+ });
1489
+ let encoderError = null;
1490
+ const encoder = new VideoEncoderCtor({
1491
+ output: (chunk, meta) => muxer.addVideoChunk(chunk, meta),
1492
+ error: (error) => { encoderError = error; }
1493
+ });
1494
+ encoder.configure({
1495
+ codec: "avc1.640028",
1496
+ width,
1497
+ height,
1498
+ bitrate: 5000000,
1499
+ framerate: fps
1500
+ });
1501
+ const canvas = document.createElement("canvas");
1502
+ canvas.width = width;
1503
+ canvas.height = height;
1504
+ try {
1505
+ for (let frameIndex = 0; frameIndex < totalFrames; frameIndex += 1) {
1506
+ const time = Math.min(duration, frameIndex / fps);
1507
+ const data = await api(apiPath("/frame") + "?time=" + encodeURIComponent(time));
1508
+ await drawSvgToCanvas(data.svg, canvas, width, height);
1509
+ const frame = new VideoFrameCtor(canvas, {
1510
+ timestamp: Math.round((frameIndex / fps) * 1000000),
1511
+ duration: Math.round((1 / fps) * 1000000)
1512
+ });
1513
+ encoder.encode(frame, { keyFrame: frameIndex % Math.max(1, fps * 2) === 0 });
1514
+ frame.close();
1515
+ if (encoderError) throw encoderError;
1516
+ if (frameIndex % 5 === 0 || frameIndex === totalFrames - 1) {
1517
+ const progress = Math.round(((frameIndex + 1) / totalFrames) * 100);
1518
+ if (button) button.textContent = "Exporting " + progress + "%";
1519
+ await yieldToBrowser();
1520
+ }
1521
+ }
1522
+ await encoder.flush();
1523
+ if (encoderError) throw encoderError;
1524
+ encoder.close();
1525
+ muxer.finalize();
1526
+ downloadBlob(new Blob([target.buffer], { type: "video/mp4" }), safeFileName(EDITOR_TITLE) + ".mp4");
1527
+ } catch (error) {
1528
+ try { encoder.close(); } catch {}
1529
+ throw error;
1530
+ }
1531
+ }
1532
+
1533
+ async function importMp4Muxer() {
1534
+ if (MP4_MUXER_URL) return import(MP4_MUXER_URL);
1535
+ if (MP4_MUXER_SOURCE) {
1536
+ if (!mp4MuxerObjectUrl) {
1537
+ mp4MuxerObjectUrl = URL.createObjectURL(new Blob([MP4_MUXER_SOURCE], { type: "text/javascript" }));
1538
+ }
1539
+ return import(mp4MuxerObjectUrl);
1540
+ }
1541
+ throw new Error("Browser MP4 export is not available in this editor build.");
1542
+ }
1543
+
1544
+ function filenameFromDisposition(header) {
1545
+ const match = /filename="([^"]+)"/.exec(header || "");
1546
+ return match ? match[1] : "";
1547
+ }
1548
+
1549
+ function rasterizeSvgBlob(svg, width, height, mimeType, quality) {
1550
+ const canvas = document.createElement("canvas");
1551
+ canvas.width = width;
1552
+ canvas.height = height;
1553
+ return drawSvgToCanvas(svg, canvas, width, height).then(() => canvasToBlob(canvas, mimeType || "image/png", quality));
1554
+ }
1555
+
1556
+ function drawSvgToCanvas(svg, canvas, width, height) {
1557
+ return new Promise((resolve, reject) => {
1558
+ const context = canvas.getContext("2d");
1559
+ if (!context) {
1560
+ reject(new Error("Could not create canvas context."));
1561
+ return;
1562
+ }
1563
+ const url = URL.createObjectURL(new Blob([svg], { type: "image/svg+xml;charset=utf-8" }));
1564
+ const image = new Image();
1565
+ image.onload = () => {
1566
+ URL.revokeObjectURL(url);
1567
+ context.clearRect(0, 0, width, height);
1568
+ context.drawImage(image, 0, 0, width, height);
1569
+ resolve();
1570
+ };
1571
+ image.onerror = () => {
1572
+ URL.revokeObjectURL(url);
1573
+ reject(new Error("Could not rasterize current SVG frame."));
1574
+ };
1575
+ image.src = url;
1576
+ });
1577
+ }
1578
+
1579
+ function canvasToBlob(canvas, mimeType, quality) {
1580
+ return new Promise((resolve, reject) => {
1581
+ canvas.toBlob((blob) => {
1582
+ if (blob) resolve(blob);
1583
+ else reject(new Error("Could not export the canvas frame. Cross-origin image assets can block browser raster export."));
1584
+ }, mimeType, quality);
1585
+ });
1586
+ }
1587
+
1588
+ function downloadBlob(blob, filename) {
1589
+ const link = document.createElement("a");
1590
+ link.href = URL.createObjectURL(blob);
1591
+ link.download = filename;
1592
+ document.body.appendChild(link);
1593
+ link.click();
1594
+ link.remove();
1595
+ setTimeout(() => URL.revokeObjectURL(link.href), 1000);
1596
+ }
1597
+
1598
+ function evenDimension(value) {
1599
+ const rounded = Math.max(2, Math.round(Number(value) || 2));
1600
+ return rounded % 2 === 0 ? rounded : rounded + 1;
1601
+ }
1602
+
1603
+ function safeFileName(value) {
1604
+ return String(value || "sketchmark").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 80) || "sketchmark";
1605
+ }
1606
+
1607
+ function yieldToBrowser() {
1608
+ return new Promise((resolve) => window.setTimeout(resolve, 0));
1609
+ }
1610
+
1611
+ function isCurveModalOpen() {
1612
+ return !curveModalBackdrop.classList.contains("hidden");
1613
+ }
1366
1614
 
1367
1615
  function openCurveModal() {
1368
1616
  refreshCurveModal();
@@ -1705,7 +1953,7 @@ async function applySegmentPreset(property, segmentIndex, preset) {
1705
1953
  if (segmentIndex < 0 || segmentIndex >= frames.length - 1) return;
1706
1954
  const start = frames[segmentIndex];
1707
1955
  await mutate(
1708
- apiPath("/keyframe"),
1956
+ apiPath("/keyframe"),
1709
1957
  { id: selectedId, property, value: start.value, time: start.time, curvePreset: preset },
1710
1958
  { refreshTree: false, refreshInspector: false, refreshTimeline: true }
1711
1959
  );
@@ -1720,7 +1968,7 @@ async function applySegmentCurve(property, segmentIndex, curve) {
1720
1968
  if (segmentIndex < 0 || segmentIndex >= frames.length - 1) return;
1721
1969
  const start = frames[segmentIndex];
1722
1970
  await mutate(
1723
- apiPath("/keyframe"),
1971
+ apiPath("/keyframe"),
1724
1972
  { id: selectedId, property, value: start.value, time: start.time, curve },
1725
1973
  { refreshTree: false, refreshInspector: false, refreshTimeline: true }
1726
1974
  );
@@ -1863,7 +2111,7 @@ function scheduleSidebarKeyframe(property, valueReader) {
1863
2111
  if (value === null || value === undefined) return;
1864
2112
  try {
1865
2113
  await mutate(
1866
- apiPath("/keyframe"),
2114
+ apiPath("/keyframe"),
1867
2115
  { id: selectedId, property, value, time: currentTime, curvePreset: "linear" },
1868
2116
  { refreshTree: false, refreshInspector: false, refreshTimeline: true }
1869
2117
  );
@@ -1888,7 +2136,7 @@ function readTextInput(id) {
1888
2136
  async function removeKeyframe(property, time) {
1889
2137
  try {
1890
2138
  if (!ensureElementEditable(selectedId)) return;
1891
- await mutate(apiPath("/remove-keyframe"), { id: selectedId, property, time });
2139
+ await mutate(apiPath("/remove-keyframe"), { id: selectedId, property, time });
1892
2140
  } catch (error) {
1893
2141
  showError(error);
1894
2142
  }
@@ -2053,15 +2301,25 @@ stage.addEventListener("pointermove", (event) => {
2053
2301
  stage.addEventListener("pointerup", finishDrag);
2054
2302
  stage.addEventListener("pointercancel", finishDrag);
2055
2303
 
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
- }
2304
+ async function finishDrag() {
2305
+ if (!drag) return;
2306
+ const snapshot = drag;
2307
+ drag = null;
2308
+ suppressClick = true;
2309
+ let committed = false;
2310
+ try {
2311
+ if (snapshot.changed) {
2312
+ await commitDrag(snapshot);
2313
+ committed = true;
2314
+ }
2315
+ } finally {
2316
+ if (snapshot.changed && !committed && snapshot.target) {
2317
+ if (snapshot.transform) snapshot.target.setAttribute("transform", snapshot.transform);
2318
+ else snapshot.target.removeAttribute("transform");
2319
+ }
2320
+ releaseLiveDragPreview(committed);
2321
+ }
2322
+ }
2065
2323
 
2066
2324
  function startDrag(event, target, mode) {
2067
2325
  if (!target || isElementLocked(target.id) || isElementHidden(target.id)) return;
@@ -2082,10 +2340,12 @@ function startDrag(event, target, mode) {
2082
2340
  changed: false,
2083
2341
  value: null
2084
2342
  };
2085
- event.preventDefault();
2086
- event.stopPropagation();
2087
- stage.setPointerCapture?.(event.pointerId);
2088
- }
2343
+ event.preventDefault();
2344
+ event.stopPropagation();
2345
+ stage.setPointerCapture?.(event.pointerId);
2346
+ showLiveDragPreview(target);
2347
+ requestVisibleCanvasStageRender(doc && doc.canvas);
2348
+ }
2089
2349
 
2090
2350
  async function commitDrag(snapshot) {
2091
2351
  const element = findElement(snapshot.id);
@@ -2101,7 +2361,7 @@ async function commitDrag(snapshot) {
2101
2361
 
2102
2362
  async function commitEditedProperty(element, property, value) {
2103
2363
  if (!ensureElementEditable(element.id)) return;
2104
- await mutate(apiPath("/keyframe"), { id: element.id, property, value, time: currentTime, curvePreset: "linear" });
2364
+ await mutate(apiPath("/keyframe"), { id: element.id, property, value, time: currentTime, curvePreset: "linear" });
2105
2365
  }
2106
2366
 
2107
2367
  function ensureElementEditable(id) {
@@ -2181,10 +2441,11 @@ function parentPoint(event, target) {
2181
2441
  point.y = event.clientY;
2182
2442
  return point.matrixTransform(matrix.inverse());
2183
2443
  }
2184
- function previewDraggedTransform(prefix) {
2185
- if (!drag || !drag.target) return;
2186
- drag.target.setAttribute("transform", prefix + (drag.transform ? " " + drag.transform : ""));
2187
- }
2444
+ function previewDraggedTransform(prefix) {
2445
+ if (!drag || !drag.target) return;
2446
+ drag.target.setAttribute("transform", prefix + (drag.transform ? " " + drag.transform : ""));
2447
+ showLiveDragPreview(drag.target);
2448
+ }
2188
2449
  function selectedTarget() {
2189
2450
  if (!selectedId || isElementHidden(selectedId) || isElementLocked(selectedId)) return null;
2190
2451
  return stage.querySelector("#" + cssId(selectedId));
@@ -2214,12 +2475,13 @@ function drawHandles(target) {
2214
2475
  }
2215
2476
  if (!matrix) return;
2216
2477
  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);
2478
+ const topRight = matrixPoint(svg, matrix, box.x + box.width, box.y);
2479
+ const bottomRight = matrixPoint(svg, matrix, box.x + box.width, box.y + box.height);
2480
+ const bottomLeft = matrixPoint(svg, matrix, box.x, box.y + box.height);
2481
+ const center = matrixPoint(svg, matrix, box.x + box.width / 2, box.y + box.height / 2);
2482
+ const topCenter = midpoint(topLeft, topRight);
2483
+ const rotate = offsetFromPoint(center, topCenter, 32);
2484
+ const scale = matrixPoint(svg, matrix, box.x + box.width, box.y + box.height);
2223
2485
  const group = svgNode("g");
2224
2486
  group.setAttribute("id", "__sketchmark_handles");
2225
2487
  group.setAttribute("style", "pointer-events:all");
@@ -2286,15 +2548,25 @@ function targetCenterInParent(target) {
2286
2548
  return { x: 0, y: 0 };
2287
2549
  }
2288
2550
  }
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
- }
2551
+ function matrixPoint(svg, matrix, x, y) {
2552
+ const point = svg.createSVGPoint();
2553
+ point.x = x;
2554
+ point.y = y;
2555
+ return point.matrixTransform(matrix);
2556
+ }
2557
+ function midpoint(a, b) {
2558
+ return { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
2559
+ }
2560
+ function offsetFromPoint(origin, edge, distance) {
2561
+ const dx = edge.x - origin.x;
2562
+ const dy = edge.y - origin.y;
2563
+ const length = Math.sqrt(dx * dx + dy * dy);
2564
+ if (!Number.isFinite(length) || length < 0.000001) return { x: edge.x, y: edge.y - distance };
2565
+ return { x: edge.x + (dx / length) * distance, y: edge.y + (dy / length) * distance };
2566
+ }
2567
+ function svgNode(name) {
2568
+ return document.createElementNS("http://www.w3.org/2000/svg", name);
2569
+ }
2298
2570
  function angleAround(center, point) {
2299
2571
  return Math.atan2(point.y - center.y, point.x - center.x) * 180 / Math.PI;
2300
2572
  }
@@ -2479,52 +2751,52 @@ load().catch(showError);
2479
2751
  }
2480
2752
 
2481
2753
 
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 };
2754
+ function escapeHtml(value) {
2755
+ return String(value).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
2756
+ }
2757
+
2758
+ function normalizeApiBase(value) {
2759
+ const text = String(value || "/api").replace(/\/+$/, "");
2760
+ return text.startsWith("/") ? text : `/${text}`;
2761
+ }
2762
+
2763
+ function editorMp4MuxerSource(value) {
2764
+ if (value === false) return "";
2765
+ if (typeof value === "string") return value;
2766
+ for (const candidate of mp4MuxerSourceCandidates()) {
2767
+ try {
2768
+ if (candidate && fs.existsSync(candidate)) return fs.readFileSync(candidate, "utf8");
2769
+ } catch {
2770
+ // Try the next candidate.
2771
+ }
2772
+ }
2773
+ return "";
2774
+ }
2775
+
2776
+ function resolveMp4MuxerSource(value) {
2777
+ return editorMp4MuxerSource(value);
2778
+ }
2779
+
2780
+ function mp4MuxerSourceCandidates() {
2781
+ const candidates = [path.join(__dirname, "vendor", "mp4-muxer.mjs")];
2782
+ try {
2783
+ const resolved = require.resolve("mp4-muxer");
2784
+ candidates.push(resolved.replace(/\.js$/, ".mjs"));
2785
+ candidates.push(path.join(path.dirname(resolved), "mp4-muxer.mjs"));
2786
+ } catch {
2787
+ // Dependency may be bundled differently by the host app.
2788
+ }
2789
+ candidates.push(path.join(/*turbopackIgnore: true*/ process.cwd(), "node_modules", "mp4-muxer", "build", "mp4-muxer.mjs"));
2790
+ return candidates;
2791
+ }
2792
+
2793
+ function scriptJson(value) {
2794
+ return JSON.stringify(value)
2795
+ .replace(/</g, "\\u003c")
2796
+ .replace(/>/g, "\\u003e")
2797
+ .replace(/&/g, "\\u0026")
2798
+ .replace(/\u2028/g, "\\u2028")
2799
+ .replace(/\u2029/g, "\\u2029");
2800
+ }
2801
+
2802
+ module.exports = { editorHtml, editorMp4MuxerSource };