sketchmark 2.1.5 → 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/dist/src/edit.js +86 -21
- package/dist/src/keyframes.js +12 -5
- package/dist/tests/run.js +33 -0
- package/package.json +63 -59
|
@@ -0,0 +1,450 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.createSketchmarkBrowserEditor = createSketchmarkBrowserEditor;
|
|
4
|
+
const animatable_1 = require("./animatable");
|
|
5
|
+
const browser_export_1 = require("./browser-export");
|
|
6
|
+
const edit_1 = require("./edit");
|
|
7
|
+
const normalize_1 = require("./normalize");
|
|
8
|
+
const svg_1 = require("./render/svg");
|
|
9
|
+
const utils_1 = require("./utils");
|
|
10
|
+
const validate_1 = require("./validate");
|
|
11
|
+
const STYLE_ID = "sketchmark-browser-editor-style";
|
|
12
|
+
function createSketchmarkBrowserEditor(root, options) {
|
|
13
|
+
ensureStyle();
|
|
14
|
+
const document = (0, utils_1.clone)(options.document);
|
|
15
|
+
assertValid(document);
|
|
16
|
+
const refs = (0, edit_1.listElementReferences)(document);
|
|
17
|
+
const state = {
|
|
18
|
+
root,
|
|
19
|
+
options,
|
|
20
|
+
document,
|
|
21
|
+
selectedId: refs[0]?.id ?? "",
|
|
22
|
+
currentTime: 0,
|
|
23
|
+
playing: false,
|
|
24
|
+
raf: 0,
|
|
25
|
+
saveTimer: 0,
|
|
26
|
+
lastFrame: 0
|
|
27
|
+
};
|
|
28
|
+
root.classList.add("sketchmark-browser-editor");
|
|
29
|
+
root.innerHTML = [
|
|
30
|
+
"<aside class='skme-tree'></aside>",
|
|
31
|
+
"<main class='skme-main'>",
|
|
32
|
+
" <div class='skme-toolbar'></div>",
|
|
33
|
+
" <div class='skme-stage-wrap'><div class='skme-stage'></div></div>",
|
|
34
|
+
" <div class='skme-playbar'></div>",
|
|
35
|
+
"</main>",
|
|
36
|
+
"<aside class='skme-inspector'></aside>"
|
|
37
|
+
].join("");
|
|
38
|
+
renderAll(state);
|
|
39
|
+
return {
|
|
40
|
+
getDocument: () => (0, utils_1.clone)(state.document),
|
|
41
|
+
setDocument: (next) => {
|
|
42
|
+
assertValid(next);
|
|
43
|
+
state.document = (0, utils_1.clone)(next);
|
|
44
|
+
if (state.selectedId && !(0, edit_1.findElementById)(state.document, state.selectedId))
|
|
45
|
+
state.selectedId = "";
|
|
46
|
+
renderAll(state);
|
|
47
|
+
},
|
|
48
|
+
save: () => saveNow(state),
|
|
49
|
+
destroy: () => {
|
|
50
|
+
window.clearTimeout(state.saveTimer);
|
|
51
|
+
window.cancelAnimationFrame(state.raf);
|
|
52
|
+
root.innerHTML = "";
|
|
53
|
+
root.classList.remove("sketchmark-browser-editor");
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
function renderAll(state) {
|
|
58
|
+
renderTree(state);
|
|
59
|
+
renderToolbar(state);
|
|
60
|
+
renderStage(state);
|
|
61
|
+
renderPlaybar(state);
|
|
62
|
+
renderInspector(state);
|
|
63
|
+
}
|
|
64
|
+
function renderTree(state) {
|
|
65
|
+
const tree = requirePart(state.root, ".skme-tree");
|
|
66
|
+
const refs = (0, edit_1.listElementReferences)(state.document);
|
|
67
|
+
tree.innerHTML = `<div class="skme-panel-title">Elements</div>${refs.map((ref) => {
|
|
68
|
+
const selected = ref.id === state.selectedId ? " selected" : "";
|
|
69
|
+
const pad = Math.max(0, ref.depth) * 12;
|
|
70
|
+
return `<button class="skme-tree-row${selected}" data-select="${escapeAttr(ref.id)}" style="padding-left:${pad + 6}px"><span>${escapeHtml(ref.id)}</span><small>${escapeHtml(ref.type)}</small></button>`;
|
|
71
|
+
}).join("") || "<p class='skme-muted'>No elements.</p>"}`;
|
|
72
|
+
tree.querySelectorAll("[data-select]").forEach((button) => {
|
|
73
|
+
button.onclick = () => {
|
|
74
|
+
state.selectedId = button.dataset.select ?? "";
|
|
75
|
+
renderTree(state);
|
|
76
|
+
renderStage(state);
|
|
77
|
+
renderInspector(state);
|
|
78
|
+
};
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
function renderToolbar(state) {
|
|
82
|
+
const toolbar = requirePart(state.root, ".skme-toolbar");
|
|
83
|
+
toolbar.innerHTML = [
|
|
84
|
+
`<strong>${escapeHtml(state.options.title ?? "Sketchmark Editor")}</strong>`,
|
|
85
|
+
"<span class='skme-spacer'></span>",
|
|
86
|
+
"<button data-save>Save</button>",
|
|
87
|
+
"<button data-export='svg'>SVG</button>",
|
|
88
|
+
"<button data-export='png'>PNG</button>",
|
|
89
|
+
"<button data-export='jpg'>JPG</button>",
|
|
90
|
+
"<button data-export='html'>HTML</button>",
|
|
91
|
+
"<button data-export='json'>JSON</button>",
|
|
92
|
+
"<button data-export='mp4'>MP4</button>",
|
|
93
|
+
"<span class='skme-status' data-status></span>"
|
|
94
|
+
].join("");
|
|
95
|
+
toolbar.querySelector("[data-save]").onclick = () => saveNow(state);
|
|
96
|
+
toolbar.querySelectorAll("[data-export]").forEach((button) => {
|
|
97
|
+
button.onclick = () => exportDocument(state, button.dataset.export);
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
function renderStage(state) {
|
|
101
|
+
const stage = requirePart(state.root, ".skme-stage");
|
|
102
|
+
try {
|
|
103
|
+
const resolved = (0, normalize_1.resolveVisualFrame)(state.document, state.currentTime);
|
|
104
|
+
stage.innerHTML = (0, svg_1.renderResolvedSvg)(resolved);
|
|
105
|
+
const svg = stage.querySelector("svg");
|
|
106
|
+
if (!svg)
|
|
107
|
+
return;
|
|
108
|
+
svg.addEventListener("pointerdown", (event) => beginDrag(state, event));
|
|
109
|
+
svg.querySelectorAll("[id]").forEach((element) => {
|
|
110
|
+
element.style.cursor = "pointer";
|
|
111
|
+
element.addEventListener("click", (event) => {
|
|
112
|
+
event.stopPropagation();
|
|
113
|
+
const id = event.currentTarget.id;
|
|
114
|
+
if (id) {
|
|
115
|
+
state.selectedId = id;
|
|
116
|
+
renderTree(state);
|
|
117
|
+
renderStage(state);
|
|
118
|
+
renderInspector(state);
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
if (state.selectedId) {
|
|
123
|
+
const selected = svg.querySelector(`#${cssEscape(state.selectedId)}`);
|
|
124
|
+
if (selected)
|
|
125
|
+
drawSelection(svg, selected);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
catch (error) {
|
|
129
|
+
stage.innerHTML = `<pre class="skme-error">${escapeHtml(errorMessage(error))}</pre>`;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
function renderPlaybar(state) {
|
|
133
|
+
const playbar = requirePart(state.root, ".skme-playbar");
|
|
134
|
+
const duration = Math.max(0, Number(state.document.canvas.duration ?? 0));
|
|
135
|
+
playbar.innerHTML = [
|
|
136
|
+
`<button data-play>${state.playing ? "Pause" : "Play"}</button>`,
|
|
137
|
+
`<input data-time type="range" min="0" max="${duration || 1}" step="0.001" value="${state.currentTime}">`,
|
|
138
|
+
`<span>${state.currentTime.toFixed(2)}s / ${duration.toFixed(2)}s</span>`
|
|
139
|
+
].join("");
|
|
140
|
+
playbar.querySelector("[data-play]").onclick = () => togglePlayback(state);
|
|
141
|
+
const slider = playbar.querySelector("[data-time]");
|
|
142
|
+
slider.oninput = () => {
|
|
143
|
+
state.currentTime = Number(slider.value);
|
|
144
|
+
renderStage(state);
|
|
145
|
+
renderPlaybar(state);
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
function renderInspector(state) {
|
|
149
|
+
const inspector = requirePart(state.root, ".skme-inspector");
|
|
150
|
+
const element = state.selectedId ? (0, edit_1.findElementById)(state.document, state.selectedId) : undefined;
|
|
151
|
+
if (!element) {
|
|
152
|
+
inspector.innerHTML = "<div class='skme-panel-title'>Inspector</div><p class='skme-muted'>Select an element.</p>";
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
const tracks = (0, edit_1.listTimelineTracks)(state.document, element.id ?? "");
|
|
156
|
+
inspector.innerHTML = [
|
|
157
|
+
"<div class='skme-panel-title'>Inspector</div>",
|
|
158
|
+
fieldText("ID", element.id ?? "", true),
|
|
159
|
+
fieldText("Type", element.type, true),
|
|
160
|
+
numberField("x", numberProp(element, "x")),
|
|
161
|
+
numberField("y", numberProp(element, "y")),
|
|
162
|
+
numberField("width", numberProp(element, "width")),
|
|
163
|
+
numberField("height", numberProp(element, "height")),
|
|
164
|
+
numberField("opacity", numberProp(element, "opacity")),
|
|
165
|
+
numberField("rotation", numberProp(element, "rotation")),
|
|
166
|
+
numberField("scale", numberProp(element, "scale")),
|
|
167
|
+
paintField("fill", element.fill),
|
|
168
|
+
paintField("stroke", element.stroke),
|
|
169
|
+
numberField("strokeWidth", numberProp(element, "strokeWidth")),
|
|
170
|
+
textAreaField("text", element.type === "text" ? element.text ?? "" : undefined),
|
|
171
|
+
"<hr>",
|
|
172
|
+
renderKeyframeEditor(element),
|
|
173
|
+
"<hr>",
|
|
174
|
+
"<div class='skme-panel-title small'>Timeline</div>",
|
|
175
|
+
tracks.length ? tracks.map((track) => `<div class='skme-track'><b>${escapeHtml(track.property)}</b><span>${track.keyframes.length} keyframes</span></div>`).join("") : "<p class='skme-muted'>No tracks.</p>"
|
|
176
|
+
].join("");
|
|
177
|
+
inspector.querySelectorAll("[data-prop]").forEach((input) => {
|
|
178
|
+
input.oninput = () => {
|
|
179
|
+
const property = input.dataset.prop;
|
|
180
|
+
const value = input.dataset.kind === "number" ? Number(input.value) : input.value;
|
|
181
|
+
if (input.dataset.kind === "number" && !Number.isFinite(value))
|
|
182
|
+
return;
|
|
183
|
+
updateProperty(state, element.id, property, value);
|
|
184
|
+
};
|
|
185
|
+
});
|
|
186
|
+
const propertySelect = inspector.querySelector("[data-key-prop]");
|
|
187
|
+
const keyButton = inspector.querySelector("[data-add-key]");
|
|
188
|
+
if (propertySelect && keyButton) {
|
|
189
|
+
keyButton.onclick = () => addKeyframe(state, element, propertySelect.value);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
function renderKeyframeEditor(element) {
|
|
193
|
+
const id = element.id;
|
|
194
|
+
if (!id)
|
|
195
|
+
return "";
|
|
196
|
+
const options = (0, animatable_1.animatablePropertiesForElement)(element)
|
|
197
|
+
.map((spec) => `<option value="${escapeAttr(spec.property)}">${escapeHtml(spec.property)}</option>`)
|
|
198
|
+
.join("");
|
|
199
|
+
return [
|
|
200
|
+
"<div class='skme-panel-title small'>Keyframe</div>",
|
|
201
|
+
"<div class='skme-row'>",
|
|
202
|
+
`<select data-key-prop>${options}</select>`,
|
|
203
|
+
"<button data-add-key>Set</button>",
|
|
204
|
+
"</div>"
|
|
205
|
+
].join("");
|
|
206
|
+
}
|
|
207
|
+
function updateProperty(state, id, property, value) {
|
|
208
|
+
try {
|
|
209
|
+
state.document = (0, edit_1.setElementProperty)(state.document, id, property, value);
|
|
210
|
+
markChanged(state);
|
|
211
|
+
renderStage(state);
|
|
212
|
+
}
|
|
213
|
+
catch (error) {
|
|
214
|
+
setStatus(state, errorMessage(error), true);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
function addKeyframe(state, element, property) {
|
|
218
|
+
if (!element.id)
|
|
219
|
+
return;
|
|
220
|
+
const value = (0, animatable_1.getPropertyValue)(element, property) ?? (0, animatable_1.baseValueForProperty)(element, property);
|
|
221
|
+
if (value === undefined) {
|
|
222
|
+
setStatus(state, `No value for ${property}`, true);
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
try {
|
|
226
|
+
state.document = (0, edit_1.setTimelineKeyframe)(state.document, element.id, property, state.currentTime, value);
|
|
227
|
+
markChanged(state);
|
|
228
|
+
renderInspector(state);
|
|
229
|
+
renderStage(state);
|
|
230
|
+
}
|
|
231
|
+
catch (error) {
|
|
232
|
+
setStatus(state, errorMessage(error), true);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
function beginDrag(state, event) {
|
|
236
|
+
const target = event.target;
|
|
237
|
+
const id = closestSvgId(target);
|
|
238
|
+
if (!id)
|
|
239
|
+
return;
|
|
240
|
+
const element = (0, edit_1.findElementById)(state.document, id);
|
|
241
|
+
if (!element || !("x" in element) || !("y" in element))
|
|
242
|
+
return;
|
|
243
|
+
const point = svgPoint(event);
|
|
244
|
+
if (!point)
|
|
245
|
+
return;
|
|
246
|
+
state.selectedId = id;
|
|
247
|
+
state.drag = { id, start: point, baseX: Number(element.x ?? 0), baseY: Number(element.y ?? 0) };
|
|
248
|
+
const svg = event.currentTarget;
|
|
249
|
+
svg.setPointerCapture(event.pointerId);
|
|
250
|
+
svg.addEventListener("pointermove", dragMove);
|
|
251
|
+
svg.addEventListener("pointerup", dragEnd);
|
|
252
|
+
svg.addEventListener("pointercancel", dragEnd);
|
|
253
|
+
renderTree(state);
|
|
254
|
+
renderInspector(state);
|
|
255
|
+
function dragMove(moveEvent) {
|
|
256
|
+
if (!state.drag)
|
|
257
|
+
return;
|
|
258
|
+
const nextPoint = svgPoint(moveEvent);
|
|
259
|
+
if (!nextPoint)
|
|
260
|
+
return;
|
|
261
|
+
const current = (0, edit_1.findElementById)(state.document, state.drag.id);
|
|
262
|
+
if (!current || !("x" in current) || !("y" in current))
|
|
263
|
+
return;
|
|
264
|
+
current.x = state.drag.baseX + nextPoint[0] - state.drag.start[0];
|
|
265
|
+
current.y = state.drag.baseY + nextPoint[1] - state.drag.start[1];
|
|
266
|
+
renderStage(state);
|
|
267
|
+
renderInspector(state);
|
|
268
|
+
}
|
|
269
|
+
function dragEnd(endEvent) {
|
|
270
|
+
svg.releasePointerCapture(endEvent.pointerId);
|
|
271
|
+
svg.removeEventListener("pointermove", dragMove);
|
|
272
|
+
svg.removeEventListener("pointerup", dragEnd);
|
|
273
|
+
svg.removeEventListener("pointercancel", dragEnd);
|
|
274
|
+
state.drag = undefined;
|
|
275
|
+
markChanged(state);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
function togglePlayback(state) {
|
|
279
|
+
state.playing = !state.playing;
|
|
280
|
+
state.lastFrame = performance.now();
|
|
281
|
+
renderPlaybar(state);
|
|
282
|
+
if (state.playing)
|
|
283
|
+
tick(state);
|
|
284
|
+
}
|
|
285
|
+
function tick(state) {
|
|
286
|
+
if (!state.playing)
|
|
287
|
+
return;
|
|
288
|
+
const now = performance.now();
|
|
289
|
+
const delta = Math.max(0, (now - state.lastFrame) / 1000);
|
|
290
|
+
state.lastFrame = now;
|
|
291
|
+
const duration = Math.max(0, Number(state.document.canvas.duration ?? 0));
|
|
292
|
+
state.currentTime = duration > 0 ? (state.currentTime + delta) % duration : 0;
|
|
293
|
+
renderStage(state);
|
|
294
|
+
renderPlaybar(state);
|
|
295
|
+
state.raf = window.requestAnimationFrame(() => tick(state));
|
|
296
|
+
}
|
|
297
|
+
function markChanged(state) {
|
|
298
|
+
state.options.onChange?.((0, utils_1.clone)(state.document));
|
|
299
|
+
setStatus(state, "Changed");
|
|
300
|
+
if (state.options.autoSaveDelay === false || !state.options.onSave)
|
|
301
|
+
return;
|
|
302
|
+
window.clearTimeout(state.saveTimer);
|
|
303
|
+
state.saveTimer = window.setTimeout(() => {
|
|
304
|
+
saveNow(state).catch((error) => setStatus(state, errorMessage(error), true));
|
|
305
|
+
}, state.options.autoSaveDelay ?? 800);
|
|
306
|
+
}
|
|
307
|
+
async function saveNow(state) {
|
|
308
|
+
if (!state.options.onSave) {
|
|
309
|
+
setStatus(state, "No save handler", true);
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
await state.options.onSave((0, utils_1.clone)(state.document));
|
|
313
|
+
setStatus(state, "Saved");
|
|
314
|
+
}
|
|
315
|
+
async function exportDocument(state, format) {
|
|
316
|
+
try {
|
|
317
|
+
setStatus(state, format === "mp4" ? "Exporting MP4..." : "Exporting...");
|
|
318
|
+
await (0, browser_export_1.exportVisualInBrowser)(state.document, {
|
|
319
|
+
format,
|
|
320
|
+
title: state.options.title ?? "sketchmark",
|
|
321
|
+
time: state.currentTime,
|
|
322
|
+
sourceDocument: state.document,
|
|
323
|
+
onProgress: (progress) => setStatus(state, `Export ${progress}%`)
|
|
324
|
+
});
|
|
325
|
+
setStatus(state, "Exported");
|
|
326
|
+
}
|
|
327
|
+
catch (error) {
|
|
328
|
+
setStatus(state, errorMessage(error), true);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
function assertValid(document) {
|
|
332
|
+
const result = (0, validate_1.validateVisualDocument)(document);
|
|
333
|
+
if (!result.ok)
|
|
334
|
+
throw new Error(result.issues.map((issue) => `${issue.path}: ${issue.message}`).join("\n"));
|
|
335
|
+
}
|
|
336
|
+
function fieldText(label, value, readonly) {
|
|
337
|
+
return `<label class="skme-field"><span>${label}</span><input value="${escapeAttr(value)}"${readonly ? " readonly" : ""}></label>`;
|
|
338
|
+
}
|
|
339
|
+
function numberField(property, value) {
|
|
340
|
+
if (value === undefined)
|
|
341
|
+
return "";
|
|
342
|
+
return `<label class="skme-field"><span>${property}</span><input data-prop="${escapeAttr(property)}" data-kind="number" type="number" step="0.1" value="${Number(value)}"></label>`;
|
|
343
|
+
}
|
|
344
|
+
function paintField(property, value) {
|
|
345
|
+
if (typeof value !== "string")
|
|
346
|
+
return "";
|
|
347
|
+
return `<label class="skme-field"><span>${property}</span><input data-prop="${escapeAttr(property)}" value="${escapeAttr(value)}"></label>`;
|
|
348
|
+
}
|
|
349
|
+
function textAreaField(property, value) {
|
|
350
|
+
if (value === undefined)
|
|
351
|
+
return "";
|
|
352
|
+
return `<label class="skme-field"><span>${property}</span><textarea data-prop="${escapeAttr(property)}">${escapeHtml(value)}</textarea></label>`;
|
|
353
|
+
}
|
|
354
|
+
function numberProp(element, property) {
|
|
355
|
+
const value = element[property];
|
|
356
|
+
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
|
357
|
+
}
|
|
358
|
+
function closestSvgId(target) {
|
|
359
|
+
let current = target;
|
|
360
|
+
while (current && current instanceof SVGElement) {
|
|
361
|
+
if (current.id)
|
|
362
|
+
return current.id;
|
|
363
|
+
current = current.parentElement;
|
|
364
|
+
}
|
|
365
|
+
return "";
|
|
366
|
+
}
|
|
367
|
+
function svgPoint(event) {
|
|
368
|
+
const svg = event.currentTarget ?? event.target.ownerSVGElement;
|
|
369
|
+
if (!svg)
|
|
370
|
+
return undefined;
|
|
371
|
+
const point = svg.createSVGPoint();
|
|
372
|
+
point.x = event.clientX;
|
|
373
|
+
point.y = event.clientY;
|
|
374
|
+
const matrix = svg.getScreenCTM();
|
|
375
|
+
if (!matrix)
|
|
376
|
+
return undefined;
|
|
377
|
+
const mapped = point.matrixTransform(matrix.inverse());
|
|
378
|
+
return [mapped.x, mapped.y];
|
|
379
|
+
}
|
|
380
|
+
function drawSelection(svg, selected) {
|
|
381
|
+
const box = selected.getBBox();
|
|
382
|
+
const rect = document.createElementNS("http://www.w3.org/2000/svg", "rect");
|
|
383
|
+
rect.setAttribute("x", String(box.x));
|
|
384
|
+
rect.setAttribute("y", String(box.y));
|
|
385
|
+
rect.setAttribute("width", String(box.width));
|
|
386
|
+
rect.setAttribute("height", String(box.height));
|
|
387
|
+
rect.setAttribute("fill", "none");
|
|
388
|
+
rect.setAttribute("stroke", "#0b63ff");
|
|
389
|
+
rect.setAttribute("stroke-width", "1.5");
|
|
390
|
+
rect.setAttribute("stroke-dasharray", "5 4");
|
|
391
|
+
rect.setAttribute("pointer-events", "none");
|
|
392
|
+
svg.appendChild(rect);
|
|
393
|
+
}
|
|
394
|
+
function setStatus(state, message, isError = false) {
|
|
395
|
+
const status = state.root.querySelector("[data-status]");
|
|
396
|
+
if (!status)
|
|
397
|
+
return;
|
|
398
|
+
status.textContent = message;
|
|
399
|
+
status.classList.toggle("error", isError);
|
|
400
|
+
}
|
|
401
|
+
function requirePart(root, selector) {
|
|
402
|
+
const part = root.querySelector(selector);
|
|
403
|
+
if (!part)
|
|
404
|
+
throw new Error(`Missing editor part ${selector}.`);
|
|
405
|
+
return part;
|
|
406
|
+
}
|
|
407
|
+
function errorMessage(error) {
|
|
408
|
+
return error instanceof Error ? error.message : String(error);
|
|
409
|
+
}
|
|
410
|
+
function escapeHtml(value) {
|
|
411
|
+
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
412
|
+
}
|
|
413
|
+
function escapeAttr(value) {
|
|
414
|
+
return escapeHtml(value);
|
|
415
|
+
}
|
|
416
|
+
function cssEscape(value) {
|
|
417
|
+
const css = globalThis.CSS;
|
|
418
|
+
if (css?.escape)
|
|
419
|
+
return css.escape(value);
|
|
420
|
+
return value.replace(/[^A-Za-z0-9_-]/g, (char) => `\\${char}`);
|
|
421
|
+
}
|
|
422
|
+
function ensureStyle() {
|
|
423
|
+
if (document.getElementById(STYLE_ID))
|
|
424
|
+
return;
|
|
425
|
+
const style = document.createElement("style");
|
|
426
|
+
style.id = STYLE_ID;
|
|
427
|
+
style.textContent = `
|
|
428
|
+
.sketchmark-browser-editor{height:100%;min-height:100vh;display:grid;grid-template-columns:240px 1fr 320px;background:#c0c0c0;color:#111;font:13px Arial,sans-serif;overflow:hidden}
|
|
429
|
+
.skme-tree,.skme-inspector{overflow:auto;background:#f2f2f2;border-right:1px solid #888;padding:8px}
|
|
430
|
+
.skme-inspector{border-right:0;border-left:1px solid #888}
|
|
431
|
+
.skme-main{min-width:0;display:grid;grid-template-rows:auto 1fr auto}
|
|
432
|
+
.skme-toolbar,.skme-playbar{display:flex;align-items:center;gap:6px;padding:6px;background:#e5e5e5;border-bottom:1px solid #999}
|
|
433
|
+
.skme-playbar{border-top:1px solid #999;border-bottom:0}
|
|
434
|
+
.skme-playbar input{flex:1}
|
|
435
|
+
.skme-stage-wrap{min-width:0;min-height:0;display:grid;place-items:center;overflow:auto;background:#fff}
|
|
436
|
+
.skme-stage svg{display:block;max-width:100%;max-height:calc(100vh - 88px);background:#fff;border:1px solid #333;overflow:visible}
|
|
437
|
+
.skme-panel-title{font-weight:bold;margin:0 0 8px}.skme-panel-title.small{font-size:12px;margin-top:8px}
|
|
438
|
+
.skme-tree-row{display:flex;align-items:center;justify-content:space-between;gap:8px;width:100%;border:1px solid transparent;background:transparent;text-align:left;padding:3px 6px;cursor:pointer}
|
|
439
|
+
.skme-tree-row small{color:#555}.skme-tree-row.selected{background:#003399;color:#fff}.skme-tree-row.selected small{color:#dbeafe}
|
|
440
|
+
.skme-field{display:grid;grid-template-columns:88px 1fr;gap:6px;align-items:center;margin:5px 0}
|
|
441
|
+
.skme-field input,.skme-field textarea,.skme-field select{width:100%;box-sizing:border-box;font:13px Arial,sans-serif}
|
|
442
|
+
.skme-field textarea{min-height:88px}
|
|
443
|
+
.skme-row{display:grid;grid-template-columns:1fr auto;gap:6px}
|
|
444
|
+
.skme-track{display:flex;justify-content:space-between;border:1px solid #aaa;background:#e9e9e9;margin:4px 0;padding:4px}
|
|
445
|
+
.skme-spacer{flex:1}.skme-muted{color:#555}.skme-error,.skme-status.error{color:#900}.skme-status{min-width:90px;color:#333}
|
|
446
|
+
button{font:13px Arial,sans-serif;padding:3px 8px}
|
|
447
|
+
hr{border:0;border-top:1px solid #bbb;margin:10px 0}
|
|
448
|
+
`;
|
|
449
|
+
document.head.appendChild(style);
|
|
450
|
+
}
|
package/dist/src/edit.js
CHANGED
|
@@ -142,13 +142,20 @@ function makeKeyframe(time, value, options) {
|
|
|
142
142
|
function mergeKeyframe(existing, next) {
|
|
143
143
|
if (Array.isArray(existing))
|
|
144
144
|
return next;
|
|
145
|
-
|
|
145
|
+
const merged = {
|
|
146
146
|
...existing,
|
|
147
|
-
...next
|
|
148
|
-
in: next.in ?? existing.in,
|
|
149
|
-
out: next.out ?? existing.out,
|
|
150
|
-
interpolation: next.interpolation ?? existing.interpolation
|
|
147
|
+
...next
|
|
151
148
|
};
|
|
149
|
+
mergeCurveValue(merged, "in", next.in ?? existing.in);
|
|
150
|
+
mergeCurveValue(merged, "out", next.out ?? existing.out);
|
|
151
|
+
mergeCurveValue(merged, "interpolation", next.interpolation ?? existing.interpolation);
|
|
152
|
+
return merged;
|
|
153
|
+
}
|
|
154
|
+
function mergeCurveValue(frame, key, value) {
|
|
155
|
+
if (value)
|
|
156
|
+
frame[key] = value;
|
|
157
|
+
else
|
|
158
|
+
delete frame[key];
|
|
152
159
|
}
|
|
153
160
|
function keyframeTime(frame) {
|
|
154
161
|
return Array.isArray(frame) ? frame[0] : frame.time;
|
|
@@ -171,30 +178,88 @@ function repairLegacyTimelineCurves(document) {
|
|
|
171
178
|
}
|
|
172
179
|
function repairTrackCurve(track) {
|
|
173
180
|
const record = track;
|
|
174
|
-
|
|
175
|
-
const curve = legacyCurve(record.curve);
|
|
176
|
-
if (curve)
|
|
177
|
-
record.curve = curve;
|
|
178
|
-
else
|
|
179
|
-
delete record.curve;
|
|
180
|
-
}
|
|
181
|
+
repairCurveField(record, "curve");
|
|
181
182
|
}
|
|
182
183
|
function repairKeyframeCurve(frame) {
|
|
183
184
|
if (Array.isArray(frame))
|
|
184
185
|
return;
|
|
185
186
|
const record = frame;
|
|
186
|
-
for (const key of ["in", "out", "interpolation"])
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
187
|
+
for (const key of ["in", "out", "interpolation"])
|
|
188
|
+
repairCurveField(record, key);
|
|
189
|
+
}
|
|
190
|
+
function repairCurveField(record, key) {
|
|
191
|
+
if (!(key in record))
|
|
192
|
+
return;
|
|
193
|
+
const curve = coerceTimelineCurve(record[key]);
|
|
194
|
+
if (curve)
|
|
195
|
+
record[key] = curve;
|
|
196
|
+
else
|
|
197
|
+
delete record[key];
|
|
198
|
+
}
|
|
199
|
+
function coerceTimelineCurve(value) {
|
|
200
|
+
if (value === undefined)
|
|
201
|
+
return undefined;
|
|
202
|
+
if (typeof value === "string")
|
|
203
|
+
return legacyCurve(value);
|
|
204
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
205
|
+
return undefined;
|
|
206
|
+
const record = value;
|
|
207
|
+
if (record.type === "hold")
|
|
208
|
+
return { type: "hold" };
|
|
209
|
+
if (record.type === "cubicBezier") {
|
|
210
|
+
const x1 = finiteNumber(record.x1);
|
|
211
|
+
const y1 = finiteNumber(record.y1);
|
|
212
|
+
const x2 = finiteNumber(record.x2);
|
|
213
|
+
const y2 = finiteNumber(record.y2);
|
|
214
|
+
if (x1 === undefined || y1 === undefined || x2 === undefined || y2 === undefined)
|
|
215
|
+
return undefined;
|
|
216
|
+
if (x1 < 0 || x1 > 1 || x2 < 0 || x2 > 1)
|
|
217
|
+
return undefined;
|
|
218
|
+
return { type: "cubicBezier", x1, y1, x2, y2 };
|
|
219
|
+
}
|
|
220
|
+
if (record.type === "graph") {
|
|
221
|
+
const points = Array.isArray(record.points)
|
|
222
|
+
? record.points.map(coerceCurvePoint).filter((point) => Boolean(point))
|
|
223
|
+
: [];
|
|
224
|
+
if (points.length < 2)
|
|
225
|
+
return undefined;
|
|
226
|
+
let previousX = Number.NEGATIVE_INFINITY;
|
|
227
|
+
for (const point of points) {
|
|
228
|
+
if (point[0] < 0 || point[0] > 1 || point[0] <= previousX)
|
|
229
|
+
return undefined;
|
|
230
|
+
previousX = point[0];
|
|
231
|
+
}
|
|
232
|
+
if (points[0]?.[0] !== 0 || points[points.length - 1]?.[0] !== 1)
|
|
233
|
+
return undefined;
|
|
234
|
+
return { type: "graph", points };
|
|
194
235
|
}
|
|
236
|
+
return undefined;
|
|
237
|
+
}
|
|
238
|
+
function coerceCurvePoint(value) {
|
|
239
|
+
if (!Array.isArray(value) || value.length < 2)
|
|
240
|
+
return undefined;
|
|
241
|
+
const x = finiteNumber(value[0]);
|
|
242
|
+
const y = finiteNumber(value[1]);
|
|
243
|
+
return x === undefined || y === undefined ? undefined : [x, y];
|
|
244
|
+
}
|
|
245
|
+
function finiteNumber(value) {
|
|
246
|
+
const number = Number(value);
|
|
247
|
+
return Number.isFinite(number) ? number : undefined;
|
|
195
248
|
}
|
|
196
249
|
function legacyCurve(value) {
|
|
197
|
-
|
|
250
|
+
switch (value.trim()) {
|
|
251
|
+
case "ease":
|
|
252
|
+
case "easeInOut":
|
|
253
|
+
case "ease-inout":
|
|
254
|
+
case "easeInout":
|
|
255
|
+
return (0, keyframes_1.timelineCurvePreset)("ease-in-out");
|
|
256
|
+
case "easeIn":
|
|
257
|
+
return (0, keyframes_1.timelineCurvePreset)("ease-in");
|
|
258
|
+
case "easeOut":
|
|
259
|
+
return (0, keyframes_1.timelineCurvePreset)("ease-out");
|
|
260
|
+
default:
|
|
261
|
+
return (0, keyframes_1.timelineCurvePreset)(value);
|
|
262
|
+
}
|
|
198
263
|
}
|
|
199
264
|
function finiteOrZero(value) {
|
|
200
265
|
return (0, utils_1.isFiniteNumber)(value) ? value : 0;
|
package/dist/src/keyframes.js
CHANGED
|
@@ -151,13 +151,20 @@ function makeKeyframe(time, value, options = {}) {
|
|
|
151
151
|
function mergeKeyframe(existing, next) {
|
|
152
152
|
if (Array.isArray(existing))
|
|
153
153
|
return next;
|
|
154
|
-
|
|
154
|
+
const merged = {
|
|
155
155
|
...existing,
|
|
156
|
-
...next
|
|
157
|
-
in: next.in ?? existing.in,
|
|
158
|
-
out: next.out ?? existing.out,
|
|
159
|
-
interpolation: next.interpolation ?? existing.interpolation
|
|
156
|
+
...next
|
|
160
157
|
};
|
|
158
|
+
mergeCurveValue(merged, "in", next.in ?? existing.in);
|
|
159
|
+
mergeCurveValue(merged, "out", next.out ?? existing.out);
|
|
160
|
+
mergeCurveValue(merged, "interpolation", next.interpolation ?? existing.interpolation);
|
|
161
|
+
return merged;
|
|
162
|
+
}
|
|
163
|
+
function mergeCurveValue(frame, key, value) {
|
|
164
|
+
if (value)
|
|
165
|
+
frame[key] = value;
|
|
166
|
+
else
|
|
167
|
+
delete frame[key];
|
|
161
168
|
}
|
|
162
169
|
function keyframeTime(frame) {
|
|
163
170
|
return Array.isArray(frame) ? frame[0] : frame.time;
|
package/dist/tests/run.js
CHANGED
|
@@ -487,6 +487,39 @@ test("edits nested element properties and timeline keyframes", () => {
|
|
|
487
487
|
const cleaned = (0, src_1.removeTimelineKeyframe)(animated, "nested", "position", 1);
|
|
488
488
|
assert(!(0, src_1.listTimelineTracks)(cleaned, "nested").length, "timeline keyframe removal should prune empty tracks");
|
|
489
489
|
});
|
|
490
|
+
test("editor helpers repair malformed timeline curve fields before validating", () => {
|
|
491
|
+
const doc = {
|
|
492
|
+
version: 1,
|
|
493
|
+
canvas: { width: 320, height: 180, duration: 2 },
|
|
494
|
+
elements: [
|
|
495
|
+
{
|
|
496
|
+
id: "dot",
|
|
497
|
+
type: "point",
|
|
498
|
+
x: 0,
|
|
499
|
+
y: 0,
|
|
500
|
+
timeline: {
|
|
501
|
+
tracks: {
|
|
502
|
+
position: {
|
|
503
|
+
curve: "easeOut",
|
|
504
|
+
keyframes: [
|
|
505
|
+
{ time: 0, value: [0, 0], in: null, interpolation: ["linear"] },
|
|
506
|
+
{ time: 1, value: [100, 0] }
|
|
507
|
+
]
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
]
|
|
513
|
+
};
|
|
514
|
+
const repaired = (0, src_1.setTimelineKeyframe)(doc, "dot", "position", 0, [0, 0], { out: { type: "hold" } });
|
|
515
|
+
const result = (0, src_1.validateVisualDocument)(repaired);
|
|
516
|
+
assert(result.ok, `repaired edit document should validate: ${result.issues.map((item) => item.message).join("; ")}`);
|
|
517
|
+
const track = (repaired.elements?.[0]).timeline.tracks.position;
|
|
518
|
+
const first = track.keyframes[0];
|
|
519
|
+
assert(track.curve.type === "cubicBezier", "legacy camel-case track curve should be canonicalized");
|
|
520
|
+
assert(first.out.type === "hold", "new keyframe curve should be applied");
|
|
521
|
+
assert(!("in" in first) && !("interpolation" in first), "malformed keyframe curves should be removed");
|
|
522
|
+
});
|
|
490
523
|
test("edits and renders path position offsets", () => {
|
|
491
524
|
const doc = {
|
|
492
525
|
version: 1,
|