sketchmark 2.0.0 → 2.1.0
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/ANIMATABLE_MATRIX.md +177 -0
- package/KERNEL_SPEC.md +412 -0
- package/PACKS.md +81 -0
- package/PRESETS.md +182 -0
- package/README.md +274 -188
- package/bin/editor-ui.cjs +2285 -0
- package/bin/preview-ui.cjs +74 -0
- package/bin/sketchmark.cjs +648 -2008
- package/dist/src/animatable.d.ts +21 -0
- package/dist/src/animatable.js +439 -0
- package/dist/src/builders/index.d.ts +1 -11
- package/dist/src/builders/index.js +1 -19
- package/dist/src/diagnostics.js +1 -64
- package/dist/src/edit.d.ts +27 -0
- package/dist/src/edit.js +162 -0
- package/dist/src/index.d.ts +4 -13
- package/dist/src/index.js +4 -13
- package/dist/src/keyframes.d.ts +48 -0
- package/dist/src/keyframes.js +182 -0
- package/dist/src/motion.d.ts +4 -0
- package/dist/src/motion.js +262 -0
- package/dist/src/normalize.js +120 -151
- package/dist/src/presets/characters.d.ts +15 -0
- package/dist/src/presets/characters.js +113 -0
- package/dist/src/presets/compose.d.ts +5 -0
- package/dist/src/presets/compose.js +80 -0
- package/dist/src/presets/effects.d.ts +40 -0
- package/dist/src/presets/effects.js +79 -0
- package/dist/src/presets/helpers.d.ts +33 -0
- package/dist/src/presets/helpers.js +165 -0
- package/dist/src/presets/index.d.ts +9 -0
- package/dist/src/presets/index.js +48 -0
- package/dist/src/presets/motions.d.ts +33 -0
- package/dist/src/presets/motions.js +75 -0
- package/dist/src/presets/scenes.d.ts +35 -0
- package/dist/src/presets/scenes.js +134 -0
- package/dist/src/presets/shapes.d.ts +71 -0
- package/dist/src/presets/shapes.js +96 -0
- package/dist/src/presets/transitions.d.ts +29 -0
- package/dist/src/presets/transitions.js +113 -0
- package/dist/src/presets/types.d.ts +34 -0
- package/dist/src/presets/types.js +2 -0
- package/dist/src/render/html.js +1 -4
- package/dist/src/render/svg.d.ts +2 -2
- package/dist/src/render/svg.js +86 -82
- package/dist/src/render/three-html.js +67 -113
- package/dist/src/scenes.js +1 -0
- package/dist/src/schema.js +218 -280
- package/dist/src/shapes/builtins.js +11 -47
- package/dist/src/shapes/common.js +12 -11
- package/dist/src/shapes/registry.d.ts +0 -1
- package/dist/src/shapes/registry.js +0 -4
- package/dist/src/shapes/types.d.ts +1 -3
- package/dist/src/types.d.ts +57 -288
- package/dist/src/utils.d.ts +2 -11
- package/dist/src/utils.js +13 -70
- package/dist/src/validate.js +321 -275
- package/dist/tests/run.js +576 -510
- package/examples/1730642890464.jpg +0 -0
- package/examples/app-screen.svg +1 -0
- package/examples/app-screen.visual.json +503 -0
- package/examples/dashboard-table.svg +1 -0
- package/examples/dashboard-table.visual.json +708 -0
- package/examples/dev-docs.svg +1 -0
- package/examples/dev-docs.visual.json +248 -0
- package/examples/explainer.mp4 +0 -0
- package/examples/explainer.visual.json +1713 -0
- package/examples/group-origin-effects-lab-check.svg +1 -0
- package/examples/group-origin-effects-lab.visual.json +1880 -0
- package/examples/image-clip-radius.visual.json +271 -0
- package/examples/make-app-screen.cjs +368 -0
- package/examples/make-dashboard-table.cjs +277 -0
- package/examples/make-dev-docs.cjs +233 -0
- package/examples/make-explainer.cjs +438 -0
- package/examples/make-group-origin-effects-lab.cjs +370 -0
- package/examples/make-image-clip-radius.cjs +169 -0
- package/examples/make-modal-dialog.cjs +355 -0
- package/examples/make-origin-effects-lab.cjs +311 -0
- package/examples/make-preset-character-motion.cjs +32 -0
- package/examples/make-presets-demo.cjs +30 -0
- package/examples/make-pricing.cjs +286 -0
- package/examples/make-product-demo.cjs +468 -0
- package/examples/make-product-hero.cjs +223 -0
- package/examples/make-release-notes.cjs +333 -0
- package/examples/make-settings-panel.cjs +435 -0
- package/examples/make-split-preview.cjs +248 -0
- package/examples/make-storyboard.cjs +215 -0
- package/examples/make-transcript.cjs +234 -0
- package/examples/make-typography-test.cjs +397 -0
- package/examples/make-ui-demo-explainer.cjs +1094 -0
- package/examples/make-ui-flow.cjs +762 -0
- package/examples/make-walkthrough.cjs +815 -0
- package/examples/modal-dialog.svg +1 -0
- package/examples/modal-dialog.visual.json +239 -0
- package/examples/origin-effects-lab-check.svg +1 -0
- package/examples/origin-effects-lab.visual.json +1412 -0
- package/examples/preset-character-motion.visual.json +949 -0
- package/examples/presets-demo.visual.json +787 -0
- package/examples/pricing.svg +1 -0
- package/examples/pricing.visual.json +652 -0
- package/examples/product-demo.mp4 +0 -0
- package/examples/product-demo.visual.json +866 -0
- package/examples/product-hero.svg +1 -0
- package/examples/product-hero.visual.json +242 -0
- package/examples/release-notes.svg +1 -0
- package/examples/release-notes.visual.json +467 -0
- package/examples/settings-panel.svg +1 -0
- package/examples/settings-panel.visual.json +501 -0
- package/examples/split-preview.svg +1 -0
- package/examples/split-preview.visual.json +124 -0
- package/examples/storyboard.svg +1 -0
- package/examples/storyboard.visual.json +312 -0
- package/examples/transcript.svg +1 -0
- package/examples/transcript.visual.json +407 -0
- package/examples/typography-indent-check.svg +1 -0
- package/examples/typography-lineheight-0.svg +1 -0
- package/examples/typography-lineheight-2.svg +1 -0
- package/examples/typography-test-check.svg +1 -0
- package/examples/typography-test.svg +1 -0
- package/examples/typography-test.visual.json +757 -0
- package/examples/ui-demo-explainer-billing.svg +1 -0
- package/examples/ui-demo-explainer-check.svg +1 -0
- package/examples/ui-demo-explainer-save.svg +1 -0
- package/examples/ui-demo-explainer-toggle.svg +1 -0
- package/examples/ui-demo-explainer.mp4 +0 -0
- package/examples/ui-demo-explainer.visual.json +2597 -0
- package/examples/ui-flow.mp4 +0 -0
- package/examples/ui-flow.visual.json +1211 -0
- package/examples/walkthrough.mp4 +0 -0
- package/examples/walkthrough.visual.json +1372 -0
- package/package.json +52 -52
- package/schema/visual.schema.json +1086 -930
|
@@ -0,0 +1,2285 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
function editorHtml(title) {
|
|
4
|
+
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>
|
|
5
|
+
@font-face{font-family:'Roboto';src:url('/fonts/Roboto-Light.ttf') format('truetype');font-weight:300;font-style:normal;font-display:swap}
|
|
6
|
+
@font-face{font-family:'Roboto';src:url('/fonts/Roboto-Regular.ttf') format('truetype');font-weight:400;font-style:normal;font-display:swap}
|
|
7
|
+
@font-face{font-family:'Roboto';src:url('/fonts/Roboto-Bold.ttf') format('truetype');font-weight:700;font-style:normal;font-display:swap}
|
|
8
|
+
html,body{margin:0;width:100%;height:100%;font:13px Roboto,Arial,sans-serif;background:#c0c0c0;color:#000;overflow:hidden}
|
|
9
|
+
body{display:grid;grid-template-columns:240px 1fr 300px;grid-template-rows:1fr 165px;min-width:900px;overflow:hidden}
|
|
10
|
+
button,input,select,textarea{font:13px Roboto,Arial,sans-serif}
|
|
11
|
+
button{padding:3px 8px}
|
|
12
|
+
input,select,textarea{box-sizing:border-box;width:100%}
|
|
13
|
+
textarea{min-height:88px;padding:4px;resize:vertical}
|
|
14
|
+
#tree,#inspector,#timeline{background:#f3f4f6;border:1px solid #c6ccd6;overflow:auto;padding:6px;scrollbar-width:none;-ms-overflow-style:none}
|
|
15
|
+
#tree{grid-row:1/3}
|
|
16
|
+
#stageWrap{position:relative;display:grid;place-items:center;min-width:0;min-height:0;padding:0;background:#fff;overflow:hidden;cursor:default}
|
|
17
|
+
#stageWrap.panning{cursor:grabbing}
|
|
18
|
+
#stage{display:grid;place-items:center;min-width:0;min-height:0}
|
|
19
|
+
#stage svg{max-width:100%;max-height:calc(100vh - 190px);background:white;border:1px solid #333;overflow:visible}
|
|
20
|
+
#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}
|
|
21
|
+
#viewportHud button{padding:1px 8px;min-width:42px}
|
|
22
|
+
#zoomLabel{min-width:42px;text-align:center;font-weight:bold;color:#111827}
|
|
23
|
+
#timeline{grid-column:2/4}
|
|
24
|
+
.row{display:grid;grid-template-columns:1fr 1fr;gap:6px;margin:4px 0}
|
|
25
|
+
.row label{display:block}
|
|
26
|
+
.colorField{display:grid;grid-template-columns:34px 1fr;gap:6px;align-items:center}
|
|
27
|
+
.colorField input[type=color]{width:34px;height:28px;padding:0;border:1px solid #8f96a3;background:#fff;cursor:pointer}
|
|
28
|
+
.stack{display:grid;gap:5px}.section{margin:0 0 10px}.label{display:block;font-weight:bold;margin:0 0 3px}
|
|
29
|
+
.panelGroup{margin:0 0 8px;border:1px solid #c9cfdb;background:#f8fafc}
|
|
30
|
+
.panelGroup > summary{list-style:none;cursor:pointer;padding:6px 8px;font-weight:bold;display:flex;justify-content:space-between;align-items:center;gap:8px}
|
|
31
|
+
.panelGroup > summary::-webkit-details-marker{display:none}
|
|
32
|
+
.panelGroup > summary::after{content:"+";font-weight:bold;color:#4b5563}
|
|
33
|
+
.panelGroup[open] > summary::after{content:"-"}
|
|
34
|
+
.panelGroup > summary .summaryMeta{font-size:11px;color:#4b5563;font-weight:normal;margin-left:auto}
|
|
35
|
+
.panelBody{padding:6px 8px;border-top:1px solid #d8dee8}
|
|
36
|
+
.subhead{display:block;font-size:11px;font-weight:bold;color:#374151;margin:6px 0 2px}
|
|
37
|
+
.treeRow{display:grid;grid-template-columns:16px 20px 20px 1fr;gap:3px;align-items:center;margin:1px 0}
|
|
38
|
+
.treePad{display:block;width:16px;height:20px}
|
|
39
|
+
.treeCtl{height:20px;padding:0;border:1px solid #9aa1ad;background:#f8fafc;cursor:pointer;line-height:18px;font-size:11px}
|
|
40
|
+
.treeCtl.active{background:#003399;color:#fff;border-color:#003399}
|
|
41
|
+
.canvasError{color:#900;font-size:11px;min-height:14px}
|
|
42
|
+
.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}
|
|
43
|
+
.treeBtn.dim{opacity:0.6}
|
|
44
|
+
.treeBtn.selected{background:#003399;color:white}.muted{color:#555}.track{border:1px solid #888;background:#ddd;padding:5px;margin:4px 0}
|
|
45
|
+
.trackName{display:block;font-weight:bold;margin-bottom:4px}
|
|
46
|
+
.trackLine{display:flex;flex-wrap:wrap;align-items:center;gap:4px}
|
|
47
|
+
.kf{display:inline-flex;align-items:center;gap:4px;padding:2px 4px;background:#eee;border:1px solid #999;cursor:pointer}.kf button{padding:0 4px;margin:0}
|
|
48
|
+
.segBadge{padding:1px 6px;border:1px solid #8f96a3;background:#f9fbff;font-size:11px;cursor:pointer}
|
|
49
|
+
.segBadge.active{background:#003399;color:#fff;border-color:#003399}
|
|
50
|
+
.curvePanel{border:1px solid #777;background:#ececec;padding:6px;margin-top:6px}
|
|
51
|
+
.curvePanelHead{display:flex;justify-content:space-between;align-items:center;gap:8px;margin-bottom:6px}
|
|
52
|
+
.curvePanelTitle{font-weight:bold}
|
|
53
|
+
.curvePanelRange{font-size:11px;color:#333}
|
|
54
|
+
.curvePreview{width:100%;height:120px;border:1px solid #9aa1ad;background:#fff;margin-bottom:6px;overflow:hidden}
|
|
55
|
+
.curvePreview svg{display:block;width:100%;height:100%;overflow:hidden}
|
|
56
|
+
.curvePresets{display:flex;flex-wrap:wrap;gap:6px}
|
|
57
|
+
.curvePreset{padding:2px 8px;border:1px solid #8f96a3;background:#f9fbff;cursor:pointer}
|
|
58
|
+
.curvePreset.active{background:#003399;color:#fff;border-color:#003399}
|
|
59
|
+
.curveCustom{border:1px solid #b8bcc4;background:#f7f7f7;padding:6px;margin-top:6px}
|
|
60
|
+
.curveCustomLabel{display:block;font-size:11px;color:#333;margin-bottom:4px}
|
|
61
|
+
.curveCustomFields{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:6px}
|
|
62
|
+
.curveCustom textarea{width:100%;height:56px;box-sizing:border-box;font:12px Consolas,monospace}
|
|
63
|
+
.modalBackdrop{position:fixed;inset:0;background:rgba(0,0,0,.45);display:grid;place-items:center;z-index:9999}
|
|
64
|
+
.modalBackdrop.hidden{display:none}
|
|
65
|
+
.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}
|
|
66
|
+
.curveModalBar{display:flex;justify-content:space-between;align-items:center;gap:8px;margin-bottom:8px}
|
|
67
|
+
.curveModalContent .curvePanel{margin-top:0}
|
|
68
|
+
#tree::-webkit-scrollbar,#inspector::-webkit-scrollbar,#timeline::-webkit-scrollbar,.curveModal::-webkit-scrollbar{width:0;height:0}
|
|
69
|
+
#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 auto;gap:6px;align-items:center}
|
|
70
|
+
.menuWrap{position:relative}
|
|
71
|
+
.menuBtn{min-width:72px}
|
|
72
|
+
.menuList{position:absolute;right:0;top:calc(100% + 4px);display:grid;gap:2px;padding:4px;background:#f3f4f6;border:1px solid #8f96a3;box-shadow:0 4px 12px rgba(0,0,0,.2);z-index:5}
|
|
73
|
+
.menuList button{min-width:88px;text-align:left;padding:2px 8px}
|
|
74
|
+
.menuList.hidden{display:none}
|
|
75
|
+
</style></head><body><aside id="tree"></aside><main id="stageWrap"><div id="stage"></div><div id="viewportHud"><button id="zoomOut" type="button" title="Zoom out">-</button><button id="zoomIn" type="button" title="Zoom in">+</button><button id="zoomFit" type="button" title="Reset zoom and pan">Fit</button><span id="zoomLabel">100%</span></div></main><aside id="inspector"></aside><section id="timeline"></section><div id="curveModalBackdrop" class="modalBackdrop hidden"><div id="curveModal" class="curveModal" role="dialog" aria-modal="true" aria-label="Interpolation Graph"><div class="curveModalBar"><strong>Interpolation Graph</strong><button id="curveModalClose" type="button">Close</button></div><div id="curveModalContent" class="curveModalContent"></div></div></div><script>
|
|
76
|
+
const tree = document.getElementById("tree");
|
|
77
|
+
const stageWrap = document.getElementById("stageWrap");
|
|
78
|
+
const stage = document.getElementById("stage");
|
|
79
|
+
const zoomOut = document.getElementById("zoomOut");
|
|
80
|
+
const zoomIn = document.getElementById("zoomIn");
|
|
81
|
+
const zoomFit = document.getElementById("zoomFit");
|
|
82
|
+
const zoomLabel = document.getElementById("zoomLabel");
|
|
83
|
+
const inspector = document.getElementById("inspector");
|
|
84
|
+
const timeline = document.getElementById("timeline");
|
|
85
|
+
const curveModalBackdrop = document.getElementById("curveModalBackdrop");
|
|
86
|
+
const curveModal = document.getElementById("curveModal");
|
|
87
|
+
const curveModalContent = document.getElementById("curveModalContent");
|
|
88
|
+
const curveModalClose = document.getElementById("curveModalClose");
|
|
89
|
+
let doc = null;
|
|
90
|
+
let refs = [];
|
|
91
|
+
let selectedId = "";
|
|
92
|
+
let currentTime = 0;
|
|
93
|
+
let playing = false;
|
|
94
|
+
let lastTick = 0;
|
|
95
|
+
let playHandle = 0;
|
|
96
|
+
let resolvedDoc = null;
|
|
97
|
+
let drawScheduled = false;
|
|
98
|
+
let drawInFlight = false;
|
|
99
|
+
let drawQueued = false;
|
|
100
|
+
let drag = null;
|
|
101
|
+
let suppressClick = false;
|
|
102
|
+
const FONT_FAMILY_OPTIONS = [
|
|
103
|
+
{ label: "Roboto (Local)", value: "Roboto, Arial, sans-serif" }
|
|
104
|
+
];
|
|
105
|
+
const FONT_WEIGHT_OPTIONS = [
|
|
106
|
+
{ label: "300", value: "300" },
|
|
107
|
+
{ label: "400", value: "400" },
|
|
108
|
+
{ label: "500", value: "500" },
|
|
109
|
+
{ label: "600", value: "600" },
|
|
110
|
+
{ label: "700", value: "700" }
|
|
111
|
+
];
|
|
112
|
+
let collapsedGroups = new Set();
|
|
113
|
+
let hiddenIds = new Set();
|
|
114
|
+
let lockedIds = new Set();
|
|
115
|
+
let parentById = Object.create(null);
|
|
116
|
+
let childIdsById = Object.create(null);
|
|
117
|
+
let typeById = Object.create(null);
|
|
118
|
+
let sidebarCommitTimers = Object.create(null);
|
|
119
|
+
let selectedSegment = null;
|
|
120
|
+
let panelOpenState = Object.create(null);
|
|
121
|
+
let viewport = { initialized: false, baseWidth: 1, baseHeight: 1, x: 0, y: 0, width: 1, height: 1 };
|
|
122
|
+
let viewportPan = null;
|
|
123
|
+
let spacePanActive = false;
|
|
124
|
+
|
|
125
|
+
curveModalClose.onclick = closeCurveModal;
|
|
126
|
+
curveModalBackdrop.onclick = (event) => {
|
|
127
|
+
if (event.target === curveModalBackdrop) closeCurveModal();
|
|
128
|
+
};
|
|
129
|
+
curveModal.onclick = (event) => event.stopPropagation();
|
|
130
|
+
zoomOut.onclick = () => zoomBy(1.12);
|
|
131
|
+
zoomIn.onclick = () => zoomBy(1 / 1.12);
|
|
132
|
+
zoomFit.onclick = () => resetViewport(true);
|
|
133
|
+
document.addEventListener("click", (event) => {
|
|
134
|
+
const wrap = document.getElementById("exportMenuWrap");
|
|
135
|
+
const menu = document.getElementById("exportMenu");
|
|
136
|
+
if (!wrap || !menu) return;
|
|
137
|
+
if (wrap.contains(event.target)) return;
|
|
138
|
+
menu.classList.add("hidden");
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
async function api(path, options) {
|
|
142
|
+
const response = await fetch(path, options || { cache: "no-store" });
|
|
143
|
+
const data = await response.json();
|
|
144
|
+
if (!data.ok) throw new Error(data.error || "Request failed.");
|
|
145
|
+
return data;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function load() {
|
|
149
|
+
clearSidebarCommitTimers();
|
|
150
|
+
const data = await api("/api/document");
|
|
151
|
+
doc = data.document;
|
|
152
|
+
refs = data.elements;
|
|
153
|
+
rebuildElementIndex();
|
|
154
|
+
if (selectedId && !findElement(selectedId)) selectedId = "";
|
|
155
|
+
if (!selectedId && refs[0]) selectedId = refs[0].id;
|
|
156
|
+
renderTree();
|
|
157
|
+
renderInspector();
|
|
158
|
+
renderTimeline();
|
|
159
|
+
requestDraw();
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async function draw() {
|
|
163
|
+
if (drawInFlight) {
|
|
164
|
+
drawQueued = true;
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
drawInFlight = true;
|
|
168
|
+
const time = currentTime;
|
|
169
|
+
try {
|
|
170
|
+
const data = await api("/api/frame?time=" + encodeURIComponent(time));
|
|
171
|
+
resolvedDoc = data.resolved || null;
|
|
172
|
+
stage.innerHTML = data.svg;
|
|
173
|
+
const svg = stage.querySelector("svg");
|
|
174
|
+
if (svg) {
|
|
175
|
+
svg.style.overflow = "visible";
|
|
176
|
+
applyViewportToSvg(svg, data.canvas || (doc && doc.canvas));
|
|
177
|
+
} else {
|
|
178
|
+
updateZoomLabel();
|
|
179
|
+
}
|
|
180
|
+
applyEditorFlagsToStage();
|
|
181
|
+
const selected = selectedId ? stage.querySelector("#" + cssId(selectedId)) : null;
|
|
182
|
+
if (selected && !isElementHidden(selectedId) && !isElementLocked(selectedId)) {
|
|
183
|
+
drawHandles(selected);
|
|
184
|
+
} else {
|
|
185
|
+
clearHandles();
|
|
186
|
+
}
|
|
187
|
+
if (selectedId) syncInspectorValues();
|
|
188
|
+
} finally {
|
|
189
|
+
drawInFlight = false;
|
|
190
|
+
if (drawQueued) {
|
|
191
|
+
drawQueued = false;
|
|
192
|
+
requestDraw();
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function requestDraw() {
|
|
198
|
+
if (drawInFlight) {
|
|
199
|
+
drawQueued = true;
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
if (drawScheduled) return;
|
|
203
|
+
drawScheduled = true;
|
|
204
|
+
requestAnimationFrame(() => {
|
|
205
|
+
drawScheduled = false;
|
|
206
|
+
draw().catch(showError);
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function canvasSize(canvas) {
|
|
211
|
+
const width = Math.max(1, Number(canvas && canvas.width || 1));
|
|
212
|
+
const height = Math.max(1, Number(canvas && canvas.height || 1));
|
|
213
|
+
return { width, height };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function ensureViewportState(canvas, forceReset) {
|
|
217
|
+
const size = canvasSize(canvas);
|
|
218
|
+
const changed = !viewport.initialized || viewport.baseWidth !== size.width || viewport.baseHeight !== size.height;
|
|
219
|
+
if (forceReset || changed) {
|
|
220
|
+
viewport = {
|
|
221
|
+
initialized: true,
|
|
222
|
+
baseWidth: size.width,
|
|
223
|
+
baseHeight: size.height,
|
|
224
|
+
x: 0,
|
|
225
|
+
y: 0,
|
|
226
|
+
width: size.width,
|
|
227
|
+
height: size.height
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
clampViewport();
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function applyViewportToSvg(svg, canvas) {
|
|
234
|
+
if (!svg) return;
|
|
235
|
+
ensureViewportState(canvas, false);
|
|
236
|
+
svg.setAttribute("viewBox", viewport.x.toFixed(3) + " " + viewport.y.toFixed(3) + " " + viewport.width.toFixed(3) + " " + viewport.height.toFixed(3));
|
|
237
|
+
svg.setAttribute("preserveAspectRatio", "xMidYMid meet");
|
|
238
|
+
updateZoomLabel();
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function updateZoomLabel() {
|
|
242
|
+
if (!zoomLabel) return;
|
|
243
|
+
if (!viewport.initialized || viewport.width <= 0) {
|
|
244
|
+
zoomLabel.textContent = "100%";
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
const zoom = Math.round((viewport.baseWidth / viewport.width) * 100);
|
|
248
|
+
zoomLabel.textContent = zoom + "%";
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function clampViewport() {
|
|
252
|
+
if (!viewport.initialized) return;
|
|
253
|
+
viewport.width = clampRange(viewport.width, viewport.baseWidth * 0.08, viewport.baseWidth);
|
|
254
|
+
viewport.height = clampRange(viewport.height, viewport.baseHeight * 0.08, viewport.baseHeight);
|
|
255
|
+
const maxX = viewport.baseWidth - viewport.width;
|
|
256
|
+
const maxY = viewport.baseHeight - viewport.height;
|
|
257
|
+
viewport.x = clampRange(viewport.x, 0, Math.max(0, maxX));
|
|
258
|
+
viewport.y = clampRange(viewport.y, 0, Math.max(0, maxY));
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function clampRange(value, min, max) {
|
|
262
|
+
return Math.max(min, Math.min(max, Number(value)));
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function wheelDeltaToPixels(event) {
|
|
266
|
+
const mode = Number(event && event.deltaMode || 0);
|
|
267
|
+
if (mode === 1) return { x: event.deltaX * 16, y: event.deltaY * 16 };
|
|
268
|
+
if (mode === 2) return { x: event.deltaX * Math.max(1, stageWrap.clientWidth), y: event.deltaY * Math.max(1, stageWrap.clientHeight) };
|
|
269
|
+
return { x: event.deltaX, y: event.deltaY };
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function zoomFactorFromWheel(event) {
|
|
273
|
+
const delta = wheelDeltaToPixels(event);
|
|
274
|
+
const dy = clampRange(delta.y, -600, 600);
|
|
275
|
+
return Math.exp(dy * 0.002);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function currentSvg() {
|
|
279
|
+
return stage.querySelector("svg");
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function svgPointFromClient(svg, clientX, clientY) {
|
|
283
|
+
if (!svg || !svg.getScreenCTM) return null;
|
|
284
|
+
const matrix = svg.getScreenCTM();
|
|
285
|
+
if (!matrix || !matrix.inverse) return null;
|
|
286
|
+
const point = svg.createSVGPoint();
|
|
287
|
+
point.x = clientX;
|
|
288
|
+
point.y = clientY;
|
|
289
|
+
return point.matrixTransform(matrix.inverse());
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function zoomBy(factor, clientX, clientY) {
|
|
293
|
+
const svg = currentSvg();
|
|
294
|
+
if (!svg || !doc || !doc.canvas) return;
|
|
295
|
+
ensureViewportState(doc.canvas, false);
|
|
296
|
+
const focus = Number.isFinite(clientX) && Number.isFinite(clientY)
|
|
297
|
+
? svgPointFromClient(svg, clientX, clientY)
|
|
298
|
+
: { x: viewport.x + viewport.width / 2, y: viewport.y + viewport.height / 2 };
|
|
299
|
+
const worldX = focus ? focus.x : viewport.x + viewport.width / 2;
|
|
300
|
+
const worldY = focus ? focus.y : viewport.y + viewport.height / 2;
|
|
301
|
+
const ux = clamp01((worldX - viewport.x) / viewport.width);
|
|
302
|
+
const uy = clamp01((worldY - viewport.y) / viewport.height);
|
|
303
|
+
const nextWidth = clampRange(viewport.width * Number(factor || 1), viewport.baseWidth * 0.08, viewport.baseWidth);
|
|
304
|
+
const zoomScale = nextWidth / viewport.width;
|
|
305
|
+
const nextHeight = clampRange(viewport.height * zoomScale, viewport.baseHeight * 0.08, viewport.baseHeight);
|
|
306
|
+
viewport.x = worldX - ux * nextWidth;
|
|
307
|
+
viewport.y = worldY - uy * nextHeight;
|
|
308
|
+
viewport.width = nextWidth;
|
|
309
|
+
viewport.height = nextHeight;
|
|
310
|
+
clampViewport();
|
|
311
|
+
applyViewportToSvg(svg, doc.canvas);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function resetViewport(applyNow) {
|
|
315
|
+
ensureViewportState(doc && doc.canvas, true);
|
|
316
|
+
if (!applyNow) return;
|
|
317
|
+
const svg = currentSvg();
|
|
318
|
+
if (svg) applyViewportToSvg(svg, doc && doc.canvas);
|
|
319
|
+
else updateZoomLabel();
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function panViewportByPixels(deltaX, deltaY) {
|
|
323
|
+
const svg = currentSvg();
|
|
324
|
+
if (!svg || !doc || !doc.canvas) return;
|
|
325
|
+
ensureViewportState(doc.canvas, false);
|
|
326
|
+
const rect = svg.getBoundingClientRect();
|
|
327
|
+
const widthPx = Math.max(1, rect.width);
|
|
328
|
+
const heightPx = Math.max(1, rect.height);
|
|
329
|
+
viewport.x += (Number(deltaX) || 0) * (viewport.width / widthPx);
|
|
330
|
+
viewport.y += (Number(deltaY) || 0) * (viewport.height / heightPx);
|
|
331
|
+
clampViewport();
|
|
332
|
+
applyViewportToSvg(svg, doc.canvas);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function shouldStartViewportPan(event) {
|
|
336
|
+
if (!stage.contains(event.target)) return false;
|
|
337
|
+
if (event.target && event.target.closest && event.target.closest("#viewportHud")) return false;
|
|
338
|
+
if (event.button === 1) return true;
|
|
339
|
+
return spacePanActive && event.button === 0;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function beginViewportPan(event) {
|
|
343
|
+
if (!doc || !doc.canvas) return;
|
|
344
|
+
const svg = currentSvg();
|
|
345
|
+
if (!svg) return;
|
|
346
|
+
ensureViewportState(doc.canvas, false);
|
|
347
|
+
viewportPan = {
|
|
348
|
+
pointerId: event.pointerId,
|
|
349
|
+
startClientX: event.clientX,
|
|
350
|
+
startClientY: event.clientY,
|
|
351
|
+
startX: viewport.x,
|
|
352
|
+
startY: viewport.y,
|
|
353
|
+
moved: false
|
|
354
|
+
};
|
|
355
|
+
stageWrap.classList.add("panning");
|
|
356
|
+
stageWrap.setPointerCapture?.(event.pointerId);
|
|
357
|
+
event.preventDefault();
|
|
358
|
+
event.stopPropagation();
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function updateViewportPan(event) {
|
|
362
|
+
if (!viewportPan || event.pointerId !== viewportPan.pointerId) return;
|
|
363
|
+
const svg = currentSvg();
|
|
364
|
+
if (!svg || !doc || !doc.canvas) return;
|
|
365
|
+
const startPoint = svgPointFromClient(svg, viewportPan.startClientX, viewportPan.startClientY);
|
|
366
|
+
const currentPoint = svgPointFromClient(svg, event.clientX, event.clientY);
|
|
367
|
+
if (!startPoint || !currentPoint) return;
|
|
368
|
+
const dx = currentPoint.x - startPoint.x;
|
|
369
|
+
const dy = currentPoint.y - startPoint.y;
|
|
370
|
+
viewport.x = viewportPan.startX - dx;
|
|
371
|
+
viewport.y = viewportPan.startY - dy;
|
|
372
|
+
clampViewport();
|
|
373
|
+
applyViewportToSvg(svg, doc.canvas);
|
|
374
|
+
viewportPan.moved = viewportPan.moved || Math.abs(dx) > 0.25 || Math.abs(dy) > 0.25;
|
|
375
|
+
event.preventDefault();
|
|
376
|
+
event.stopPropagation();
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function endViewportPan(event) {
|
|
380
|
+
if (!viewportPan) return;
|
|
381
|
+
if (event && event.pointerId !== undefined && event.pointerId !== viewportPan.pointerId) return;
|
|
382
|
+
const pointerId = viewportPan.pointerId;
|
|
383
|
+
if (viewportPan.moved) suppressClick = true;
|
|
384
|
+
viewportPan = null;
|
|
385
|
+
stageWrap.classList.remove("panning");
|
|
386
|
+
if (pointerId !== undefined) stageWrap.releasePointerCapture?.(pointerId);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function isEditableInputTarget(target) {
|
|
390
|
+
if (!target || !target.tagName) return false;
|
|
391
|
+
const tag = String(target.tagName).toLowerCase();
|
|
392
|
+
return tag === "input" || tag === "textarea" || tag === "select" || Boolean(target.isContentEditable);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function isPanelOpen(panelId, defaultOpen) {
|
|
396
|
+
if (Object.prototype.hasOwnProperty.call(panelOpenState, panelId)) return Boolean(panelOpenState[panelId]);
|
|
397
|
+
return Boolean(defaultOpen);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function panelDetails(panelId, title, body, options) {
|
|
401
|
+
const open = isPanelOpen(panelId, options && options.defaultOpen);
|
|
402
|
+
const extraClass = options && options.className ? " " + options.className : "";
|
|
403
|
+
const meta = options && options.meta !== undefined && options.meta !== ""
|
|
404
|
+
? "<span class='summaryMeta'>" + escapeText(String(options.meta)) + "</span>"
|
|
405
|
+
: "";
|
|
406
|
+
return "<details class='panelGroup" + extraClass + "' data-panel='" + escapeAttr(panelId) + "'" + (open ? " open" : "") + ">" +
|
|
407
|
+
"<summary><span>" + escapeText(title) + "</span>" + meta + "</summary>" +
|
|
408
|
+
"<div class='panelBody'>" + (body || "") + "</div>" +
|
|
409
|
+
"</details>";
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function bindPanelStates(scope) {
|
|
413
|
+
const root = scope || document;
|
|
414
|
+
const panels = root.querySelectorAll("details[data-panel]");
|
|
415
|
+
for (const panel of panels) {
|
|
416
|
+
const panelId = panel.getAttribute("data-panel");
|
|
417
|
+
if (!panelId) continue;
|
|
418
|
+
panelOpenState[panelId] = panel.open;
|
|
419
|
+
panel.ontoggle = () => {
|
|
420
|
+
panelOpenState[panelId] = panel.open;
|
|
421
|
+
};
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function renderTree() {
|
|
426
|
+
const canvas = doc && doc.canvas ? doc.canvas : {};
|
|
427
|
+
const canvasSummary = Math.round(valueOr(canvas.width, 1)) + "x" + Math.round(valueOr(canvas.height, 1));
|
|
428
|
+
const canvasBody =
|
|
429
|
+
"<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>" +
|
|
430
|
+
"<div class='row'>" + colorTextInput("Background", "canvasBackground", "", valueOr(canvas.background, ""), "", "#ffffff") + "<div></div></div>" +
|
|
431
|
+
"<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>" +
|
|
432
|
+
"<div id='canvasError' class='canvasError'></div>";
|
|
433
|
+
tree.innerHTML =
|
|
434
|
+
panelDetails("tree-canvas", "Canvas", canvasBody, { defaultOpen: false, meta: canvasSummary }) +
|
|
435
|
+
panelDetails("tree-elements", "Elements", "<div id='elementsTree'></div>", { defaultOpen: false, meta: refs.length + " items" });
|
|
436
|
+
bindPanelStates(tree);
|
|
437
|
+
bindCanvasInputs();
|
|
438
|
+
const treeRoot = document.getElementById("elementsTree");
|
|
439
|
+
if (!treeRoot) return;
|
|
440
|
+
for (const ref of refs) {
|
|
441
|
+
if (isInCollapsedBranch(ref.id)) continue;
|
|
442
|
+
const row = document.createElement("div");
|
|
443
|
+
row.className = "treeRow";
|
|
444
|
+
row.style.paddingLeft = 8 + ref.depth * 14 + "px";
|
|
445
|
+
const hasChildren = hasTreeChildren(ref.id) && typeById[ref.id] === "group";
|
|
446
|
+
if (hasChildren) {
|
|
447
|
+
const fold = document.createElement("button");
|
|
448
|
+
fold.className = "treeCtl";
|
|
449
|
+
fold.textContent = collapsedGroups.has(ref.id) ? "+" : "-";
|
|
450
|
+
fold.title = collapsedGroups.has(ref.id) ? "Expand group" : "Collapse group";
|
|
451
|
+
fold.onclick = (event) => {
|
|
452
|
+
event.stopPropagation();
|
|
453
|
+
toggleCollapse(ref.id);
|
|
454
|
+
};
|
|
455
|
+
row.appendChild(fold);
|
|
456
|
+
} else {
|
|
457
|
+
const pad = document.createElement("span");
|
|
458
|
+
pad.className = "treePad";
|
|
459
|
+
row.appendChild(pad);
|
|
460
|
+
}
|
|
461
|
+
const hide = document.createElement("button");
|
|
462
|
+
hide.className = "treeCtl" + (hiddenIds.has(ref.id) ? " active" : "");
|
|
463
|
+
hide.textContent = "H";
|
|
464
|
+
hide.title = hiddenIds.has(ref.id) ? "Show element/group" : "Hide element/group";
|
|
465
|
+
hide.onclick = (event) => {
|
|
466
|
+
event.stopPropagation();
|
|
467
|
+
toggleHidden(ref.id);
|
|
468
|
+
};
|
|
469
|
+
row.appendChild(hide);
|
|
470
|
+
const lock = document.createElement("button");
|
|
471
|
+
lock.className = "treeCtl" + (lockedIds.has(ref.id) ? " active" : "");
|
|
472
|
+
lock.textContent = "L";
|
|
473
|
+
lock.title = lockedIds.has(ref.id) ? "Unlock element/group" : "Lock element/group";
|
|
474
|
+
lock.onclick = (event) => {
|
|
475
|
+
event.stopPropagation();
|
|
476
|
+
toggleLocked(ref.id);
|
|
477
|
+
};
|
|
478
|
+
row.appendChild(lock);
|
|
479
|
+
const button = document.createElement("button");
|
|
480
|
+
button.className = "treeBtn" + (ref.id === selectedId ? " selected" : "") + (isElementHidden(ref.id) ? " dim" : "");
|
|
481
|
+
button.textContent = ref.id + " " + ref.type;
|
|
482
|
+
button.onclick = () => select(ref.id);
|
|
483
|
+
row.appendChild(button);
|
|
484
|
+
treeRoot.appendChild(row);
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
function bindCanvasInputs() {
|
|
489
|
+
const bind = (id, callback) => {
|
|
490
|
+
const input = document.getElementById(id);
|
|
491
|
+
if (!input) return;
|
|
492
|
+
input.oninput = callback;
|
|
493
|
+
input.onchange = callback;
|
|
494
|
+
};
|
|
495
|
+
bind("canvasWidth", () => scheduleCanvasCommit("width", () => parseCanvasNumber("canvasWidth", { min: 1, integer: true })));
|
|
496
|
+
bind("canvasHeight", () => scheduleCanvasCommit("height", () => parseCanvasNumber("canvasHeight", { min: 1, integer: true })));
|
|
497
|
+
bind("canvasBackground", () => scheduleCanvasCommit("background", () => parseCanvasBackground("canvasBackground")));
|
|
498
|
+
bind("canvasDuration", () => scheduleCanvasCommit("duration", () => parseCanvasNumber("canvasDuration", { min: 0, optional: true })));
|
|
499
|
+
bind("canvasFps", () => scheduleCanvasCommit("fps", () => parseCanvasNumber("canvasFps", { min: 1, integer: true, optional: true })));
|
|
500
|
+
bindColorPickerPair("canvasBackground");
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function scheduleCanvasCommit(property, reader) {
|
|
504
|
+
const timerKey = "__canvas__" + property;
|
|
505
|
+
if (sidebarCommitTimers[timerKey]) clearTimeout(sidebarCommitTimers[timerKey]);
|
|
506
|
+
sidebarCommitTimers[timerKey] = setTimeout(async () => {
|
|
507
|
+
delete sidebarCommitTimers[timerKey];
|
|
508
|
+
try {
|
|
509
|
+
const value = reader();
|
|
510
|
+
if (value === undefined) return;
|
|
511
|
+
await mutate(
|
|
512
|
+
"/api/canvas",
|
|
513
|
+
{ [property]: value },
|
|
514
|
+
{ refreshTree: false, refreshInspector: false, refreshTimeline: true }
|
|
515
|
+
);
|
|
516
|
+
showCanvasError("");
|
|
517
|
+
} catch (error) {
|
|
518
|
+
showCanvasError(error && error.message ? error.message : String(error));
|
|
519
|
+
}
|
|
520
|
+
}, 160);
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
function parseCanvasNumber(inputId, options) {
|
|
524
|
+
const input = document.getElementById(inputId);
|
|
525
|
+
if (!input) return undefined;
|
|
526
|
+
const raw = String(input.value || "").trim();
|
|
527
|
+
if (!raw) {
|
|
528
|
+
if (options && options.optional) return null;
|
|
529
|
+
throw new Error(inputId + " cannot be empty.");
|
|
530
|
+
}
|
|
531
|
+
const numeric = Number(raw);
|
|
532
|
+
if (!Number.isFinite(numeric)) throw new Error(inputId + " must be a number.");
|
|
533
|
+
const min = options && Number.isFinite(Number(options.min)) ? Number(options.min) : undefined;
|
|
534
|
+
if (min !== undefined && numeric < min) throw new Error(inputId + " must be >= " + min + ".");
|
|
535
|
+
return options && options.integer ? Math.round(numeric) : numeric;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
function parseCanvasBackground(inputId) {
|
|
539
|
+
const input = document.getElementById(inputId);
|
|
540
|
+
if (!input) return undefined;
|
|
541
|
+
const raw = String(input.value || "").trim();
|
|
542
|
+
return raw ? raw : null;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
function showCanvasError(message) {
|
|
546
|
+
const box = document.getElementById("canvasError");
|
|
547
|
+
if (box) box.textContent = message || "";
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
function rebuildElementIndex() {
|
|
551
|
+
parentById = Object.create(null);
|
|
552
|
+
childIdsById = Object.create(null);
|
|
553
|
+
typeById = Object.create(null);
|
|
554
|
+
const visit = (elements, parentId) => {
|
|
555
|
+
for (const element of elements || []) {
|
|
556
|
+
const id = element && element.id;
|
|
557
|
+
const nextParent = id || parentId;
|
|
558
|
+
if (id) {
|
|
559
|
+
typeById[id] = element.type;
|
|
560
|
+
parentById[id] = parentId || "";
|
|
561
|
+
childIdsById[id] = childIdsById[id] || [];
|
|
562
|
+
if (parentId) {
|
|
563
|
+
childIdsById[parentId] = childIdsById[parentId] || [];
|
|
564
|
+
childIdsById[parentId].push(id);
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
if (element && element.type === "group") visit(element.children || [], nextParent);
|
|
568
|
+
}
|
|
569
|
+
};
|
|
570
|
+
visit(doc && doc.elements ? doc.elements : [], "");
|
|
571
|
+
pruneEditorSets();
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
function pruneEditorSets() {
|
|
575
|
+
const prune = (set, predicate) => {
|
|
576
|
+
for (const id of Array.from(set)) if (!predicate(id)) set.delete(id);
|
|
577
|
+
};
|
|
578
|
+
prune(hiddenIds, (id) => Boolean(typeById[id]));
|
|
579
|
+
prune(lockedIds, (id) => Boolean(typeById[id]));
|
|
580
|
+
prune(collapsedGroups, (id) => typeById[id] === "group");
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
function hasTreeChildren(id) {
|
|
584
|
+
const children = childIdsById[id];
|
|
585
|
+
return Boolean(children && children.length);
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
function isInCollapsedBranch(id) {
|
|
589
|
+
let parent = parentById[id];
|
|
590
|
+
while (parent) {
|
|
591
|
+
if (collapsedGroups.has(parent)) return true;
|
|
592
|
+
parent = parentById[parent];
|
|
593
|
+
}
|
|
594
|
+
return false;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
function isElementHidden(id) {
|
|
598
|
+
return hasMarkedAncestor(id, hiddenIds);
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
function isElementLocked(id) {
|
|
602
|
+
return hasMarkedAncestor(id, lockedIds);
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
function hasMarkedAncestor(id, set) {
|
|
606
|
+
let current = id;
|
|
607
|
+
while (current) {
|
|
608
|
+
if (set.has(current)) return true;
|
|
609
|
+
current = parentById[current];
|
|
610
|
+
}
|
|
611
|
+
return false;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
function toggleCollapse(id) {
|
|
615
|
+
if (collapsedGroups.has(id)) collapsedGroups.delete(id);
|
|
616
|
+
else collapsedGroups.add(id);
|
|
617
|
+
renderTree();
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
function toggleHidden(id) {
|
|
621
|
+
clearSidebarCommitTimers();
|
|
622
|
+
if (hiddenIds.has(id)) hiddenIds.delete(id);
|
|
623
|
+
else hiddenIds.add(id);
|
|
624
|
+
clearHandles();
|
|
625
|
+
renderTree();
|
|
626
|
+
renderInspector();
|
|
627
|
+
requestDraw();
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
function toggleLocked(id) {
|
|
631
|
+
clearSidebarCommitTimers();
|
|
632
|
+
if (lockedIds.has(id)) lockedIds.delete(id);
|
|
633
|
+
else lockedIds.add(id);
|
|
634
|
+
if (drag && isElementLocked(drag.id)) drag = null;
|
|
635
|
+
clearHandles();
|
|
636
|
+
renderTree();
|
|
637
|
+
renderInspector();
|
|
638
|
+
requestDraw();
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
function applyEditorFlagsToStage() {
|
|
642
|
+
for (const id of hiddenIds) {
|
|
643
|
+
const target = stage.querySelector("#" + cssId(id));
|
|
644
|
+
if (target) target.style.display = "none";
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
function select(id, options) {
|
|
649
|
+
clearSidebarCommitTimers();
|
|
650
|
+
if (selectedId !== id) {
|
|
651
|
+
selectedSegment = null;
|
|
652
|
+
closeCurveModal();
|
|
653
|
+
}
|
|
654
|
+
selectedId = id;
|
|
655
|
+
renderTree();
|
|
656
|
+
renderInspector();
|
|
657
|
+
renderTimeline();
|
|
658
|
+
if (options && options.draw === false) {
|
|
659
|
+
refreshHandles();
|
|
660
|
+
} else {
|
|
661
|
+
requestDraw();
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
function deselect() {
|
|
666
|
+
if (!selectedId) return;
|
|
667
|
+
clearSidebarCommitTimers();
|
|
668
|
+
selectedId = "";
|
|
669
|
+
selectedSegment = null;
|
|
670
|
+
closeCurveModal();
|
|
671
|
+
clearHandles();
|
|
672
|
+
renderTree();
|
|
673
|
+
renderInspector();
|
|
674
|
+
renderTimeline();
|
|
675
|
+
requestDraw();
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
function renderInspector() {
|
|
679
|
+
const element = findElement(selectedId);
|
|
680
|
+
if (!element) {
|
|
681
|
+
inspector.innerHTML = "<div class='muted'>Select an element.</div>";
|
|
682
|
+
return;
|
|
683
|
+
}
|
|
684
|
+
const displayElement = findResolvedElement(selectedId) || element;
|
|
685
|
+
const supportsPosition = ["path","point","text","image","group"].includes(element.type);
|
|
686
|
+
const supportsOrigin = ["path","text","image","group"].includes(element.type);
|
|
687
|
+
const supportsPaint = element.type !== "point";
|
|
688
|
+
const supportsEffects = ["path","text","image","group"].includes(element.type);
|
|
689
|
+
const isPath = element.type === "path";
|
|
690
|
+
const isTextElement = element.type === "text";
|
|
691
|
+
const origin = originPointValue(displayElement);
|
|
692
|
+
const locked = isElementLocked(selectedId);
|
|
693
|
+
const hidden = isElementHidden(selectedId);
|
|
694
|
+
const lockDisabled = locked ? "disabled" : "";
|
|
695
|
+
const positionDisabled = supportsPosition && !locked ? "" : "disabled";
|
|
696
|
+
const originRows = supportsOrigin
|
|
697
|
+
? "<div class='row'><label>Origin X<input id='propOriginX' type='number' step='1' value='" + origin[0] + "' " + lockDisabled + "></label><label>Origin Y<input id='propOriginY' type='number' step='1' value='" + origin[1] + "' " + lockDisabled + "></label></div>"
|
|
698
|
+
: "";
|
|
699
|
+
const paintHint = supportsPaint && ((displayElement.fill !== undefined && typeof displayElement.fill !== "string") || (displayElement.stroke !== undefined && typeof displayElement.stroke !== "string"))
|
|
700
|
+
? "<div class='tiny'>Structured paint channels can be keyframed below.</div>"
|
|
701
|
+
: "";
|
|
702
|
+
const paintRows = supportsPaint
|
|
703
|
+
? "<div class='row'>" +
|
|
704
|
+
colorTextInput("Fill", "propFill", "fill", typeof displayElement.fill === "string" ? displayElement.fill : "", lockDisabled, "#22c55e or color") +
|
|
705
|
+
colorTextInput("Stroke", "propStroke", "stroke", typeof displayElement.stroke === "string" ? displayElement.stroke : "", lockDisabled, "#0f172a or color") +
|
|
706
|
+
"</div>" +
|
|
707
|
+
"<div class='row'><label>Stroke W<input id='propStrokeWidth' type='number' step='0.1' value='" + valueOr(displayElement.strokeWidth, 1) + "' " + lockDisabled + "></label><label>Dash Offset<input id='propDashOffset' type='number' step='0.1' value='" + valueOr(displayElement.dashOffset, 0) + "' " + lockDisabled + "></label></div>"
|
|
708
|
+
: "";
|
|
709
|
+
const pathRows = isPath
|
|
710
|
+
? "<div class='row'><label>Draw Start<input id='propDrawStart' type='number' min='0' max='1' step='0.01' value='" + valueOr(displayElement.drawStart, 0) + "' " + lockDisabled + "></label><label>Draw End<input id='propDrawEnd' type='number' min='0' max='1' step='0.01' value='" + valueOr(displayElement.drawEnd, 1) + "' " + lockDisabled + "></label></div>" +
|
|
711
|
+
"<div class='row'><label>Dash Array<input id='propDashArray' type='text' value='" + escapeAttr(formatArrayValue(displayElement.dashArray)) + "' " + lockDisabled + "></label><div></div></div>"
|
|
712
|
+
: "";
|
|
713
|
+
const textTrackProperty = isTextElement ? textEditorProperty(displayElement) : "text";
|
|
714
|
+
const typographyRows = isTextElement
|
|
715
|
+
? panelDetails(
|
|
716
|
+
"inspector-typography",
|
|
717
|
+
"Typography",
|
|
718
|
+
"<div class='row'><label>Font<select id='propFontFamily' " + lockDisabled + ">" + fontFamilyOptionsHtml(displayElement.fontFamily) + "</select></label><label>Weight<select id='propWeight' " + lockDisabled + ">" + fontWeightOptionsHtml(displayElement.weight) + "</select></label></div>" +
|
|
719
|
+
"<div class='row'><label>Font Size<input id='propFontSize' type='number' step='1' value='" + valueOr(displayElement.fontSize, 16) + "' " + lockDisabled + "></label><label>Line Height<input id='propLineHeight' type='number' step='0.05' value='" + valueOr(displayElement.lineHeight, 1.2) + "' " + lockDisabled + "></label></div>" +
|
|
720
|
+
"<div class='row'><label>Letter Spacing<input id='propLetterSpacing' type='number' step='0.1' value='" + valueOr(displayElement.letterSpacing, 0) + "' " + lockDisabled + "></label><div></div></div>",
|
|
721
|
+
{ defaultOpen: false }
|
|
722
|
+
)
|
|
723
|
+
: "";
|
|
724
|
+
const textRows = isTextElement
|
|
725
|
+
? "<div class='stack'><label>Text<textarea id='propText' rows='4' " + lockDisabled + "></textarea></label><div class='tiny'>Use new lines for multiline text. Elements already using lines[] keep that format.</div></div>" +
|
|
726
|
+
typographyRows
|
|
727
|
+
: "";
|
|
728
|
+
const effectsRows = supportsEffects ? renderEffectsRows(displayElement, lockDisabled) : "";
|
|
729
|
+
const sourceRows = element.type === "image" ? renderImageSourceRows(displayElement, lockDisabled) : "";
|
|
730
|
+
const structuredPaintRows = supportsPaint ? renderStructuredPaintRows(displayElement, "fill", lockDisabled) + renderStructuredPaintRows(displayElement, "stroke", lockDisabled) : "";
|
|
731
|
+
const selectedMeta = escapeText(element.type) + (hidden ? " | hidden" : "") + (locked ? " | locked" : "");
|
|
732
|
+
const selectedRows =
|
|
733
|
+
"<strong>" + escapeText(element.id || "") + "</strong>" +
|
|
734
|
+
"<div class='muted'>" + selectedMeta + "</div>" +
|
|
735
|
+
(locked ? "<div class='tiny'>Locked elements and groups cannot be edited from canvas or inspector.</div>" : "") +
|
|
736
|
+
"<div id='error'></div>";
|
|
737
|
+
const transformRows =
|
|
738
|
+
"<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>" +
|
|
739
|
+
"<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>" +
|
|
740
|
+
"<div class='row'><label>Scale X<input id='propScaleX' type='number' step='0.05' value='" + valueOr(displayElement.scaleX, valueOr(displayElement.scale, 1)) + "' " + lockDisabled + "></label><label>Scale Y<input id='propScaleY' type='number' step='0.05' value='" + valueOr(displayElement.scaleY, valueOr(displayElement.scale, 1)) + "' " + lockDisabled + "></label></div>" +
|
|
741
|
+
originRows;
|
|
742
|
+
const appearanceRows =
|
|
743
|
+
"<div class='row'><label>Opacity<input id='propOpacity' type='number' min='0' max='1' step='0.05' value='" + valueOr(displayElement.opacity, 1) + "' " + lockDisabled + "></label><div></div></div>" +
|
|
744
|
+
paintRows +
|
|
745
|
+
paintHint;
|
|
746
|
+
const contentRows = pathRows + textRows + sourceRows;
|
|
747
|
+
const keyframeRows =
|
|
748
|
+
"<div class='row'><label>Time<input id='kfTime' type='number' step='0.05' value='" + currentTime.toFixed(2) + "'></label><div></div></div>" +
|
|
749
|
+
"<p class='tiny'>Changing sidebar values updates keyframes at the current time.</p>" +
|
|
750
|
+
"<p class='tiny'>Interpolation curves are edited from timeline badges.</p>" +
|
|
751
|
+
"<p class='tiny'>Drag to move. Use the square to scale and the round handle to rotate.</p>";
|
|
752
|
+
inspector.innerHTML =
|
|
753
|
+
panelDetails("inspector-selected", "Selected", selectedRows, { defaultOpen: true, meta: element.type }) +
|
|
754
|
+
panelDetails("inspector-transform", "Transform", transformRows, { defaultOpen: false }) +
|
|
755
|
+
panelDetails("inspector-appearance", "Appearance", appearanceRows, { defaultOpen: false }) +
|
|
756
|
+
(contentRows ? panelDetails("inspector-content", "Path / Content", contentRows, { defaultOpen: false }) : "") +
|
|
757
|
+
(supportsEffects ? panelDetails("inspector-effects", "Effects", effectsRows, { defaultOpen: false }) : "") +
|
|
758
|
+
(structuredPaintRows ? panelDetails("inspector-structured-paint", "Structured Paint", structuredPaintRows, { defaultOpen: false }) : "") +
|
|
759
|
+
panelDetails("inspector-keyframe", "Keyframe", keyframeRows, { defaultOpen: false });
|
|
760
|
+
bindPanelStates(inspector);
|
|
761
|
+
if (supportsPaint) {
|
|
762
|
+
setInput("propFill", typeof displayElement.fill === "string" ? displayElement.fill : "");
|
|
763
|
+
setInput("propStroke", typeof displayElement.stroke === "string" ? displayElement.stroke : "");
|
|
764
|
+
}
|
|
765
|
+
if (isTextElement) {
|
|
766
|
+
setInput("propText", textEditorValue(displayElement));
|
|
767
|
+
setInput("propFontFamily", valueOr(displayElement.fontFamily, defaultFontFamilyValue()));
|
|
768
|
+
setInput("propWeight", valueOr(displayElement.weight, 400));
|
|
769
|
+
setInput("propFontSize", valueOr(displayElement.fontSize, 16));
|
|
770
|
+
setInput("propLineHeight", valueOr(displayElement.lineHeight, 1.2));
|
|
771
|
+
setInput("propLetterSpacing", valueOr(displayElement.letterSpacing, 0));
|
|
772
|
+
}
|
|
773
|
+
const kfTimeInput = document.getElementById("kfTime");
|
|
774
|
+
if (kfTimeInput) {
|
|
775
|
+
kfTimeInput.oninput = (event) => {
|
|
776
|
+
setCurrentTime(event.target.value);
|
|
777
|
+
};
|
|
778
|
+
}
|
|
779
|
+
const bindAutoKeyframe = (id, callback) => {
|
|
780
|
+
const input = document.getElementById(id);
|
|
781
|
+
if (!input) return;
|
|
782
|
+
input.oninput = callback;
|
|
783
|
+
input.onchange = callback;
|
|
784
|
+
};
|
|
785
|
+
if (supportsPosition && !locked) {
|
|
786
|
+
bindAutoKeyframe("propX", scheduleSidebarPositionKeyframe);
|
|
787
|
+
bindAutoKeyframe("propY", scheduleSidebarPositionKeyframe);
|
|
788
|
+
}
|
|
789
|
+
if (supportsOrigin && !locked) {
|
|
790
|
+
bindAutoKeyframe("propOriginX", scheduleSidebarOriginKeyframe);
|
|
791
|
+
bindAutoKeyframe("propOriginY", scheduleSidebarOriginKeyframe);
|
|
792
|
+
}
|
|
793
|
+
if (!locked) {
|
|
794
|
+
bindAutoKeyframe("propRotation", () => scheduleSidebarNumberKeyframe("rotation", "propRotation"));
|
|
795
|
+
bindAutoKeyframe("propScale", () => scheduleSidebarNumberKeyframe("scale", "propScale"));
|
|
796
|
+
bindAutoKeyframe("propScaleX", () => scheduleSidebarNumberKeyframe("scaleX", "propScaleX"));
|
|
797
|
+
bindAutoKeyframe("propScaleY", () => scheduleSidebarNumberKeyframe("scaleY", "propScaleY"));
|
|
798
|
+
bindAutoKeyframe("propOpacity", () => scheduleSidebarNumberKeyframe("opacity", "propOpacity"));
|
|
799
|
+
if (supportsPaint) {
|
|
800
|
+
bindAutoKeyframe("propFill", () => scheduleSidebarPaintKeyframe("fill", "propFill"));
|
|
801
|
+
bindAutoKeyframe("propStroke", () => scheduleSidebarPaintKeyframe("stroke", "propStroke"));
|
|
802
|
+
bindAutoKeyframe("propStrokeWidth", () => scheduleSidebarNumberKeyframe("strokeWidth", "propStrokeWidth"));
|
|
803
|
+
bindAutoKeyframe("propDashOffset", () => scheduleSidebarNumberKeyframe("dashOffset", "propDashOffset"));
|
|
804
|
+
}
|
|
805
|
+
if (isPath) {
|
|
806
|
+
bindAutoKeyframe("propDrawStart", () => scheduleSidebarNumberKeyframe("drawStart", "propDrawStart"));
|
|
807
|
+
bindAutoKeyframe("propDrawEnd", () => scheduleSidebarNumberKeyframe("drawEnd", "propDrawEnd"));
|
|
808
|
+
bindAutoKeyframe("propDashArray", () => scheduleSidebarNumberArrayKeyframe("dashArray", "propDashArray"));
|
|
809
|
+
}
|
|
810
|
+
if (isTextElement) {
|
|
811
|
+
bindAutoKeyframe("propText", () => scheduleSidebarTextContentKeyframe(textTrackProperty, "propText"));
|
|
812
|
+
bindAutoKeyframe("propFontFamily", () => scheduleSidebarTextKeyframe("fontFamily", "propFontFamily"));
|
|
813
|
+
bindAutoKeyframe("propWeight", () => scheduleSidebarNumberOrTextKeyframe("weight", "propWeight"));
|
|
814
|
+
bindAutoKeyframe("propFontSize", () => scheduleSidebarNumberKeyframe("fontSize", "propFontSize"));
|
|
815
|
+
bindAutoKeyframe("propLineHeight", () => scheduleSidebarNumberKeyframe("lineHeight", "propLineHeight"));
|
|
816
|
+
bindAutoKeyframe("propLetterSpacing", () => scheduleSidebarNumberKeyframe("letterSpacing", "propLetterSpacing"));
|
|
817
|
+
}
|
|
818
|
+
bindDynamicInspectorInputs(bindAutoKeyframe);
|
|
819
|
+
}
|
|
820
|
+
bindColorPickersInScope(inspector);
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
function fontFamilyOptionsHtml(currentValue) {
|
|
824
|
+
const current = String(valueOr(currentValue, "")).trim();
|
|
825
|
+
const seen = new Set();
|
|
826
|
+
const options = [];
|
|
827
|
+
for (const option of FONT_FAMILY_OPTIONS) {
|
|
828
|
+
if (!option || !option.value || seen.has(option.value)) continue;
|
|
829
|
+
seen.add(option.value);
|
|
830
|
+
options.push("<option value='" + escapeAttr(option.value) + "'" + (current === option.value ? " selected" : "") + ">" + escapeText(option.label) + "</option>");
|
|
831
|
+
}
|
|
832
|
+
if (current && !seen.has(current)) {
|
|
833
|
+
options.unshift("<option value='" + escapeAttr(current) + "' selected>" + escapeText(current) + "</option>");
|
|
834
|
+
}
|
|
835
|
+
if (!current && options.length) {
|
|
836
|
+
options[0] = options[0].replace("<option ", "<option selected ");
|
|
837
|
+
}
|
|
838
|
+
return options.join("");
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
function defaultFontFamilyValue() {
|
|
842
|
+
return FONT_FAMILY_OPTIONS[0] && FONT_FAMILY_OPTIONS[0].value ? FONT_FAMILY_OPTIONS[0].value : "";
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
function fontWeightOptionsHtml(currentValue) {
|
|
846
|
+
const current = String(valueOr(currentValue, "")).trim();
|
|
847
|
+
const seen = new Set();
|
|
848
|
+
const options = [];
|
|
849
|
+
for (const option of FONT_WEIGHT_OPTIONS) {
|
|
850
|
+
if (!option || !option.value || seen.has(option.value)) continue;
|
|
851
|
+
seen.add(option.value);
|
|
852
|
+
options.push("<option value='" + escapeAttr(option.value) + "'" + (current === option.value ? " selected" : "") + ">" + escapeText(option.label) + "</option>");
|
|
853
|
+
}
|
|
854
|
+
if (current && !seen.has(current)) {
|
|
855
|
+
options.unshift("<option value='" + escapeAttr(current) + "' selected>" + escapeText(current) + "</option>");
|
|
856
|
+
}
|
|
857
|
+
if (!current && options.length) {
|
|
858
|
+
const defaultIndex = options.findIndex((option) => option.includes("value='400'"));
|
|
859
|
+
if (defaultIndex >= 0) {
|
|
860
|
+
options[defaultIndex] = options[defaultIndex].replace("<option ", "<option selected ");
|
|
861
|
+
} else {
|
|
862
|
+
options[0] = options[0].replace("<option ", "<option selected ");
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
return options.join("");
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
function renderEffectsRows(element, disabled) {
|
|
869
|
+
const effects = element.effects || {};
|
|
870
|
+
const shadow = effects.shadow || {};
|
|
871
|
+
return "<div class='row'>" +
|
|
872
|
+
dynamicNumberInput("Blur", "propEffectsBlur", "effects.blur", valueOr(effects.blur, 0), disabled, "0.1") +
|
|
873
|
+
dynamicNumberInput("Bright", "propEffectsBrightness", "effects.brightness", valueOr(effects.brightness, 1), disabled, "0.05") +
|
|
874
|
+
"</div>" +
|
|
875
|
+
"<div class='row'>" +
|
|
876
|
+
dynamicNumberInput("Contrast", "propEffectsContrast", "effects.contrast", valueOr(effects.contrast, 1), disabled, "0.05") +
|
|
877
|
+
dynamicNumberInput("Saturate", "propEffectsSaturate", "effects.saturate", valueOr(effects.saturate, 1), disabled, "0.05") +
|
|
878
|
+
"</div>" +
|
|
879
|
+
"<div class='row'>" +
|
|
880
|
+
dynamicNumberInput("Hue", "propEffectsHue", "effects.hueRotate", valueOr(effects.hueRotate, 0), disabled, "1") +
|
|
881
|
+
dynamicColorInput("Shadow Color", "propShadowColor", "effects.shadow.color", valueOr(shadow.color, "#000000"), disabled) +
|
|
882
|
+
"</div>" +
|
|
883
|
+
"<div class='row'>" +
|
|
884
|
+
dynamicNumberInput("Shadow X", "propShadowDx", "effects.shadow.dx", valueOr(shadow.dx, 0), disabled, "0.5") +
|
|
885
|
+
dynamicNumberInput("Shadow Y", "propShadowDy", "effects.shadow.dy", valueOr(shadow.dy, 0), disabled, "0.5") +
|
|
886
|
+
"</div>" +
|
|
887
|
+
"<div class='row'>" +
|
|
888
|
+
dynamicNumberInput("Shadow Blur", "propShadowBlur", "effects.shadow.blur", valueOr(shadow.blur, 0), disabled, "0.5") +
|
|
889
|
+
dynamicNumberInput("Shadow Opacity", "propShadowOpacity", "effects.shadow.opacity", valueOr(shadow.opacity, 1), disabled, "0.05", "0", "1") +
|
|
890
|
+
"</div>";
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
function renderImageSourceRows(element, disabled) {
|
|
894
|
+
const source = element.source || { x: 0, y: 0, width: element.width || 0, height: element.height || 0 };
|
|
895
|
+
return "<div class='row'>" +
|
|
896
|
+
dynamicNumberInput("Source X", "propSourceX", "source.x", valueOr(source.x, 0), disabled, "1") +
|
|
897
|
+
dynamicNumberInput("Source Y", "propSourceY", "source.y", valueOr(source.y, 0), disabled, "1") +
|
|
898
|
+
"</div>" +
|
|
899
|
+
"<div class='row'>" +
|
|
900
|
+
dynamicNumberInput("Source W", "propSourceWidth", "source.width", valueOr(source.width, element.width || 0), disabled, "1") +
|
|
901
|
+
dynamicNumberInput("Source H", "propSourceHeight", "source.height", valueOr(source.height, element.height || 0), disabled, "1") +
|
|
902
|
+
"</div>" +
|
|
903
|
+
"<div class='row'>" +
|
|
904
|
+
dynamicClipRadiusInput("Radius", "propClipRadius", clipRadiusValue(element), disabled, "1", "0") +
|
|
905
|
+
"<div></div>" +
|
|
906
|
+
"</div>";
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
function renderStructuredPaintRows(element, root, disabled) {
|
|
910
|
+
const paint = element[root];
|
|
911
|
+
if (!paint || typeof paint !== "object") return "";
|
|
912
|
+
let html = "<span class='subhead'>" + escapeText(cap(root)) + " Paint</span>";
|
|
913
|
+
if (paint.type === "linearGradient") {
|
|
914
|
+
html += "<div class='row'>" +
|
|
915
|
+
dynamicPointInput(root + " From", "prop" + cap(root) + "From", root + ".from", paint.from || [0, 0], disabled) +
|
|
916
|
+
dynamicPointInput(root + " To", "prop" + cap(root) + "To", root + ".to", paint.to || [100, 0], disabled) +
|
|
917
|
+
"</div>";
|
|
918
|
+
} else if (paint.type === "radialGradient") {
|
|
919
|
+
html += "<div class='row'>" +
|
|
920
|
+
dynamicPointInput(root + " Center", "prop" + cap(root) + "Center", root + ".center", paint.center || [50, 50], disabled) +
|
|
921
|
+
dynamicPointInput(root + " Focus", "prop" + cap(root) + "Focus", root + ".focus", paint.focus || paint.center || [50, 50], disabled) +
|
|
922
|
+
"</div><div class='row'>" +
|
|
923
|
+
dynamicNumberInput(root + " Radius", "prop" + cap(root) + "Radius", root + ".radius", valueOr(paint.radius, 50), disabled, "1") +
|
|
924
|
+
"<div></div></div>";
|
|
925
|
+
}
|
|
926
|
+
const stops = Array.isArray(paint.stops) ? paint.stops : [];
|
|
927
|
+
for (let index = 0; index < stops.length; index += 1) {
|
|
928
|
+
const stop = stops[index];
|
|
929
|
+
const offset = Array.isArray(stop) ? stop[0] : stop && stop.offset;
|
|
930
|
+
const color = Array.isArray(stop) ? stop[1] : stop && stop.color;
|
|
931
|
+
html += "<div class='row'>" +
|
|
932
|
+
dynamicNumberInput(root + " Stop " + index, "prop" + cap(root) + "Stop" + index + "Offset", root + ".stops." + index + ".offset", valueOr(offset, index / Math.max(1, stops.length - 1)), disabled, "0.01", "0", "1") +
|
|
933
|
+
dynamicColorInput("Color " + index, "prop" + cap(root) + "Stop" + index + "Color", root + ".stops." + index + ".color", valueOr(color, "#000000"), disabled) +
|
|
934
|
+
"</div>";
|
|
935
|
+
}
|
|
936
|
+
return html;
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
function dynamicNumberInput(label, id, property, value, disabled, step, min, max) {
|
|
940
|
+
return "<label>" + escapeText(label) + "<input id='" + id + "' type='number' data-kf-property='" + property + "' data-kf-kind='number' step='" + (step || "1") + "' " + (min === undefined ? "" : "min='" + min + "' ") + (max === undefined ? "" : "max='" + max + "' ") + "value='" + escapeAttr(value) + "' " + disabled + "></label>";
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
function dynamicClipRadiusInput(label, id, value, disabled, step, min, max) {
|
|
944
|
+
return "<label>" + escapeText(label) + "<input id='" + id + "' type='number' data-kf-property='clip.d' data-kf-kind='clipRadius' step='" + (step || "1") + "' " + (min === undefined ? "" : "min='" + min + "' ") + (max === undefined ? "" : "max='" + max + "' ") + "value='" + escapeAttr(value) + "' " + disabled + "></label>";
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
function dynamicTextInput(label, id, property, value, disabled) {
|
|
948
|
+
return "<label>" + escapeText(label) + "<input id='" + id + "' type='text' data-kf-property='" + property + "' data-kf-kind='text' value='" + escapeAttr(value) + "' " + disabled + "></label>";
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
function dynamicColorInput(label, id, property, value, disabled) {
|
|
952
|
+
return colorTextInput(label, id, property, value, disabled, "#000000");
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
function colorTextInput(label, id, property, value, disabled, placeholder) {
|
|
956
|
+
const data = property ? " data-kf-property='" + escapeAttr(property) + "' data-kf-kind='text'" : "";
|
|
957
|
+
const place = placeholder ? " placeholder='" + escapeAttr(placeholder) + "'" : "";
|
|
958
|
+
return "<label>" + escapeText(label) +
|
|
959
|
+
"<div class='colorField'>" +
|
|
960
|
+
"<input id='" + id + "Picker' type='color' data-color-source='" + id + "' value='" + escapeAttr(colorPickerValue(value)) + "' " + disabled + ">" +
|
|
961
|
+
"<input id='" + id + "' type='text'" + data + place + " value='" + escapeAttr(valueOr(value, "")) + "' " + disabled + ">" +
|
|
962
|
+
"</div></label>";
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
function bindColorPickersInScope(scope) {
|
|
966
|
+
const root = scope || document;
|
|
967
|
+
const pickers = root.querySelectorAll("input[type='color'][data-color-source]");
|
|
968
|
+
const seen = new Set();
|
|
969
|
+
for (const picker of pickers) {
|
|
970
|
+
const textId = picker.getAttribute("data-color-source");
|
|
971
|
+
if (!textId || seen.has(textId)) continue;
|
|
972
|
+
seen.add(textId);
|
|
973
|
+
bindColorPickerPair(textId);
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
function bindColorPickerPair(textId) {
|
|
978
|
+
const text = document.getElementById(textId);
|
|
979
|
+
const picker = document.getElementById(textId + "Picker");
|
|
980
|
+
if (!text || !picker) return;
|
|
981
|
+
const sync = () => {
|
|
982
|
+
picker.value = colorPickerValue(text.value);
|
|
983
|
+
};
|
|
984
|
+
sync();
|
|
985
|
+
picker.oninput = () => {
|
|
986
|
+
text.value = picker.value;
|
|
987
|
+
text.dispatchEvent(new Event("input", { bubbles: true }));
|
|
988
|
+
};
|
|
989
|
+
picker.onchange = picker.oninput;
|
|
990
|
+
text.addEventListener("input", sync);
|
|
991
|
+
text.addEventListener("change", sync);
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
function syncColorPickersInScope(scope) {
|
|
995
|
+
const root = scope || document;
|
|
996
|
+
const pickers = root.querySelectorAll("input[type='color'][data-color-source]");
|
|
997
|
+
for (const picker of pickers) {
|
|
998
|
+
const text = document.getElementById(picker.getAttribute("data-color-source"));
|
|
999
|
+
if (text) picker.value = colorPickerValue(text.value);
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
function colorPickerValue(value) {
|
|
1004
|
+
const normalized = normalizeHexColor(value);
|
|
1005
|
+
return normalized || "#000000";
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
function normalizeHexColor(value) {
|
|
1009
|
+
const text = String(value === undefined || value === null ? "" : value).trim();
|
|
1010
|
+
const short = /^#([0-9a-f]{3})$/i.exec(text);
|
|
1011
|
+
if (short) return "#" + short[1].split("").map((char) => char + char).join("").toLowerCase();
|
|
1012
|
+
const full = /^#([0-9a-f]{6})$/i.exec(text);
|
|
1013
|
+
if (full) return "#" + full[1].toLowerCase();
|
|
1014
|
+
return undefined;
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
function dynamicPointInput(label, idPrefix, property, value, disabled) {
|
|
1018
|
+
const point = Array.isArray(value) ? value : [0, 0];
|
|
1019
|
+
const xId = idPrefix + "X";
|
|
1020
|
+
const yId = idPrefix + "Y";
|
|
1021
|
+
return "<label>" + escapeText(label) +
|
|
1022
|
+
"<div class='row'>" +
|
|
1023
|
+
"<input id='" + xId + "' type='number' data-kf-property='" + property + "' data-kf-kind='point' data-kf-x='" + xId + "' data-kf-y='" + yId + "' step='1' value='" + escapeAttr(valueOr(point[0], 0)) + "' " + disabled + ">" +
|
|
1024
|
+
"<input id='" + yId + "' type='number' data-kf-property='" + property + "' data-kf-kind='point' data-kf-x='" + xId + "' data-kf-y='" + yId + "' step='1' value='" + escapeAttr(valueOr(point[1], 0)) + "' " + disabled + ">" +
|
|
1025
|
+
"</div></label>";
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
function bindDynamicInspectorInputs(bindAutoKeyframe) {
|
|
1029
|
+
const inputs = inspector.querySelectorAll("[data-kf-property]");
|
|
1030
|
+
for (const input of inputs) {
|
|
1031
|
+
bindAutoKeyframe(input.id, () => scheduleSidebarDynamicKeyframe(input));
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
function renderTimeline() {
|
|
1036
|
+
const element = findElement(selectedId);
|
|
1037
|
+
const tracks = element && element.timeline && element.timeline.tracks ? element.timeline.tracks : {};
|
|
1038
|
+
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 id='exportMenuWrap' class='menuWrap'><button id='exportMenuBtn' class='menuBtn' type='button' title='Export options'>Export</button><div id='exportMenu' class='menuList hidden'><button id='exportSvg' type='button' title='Export current frame as SVG'>SVG</button><button id='exportPng' type='button' title='Export current frame as PNG'>PNG</button><button id='exportMp4' type='button' title='Export full animation as MP4'>MP4</button></div></div></div>";
|
|
1039
|
+
document.getElementById("play").onclick = togglePlay;
|
|
1040
|
+
const exportMenuBtn = document.getElementById("exportMenuBtn");
|
|
1041
|
+
const exportMenu = document.getElementById("exportMenu");
|
|
1042
|
+
exportMenuBtn.onclick = (event) => {
|
|
1043
|
+
event.stopPropagation();
|
|
1044
|
+
if (exportMenu) exportMenu.classList.toggle("hidden");
|
|
1045
|
+
};
|
|
1046
|
+
if (exportMenu) exportMenu.onclick = (event) => event.stopPropagation();
|
|
1047
|
+
document.getElementById("exportSvg").onclick = () => {
|
|
1048
|
+
if (exportMenu) exportMenu.classList.add("hidden");
|
|
1049
|
+
exportDocument("svg", exportMenuBtn);
|
|
1050
|
+
};
|
|
1051
|
+
document.getElementById("exportPng").onclick = () => {
|
|
1052
|
+
if (exportMenu) exportMenu.classList.add("hidden");
|
|
1053
|
+
exportDocument("png", exportMenuBtn);
|
|
1054
|
+
};
|
|
1055
|
+
document.getElementById("exportMp4").onclick = () => {
|
|
1056
|
+
if (exportMenu) exportMenu.classList.add("hidden");
|
|
1057
|
+
exportDocument("mp4", exportMenuBtn);
|
|
1058
|
+
};
|
|
1059
|
+
document.getElementById("refresh").onclick = load;
|
|
1060
|
+
document.getElementById("scrub").oninput = (event) => {
|
|
1061
|
+
setCurrentTime(event.target.value);
|
|
1062
|
+
};
|
|
1063
|
+
const properties = Object.keys(tracks);
|
|
1064
|
+
if (!properties.length) {
|
|
1065
|
+
selectedSegment = null;
|
|
1066
|
+
closeCurveModal();
|
|
1067
|
+
const empty = document.createElement("div");
|
|
1068
|
+
empty.className = "tiny";
|
|
1069
|
+
empty.textContent = "No keyframes on selected element yet.";
|
|
1070
|
+
timeline.appendChild(empty);
|
|
1071
|
+
return;
|
|
1072
|
+
}
|
|
1073
|
+
selectedSegment = reconcileSelectedSegment(tracks, selectedSegment);
|
|
1074
|
+
for (const property of properties) {
|
|
1075
|
+
const track = tracks[property];
|
|
1076
|
+
const frames = normalizeTrackKeyframes(track);
|
|
1077
|
+
const box = document.createElement("div");
|
|
1078
|
+
box.className = "track";
|
|
1079
|
+
const name = document.createElement("span");
|
|
1080
|
+
name.className = "trackName";
|
|
1081
|
+
name.textContent = property;
|
|
1082
|
+
box.appendChild(name);
|
|
1083
|
+
const line = document.createElement("div");
|
|
1084
|
+
line.className = "trackLine";
|
|
1085
|
+
for (let index = 0; index < frames.length; index += 1) {
|
|
1086
|
+
const frame = frames[index];
|
|
1087
|
+
const chip = document.createElement("span");
|
|
1088
|
+
chip.className = "kf";
|
|
1089
|
+
chip.title = "Jump to this keyframe time";
|
|
1090
|
+
const text = document.createElement("span");
|
|
1091
|
+
text.textContent = Number(frame.time).toFixed(2) + "s " + formatMotionValue(frame.value);
|
|
1092
|
+
chip.appendChild(text);
|
|
1093
|
+
const remove = document.createElement("button");
|
|
1094
|
+
remove.textContent = "x";
|
|
1095
|
+
remove.onclick = (event) => {
|
|
1096
|
+
event.stopPropagation();
|
|
1097
|
+
removeKeyframe(property, frame.time);
|
|
1098
|
+
};
|
|
1099
|
+
chip.onclick = () => setCurrentTime(frame.time);
|
|
1100
|
+
chip.appendChild(remove);
|
|
1101
|
+
line.appendChild(chip);
|
|
1102
|
+
if (index >= frames.length - 1) continue;
|
|
1103
|
+
const interpolation = resolveSegmentInterpolation(track, frames, index);
|
|
1104
|
+
const indicator = document.createElement("button");
|
|
1105
|
+
indicator.type = "button";
|
|
1106
|
+
indicator.className = "segBadge" + (selectedSegment && selectedSegment.property === property && selectedSegment.index === index ? " active" : "");
|
|
1107
|
+
indicator.textContent = interpolation.preset === "custom" ? "custom" : interpolation.preset;
|
|
1108
|
+
indicator.title = "Edit interpolation: " + Number(frames[index].time).toFixed(2) + "s -> " + Number(frames[index + 1].time).toFixed(2) + "s";
|
|
1109
|
+
indicator.onclick = () => {
|
|
1110
|
+
selectedSegment = { property, index };
|
|
1111
|
+
renderTimeline();
|
|
1112
|
+
openCurveModal();
|
|
1113
|
+
};
|
|
1114
|
+
line.appendChild(indicator);
|
|
1115
|
+
}
|
|
1116
|
+
box.appendChild(line);
|
|
1117
|
+
timeline.appendChild(box);
|
|
1118
|
+
}
|
|
1119
|
+
if (isCurveModalOpen()) refreshCurveModal(tracks);
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
async function exportDocument(format, triggerButton) {
|
|
1123
|
+
const button = triggerButton || document.getElementById("exportMenuBtn");
|
|
1124
|
+
const label = button ? button.textContent : "";
|
|
1125
|
+
try {
|
|
1126
|
+
if (button) {
|
|
1127
|
+
button.disabled = true;
|
|
1128
|
+
button.textContent = format === "mp4" ? "Exporting..." : "Export...";
|
|
1129
|
+
}
|
|
1130
|
+
const response = await fetch("/api/export?format=" + encodeURIComponent(format) + "&time=" + encodeURIComponent(currentTime), { cache: "no-store" });
|
|
1131
|
+
if (!response.ok) {
|
|
1132
|
+
let message = "Export failed.";
|
|
1133
|
+
try {
|
|
1134
|
+
const data = await response.json();
|
|
1135
|
+
message = data.error || message;
|
|
1136
|
+
} catch {}
|
|
1137
|
+
throw new Error(message);
|
|
1138
|
+
}
|
|
1139
|
+
const blob = await response.blob();
|
|
1140
|
+
const link = document.createElement("a");
|
|
1141
|
+
link.href = URL.createObjectURL(blob);
|
|
1142
|
+
link.download = filenameFromDisposition(response.headers.get("content-disposition")) || ("sketchmark." + format);
|
|
1143
|
+
document.body.appendChild(link);
|
|
1144
|
+
link.click();
|
|
1145
|
+
link.remove();
|
|
1146
|
+
setTimeout(() => URL.revokeObjectURL(link.href), 1000);
|
|
1147
|
+
} catch (error) {
|
|
1148
|
+
showError(error);
|
|
1149
|
+
} finally {
|
|
1150
|
+
if (button) {
|
|
1151
|
+
button.disabled = false;
|
|
1152
|
+
button.textContent = label;
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
function filenameFromDisposition(header) {
|
|
1158
|
+
const match = /filename="([^"]+)"/.exec(header || "");
|
|
1159
|
+
return match ? match[1] : "";
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
function isCurveModalOpen() {
|
|
1163
|
+
return !curveModalBackdrop.classList.contains("hidden");
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
function openCurveModal() {
|
|
1167
|
+
refreshCurveModal();
|
|
1168
|
+
curveModalBackdrop.classList.remove("hidden");
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
function closeCurveModal() {
|
|
1172
|
+
curveModalBackdrop.classList.add("hidden");
|
|
1173
|
+
curveModalContent.innerHTML = "";
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
function selectedTracks() {
|
|
1177
|
+
const element = findElement(selectedId);
|
|
1178
|
+
return element && element.timeline && element.timeline.tracks ? element.timeline.tracks : {};
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
function refreshCurveModal(tracksInput) {
|
|
1182
|
+
const tracks = tracksInput || selectedTracks();
|
|
1183
|
+
const panel = renderCurvePanel(tracks);
|
|
1184
|
+
if (!panel) {
|
|
1185
|
+
closeCurveModal();
|
|
1186
|
+
return;
|
|
1187
|
+
}
|
|
1188
|
+
curveModalContent.innerHTML = "";
|
|
1189
|
+
curveModalContent.appendChild(panel);
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
function renderCurvePanel(tracks) {
|
|
1193
|
+
if (!selectedSegment) return null;
|
|
1194
|
+
const track = tracks[selectedSegment.property];
|
|
1195
|
+
if (!track) return null;
|
|
1196
|
+
const frames = normalizeTrackKeyframes(track);
|
|
1197
|
+
if (selectedSegment.index < 0 || selectedSegment.index >= frames.length - 1) return null;
|
|
1198
|
+
const start = frames[selectedSegment.index];
|
|
1199
|
+
const end = frames[selectedSegment.index + 1];
|
|
1200
|
+
const interpolation = resolveSegmentInterpolation(track, frames, selectedSegment.index);
|
|
1201
|
+
const panel = document.createElement("div");
|
|
1202
|
+
panel.className = "curvePanel";
|
|
1203
|
+
const header = document.createElement("div");
|
|
1204
|
+
header.className = "curvePanelHead";
|
|
1205
|
+
header.innerHTML = "<span class='curvePanelTitle'>Interpolation</span><span class='curvePanelRange'>" + escapeText(selectedSegment.property) + " | " + Number(start.time).toFixed(2) + "s -> " + Number(end.time).toFixed(2) + "s</span>";
|
|
1206
|
+
panel.appendChild(header);
|
|
1207
|
+
const graph = document.createElement("div");
|
|
1208
|
+
graph.className = "curvePreview";
|
|
1209
|
+
graph.innerHTML = renderCurveSvg(interpolation.curve, interpolation.ease, 320, 120);
|
|
1210
|
+
panel.appendChild(graph);
|
|
1211
|
+
const presets = document.createElement("div");
|
|
1212
|
+
presets.className = "curvePresets";
|
|
1213
|
+
const options = ["linear", "ease-in", "ease-out", "ease-in-out", "hold"];
|
|
1214
|
+
for (const option of options) {
|
|
1215
|
+
const button = document.createElement("button");
|
|
1216
|
+
button.type = "button";
|
|
1217
|
+
button.className = "curvePreset" + (interpolation.preset === option ? " active" : "");
|
|
1218
|
+
button.textContent = option;
|
|
1219
|
+
button.onclick = () => {
|
|
1220
|
+
applySegmentPreset(selectedSegment.property, selectedSegment.index, option).catch(showError);
|
|
1221
|
+
};
|
|
1222
|
+
presets.appendChild(button);
|
|
1223
|
+
}
|
|
1224
|
+
panel.appendChild(presets);
|
|
1225
|
+
const custom = document.createElement("div");
|
|
1226
|
+
custom.className = "curveCustom";
|
|
1227
|
+
if (interpolation.curve && interpolation.curve.type === "graph" && !isLinearGraphCurve(interpolation.curve.points)) {
|
|
1228
|
+
const label = document.createElement("span");
|
|
1229
|
+
label.className = "curveCustomLabel";
|
|
1230
|
+
label.textContent = "Custom graph points [x,y] (x must increase):";
|
|
1231
|
+
custom.appendChild(label);
|
|
1232
|
+
const area = document.createElement("textarea");
|
|
1233
|
+
area.value = formatGraphPoints(interpolation.curve.points);
|
|
1234
|
+
custom.appendChild(area);
|
|
1235
|
+
const applyGraph = document.createElement("button");
|
|
1236
|
+
applyGraph.type = "button";
|
|
1237
|
+
applyGraph.textContent = "Apply Graph";
|
|
1238
|
+
applyGraph.onclick = () => {
|
|
1239
|
+
const points = parseGraphPoints(area.value);
|
|
1240
|
+
if (!points) {
|
|
1241
|
+
showError(new Error("Graph points must be JSON array of [x,y] with increasing x."));
|
|
1242
|
+
return;
|
|
1243
|
+
}
|
|
1244
|
+
applySegmentCurve(selectedSegment.property, selectedSegment.index, { type: "graph", points }).catch(showError);
|
|
1245
|
+
};
|
|
1246
|
+
custom.appendChild(applyGraph);
|
|
1247
|
+
} else {
|
|
1248
|
+
const label = document.createElement("span");
|
|
1249
|
+
label.className = "curveCustomLabel";
|
|
1250
|
+
label.textContent = "Custom cubicBezier (editable graph controls):";
|
|
1251
|
+
custom.appendChild(label);
|
|
1252
|
+
const cubic = curveToEditableCubic(interpolation.curve, interpolation.ease);
|
|
1253
|
+
const fields = document.createElement("div");
|
|
1254
|
+
fields.className = "curveCustomFields";
|
|
1255
|
+
fields.innerHTML =
|
|
1256
|
+
"<label>x1<input id='curveX1' type='number' step='0.01' min='0' max='1' value='" + escapeAttr(cubic.x1.toFixed(2)) + "'></label>" +
|
|
1257
|
+
"<label>y1<input id='curveY1' type='number' step='0.01' value='" + escapeAttr(cubic.y1.toFixed(2)) + "'></label>" +
|
|
1258
|
+
"<label>x2<input id='curveX2' type='number' step='0.01' min='0' max='1' value='" + escapeAttr(cubic.x2.toFixed(2)) + "'></label>" +
|
|
1259
|
+
"<label>y2<input id='curveY2' type='number' step='0.01' value='" + escapeAttr(cubic.y2.toFixed(2)) + "'></label>";
|
|
1260
|
+
custom.appendChild(fields);
|
|
1261
|
+
const applyCubic = document.createElement("button");
|
|
1262
|
+
applyCubic.type = "button";
|
|
1263
|
+
applyCubic.textContent = "Apply Custom";
|
|
1264
|
+
applyCubic.onclick = () => {
|
|
1265
|
+
const x1 = clamp01(Number(document.getElementById("curveX1").value));
|
|
1266
|
+
const y1 = Number(document.getElementById("curveY1").value);
|
|
1267
|
+
const x2 = clamp01(Number(document.getElementById("curveX2").value));
|
|
1268
|
+
const y2 = Number(document.getElementById("curveY2").value);
|
|
1269
|
+
if (!Number.isFinite(y1) || !Number.isFinite(y2)) {
|
|
1270
|
+
showError(new Error("Bezier y values must be finite numbers."));
|
|
1271
|
+
return;
|
|
1272
|
+
}
|
|
1273
|
+
applySegmentCurve(selectedSegment.property, selectedSegment.index, { type: "cubicBezier", x1, y1, x2, y2 }).catch(showError);
|
|
1274
|
+
};
|
|
1275
|
+
custom.appendChild(applyCubic);
|
|
1276
|
+
}
|
|
1277
|
+
panel.appendChild(custom);
|
|
1278
|
+
const hint = document.createElement("div");
|
|
1279
|
+
hint.className = "tiny";
|
|
1280
|
+
hint.textContent = "Click an interpolation badge between keyframes to edit this segment.";
|
|
1281
|
+
panel.appendChild(hint);
|
|
1282
|
+
return panel;
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
function reconcileSelectedSegment(tracks, current) {
|
|
1286
|
+
if (current && isSegmentSelectionValid(tracks, current)) return current;
|
|
1287
|
+
for (const [property, track] of Object.entries(tracks)) {
|
|
1288
|
+
const frames = normalizeTrackKeyframes(track);
|
|
1289
|
+
if (frames.length >= 2) return { property, index: 0 };
|
|
1290
|
+
}
|
|
1291
|
+
return null;
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
function isSegmentSelectionValid(tracks, selection) {
|
|
1295
|
+
if (!selection) return false;
|
|
1296
|
+
const track = tracks[selection.property];
|
|
1297
|
+
if (!track) return false;
|
|
1298
|
+
const frames = normalizeTrackKeyframes(track);
|
|
1299
|
+
return selection.index >= 0 && selection.index < frames.length - 1;
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
function normalizeTrackKeyframes(track) {
|
|
1303
|
+
const frames = [];
|
|
1304
|
+
for (const frame of track && Array.isArray(track.keyframes) ? track.keyframes : []) {
|
|
1305
|
+
if (Array.isArray(frame) && Number.isFinite(Number(frame[0]))) {
|
|
1306
|
+
frames.push({ time: Number(frame[0]), value: frame[1] });
|
|
1307
|
+
continue;
|
|
1308
|
+
}
|
|
1309
|
+
if (frame && typeof frame === "object" && Number.isFinite(Number(frame.time))) {
|
|
1310
|
+
frames.push({
|
|
1311
|
+
time: Number(frame.time),
|
|
1312
|
+
value: frame.value,
|
|
1313
|
+
in: frame.in,
|
|
1314
|
+
out: frame.out,
|
|
1315
|
+
interpolation: frame.interpolation
|
|
1316
|
+
});
|
|
1317
|
+
}
|
|
1318
|
+
}
|
|
1319
|
+
frames.sort((left, right) => left.time - right.time);
|
|
1320
|
+
return frames;
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
function resolveSegmentInterpolation(track, frames, index) {
|
|
1324
|
+
const previous = frames[index];
|
|
1325
|
+
const next = frames[index + 1];
|
|
1326
|
+
const curve = (previous && (previous.out || previous.interpolation)) || (next && next.in) || (track && track.curve);
|
|
1327
|
+
const ease = curve ? undefined : track && track.ease;
|
|
1328
|
+
return { curve, ease, preset: curvePresetName(curve, ease), label: curvePresetLabel(curve, ease) };
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
function curvePresetName(curve, ease) {
|
|
1332
|
+
if (!curve) {
|
|
1333
|
+
const eased = String(ease || "linear");
|
|
1334
|
+
return eased === "ease-in" || eased === "ease-out" || eased === "ease-in-out" || eased === "linear" ? eased : "custom";
|
|
1335
|
+
}
|
|
1336
|
+
if (curve.type === "hold") return "hold";
|
|
1337
|
+
if (curve.type === "graph") return isLinearGraphCurve(curve.points) ? "linear" : "custom";
|
|
1338
|
+
if (curve.type === "cubicBezier") {
|
|
1339
|
+
if (isBezierCurve(curve, 0.42, 0, 1, 1)) return "ease-in";
|
|
1340
|
+
if (isBezierCurve(curve, 0, 0, 0.58, 1)) return "ease-out";
|
|
1341
|
+
if (isBezierCurve(curve, 0.42, 0, 0.58, 1)) return "ease-in-out";
|
|
1342
|
+
if (isBezierCurve(curve, 0, 0, 1, 1)) return "linear";
|
|
1343
|
+
return "custom";
|
|
1344
|
+
}
|
|
1345
|
+
return "custom";
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
function curvePresetLabel(curve, ease) {
|
|
1349
|
+
const name = curvePresetName(curve, ease);
|
|
1350
|
+
return name === "custom" ? "custom" : name;
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1353
|
+
function isLinearGraphCurve(points) {
|
|
1354
|
+
if (!Array.isArray(points) || points.length < 2) return false;
|
|
1355
|
+
const first = points[0];
|
|
1356
|
+
const last = points[points.length - 1];
|
|
1357
|
+
if (!Array.isArray(first) || !Array.isArray(last)) return false;
|
|
1358
|
+
return nearlyEqual(Number(first[0]), 0) && nearlyEqual(Number(first[1]), 0) && nearlyEqual(Number(last[0]), 1) && nearlyEqual(Number(last[1]), 1);
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
function isBezierCurve(curve, x1, y1, x2, y2) {
|
|
1362
|
+
return nearlyEqual(Number(curve.x1), x1) && nearlyEqual(Number(curve.y1), y1) && nearlyEqual(Number(curve.x2), x2) && nearlyEqual(Number(curve.y2), y2);
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
function nearlyEqual(a, b) {
|
|
1366
|
+
return Math.abs(Number(a) - Number(b)) < 0.0001;
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1369
|
+
function renderCurveSvg(curve, ease, width, height) {
|
|
1370
|
+
const innerWidth = Math.max(16, Number(width || 160));
|
|
1371
|
+
const innerHeight = Math.max(16, Number(height || 56));
|
|
1372
|
+
const line = curvePath(curve, ease, innerWidth - 2, innerHeight - 2, 36);
|
|
1373
|
+
return "<svg viewBox='0 0 " + innerWidth + " " + innerHeight + "' preserveAspectRatio='none' width='100%' height='100%' aria-hidden='true'>" +
|
|
1374
|
+
"<path d='M1 " + (innerHeight - 1) + " L" + (innerWidth - 1) + " 1' stroke='#d2d6dd' stroke-width='1' fill='none'/>" +
|
|
1375
|
+
"<path d='" + line + "' stroke='#0f172a' stroke-width='2' fill='none'/>" +
|
|
1376
|
+
"</svg>";
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
function curvePath(curve, ease, width, height, steps) {
|
|
1380
|
+
const segments = [];
|
|
1381
|
+
for (let index = 0; index <= steps; index += 1) {
|
|
1382
|
+
const t = index / Math.max(1, steps);
|
|
1383
|
+
const x = 1 + t * width;
|
|
1384
|
+
const y = 1 + (1 - clamp01(sampleCurve(curve, ease, t))) * height;
|
|
1385
|
+
segments.push((index === 0 ? "M" : "L") + x.toFixed(2) + " " + y.toFixed(2));
|
|
1386
|
+
}
|
|
1387
|
+
return segments.join(" ");
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1390
|
+
function sampleCurve(curve, ease, t) {
|
|
1391
|
+
const x = clamp01(t);
|
|
1392
|
+
if (!curve) return sampleEase(ease, x);
|
|
1393
|
+
if (curve.type === "hold") return x < 1 ? 0 : 1;
|
|
1394
|
+
if (curve.type === "graph") return sampleGraph(curve.points, x);
|
|
1395
|
+
if (curve.type === "cubicBezier") return sampleCubicBezier(x, Number(curve.x1), Number(curve.y1), Number(curve.x2), Number(curve.y2));
|
|
1396
|
+
return sampleEase(ease, x);
|
|
1397
|
+
}
|
|
1398
|
+
|
|
1399
|
+
function sampleGraph(points, t) {
|
|
1400
|
+
const list = Array.isArray(points) ? points.filter((point) => Array.isArray(point) && Number.isFinite(Number(point[0])) && Number.isFinite(Number(point[1]))) : [];
|
|
1401
|
+
if (!list.length) return t;
|
|
1402
|
+
list.sort((left, right) => Number(left[0]) - Number(right[0]));
|
|
1403
|
+
if (t <= Number(list[0][0])) return Number(list[0][1]);
|
|
1404
|
+
for (let index = 1; index < list.length; index += 1) {
|
|
1405
|
+
const previous = list[index - 1];
|
|
1406
|
+
const next = list[index];
|
|
1407
|
+
if (t <= Number(next[0])) {
|
|
1408
|
+
const span = Math.max(0.000001, Number(next[0]) - Number(previous[0]));
|
|
1409
|
+
const local = (t - Number(previous[0])) / span;
|
|
1410
|
+
return Number(previous[1]) + (Number(next[1]) - Number(previous[1])) * local;
|
|
1411
|
+
}
|
|
1412
|
+
}
|
|
1413
|
+
const last = list[list.length - 1];
|
|
1414
|
+
return Number(last[1]);
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1417
|
+
function sampleCubicBezier(t, x1, y1, x2, y2) {
|
|
1418
|
+
let low = 0;
|
|
1419
|
+
let high = 1;
|
|
1420
|
+
let u = t;
|
|
1421
|
+
for (let index = 0; index < 24; index += 1) {
|
|
1422
|
+
u = (low + high) / 2;
|
|
1423
|
+
const x = cubicBezierValue(0, x1, x2, 1, u);
|
|
1424
|
+
if (x < t) low = u;
|
|
1425
|
+
else high = u;
|
|
1426
|
+
}
|
|
1427
|
+
return cubicBezierValue(0, y1, y2, 1, u);
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1430
|
+
function cubicBezierValue(a, b, c, d, t) {
|
|
1431
|
+
const mt = 1 - t;
|
|
1432
|
+
return mt * mt * mt * a + 3 * mt * mt * t * b + 3 * mt * t * t * c + t * t * t * d;
|
|
1433
|
+
}
|
|
1434
|
+
|
|
1435
|
+
function sampleEase(ease, t) {
|
|
1436
|
+
const x = clamp01(t);
|
|
1437
|
+
switch (ease) {
|
|
1438
|
+
case "ease-in":
|
|
1439
|
+
return x * x;
|
|
1440
|
+
case "ease-out":
|
|
1441
|
+
return 1 - (1 - x) * (1 - x);
|
|
1442
|
+
case "ease-in-out":
|
|
1443
|
+
return x < 0.5 ? 2 * x * x : 1 - Math.pow(-2 * x + 2, 2) / 2;
|
|
1444
|
+
case "linear":
|
|
1445
|
+
default:
|
|
1446
|
+
return x;
|
|
1447
|
+
}
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1450
|
+
function clamp01(value) {
|
|
1451
|
+
const numeric = Number(value);
|
|
1452
|
+
if (!Number.isFinite(numeric)) return 0;
|
|
1453
|
+
return Math.max(0, Math.min(1, numeric));
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
function curveToEditableCubic(curve, ease) {
|
|
1457
|
+
if (curve && curve.type === "cubicBezier") {
|
|
1458
|
+
return {
|
|
1459
|
+
x1: clamp01(curve.x1),
|
|
1460
|
+
y1: Number.isFinite(Number(curve.y1)) ? Number(curve.y1) : 0,
|
|
1461
|
+
x2: clamp01(curve.x2),
|
|
1462
|
+
y2: Number.isFinite(Number(curve.y2)) ? Number(curve.y2) : 1
|
|
1463
|
+
};
|
|
1464
|
+
}
|
|
1465
|
+
const preset = curvePresetName(curve, ease);
|
|
1466
|
+
if (preset === "ease-in") return { x1: 0.42, y1: 0, x2: 1, y2: 1 };
|
|
1467
|
+
if (preset === "ease-out") return { x1: 0, y1: 0, x2: 0.58, y2: 1 };
|
|
1468
|
+
if (preset === "ease-in-out") return { x1: 0.42, y1: 0, x2: 0.58, y2: 1 };
|
|
1469
|
+
if (preset === "linear") return { x1: 0, y1: 0, x2: 1, y2: 1 };
|
|
1470
|
+
return { x1: 0.42, y1: 0, x2: 0.58, y2: 1 };
|
|
1471
|
+
}
|
|
1472
|
+
|
|
1473
|
+
function formatGraphPoints(points) {
|
|
1474
|
+
const list = Array.isArray(points) ? points.filter((point) => Array.isArray(point) && point.length >= 2) : [];
|
|
1475
|
+
const safe = list.map((point) => [Number(point[0]), Number(point[1])]);
|
|
1476
|
+
return JSON.stringify(safe, null, 2);
|
|
1477
|
+
}
|
|
1478
|
+
|
|
1479
|
+
function parseGraphPoints(text) {
|
|
1480
|
+
let parsed;
|
|
1481
|
+
try {
|
|
1482
|
+
parsed = JSON.parse(String(text || "").trim());
|
|
1483
|
+
} catch {
|
|
1484
|
+
return null;
|
|
1485
|
+
}
|
|
1486
|
+
if (!Array.isArray(parsed) || parsed.length < 2) return null;
|
|
1487
|
+
const points = parsed
|
|
1488
|
+
.map((point) => Array.isArray(point) && point.length >= 2 ? [Number(point[0]), Number(point[1])] : null)
|
|
1489
|
+
.filter((point) => point && Number.isFinite(point[0]) && Number.isFinite(point[1]));
|
|
1490
|
+
if (points.length < 2) return null;
|
|
1491
|
+
points.sort((left, right) => left[0] - right[0]);
|
|
1492
|
+
for (let index = 1; index < points.length; index += 1) {
|
|
1493
|
+
if (points[index][0] <= points[index - 1][0]) return null;
|
|
1494
|
+
}
|
|
1495
|
+
return points;
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1498
|
+
async function applySegmentPreset(property, segmentIndex, preset) {
|
|
1499
|
+
if (!selectedId || !ensureElementEditable(selectedId)) return;
|
|
1500
|
+
const element = findElement(selectedId);
|
|
1501
|
+
const track = element && element.timeline && element.timeline.tracks ? element.timeline.tracks[property] : null;
|
|
1502
|
+
if (!track) return;
|
|
1503
|
+
const frames = normalizeTrackKeyframes(track);
|
|
1504
|
+
if (segmentIndex < 0 || segmentIndex >= frames.length - 1) return;
|
|
1505
|
+
const start = frames[segmentIndex];
|
|
1506
|
+
await mutate(
|
|
1507
|
+
"/api/keyframe",
|
|
1508
|
+
{ id: selectedId, property, value: start.value, time: start.time, curvePreset: preset },
|
|
1509
|
+
{ refreshTree: false, refreshInspector: false, refreshTimeline: true }
|
|
1510
|
+
);
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1513
|
+
async function applySegmentCurve(property, segmentIndex, curve) {
|
|
1514
|
+
if (!selectedId || !ensureElementEditable(selectedId)) return;
|
|
1515
|
+
const element = findElement(selectedId);
|
|
1516
|
+
const track = element && element.timeline && element.timeline.tracks ? element.timeline.tracks[property] : null;
|
|
1517
|
+
if (!track) return;
|
|
1518
|
+
const frames = normalizeTrackKeyframes(track);
|
|
1519
|
+
if (segmentIndex < 0 || segmentIndex >= frames.length - 1) return;
|
|
1520
|
+
const start = frames[segmentIndex];
|
|
1521
|
+
await mutate(
|
|
1522
|
+
"/api/keyframe",
|
|
1523
|
+
{ id: selectedId, property, value: start.value, time: start.time, curve },
|
|
1524
|
+
{ refreshTree: false, refreshInspector: false, refreshTimeline: true }
|
|
1525
|
+
);
|
|
1526
|
+
}
|
|
1527
|
+
|
|
1528
|
+
function setCurrentTime(time) {
|
|
1529
|
+
const duration = Math.max(Number(doc && doc.canvas && doc.canvas.duration || 0), 0.01);
|
|
1530
|
+
const next = Math.max(0, Math.min(Number(time || 0), duration));
|
|
1531
|
+
currentTime = next;
|
|
1532
|
+
const scrub = document.getElementById("scrub");
|
|
1533
|
+
if (scrub) scrub.value = String(next);
|
|
1534
|
+
const label = document.getElementById("timeLabel");
|
|
1535
|
+
if (label) label.textContent = next.toFixed(2) + "s";
|
|
1536
|
+
const kfTime = document.getElementById("kfTime");
|
|
1537
|
+
if (kfTime) kfTime.value = next.toFixed(2);
|
|
1538
|
+
requestDraw();
|
|
1539
|
+
}
|
|
1540
|
+
|
|
1541
|
+
function clearSidebarCommitTimers() {
|
|
1542
|
+
for (const key of Object.keys(sidebarCommitTimers)) {
|
|
1543
|
+
clearTimeout(sidebarCommitTimers[key]);
|
|
1544
|
+
delete sidebarCommitTimers[key];
|
|
1545
|
+
}
|
|
1546
|
+
}
|
|
1547
|
+
|
|
1548
|
+
function scheduleSidebarPositionKeyframe() {
|
|
1549
|
+
scheduleSidebarKeyframe("position", () => {
|
|
1550
|
+
const x = readNumberInput("propX");
|
|
1551
|
+
const y = readNumberInput("propY");
|
|
1552
|
+
if (!Number.isFinite(x) || !Number.isFinite(y)) return null;
|
|
1553
|
+
return [x, y];
|
|
1554
|
+
});
|
|
1555
|
+
}
|
|
1556
|
+
|
|
1557
|
+
function scheduleSidebarOriginKeyframe() {
|
|
1558
|
+
scheduleSidebarKeyframe("origin", () => {
|
|
1559
|
+
const x = readNumberInput("propOriginX");
|
|
1560
|
+
const y = readNumberInput("propOriginY");
|
|
1561
|
+
if (!Number.isFinite(x) || !Number.isFinite(y)) return null;
|
|
1562
|
+
return [x, y];
|
|
1563
|
+
});
|
|
1564
|
+
}
|
|
1565
|
+
|
|
1566
|
+
function scheduleSidebarPointKeyframe(property, xId, yId) {
|
|
1567
|
+
scheduleSidebarKeyframe(property, () => {
|
|
1568
|
+
const x = readNumberInput(xId);
|
|
1569
|
+
const y = readNumberInput(yId);
|
|
1570
|
+
if (!Number.isFinite(x) || !Number.isFinite(y)) return null;
|
|
1571
|
+
return [x, y];
|
|
1572
|
+
});
|
|
1573
|
+
}
|
|
1574
|
+
|
|
1575
|
+
function scheduleSidebarNumberKeyframe(property, inputId) {
|
|
1576
|
+
scheduleSidebarKeyframe(property, () => {
|
|
1577
|
+
const value = readNumberInput(inputId);
|
|
1578
|
+
return Number.isFinite(value) ? value : null;
|
|
1579
|
+
});
|
|
1580
|
+
}
|
|
1581
|
+
|
|
1582
|
+
function scheduleSidebarNumberArrayKeyframe(property, inputId) {
|
|
1583
|
+
scheduleSidebarKeyframe(property, () => {
|
|
1584
|
+
const text = readTextInput(inputId).trim();
|
|
1585
|
+
if (!text) return [];
|
|
1586
|
+
const values = text.split(/[,\s]+/).filter(Boolean).map(Number);
|
|
1587
|
+
return values.every(Number.isFinite) ? values : null;
|
|
1588
|
+
});
|
|
1589
|
+
}
|
|
1590
|
+
|
|
1591
|
+
function scheduleSidebarPaintKeyframe(property, inputId) {
|
|
1592
|
+
scheduleSidebarKeyframe(property, () => {
|
|
1593
|
+
const value = readTextInput(inputId).trim();
|
|
1594
|
+
return value ? value : null;
|
|
1595
|
+
});
|
|
1596
|
+
}
|
|
1597
|
+
|
|
1598
|
+
function scheduleSidebarTextContentKeyframe(property, inputId) {
|
|
1599
|
+
if (property === "lines") {
|
|
1600
|
+
scheduleSidebarStringArrayKeyframe(property, inputId);
|
|
1601
|
+
return;
|
|
1602
|
+
}
|
|
1603
|
+
scheduleSidebarTextKeyframe(property, inputId);
|
|
1604
|
+
}
|
|
1605
|
+
|
|
1606
|
+
function scheduleSidebarTextKeyframe(property, inputId) {
|
|
1607
|
+
scheduleSidebarKeyframe(property, () => readTextInput(inputId));
|
|
1608
|
+
}
|
|
1609
|
+
|
|
1610
|
+
function scheduleSidebarNumberOrTextKeyframe(property, inputId) {
|
|
1611
|
+
scheduleSidebarKeyframe(property, () => {
|
|
1612
|
+
const value = readTextInput(inputId).trim();
|
|
1613
|
+
if (!value) return null;
|
|
1614
|
+
const number = Number(value);
|
|
1615
|
+
return Number.isFinite(number) ? number : value;
|
|
1616
|
+
});
|
|
1617
|
+
}
|
|
1618
|
+
|
|
1619
|
+
function scheduleSidebarStringArrayKeyframe(property, inputId) {
|
|
1620
|
+
scheduleSidebarKeyframe(property, () => {
|
|
1621
|
+
const value = readTextInput(inputId);
|
|
1622
|
+
if (!value) return [];
|
|
1623
|
+
return value.split(/\\r?\\n/);
|
|
1624
|
+
});
|
|
1625
|
+
}
|
|
1626
|
+
|
|
1627
|
+
function scheduleSidebarDynamicKeyframe(input) {
|
|
1628
|
+
const property = input.getAttribute("data-kf-property");
|
|
1629
|
+
const kind = input.getAttribute("data-kf-kind") || "text";
|
|
1630
|
+
if (!property) return;
|
|
1631
|
+
if (kind === "number") {
|
|
1632
|
+
scheduleSidebarNumberKeyframe(property, input.id);
|
|
1633
|
+
return;
|
|
1634
|
+
}
|
|
1635
|
+
if (kind === "point") {
|
|
1636
|
+
scheduleSidebarPointKeyframe(property, input.getAttribute("data-kf-x"), input.getAttribute("data-kf-y"));
|
|
1637
|
+
return;
|
|
1638
|
+
}
|
|
1639
|
+
if (kind === "clipRadius") {
|
|
1640
|
+
scheduleSidebarClipRadiusKeyframe(property, input.id);
|
|
1641
|
+
return;
|
|
1642
|
+
}
|
|
1643
|
+
scheduleSidebarTextKeyframe(property, input.id);
|
|
1644
|
+
}
|
|
1645
|
+
|
|
1646
|
+
function scheduleSidebarClipRadiusKeyframe(property, inputId) {
|
|
1647
|
+
scheduleSidebarKeyframe(property, () => {
|
|
1648
|
+
const radius = readNumberInput(inputId);
|
|
1649
|
+
if (!Number.isFinite(radius)) return null;
|
|
1650
|
+
const element = findResolvedElement(selectedId) || findElement(selectedId);
|
|
1651
|
+
if (!element || element.type !== "image") return null;
|
|
1652
|
+
return roundedRectClipPath(Number(element.x || 0), Number(element.y || 0), Number(element.width || 0), Number(element.height || 0), radius);
|
|
1653
|
+
});
|
|
1654
|
+
}
|
|
1655
|
+
|
|
1656
|
+
function scheduleSidebarKeyframe(property, valueReader) {
|
|
1657
|
+
if (!selectedId || !ensureElementEditable(selectedId)) return;
|
|
1658
|
+
if (sidebarCommitTimers[property]) clearTimeout(sidebarCommitTimers[property]);
|
|
1659
|
+
sidebarCommitTimers[property] = setTimeout(async () => {
|
|
1660
|
+
delete sidebarCommitTimers[property];
|
|
1661
|
+
const value = valueReader();
|
|
1662
|
+
if (value === null || value === undefined) return;
|
|
1663
|
+
try {
|
|
1664
|
+
await mutate(
|
|
1665
|
+
"/api/keyframe",
|
|
1666
|
+
{ id: selectedId, property, value, time: currentTime, curvePreset: "linear" },
|
|
1667
|
+
{ refreshTree: false, refreshInspector: false, refreshTimeline: true }
|
|
1668
|
+
);
|
|
1669
|
+
} catch (error) {
|
|
1670
|
+
showError(error);
|
|
1671
|
+
}
|
|
1672
|
+
}, 120);
|
|
1673
|
+
}
|
|
1674
|
+
|
|
1675
|
+
function readNumberInput(id) {
|
|
1676
|
+
const input = document.getElementById(id);
|
|
1677
|
+
if (!input) return NaN;
|
|
1678
|
+
const value = Number(input.value);
|
|
1679
|
+
return Number.isFinite(value) ? value : NaN;
|
|
1680
|
+
}
|
|
1681
|
+
|
|
1682
|
+
function readTextInput(id) {
|
|
1683
|
+
const input = document.getElementById(id);
|
|
1684
|
+
return input ? String(input.value ?? "") : "";
|
|
1685
|
+
}
|
|
1686
|
+
|
|
1687
|
+
async function removeKeyframe(property, time) {
|
|
1688
|
+
try {
|
|
1689
|
+
if (!ensureElementEditable(selectedId)) return;
|
|
1690
|
+
await mutate("/api/remove-keyframe", { id: selectedId, property, time });
|
|
1691
|
+
} catch (error) {
|
|
1692
|
+
showError(error);
|
|
1693
|
+
}
|
|
1694
|
+
}
|
|
1695
|
+
|
|
1696
|
+
async function mutate(path, body, options) {
|
|
1697
|
+
const refreshTree = !options || options.refreshTree !== false;
|
|
1698
|
+
const refreshInspector = !options || options.refreshInspector !== false;
|
|
1699
|
+
const refreshTimeline = !options || options.refreshTimeline !== false;
|
|
1700
|
+
const data = await api(path, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) });
|
|
1701
|
+
doc = data.document;
|
|
1702
|
+
refs = data.elements;
|
|
1703
|
+
const duration = Math.max(Number(doc && doc.canvas && doc.canvas.duration || 0), 0.01);
|
|
1704
|
+
if (!Number.isFinite(currentTime) || currentTime < 0) currentTime = 0;
|
|
1705
|
+
if (currentTime > duration) currentTime = duration;
|
|
1706
|
+
rebuildElementIndex();
|
|
1707
|
+
if (selectedId && !findElement(selectedId)) selectedId = "";
|
|
1708
|
+
if (refreshTree) renderTree();
|
|
1709
|
+
if (refreshInspector) renderInspector();
|
|
1710
|
+
if (refreshTimeline) renderTimeline();
|
|
1711
|
+
requestDraw();
|
|
1712
|
+
}
|
|
1713
|
+
|
|
1714
|
+
stageWrap.addEventListener("wheel", (event) => {
|
|
1715
|
+
if (!stage.contains(event.target)) return;
|
|
1716
|
+
const delta = wheelDeltaToPixels(event);
|
|
1717
|
+
const zoomGesture = event.ctrlKey || event.metaKey;
|
|
1718
|
+
if (spacePanActive || !zoomGesture) {
|
|
1719
|
+
panViewportByPixels(delta.x, delta.y);
|
|
1720
|
+
event.preventDefault();
|
|
1721
|
+
return;
|
|
1722
|
+
}
|
|
1723
|
+
zoomBy(zoomFactorFromWheel(event), event.clientX, event.clientY);
|
|
1724
|
+
event.preventDefault();
|
|
1725
|
+
}, { passive: false });
|
|
1726
|
+
|
|
1727
|
+
stageWrap.addEventListener("pointerdown", (event) => {
|
|
1728
|
+
if (!shouldStartViewportPan(event)) return;
|
|
1729
|
+
beginViewportPan(event);
|
|
1730
|
+
}, true);
|
|
1731
|
+
|
|
1732
|
+
stageWrap.addEventListener("pointermove", (event) => {
|
|
1733
|
+
updateViewportPan(event);
|
|
1734
|
+
}, true);
|
|
1735
|
+
|
|
1736
|
+
stageWrap.addEventListener("pointerup", (event) => {
|
|
1737
|
+
endViewportPan(event);
|
|
1738
|
+
}, true);
|
|
1739
|
+
|
|
1740
|
+
stageWrap.addEventListener("pointercancel", (event) => {
|
|
1741
|
+
endViewportPan(event);
|
|
1742
|
+
}, true);
|
|
1743
|
+
|
|
1744
|
+
stage.addEventListener("click", (event) => {
|
|
1745
|
+
if (suppressClick) {
|
|
1746
|
+
suppressClick = false;
|
|
1747
|
+
return;
|
|
1748
|
+
}
|
|
1749
|
+
if (event.target.closest("[data-handle]")) return;
|
|
1750
|
+
const selected = selectedTarget();
|
|
1751
|
+
const selectedElement = findElement(selectedId);
|
|
1752
|
+
if (selected && selectedElement && selectedElement.type === "group" && selected.contains(event.target)) return;
|
|
1753
|
+
const target = event.target.closest("[id]");
|
|
1754
|
+
if (target && findElement(target.id)) {
|
|
1755
|
+
if (isElementLocked(target.id) || isElementHidden(target.id)) return;
|
|
1756
|
+
select(target.id);
|
|
1757
|
+
}
|
|
1758
|
+
else deselect();
|
|
1759
|
+
});
|
|
1760
|
+
|
|
1761
|
+
stageWrap.addEventListener("click", (event) => {
|
|
1762
|
+
if (suppressClick) {
|
|
1763
|
+
suppressClick = false;
|
|
1764
|
+
return;
|
|
1765
|
+
}
|
|
1766
|
+
if (event.target === stageWrap) deselect();
|
|
1767
|
+
});
|
|
1768
|
+
|
|
1769
|
+
document.addEventListener("keydown", (event) => {
|
|
1770
|
+
if (event.code === "Space" && !isEditableInputTarget(document.activeElement)) {
|
|
1771
|
+
spacePanActive = true;
|
|
1772
|
+
event.preventDefault();
|
|
1773
|
+
return;
|
|
1774
|
+
}
|
|
1775
|
+
if (event.key !== "Escape") return;
|
|
1776
|
+
if (isCurveModalOpen()) {
|
|
1777
|
+
closeCurveModal();
|
|
1778
|
+
return;
|
|
1779
|
+
}
|
|
1780
|
+
drag = null;
|
|
1781
|
+
endViewportPan();
|
|
1782
|
+
suppressClick = false;
|
|
1783
|
+
deselect();
|
|
1784
|
+
});
|
|
1785
|
+
|
|
1786
|
+
document.addEventListener("keyup", (event) => {
|
|
1787
|
+
if (event.code === "Space") spacePanActive = false;
|
|
1788
|
+
});
|
|
1789
|
+
|
|
1790
|
+
window.addEventListener("blur", () => {
|
|
1791
|
+
spacePanActive = false;
|
|
1792
|
+
endViewportPan();
|
|
1793
|
+
});
|
|
1794
|
+
|
|
1795
|
+
stage.addEventListener("pointerdown", (event) => {
|
|
1796
|
+
const handle = event.target.closest("[data-handle]");
|
|
1797
|
+
if (handle && selectedId) {
|
|
1798
|
+
if (isElementLocked(selectedId) || isElementHidden(selectedId)) return;
|
|
1799
|
+
const selected = selectedTarget();
|
|
1800
|
+
if (selected) startDrag(event, selected, handle.getAttribute("data-handle"));
|
|
1801
|
+
return;
|
|
1802
|
+
}
|
|
1803
|
+
const selected = selectedTarget();
|
|
1804
|
+
const selectedElement = findElement(selectedId);
|
|
1805
|
+
const target = selected && selectedElement && selectedElement.type === "group" && selected.contains(event.target)
|
|
1806
|
+
? selected
|
|
1807
|
+
: event.target.closest("[id]");
|
|
1808
|
+
if (!target || !findElement(target.id)) return;
|
|
1809
|
+
if (isElementLocked(target.id) || isElementHidden(target.id)) return;
|
|
1810
|
+
select(target.id, { draw: false });
|
|
1811
|
+
const element = findElement(target.id);
|
|
1812
|
+
const mode = event.shiftKey ? "rotate" : event.ctrlKey ? "scale" : "move";
|
|
1813
|
+
if (mode === "move" && !supportsPosition(element)) return;
|
|
1814
|
+
startDrag(event, target, mode);
|
|
1815
|
+
});
|
|
1816
|
+
|
|
1817
|
+
stage.addEventListener("pointermove", (event) => {
|
|
1818
|
+
if (!drag) return;
|
|
1819
|
+
const point = parentPoint(event, drag.target);
|
|
1820
|
+
const dx = point.x - drag.start.x;
|
|
1821
|
+
const dy = point.y - drag.start.y;
|
|
1822
|
+
drag.changed = drag.changed || Math.abs(dx) > 0.25 || Math.abs(dy) > 0.25;
|
|
1823
|
+
let x = drag.x;
|
|
1824
|
+
let y = drag.y;
|
|
1825
|
+
let rotation = drag.rotation;
|
|
1826
|
+
let scale = drag.scale;
|
|
1827
|
+
if (drag.mode === "move") {
|
|
1828
|
+
x = Math.round(drag.x + dx);
|
|
1829
|
+
y = Math.round(drag.y + dy);
|
|
1830
|
+
setInput("propX", x);
|
|
1831
|
+
setInput("propY", y);
|
|
1832
|
+
previewDraggedTransform("translate(" + dx + " " + dy + ")");
|
|
1833
|
+
} else if (drag.mode === "rotate") {
|
|
1834
|
+
const delta = angleAround(drag.center, point) - angleAround(drag.center, drag.start);
|
|
1835
|
+
drag.changed = drag.changed || Math.abs(delta) > 0.25;
|
|
1836
|
+
rotation = Math.round((drag.rotation + delta) * 100) / 100;
|
|
1837
|
+
setInput("propRotation", rotation);
|
|
1838
|
+
previewDraggedTransform("rotate(" + delta + " " + drag.center.x + " " + drag.center.y + ")");
|
|
1839
|
+
} else {
|
|
1840
|
+
const startDistance = Math.max(distance(drag.center, drag.start), 1);
|
|
1841
|
+
const nextDistance = Math.max(distance(drag.center, point), 1);
|
|
1842
|
+
const ratio = Math.max(0.05, nextDistance / startDistance);
|
|
1843
|
+
drag.changed = drag.changed || Math.abs(ratio - 1) > 0.005;
|
|
1844
|
+
scale = Math.max(0.05, Math.round((drag.scale * ratio) * 100) / 100);
|
|
1845
|
+
setInput("propScale", scale);
|
|
1846
|
+
previewDraggedTransform("translate(" + drag.center.x + " " + drag.center.y + ") scale(" + ratio + ") translate(" + (-drag.center.x) + " " + (-drag.center.y) + ")");
|
|
1847
|
+
}
|
|
1848
|
+
drag.value = { x, y, rotation, scale };
|
|
1849
|
+
refreshHandles();
|
|
1850
|
+
});
|
|
1851
|
+
|
|
1852
|
+
stage.addEventListener("pointerup", finishDrag);
|
|
1853
|
+
stage.addEventListener("pointercancel", finishDrag);
|
|
1854
|
+
|
|
1855
|
+
async function finishDrag() {
|
|
1856
|
+
if (!drag) return;
|
|
1857
|
+
const snapshot = drag;
|
|
1858
|
+
drag = null;
|
|
1859
|
+
suppressClick = true;
|
|
1860
|
+
if (snapshot.changed) {
|
|
1861
|
+
await commitDrag(snapshot);
|
|
1862
|
+
}
|
|
1863
|
+
}
|
|
1864
|
+
|
|
1865
|
+
function startDrag(event, target, mode) {
|
|
1866
|
+
if (!target || isElementLocked(target.id) || isElementHidden(target.id)) return;
|
|
1867
|
+
const element = findElement(target.id);
|
|
1868
|
+
if (!element) return;
|
|
1869
|
+
const resolved = findResolvedElement(target.id) || element;
|
|
1870
|
+
drag = {
|
|
1871
|
+
id: target.id,
|
|
1872
|
+
target,
|
|
1873
|
+
mode,
|
|
1874
|
+
start: parentPoint(event, target),
|
|
1875
|
+
center: targetCenterInParent(target),
|
|
1876
|
+
x: Number(resolved.x ?? element.x ?? 0),
|
|
1877
|
+
y: Number(resolved.y ?? element.y ?? 0),
|
|
1878
|
+
rotation: Number(resolved.rotation ?? element.rotation ?? 0),
|
|
1879
|
+
scale: Number(resolved.scale ?? element.scale ?? 1),
|
|
1880
|
+
transform: target.getAttribute("transform") || "",
|
|
1881
|
+
changed: false,
|
|
1882
|
+
value: null
|
|
1883
|
+
};
|
|
1884
|
+
event.preventDefault();
|
|
1885
|
+
event.stopPropagation();
|
|
1886
|
+
stage.setPointerCapture?.(event.pointerId);
|
|
1887
|
+
}
|
|
1888
|
+
|
|
1889
|
+
async function commitDrag(snapshot) {
|
|
1890
|
+
const element = findElement(snapshot.id);
|
|
1891
|
+
if (!element || !snapshot.value) return;
|
|
1892
|
+
if (snapshot.mode === "move") {
|
|
1893
|
+
await commitEditedProperty(element, "position", [snapshot.value.x, snapshot.value.y]);
|
|
1894
|
+
} else if (snapshot.mode === "rotate") {
|
|
1895
|
+
await commitEditedProperty(element, "rotation", snapshot.value.rotation);
|
|
1896
|
+
} else if (snapshot.mode === "scale") {
|
|
1897
|
+
await commitEditedProperty(element, "scale", snapshot.value.scale);
|
|
1898
|
+
}
|
|
1899
|
+
}
|
|
1900
|
+
|
|
1901
|
+
async function commitEditedProperty(element, property, value) {
|
|
1902
|
+
if (!ensureElementEditable(element.id)) return;
|
|
1903
|
+
await mutate("/api/keyframe", { id: element.id, property, value, time: currentTime, curvePreset: "linear" });
|
|
1904
|
+
}
|
|
1905
|
+
|
|
1906
|
+
function ensureElementEditable(id) {
|
|
1907
|
+
if (!id) return false;
|
|
1908
|
+
if (isElementLocked(id)) {
|
|
1909
|
+
showError(new Error("Element is locked."));
|
|
1910
|
+
return false;
|
|
1911
|
+
}
|
|
1912
|
+
return true;
|
|
1913
|
+
}
|
|
1914
|
+
|
|
1915
|
+
function findElement(id, elements) {
|
|
1916
|
+
for (const element of elements || doc.elements || []) {
|
|
1917
|
+
if (element.id === id) return element;
|
|
1918
|
+
if (element.type === "group") {
|
|
1919
|
+
const found = findElement(id, element.children);
|
|
1920
|
+
if (found) return found;
|
|
1921
|
+
}
|
|
1922
|
+
}
|
|
1923
|
+
}
|
|
1924
|
+
function findResolvedElement(id, elements) {
|
|
1925
|
+
if (!resolvedDoc) return undefined;
|
|
1926
|
+
for (const element of elements || resolvedDoc.elements || []) {
|
|
1927
|
+
if (element.id === id) return element;
|
|
1928
|
+
if (element.type === "group") {
|
|
1929
|
+
const found = findResolvedElement(id, element.children);
|
|
1930
|
+
if (found) return found;
|
|
1931
|
+
}
|
|
1932
|
+
}
|
|
1933
|
+
}
|
|
1934
|
+
function togglePlay() {
|
|
1935
|
+
playing = !playing;
|
|
1936
|
+
const button = document.getElementById("play");
|
|
1937
|
+
if (button) button.textContent = playing ? "Pause" : "Play";
|
|
1938
|
+
if (playing) {
|
|
1939
|
+
lastTick = performance.now();
|
|
1940
|
+
if (playHandle) cancelAnimationFrame(playHandle);
|
|
1941
|
+
playHandle = requestAnimationFrame(playTick);
|
|
1942
|
+
} else if (playHandle) {
|
|
1943
|
+
cancelAnimationFrame(playHandle);
|
|
1944
|
+
playHandle = 0;
|
|
1945
|
+
}
|
|
1946
|
+
}
|
|
1947
|
+
function playTick(now) {
|
|
1948
|
+
if (!playing) {
|
|
1949
|
+
playHandle = 0;
|
|
1950
|
+
return;
|
|
1951
|
+
}
|
|
1952
|
+
const duration = Math.max(Number(doc.canvas.duration || 0), 0.01);
|
|
1953
|
+
currentTime = (currentTime + Math.max(0, now - lastTick) / 1000) % duration;
|
|
1954
|
+
lastTick = now;
|
|
1955
|
+
const scrub = document.getElementById("scrub");
|
|
1956
|
+
const label = document.getElementById("timeLabel");
|
|
1957
|
+
if (scrub) scrub.value = currentTime;
|
|
1958
|
+
if (label) label.textContent = currentTime.toFixed(2) + "s";
|
|
1959
|
+
const kfTime = document.getElementById("kfTime");
|
|
1960
|
+
if (kfTime) kfTime.value = currentTime.toFixed(2);
|
|
1961
|
+
requestDraw();
|
|
1962
|
+
playHandle = requestAnimationFrame(playTick);
|
|
1963
|
+
}
|
|
1964
|
+
function svgPoint(event) {
|
|
1965
|
+
const svg = stage.querySelector("svg");
|
|
1966
|
+
if (!svg) return { x: 0, y: 0 };
|
|
1967
|
+
const point = svg.createSVGPoint();
|
|
1968
|
+
point.x = event.clientX;
|
|
1969
|
+
point.y = event.clientY;
|
|
1970
|
+
return point.matrixTransform(svg.getScreenCTM().inverse());
|
|
1971
|
+
}
|
|
1972
|
+
function parentPoint(event, target) {
|
|
1973
|
+
const svg = stage.querySelector("svg");
|
|
1974
|
+
if (!svg || !target) return { x: 0, y: 0 };
|
|
1975
|
+
const parent = target.parentNode && target.parentNode.getScreenCTM ? target.parentNode : svg;
|
|
1976
|
+
const matrix = parent.getScreenCTM();
|
|
1977
|
+
if (!matrix) return svgPoint(event);
|
|
1978
|
+
const point = svg.createSVGPoint();
|
|
1979
|
+
point.x = event.clientX;
|
|
1980
|
+
point.y = event.clientY;
|
|
1981
|
+
return point.matrixTransform(matrix.inverse());
|
|
1982
|
+
}
|
|
1983
|
+
function previewDraggedTransform(prefix) {
|
|
1984
|
+
if (!drag || !drag.target) return;
|
|
1985
|
+
drag.target.setAttribute("transform", prefix + (drag.transform ? " " + drag.transform : ""));
|
|
1986
|
+
}
|
|
1987
|
+
function selectedTarget() {
|
|
1988
|
+
if (!selectedId || isElementHidden(selectedId) || isElementLocked(selectedId)) return null;
|
|
1989
|
+
return stage.querySelector("#" + cssId(selectedId));
|
|
1990
|
+
}
|
|
1991
|
+
function refreshHandles() {
|
|
1992
|
+
const target = selectedTarget();
|
|
1993
|
+
if (target) drawHandles(target);
|
|
1994
|
+
else clearHandles();
|
|
1995
|
+
}
|
|
1996
|
+
function clearHandles() {
|
|
1997
|
+
const svg = stage.querySelector("svg");
|
|
1998
|
+
const handles = svg ? svg.querySelector("#__sketchmark_handles") : null;
|
|
1999
|
+
if (handles) handles.remove();
|
|
2000
|
+
}
|
|
2001
|
+
function drawHandles(target) {
|
|
2002
|
+
const svg = target.ownerSVGElement;
|
|
2003
|
+
if (!svg || !target.getBBox || !target.getCTM) return;
|
|
2004
|
+
const old = svg.querySelector("#__sketchmark_handles");
|
|
2005
|
+
if (old) old.remove();
|
|
2006
|
+
let box;
|
|
2007
|
+
let matrix;
|
|
2008
|
+
try {
|
|
2009
|
+
box = target.getBBox();
|
|
2010
|
+
matrix = elementMatrixInSvg(target, svg);
|
|
2011
|
+
} catch {
|
|
2012
|
+
return;
|
|
2013
|
+
}
|
|
2014
|
+
if (!matrix) return;
|
|
2015
|
+
const topLeft = matrixPoint(svg, matrix, box.x, box.y);
|
|
2016
|
+
const topRight = matrixPoint(svg, matrix, box.x + box.width, box.y);
|
|
2017
|
+
const bottomRight = matrixPoint(svg, matrix, box.x + box.width, box.y + box.height);
|
|
2018
|
+
const bottomLeft = matrixPoint(svg, matrix, box.x, box.y + box.height);
|
|
2019
|
+
const center = matrixPoint(svg, matrix, box.x + box.width / 2, box.y + box.height / 2);
|
|
2020
|
+
const rotate = matrixPoint(svg, matrix, box.x + box.width / 2, box.y - 32);
|
|
2021
|
+
const scale = matrixPoint(svg, matrix, box.x + box.width, box.y + box.height);
|
|
2022
|
+
const group = svgNode("g");
|
|
2023
|
+
group.setAttribute("id", "__sketchmark_handles");
|
|
2024
|
+
group.setAttribute("style", "pointer-events:all");
|
|
2025
|
+
const selection = svgNode("polygon");
|
|
2026
|
+
selection.setAttribute("points", topLeft.x + "," + topLeft.y + " " + topRight.x + "," + topRight.y + " " + bottomRight.x + "," + bottomRight.y + " " + bottomLeft.x + "," + bottomLeft.y);
|
|
2027
|
+
selection.setAttribute("fill", "none");
|
|
2028
|
+
selection.setAttribute("stroke", "#ff0000");
|
|
2029
|
+
selection.setAttribute("stroke-width", "1.5");
|
|
2030
|
+
selection.setAttribute("stroke-dasharray", "4 3");
|
|
2031
|
+
selection.setAttribute("vector-effect", "non-scaling-stroke");
|
|
2032
|
+
selection.setAttribute("style", "pointer-events:none");
|
|
2033
|
+
const stem = svgNode("line");
|
|
2034
|
+
stem.setAttribute("x1", center.x);
|
|
2035
|
+
stem.setAttribute("y1", center.y);
|
|
2036
|
+
stem.setAttribute("x2", rotate.x);
|
|
2037
|
+
stem.setAttribute("y2", rotate.y);
|
|
2038
|
+
stem.setAttribute("stroke", "#000");
|
|
2039
|
+
stem.setAttribute("stroke-width", "1");
|
|
2040
|
+
stem.setAttribute("stroke-dasharray", "3 3");
|
|
2041
|
+
const rotateHandle = svgNode("circle");
|
|
2042
|
+
rotateHandle.setAttribute("cx", rotate.x);
|
|
2043
|
+
rotateHandle.setAttribute("cy", rotate.y);
|
|
2044
|
+
rotateHandle.setAttribute("r", "7");
|
|
2045
|
+
rotateHandle.setAttribute("fill", "#ffff66");
|
|
2046
|
+
rotateHandle.setAttribute("stroke", "#000");
|
|
2047
|
+
rotateHandle.setAttribute("data-handle", "rotate");
|
|
2048
|
+
rotateHandle.setAttribute("style", "cursor:grab");
|
|
2049
|
+
const scaleHandle = svgNode("rect");
|
|
2050
|
+
scaleHandle.setAttribute("x", scale.x - 6);
|
|
2051
|
+
scaleHandle.setAttribute("y", scale.y - 6);
|
|
2052
|
+
scaleHandle.setAttribute("width", "12");
|
|
2053
|
+
scaleHandle.setAttribute("height", "12");
|
|
2054
|
+
scaleHandle.setAttribute("fill", "#66ffff");
|
|
2055
|
+
scaleHandle.setAttribute("stroke", "#000");
|
|
2056
|
+
scaleHandle.setAttribute("data-handle", "scale");
|
|
2057
|
+
scaleHandle.setAttribute("style", "cursor:nwse-resize");
|
|
2058
|
+
group.appendChild(selection);
|
|
2059
|
+
group.appendChild(stem);
|
|
2060
|
+
group.appendChild(rotateHandle);
|
|
2061
|
+
group.appendChild(scaleHandle);
|
|
2062
|
+
svg.appendChild(group);
|
|
2063
|
+
}
|
|
2064
|
+
function elementMatrixInSvg(target, svg) {
|
|
2065
|
+
const targetScreen = target.getScreenCTM ? target.getScreenCTM() : null;
|
|
2066
|
+
const svgScreen = svg.getScreenCTM ? svg.getScreenCTM() : null;
|
|
2067
|
+
if (targetScreen && svgScreen && svgScreen.inverse) {
|
|
2068
|
+
try {
|
|
2069
|
+
return svgScreen.inverse().multiply(targetScreen);
|
|
2070
|
+
} catch {}
|
|
2071
|
+
}
|
|
2072
|
+
return target.getCTM ? target.getCTM() : null;
|
|
2073
|
+
}
|
|
2074
|
+
function targetCenterInParent(target) {
|
|
2075
|
+
const svg = target.ownerSVGElement || stage.querySelector("svg");
|
|
2076
|
+
if (!svg || !target.getBBox || !target.getCTM) return { x: 0, y: 0 };
|
|
2077
|
+
try {
|
|
2078
|
+
const box = target.getBBox();
|
|
2079
|
+
const targetMatrix = target.getCTM();
|
|
2080
|
+
const parent = target.parentNode && target.parentNode.getCTM ? target.parentNode : svg;
|
|
2081
|
+
const parentMatrix = parent.getCTM ? parent.getCTM() : null;
|
|
2082
|
+
if (!targetMatrix || !parentMatrix) return { x: box.x + box.width / 2, y: box.y + box.height / 2 };
|
|
2083
|
+
return matrixPoint(svg, parentMatrix.inverse().multiply(targetMatrix), box.x + box.width / 2, box.y + box.height / 2);
|
|
2084
|
+
} catch {
|
|
2085
|
+
return { x: 0, y: 0 };
|
|
2086
|
+
}
|
|
2087
|
+
}
|
|
2088
|
+
function matrixPoint(svg, matrix, x, y) {
|
|
2089
|
+
const point = svg.createSVGPoint();
|
|
2090
|
+
point.x = x;
|
|
2091
|
+
point.y = y;
|
|
2092
|
+
return point.matrixTransform(matrix);
|
|
2093
|
+
}
|
|
2094
|
+
function svgNode(name) {
|
|
2095
|
+
return document.createElementNS("http://www.w3.org/2000/svg", name);
|
|
2096
|
+
}
|
|
2097
|
+
function angleAround(center, point) {
|
|
2098
|
+
return Math.atan2(point.y - center.y, point.x - center.x) * 180 / Math.PI;
|
|
2099
|
+
}
|
|
2100
|
+
function distance(a, b) {
|
|
2101
|
+
const dx = a.x - b.x;
|
|
2102
|
+
const dy = a.y - b.y;
|
|
2103
|
+
return Math.sqrt(dx * dx + dy * dy);
|
|
2104
|
+
}
|
|
2105
|
+
function supportsPosition(element) {
|
|
2106
|
+
return element && ["path","point","text","image","group"].includes(element.type);
|
|
2107
|
+
}
|
|
2108
|
+
function setInput(id, value) {
|
|
2109
|
+
const input = document.getElementById(id);
|
|
2110
|
+
if (input) input.value = value;
|
|
2111
|
+
}
|
|
2112
|
+
function syncInspectorValues() {
|
|
2113
|
+
if (drag || !selectedId) return;
|
|
2114
|
+
const active = document.activeElement;
|
|
2115
|
+
if (active && active.closest && active.closest("#inspector")) return;
|
|
2116
|
+
const element = findResolvedElement(selectedId) || findElement(selectedId);
|
|
2117
|
+
if (!element) return;
|
|
2118
|
+
const origin = originPointValue(element);
|
|
2119
|
+
setInput("propX", valueOr(element.x, 0));
|
|
2120
|
+
setInput("propY", valueOr(element.y, 0));
|
|
2121
|
+
setInput("propRotation", valueOr(element.rotation, 0));
|
|
2122
|
+
setInput("propScale", valueOr(element.scale, 1));
|
|
2123
|
+
setInput("propScaleX", valueOr(element.scaleX, valueOr(element.scale, 1)));
|
|
2124
|
+
setInput("propScaleY", valueOr(element.scaleY, valueOr(element.scale, 1)));
|
|
2125
|
+
setInput("propOriginX", origin[0]);
|
|
2126
|
+
setInput("propOriginY", origin[1]);
|
|
2127
|
+
setInput("propOpacity", valueOr(element.opacity, 1));
|
|
2128
|
+
setInput("propStrokeWidth", valueOr(element.strokeWidth, 1));
|
|
2129
|
+
setInput("propDashOffset", valueOr(element.dashOffset, 0));
|
|
2130
|
+
setInput("propDashArray", formatArrayValue(element.dashArray));
|
|
2131
|
+
setInput("propDrawStart", valueOr(element.drawStart, 0));
|
|
2132
|
+
setInput("propDrawEnd", valueOr(element.drawEnd, 1));
|
|
2133
|
+
if (typeof element.fill === "string") setInput("propFill", element.fill);
|
|
2134
|
+
if (typeof element.stroke === "string") setInput("propStroke", element.stroke);
|
|
2135
|
+
if (element.type === "text") {
|
|
2136
|
+
setInput("propText", textEditorValue(element));
|
|
2137
|
+
setInput("propFontFamily", valueOr(element.fontFamily, defaultFontFamilyValue()));
|
|
2138
|
+
setInput("propWeight", valueOr(element.weight, 400));
|
|
2139
|
+
setInput("propFontSize", valueOr(element.fontSize, 16));
|
|
2140
|
+
setInput("propLineHeight", valueOr(element.lineHeight, 1.2));
|
|
2141
|
+
setInput("propLetterSpacing", valueOr(element.letterSpacing, 0));
|
|
2142
|
+
}
|
|
2143
|
+
syncDynamicInspectorValues(element);
|
|
2144
|
+
syncColorPickersInScope(inspector);
|
|
2145
|
+
}
|
|
2146
|
+
|
|
2147
|
+
function textEditorProperty(element) {
|
|
2148
|
+
return Array.isArray(element && element.lines) && element.lines.length ? "lines" : "text";
|
|
2149
|
+
}
|
|
2150
|
+
|
|
2151
|
+
function textEditorValue(element) {
|
|
2152
|
+
if (Array.isArray(element && element.lines) && element.lines.length) {
|
|
2153
|
+
return element.lines.map((line) => String(line)).join("\\n");
|
|
2154
|
+
}
|
|
2155
|
+
return String(valueOr(element && element.text, ""));
|
|
2156
|
+
}
|
|
2157
|
+
|
|
2158
|
+
function syncDynamicInspectorValues(element) {
|
|
2159
|
+
const effects = element.effects || {};
|
|
2160
|
+
const shadow = effects.shadow || {};
|
|
2161
|
+
setInput("propEffectsBlur", valueOr(effects.blur, 0));
|
|
2162
|
+
setInput("propEffectsBrightness", valueOr(effects.brightness, 1));
|
|
2163
|
+
setInput("propEffectsContrast", valueOr(effects.contrast, 1));
|
|
2164
|
+
setInput("propEffectsSaturate", valueOr(effects.saturate, 1));
|
|
2165
|
+
setInput("propEffectsHue", valueOr(effects.hueRotate, 0));
|
|
2166
|
+
setInput("propShadowDx", valueOr(shadow.dx, 0));
|
|
2167
|
+
setInput("propShadowDy", valueOr(shadow.dy, 0));
|
|
2168
|
+
setInput("propShadowBlur", valueOr(shadow.blur, 0));
|
|
2169
|
+
setInput("propShadowColor", valueOr(shadow.color, "#000000"));
|
|
2170
|
+
setInput("propShadowOpacity", valueOr(shadow.opacity, 1));
|
|
2171
|
+
if (element.type === "image") {
|
|
2172
|
+
const source = element.source || { x: 0, y: 0, width: element.width || 0, height: element.height || 0 };
|
|
2173
|
+
setInput("propSourceX", valueOr(source.x, 0));
|
|
2174
|
+
setInput("propSourceY", valueOr(source.y, 0));
|
|
2175
|
+
setInput("propSourceWidth", valueOr(source.width, element.width || 0));
|
|
2176
|
+
setInput("propSourceHeight", valueOr(source.height, element.height || 0));
|
|
2177
|
+
setInput("propClipRadius", clipRadiusValue(element));
|
|
2178
|
+
}
|
|
2179
|
+
syncStructuredPaintValues(element, "fill");
|
|
2180
|
+
syncStructuredPaintValues(element, "stroke");
|
|
2181
|
+
}
|
|
2182
|
+
|
|
2183
|
+
function syncStructuredPaintValues(element, root) {
|
|
2184
|
+
const paint = element[root];
|
|
2185
|
+
if (!paint || typeof paint !== "object") return;
|
|
2186
|
+
const prefix = "prop" + cap(root);
|
|
2187
|
+
if (paint.type === "linearGradient") {
|
|
2188
|
+
setPointInputs(prefix + "From", paint.from || [0, 0]);
|
|
2189
|
+
setPointInputs(prefix + "To", paint.to || [100, 0]);
|
|
2190
|
+
} else if (paint.type === "radialGradient") {
|
|
2191
|
+
setPointInputs(prefix + "Center", paint.center || [50, 50]);
|
|
2192
|
+
setPointInputs(prefix + "Focus", paint.focus || paint.center || [50, 50]);
|
|
2193
|
+
setInput(prefix + "Radius", valueOr(paint.radius, 50));
|
|
2194
|
+
}
|
|
2195
|
+
const stops = Array.isArray(paint.stops) ? paint.stops : [];
|
|
2196
|
+
for (let index = 0; index < stops.length; index += 1) {
|
|
2197
|
+
const stop = stops[index];
|
|
2198
|
+
setInput(prefix + "Stop" + index + "Offset", Array.isArray(stop) ? stop[0] : valueOr(stop && stop.offset, 0));
|
|
2199
|
+
setInput(prefix + "Stop" + index + "Color", Array.isArray(stop) ? stop[1] : valueOr(stop && stop.color, "#000000"));
|
|
2200
|
+
}
|
|
2201
|
+
}
|
|
2202
|
+
|
|
2203
|
+
function setPointInputs(prefix, point) {
|
|
2204
|
+
const value = Array.isArray(point) ? point : [0, 0];
|
|
2205
|
+
setInput(prefix + "X", valueOr(value[0], 0));
|
|
2206
|
+
setInput(prefix + "Y", valueOr(value[1], 0));
|
|
2207
|
+
}
|
|
2208
|
+
function originPointValue(element) {
|
|
2209
|
+
if (Array.isArray(element && element.origin) && element.origin.length === 2) {
|
|
2210
|
+
const x = Number(element.origin[0]);
|
|
2211
|
+
const y = Number(element.origin[1]);
|
|
2212
|
+
if (Number.isFinite(x) && Number.isFinite(y)) return [x, y];
|
|
2213
|
+
}
|
|
2214
|
+
const x = Number(element && element.x);
|
|
2215
|
+
const y = Number(element && element.y);
|
|
2216
|
+
if (Number.isFinite(x) && Number.isFinite(y)) return [x, y];
|
|
2217
|
+
return [0, 0];
|
|
2218
|
+
}
|
|
2219
|
+
function valueOr(value, fallback) { return value === undefined ? fallback : value; }
|
|
2220
|
+
function clipRadiusValue(element) {
|
|
2221
|
+
if (!element || !element.clip || typeof element.clip.d !== "string") return 0;
|
|
2222
|
+
const numbers = element.clip.d.match(/-?\\d+(?:\\.\\d+)?(?:e[+-]?\\d+)?/gi);
|
|
2223
|
+
if (!numbers || numbers.length < 2) return 0;
|
|
2224
|
+
const x = Number(element.x || 0);
|
|
2225
|
+
const y = Number(element.y || 0);
|
|
2226
|
+
const width = Math.max(0, Number(element.width || 0));
|
|
2227
|
+
const height = Math.max(0, Number(element.height || 0));
|
|
2228
|
+
const firstX = Number(numbers[0]);
|
|
2229
|
+
const firstY = Number(numbers[1]);
|
|
2230
|
+
if (!Number.isFinite(firstX) || !Number.isFinite(firstY)) return 0;
|
|
2231
|
+
if (Math.abs(firstX - x) < 0.001 && Math.abs(firstY - y) < 0.001) return 0;
|
|
2232
|
+
const radius = Math.max(0, Math.min(firstX - x, width / 2, height / 2));
|
|
2233
|
+
return Number.isFinite(radius) ? Number(radius.toFixed(2)) : 0;
|
|
2234
|
+
}
|
|
2235
|
+
function roundedRectClipPath(x, y, width, height, radius) {
|
|
2236
|
+
const left = finiteNumber(x, 0);
|
|
2237
|
+
const top = finiteNumber(y, 0);
|
|
2238
|
+
const w = Math.max(0, finiteNumber(width, 0));
|
|
2239
|
+
const h = Math.max(0, finiteNumber(height, 0));
|
|
2240
|
+
const r = Math.min(Math.max(0, finiteNumber(radius, 0)), w / 2, h / 2);
|
|
2241
|
+
const right = left + w;
|
|
2242
|
+
const bottom = top + h;
|
|
2243
|
+
if (r <= 0) return "M " + left + " " + top + " H " + right + " V " + bottom + " H " + left + " Z";
|
|
2244
|
+
return [
|
|
2245
|
+
"M " + (left + r) + " " + top,
|
|
2246
|
+
"H " + (right - r),
|
|
2247
|
+
"Q " + right + " " + top + " " + right + " " + (top + r),
|
|
2248
|
+
"V " + (bottom - r),
|
|
2249
|
+
"Q " + right + " " + bottom + " " + (right - r) + " " + bottom,
|
|
2250
|
+
"H " + (left + r),
|
|
2251
|
+
"Q " + left + " " + bottom + " " + left + " " + (bottom - r),
|
|
2252
|
+
"V " + (top + r),
|
|
2253
|
+
"Q " + left + " " + top + " " + (left + r) + " " + top,
|
|
2254
|
+
"Z"
|
|
2255
|
+
].join(" ");
|
|
2256
|
+
}
|
|
2257
|
+
function finiteNumber(value, fallback) {
|
|
2258
|
+
const number = Number(value);
|
|
2259
|
+
return Number.isFinite(number) ? number : fallback;
|
|
2260
|
+
}
|
|
2261
|
+
function formatArrayValue(value) {
|
|
2262
|
+
return Array.isArray(value) ? value.map((item) => typeof item === "number" ? Number(item).toFixed(2).replace(/\\.00$/, "") : String(item)).join(",") : "";
|
|
2263
|
+
}
|
|
2264
|
+
function formatMotionValue(value) {
|
|
2265
|
+
if (Array.isArray(value)) return "[" + value.map((item) => typeof item === "number" ? Number(item).toFixed(2).replace(/\\.00$/, "") : JSON.stringify(item)).join(",") + "]";
|
|
2266
|
+
if (typeof value === "number") return Number(value).toFixed(2).replace(/\\.00$/, "");
|
|
2267
|
+
if (value === undefined) return "";
|
|
2268
|
+
if (value && typeof value === "object") return JSON.stringify(value);
|
|
2269
|
+
return String(value);
|
|
2270
|
+
}
|
|
2271
|
+
function cap(value) { value = String(value); return value.charAt(0).toUpperCase() + value.slice(1); }
|
|
2272
|
+
function cssId(id) { return String(id).replace(/([ !"#$%&'()*+,./:;<=>?@[\\\\\\]^\\\`{|}~])/g, "\\\\$1"); }
|
|
2273
|
+
function escapeText(value) { return String(value).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">"); }
|
|
2274
|
+
function escapeAttr(value) { return escapeText(value).replace(/"/g, """).replace(/'/g, "'"); }
|
|
2275
|
+
function showError(error) { const box = document.getElementById("error"); if (box) box.textContent = error.message || String(error); }
|
|
2276
|
+
load().catch(showError);
|
|
2277
|
+
</script></body></html>`;
|
|
2278
|
+
}
|
|
2279
|
+
|
|
2280
|
+
|
|
2281
|
+
function escapeHtml(value) {
|
|
2282
|
+
return String(value).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
2283
|
+
}
|
|
2284
|
+
|
|
2285
|
+
module.exports = { editorHtml };
|