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 +691 -419
- package/bin/editor-ui.d.ts +9 -6
- package/dist/src/authoring/compose.d.ts +4 -0
- package/dist/src/authoring/compose.js +49 -0
- package/dist/src/authoring/index.d.ts +7 -0
- package/dist/src/authoring/index.js +45 -0
- package/dist/src/authoring/layout.d.ts +43 -0
- package/dist/src/authoring/layout.js +89 -0
- package/dist/src/authoring/motion.d.ts +53 -0
- package/dist/src/authoring/motion.js +93 -0
- package/dist/src/authoring/pose.d.ts +20 -0
- package/dist/src/authoring/pose.js +73 -0
- package/dist/src/authoring/states.d.ts +17 -0
- package/dist/src/authoring/states.js +28 -0
- package/dist/src/authoring/types.d.ts +14 -0
- package/dist/src/authoring/types.js +2 -0
- package/dist/src/browser-editor.d.ts +15 -0
- package/dist/src/browser-editor.js +450 -0
- package/package.json +63 -59
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
|
-
|
|
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
|
|
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
|
-
|
|
137
|
-
|
|
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
|
-
|
|
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
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
}
|
|
187
|
-
|
|
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
|
-
|
|
442
|
-
panelDetails("tree-
|
|
443
|
-
|
|
444
|
-
|
|
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
|
-
|
|
2062
|
-
|
|
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
|
|
2222
|
-
const
|
|
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
|
|
2296
|
-
return
|
|
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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
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 };
|