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