@1agh/maude 0.39.1 → 0.41.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/README.md +1 -1
- package/apps/studio/ai-banner.tsx +2 -2
- package/apps/studio/annotations-context-toolbar.tsx +44 -1
- package/apps/studio/annotations-layer.tsx +306 -5
- package/apps/studio/annotations-model.ts +107 -6
- package/apps/studio/api.ts +1316 -67
- package/apps/studio/artboard-marquee.tsx +1 -1
- package/apps/studio/bin/_canvas-rects-playwright.mjs +55 -0
- package/apps/studio/bin/_canvas-rects-static.mjs +166 -0
- package/apps/studio/bin/_fetch-asset.mjs +556 -0
- package/apps/studio/bin/_html-playwright.mjs +9 -1
- package/apps/studio/bin/_pdf-playwright.mjs +8 -1
- package/apps/studio/bin/_png-playwright.mjs +8 -1
- package/apps/studio/bin/_pw-launch.mjs +54 -0
- package/apps/studio/bin/_svg-playwright.mjs +8 -1
- package/apps/studio/bin/_video-playwright.mjs +452 -0
- package/apps/studio/bin/annotate.mjs +576 -34
- package/apps/studio/bin/canvas-rects.sh +152 -0
- package/apps/studio/bin/fetch-asset.sh +34 -0
- package/apps/studio/bin/prep.sh +8 -1
- package/apps/studio/bin/read-annotations.mjs +138 -7
- package/apps/studio/bin/smoke.sh +42 -6
- package/apps/studio/build.ts +21 -0
- package/apps/studio/canvas-comment-mount.tsx +138 -4
- package/apps/studio/canvas-edit.ts +2625 -111
- package/apps/studio/canvas-lib.tsx +238 -2
- package/apps/studio/canvas-list-watch.ts +6 -2
- package/apps/studio/canvas-shell.tsx +514 -20
- package/apps/studio/client/app.jsx +2480 -52
- package/apps/studio/client/comments-overlay.css +130 -126
- package/apps/studio/client/github.js +15 -0
- package/apps/studio/client/panels/TimelinePanel.jsx +860 -0
- package/apps/studio/client/panels/timeline-parse.js +229 -0
- package/apps/studio/client/panels/timeline-snap.js +55 -0
- package/apps/studio/client/styles/3-shell-maude.css +155 -0
- package/apps/studio/comments-overlay.tsx +148 -41
- package/apps/studio/config.schema.json +14 -0
- package/apps/studio/context-menu.tsx +16 -6
- package/apps/studio/context.ts +113 -1
- package/apps/studio/contextual-toolbar.tsx +262 -4
- package/apps/studio/cursors-overlay.tsx +4 -4
- package/apps/studio/dist/client.bundle.js +89 -17
- package/apps/studio/dist/comment-mount.js +59 -1
- package/apps/studio/dist/runtime/.min-sizes.json +11 -1
- package/apps/studio/dist/runtime/@remotion_media.js +491 -0
- package/apps/studio/dist/runtime/@remotion_player.js +56 -0
- package/apps/studio/dist/runtime/@remotion_transitions.js +300 -0
- package/apps/studio/dist/runtime/@remotion_transitions_clock-wipe.js +1 -0
- package/apps/studio/dist/runtime/@remotion_transitions_fade.js +1 -0
- package/apps/studio/dist/runtime/@remotion_transitions_flip.js +1 -0
- package/apps/studio/dist/runtime/@remotion_transitions_none.js +1 -0
- package/apps/studio/dist/runtime/@remotion_transitions_slide.js +1 -0
- package/apps/studio/dist/runtime/@remotion_transitions_wipe.js +1 -0
- package/apps/studio/dist/runtime/REMOTION-LICENSE.md +28 -0
- package/apps/studio/dist/runtime/remotion.js +42 -0
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/dom-selection.ts +127 -1
- package/apps/studio/drag-state.ts +24 -0
- package/apps/studio/equal-spacing-detector.ts +205 -0
- package/apps/studio/export-dialog.tsx +1 -1
- package/apps/studio/exporters/_browser-bundles.ts +117 -0
- package/apps/studio/exporters/_runtime.ts +69 -0
- package/apps/studio/exporters/html.ts +4 -3
- package/apps/studio/exporters/index.ts +28 -2
- package/apps/studio/exporters/pdf.ts +4 -3
- package/apps/studio/exporters/png.ts +5 -3
- package/apps/studio/exporters/pptx.ts +6 -4
- package/apps/studio/exporters/svg.ts +9 -5
- package/apps/studio/exporters/video-encode-lib.ts +200 -0
- package/apps/studio/exporters/video-render-lib.ts +108 -0
- package/apps/studio/exporters/video.ts +184 -0
- package/apps/studio/history.ts +47 -1
- package/apps/studio/http.ts +758 -27
- package/apps/studio/input-router.tsx +19 -1
- package/apps/studio/marquee-overlay.tsx +1 -1
- package/apps/studio/measure-overlay.tsx +241 -0
- package/apps/studio/participants-chrome.tsx +3 -3
- package/apps/studio/runtime-bundle.ts +30 -0
- package/apps/studio/server.ts +34 -9
- package/apps/studio/sizing-mode.ts +117 -0
- package/apps/studio/spacing-handles.ts +166 -0
- package/apps/studio/test/annotate-write.test.ts +890 -0
- package/apps/studio/test/annotations-roundtrip.test.ts +47 -0
- package/apps/studio/test/camera-reveal.test.tsx +173 -0
- package/apps/studio/test/canvas-create-api.test.ts +76 -0
- package/apps/studio/test/canvas-edit.test.ts +140 -0
- package/apps/studio/test/canvas-list-watch.test.ts +49 -0
- package/apps/studio/test/canvas-media-drop.test.ts +30 -1
- package/apps/studio/test/canvas-origin-gate.test.ts +54 -0
- package/apps/studio/test/canvas-rects.test.ts +198 -0
- package/apps/studio/test/clip-addressing.test.ts +732 -0
- package/apps/studio/test/comments-overlay.test.ts +117 -0
- package/apps/studio/test/config-reload.test.ts +230 -0
- package/apps/studio/test/dns-rebinding-guard.test.ts +74 -0
- package/apps/studio/test/dom-selection.test.ts +130 -0
- package/apps/studio/test/edit-css-occurrence.test.ts +81 -0
- package/apps/studio/test/edit-persistence.test.ts +91 -0
- package/apps/studio/test/edit-scope-api.test.ts +115 -0
- package/apps/studio/test/element-resize.test.ts +136 -0
- package/apps/studio/test/element-structural-api.test.ts +360 -0
- package/apps/studio/test/element-structural-edit.test.ts +233 -0
- package/apps/studio/test/equal-spacing-detector.test.ts +165 -1
- package/apps/studio/test/exporters/runtime.test.ts +59 -0
- package/apps/studio/test/file-lock.test.ts +84 -0
- package/apps/studio/test/fixtures/video-comp-fixture.tsx +82 -0
- package/apps/studio/test/history-rollback.test.ts +26 -0
- package/apps/studio/test/knob-props-authored.test.ts +87 -0
- package/apps/studio/test/read-annotations.test.ts +154 -0
- package/apps/studio/test/sizing-mode.test.ts +102 -0
- package/apps/studio/test/spacing-handles.test.ts +138 -0
- package/apps/studio/test/specimen-select.test.ts +88 -0
- package/apps/studio/test/timeline-parse.test.ts +127 -0
- package/apps/studio/test/timeline-snap.test.ts +85 -0
- package/apps/studio/test/tool-palette-insert-anchor.test.ts +84 -0
- package/apps/studio/test/undo-sequence-byte-compare.test.ts +211 -0
- package/apps/studio/test/video-asset.test.ts +163 -0
- package/apps/studio/test/video-comp-fixture.test.ts +50 -0
- package/apps/studio/test/video-comp.test.ts +168 -0
- package/apps/studio/test/video-render-bridge.test.ts +149 -0
- package/apps/studio/tool-palette.tsx +122 -2
- package/apps/studio/undo-hud.tsx +2 -2
- package/apps/studio/use-annotation-resize.tsx +6 -3
- package/apps/studio/use-canvas-media-drop.tsx +66 -4
- package/apps/studio/use-element-resize.tsx +732 -0
- package/apps/studio/use-keyboard-discipline.tsx +205 -15
- package/apps/studio/use-selection-set.tsx +14 -0
- package/apps/studio/use-spacing-handles.tsx +388 -0
- package/apps/studio/video-comp.tsx +444 -0
- package/apps/studio/whats-new.json +55 -0
- package/apps/studio/ws.ts +5 -0
- package/cli/commands/design.mjs +6 -1
- package/cli/commands/design.test.mjs +49 -1
- package/cli/lib/fetch-asset.test.mjs +213 -0
- package/package.json +8 -8
- package/plugins/design/dependencies.json +10 -2
- package/plugins/design/templates/_shell.html +25 -2
- package/plugins/design/templates/design-system-inspiration/_MAPPING.md +1 -1
|
@@ -12,6 +12,7 @@ import { createRoot } from 'react-dom/client';
|
|
|
12
12
|
// import that Bun erases), so this pulls only string constants into the client
|
|
13
13
|
// bundle — no React, no input-router. See the tool-cursor handler below.
|
|
14
14
|
import { resolveToolCursor } from '../canvas-cursors.ts';
|
|
15
|
+
import { sizingModeOf, sizingModePatch } from '../sizing-mode.ts';
|
|
15
16
|
import { canvasUrl } from './canvas-url.js';
|
|
16
17
|
import ChatPanel from './panels/ChatPanel.jsx';
|
|
17
18
|
import DiffView from './panels/DiffView.jsx';
|
|
@@ -19,8 +20,17 @@ import GitPanel from './panels/GitPanel.jsx';
|
|
|
19
20
|
import IdentityBar from './panels/IdentityBar.jsx';
|
|
20
21
|
import OnboardingWizard from './panels/OnboardingWizard.jsx';
|
|
21
22
|
import { ReadinessDialog } from './panels/ReadinessList.jsx';
|
|
23
|
+
import TimelinePanel from './panels/TimelinePanel.jsx';
|
|
24
|
+
import { parseCompTimeline } from './panels/timeline-parse.js';
|
|
22
25
|
import RepoBranchSwitcher from './panels/RepoBranchSwitcher.jsx';
|
|
23
|
-
import {
|
|
26
|
+
import {
|
|
27
|
+
appIsFirstRun,
|
|
28
|
+
isNativeApp,
|
|
29
|
+
onUpdateReady,
|
|
30
|
+
pickMediaFile,
|
|
31
|
+
restartToUpdate,
|
|
32
|
+
saveExport,
|
|
33
|
+
} from './github.js';
|
|
24
34
|
import { COLLAB_TOUR } from './tour/collab-tour.js';
|
|
25
35
|
import { TourOverlay } from './tour/overlay.jsx';
|
|
26
36
|
import { USAGE_TOUR } from './tour/usage-tour.js';
|
|
@@ -92,6 +102,33 @@ function basename(p) {
|
|
|
92
102
|
return p.split('/').pop();
|
|
93
103
|
}
|
|
94
104
|
|
|
105
|
+
// DDR-150 dogfood — a transient shell toast for timeline/inline-edit op results.
|
|
106
|
+
// The clip ops used to console.warn their failures (a 422 "series clip can't
|
|
107
|
+
// move" looked like "the button does nothing"). Self-contained DOM (no React
|
|
108
|
+
// state), mirrors the canvas-side showCanvasToast.
|
|
109
|
+
function shellToast(message, ok = false) {
|
|
110
|
+
if (typeof document === 'undefined') return;
|
|
111
|
+
let el = document.getElementById('st-op-toast');
|
|
112
|
+
if (!el) {
|
|
113
|
+
el = document.createElement('div');
|
|
114
|
+
el.id = 'st-op-toast';
|
|
115
|
+
el.setAttribute('role', 'status');
|
|
116
|
+
el.style.cssText =
|
|
117
|
+
'position:fixed;left:50%;bottom:64px;transform:translateX(-50%);z-index:80;' +
|
|
118
|
+
'max-width:440px;padding:8px 14px;border-radius:8px;font:12px/1.45 var(--font-mono,monospace);' +
|
|
119
|
+
'box-shadow:0 8px 28px rgba(0,0,0,.34);pointer-events:none;opacity:0;transition:opacity 140ms ease;';
|
|
120
|
+
document.body.appendChild(el);
|
|
121
|
+
}
|
|
122
|
+
el.style.background = ok ? '#1d3524' : '#3a1d1d';
|
|
123
|
+
el.style.color = ok ? '#b7e4c0' : '#f1b8b8';
|
|
124
|
+
el.textContent = message;
|
|
125
|
+
el.style.opacity = '1';
|
|
126
|
+
clearTimeout(shellToast._t);
|
|
127
|
+
shellToast._t = setTimeout(() => {
|
|
128
|
+
el.style.opacity = '0';
|
|
129
|
+
}, 3200);
|
|
130
|
+
}
|
|
131
|
+
|
|
95
132
|
// Strip canvas extensions for display. `Canvas Viewport.tsx` → `Canvas Viewport`.
|
|
96
133
|
// Sidecars (`.meta.json`, `.css`, `.registry.json`) keep their extensions so
|
|
97
134
|
// the file type stays unambiguous.
|
|
@@ -768,6 +805,11 @@ const EXPORT_CARDS = [
|
|
|
768
805
|
{ id: 'svg', label: 'SVG', sub: 'per artboard', icon: 'vector', format: 'svg' },
|
|
769
806
|
{ id: 'html', label: 'HTML', sub: 'self-contained', icon: 'code', format: 'html' },
|
|
770
807
|
{ id: 'pptx', label: 'PPTX', sub: 'slides', icon: 'presentation', format: 'pptx' },
|
|
808
|
+
// DDR-148 — temporal formats. Shown only when the active canvas has a
|
|
809
|
+
// video-comp (`temporal: true` + the hasComps gate in ExportDialog); the
|
|
810
|
+
// capture engine renders the artboard frame-by-frame.
|
|
811
|
+
{ id: 'mp4', label: 'MP4', sub: 'video · H.264', icon: 'presentation', format: 'mp4', temporal: true },
|
|
812
|
+
{ id: 'gif', label: 'GIF', sub: 'animated', icon: 'image', format: 'gif', temporal: true },
|
|
771
813
|
{ id: 'canva', label: 'Canva', sub: 'handoff bundle', icon: 'external', format: 'canva' },
|
|
772
814
|
{ id: 'zip', label: 'ZIP', sub: 'project bundle', icon: 'archive', format: 'zip' },
|
|
773
815
|
{ id: 'shadcn', label: 'AI handoff', sub: 'production drop', icon: 'sparkle', handoff: true },
|
|
@@ -787,6 +829,9 @@ const EXPORT_VALID_SCOPES = {
|
|
|
787
829
|
svg: ['selection', 'artboard', 'canvas-as-separate'],
|
|
788
830
|
html: ['artboard', 'canvas-as-separate'],
|
|
789
831
|
pptx: ['canvas-as-separate'],
|
|
832
|
+
mp4: ['artboard'],
|
|
833
|
+
gif: ['artboard'],
|
|
834
|
+
webm: ['artboard'],
|
|
790
835
|
canva: ['canvas-as-separate'],
|
|
791
836
|
zip: ['project-raw'],
|
|
792
837
|
};
|
|
@@ -796,12 +841,186 @@ const PNG_SCALES = [
|
|
|
796
841
|
{ value: 3, label: '3× (max)' },
|
|
797
842
|
];
|
|
798
843
|
|
|
799
|
-
|
|
844
|
+
// feature-element-editing-robustness Stage F1 — AssetPicker. A shell modal that
|
|
845
|
+
// lists the versioned content-addressed media under <designRoot>/assets/ (via the
|
|
846
|
+
// main-origin-only GET /_api/assets) and lets the user pick one — or upload a new
|
|
847
|
+
// file (POST /_api/asset, content-addressed) — for a media Replace / image
|
|
848
|
+
// Insert. Thumbnails load from the main origin's designRoot static serve
|
|
849
|
+
// (/${designRel}/${asset.path}); never a remote hotlink (the CSP split origin
|
|
850
|
+
// blocks those — memory reference_canvas_images_download_first).
|
|
851
|
+
function AssetPicker({ designRel, onPick, onClose }) {
|
|
852
|
+
const [assets, setAssets] = useState(null);
|
|
853
|
+
const [busy, setBusy] = useState(false);
|
|
854
|
+
const [err, setErr] = useState(null);
|
|
855
|
+
|
|
856
|
+
useEffect(() => {
|
|
857
|
+
let alive = true;
|
|
858
|
+
fetch('/_api/assets')
|
|
859
|
+
.then((r) => r.json())
|
|
860
|
+
.then((j) => alive && setAssets(j.ok ? j.assets : []))
|
|
861
|
+
.catch(() => alive && setAssets([]));
|
|
862
|
+
return () => {
|
|
863
|
+
alive = false;
|
|
864
|
+
};
|
|
865
|
+
}, []);
|
|
866
|
+
|
|
867
|
+
useEffect(() => {
|
|
868
|
+
const onKey = (e) => {
|
|
869
|
+
if (e.key === 'Escape') {
|
|
870
|
+
e.stopPropagation();
|
|
871
|
+
onClose();
|
|
872
|
+
}
|
|
873
|
+
};
|
|
874
|
+
window.addEventListener('keydown', onKey, true);
|
|
875
|
+
return () => window.removeEventListener('keydown', onKey, true);
|
|
876
|
+
}, [onClose]);
|
|
877
|
+
|
|
878
|
+
const doUpload = async (f) => {
|
|
879
|
+
if (!f) return;
|
|
880
|
+
setBusy(true);
|
|
881
|
+
setErr(null);
|
|
882
|
+
try {
|
|
883
|
+
const res = await fetch('/_api/asset', {
|
|
884
|
+
method: 'POST',
|
|
885
|
+
headers: { 'content-type': f.type || 'application/octet-stream' },
|
|
886
|
+
body: f,
|
|
887
|
+
});
|
|
888
|
+
const j = await res.json().catch(() => ({}));
|
|
889
|
+
// /_api/asset returns 201 { path } on success — NO `ok` field (the bug: a
|
|
890
|
+
// `j.ok` check always failed → "upload failed" even on a good upload).
|
|
891
|
+
if (res.ok && j.path) {
|
|
892
|
+
onPick(j.path);
|
|
893
|
+
return;
|
|
894
|
+
}
|
|
895
|
+
setErr(j.error || `upload failed (HTTP ${res.status})`);
|
|
896
|
+
} catch {
|
|
897
|
+
setErr('upload failed');
|
|
898
|
+
} finally {
|
|
899
|
+
setBusy(false);
|
|
900
|
+
}
|
|
901
|
+
};
|
|
902
|
+
|
|
903
|
+
// Native desktop (Tauri WKWebView) — an HTML <input type=file> won't present
|
|
904
|
+
// the file panel AT ALL here, so route through the same native-dialog spine
|
|
905
|
+
// export uses (dogfood: "pri exportu to uz umime"). The Rust pick_media_file
|
|
906
|
+
// command opens an OS open-dialog + reads the bytes; we POST them to
|
|
907
|
+
// /_api/asset (magic-byte sniffed, so name/ext aren't trusted).
|
|
908
|
+
const openFilePickerNative = async () => {
|
|
909
|
+
setBusy(true);
|
|
910
|
+
setErr(null);
|
|
911
|
+
try {
|
|
912
|
+
const picked = await pickMediaFile();
|
|
913
|
+
if (picked?.bytes) {
|
|
914
|
+
// Blob from the byte array; the server sniffs the type, so no content-type
|
|
915
|
+
// needed. (doUpload sets its own busy=false in finally.)
|
|
916
|
+
await doUpload(new Blob([new Uint8Array(picked.bytes)]));
|
|
917
|
+
return;
|
|
918
|
+
}
|
|
919
|
+
} catch (e) {
|
|
920
|
+
setErr(e?.message || 'open failed');
|
|
921
|
+
}
|
|
922
|
+
setBusy(false); // only reached on cancel / error (doUpload owns the success path)
|
|
923
|
+
};
|
|
924
|
+
|
|
925
|
+
// Browser — imperative <input>, mirrors the proven replaceMediaViaPicker
|
|
926
|
+
// pattern (freshly created, appended to document.body, clicked synchronously in
|
|
927
|
+
// the gesture). Off-screen (not display:none) so it lays out.
|
|
928
|
+
const openFilePicker = () => {
|
|
929
|
+
if (isNativeApp()) {
|
|
930
|
+
openFilePickerNative();
|
|
931
|
+
return;
|
|
932
|
+
}
|
|
933
|
+
const input = document.createElement('input');
|
|
934
|
+
input.type = 'file';
|
|
935
|
+
input.accept = 'image/*,video/*';
|
|
936
|
+
input.style.cssText =
|
|
937
|
+
'position:fixed;left:-9999px;top:0;width:1px;height:1px;opacity:0;pointer-events:none';
|
|
938
|
+
document.body.appendChild(input);
|
|
939
|
+
const cleanup = () => {
|
|
940
|
+
if (input.isConnected) input.remove();
|
|
941
|
+
};
|
|
942
|
+
window.addEventListener('focus', () => setTimeout(cleanup, 300), { once: true });
|
|
943
|
+
input.addEventListener('change', () => {
|
|
944
|
+
const file = input.files?.[0];
|
|
945
|
+
cleanup();
|
|
946
|
+
if (file) doUpload(file);
|
|
947
|
+
});
|
|
948
|
+
input.click();
|
|
949
|
+
};
|
|
950
|
+
|
|
951
|
+
const assetUrl = (p) => `/${designRel}/${p}`;
|
|
952
|
+
|
|
953
|
+
return (
|
|
954
|
+
<div
|
|
955
|
+
className="st-scrim"
|
|
956
|
+
role="presentation"
|
|
957
|
+
onMouseDown={(e) => {
|
|
958
|
+
if (e.target === e.currentTarget) onClose();
|
|
959
|
+
}}
|
|
960
|
+
>
|
|
961
|
+
<div className="st-dialog st-asset-picker" role="dialog" aria-modal="true" aria-label="Choose media">
|
|
962
|
+
<div className="st-dialog-hd">
|
|
963
|
+
<span className="st-dialog-title">Choose media</span>
|
|
964
|
+
<button type="button" className="st-iconbtn" aria-label="Close" onClick={onClose}>
|
|
965
|
+
<StIcon name="x" size={15} />
|
|
966
|
+
</button>
|
|
967
|
+
</div>
|
|
968
|
+
<div className="st-dialog-bd">
|
|
969
|
+
<div className="st-ap-toolbar">
|
|
970
|
+
<button type="button" className="st-btn" onClick={openFilePicker} disabled={busy}>
|
|
971
|
+
{busy ? 'Uploading…' : 'Upload…'}
|
|
972
|
+
</button>
|
|
973
|
+
{err && <span className="st-ap-err">{err}</span>}
|
|
974
|
+
</div>
|
|
975
|
+
<div className="st-ap-grid">
|
|
976
|
+
{assets == null ? (
|
|
977
|
+
<div className="st-ap-empty">Loading…</div>
|
|
978
|
+
) : assets.length === 0 ? (
|
|
979
|
+
<div className="st-ap-empty">No assets yet — upload one.</div>
|
|
980
|
+
) : (
|
|
981
|
+
assets.map((a) => (
|
|
982
|
+
<button
|
|
983
|
+
type="button"
|
|
984
|
+
key={a.path}
|
|
985
|
+
className="st-ap-cell"
|
|
986
|
+
title={`${a.name} · ${Math.max(1, Math.round(a.size / 1024))} KB`}
|
|
987
|
+
onClick={() => onPick(a.path)}
|
|
988
|
+
>
|
|
989
|
+
{a.kind === 'video' ? (
|
|
990
|
+
<video className="st-ap-thumb" src={assetUrl(a.path)} muted playsInline />
|
|
991
|
+
) : (
|
|
992
|
+
<img className="st-ap-thumb" src={assetUrl(a.path)} alt={a.name} loading="lazy" />
|
|
993
|
+
)}
|
|
994
|
+
<span className="st-ap-name">{a.name}</span>
|
|
995
|
+
</button>
|
|
996
|
+
))
|
|
997
|
+
)}
|
|
998
|
+
</div>
|
|
999
|
+
</div>
|
|
1000
|
+
</div>
|
|
1001
|
+
</div>
|
|
1002
|
+
);
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
function ExportDialog({
|
|
1006
|
+
mode,
|
|
1007
|
+
initialScope,
|
|
1008
|
+
activePath,
|
|
1009
|
+
hasComps = false,
|
|
1010
|
+
activeArtboardId = null,
|
|
1011
|
+
selection = null,
|
|
1012
|
+
onClose,
|
|
1013
|
+
}) {
|
|
800
1014
|
const [sel, setSel] = useState(mode === 'handoff' ? 'shadcn' : 'png');
|
|
801
1015
|
const [scope, setScope] = useState(
|
|
802
1016
|
initialScope && EXPORT_SCOPE_LABELS[initialScope] ? initialScope : 'artboard'
|
|
803
1017
|
);
|
|
804
1018
|
const [scale, setScale] = useState(2);
|
|
1019
|
+
// DDR-148 addendum — mp4/webm of a registered video-comp render through
|
|
1020
|
+
// renderMediaOnWeb, which produces real audio (Remotion owns the
|
|
1021
|
+
// TransitionSeries/volume-closure timeline). gif has no audio track at all
|
|
1022
|
+
// (format limitation, not a toggle), so this only applies to mp4/webm.
|
|
1023
|
+
const [audio, setAudio] = useState(true);
|
|
805
1024
|
const [busy, setBusy] = useState(false);
|
|
806
1025
|
const [status, setStatus] = useState(null); // { ok, msg }
|
|
807
1026
|
const [recent, setRecent] = useState([]);
|
|
@@ -847,7 +1066,18 @@ function ExportDialog({ mode, initialScope, activePath, onClose }) {
|
|
|
847
1066
|
}
|
|
848
1067
|
setBusy(true);
|
|
849
1068
|
setStatus(null);
|
|
850
|
-
|
|
1069
|
+
// `scale` drives PNG size AND video resolution (deviceScaleFactor → encoder
|
|
1070
|
+
// dims); temporal formats were previously fixed at the tiny native size.
|
|
1071
|
+
const options = card.format === 'png' || card.temporal ? { scale } : {};
|
|
1072
|
+
// Export-with-audio (DDR-148 addendum) — mp4/webm only; gif is silent by
|
|
1073
|
+
// format, so the checkbox never renders for it (see hasAudioToggle below).
|
|
1074
|
+
if (card.format === 'mp4' || card.format === 'webm') options.audio = audio;
|
|
1075
|
+
// Scope targeting hints (resolveScope reads these): `artboardId` makes
|
|
1076
|
+
// "Active artboard" export the right screen instead of `:first-of-type`;
|
|
1077
|
+
// `selection` makes "Current selection" export the selected element. Mirrors
|
|
1078
|
+
// the in-canvas dialog's captureScopeHints.
|
|
1079
|
+
if (activeArtboardId) options.artboardId = activeArtboardId;
|
|
1080
|
+
if (selection?.selector) options.selection = selection;
|
|
851
1081
|
try {
|
|
852
1082
|
const r = await fetch('/_api/export', {
|
|
853
1083
|
method: 'POST',
|
|
@@ -863,16 +1093,30 @@ function ExportDialog({ mode, initialScope, activePath, onClose }) {
|
|
|
863
1093
|
const fn = /filename="([^"]+)"/.exec(disp);
|
|
864
1094
|
const filename = (fn && fn[1]) || `export.${card.format}`;
|
|
865
1095
|
const blob = await r.blob();
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
1096
|
+
if (isNativeApp()) {
|
|
1097
|
+
// Native: WKWebView swallows the `<a download>` blob (the file lands in
|
|
1098
|
+
// an unknown default with no prompt). Route through a real OS save dialog
|
|
1099
|
+
// so the export OFFERS a location. Cancel → soft no-op.
|
|
1100
|
+
const bytes = Array.from(new Uint8Array(await blob.arrayBuffer()));
|
|
1101
|
+
const savedPath = await saveExport(filename, bytes);
|
|
1102
|
+
if (savedPath) {
|
|
1103
|
+
setStatus({ ok: true, msg: `Saved to ${savedPath}` });
|
|
1104
|
+
loadRecent();
|
|
1105
|
+
} else {
|
|
1106
|
+
setStatus({ ok: true, msg: 'Save cancelled' });
|
|
1107
|
+
}
|
|
1108
|
+
} else {
|
|
1109
|
+
const url = URL.createObjectURL(blob);
|
|
1110
|
+
const a = document.createElement('a');
|
|
1111
|
+
a.href = url;
|
|
1112
|
+
a.download = filename;
|
|
1113
|
+
document.body.appendChild(a);
|
|
1114
|
+
a.click();
|
|
1115
|
+
a.remove();
|
|
1116
|
+
URL.revokeObjectURL(url);
|
|
1117
|
+
setStatus({ ok: true, msg: `Exported ${filename}` });
|
|
1118
|
+
loadRecent();
|
|
1119
|
+
}
|
|
876
1120
|
} catch (err) {
|
|
877
1121
|
setStatus({ ok: false, msg: err && err.message ? err.message : String(err) });
|
|
878
1122
|
}
|
|
@@ -901,7 +1145,7 @@ function ExportDialog({ mode, initialScope, activePath, onClose }) {
|
|
|
901
1145
|
: 'Format'}
|
|
902
1146
|
</div>
|
|
903
1147
|
<div className="st-fmt-grid">
|
|
904
|
-
{EXPORT_CARDS.map((c) => (
|
|
1148
|
+
{EXPORT_CARDS.filter((c) => !c.temporal || hasComps).map((c) => (
|
|
905
1149
|
<button
|
|
906
1150
|
type="button"
|
|
907
1151
|
key={c.id}
|
|
@@ -936,10 +1180,10 @@ function ExportDialog({ mode, initialScope, activePath, onClose }) {
|
|
|
936
1180
|
</select>
|
|
937
1181
|
</div>
|
|
938
1182
|
)}
|
|
939
|
-
{!card.handoff && card.format === 'png' && (
|
|
1183
|
+
{!card.handoff && (card.format === 'png' || card.temporal) && (
|
|
940
1184
|
<div className="st-dialog-row">
|
|
941
1185
|
<label className="st-dialog-lbl" htmlFor="st-export-size">
|
|
942
|
-
Size
|
|
1186
|
+
{card.temporal ? 'Resolution' : 'Size'}
|
|
943
1187
|
</label>
|
|
944
1188
|
<select
|
|
945
1189
|
id="st-export-size"
|
|
@@ -960,6 +1204,28 @@ function ExportDialog({ mode, initialScope, activePath, onClose }) {
|
|
|
960
1204
|
Resolution multiplier — {scale}× ≈ {1440 * scale}×{900 * scale} for a 1440×900 artboard.
|
|
961
1205
|
</div>
|
|
962
1206
|
)}
|
|
1207
|
+
{!card.handoff && card.temporal && (
|
|
1208
|
+
<div className="st-mono" style={{ fontSize: 11, color: 'var(--fg-3)' }}>
|
|
1209
|
+
{scale}× the artboard's native resolution (e.g. 960×540 → {960 * scale}×{540 * scale}).
|
|
1210
|
+
</div>
|
|
1211
|
+
)}
|
|
1212
|
+
{!card.handoff && (card.format === 'mp4' || card.format === 'webm') && (
|
|
1213
|
+
<div className="st-dialog-row">
|
|
1214
|
+
<label
|
|
1215
|
+
className="st-dialog-lbl"
|
|
1216
|
+
htmlFor="st-export-audio"
|
|
1217
|
+
style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer' }}
|
|
1218
|
+
>
|
|
1219
|
+
<input
|
|
1220
|
+
id="st-export-audio"
|
|
1221
|
+
type="checkbox"
|
|
1222
|
+
checked={audio}
|
|
1223
|
+
onChange={(e) => setAudio(e.target.checked)}
|
|
1224
|
+
/>
|
|
1225
|
+
Export with audio
|
|
1226
|
+
</label>
|
|
1227
|
+
</div>
|
|
1228
|
+
)}
|
|
963
1229
|
{card.handoff && (
|
|
964
1230
|
<div className="callout callout--info" style={{ fontSize: 12 }}>
|
|
965
1231
|
Hands the active canvas off to production. Copies{' '}
|
|
@@ -2340,6 +2606,9 @@ function FileDropdown({ onAction, onClose, hasCanvas }) {
|
|
|
2340
2606
|
items={[
|
|
2341
2607
|
// Bare N — the browser reserves ⌘N (New Window) and never delivers it.
|
|
2342
2608
|
{ id: 'new', label: 'New canvas…', shortcut: 'N' },
|
|
2609
|
+
// DDR-150 P4 Task 12 — one-click "udělej z toho video" from the clips
|
|
2610
|
+
// dropped as reference chips on the active canvas.
|
|
2611
|
+
{ id: 'assemble', label: 'Assemble dropped clips → video', disabled: !hasCanvas },
|
|
2343
2612
|
{ id: 'export', label: 'Export…', shortcut: '⇧⌘E' },
|
|
2344
2613
|
{ id: 'handoff', label: 'Handoff to production', shortcut: '⇧⌘H' },
|
|
2345
2614
|
{ sep: true },
|
|
@@ -2350,7 +2619,7 @@ function FileDropdown({ onAction, onClose, hasCanvas }) {
|
|
|
2350
2619
|
);
|
|
2351
2620
|
}
|
|
2352
2621
|
|
|
2353
|
-
function EditDropdown({ onAction, onClose }) {
|
|
2622
|
+
function EditDropdown({ onAction, onClose, hasCanvas }) {
|
|
2354
2623
|
return (
|
|
2355
2624
|
<DropdownMenu
|
|
2356
2625
|
label="Edit"
|
|
@@ -2363,6 +2632,17 @@ function EditDropdown({ onAction, onClose }) {
|
|
|
2363
2632
|
{ sep: true },
|
|
2364
2633
|
{ id: 'deselect-all', label: 'Deselect all', shortcut: 'Esc' },
|
|
2365
2634
|
{ id: 'select-all-annotations', label: 'Select all annotations', shortcut: '⇧⌘A' },
|
|
2635
|
+
// Stage I4 — insert an empty artboard from a device-size preset into the
|
|
2636
|
+
// active canvas. Only meaningful with a canvas open.
|
|
2637
|
+
...(hasCanvas
|
|
2638
|
+
? [
|
|
2639
|
+
{ sep: true },
|
|
2640
|
+
{ id: 'new-artboard:desktop', label: 'New artboard: Desktop' },
|
|
2641
|
+
{ id: 'new-artboard:laptop', label: 'New artboard: Laptop' },
|
|
2642
|
+
{ id: 'new-artboard:tablet', label: 'New artboard: Tablet' },
|
|
2643
|
+
{ id: 'new-artboard:mobile', label: 'New artboard: Mobile' },
|
|
2644
|
+
]
|
|
2645
|
+
: []),
|
|
2366
2646
|
]}
|
|
2367
2647
|
/>
|
|
2368
2648
|
);
|
|
@@ -2405,15 +2685,22 @@ function Menubar({
|
|
|
2405
2685
|
inspectorOpen,
|
|
2406
2686
|
inspectorTab,
|
|
2407
2687
|
onToggleInspector,
|
|
2688
|
+
autoOpenInspector,
|
|
2689
|
+
onToggleAutoOpenInspector,
|
|
2408
2690
|
onOpenLayers,
|
|
2691
|
+
timelineOpen,
|
|
2692
|
+
onToggleTimeline,
|
|
2693
|
+
hasComps = false,
|
|
2409
2694
|
assistantOpen,
|
|
2410
2695
|
onToggleAssistant,
|
|
2411
2696
|
assistantBusy,
|
|
2412
2697
|
assistantUnseen,
|
|
2413
2698
|
onNewCanvas,
|
|
2699
|
+
onAssembleVideo,
|
|
2414
2700
|
onOpenExport,
|
|
2415
2701
|
onReload,
|
|
2416
2702
|
onCloseCanvas,
|
|
2703
|
+
onInsertArtboard,
|
|
2417
2704
|
}) {
|
|
2418
2705
|
const isSystem = activePath === SYSTEM_TAB;
|
|
2419
2706
|
const stamp = isSystem ? 'SYSTEM' : activePath ? 'CANVAS' : 'IDLE';
|
|
@@ -2464,6 +2751,22 @@ function Menubar({
|
|
|
2464
2751
|
checked: inspectorOpen,
|
|
2465
2752
|
disabled: false,
|
|
2466
2753
|
},
|
|
2754
|
+
{
|
|
2755
|
+
id: 'autoopen',
|
|
2756
|
+
label: 'Auto-open Inspector on select',
|
|
2757
|
+
shortcut: '',
|
|
2758
|
+
checked: !!autoOpenInspector,
|
|
2759
|
+
disabled: false,
|
|
2760
|
+
},
|
|
2761
|
+
// DDR-148 — Timeline (video-comp scrub). Phase-tag hints when the active
|
|
2762
|
+
// canvas actually has a comp; the panel itself shows an empty state otherwise.
|
|
2763
|
+
{
|
|
2764
|
+
id: 'timeline',
|
|
2765
|
+
label: 'Timeline',
|
|
2766
|
+
phase: hasComps ? 'video' : undefined,
|
|
2767
|
+
checked: timelineOpen,
|
|
2768
|
+
disabled: false,
|
|
2769
|
+
},
|
|
2467
2770
|
// Phase 31 (DDR-123) — native-only ACP chat sidepanel.
|
|
2468
2771
|
...(isNativeApp()
|
|
2469
2772
|
? [
|
|
@@ -2598,6 +2901,7 @@ function Menubar({
|
|
|
2598
2901
|
hasCanvas={!!activePath}
|
|
2599
2902
|
onAction={(id) => {
|
|
2600
2903
|
if (id === 'new') onNewCanvas?.();
|
|
2904
|
+
else if (id === 'assemble') onAssembleVideo?.();
|
|
2601
2905
|
else if (id === 'export') onOpenExport?.('export');
|
|
2602
2906
|
else if (id === 'handoff') onOpenExport?.('handoff');
|
|
2603
2907
|
else if (id === 'reload') onReload?.();
|
|
@@ -2608,12 +2912,15 @@ function Menubar({
|
|
|
2608
2912
|
)}
|
|
2609
2913
|
{openMenu === 'edit' && (
|
|
2610
2914
|
<EditDropdown
|
|
2915
|
+
hasCanvas={!!activePath && !isSystem}
|
|
2611
2916
|
onAction={(id) => {
|
|
2612
2917
|
if (id === 'undo') postToActiveCanvas({ dgn: 'undo' });
|
|
2613
2918
|
else if (id === 'redo') postToActiveCanvas({ dgn: 'redo' });
|
|
2614
2919
|
else if (id === 'deselect-all') postToActiveCanvas({ dgn: 'selection-clear' });
|
|
2615
2920
|
else if (id === 'select-all-annotations')
|
|
2616
2921
|
postToActiveCanvas({ dgn: 'annotation-select-all' });
|
|
2922
|
+
else if (id.startsWith('new-artboard:'))
|
|
2923
|
+
onInsertArtboard?.(id.slice('new-artboard:'.length));
|
|
2617
2924
|
}}
|
|
2618
2925
|
onClose={() => setOpenMenu(null)}
|
|
2619
2926
|
/>
|
|
@@ -2628,6 +2935,8 @@ function Menubar({
|
|
|
2628
2935
|
else if (id === 'hidden') onToggleShowHidden();
|
|
2629
2936
|
else if (id === 'annotate') onToggleAnnotations();
|
|
2630
2937
|
else if (id === 'inspector') onToggleInspector();
|
|
2938
|
+
else if (id === 'autoopen') onToggleAutoOpenInspector?.();
|
|
2939
|
+
else if (id === 'timeline') onToggleTimeline?.();
|
|
2631
2940
|
else if (id === 'assistant') onToggleAssistant?.();
|
|
2632
2941
|
else if (id === 'layers') onOpenLayers?.();
|
|
2633
2942
|
else if (id === 'minimap') onToggleMinimap?.();
|
|
@@ -3515,7 +3824,10 @@ function SyncBanner({ status }) {
|
|
|
3515
3824
|
|
|
3516
3825
|
const CSS_DISPLAYS = ['block', 'inline-block', 'flex', 'inline-flex', 'grid', 'inline', 'none'];
|
|
3517
3826
|
const CSS_FLEX_DIR = ['row', 'row-reverse', 'column', 'column-reverse'];
|
|
3827
|
+
const CSS_FLEX_WRAP = ['nowrap', 'wrap', 'wrap-reverse'];
|
|
3518
3828
|
const CSS_ALIGN = ['stretch', 'flex-start', 'center', 'flex-end', 'baseline'];
|
|
3829
|
+
// Stage M — flex-CHILD align-self (adds `auto` to the container align-items set).
|
|
3830
|
+
const CSS_ALIGN_SELF = ['auto', 'stretch', 'flex-start', 'center', 'flex-end', 'baseline'];
|
|
3519
3831
|
const CSS_JUSTIFY = [
|
|
3520
3832
|
'flex-start',
|
|
3521
3833
|
'center',
|
|
@@ -3554,6 +3866,27 @@ const PROP_LEAD = {
|
|
|
3554
3866
|
opacity: { icon: 'p-opacity' },
|
|
3555
3867
|
};
|
|
3556
3868
|
const CSS_ALIGN_OPTS = ['left', 'center', 'right', 'justify'];
|
|
3869
|
+
// feature-element-editing-robustness Stage B — enum option lists for the promoted
|
|
3870
|
+
// DDR-104 OUT-list knobs (Position / Typography extras / Media framing).
|
|
3871
|
+
const CSS_POSITION = ['static', 'relative', 'absolute', 'fixed', 'sticky'];
|
|
3872
|
+
const CSS_FONT_STYLE = ['normal', 'italic', 'oblique'];
|
|
3873
|
+
const CSS_TEXT_TRANSFORM = ['none', 'uppercase', 'lowercase', 'capitalize'];
|
|
3874
|
+
const CSS_TEXT_DECORATION = ['none', 'underline', 'line-through', 'overline'];
|
|
3875
|
+
const CSS_WHITE_SPACE = ['normal', 'nowrap', 'pre', 'pre-wrap', 'pre-line', 'break-spaces'];
|
|
3876
|
+
const CSS_OBJECT_FIT = ['fill', 'contain', 'cover', 'none', 'scale-down'];
|
|
3877
|
+
const CSS_OVERFLOW = ['visible', 'hidden', 'auto', 'scroll'];
|
|
3878
|
+
// Common aspect ratios for the Media dropdown (dogfood request — a select, not a
|
|
3879
|
+
// free-text field). Canonical spaced form so a dropdown-set value round-trips.
|
|
3880
|
+
const CSS_ASPECT_RATIO = ['auto', '1 / 1', '4 / 3', '3 / 2', '16 / 9', '21 / 9', '3 / 4', '2 / 3', '9 / 16'];
|
|
3881
|
+
|
|
3882
|
+
// feature-element-editing-robustness Stage I4 — device presets for the "New
|
|
3883
|
+
// artboard" menu (inserts an empty <DCArtboard> of these dims into the canvas).
|
|
3884
|
+
const SCREEN_PRESETS = {
|
|
3885
|
+
desktop: { label: 'Desktop', width: 1440, height: 1024 },
|
|
3886
|
+
laptop: { label: 'Laptop', width: 1280, height: 800 },
|
|
3887
|
+
tablet: { label: 'Tablet', width: 834, height: 1194 },
|
|
3888
|
+
mobile: { label: 'Mobile', width: 390, height: 844 },
|
|
3889
|
+
};
|
|
3557
3890
|
|
|
3558
3891
|
let _cssColorCtx = null;
|
|
3559
3892
|
// Normalize any CSS color string to #rrggbb for the native color input via a
|
|
@@ -4156,7 +4489,7 @@ function TokenPopover({ kind, groups, current, onPick, label, swatchBg, seedHex,
|
|
|
4156
4489
|
);
|
|
4157
4490
|
}
|
|
4158
4491
|
|
|
4159
|
-
function CssKnobs({ el, cfg, onOptimistic, onRecordEdit, onUndoRedo }) {
|
|
4492
|
+
function CssKnobs({ el, cfg, onOptimistic, onRecordEdit, onReplaceMedia, onUndoRedo }) {
|
|
4160
4493
|
const editable = !!el.id;
|
|
4161
4494
|
const computed = el.computed || {};
|
|
4162
4495
|
// Phase 12.3 — optimistic local overlay over the selection's authored / custom
|
|
@@ -4200,9 +4533,11 @@ function CssKnobs({ el, cfg, onOptimistic, onRecordEdit, onUndoRedo }) {
|
|
|
4200
4533
|
const [status, setStatus] = useState({});
|
|
4201
4534
|
const [open, setOpen] = useState({
|
|
4202
4535
|
Layout: true,
|
|
4536
|
+
Position: true,
|
|
4203
4537
|
Typography: true,
|
|
4204
4538
|
Spacing: true,
|
|
4205
4539
|
Size: true,
|
|
4540
|
+
Media: true,
|
|
4206
4541
|
Appearance: true,
|
|
4207
4542
|
Advanced: false,
|
|
4208
4543
|
});
|
|
@@ -4316,6 +4651,48 @@ function CssKnobs({ el, cfg, onOptimistic, onRecordEdit, onUndoRedo }) {
|
|
|
4316
4651
|
post('/_api/edit-attr', { canvas: el.file, id: el.id, attr, reset: true }, `@${attr}`);
|
|
4317
4652
|
record('attr', attr, before, null);
|
|
4318
4653
|
};
|
|
4654
|
+
// Stage M1 — apply a Fixed / Hug / Fill sizing mode to one axis. The pure
|
|
4655
|
+
// `sizingModePatch` returns the exact writes (context-aware Fill: flex main axis
|
|
4656
|
+
// → flex-grow, cross axis → align-self, block/grid → 100%) + the fill-props to
|
|
4657
|
+
// clear. Each ride the same commit/reset lanes (per-prop edit-css + undo record);
|
|
4658
|
+
// the server's per-file lock serializes them, so the resulting box is coherent.
|
|
4659
|
+
const parentLayout = { display: el.parentDisplay, flexDirection: el.parentFlexDirection };
|
|
4660
|
+
const applySizing = (axis, mode) => {
|
|
4661
|
+
if (!editable) return;
|
|
4662
|
+
const px = Math.round((axis === 'width' ? el.bounds?.w : el.bounds?.h) || 0);
|
|
4663
|
+
const patch = sizingModePatch(axis, mode, parentLayout, px);
|
|
4664
|
+
for (const p of patch.reset) if (authored[p]) reset(p);
|
|
4665
|
+
for (const [prop, value] of patch.set) commit(prop, value);
|
|
4666
|
+
};
|
|
4667
|
+
const parentIsFlexChild =
|
|
4668
|
+
el.parentDisplay === 'flex' || el.parentDisplay === 'inline-flex';
|
|
4669
|
+
const sizeModeSeg = (axis) => {
|
|
4670
|
+
const cur = sizingModeOf(axis, authored, computed, parentLayout);
|
|
4671
|
+
return (
|
|
4672
|
+
<div className="st-cp-modeseg" role="group" aria-label={`${axis} sizing mode`}>
|
|
4673
|
+
<span className="st-cp-modeax" aria-hidden="true">
|
|
4674
|
+
{axis === 'width' ? 'W' : 'H'}
|
|
4675
|
+
</span>
|
|
4676
|
+
{[
|
|
4677
|
+
['fixed', 'Fixed'],
|
|
4678
|
+
['hug', 'Hug'],
|
|
4679
|
+
['fill', 'Fill'],
|
|
4680
|
+
].map(([m, label]) => (
|
|
4681
|
+
<button
|
|
4682
|
+
key={m}
|
|
4683
|
+
type="button"
|
|
4684
|
+
className={`st-cp-modebtn${cur === m ? ' is-active' : ''}`}
|
|
4685
|
+
aria-pressed={cur === m}
|
|
4686
|
+
disabled={!editable}
|
|
4687
|
+
onClick={() => applySizing(axis, m)}
|
|
4688
|
+
title={`${label} ${axis}`}
|
|
4689
|
+
>
|
|
4690
|
+
{label}
|
|
4691
|
+
</button>
|
|
4692
|
+
))}
|
|
4693
|
+
</div>
|
|
4694
|
+
);
|
|
4695
|
+
};
|
|
4319
4696
|
// Cmd+Z / Cmd+Shift+Z (or Cmd+Y) inside the inspector forwards to the canvas
|
|
4320
4697
|
// undo stack — Figma-parity: a property field reverts the last DOCUMENT edit,
|
|
4321
4698
|
// not field text. Without this, an edit committed with focus still in the
|
|
@@ -4474,7 +4851,9 @@ function CssKnobs({ el, cfg, onOptimistic, onRecordEdit, onUndoRedo }) {
|
|
|
4474
4851
|
|
|
4475
4852
|
// Props each section owns — drives the per-section "reset section" affordance.
|
|
4476
4853
|
const SECTION_PROPS = {
|
|
4477
|
-
Layout: ['display', 'flex-direction', 'align-items', 'justify-content', 'gap'],
|
|
4854
|
+
Layout: ['display', 'flex-direction', 'flex-wrap', 'align-items', 'justify-content', 'gap'],
|
|
4855
|
+
// feature-element-editing-robustness Stage B — promoted DDR-104 OUT-list.
|
|
4856
|
+
Position: ['position', 'top', 'right', 'bottom', 'left', 'z-index'],
|
|
4478
4857
|
Typography: [
|
|
4479
4858
|
'font-family',
|
|
4480
4859
|
'color',
|
|
@@ -4483,6 +4862,10 @@ function CssKnobs({ el, cfg, onOptimistic, onRecordEdit, onUndoRedo }) {
|
|
|
4483
4862
|
'line-height',
|
|
4484
4863
|
'letter-spacing',
|
|
4485
4864
|
'text-align',
|
|
4865
|
+
'font-style',
|
|
4866
|
+
'text-transform',
|
|
4867
|
+
'text-decoration',
|
|
4868
|
+
'white-space',
|
|
4486
4869
|
],
|
|
4487
4870
|
Spacing: [
|
|
4488
4871
|
'margin-top',
|
|
@@ -4494,7 +4877,22 @@ function CssKnobs({ el, cfg, onOptimistic, onRecordEdit, onUndoRedo }) {
|
|
|
4494
4877
|
'padding-bottom',
|
|
4495
4878
|
'padding-left',
|
|
4496
4879
|
],
|
|
4497
|
-
Size: [
|
|
4880
|
+
Size: [
|
|
4881
|
+
'width',
|
|
4882
|
+
'height',
|
|
4883
|
+
'min-width',
|
|
4884
|
+
'min-height',
|
|
4885
|
+
'max-width',
|
|
4886
|
+
'max-height',
|
|
4887
|
+
'overflow',
|
|
4888
|
+
// Stage M — flex-child sizing props (written by the Fill mode + shown as rows
|
|
4889
|
+
// when the parent is flex). Included so a section-reset clears them too.
|
|
4890
|
+
'flex-grow',
|
|
4891
|
+
'flex-shrink',
|
|
4892
|
+
'flex-basis',
|
|
4893
|
+
'align-self',
|
|
4894
|
+
],
|
|
4895
|
+
Media: ['object-fit', 'aspect-ratio', 'object-position'],
|
|
4498
4896
|
Appearance: [
|
|
4499
4897
|
'background-color',
|
|
4500
4898
|
'border-radius',
|
|
@@ -4507,6 +4905,8 @@ function CssKnobs({ el, cfg, onOptimistic, onRecordEdit, onUndoRedo }) {
|
|
|
4507
4905
|
'border-color',
|
|
4508
4906
|
'box-shadow',
|
|
4509
4907
|
'opacity',
|
|
4908
|
+
'transform',
|
|
4909
|
+
'transform-origin',
|
|
4510
4910
|
],
|
|
4511
4911
|
};
|
|
4512
4912
|
const resetSection = (name) => {
|
|
@@ -4742,6 +5142,41 @@ function CssKnobs({ el, cfg, onOptimistic, onRecordEdit, onUndoRedo }) {
|
|
|
4742
5142
|
);
|
|
4743
5143
|
};
|
|
4744
5144
|
|
|
5145
|
+
// feature-element-editing-robustness Stage B (Task B3) — a position INSET side
|
|
5146
|
+
// (top/right/bottom/left). Mirrors `side()` but for the bare inset longhands
|
|
5147
|
+
// (no group prefix), allowing NEGATIVE values and an `auto` default, and reuses
|
|
5148
|
+
// the same box-model scrub grammar: alt = the axis pair, alt+shift = all four.
|
|
5149
|
+
const inset = (prop) => {
|
|
5150
|
+
const a = authored[prop];
|
|
5151
|
+
const shown =
|
|
5152
|
+
a != null && a !== '' && a !== 'auto'
|
|
5153
|
+
? cssSplitUnit(a).n || a
|
|
5154
|
+
: cssSplitUnit(cssHint(computed[prop]) ?? '').n || '';
|
|
5155
|
+
const isZero = !a || a === 'auto' || a === '0' || a === '0px';
|
|
5156
|
+
const pair = prop === 'top' || prop === 'bottom' ? ['top', 'bottom'] : ['left', 'right'];
|
|
5157
|
+
const all = ['top', 'right', 'bottom', 'left'];
|
|
5158
|
+
return (
|
|
5159
|
+
<input
|
|
5160
|
+
className={`st-cp-boxv st-cp-scrub st-cp-boxv--i${prop[0]}${isZero ? ' is-zero' : ''}`}
|
|
5161
|
+
key={`${prop}:${a ?? ''}`}
|
|
5162
|
+
aria-label={prop}
|
|
5163
|
+
defaultValue={shown}
|
|
5164
|
+
placeholder="auto"
|
|
5165
|
+
title="drag to scrub · alt = axis pair · alt+shift = all sides · type auto"
|
|
5166
|
+
onPointerDown={makeScrub(prop, { sides: { pair, all }, min: -Infinity })}
|
|
5167
|
+
onKeyDown={(e) => {
|
|
5168
|
+
if (e.key === 'Enter') e.currentTarget.blur();
|
|
5169
|
+
}}
|
|
5170
|
+
onBlur={(e) => {
|
|
5171
|
+
const raw = e.currentTarget.value.trim();
|
|
5172
|
+
if (!raw) return;
|
|
5173
|
+
const val = /[a-z%]/i.test(raw) ? raw : `${raw}px`;
|
|
5174
|
+
commit(prop, val);
|
|
5175
|
+
}}
|
|
5176
|
+
/>
|
|
5177
|
+
);
|
|
5178
|
+
};
|
|
5179
|
+
|
|
4745
5180
|
const corner = (label, prop) => (
|
|
4746
5181
|
<label className="st-cp-cornerf">
|
|
4747
5182
|
<span>{label}</span>
|
|
@@ -4778,12 +5213,66 @@ function CssKnobs({ el, cfg, onOptimistic, onRecordEdit, onUndoRedo }) {
|
|
|
4778
5213
|
|
|
4779
5214
|
{sec(
|
|
4780
5215
|
'Layout',
|
|
5216
|
+
(() => {
|
|
5217
|
+
// Stage M2 — auto-layout editor. Present the flex vocabulary (Direction ·
|
|
5218
|
+
// Wrap · Distribution · Align · Gap) only when the element IS a flex/grid
|
|
5219
|
+
// container, so a plain block doesn't carry knobs that do nothing; a
|
|
5220
|
+
// non-container gets a one-click "make it a flex layout" instead (the
|
|
5221
|
+
// DDR-104 gap-degrades-gracefully precedent). align-items / justify-content
|
|
5222
|
+
// / gap apply to grid too; flex-direction / flex-wrap are flex-only.
|
|
5223
|
+
const disp = (authored.display || cssHint(computed.display) || '').trim();
|
|
5224
|
+
const isFlex = disp === 'flex' || disp === 'inline-flex';
|
|
5225
|
+
const isGrid = disp === 'grid' || disp === 'inline-grid';
|
|
5226
|
+
return (
|
|
5227
|
+
<>
|
|
5228
|
+
{row('display', csel('display', CSS_DISPLAYS))}
|
|
5229
|
+
{isFlex ? (
|
|
5230
|
+
<>
|
|
5231
|
+
{row('flex-direction', csel('flex-direction', CSS_FLEX_DIR))}
|
|
5232
|
+
{row('flex-wrap', csel('flex-wrap', CSS_FLEX_WRAP))}
|
|
5233
|
+
</>
|
|
5234
|
+
) : null}
|
|
5235
|
+
{isFlex || isGrid ? (
|
|
5236
|
+
<>
|
|
5237
|
+
{row('align-items', csel('align-items', CSS_ALIGN))}
|
|
5238
|
+
{row('justify-content', csel('justify-content', CSS_JUSTIFY))}
|
|
5239
|
+
{row('gap', num('gap', 'space'))}
|
|
5240
|
+
</>
|
|
5241
|
+
) : (
|
|
5242
|
+
<button
|
|
5243
|
+
type="button"
|
|
5244
|
+
className="st-cp-makeflex"
|
|
5245
|
+
disabled={!editable}
|
|
5246
|
+
onClick={() => commit('display', 'flex')}
|
|
5247
|
+
>
|
|
5248
|
+
+ Auto layout (flex)
|
|
5249
|
+
</button>
|
|
5250
|
+
)}
|
|
5251
|
+
</>
|
|
5252
|
+
);
|
|
5253
|
+
})()
|
|
5254
|
+
)}
|
|
5255
|
+
|
|
5256
|
+
{sec(
|
|
5257
|
+
'Position',
|
|
4781
5258
|
<>
|
|
4782
|
-
{row('
|
|
4783
|
-
|
|
4784
|
-
|
|
4785
|
-
|
|
4786
|
-
|
|
5259
|
+
{row('position', csel('position', CSS_POSITION))}
|
|
5260
|
+
<div className="st-cp-box st-cp-box--inset" aria-label="position inset (top / right / bottom / left)">
|
|
5261
|
+
<span className="st-cp-boxtag st-cp-boxtag--i">{prov(provOf('top'))}inset</span>
|
|
5262
|
+
{inset('top')}
|
|
5263
|
+
{inset('right')}
|
|
5264
|
+
{inset('bottom')}
|
|
5265
|
+
{inset('left')}
|
|
5266
|
+
<div className="st-cp-boxcore st-cp-boxcore--pos">
|
|
5267
|
+
{authored.position || cssHint(computed.position) || 'static'}
|
|
5268
|
+
</div>
|
|
5269
|
+
</div>
|
|
5270
|
+
{(authored.position || cssHint(computed.position) || 'static') === 'static' ? (
|
|
5271
|
+
<div className="st-cp-note">
|
|
5272
|
+
top / right / bottom / left apply once position is relative, absolute, fixed, or sticky
|
|
5273
|
+
</div>
|
|
5274
|
+
) : null}
|
|
5275
|
+
{row('z-index', num('z-index'))}
|
|
4787
5276
|
</>
|
|
4788
5277
|
)}
|
|
4789
5278
|
|
|
@@ -4817,6 +5306,11 @@ function CssKnobs({ el, cfg, onOptimistic, onRecordEdit, onUndoRedo }) {
|
|
|
4817
5306
|
))}
|
|
4818
5307
|
</div>
|
|
4819
5308
|
)}
|
|
5309
|
+
{/* Stage B (Task B4) — promoted typography knobs (was DDR-104 OUT-list). */}
|
|
5310
|
+
{row('font-style', csel('font-style', CSS_FONT_STYLE))}
|
|
5311
|
+
{row('text-transform', csel('text-transform', CSS_TEXT_TRANSFORM))}
|
|
5312
|
+
{row('text-decoration', csel('text-decoration', CSS_TEXT_DECORATION))}
|
|
5313
|
+
{row('white-space', csel('white-space', CSS_WHITE_SPACE))}
|
|
4820
5314
|
</>
|
|
4821
5315
|
)}
|
|
4822
5316
|
|
|
@@ -4850,12 +5344,103 @@ function CssKnobs({ el, cfg, onOptimistic, onRecordEdit, onUndoRedo }) {
|
|
|
4850
5344
|
{sec(
|
|
4851
5345
|
'Size',
|
|
4852
5346
|
<>
|
|
5347
|
+
{/* Stage M1 — per-axis Fixed / Hug / Fill sizing mode (Figma parity). The
|
|
5348
|
+
Fixed case leaves the numeric width/height knobs below in control. */}
|
|
5349
|
+
<div className="st-cp-modes">
|
|
5350
|
+
{sizeModeSeg('width')}
|
|
5351
|
+
{sizeModeSeg('height')}
|
|
5352
|
+
</div>
|
|
4853
5353
|
{row('width', num('width'))}
|
|
4854
5354
|
{row('height', num('height'))}
|
|
5355
|
+
{row('min-width', num('min-width'))}
|
|
4855
5356
|
{row('max-width', num('max-width'))}
|
|
5357
|
+
{row('min-height', num('min-height'))}
|
|
5358
|
+
{row('max-height', num('max-height'))}
|
|
5359
|
+
{row('overflow', csel('overflow', CSS_OVERFLOW))}
|
|
5360
|
+
{/* Stage M1 — flex-CHILD controls, only meaningful when the parent is a
|
|
5361
|
+
flex container. align-self is the cross-axis override; flex-grow/shrink/
|
|
5362
|
+
basis are the main-axis behavior the Fill mode writes for you. */}
|
|
5363
|
+
{parentIsFlexChild ? (
|
|
5364
|
+
<>
|
|
5365
|
+
<div className="st-cp-subhd">In flex parent</div>
|
|
5366
|
+
{row('align-self', csel('align-self', CSS_ALIGN_SELF))}
|
|
5367
|
+
{row('flex-grow', num('flex-grow', null, { unitless: true }))}
|
|
5368
|
+
{row('flex-shrink', num('flex-shrink', null, { unitless: true }))}
|
|
5369
|
+
{row('flex-basis', num('flex-basis'))}
|
|
5370
|
+
</>
|
|
5371
|
+
) : null}
|
|
4856
5372
|
</>
|
|
4857
5373
|
)}
|
|
4858
5374
|
|
|
5375
|
+
{/* Stage B (Task B5) — Media framing. Rendered only for a media element
|
|
5376
|
+
(img / video / picture / svg / canvas) or a selection that already
|
|
5377
|
+
carries a framing prop, so a plain <div> doesn't grow object-fit rows.
|
|
5378
|
+
Media = box/framing/source (this plan); the photo-editor plan's "Photo"
|
|
5379
|
+
tab owns pixels/look — they are separate DOM slots by design. */}
|
|
5380
|
+
{(() => {
|
|
5381
|
+
const t = (el.tag || '').toLowerCase();
|
|
5382
|
+
const isMediaEl = t === 'img' || t === 'video' || t === 'picture' || t === 'svg' || t === 'canvas';
|
|
5383
|
+
const showMedia =
|
|
5384
|
+
isMediaEl ||
|
|
5385
|
+
!!authored['object-fit'] ||
|
|
5386
|
+
!!authored['object-position'] ||
|
|
5387
|
+
!!authored['aspect-ratio'];
|
|
5388
|
+
// Stage F2 — "Replace…" opens the AssetPicker to re-point src (authored
|
|
5389
|
+
// <img>/<video> only; a template-expression src can't be string-swapped,
|
|
5390
|
+
// so gate on a real src attr being present).
|
|
5391
|
+
const canReplace = (t === 'img' || t === 'video') && !!el.attrs?.src && !!onReplaceMedia;
|
|
5392
|
+
return showMedia
|
|
5393
|
+
? sec(
|
|
5394
|
+
'Media',
|
|
5395
|
+
<>
|
|
5396
|
+
{canReplace && (
|
|
5397
|
+
<div className="st-cp-mediabtn">
|
|
5398
|
+
<button
|
|
5399
|
+
type="button"
|
|
5400
|
+
className="st-btn st-cp-replace"
|
|
5401
|
+
onClick={() => onReplaceMedia(el)}
|
|
5402
|
+
>
|
|
5403
|
+
Replace…
|
|
5404
|
+
</button>
|
|
5405
|
+
</div>
|
|
5406
|
+
)}
|
|
5407
|
+
{row('object-fit', csel('object-fit', CSS_OBJECT_FIT))}
|
|
5408
|
+
{row('object-position', text('object-position'))}
|
|
5409
|
+
{row(
|
|
5410
|
+
'aspect-ratio',
|
|
5411
|
+
<select
|
|
5412
|
+
className="st-cp-nsel"
|
|
5413
|
+
aria-label="aspect-ratio"
|
|
5414
|
+
value={
|
|
5415
|
+
CSS_ASPECT_RATIO.includes(authored['aspect-ratio'])
|
|
5416
|
+
? authored['aspect-ratio']
|
|
5417
|
+
: ''
|
|
5418
|
+
}
|
|
5419
|
+
onChange={(e) => {
|
|
5420
|
+
const v = e.target.value;
|
|
5421
|
+
commit('aspect-ratio', v);
|
|
5422
|
+
// A fixed height overrides aspect-ratio (CSS: explicit
|
|
5423
|
+
// width+height win). When applying a real ratio, release the
|
|
5424
|
+
// height so the ratio actually reshapes the box (dogfood:
|
|
5425
|
+
// "nastavil jsem 16/9 a nic se nestalo").
|
|
5426
|
+
if (v && v !== 'auto' && authored.height) reset('height');
|
|
5427
|
+
}}
|
|
5428
|
+
>
|
|
5429
|
+
<option value="" disabled>
|
|
5430
|
+
{cssHint(computed['aspect-ratio']) || '—'}
|
|
5431
|
+
</option>
|
|
5432
|
+
{CSS_ASPECT_RATIO.map((v) => (
|
|
5433
|
+
<option key={v} value={v}>
|
|
5434
|
+
{v}
|
|
5435
|
+
</option>
|
|
5436
|
+
))}
|
|
5437
|
+
</select>
|
|
5438
|
+
)}
|
|
5439
|
+
</>
|
|
5440
|
+
)
|
|
5441
|
+
: null;
|
|
5442
|
+
})()}
|
|
5443
|
+
|
|
4859
5444
|
{sec(
|
|
4860
5445
|
'Appearance',
|
|
4861
5446
|
<>
|
|
@@ -4939,6 +5524,9 @@ function CssKnobs({ el, cfg, onOptimistic, onRecordEdit, onUndoRedo }) {
|
|
|
4939
5524
|
/>
|
|
4940
5525
|
</div>
|
|
4941
5526
|
)}
|
|
5527
|
+
{/* Stage B (Task B4) — transform as a free-value row (mirrors box-shadow). */}
|
|
5528
|
+
{row('transform', text('transform'))}
|
|
5529
|
+
{row('transform-origin', text('transform-origin'))}
|
|
4942
5530
|
</>
|
|
4943
5531
|
)}
|
|
4944
5532
|
|
|
@@ -5204,7 +5792,7 @@ function LayerRow({
|
|
|
5204
5792
|
selectedId,
|
|
5205
5793
|
selectedIndex,
|
|
5206
5794
|
collapsed,
|
|
5207
|
-
|
|
5795
|
+
hiddenOverride,
|
|
5208
5796
|
onToggle,
|
|
5209
5797
|
onSelect,
|
|
5210
5798
|
onHover,
|
|
@@ -5221,7 +5809,7 @@ function LayerRow({
|
|
|
5221
5809
|
// clones at once. Fall back to id-only when the selection carries no index.
|
|
5222
5810
|
const isSel =
|
|
5223
5811
|
node.id === selectedId && (selectedIndex == null || node.index === selectedIndex);
|
|
5224
|
-
const isHidden =
|
|
5812
|
+
const isHidden = hiddenOverride?.has(key) ? hiddenOverride.get(key) : !!node.hidden;
|
|
5225
5813
|
// A shared data-cd-id (reused component instance) IS reorderable now — the
|
|
5226
5814
|
// server maps the occurrence index to the parent <Component> usage — so these
|
|
5227
5815
|
// are no longer greyed/blocked. A `.map()`ed single-usage element still can't
|
|
@@ -5314,7 +5902,7 @@ function LayerRow({
|
|
|
5314
5902
|
selectedId={selectedId}
|
|
5315
5903
|
selectedIndex={selectedIndex}
|
|
5316
5904
|
collapsed={collapsed}
|
|
5317
|
-
|
|
5905
|
+
hiddenOverride={hiddenOverride}
|
|
5318
5906
|
onToggle={onToggle}
|
|
5319
5907
|
onSelect={onSelect}
|
|
5320
5908
|
onHover={onHover}
|
|
@@ -5394,10 +5982,123 @@ function InspectComputed({ el }) {
|
|
|
5394
5982
|
);
|
|
5395
5983
|
}
|
|
5396
5984
|
|
|
5985
|
+
// Dogfood 2026-07-07 — resolve the artboard id for a whole-artboard selection
|
|
5986
|
+
// defensively: prefer `el.artboardId` (set by `hoverTargetToSelection` for a
|
|
5987
|
+
// live chrome click), but fall back to parsing it out of `el.selector` — some
|
|
5988
|
+
// selection-construction paths (Layers-tree artboard row, a restored
|
|
5989
|
+
// `_active.json` selection) may not carry `artboardId` even though the
|
|
5990
|
+
// selector is always the `[data-dc-screen="…"]` chrome form for one.
|
|
5991
|
+
function resolveArtboardIdFromSelection(el) {
|
|
5992
|
+
if (el.artboardId) return el.artboardId;
|
|
5993
|
+
const m = /^\[data-dc-screen="([^"]+)"\]$/.exec(el.selector || '');
|
|
5994
|
+
return m ? m[1] : null;
|
|
5995
|
+
}
|
|
5996
|
+
|
|
5997
|
+
// Dogfood 2026-07-07 — the CSS tab showed everything disabled for a
|
|
5998
|
+
// whole-ARTBOARD selection (`CssKnobs`'s `editable = !!el.id`, and an artboard
|
|
5999
|
+
// chrome click has NO data-cd-id — DCArtboard doesn't forward it to the DOM).
|
|
6000
|
+
// A dedicated, MUCH smaller panel: exact width/height fields (writes via
|
|
6001
|
+
// /_api/resize-artboard, NOT edit-css — DDR-027 numeric JSX props) + the SAME
|
|
6002
|
+
// SCREEN_PRESETS the "+ Artboard" menu uses, so picking "Tablet" resizes the
|
|
6003
|
+
// CURRENT artboard to 834×1194 in one click instead of typing both fields.
|
|
6004
|
+
function ArtboardKnobs({ el, onResizeArtboard }) {
|
|
6005
|
+
const artboardId = resolveArtboardIdFromSelection(el);
|
|
6006
|
+
// Dogfood 2026-07-07 (round 2) — `worldW`/`worldH` (zoom-independent) are
|
|
6007
|
+
// undefined for a selection that reached here via a code path predating
|
|
6008
|
+
// that field (a canvas iframe that hasn't remounted since); fall back to
|
|
6009
|
+
// `bounds` (the SCREEN rect — always populated, but wrong at any zoom other
|
|
6010
|
+
// than 100%) so the fields show SOMETHING rather than sit empty. Self-heals
|
|
6011
|
+
// to the exact value the moment `worldW`/`worldH` are present.
|
|
6012
|
+
const w = Number.isFinite(el.worldW)
|
|
6013
|
+
? el.worldW
|
|
6014
|
+
: Number.isFinite(el.bounds?.w)
|
|
6015
|
+
? el.bounds.w
|
|
6016
|
+
: null;
|
|
6017
|
+
const h = Number.isFinite(el.worldH)
|
|
6018
|
+
? el.worldH
|
|
6019
|
+
: Number.isFinite(el.bounds?.h)
|
|
6020
|
+
? el.bounds.h
|
|
6021
|
+
: null;
|
|
6022
|
+
const commitSize = (width, height) => {
|
|
6023
|
+
if (!artboardId) return;
|
|
6024
|
+
const nw = Number.isFinite(width) && width > 0 ? Math.round(width) : undefined;
|
|
6025
|
+
const nh = Number.isFinite(height) && height > 0 ? Math.round(height) : undefined;
|
|
6026
|
+
if (nw == null && nh == null) return;
|
|
6027
|
+
onResizeArtboard?.(artboardId, nw, nh);
|
|
6028
|
+
};
|
|
6029
|
+
const activePreset = Object.entries(SCREEN_PRESETS).find(
|
|
6030
|
+
([, p]) => p.width === w && p.height === h
|
|
6031
|
+
)?.[0];
|
|
6032
|
+
return (
|
|
6033
|
+
<section className="st-cp-sec">
|
|
6034
|
+
<div className="st-cp-sechd-row">
|
|
6035
|
+
<span className="st-cp-sechd">Artboard</span>
|
|
6036
|
+
</div>
|
|
6037
|
+
<div style={{ display: 'flex', gap: 8, padding: '4px 12px' }}>
|
|
6038
|
+
<div className="st-cp-num">
|
|
6039
|
+
<span className="st-cp-numlead" aria-hidden="true">
|
|
6040
|
+
W
|
|
6041
|
+
</span>
|
|
6042
|
+
<input
|
|
6043
|
+
className="st-cp-numin"
|
|
6044
|
+
type="number"
|
|
6045
|
+
min="1"
|
|
6046
|
+
aria-label="artboard width"
|
|
6047
|
+
key={`w:${w ?? ''}`}
|
|
6048
|
+
defaultValue={w ?? ''}
|
|
6049
|
+
onKeyDown={(e) => {
|
|
6050
|
+
if (e.key === 'Enter') e.currentTarget.blur();
|
|
6051
|
+
}}
|
|
6052
|
+
onBlur={(e) => commitSize(Number.parseFloat(e.currentTarget.value), null)}
|
|
6053
|
+
/>
|
|
6054
|
+
</div>
|
|
6055
|
+
<div className="st-cp-num">
|
|
6056
|
+
<span className="st-cp-numlead" aria-hidden="true">
|
|
6057
|
+
H
|
|
6058
|
+
</span>
|
|
6059
|
+
<input
|
|
6060
|
+
className="st-cp-numin"
|
|
6061
|
+
type="number"
|
|
6062
|
+
min="1"
|
|
6063
|
+
aria-label="artboard height"
|
|
6064
|
+
key={`h:${h ?? ''}`}
|
|
6065
|
+
defaultValue={h ?? ''}
|
|
6066
|
+
onKeyDown={(e) => {
|
|
6067
|
+
if (e.key === 'Enter') e.currentTarget.blur();
|
|
6068
|
+
}}
|
|
6069
|
+
onBlur={(e) => commitSize(null, Number.parseFloat(e.currentTarget.value))}
|
|
6070
|
+
/>
|
|
6071
|
+
</div>
|
|
6072
|
+
</div>
|
|
6073
|
+
<div style={{ padding: '0 12px 8px' }}>
|
|
6074
|
+
<select
|
|
6075
|
+
className="st-cp-nsel"
|
|
6076
|
+
aria-label="artboard size preset"
|
|
6077
|
+
value={activePreset ?? ''}
|
|
6078
|
+
onChange={(e) => {
|
|
6079
|
+
const p = SCREEN_PRESETS[e.currentTarget.value];
|
|
6080
|
+
if (p) commitSize(p.width, p.height);
|
|
6081
|
+
}}
|
|
6082
|
+
>
|
|
6083
|
+
<option value="" disabled>
|
|
6084
|
+
{activePreset ? SCREEN_PRESETS[activePreset].label : 'Preset size…'}
|
|
6085
|
+
</option>
|
|
6086
|
+
{Object.entries(SCREEN_PRESETS).map(([key, p]) => (
|
|
6087
|
+
<option key={key} value={key}>
|
|
6088
|
+
{p.label} — {p.width}×{p.height}
|
|
6089
|
+
</option>
|
|
6090
|
+
))}
|
|
6091
|
+
</select>
|
|
6092
|
+
</div>
|
|
6093
|
+
</section>
|
|
6094
|
+
);
|
|
6095
|
+
}
|
|
6096
|
+
|
|
5397
6097
|
function InspectorPanel({
|
|
5398
6098
|
selected,
|
|
5399
6099
|
onClose,
|
|
5400
6100
|
layersTree,
|
|
6101
|
+
canvasFile,
|
|
5401
6102
|
onSelectLayer,
|
|
5402
6103
|
onHoverLayer,
|
|
5403
6104
|
onReorderLayer,
|
|
@@ -5405,7 +6106,10 @@ function InspectorPanel({
|
|
|
5405
6106
|
cfg,
|
|
5406
6107
|
onOptimistic,
|
|
5407
6108
|
onRecordEdit,
|
|
6109
|
+
onReplaceMedia,
|
|
6110
|
+
onResizeArtboard,
|
|
5408
6111
|
onUndoRedo,
|
|
6112
|
+
editScope,
|
|
5409
6113
|
tab: tabProp,
|
|
5410
6114
|
onTabChange,
|
|
5411
6115
|
width,
|
|
@@ -5422,9 +6126,18 @@ function InspectorPanel({
|
|
|
5422
6126
|
onTabChange?.(t);
|
|
5423
6127
|
};
|
|
5424
6128
|
const [collapsed, setCollapsed] = useState(() => new Set());
|
|
5425
|
-
// Phase 12.3 (W3.1) — per-layer visibility toggle.
|
|
5426
|
-
//
|
|
5427
|
-
|
|
6129
|
+
// Phase 12.3 (W3.1) — per-layer visibility toggle. Persists via /_api/edit-css
|
|
6130
|
+
// (property 'display', mirroring CssKnobs' commit/reset) and is undoable via
|
|
6131
|
+
// the same edit-source command (see the undo/redo coverage RCA:
|
|
6132
|
+
// .ai/logs/rca/issue-undo-redo-coverage-gaps.md). Keyed by `${id}:${index}`;
|
|
6133
|
+
// holds only OPTIMISTIC overrides (either direction) over the tree-reported
|
|
6134
|
+
// `node.hidden` (the authoritative source value) so a first render of an
|
|
6135
|
+
// already-hidden element shows the correct eye-icon state without a click.
|
|
6136
|
+
const [hiddenOverride, setHiddenOverride] = useState(() => new Map());
|
|
6137
|
+
const isNodeHidden = (node) => {
|
|
6138
|
+
const key = `${node.id}:${node.index}`;
|
|
6139
|
+
return hiddenOverride.has(key) ? hiddenOverride.get(key) : !!node.hidden;
|
|
6140
|
+
};
|
|
5428
6141
|
// Phase 12.1 (DDR-138) — drag-to-reorder state (lifted so every row sees the
|
|
5429
6142
|
// same drop target) + an aria-live announcement for keyboard moves.
|
|
5430
6143
|
const [dragState, setDragState] = useState(null);
|
|
@@ -5665,7 +6378,9 @@ function InspectorPanel({
|
|
|
5665
6378
|
});
|
|
5666
6379
|
const toggleVisibility = (node) => {
|
|
5667
6380
|
const key = `${node.id}:${node.index}`;
|
|
5668
|
-
const
|
|
6381
|
+
const wasHidden = isNodeHidden(node);
|
|
6382
|
+
const willHide = !wasHidden;
|
|
6383
|
+
setHiddenOverride((prev) => new Map(prev).set(key, willHide));
|
|
5669
6384
|
onOptimistic?.({
|
|
5670
6385
|
id: node.id,
|
|
5671
6386
|
artboardId: layersTree?.artboardId ?? null,
|
|
@@ -5673,11 +6388,26 @@ function InspectorPanel({
|
|
|
5673
6388
|
prop: 'display',
|
|
5674
6389
|
value: willHide ? 'none' : null,
|
|
5675
6390
|
});
|
|
5676
|
-
|
|
5677
|
-
|
|
5678
|
-
|
|
5679
|
-
|
|
5680
|
-
|
|
6391
|
+
if (!canvasFile || !node.id) return;
|
|
6392
|
+
fetch('/_api/edit-css', {
|
|
6393
|
+
method: 'POST',
|
|
6394
|
+
headers: { 'content-type': 'application/json' },
|
|
6395
|
+
body: JSON.stringify(
|
|
6396
|
+
willHide
|
|
6397
|
+
? { canvas: canvasFile, id: node.id, property: 'display', value: 'none' }
|
|
6398
|
+
: { canvas: canvasFile, id: node.id, property: 'display', reset: true }
|
|
6399
|
+
),
|
|
6400
|
+
}).catch(() => {});
|
|
6401
|
+
// Record onto the canvas undo stack (Cmd+Z), same as any other inline CSS
|
|
6402
|
+
// edit — fire-and-forget alongside the POST above, mirroring CssKnobs'
|
|
6403
|
+
// commit()/reset() (which don't gate the record on the fetch resolving).
|
|
6404
|
+
onRecordEdit?.({
|
|
6405
|
+
op: 'css',
|
|
6406
|
+
canvas: canvasFile,
|
|
6407
|
+
id: node.id,
|
|
6408
|
+
key: 'display',
|
|
6409
|
+
before: wasHidden ? 'none' : null,
|
|
6410
|
+
after: willHide ? 'none' : null,
|
|
5681
6411
|
});
|
|
5682
6412
|
};
|
|
5683
6413
|
// `selected` may be a single element, an array (multi-select), or null.
|
|
@@ -5714,6 +6444,30 @@ function InspectorPanel({
|
|
|
5714
6444
|
<StIcon name="x" size={14} />
|
|
5715
6445
|
</button>
|
|
5716
6446
|
</div>
|
|
6447
|
+
{/* Stage H (INV-3) — edit-scope strip: is an edit local to this element or
|
|
6448
|
+
shared across N rendered places? Visible across every tab so it's never
|
|
6449
|
+
a surprise. Only for a single element selection with a resolved verdict. */}
|
|
6450
|
+
{el?.id && !(Array.isArray(selected) && selected.length > 1) && editScope ? (
|
|
6451
|
+
<div
|
|
6452
|
+
className={`st-scope st-scope--${editScope.scope}`}
|
|
6453
|
+
title={
|
|
6454
|
+
editScope.scope === 'shared'
|
|
6455
|
+
? `Editing this element's style changes ${editScope.affects} place${
|
|
6456
|
+
editScope.affects === 1 ? '' : 's'
|
|
6457
|
+
}${
|
|
6458
|
+
editScope.componentName ? ` (component ${editScope.componentName})` : ''
|
|
6459
|
+
}. Move/resize a whole instance to keep it local.`
|
|
6460
|
+
: 'This edit affects only this element.'
|
|
6461
|
+
}
|
|
6462
|
+
>
|
|
6463
|
+
<span className="st-scope-dot" aria-hidden="true" />
|
|
6464
|
+
{editScope.scope === 'shared'
|
|
6465
|
+
? `Shared${editScope.componentName ? ` · ${editScope.componentName}` : ''} · edits ${
|
|
6466
|
+
editScope.affects
|
|
6467
|
+
} place${editScope.affects === 1 ? '' : 's'}`
|
|
6468
|
+
: 'Local · this element only'}
|
|
6469
|
+
</div>
|
|
6470
|
+
) : null}
|
|
5717
6471
|
<div className="st-rp-body">
|
|
5718
6472
|
{!el ? (
|
|
5719
6473
|
<div className="st-rp-empty">
|
|
@@ -5799,7 +6553,7 @@ function InspectorPanel({
|
|
|
5799
6553
|
selectedId={el.id}
|
|
5800
6554
|
selectedIndex={el.index}
|
|
5801
6555
|
collapsed={collapsed}
|
|
5802
|
-
|
|
6556
|
+
hiddenOverride={hiddenOverride}
|
|
5803
6557
|
onToggle={toggleCollapse}
|
|
5804
6558
|
onSelect={(node) => {
|
|
5805
6559
|
onSelectLayer?.(node);
|
|
@@ -5845,12 +6599,15 @@ function InspectorPanel({
|
|
|
5845
6599
|
</div>
|
|
5846
6600
|
)}
|
|
5847
6601
|
</>
|
|
6602
|
+
) : !el.id && resolveArtboardIdFromSelection(el) ? (
|
|
6603
|
+
<ArtboardKnobs el={el} onResizeArtboard={onResizeArtboard} />
|
|
5848
6604
|
) : (
|
|
5849
6605
|
<CssKnobs
|
|
5850
6606
|
el={el}
|
|
5851
6607
|
cfg={cfg}
|
|
5852
6608
|
onOptimistic={onOptimistic}
|
|
5853
6609
|
onRecordEdit={onRecordEdit}
|
|
6610
|
+
onReplaceMedia={onReplaceMedia}
|
|
5854
6611
|
onUndoRedo={onUndoRedo}
|
|
5855
6612
|
/>
|
|
5856
6613
|
)}
|
|
@@ -5874,6 +6631,32 @@ function App() {
|
|
|
5874
6631
|
useEffect(() => {
|
|
5875
6632
|
selectedRef.current = selected;
|
|
5876
6633
|
}, [selected]);
|
|
6634
|
+
|
|
6635
|
+
// Stage H (INV-3) — resolve the edit-scope (local vs shared component instance)
|
|
6636
|
+
// for the current single selection so the Inspector can show whether an edit
|
|
6637
|
+
// stays here or changes N places. Read-only GET, debounced by the selection id;
|
|
6638
|
+
// aborts a stale in-flight fetch when the selection changes. Fetched with
|
|
6639
|
+
// rendered=1 (source-usage-driven; the .map() refinement is a follow-up).
|
|
6640
|
+
const [editScope, setEditScope] = useState(null);
|
|
6641
|
+
useEffect(() => {
|
|
6642
|
+
const one = Array.isArray(selected) ? (selected.length === 1 ? selected[0] : null) : selected;
|
|
6643
|
+
const id = one && typeof one.id === 'string' ? one.id : null;
|
|
6644
|
+
if (!id || !activePath) {
|
|
6645
|
+
setEditScope(null);
|
|
6646
|
+
return;
|
|
6647
|
+
}
|
|
6648
|
+
const ac = new AbortController();
|
|
6649
|
+
const q = new URLSearchParams({ canvas: activePath, id });
|
|
6650
|
+
fetch(`/_api/edit-scope?${q}`, { signal: ac.signal })
|
|
6651
|
+
.then((r) => (r.ok ? r.json() : null))
|
|
6652
|
+
.then((j) => {
|
|
6653
|
+
if (j?.ok) setEditScope(j);
|
|
6654
|
+
})
|
|
6655
|
+
.catch(() => {
|
|
6656
|
+
/* aborted / offline — leave the last verdict, the badge just won't show */
|
|
6657
|
+
});
|
|
6658
|
+
return () => ac.abort();
|
|
6659
|
+
}, [selected, activePath]);
|
|
5877
6660
|
// feature-acp-context-hardening — halo re-apply retry ladder. A single
|
|
5878
6661
|
// select-by-id post races the fresh iframe: dgn:'loaded' fires from the
|
|
5879
6662
|
// inline inspector script at HTML-parse time, BEFORE the React canvas-shell
|
|
@@ -5918,6 +6701,14 @@ function App() {
|
|
|
5918
6701
|
// (same freshness idiom as selectedRef; avoids a render-time TDZ on the
|
|
5919
6702
|
// later useCallback).
|
|
5920
6703
|
const reorderLayerRef = useRef(null);
|
|
6704
|
+
// Same freshness idiom for `repositionElement` (defined far below), read
|
|
6705
|
+
// when the in-canvas drag posts dgn:'reposition-request' — the coordinate-
|
|
6706
|
+
// mode commit for out-of-flow (position:absolute/fixed) elements.
|
|
6707
|
+
const repositionElementRef = useRef(null);
|
|
6708
|
+
// feature-element-editing-robustness Stage D — sibling ref for `resizeElement`
|
|
6709
|
+
// (defined far below), read when the in-canvas resize overlay posts
|
|
6710
|
+
// dgn:'resize-request' on pointer-up. Same origin-split as reposition.
|
|
6711
|
+
const resizeElementRef = useRef(null);
|
|
5921
6712
|
// Phase 12.1 — true between a layers-panel reorder and the fresh layers-tree
|
|
5922
6713
|
// landing. A reorder churns positional data-cd-ids, so a rapid 2nd drag would
|
|
5923
6714
|
// target stale ids; the Layers tree gates new drags on this until the rebuilt
|
|
@@ -5993,15 +6784,15 @@ function App() {
|
|
|
5993
6784
|
const cap = setTimeout(() => setLoadingPath(null), 15000);
|
|
5994
6785
|
return () => clearTimeout(cap);
|
|
5995
6786
|
}, [loadingPath]);
|
|
5996
|
-
// Loaded
|
|
5997
|
-
//
|
|
6787
|
+
// Loaded at boot from /_config and re-fetched on the server's
|
|
6788
|
+
// `config-updated` push (config.json hot-reload — /design:setup-ds rewrites
|
|
6789
|
+
// it mid-session) — informs canvasUrl() so TSX iframes can pass the right
|
|
6790
|
+
// ?designRel + ?tokens query to the canvas mount shell.
|
|
5998
6791
|
const [cfg, setCfg] = useState({ designRel: '.design' });
|
|
5999
|
-
|
|
6000
|
-
let cancelled = false;
|
|
6792
|
+
const loadServerConfig = useCallback(() => {
|
|
6001
6793
|
fetch('/_config')
|
|
6002
6794
|
.then((r) => r.json())
|
|
6003
6795
|
.then((data) => {
|
|
6004
|
-
if (cancelled) return;
|
|
6005
6796
|
const designRel = (data.designRoot || '.design').replace(/^\/+|\/+$/g, '');
|
|
6006
6797
|
// Functional merge — the `/_config` and `/_index-data` fetches race, and
|
|
6007
6798
|
// the latter contributes `canvasDesignSystems` (DDR-093). A full-replace
|
|
@@ -6023,10 +6814,10 @@ function App() {
|
|
|
6023
6814
|
}));
|
|
6024
6815
|
})
|
|
6025
6816
|
.catch(() => {});
|
|
6026
|
-
return () => {
|
|
6027
|
-
cancelled = true;
|
|
6028
|
-
};
|
|
6029
6817
|
}, []);
|
|
6818
|
+
useEffect(() => {
|
|
6819
|
+
loadServerConfig();
|
|
6820
|
+
}, [loadServerConfig]);
|
|
6030
6821
|
// Backfill the sync banner on mount from /_sync-status. The 'sync:status' WS
|
|
6031
6822
|
// broadcast is one-shot for the zero-syncable case (DDR-060 / 9.1-D), so a
|
|
6032
6823
|
// tab that connects after boot would otherwise miss it. {linked:false} (solo)
|
|
@@ -6079,6 +6870,46 @@ function App() {
|
|
|
6079
6870
|
// The palette (T4) drives them; the dialog (T5) + panel (T6) consume them.
|
|
6080
6871
|
const [exportDialog, setExportDialog] = useState(null); // null | { mode: 'export'|'handoff', scope? }
|
|
6081
6872
|
const [inspectorOpen, setInspectorOpen] = useState(false);
|
|
6873
|
+
// DDR-148 — Timeline panel (right dock) for scrubbing a video-comp. `activeComps`
|
|
6874
|
+
// is populated from the iframe's `timeline-comps` announce; `timelineFrame` from
|
|
6875
|
+
// its live `timeline-frame`. Empty comps ⇒ the panel shows its empty state.
|
|
6876
|
+
const [timelineOpen, setTimelineOpen] = useState(false);
|
|
6877
|
+
const [activeComps, setActiveComps] = useState([]);
|
|
6878
|
+
const [timelineFrame, setTimelineFrame] = useState(0);
|
|
6879
|
+
const [timelinePlaying, setTimelinePlaying] = useState(false);
|
|
6880
|
+
const [timelineLoop, setTimelineLoop] = useState(true);
|
|
6881
|
+
const [timelineMuted, setTimelineMuted] = useState(false);
|
|
6882
|
+
const [timelineVolume, setTimelineVolume] = useState(1);
|
|
6883
|
+
const [timelineHeight, setTimelineHeight] = useState(216);
|
|
6884
|
+
// The artboard nearest the viewport centre (reported by canvas-lib on pan) —
|
|
6885
|
+
// the Timeline follows THIS, so it redraws as you move across the canvas.
|
|
6886
|
+
const [canvasActiveArtboard, setCanvasActiveArtboard] = useState(null);
|
|
6887
|
+
// DDR-148 — parsed sequence/keyframe rows for the Timeline (from raw .tsx).
|
|
6888
|
+
const [timelineSequences, setTimelineSequences] = useState([]);
|
|
6889
|
+
const [timelineAudio, setTimelineAudio] = useState([]);
|
|
6890
|
+
const [timelineTotal, setTimelineTotal] = useState(0);
|
|
6891
|
+
// On a multi-comp canvas the Timeline drives ONE comp (the parser scopes to it
|
|
6892
|
+
// + reports its total). Match that total back to the announced comp id so the
|
|
6893
|
+
// transport/scrub targets ONLY that comp — not every artboard on the canvas.
|
|
6894
|
+
const timelineCompId = useMemo(() => {
|
|
6895
|
+
if (!activeComps.length) return null;
|
|
6896
|
+
const match = activeComps.find((c) => c.durationInFrames === timelineTotal);
|
|
6897
|
+
return (match ?? activeComps[0]).id;
|
|
6898
|
+
}, [activeComps, timelineTotal]);
|
|
6899
|
+
const timelineCompIdRef = useRef(null);
|
|
6900
|
+
useEffect(() => {
|
|
6901
|
+
timelineCompIdRef.current = timelineCompId;
|
|
6902
|
+
}, [timelineCompId]);
|
|
6903
|
+
// The DCArtboard id the timeline scoped to (from parseCompTimeline). Used for
|
|
6904
|
+
// /_api/comp-clips + every clip op so the enumerator targets the SAME comp the
|
|
6905
|
+
// rows came from — `timelineCompId` is the Player's `videocomp-N` id, which
|
|
6906
|
+
// never matches a DCArtboard id and made the enumerator fall back to the wrong
|
|
6907
|
+
// comp on a multi-comp canvas (the showreel badge/replace/op mis-scope).
|
|
6908
|
+
const [timelineArtboardId, setTimelineArtboardId] = useState(null);
|
|
6909
|
+
const timelineArtboardIdRef = useRef(null);
|
|
6910
|
+
useEffect(() => {
|
|
6911
|
+
timelineArtboardIdRef.current = timelineArtboardId;
|
|
6912
|
+
}, [timelineArtboardId]);
|
|
6082
6913
|
// Phase 31 (DDR-123) — the native ACP chat sidepanel (right dock, native-only).
|
|
6083
6914
|
const [assistantOpen, setAssistantOpen] = useState(false);
|
|
6084
6915
|
const [assistantBusy, setAssistantBusy] = useState(false);
|
|
@@ -6116,6 +6947,33 @@ function App() {
|
|
|
6116
6947
|
// Inspector tab is lifted so View ▸ Layers can open the panel ON the Layers
|
|
6117
6948
|
// tab (the menu item sat disabled as "Phase 12" long after the tab shipped).
|
|
6118
6949
|
const [inspectorTab, setInspectorTab] = useState('inspect');
|
|
6950
|
+
// feature-element-editing-robustness Stage C — auto-open the Inspector on the
|
|
6951
|
+
// CSS tab when a fresh single selection arrives AND no right panel is already
|
|
6952
|
+
// open. Preference-backed (default ON); disable it in the View menu. Refs keep
|
|
6953
|
+
// the postMessage selection listener stale-closure-safe.
|
|
6954
|
+
const [autoOpenInspector, setAutoOpenInspector] = useState(() => {
|
|
6955
|
+
try {
|
|
6956
|
+
return localStorage.getItem('maude-auto-open-inspector') !== '0';
|
|
6957
|
+
} catch {
|
|
6958
|
+
return true;
|
|
6959
|
+
}
|
|
6960
|
+
});
|
|
6961
|
+
const autoOpenInspectorRef = useRef(autoOpenInspector);
|
|
6962
|
+
useEffect(() => {
|
|
6963
|
+
autoOpenInspectorRef.current = autoOpenInspector;
|
|
6964
|
+
try {
|
|
6965
|
+
localStorage.setItem('maude-auto-open-inspector', autoOpenInspector ? '1' : '0');
|
|
6966
|
+
} catch {
|
|
6967
|
+
/* private mode / storage disabled */
|
|
6968
|
+
}
|
|
6969
|
+
}, [autoOpenInspector]);
|
|
6970
|
+
// Whether ANY right-dock panel is open — the auto-open guard reads this ref so
|
|
6971
|
+
// it never steals focus from an already-open panel (the user's explicit rule).
|
|
6972
|
+
const anyRightPanelOpenRef = useRef(false);
|
|
6973
|
+
useEffect(() => {
|
|
6974
|
+
anyRightPanelOpenRef.current =
|
|
6975
|
+
inspectorOpen || commentsPanelOpen || changesOpen || assistantOpen;
|
|
6976
|
+
}, [inspectorOpen, commentsPanelOpen, changesOpen, assistantOpen]);
|
|
6119
6977
|
// The right dock holds exactly ONE panel (Changes / Inspector / Comments) at
|
|
6120
6978
|
// a time — opening any panel REPLACES whatever was there. These two helpers
|
|
6121
6979
|
// are the single source of that invariant; every open/toggle path routes
|
|
@@ -6128,7 +6986,24 @@ function App() {
|
|
|
6128
6986
|
setInspectorOpen(which === 'inspector');
|
|
6129
6987
|
setCommentsPanelOpen(which === 'comments');
|
|
6130
6988
|
setAssistantOpen(which === 'assistant');
|
|
6989
|
+
// NOTE: the Timeline is a BOTTOM dock (DDR-148), NOT part of the right-rail
|
|
6990
|
+
// mutual-exclusion — it stays open when a right panel opens.
|
|
6131
6991
|
}, []);
|
|
6992
|
+
// feature-element-editing-robustness Stage C (Task C1) — open the Inspector on
|
|
6993
|
+
// the CSS tab for a fresh SINGLE selection, but only when nothing is already
|
|
6994
|
+
// docked on the right (never override an open Layers/Inspect/Comments panel)
|
|
6995
|
+
// and only when the preference is on. Idempotent: once the inspector is open,
|
|
6996
|
+
// the `anyRightPanelOpen` guard stops it re-firing on the next selection.
|
|
6997
|
+
const maybeAutoOpenInspectorOnSelect = useCallback(
|
|
6998
|
+
(sel) => {
|
|
6999
|
+
if (!autoOpenInspectorRef.current) return;
|
|
7000
|
+
if (!sel || !sel.id) return; // need a stable element id to inspect
|
|
7001
|
+
if (anyRightPanelOpenRef.current) return; // don't steal from an open panel
|
|
7002
|
+
openRightPanel('inspector');
|
|
7003
|
+
setInspectorTab('css');
|
|
7004
|
+
},
|
|
7005
|
+
[openRightPanel]
|
|
7006
|
+
);
|
|
6132
7007
|
// Functional updates so this is stale-closure-safe inside the keydown /
|
|
6133
7008
|
// postMessage listeners; opening always clears the sibling panels.
|
|
6134
7009
|
const toggleRightPanel = useCallback((which) => {
|
|
@@ -6170,6 +7045,9 @@ function App() {
|
|
|
6170
7045
|
});
|
|
6171
7046
|
}
|
|
6172
7047
|
}, []);
|
|
7048
|
+
// DDR-148 — the Timeline is a BOTTOM dock, independent of the right rail: it
|
|
7049
|
+
// toggles on its own and coexists with Inspector/Changes/Comments/Chat.
|
|
7050
|
+
const toggleTimeline = useCallback(() => setTimelineOpen((v) => !v), []);
|
|
6173
7051
|
const whatsNew = useWhatsNew(MDCC_VERSION);
|
|
6174
7052
|
// Phase 29 (E4) — first-run onboarding wizard. The native shell boots a minimal
|
|
6175
7053
|
// "welcome" project on first launch; we ask it whether this is a first run and, if
|
|
@@ -6288,6 +7166,269 @@ function App() {
|
|
|
6288
7166
|
[activePath]
|
|
6289
7167
|
);
|
|
6290
7168
|
|
|
7169
|
+
// DDR-150 dogfood #7 — a file dragged from Finder and dropped on a shell area
|
|
7170
|
+
// with no drop target used to make the browser NAVIGATE AWAY to the file
|
|
7171
|
+
// (file:// replaces the app). Document-level guard: always cancel the default
|
|
7172
|
+
// for file drags; the TimelinePanel's own onDrop (bubbling before this) still
|
|
7173
|
+
// receives the drop first — this only blocks the browser fallback.
|
|
7174
|
+
useEffect(() => {
|
|
7175
|
+
const isFileDrag = (e) => Array.from(e.dataTransfer?.types ?? []).includes('Files');
|
|
7176
|
+
const onDocDragOver = (e) => {
|
|
7177
|
+
if (isFileDrag(e)) e.preventDefault();
|
|
7178
|
+
};
|
|
7179
|
+
const onDocDrop = (e) => {
|
|
7180
|
+
if (isFileDrag(e)) e.preventDefault();
|
|
7181
|
+
};
|
|
7182
|
+
document.addEventListener('dragover', onDocDragOver);
|
|
7183
|
+
document.addEventListener('drop', onDocDrop);
|
|
7184
|
+
return () => {
|
|
7185
|
+
document.removeEventListener('dragover', onDocDragOver);
|
|
7186
|
+
document.removeEventListener('drop', onDocDrop);
|
|
7187
|
+
};
|
|
7188
|
+
}, []);
|
|
7189
|
+
|
|
7190
|
+
// DDR-150 P3 Task 9 — timeline keyboard shortcuts. Read live state through a
|
|
7191
|
+
// ref so the listener attaches once. Gated on: timeline open + a comp active +
|
|
7192
|
+
// focus NOT in a text field. Space doesn't steal the canvas PAN chord because
|
|
7193
|
+
// that keydown fires inside the focused canvas iframe, never reaching this
|
|
7194
|
+
// top-document listener. Space = play/pause · ←/→ = ±1 frame (Shift = ±1s) ·
|
|
7195
|
+
// Home/End = start/end · ,/. = prev/next keyframe boundary.
|
|
7196
|
+
const tlKeyRef = useRef({});
|
|
7197
|
+
tlKeyRef.current = {
|
|
7198
|
+
open: timelineOpen,
|
|
7199
|
+
comps: activeComps,
|
|
7200
|
+
frame: timelineFrame,
|
|
7201
|
+
total: timelineTotal,
|
|
7202
|
+
playing: timelinePlaying,
|
|
7203
|
+
compId: timelineCompId,
|
|
7204
|
+
muted: timelineMuted,
|
|
7205
|
+
loop: timelineLoop,
|
|
7206
|
+
sequences: timelineSequences,
|
|
7207
|
+
post: postToActiveCanvas,
|
|
7208
|
+
canvas: activePath,
|
|
7209
|
+
};
|
|
7210
|
+
|
|
7211
|
+
// DDR-150 dogfood #1 — Timeline undo/redo. Every successful clip op (move /
|
|
7212
|
+
// trim / remove / insert / z-reorder / replace-src) returns a server `seq`
|
|
7213
|
+
// registered in the whole-file undo log; Cmd+Z / Shift+Cmd+Z replay it via
|
|
7214
|
+
// /_api/reorder-revert (guarded swap — 409s honestly if the canvas diverged).
|
|
7215
|
+
const tlUndoRef = useRef({ undo: [], redo: [] });
|
|
7216
|
+
const pushTlUndo = useCallback((canvas, seq, label) => {
|
|
7217
|
+
if (typeof seq !== 'number') return;
|
|
7218
|
+
tlUndoRef.current.undo.push({ canvas, seq, label });
|
|
7219
|
+
tlUndoRef.current.redo = []; // a new edit invalidates the redo branch
|
|
7220
|
+
if (tlUndoRef.current.undo.length > 50) tlUndoRef.current.undo.shift();
|
|
7221
|
+
}, []);
|
|
7222
|
+
const tlUndoRedo = useCallback((dir) => {
|
|
7223
|
+
const s = tlUndoRef.current;
|
|
7224
|
+
const src = dir === 'undo' ? s.undo : s.redo;
|
|
7225
|
+
const dst = dir === 'undo' ? s.redo : s.undo;
|
|
7226
|
+
const canvas = tlKeyRef.current.canvas;
|
|
7227
|
+
// Last entry for THIS canvas (stacks are global; ops are per-canvas).
|
|
7228
|
+
let idx = -1;
|
|
7229
|
+
for (let i = src.length - 1; i >= 0; i--) {
|
|
7230
|
+
if (src[i].canvas === canvas) {
|
|
7231
|
+
idx = i;
|
|
7232
|
+
break;
|
|
7233
|
+
}
|
|
7234
|
+
}
|
|
7235
|
+
if (idx < 0) {
|
|
7236
|
+
shellToast(dir === 'undo' ? 'Nothing to undo on this timeline.' : 'Nothing to redo.');
|
|
7237
|
+
return;
|
|
7238
|
+
}
|
|
7239
|
+
const entry = src[idx];
|
|
7240
|
+
fetch('/_api/reorder-revert', {
|
|
7241
|
+
method: 'POST',
|
|
7242
|
+
headers: { 'content-type': 'application/json' },
|
|
7243
|
+
body: JSON.stringify({ canvas: entry.canvas, seq: entry.seq, dir }),
|
|
7244
|
+
})
|
|
7245
|
+
.then((r) => r.json().catch(() => ({})))
|
|
7246
|
+
.then((j) => {
|
|
7247
|
+
src.splice(idx, 1);
|
|
7248
|
+
if (j?.ok) {
|
|
7249
|
+
dst.push(entry);
|
|
7250
|
+
shellToast(`${dir === 'undo' ? 'Undid' : 'Redid'}: ${entry.label}`, true);
|
|
7251
|
+
} else {
|
|
7252
|
+
// 409 canvas diverged / 404 server restarted — entry is dead, drop it.
|
|
7253
|
+
shellToast(`${dir === 'undo' ? 'Undo' : 'Redo'} skipped: ${j?.error || 'failed'}`);
|
|
7254
|
+
}
|
|
7255
|
+
})
|
|
7256
|
+
.catch(() => shellToast(`${dir === 'undo' ? 'Undo' : 'Redo'} failed: network error`));
|
|
7257
|
+
}, []);
|
|
7258
|
+
useEffect(() => {
|
|
7259
|
+
const onKey = (e) => {
|
|
7260
|
+
const s = tlKeyRef.current;
|
|
7261
|
+
if (!s.open || !s.comps?.length) return;
|
|
7262
|
+
const t = e.target;
|
|
7263
|
+
const tag = t?.tagName;
|
|
7264
|
+
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || t?.isContentEditable) return;
|
|
7265
|
+
const fps = s.comps[0]?.fps || 30;
|
|
7266
|
+
const total = Math.max(1, s.total);
|
|
7267
|
+
const doSeek = (f) => {
|
|
7268
|
+
const nf = Math.max(0, Math.min(total - 1, Math.round(f)));
|
|
7269
|
+
setTimelineFrame(nf);
|
|
7270
|
+
setTimelinePlaying(false);
|
|
7271
|
+
s.post({ dgn: 'timeline-seek', frame: nf, id: s.compId });
|
|
7272
|
+
};
|
|
7273
|
+
const snapFrames = () => {
|
|
7274
|
+
const pts = new Set([0, total - 1]);
|
|
7275
|
+
for (const seq of s.sequences || []) {
|
|
7276
|
+
pts.add(seq.from);
|
|
7277
|
+
for (const kf of seq.keyframes || []) {
|
|
7278
|
+
pts.add(kf.from);
|
|
7279
|
+
pts.add(kf.to);
|
|
7280
|
+
}
|
|
7281
|
+
}
|
|
7282
|
+
return [...pts].filter((n) => n >= 0 && n < total).sort((a, b) => a - b);
|
|
7283
|
+
};
|
|
7284
|
+
if ((e.metaKey || e.ctrlKey) && (e.key === 'z' || e.key === 'Z')) {
|
|
7285
|
+
// Cmd+Z / Shift+Cmd+Z — undo/redo the last timeline clip op. Only when
|
|
7286
|
+
// the shell (not the canvas iframe) has focus + the timeline is active,
|
|
7287
|
+
// so the canvas's own annotation undo is untouched.
|
|
7288
|
+
e.preventDefault();
|
|
7289
|
+
tlUndoRedo(e.shiftKey ? 'redo' : 'undo');
|
|
7290
|
+
return;
|
|
7291
|
+
}
|
|
7292
|
+
if (e.key === ' ' || e.code === 'Space') {
|
|
7293
|
+
e.preventDefault();
|
|
7294
|
+
if (s.playing) {
|
|
7295
|
+
setTimelinePlaying(false);
|
|
7296
|
+
s.post({ dgn: 'timeline-pause', id: s.compId });
|
|
7297
|
+
} else {
|
|
7298
|
+
setTimelinePlaying(true);
|
|
7299
|
+
s.post({ dgn: 'timeline-mute', muted: s.muted, id: s.compId });
|
|
7300
|
+
s.post({ dgn: 'timeline-loop', loop: s.loop, id: s.compId });
|
|
7301
|
+
s.post({ dgn: 'timeline-play', id: s.compId });
|
|
7302
|
+
}
|
|
7303
|
+
} else if (e.key === 'ArrowRight') {
|
|
7304
|
+
e.preventDefault();
|
|
7305
|
+
doSeek(s.frame + (e.shiftKey ? fps : 1));
|
|
7306
|
+
} else if (e.key === 'ArrowLeft') {
|
|
7307
|
+
e.preventDefault();
|
|
7308
|
+
doSeek(s.frame - (e.shiftKey ? fps : 1));
|
|
7309
|
+
} else if (e.key === 'Home') {
|
|
7310
|
+
e.preventDefault();
|
|
7311
|
+
doSeek(0);
|
|
7312
|
+
} else if (e.key === 'End') {
|
|
7313
|
+
e.preventDefault();
|
|
7314
|
+
doSeek(total - 1);
|
|
7315
|
+
} else if (e.key === '.') {
|
|
7316
|
+
e.preventDefault();
|
|
7317
|
+
const next = snapFrames().find((n) => n > s.frame);
|
|
7318
|
+
if (next != null) doSeek(next);
|
|
7319
|
+
} else if (e.key === ',') {
|
|
7320
|
+
e.preventDefault();
|
|
7321
|
+
const prev = snapFrames()
|
|
7322
|
+
.reverse()
|
|
7323
|
+
.find((n) => n < s.frame);
|
|
7324
|
+
if (prev != null) doSeek(prev);
|
|
7325
|
+
}
|
|
7326
|
+
};
|
|
7327
|
+
window.addEventListener('keydown', onKey);
|
|
7328
|
+
return () => window.removeEventListener('keydown', onKey);
|
|
7329
|
+
}, []);
|
|
7330
|
+
|
|
7331
|
+
// DDR-148 — reset the Timeline's comp meta when the active canvas changes (a
|
|
7332
|
+
// non-comp canvas fires no announce, so stale comps would linger), then ask
|
|
7333
|
+
// the (possibly already-mounted) active canvas to re-announce. video-comp.tsx
|
|
7334
|
+
// answers `timeline-request-comps` with a fresh `timeline-comps`.
|
|
7335
|
+
useEffect(() => {
|
|
7336
|
+
setActiveComps([]);
|
|
7337
|
+
setTimelineFrame(0);
|
|
7338
|
+
setTimelinePlaying(false);
|
|
7339
|
+
const t = setTimeout(() => postToActiveCanvas({ dgn: 'timeline-request-comps' }), 60);
|
|
7340
|
+
return () => clearTimeout(t);
|
|
7341
|
+
}, [activePath, postToActiveCanvas]);
|
|
7342
|
+
|
|
7343
|
+
// DDR-148 — fetch the active canvas's raw source when the Timeline is open.
|
|
7344
|
+
// Re-runs when a comp is (re)announced, so it refreshes after a canvas edit.
|
|
7345
|
+
const [timelineSource, setTimelineSource] = useState('');
|
|
7346
|
+
// DDR-150 dogfood — authoritative per-clip media from the enumerator (handles
|
|
7347
|
+
// wrapper components + array-fed src the regex parser can't see), merged into
|
|
7348
|
+
// the rows for the kind badge + replace addressing.
|
|
7349
|
+
const [timelineClipMedia, setTimelineClipMedia] = useState([]);
|
|
7350
|
+
useEffect(() => {
|
|
7351
|
+
if (!timelineOpen || activeComps.length === 0 || !activePath || activePath === SYSTEM_TAB) {
|
|
7352
|
+
setTimelineSource('');
|
|
7353
|
+
setTimelineClipMedia([]);
|
|
7354
|
+
return undefined;
|
|
7355
|
+
}
|
|
7356
|
+
let alive = true;
|
|
7357
|
+
fetch(`/_api/canvas-source?file=${encodeURIComponent(activePath)}`)
|
|
7358
|
+
.then((r) => (r.ok ? r.json() : null))
|
|
7359
|
+
.then((j) => {
|
|
7360
|
+
if (alive && j?.ok && typeof j.source === 'string') setTimelineSource(j.source);
|
|
7361
|
+
})
|
|
7362
|
+
.catch(() => {});
|
|
7363
|
+
return () => {
|
|
7364
|
+
alive = false;
|
|
7365
|
+
};
|
|
7366
|
+
}, [timelineOpen, activeComps, activePath]);
|
|
7367
|
+
|
|
7368
|
+
// Authoritative per-clip media (comp-clips) — a SEPARATE effect keyed on the
|
|
7369
|
+
// RESOLVED artboard id, which the parser only knows AFTER timelineSource
|
|
7370
|
+
// arrives. Fetching it here (not in the source effect) means it re-runs once
|
|
7371
|
+
// the parser scopes to the right comp — so a multi-comp canvas (the showreel:
|
|
7372
|
+
// Movie + Reel) overlays the CORRECT comp's media, not the enumerator's own
|
|
7373
|
+
// fallback pick.
|
|
7374
|
+
useEffect(() => {
|
|
7375
|
+
if (!timelineOpen || !activePath || activePath === SYSTEM_TAB || !timelineSource) {
|
|
7376
|
+
setTimelineClipMedia([]);
|
|
7377
|
+
return undefined;
|
|
7378
|
+
}
|
|
7379
|
+
let alive = true;
|
|
7380
|
+
const artboardId = timelineArtboardId || undefined;
|
|
7381
|
+
const ccUrl = `/_api/comp-clips?canvas=${encodeURIComponent(activePath)}${artboardId ? `&artboardId=${encodeURIComponent(artboardId)}` : ''}`;
|
|
7382
|
+
fetch(ccUrl)
|
|
7383
|
+
.then((r) => (r.ok ? r.json() : null))
|
|
7384
|
+
.then((cc) => {
|
|
7385
|
+
if (!alive) return;
|
|
7386
|
+
const seqs =
|
|
7387
|
+
cc?.ok && Array.isArray(cc.clips) ? cc.clips.filter((c) => c.kind === 'sequence') : [];
|
|
7388
|
+
setTimelineClipMedia(seqs);
|
|
7389
|
+
})
|
|
7390
|
+
.catch(() => {});
|
|
7391
|
+
return () => {
|
|
7392
|
+
alive = false;
|
|
7393
|
+
};
|
|
7394
|
+
}, [timelineOpen, activePath, timelineSource, timelineArtboardId]);
|
|
7395
|
+
|
|
7396
|
+
// Parse (cheap) — re-runs on source change AND on selection change, so the
|
|
7397
|
+
// Timeline REDRAWS to whichever artboard the user just selected/moved to.
|
|
7398
|
+
useEffect(() => {
|
|
7399
|
+
if (!timelineSource) {
|
|
7400
|
+
setTimelineSequences([]);
|
|
7401
|
+
setTimelineAudio([]);
|
|
7402
|
+
setTimelineTotal(0);
|
|
7403
|
+
return;
|
|
7404
|
+
}
|
|
7405
|
+
const total = activeComps[0]?.durationInFrames || 0;
|
|
7406
|
+
const artboard = canvasActiveArtboard ?? selected?.artboardId ?? null;
|
|
7407
|
+
const parsed = parseCompTimeline(timelineSource, total, artboard);
|
|
7408
|
+
setTimelineArtboardId(parsed.artboardId ?? artboard ?? null);
|
|
7409
|
+
// Overlay the enumerator's authoritative media (kind badge + replace target)
|
|
7410
|
+
// onto each row by sequence index — it sees wrapper-component + array-fed
|
|
7411
|
+
// media the row parser can't (the showreel <ClipShot clip={CLIPS[i]}/> case).
|
|
7412
|
+
const merged = parsed.sequences.map((s, i) => {
|
|
7413
|
+
const cm = timelineClipMedia[i];
|
|
7414
|
+
if (!cm) return s;
|
|
7415
|
+
return {
|
|
7416
|
+
...s,
|
|
7417
|
+
mediaTag: cm.mediaTag ?? s.mediaTag,
|
|
7418
|
+
mediaSrc: cm.mediaSrc ?? s.mediaSrc,
|
|
7419
|
+
// Replaceable when the enumerator found an addressable media target:
|
|
7420
|
+
// a literal-src element (mediaCdId) or an array-fed src (mediaArrayRef).
|
|
7421
|
+
replaceable: !!(cm.mediaCdId || cm.mediaArrayRef),
|
|
7422
|
+
// The clip's stacked layers (mp4 background + title/…) for expandable rows.
|
|
7423
|
+
layers: Array.isArray(cm.layers) ? cm.layers : [],
|
|
7424
|
+
hidden: !!cm.hidden,
|
|
7425
|
+
};
|
|
7426
|
+
});
|
|
7427
|
+
setTimelineSequences(merged);
|
|
7428
|
+
setTimelineAudio(parsed.audio || []);
|
|
7429
|
+
setTimelineTotal(parsed.total);
|
|
7430
|
+
}, [timelineSource, timelineClipMedia, selected, activeComps, canvasActiveArtboard]);
|
|
7431
|
+
|
|
6291
7432
|
const toggleAnnotations = useCallback(() => {
|
|
6292
7433
|
setAnnotationsVisible((v) => {
|
|
6293
7434
|
const next = !v;
|
|
@@ -6563,6 +7704,11 @@ function App() {
|
|
|
6563
7704
|
// the branch-scoped tree so other open tabs reflect it without a
|
|
6564
7705
|
// reload. Cross-machine peers get a new canvas via git "Get latest".
|
|
6565
7706
|
loadTree();
|
|
7707
|
+
} else if (m.type === 'config-updated') {
|
|
7708
|
+
// Server hot-reloaded .design/config.json (/design:setup-ds rewrote
|
|
7709
|
+
// it) — refetch /_config so designSystems / tokensCssRel / groups
|
|
7710
|
+
// match. The tree refresh arrives separately via canvas-list-update.
|
|
7711
|
+
loadServerConfig();
|
|
6566
7712
|
} else if (m.type === 'acp-focus') {
|
|
6567
7713
|
// Phase 31 (DDR-123) — `/design:chat` from the terminal asked us to
|
|
6568
7714
|
// surface the native ACP chat sidepanel. Native-only (the panel
|
|
@@ -6590,9 +7736,9 @@ function App() {
|
|
|
6590
7736
|
}
|
|
6591
7737
|
connect();
|
|
6592
7738
|
return () => wsRef.current && wsRef.current.close();
|
|
6593
|
-
// loadTree
|
|
6594
|
-
//
|
|
6595
|
-
}, [loadTree]);
|
|
7739
|
+
// loadTree + loadServerConfig are stable useCallback([])s; listed so the
|
|
7740
|
+
// canvas-list-update / config-updated handlers always call the live refs.
|
|
7741
|
+
}, [loadTree, loadServerConfig]);
|
|
6596
7742
|
|
|
6597
7743
|
function wsSend(obj) {
|
|
6598
7744
|
const ws = wsRef.current;
|
|
@@ -6878,6 +8024,139 @@ function App() {
|
|
|
6878
8024
|
[loadTree, openTab]
|
|
6879
8025
|
);
|
|
6880
8026
|
|
|
8027
|
+
// DDR-150 P4 Task 12 — one-click "udělej z toho video". Reads the ACTIVE
|
|
8028
|
+
// canvas's annotation sidecar (main-origin — no cross-origin round-trip),
|
|
8029
|
+
// gathers every dropped media-reference chip's src in document order, and POSTs
|
|
8030
|
+
// an assembled video-comp (kind: 'video-comp') → opens the new, immediately
|
|
8031
|
+
// hand-editable comp. Durations default server-side to fps*3 (the user trims on
|
|
8032
|
+
// the Timeline); client-side <video>.duration probing is a documented tightening.
|
|
8033
|
+
const assembleVideo = useCallback(async () => {
|
|
8034
|
+
if (!activePath || activePath === SYSTEM_TAB) return;
|
|
8035
|
+
try {
|
|
8036
|
+
const ar = await fetch(`/_api/annotations?file=${encodeURIComponent(activePath)}`);
|
|
8037
|
+
const svg = ar.ok ? await ar.text() : '';
|
|
8038
|
+
const clips = [];
|
|
8039
|
+
if (svg) {
|
|
8040
|
+
const doc = new DOMParser().parseFromString(svg, 'image/svg+xml');
|
|
8041
|
+
for (const g of doc.querySelectorAll('[data-tool="mediaref"]')) {
|
|
8042
|
+
const src = g.getAttribute('data-src');
|
|
8043
|
+
if (!src) continue;
|
|
8044
|
+
clips.push({ src, mediaKind: g.getAttribute('data-media-kind') === 'audio' ? 'audio' : 'video' });
|
|
8045
|
+
}
|
|
8046
|
+
}
|
|
8047
|
+
if (clips.length === 0) {
|
|
8048
|
+
window.alert('Drop video/audio clips on the canvas first, then assemble them into a video.');
|
|
8049
|
+
return;
|
|
8050
|
+
}
|
|
8051
|
+
const baseName = displayName(basename(activePath)).replace(/\.tsx$/i, '');
|
|
8052
|
+
const name = `${baseName} Video`;
|
|
8053
|
+
const r = await fetch('/_api/canvas', {
|
|
8054
|
+
method: 'POST',
|
|
8055
|
+
headers: { 'Content-Type': 'application/json' },
|
|
8056
|
+
body: JSON.stringify({ name, kind: 'video-comp', clips }),
|
|
8057
|
+
});
|
|
8058
|
+
const j = await r.json().catch(() => ({}));
|
|
8059
|
+
if (!r.ok || !j.ok) {
|
|
8060
|
+
window.alert(`Assemble failed: ${j.error || `error ${r.status}`}`);
|
|
8061
|
+
return;
|
|
8062
|
+
}
|
|
8063
|
+
await loadTree();
|
|
8064
|
+
openTab(j.file);
|
|
8065
|
+
} catch (e) {
|
|
8066
|
+
window.alert(`Assemble failed: ${e instanceof Error ? e.message : 'network error'}`);
|
|
8067
|
+
}
|
|
8068
|
+
}, [activePath, loadTree, openTab]);
|
|
8069
|
+
|
|
8070
|
+
// DDR-150 dogfood #5 — shared replace-media flow, PICKER-FIRST. The <input
|
|
8071
|
+
// type=file> is created + clicked SYNCHRONOUSLY inside the caller's click
|
|
8072
|
+
// gesture (browsers revoke transient user-activation after any await, which is
|
|
8073
|
+
// why the old fetch-then-click never opened the dialog). The comp-clips lookup
|
|
8074
|
+
// + upload + src patch all happen in the change handler, where no activation
|
|
8075
|
+
// is needed. `resolveCdId(cc)` maps the fresh enumerator payload to the target
|
|
8076
|
+
// media target (sequence clip via cd-id, audio bed via cd-id, or a showreel
|
|
8077
|
+
// array-fed src via mediaArrayRef → /_api/edit-array-src). `resolveTarget(cc)`
|
|
8078
|
+
// returns { cdId } | { arrayRef } | null.
|
|
8079
|
+
const replaceMediaViaPicker = useCallback(
|
|
8080
|
+
({ accept, resolveTarget }) => {
|
|
8081
|
+
const canvas = activePath;
|
|
8082
|
+
const artboardId = timelineArtboardId || undefined;
|
|
8083
|
+
const input = document.createElement('input');
|
|
8084
|
+
input.type = 'file';
|
|
8085
|
+
input.accept = accept;
|
|
8086
|
+
// OFF-SCREEN, not display:none — the native desktop app (Tauri WKWebView)
|
|
8087
|
+
// will NOT present the file panel for a `display:none` input (it must be
|
|
8088
|
+
// laid out). Off-screen + transparent works in both WKWebView and browsers
|
|
8089
|
+
// (dogfood: "replace ... nefunguje ale v desktop app"). Guard against a
|
|
8090
|
+
// cancelled dialog leaking the node with a one-shot focus cleanup.
|
|
8091
|
+
input.style.cssText =
|
|
8092
|
+
'position:fixed;left:-9999px;top:0;width:1px;height:1px;opacity:0;pointer-events:none';
|
|
8093
|
+
document.body.appendChild(input);
|
|
8094
|
+
const cleanup = () => {
|
|
8095
|
+
if (input.isConnected) input.remove();
|
|
8096
|
+
};
|
|
8097
|
+
// If the user cancels the panel, `change` never fires — reclaim the node
|
|
8098
|
+
// when focus returns to the window (fires on both pick and cancel).
|
|
8099
|
+
window.addEventListener('focus', () => setTimeout(cleanup, 300), { once: true });
|
|
8100
|
+
input.addEventListener('change', () => {
|
|
8101
|
+
const file = input.files?.[0];
|
|
8102
|
+
cleanup();
|
|
8103
|
+
if (!file) return;
|
|
8104
|
+
const ccUrl = `/_api/comp-clips?canvas=${encodeURIComponent(canvas)}${artboardId ? `&artboardId=${encodeURIComponent(artboardId)}` : ''}`;
|
|
8105
|
+
fetch(ccUrl)
|
|
8106
|
+
.then((r) => r.json().catch(() => ({})))
|
|
8107
|
+
.then((cc) => {
|
|
8108
|
+
const target = resolveTarget(cc);
|
|
8109
|
+
if (!target) {
|
|
8110
|
+
shellToast('No replaceable media here (its src is computed) — edit via chat.');
|
|
8111
|
+
return null;
|
|
8112
|
+
}
|
|
8113
|
+
return fetch('/_api/asset', {
|
|
8114
|
+
method: 'POST',
|
|
8115
|
+
headers: { 'Content-Type': file.type || 'application/octet-stream' },
|
|
8116
|
+
body: file,
|
|
8117
|
+
})
|
|
8118
|
+
.then((r) => r.json().catch(() => ({})))
|
|
8119
|
+
.then((up) => {
|
|
8120
|
+
if (!up?.path) {
|
|
8121
|
+
shellToast(`Upload failed: ${up?.error || 'unknown error'}`);
|
|
8122
|
+
return null;
|
|
8123
|
+
}
|
|
8124
|
+
if (target.arrayRef) {
|
|
8125
|
+
// Showreel pattern: the src lives in an array literal.
|
|
8126
|
+
return fetch('/_api/edit-array-src', {
|
|
8127
|
+
method: 'POST',
|
|
8128
|
+
headers: { 'content-type': 'application/json' },
|
|
8129
|
+
body: JSON.stringify({
|
|
8130
|
+
canvas,
|
|
8131
|
+
arrayName: target.arrayRef.arrayName,
|
|
8132
|
+
index: target.arrayRef.index,
|
|
8133
|
+
field: target.arrayRef.field,
|
|
8134
|
+
value: up.path,
|
|
8135
|
+
}),
|
|
8136
|
+
});
|
|
8137
|
+
}
|
|
8138
|
+
return fetch('/_api/edit-attr', {
|
|
8139
|
+
method: 'POST',
|
|
8140
|
+
headers: { 'content-type': 'application/json' },
|
|
8141
|
+
body: JSON.stringify({ canvas, id: target.cdId, attr: 'src', value: up.path }),
|
|
8142
|
+
});
|
|
8143
|
+
});
|
|
8144
|
+
})
|
|
8145
|
+
.then((r) => (r ? r.json() : null))
|
|
8146
|
+
.then((j) => {
|
|
8147
|
+
if (j && !j.ok) shellToast(`Replace refused: ${j.error || 'failed'}`);
|
|
8148
|
+
else if (j && j.ok) {
|
|
8149
|
+
shellToast('Media replaced.', true);
|
|
8150
|
+
if (j.seq != null) pushTlUndo(canvas, j.seq, 'replace media');
|
|
8151
|
+
}
|
|
8152
|
+
})
|
|
8153
|
+
.catch(() => shellToast('Replace failed: network error'));
|
|
8154
|
+
});
|
|
8155
|
+
input.click();
|
|
8156
|
+
},
|
|
8157
|
+
[activePath, timelineArtboardId, pushTlUndo]
|
|
8158
|
+
);
|
|
8159
|
+
|
|
6881
8160
|
// Phase 22 — soft-delete a canvas from the file tree. Confirms (destructive),
|
|
6882
8161
|
// DELETEs to the main-origin-only endpoint, refreshes the tree, and resets the
|
|
6883
8162
|
// active tab if the deleted canvas was open. The server moves the whole sidecar
|
|
@@ -6993,6 +8272,7 @@ function App() {
|
|
|
6993
8272
|
if (m.dgn === 'select' && m.selection) {
|
|
6994
8273
|
wsSend({ type: 'select', selection: m.selection });
|
|
6995
8274
|
setSelected(m.selection);
|
|
8275
|
+
maybeAutoOpenInspectorOnSelect(m.selection); // Stage C
|
|
6996
8276
|
} else if (m.dgn === 'select-set') {
|
|
6997
8277
|
// Canvas multi-select. Payload shape:
|
|
6998
8278
|
// null → empty selection
|
|
@@ -7009,9 +8289,12 @@ function App() {
|
|
|
7009
8289
|
const head = payload[0] ?? null;
|
|
7010
8290
|
if (head) wsSend({ type: 'select', selection: head });
|
|
7011
8291
|
setSelected(head);
|
|
8292
|
+
// Stage C — auto-open only for a genuine SINGLE selection, never multi.
|
|
8293
|
+
if (payload.length === 1) maybeAutoOpenInspectorOnSelect(head);
|
|
7012
8294
|
} else {
|
|
7013
8295
|
wsSend({ type: 'select', selection: payload });
|
|
7014
8296
|
setSelected(payload);
|
|
8297
|
+
maybeAutoOpenInspectorOnSelect(payload); // Stage C
|
|
7015
8298
|
}
|
|
7016
8299
|
} else if (m.dgn === 'clear-select') {
|
|
7017
8300
|
wsSend({ type: 'clear-select' });
|
|
@@ -7020,7 +8303,18 @@ function App() {
|
|
|
7020
8303
|
// Phase 12 (DDR-103) — inline text edit committed in the canvas. POST to
|
|
7021
8304
|
// the main-origin-only /_api/edit-text → editText writes the escaped
|
|
7022
8305
|
// JSXText to source; the file-watcher HMR reload then shows the new text.
|
|
7023
|
-
//
|
|
8306
|
+
// DDR-150 P1: a refusal is NO LONGER silent — post `edit-reverted` back
|
|
8307
|
+
// to the canvas, which reverts the optimistic contenteditable text AND
|
|
8308
|
+
// toasts the reason, so a failed edit can't silently vanish on the next
|
|
8309
|
+
// reload ("my text edit didn't stick"). The user-facing toast lives
|
|
8310
|
+
// canvas-side (showCanvasToast) because App has no in-scope status
|
|
8311
|
+
// surface — `setStatus` belongs to ExportDialog, not App.
|
|
8312
|
+
const editSource = e.source;
|
|
8313
|
+
const revert = (reason) => {
|
|
8314
|
+
try {
|
|
8315
|
+
editSource?.postMessage({ dgn: 'edit-reverted', op: 'text', id: m.id, reason }, '*');
|
|
8316
|
+
} catch {}
|
|
8317
|
+
};
|
|
7024
8318
|
fetch('/_api/edit-text', {
|
|
7025
8319
|
method: 'POST',
|
|
7026
8320
|
headers: { 'content-type': 'application/json' },
|
|
@@ -7028,9 +8322,9 @@ function App() {
|
|
|
7028
8322
|
})
|
|
7029
8323
|
.then((r) => r.json().catch(() => ({})))
|
|
7030
8324
|
.then((j) => {
|
|
7031
|
-
if (!j.ok)
|
|
8325
|
+
if (!j.ok) revert(j.error || "this element can't be edited inline");
|
|
7032
8326
|
})
|
|
7033
|
-
.catch(() =>
|
|
8327
|
+
.catch(() => revert('network error'));
|
|
7034
8328
|
} else if (m.dgn === 'apply-edit' && m.id && (m.op === 'css' || m.op === 'text' || m.op === 'attr')) {
|
|
7035
8329
|
// Inline-edit undo/redo (DDR-103/104 follow-up). The canvas iframe's
|
|
7036
8330
|
// `edit-source` command can't call the main-origin-only `/_api/edit-*`
|
|
@@ -7068,6 +8362,10 @@ function App() {
|
|
|
7068
8362
|
})
|
|
7069
8363
|
.then((r) => r.json().catch(() => ({})))
|
|
7070
8364
|
.then((j) => {
|
|
8365
|
+
// Undo/redo lane (Cmd+Z/Cmd+Shift+Z re-application). The primary
|
|
8366
|
+
// edit-text lane above owns the user-facing revert + toast; a
|
|
8367
|
+
// refusal here is logged. (DDR-150 P1 kept this a warn after the
|
|
8368
|
+
// setStatus-scope fix — setStatus is not in App's scope.)
|
|
7071
8369
|
if (!j.ok) console.warn('[apply-edit]', op, j.error || 'failed');
|
|
7072
8370
|
})
|
|
7073
8371
|
.catch(() => {})
|
|
@@ -7157,6 +8455,175 @@ function App() {
|
|
|
7157
8455
|
refIndex: Number.isInteger(m.refIndex) ? m.refIndex : undefined,
|
|
7158
8456
|
});
|
|
7159
8457
|
}
|
|
8458
|
+
} else if (m.dgn === 'reposition-request') {
|
|
8459
|
+
// Free-XY reposition for out-of-flow elements (position:absolute/
|
|
8460
|
+
// fixed) — the in-canvas drag switches from reorder-mode to
|
|
8461
|
+
// coordinate-mode when the dragged element is out of flow, because
|
|
8462
|
+
// reordering an absolute/fixed element's JSX siblings never changes
|
|
8463
|
+
// where it paints (the "moves then snaps back" RCA). Same trust
|
|
8464
|
+
// model + confused-deputy guard as reorder-request: untrusted canvas
|
|
8465
|
+
// REQUESTS, shell WRITES, pinned to the ACTIVE canvas (never
|
|
8466
|
+
// `m.canvas`, which an untrusted iframe could spoof).
|
|
8467
|
+
const activeWin = activePath ? iframesRef.current.get(activePath)?.contentWindow : null;
|
|
8468
|
+
const okShape =
|
|
8469
|
+
typeof m.id === 'string' &&
|
|
8470
|
+
Number.isFinite(m.left) &&
|
|
8471
|
+
Number.isFinite(m.top) &&
|
|
8472
|
+
Number.isFinite(m.beforeLeft) &&
|
|
8473
|
+
Number.isFinite(m.beforeTop);
|
|
8474
|
+
if (e.source === activeWin && okShape) {
|
|
8475
|
+
repositionElementRef.current?.(
|
|
8476
|
+
m.id,
|
|
8477
|
+
m.left,
|
|
8478
|
+
m.top,
|
|
8479
|
+
m.beforeLeft,
|
|
8480
|
+
m.beforeTop,
|
|
8481
|
+
Number.isInteger(m.idIndex) ? m.idIndex : undefined
|
|
8482
|
+
);
|
|
8483
|
+
}
|
|
8484
|
+
} else if (m.dgn === 'resize-request') {
|
|
8485
|
+
// feature-element-editing-robustness Stage D — in-canvas drag-resize
|
|
8486
|
+
// commit. Same trust model + confused-deputy guard as reposition-request:
|
|
8487
|
+
// untrusted canvas REQUESTS, shell WRITES, pinned to the ACTIVE canvas
|
|
8488
|
+
// (never `m.canvas`). Payload: { id, patch:{width,height,left?,top?},
|
|
8489
|
+
// before:{width,height,left,top} } — px strings, before values null when
|
|
8490
|
+
// the prop was unset (reset on undo).
|
|
8491
|
+
const activeWin = activePath ? iframesRef.current.get(activePath)?.contentWindow : null;
|
|
8492
|
+
const okShape =
|
|
8493
|
+
typeof m.id === 'string' && m.patch && typeof m.patch === 'object';
|
|
8494
|
+
if (e.source === activeWin && okShape) {
|
|
8495
|
+
resizeElementRef.current?.(
|
|
8496
|
+
m.id,
|
|
8497
|
+
m.patch,
|
|
8498
|
+
m.before,
|
|
8499
|
+
Number.isInteger(m.idIndex) ? m.idIndex : undefined
|
|
8500
|
+
);
|
|
8501
|
+
}
|
|
8502
|
+
} else if (m.dgn === 'delete-request') {
|
|
8503
|
+
// feature-element-editing-robustness Stage I — delete an element (Del key
|
|
8504
|
+
// / context menu / toolbar in the canvas). Same confused-deputy guard as
|
|
8505
|
+
// reorder/reposition/resize: untrusted canvas REQUESTS, shell WRITES,
|
|
8506
|
+
// pinned to the ACTIVE canvas (never `m.canvas`).
|
|
8507
|
+
const activeWin = activePath ? iframesRef.current.get(activePath)?.contentWindow : null;
|
|
8508
|
+
if (e.source === activeWin && typeof m.id === 'string') {
|
|
8509
|
+
deleteElementShellRef.current?.(m.id, Number.isInteger(m.idIndex) ? m.idIndex : undefined);
|
|
8510
|
+
}
|
|
8511
|
+
} else if (m.dgn === 'duplicate-request') {
|
|
8512
|
+
// Cmd+D (Task L3) — duplicate the selected element. Confused-deputy gated
|
|
8513
|
+
// + pinned to the active canvas, like the other structural verbs.
|
|
8514
|
+
const activeWin = activePath ? iframesRef.current.get(activePath)?.contentWindow : null;
|
|
8515
|
+
if (e.source === activeWin && typeof m.id === 'string') {
|
|
8516
|
+
duplicateElementShellRef.current?.(
|
|
8517
|
+
m.id,
|
|
8518
|
+
Number.isInteger(m.idIndex) ? m.idIndex : undefined
|
|
8519
|
+
);
|
|
8520
|
+
}
|
|
8521
|
+
} else if (m.dgn === 'copy-style') {
|
|
8522
|
+
// Task L4 — capture the current selection's authored style (shell-side).
|
|
8523
|
+
const activeWin = activePath ? iframesRef.current.get(activePath)?.contentWindow : null;
|
|
8524
|
+
if (e.source === activeWin) copyStyleRef.current?.();
|
|
8525
|
+
} else if (m.dgn === 'paste-style') {
|
|
8526
|
+
// Task L4 — apply the copied style to the target element.
|
|
8527
|
+
const activeWin = activePath ? iframesRef.current.get(activePath)?.contentWindow : null;
|
|
8528
|
+
if (e.source === activeWin && typeof m.id === 'string') pasteStyleRef.current?.(m.id);
|
|
8529
|
+
} else if (m.dgn === 'insert-request') {
|
|
8530
|
+
// Stage I3 — insert a synthesized div/text/image relative to `refId`.
|
|
8531
|
+
const activeWin = activePath ? iframesRef.current.get(activePath)?.contentWindow : null;
|
|
8532
|
+
const okShape =
|
|
8533
|
+
typeof m.refId === 'string' &&
|
|
8534
|
+
(m.kind === 'div' || m.kind === 'text' || m.kind === 'image') &&
|
|
8535
|
+
(m.position === 'before' ||
|
|
8536
|
+
m.position === 'after' ||
|
|
8537
|
+
m.position === 'inside-start' ||
|
|
8538
|
+
m.position === 'inside-end');
|
|
8539
|
+
if (e.source === activeWin && okShape) {
|
|
8540
|
+
insertElementShellRef.current?.(m.refId, m.position, m.kind, {
|
|
8541
|
+
src: typeof m.src === 'string' ? m.src : undefined,
|
|
8542
|
+
refIndex: Number.isInteger(m.refIndex) ? m.refIndex : undefined,
|
|
8543
|
+
});
|
|
8544
|
+
}
|
|
8545
|
+
} else if (m.dgn === 'insert-image-request') {
|
|
8546
|
+
// Stage F/I3 — "Insert ▸ Image" from the canvas context menu. An image
|
|
8547
|
+
// needs a contained asset src, so the shell opens the AssetPicker; the
|
|
8548
|
+
// pick then drives insertElementShell(kind:'image'). Confused-deputy
|
|
8549
|
+
// gated + pinned to the active canvas like the other request verbs.
|
|
8550
|
+
const activeWin = activePath ? iframesRef.current.get(activePath)?.contentWindow : null;
|
|
8551
|
+
const okShape =
|
|
8552
|
+
typeof m.refId === 'string' &&
|
|
8553
|
+
(m.position === 'before' ||
|
|
8554
|
+
m.position === 'after' ||
|
|
8555
|
+
m.position === 'inside-start' ||
|
|
8556
|
+
m.position === 'inside-end');
|
|
8557
|
+
if (e.source === activeWin && okShape) {
|
|
8558
|
+
openAssetPickerRef.current?.({
|
|
8559
|
+
purpose: 'insert-image',
|
|
8560
|
+
canvas: activePath,
|
|
8561
|
+
refId: m.refId,
|
|
8562
|
+
position: m.position,
|
|
8563
|
+
refIndex: Number.isInteger(m.refIndex) ? m.refIndex : undefined,
|
|
8564
|
+
});
|
|
8565
|
+
}
|
|
8566
|
+
} else if (m.dgn === 'replace-media-request') {
|
|
8567
|
+
// Stage F2 — "Replace image…" from the canvas context menu. Opens the
|
|
8568
|
+
// AssetPicker in replace mode; the pick re-points src via edit-attr. The
|
|
8569
|
+
// context menu supplies the current src as the undo before-value.
|
|
8570
|
+
const activeWin = activePath ? iframesRef.current.get(activePath)?.contentWindow : null;
|
|
8571
|
+
if (e.source === activeWin && typeof m.id === 'string') {
|
|
8572
|
+
openAssetPickerRef.current?.({
|
|
8573
|
+
purpose: 'replace-src',
|
|
8574
|
+
canvas: activePath,
|
|
8575
|
+
id: m.id,
|
|
8576
|
+
before: typeof m.before === 'string' ? m.before : null,
|
|
8577
|
+
});
|
|
8578
|
+
}
|
|
8579
|
+
} else if (m.dgn === 'replace-annotation-media-request') {
|
|
8580
|
+
// Stage F3 — "Replace…" on an annotation ImageStroke/MediaRefStroke.
|
|
8581
|
+
// Opens the SAME AssetPicker; unlike F2 the pick is posted BACK DOWN to
|
|
8582
|
+
// the canvas (no data-cd-id to ride edit-attr — the annotation model owns
|
|
8583
|
+
// its own strokes, so the canvas iframe performs the write itself).
|
|
8584
|
+
const activeWin = activePath ? iframesRef.current.get(activePath)?.contentWindow : null;
|
|
8585
|
+
if (e.source === activeWin && typeof m.id === 'string') {
|
|
8586
|
+
openAssetPickerRef.current?.({
|
|
8587
|
+
purpose: 'replace-annotation-media',
|
|
8588
|
+
canvas: activePath,
|
|
8589
|
+
id: m.id,
|
|
8590
|
+
before: typeof m.before === 'string' ? m.before : null,
|
|
8591
|
+
});
|
|
8592
|
+
}
|
|
8593
|
+
} else if (m.dgn === 'insert-artboard-request') {
|
|
8594
|
+
// Stage I4 — insert a new empty artboard from a screen-size preset.
|
|
8595
|
+
const activeWin = activePath ? iframesRef.current.get(activePath)?.contentWindow : null;
|
|
8596
|
+
const okShape =
|
|
8597
|
+
typeof m.id === 'string' &&
|
|
8598
|
+
Number.isFinite(m.width) &&
|
|
8599
|
+
Number.isFinite(m.height);
|
|
8600
|
+
if (e.source === activeWin && okShape) {
|
|
8601
|
+
insertArtboardShellRef.current?.({
|
|
8602
|
+
id: m.id,
|
|
8603
|
+
label: typeof m.label === 'string' ? m.label : m.id,
|
|
8604
|
+
width: m.width,
|
|
8605
|
+
height: m.height,
|
|
8606
|
+
});
|
|
8607
|
+
}
|
|
8608
|
+
} else if (m.dgn === 'resize-artboard-request') {
|
|
8609
|
+
// Stage D4 — free-hand artboard resize (numeric width/height props).
|
|
8610
|
+
const activeWin = activePath ? iframesRef.current.get(activePath)?.contentWindow : null;
|
|
8611
|
+
const okShape =
|
|
8612
|
+
typeof m.artboardId === 'string' &&
|
|
8613
|
+
(Number.isFinite(m.width) || Number.isFinite(m.height));
|
|
8614
|
+
if (e.source === activeWin && okShape) {
|
|
8615
|
+
resizeArtboardShellRef.current?.(
|
|
8616
|
+
m.artboardId,
|
|
8617
|
+
Number.isFinite(m.width) ? m.width : undefined,
|
|
8618
|
+
Number.isFinite(m.height) ? m.height : undefined
|
|
8619
|
+
);
|
|
8620
|
+
}
|
|
8621
|
+
} else if (m.dgn === 'delete-artboard-request') {
|
|
8622
|
+
// Backspace / context-menu delete of a whole artboard (by its id prop).
|
|
8623
|
+
const activeWin = activePath ? iframesRef.current.get(activePath)?.contentWindow : null;
|
|
8624
|
+
if (e.source === activeWin && typeof m.artboardId === 'string') {
|
|
8625
|
+
deleteArtboardShellRef.current?.(m.artboardId);
|
|
8626
|
+
}
|
|
7160
8627
|
} else if (m.dgn === 'open-inspector') {
|
|
7161
8628
|
// Phase 12 — context-menu "Inspect" / tool-palette Inspect opens the right panel.
|
|
7162
8629
|
openRightPanel('inspector');
|
|
@@ -7221,6 +8688,49 @@ function App() {
|
|
|
7221
8688
|
// meta.json-derived seed when the canvas knows better. Clamp.
|
|
7222
8689
|
const n = Math.round(m.count);
|
|
7223
8690
|
if (Number.isFinite(n) && n >= 0 && n <= 999) setActiveArtboards(n);
|
|
8691
|
+
} else if (m.dgn === 'active-artboard') {
|
|
8692
|
+
// DDR-148 — canvas-lib reports the viewport-active artboard on pan. Gate
|
|
8693
|
+
// to the ACTIVE canvas window (a background canvas must not hijack the
|
|
8694
|
+
// Timeline's target). The Timeline re-parses to this artboard's comp.
|
|
8695
|
+
const activeWin =
|
|
8696
|
+
activePath && activePath !== SYSTEM_TAB
|
|
8697
|
+
? iframesRef.current.get(activePath)?.contentWindow
|
|
8698
|
+
: null;
|
|
8699
|
+
if (e.source !== activeWin) return;
|
|
8700
|
+
// Cap the id like the sibling timeline-comps handler (defensive; inert).
|
|
8701
|
+
setCanvasActiveArtboard(typeof m.id === 'string' ? m.id.slice(0, 120) : null);
|
|
8702
|
+
} else if (m.dgn === 'timeline-comps' && Array.isArray(m.comps)) {
|
|
8703
|
+
// DDR-148 — a video-comp announces its comp meta (from video-comp.tsx).
|
|
8704
|
+
// Gate to the ACTIVE canvas window (phase-28 F-2 pattern): a background
|
|
8705
|
+
// canvas must not plant comps into the Timeline panel, which shows the
|
|
8706
|
+
// active canvas's comps. Inert display, but keep the seam closed.
|
|
8707
|
+
const activeWin =
|
|
8708
|
+
activePath && activePath !== SYSTEM_TAB
|
|
8709
|
+
? iframesRef.current.get(activePath)?.contentWindow
|
|
8710
|
+
: null;
|
|
8711
|
+
if (e.source !== activeWin) return;
|
|
8712
|
+
const safe = m.comps
|
|
8713
|
+
.filter((c) => c && typeof c.id === 'string')
|
|
8714
|
+
.slice(0, 32)
|
|
8715
|
+
.map((c) => ({
|
|
8716
|
+
id: String(c.id).slice(0, 120),
|
|
8717
|
+
fps: Math.max(1, Math.min(120, Math.round(Number(c.fps) || 30))),
|
|
8718
|
+
durationInFrames: Math.max(1, Math.min(1_000_000, Math.round(Number(c.durationInFrames) || 1))),
|
|
8719
|
+
width: Math.max(1, Math.round(Number(c.width) || 0)),
|
|
8720
|
+
height: Math.max(1, Math.round(Number(c.height) || 0)),
|
|
8721
|
+
}));
|
|
8722
|
+
setActiveComps(safe);
|
|
8723
|
+
} else if (m.dgn === 'timeline-frame' && typeof m.frame === 'number') {
|
|
8724
|
+
// Live playhead mirror from the Player (preview scrub/playback).
|
|
8725
|
+
const activeWin =
|
|
8726
|
+
activePath && activePath !== SYSTEM_TAB
|
|
8727
|
+
? iframesRef.current.get(activePath)?.contentWindow
|
|
8728
|
+
: null;
|
|
8729
|
+
if (e.source !== activeWin) return;
|
|
8730
|
+
// Only the Timeline's target comp drives the playhead — a sibling
|
|
8731
|
+
// artboard's Player must not fight it for the readout (multi-comp canvas).
|
|
8732
|
+
if (m.id && timelineCompIdRef.current && m.id !== timelineCompIdRef.current) return;
|
|
8733
|
+
if (Number.isFinite(m.frame)) setTimelineFrame(Math.max(0, Math.round(m.frame)));
|
|
7224
8734
|
} else if (m.dgn === 'toggle-palette') {
|
|
7225
8735
|
// ⌘K pressed while focus was inside the canvas iframe — the injected
|
|
7226
8736
|
// inspector forwards the chord here since the iframe's keydown never
|
|
@@ -7357,6 +8867,15 @@ function App() {
|
|
|
7357
8867
|
const fn = /filename="([^"]+)"/.exec(disp);
|
|
7358
8868
|
const filename = (fn && fn[1]) || 'export';
|
|
7359
8869
|
const blob = await r.blob();
|
|
8870
|
+
if (isNativeApp()) {
|
|
8871
|
+
// Native: route the bridged in-canvas export through the OS save dialog
|
|
8872
|
+
// too (WKWebView swallows the `<a download>` blob). Mirror the shell
|
|
8873
|
+
// ExportDialog's doExport native branch.
|
|
8874
|
+
const bytes = Array.from(new Uint8Array(await blob.arrayBuffer()));
|
|
8875
|
+
const savedPath = await saveExport(filename, bytes);
|
|
8876
|
+
reply(savedPath ? { ok: true, filename } : { ok: false, error: 'Save cancelled' });
|
|
8877
|
+
return;
|
|
8878
|
+
}
|
|
7360
8879
|
const url = URL.createObjectURL(blob);
|
|
7361
8880
|
const a = document.createElement('a');
|
|
7362
8881
|
a.href = url;
|
|
@@ -7550,6 +9069,536 @@ function App() {
|
|
|
7550
9069
|
reorderLayerRef.current = reorderLayer;
|
|
7551
9070
|
}, [reorderLayer]);
|
|
7552
9071
|
|
|
9072
|
+
// Commit an in-canvas coordinate-mode drag (out-of-flow element: absolute/
|
|
9073
|
+
// fixed) as two sequential single-property writes through the SAME
|
|
9074
|
+
// main-origin endpoint the CSS panel already uses (`/_api/edit-css`, Phase
|
|
9075
|
+
// 12 / DDR-103) — no new write surface. `editStyleProp` upserts into the
|
|
9076
|
+
// existing `style={{}}` object, so the second call (top) lands next to the
|
|
9077
|
+
// first (left) rather than clobbering it. Serialized on the shared
|
|
9078
|
+
// apply-edit chain so it can't race an in-flight write to the same file.
|
|
9079
|
+
const repositionElement = useCallback(
|
|
9080
|
+
(id, left, top, beforeLeft, beforeTop, idIndex) => {
|
|
9081
|
+
if (!id || !activePath) return;
|
|
9082
|
+
const canvas = activePath;
|
|
9083
|
+
// Stage H3 — when the dragged target is a whole component INSTANCE the canvas
|
|
9084
|
+
// passes its DOM-occurrence index; the server routes the left/top write to
|
|
9085
|
+
// that instance's own `<Component/>` usage so moving one instance stays local.
|
|
9086
|
+
const occ = Number.isInteger(idIndex) ? idIndex : undefined;
|
|
9087
|
+
// INV-2 (DDR-105) — arm the reload-suppression window BEFORE the edit-css
|
|
9088
|
+
// writes so the HMR reload is skipped and the canvas doesn't remount + drop
|
|
9089
|
+
// the selection (dogfood: "když pohnu elementem myší, ztratím focus"). Same
|
|
9090
|
+
// fix as resizeElement; also covers the keyboard nudge (L1) which reuses this.
|
|
9091
|
+
applyOptimisticStyle({ id, prop: 'left', value: `${left}px` });
|
|
9092
|
+
applyOptimisticStyle({ id, prop: 'top', value: `${top}px` });
|
|
9093
|
+
const writeProp = (property, value) =>
|
|
9094
|
+
fetch('/_api/edit-css', {
|
|
9095
|
+
method: 'POST',
|
|
9096
|
+
headers: { 'content-type': 'application/json' },
|
|
9097
|
+
body: JSON.stringify({ canvas, id, property, value: `${value}px`, idIndex: occ }),
|
|
9098
|
+
}).then((r) => r.json().catch(() => ({})));
|
|
9099
|
+
editApplyChainRef.current = editApplyChainRef.current
|
|
9100
|
+
.catch(() => {})
|
|
9101
|
+
.then(() => writeProp('left', left))
|
|
9102
|
+
.then((j1) => {
|
|
9103
|
+
if (!j1.ok) throw new Error(j1.error || 'left write failed');
|
|
9104
|
+
return writeProp('top', top);
|
|
9105
|
+
})
|
|
9106
|
+
.then((j2) => {
|
|
9107
|
+
if (!j2.ok) throw new Error(j2.error || 'top write failed');
|
|
9108
|
+
// Record BOTH properties onto the canvas undo stack — two Cmd+Z
|
|
9109
|
+
// steps (top, then left), same as committing two CSS knobs by hand.
|
|
9110
|
+
recordSourceEdit({
|
|
9111
|
+
op: 'css',
|
|
9112
|
+
canvas,
|
|
9113
|
+
id,
|
|
9114
|
+
key: 'left',
|
|
9115
|
+
before: `${beforeLeft}px`,
|
|
9116
|
+
after: `${left}px`,
|
|
9117
|
+
});
|
|
9118
|
+
recordSourceEdit({
|
|
9119
|
+
op: 'css',
|
|
9120
|
+
canvas,
|
|
9121
|
+
id,
|
|
9122
|
+
key: 'top',
|
|
9123
|
+
before: `${beforeTop}px`,
|
|
9124
|
+
after: `${top}px`,
|
|
9125
|
+
});
|
|
9126
|
+
})
|
|
9127
|
+
.catch((err) => {
|
|
9128
|
+
console.warn('[reposition]', err?.message || err);
|
|
9129
|
+
// Nothing (or only `left`) persisted — tell the canvas to restore
|
|
9130
|
+
// the pre-drag inline style so a phantom move doesn't linger.
|
|
9131
|
+
postToActiveCanvas({ dgn: 'reposition-failed' });
|
|
9132
|
+
});
|
|
9133
|
+
},
|
|
9134
|
+
[activePath, postToActiveCanvas, recordSourceEdit, applyOptimisticStyle]
|
|
9135
|
+
);
|
|
9136
|
+
useEffect(() => {
|
|
9137
|
+
repositionElementRef.current = repositionElement;
|
|
9138
|
+
}, [repositionElement]);
|
|
9139
|
+
|
|
9140
|
+
// feature-element-editing-robustness Stage D (Task D3) — commit an in-canvas
|
|
9141
|
+
// drag-resize. `patch` = { width, height, left?, top? } (px strings); left/top
|
|
9142
|
+
// are present only for a top/left-edge drag on an out-of-flow element. Writes
|
|
9143
|
+
// each present property through the SAME main-origin `/_api/edit-css` endpoint
|
|
9144
|
+
// the CSS panel + reposition use (no new write surface), serialized on the
|
|
9145
|
+
// shared apply-edit chain, and records one undo entry per property (as
|
|
9146
|
+
// reposition records left+top). Pinned to `activePath` (never `m.canvas`) —
|
|
9147
|
+
// the confused-deputy guard DDR-138/DDR-054 established for reorder/reposition.
|
|
9148
|
+
const resizeElement = useCallback(
|
|
9149
|
+
(id, patch, before, idIndex) => {
|
|
9150
|
+
if (!id || !activePath || !patch || typeof patch !== 'object') return;
|
|
9151
|
+
const canvas = activePath;
|
|
9152
|
+
const b = before && typeof before === 'object' ? before : {};
|
|
9153
|
+
// Stage H3 — a whole-instance resize carries its DOM-occurrence index so the
|
|
9154
|
+
// width/height/left/top write lands on that instance's own `<Component/>`
|
|
9155
|
+
// usage (local), not the shared inner definition. undefined for a plain element.
|
|
9156
|
+
const occ = Number.isInteger(idIndex) ? idIndex : undefined;
|
|
9157
|
+
// `transform` rides the same lane for the rotate handle (Task L8);
|
|
9158
|
+
// `padding-*`/`gap` ride it for the on-canvas spacing drag (Stage J) — same
|
|
9159
|
+
// single-prop /_api/edit-css write + per-prop undo record, no new lane.
|
|
9160
|
+
const props = [
|
|
9161
|
+
'width',
|
|
9162
|
+
'height',
|
|
9163
|
+
'left',
|
|
9164
|
+
'top',
|
|
9165
|
+
'transform',
|
|
9166
|
+
'padding-top',
|
|
9167
|
+
'padding-right',
|
|
9168
|
+
'padding-bottom',
|
|
9169
|
+
'padding-left',
|
|
9170
|
+
'gap',
|
|
9171
|
+
].filter((p) => typeof patch[p] === 'string' && patch[p]);
|
|
9172
|
+
if (!props.length) return;
|
|
9173
|
+
// Dogfood 2026-07-07 — "resize paddingu nebo gap" was still deselecting on
|
|
9174
|
+
// completion. `applyOptimisticStyle` was posting {id, prop, value} only —
|
|
9175
|
+
// missing artboardId/index — so the canvas-side `apply-style` handler
|
|
9176
|
+
// resolved the UNSCOPED `[data-cd-id]` selector (dom-selection.ts
|
|
9177
|
+
// `resolveSelectionEl`/`scopedCdSelector`), which is a latent multi-
|
|
9178
|
+
// artboard/reused-component miss. Pull the current selection's own
|
|
9179
|
+
// artboardId/index (when it matches `id`) so every optimistic apply +
|
|
9180
|
+
// the belt-and-suspenders reselect below target the SAME instance.
|
|
9181
|
+
const curSel = selectedRef.current;
|
|
9182
|
+
const curOne = Array.isArray(curSel)
|
|
9183
|
+
? curSel.length === 1
|
|
9184
|
+
? curSel[0]
|
|
9185
|
+
: null
|
|
9186
|
+
: curSel;
|
|
9187
|
+
const selArtboardId = curOne && curOne.id === id ? (curOne.artboardId ?? null) : null;
|
|
9188
|
+
const selIndex = curOne && curOne.id === id ? (curOne.index ?? 0) : 0;
|
|
9189
|
+
// INV-2 (DDR-105) — arm the reload-suppression window BEFORE the edit-css
|
|
9190
|
+
// writes so the follow-up HMR is skipped. Without this the canvas remounts
|
|
9191
|
+
// and drops the selection (the "resize deselects the element" dogfood bug).
|
|
9192
|
+
for (const p of props) {
|
|
9193
|
+
applyOptimisticStyle({
|
|
9194
|
+
id,
|
|
9195
|
+
artboardId: selArtboardId,
|
|
9196
|
+
index: selIndex,
|
|
9197
|
+
prop: p,
|
|
9198
|
+
value: patch[p],
|
|
9199
|
+
});
|
|
9200
|
+
}
|
|
9201
|
+
const writeProp = (property, value) =>
|
|
9202
|
+
fetch('/_api/edit-css', {
|
|
9203
|
+
method: 'POST',
|
|
9204
|
+
headers: { 'content-type': 'application/json' },
|
|
9205
|
+
body: JSON.stringify({ canvas, id, property, value, idIndex: occ }),
|
|
9206
|
+
}).then((r) => r.json().catch(() => ({})));
|
|
9207
|
+
let chain = editApplyChainRef.current.catch(() => {});
|
|
9208
|
+
for (const p of props) {
|
|
9209
|
+
chain = chain.then((prev) => {
|
|
9210
|
+
if (prev && prev.ok === false) throw new Error(prev.error || `${p} write failed`);
|
|
9211
|
+
return writeProp(p, patch[p]);
|
|
9212
|
+
});
|
|
9213
|
+
}
|
|
9214
|
+
editApplyChainRef.current = chain
|
|
9215
|
+
.then((last) => {
|
|
9216
|
+
if (last && last.ok === false) throw new Error(last.error || 'resize write failed');
|
|
9217
|
+
// Record one undo entry per written property (each Cmd+Z reverts one).
|
|
9218
|
+
for (const p of props) {
|
|
9219
|
+
recordSourceEdit({ op: 'css', canvas, id, key: p, before: b[p] ?? null, after: patch[p] });
|
|
9220
|
+
}
|
|
9221
|
+
// Dogfood 2026-07-07 — belt-and-suspenders reselect, mirroring the
|
|
9222
|
+
// structural ops' `pendingReorderRef` re-settle. The DDR-105 suppression
|
|
9223
|
+
// window should make this a no-op (no reload → the selection was never
|
|
9224
|
+
// lost) — idempotent per `scheduleHaloRestore`'s own doc comment, so it's
|
|
9225
|
+
// safe to always fire, and it's the backstop if suppression ever misses
|
|
9226
|
+
// (a slow FS-watcher round trip past the 1.5s window, for example).
|
|
9227
|
+
scheduleHaloRestore({ id, file: canvas, artboardId: selArtboardId, index: selIndex });
|
|
9228
|
+
})
|
|
9229
|
+
.catch((err) => {
|
|
9230
|
+
console.warn('[resize]', err?.message || err);
|
|
9231
|
+
// Nothing (or only a prefix) persisted — tell the canvas to restore the
|
|
9232
|
+
// pre-drag inline style so a phantom resize doesn't linger.
|
|
9233
|
+
postToActiveCanvas({ dgn: 'resize-failed' });
|
|
9234
|
+
});
|
|
9235
|
+
},
|
|
9236
|
+
[activePath, postToActiveCanvas, recordSourceEdit, applyOptimisticStyle, scheduleHaloRestore]
|
|
9237
|
+
);
|
|
9238
|
+
useEffect(() => {
|
|
9239
|
+
resizeElementRef.current = resizeElement;
|
|
9240
|
+
}, [resizeElement]);
|
|
9241
|
+
|
|
9242
|
+
// feature-element-editing-robustness Stage I — general element structural edits
|
|
9243
|
+
// (delete / insert element / insert artboard) + Stage D4 (artboard resize).
|
|
9244
|
+
// Each is a main-origin-only write the untrusted canvas can only REQUEST over
|
|
9245
|
+
// the dgn:* bus; the shell performs it, pinned to `activePath` (confused-deputy
|
|
9246
|
+
// guard, DDR-054/138). Undo reuses the reorder command's whole-file seq revert
|
|
9247
|
+
// (record-edit op:'reorder') — a structural edit renumbers positional ids, so an
|
|
9248
|
+
// inverse descriptor goes stale (same reason reorder uses the server seq log).
|
|
9249
|
+
const structuralWriteRef = useRef(null);
|
|
9250
|
+
const structuralWrite = useCallback(
|
|
9251
|
+
(route, body, { label, onOk, onFail } = {}) => {
|
|
9252
|
+
if (!activePath) return;
|
|
9253
|
+
const canvas = activePath;
|
|
9254
|
+
editApplyChainRef.current = editApplyChainRef.current
|
|
9255
|
+
.catch(() => {})
|
|
9256
|
+
.then(() =>
|
|
9257
|
+
fetch(route, {
|
|
9258
|
+
method: 'POST',
|
|
9259
|
+
headers: { 'content-type': 'application/json' },
|
|
9260
|
+
body: JSON.stringify({ ...body, canvas }),
|
|
9261
|
+
})
|
|
9262
|
+
.then((r) => r.json().catch(() => ({})))
|
|
9263
|
+
.then((j) => {
|
|
9264
|
+
if (!j.ok) {
|
|
9265
|
+
console.warn(`[${route}]`, j.error || 'failed');
|
|
9266
|
+
onFail?.(j);
|
|
9267
|
+
return;
|
|
9268
|
+
}
|
|
9269
|
+
if (typeof j.seq === 'number') {
|
|
9270
|
+
postToActiveCanvas({
|
|
9271
|
+
dgn: 'record-edit',
|
|
9272
|
+
payload: { op: 'reorder', canvas, seq: j.seq, label: label || 'edit' },
|
|
9273
|
+
});
|
|
9274
|
+
}
|
|
9275
|
+
onOk?.(j, canvas);
|
|
9276
|
+
})
|
|
9277
|
+
.catch((err) => onFail?.({ error: err?.message }))
|
|
9278
|
+
);
|
|
9279
|
+
},
|
|
9280
|
+
[activePath, postToActiveCanvas]
|
|
9281
|
+
);
|
|
9282
|
+
useEffect(() => {
|
|
9283
|
+
structuralWriteRef.current = structuralWrite;
|
|
9284
|
+
}, [structuralWrite]);
|
|
9285
|
+
|
|
9286
|
+
const deleteElementShell = useCallback(
|
|
9287
|
+
(id, idIndex) => {
|
|
9288
|
+
// After the delete lands + HMR reloads, clear the (now-gone) selection.
|
|
9289
|
+
structuralWrite(
|
|
9290
|
+
'/_api/delete-element',
|
|
9291
|
+
{ id, idIndex: Number.isInteger(idIndex) ? idIndex : undefined },
|
|
9292
|
+
{ label: 'delete element', onOk: () => postToActiveCanvas({ dgn: 'selection-clear' }) }
|
|
9293
|
+
);
|
|
9294
|
+
},
|
|
9295
|
+
[structuralWrite, postToActiveCanvas]
|
|
9296
|
+
);
|
|
9297
|
+
|
|
9298
|
+
const insertElementShell = useCallback(
|
|
9299
|
+
(refId, position, kind, opts = {}) => {
|
|
9300
|
+
structuralWrite(
|
|
9301
|
+
'/_api/insert-element',
|
|
9302
|
+
{
|
|
9303
|
+
refId,
|
|
9304
|
+
position,
|
|
9305
|
+
kind,
|
|
9306
|
+
src: typeof opts.src === 'string' ? opts.src : undefined,
|
|
9307
|
+
refIndex: Number.isInteger(opts.refIndex) ? opts.refIndex : undefined,
|
|
9308
|
+
},
|
|
9309
|
+
{
|
|
9310
|
+
label: `insert ${kind}`,
|
|
9311
|
+
// Select the new element once the HMR reload lands (its id is stamped
|
|
9312
|
+
// on transpile — best-effort, like reorder's pendingReorderRef).
|
|
9313
|
+
onOk: (j, canvas) => {
|
|
9314
|
+
if (j.newId) pendingReorderRef.current = { file: canvas, movedId: j.newId, artboardId: null };
|
|
9315
|
+
},
|
|
9316
|
+
}
|
|
9317
|
+
);
|
|
9318
|
+
},
|
|
9319
|
+
[structuralWrite]
|
|
9320
|
+
);
|
|
9321
|
+
|
|
9322
|
+
const duplicateElementShell = useCallback(
|
|
9323
|
+
(id, idIndex) => {
|
|
9324
|
+
structuralWrite(
|
|
9325
|
+
'/_api/duplicate-element',
|
|
9326
|
+
{ id, idIndex: Number.isInteger(idIndex) ? idIndex : undefined },
|
|
9327
|
+
{
|
|
9328
|
+
label: 'duplicate element',
|
|
9329
|
+
// Select the copy once the HMR reload lands (best-effort, like insert).
|
|
9330
|
+
onOk: (j, canvas) => {
|
|
9331
|
+
if (j.newId) pendingReorderRef.current = { file: canvas, movedId: j.newId, artboardId: null };
|
|
9332
|
+
},
|
|
9333
|
+
}
|
|
9334
|
+
);
|
|
9335
|
+
},
|
|
9336
|
+
[structuralWrite]
|
|
9337
|
+
);
|
|
9338
|
+
|
|
9339
|
+
// Task L4 — copy-style / paste-style. Captures a selection's AUTHORED inline
|
|
9340
|
+
// styles (not resolved/computed, so DS-token + inherited values aren't baked in)
|
|
9341
|
+
// minus layout/geometry props, then applies them to another element via chained
|
|
9342
|
+
// edit-css writes (one per prop, like resize — N-step undo). Clipboard lives in
|
|
9343
|
+
// the shell so it survives selection + canvas changes.
|
|
9344
|
+
const copiedStyleRef = useRef(null);
|
|
9345
|
+
// Appearance-only: exclude position/size/margin so paste-style copies the LOOK,
|
|
9346
|
+
// not the layout (Figma parity — padding/color/border/shadow/font/… carry over).
|
|
9347
|
+
const PASTE_STYLE_EXCLUDE = useMemo(
|
|
9348
|
+
() =>
|
|
9349
|
+
new Set([
|
|
9350
|
+
'position',
|
|
9351
|
+
'top',
|
|
9352
|
+
'right',
|
|
9353
|
+
'bottom',
|
|
9354
|
+
'left',
|
|
9355
|
+
'inset',
|
|
9356
|
+
'width',
|
|
9357
|
+
'height',
|
|
9358
|
+
'min-width',
|
|
9359
|
+
'max-width',
|
|
9360
|
+
'min-height',
|
|
9361
|
+
'max-height',
|
|
9362
|
+
'margin',
|
|
9363
|
+
'margin-top',
|
|
9364
|
+
'margin-right',
|
|
9365
|
+
'margin-bottom',
|
|
9366
|
+
'margin-left',
|
|
9367
|
+
]),
|
|
9368
|
+
[]
|
|
9369
|
+
);
|
|
9370
|
+
const copyStyle = useCallback(() => {
|
|
9371
|
+
const sel = selectedRef.current;
|
|
9372
|
+
const one = Array.isArray(sel) ? (sel.length === 1 ? sel[0] : null) : sel;
|
|
9373
|
+
if (!one) return;
|
|
9374
|
+
const map = {};
|
|
9375
|
+
for (const [k, v] of Object.entries(one.authored || {})) {
|
|
9376
|
+
if (!PASTE_STYLE_EXCLUDE.has(k)) map[k] = v;
|
|
9377
|
+
}
|
|
9378
|
+
for (const [k, v] of Object.entries(one.customStyles || {})) {
|
|
9379
|
+
if (!PASTE_STYLE_EXCLUDE.has(k)) map[k] = v;
|
|
9380
|
+
}
|
|
9381
|
+
copiedStyleRef.current = Object.keys(map).length ? map : null;
|
|
9382
|
+
}, [PASTE_STYLE_EXCLUDE]);
|
|
9383
|
+
const pasteStyle = useCallback(
|
|
9384
|
+
(id) => {
|
|
9385
|
+
const canvas = activePath;
|
|
9386
|
+
const map = copiedStyleRef.current;
|
|
9387
|
+
if (!canvas || !id || !map) return;
|
|
9388
|
+
const entries = Object.entries(map);
|
|
9389
|
+
let chain = editApplyChainRef.current.catch(() => {});
|
|
9390
|
+
for (const [property, value] of entries) {
|
|
9391
|
+
applyOptimisticStyle({ id, prop: property, value }); // preview + arm no-flicker
|
|
9392
|
+
chain = chain.then(() =>
|
|
9393
|
+
fetch('/_api/edit-css', {
|
|
9394
|
+
method: 'POST',
|
|
9395
|
+
headers: { 'content-type': 'application/json' },
|
|
9396
|
+
body: JSON.stringify({ canvas, id, property, value }),
|
|
9397
|
+
})
|
|
9398
|
+
.then((r) => r.json().catch(() => ({})))
|
|
9399
|
+
.then((j) => {
|
|
9400
|
+
if (j.ok)
|
|
9401
|
+
recordSourceEdit({ op: 'css', canvas, id, key: property, before: null, after: value });
|
|
9402
|
+
})
|
|
9403
|
+
.catch(() => {})
|
|
9404
|
+
);
|
|
9405
|
+
}
|
|
9406
|
+
editApplyChainRef.current = chain;
|
|
9407
|
+
},
|
|
9408
|
+
[activePath, applyOptimisticStyle, recordSourceEdit]
|
|
9409
|
+
);
|
|
9410
|
+
|
|
9411
|
+
const insertArtboardShell = useCallback(
|
|
9412
|
+
({ id, label, width, height }) => {
|
|
9413
|
+
structuralWrite(
|
|
9414
|
+
'/_api/insert-artboard',
|
|
9415
|
+
{ id, label, width, height },
|
|
9416
|
+
{ label: 'insert artboard' }
|
|
9417
|
+
);
|
|
9418
|
+
},
|
|
9419
|
+
[structuralWrite]
|
|
9420
|
+
);
|
|
9421
|
+
|
|
9422
|
+
// Stage I4 — "New artboard: <preset>" from the Edit menu. Generates a unique
|
|
9423
|
+
// id (server 422s a dup, negligible with a random suffix) + a preset label/dims
|
|
9424
|
+
// and inserts an empty <DCArtboard> after the last one (runtime default-grid
|
|
9425
|
+
// places it; DDR-027). The new frame is then selectable + resizable.
|
|
9426
|
+
const onInsertArtboard = useCallback(
|
|
9427
|
+
(presetKey) => {
|
|
9428
|
+
const p = SCREEN_PRESETS[presetKey];
|
|
9429
|
+
if (!p) return;
|
|
9430
|
+
const id = `s${Math.random().toString(36).slice(2, 8)}`;
|
|
9431
|
+
insertArtboardShell({ id, label: p.label, width: p.width, height: p.height });
|
|
9432
|
+
},
|
|
9433
|
+
[insertArtboardShell]
|
|
9434
|
+
);
|
|
9435
|
+
|
|
9436
|
+
const resizeArtboardShell = useCallback(
|
|
9437
|
+
(artboardId, width, height) => {
|
|
9438
|
+
structuralWrite(
|
|
9439
|
+
'/_api/resize-artboard',
|
|
9440
|
+
{ artboardId, width, height },
|
|
9441
|
+
{
|
|
9442
|
+
label: 'resize artboard',
|
|
9443
|
+
// Stage D4 — the resize overlay applies a live inline-style preview
|
|
9444
|
+
// during the drag (no HMR yet); on a rejected/failed write, tell it to
|
|
9445
|
+
// restore the pre-drag box so a phantom resize doesn't linger.
|
|
9446
|
+
onFail: () => postToActiveCanvas({ dgn: 'resize-artboard-failed' }),
|
|
9447
|
+
}
|
|
9448
|
+
);
|
|
9449
|
+
},
|
|
9450
|
+
[structuralWrite, postToActiveCanvas]
|
|
9451
|
+
);
|
|
9452
|
+
|
|
9453
|
+
const deleteArtboardShell = useCallback(
|
|
9454
|
+
(artboardId) => {
|
|
9455
|
+
structuralWrite('/_api/delete-artboard', { artboardId }, { label: 'delete artboard' });
|
|
9456
|
+
},
|
|
9457
|
+
[structuralWrite]
|
|
9458
|
+
);
|
|
9459
|
+
// Refs the (stale-closure) onMessage handlers below read.
|
|
9460
|
+
const deleteElementShellRef = useRef(null);
|
|
9461
|
+
const insertElementShellRef = useRef(null);
|
|
9462
|
+
const insertArtboardShellRef = useRef(null);
|
|
9463
|
+
const resizeArtboardShellRef = useRef(null);
|
|
9464
|
+
const deleteArtboardShellRef = useRef(null);
|
|
9465
|
+
const duplicateElementShellRef = useRef(null);
|
|
9466
|
+
const copyStyleRef = useRef(null);
|
|
9467
|
+
const pasteStyleRef = useRef(null);
|
|
9468
|
+
useEffect(() => {
|
|
9469
|
+
deleteElementShellRef.current = deleteElementShell;
|
|
9470
|
+
insertElementShellRef.current = insertElementShell;
|
|
9471
|
+
insertArtboardShellRef.current = insertArtboardShell;
|
|
9472
|
+
resizeArtboardShellRef.current = resizeArtboardShell;
|
|
9473
|
+
deleteArtboardShellRef.current = deleteArtboardShell;
|
|
9474
|
+
duplicateElementShellRef.current = duplicateElementShell;
|
|
9475
|
+
copyStyleRef.current = copyStyle;
|
|
9476
|
+
pasteStyleRef.current = pasteStyle;
|
|
9477
|
+
}, [
|
|
9478
|
+
deleteElementShell,
|
|
9479
|
+
insertElementShell,
|
|
9480
|
+
insertArtboardShell,
|
|
9481
|
+
resizeArtboardShell,
|
|
9482
|
+
deleteArtboardShell,
|
|
9483
|
+
duplicateElementShell,
|
|
9484
|
+
copyStyle,
|
|
9485
|
+
pasteStyle,
|
|
9486
|
+
]);
|
|
9487
|
+
|
|
9488
|
+
// Shell-level Backspace/Delete guard. CRITICAL: in the Tauri desktop app, an
|
|
9489
|
+
// unhandled Backspace triggers WKWebView back-navigation, which reloads the
|
|
9490
|
+
// WHOLE app to "Starting…" (dogfood crash). When an artboard is selected, focus
|
|
9491
|
+
// sits on the shell (not the canvas iframe), so the in-canvas key handler never
|
|
9492
|
+
// sees the keydown — the shell must catch it. Preventing the default here stops
|
|
9493
|
+
// the back-nav universally; if a single artboard is the selection, also delete
|
|
9494
|
+
// it (Backspace parity with the context menu). Element delete stays in the
|
|
9495
|
+
// canvas iframe (which has focus when an element is selected; its keydown never
|
|
9496
|
+
// reaches this window, so there's no double-handling).
|
|
9497
|
+
useEffect(() => {
|
|
9498
|
+
const onKey = (e) => {
|
|
9499
|
+
if (e.key !== 'Backspace' && e.key !== 'Delete') return;
|
|
9500
|
+
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
|
9501
|
+
const t = e.target;
|
|
9502
|
+
const editable =
|
|
9503
|
+
t &&
|
|
9504
|
+
(t.tagName === 'INPUT' ||
|
|
9505
|
+
t.tagName === 'TEXTAREA' ||
|
|
9506
|
+
t.tagName === 'SELECT' ||
|
|
9507
|
+
t.isContentEditable);
|
|
9508
|
+
if (editable) return;
|
|
9509
|
+
if (!activePath || activePath === SYSTEM_TAB) return;
|
|
9510
|
+
e.preventDefault();
|
|
9511
|
+
const one = Array.isArray(selected) ? (selected.length === 1 ? selected[0] : null) : selected;
|
|
9512
|
+
if (one?.artboardId && !one.id) deleteArtboardShell(one.artboardId);
|
|
9513
|
+
};
|
|
9514
|
+
window.addEventListener('keydown', onKey);
|
|
9515
|
+
return () => window.removeEventListener('keydown', onKey);
|
|
9516
|
+
}, [activePath, selected, deleteArtboardShell]);
|
|
9517
|
+
|
|
9518
|
+
// feature-element-editing-robustness Stage F — AssetPicker request. `req` is
|
|
9519
|
+
// { purpose:'insert-image', refId, position, refIndex } (Insert ▸ Image) or
|
|
9520
|
+
// { purpose:'replace-src', id, before } (Media-section "Replace…"). null = closed.
|
|
9521
|
+
const [assetPickerReq, setAssetPickerReq] = useState(null);
|
|
9522
|
+
const openAssetPickerRef = useRef(null);
|
|
9523
|
+
useEffect(() => {
|
|
9524
|
+
openAssetPickerRef.current = (req) => setAssetPickerReq(req);
|
|
9525
|
+
}, []);
|
|
9526
|
+
const onAssetPicked = useCallback(
|
|
9527
|
+
(pickedPath) => {
|
|
9528
|
+
const req = assetPickerReq;
|
|
9529
|
+
setAssetPickerReq(null);
|
|
9530
|
+
if (!req || !pickedPath) return;
|
|
9531
|
+
// G3 security (DDR-152) — the request captured refId/id against the canvas
|
|
9532
|
+
// that was active when the picker opened; if the user switched canvases
|
|
9533
|
+
// while the modal was up, those ids are meaningless (or worse, collide) on
|
|
9534
|
+
// the now-active canvas. Abort rather than write to the wrong file.
|
|
9535
|
+
if (req.canvas && req.canvas !== activePath) {
|
|
9536
|
+
console.warn('[asset-picker] active canvas changed since request — aborting');
|
|
9537
|
+
return;
|
|
9538
|
+
}
|
|
9539
|
+
if (req.purpose === 'insert-image') {
|
|
9540
|
+
insertElementShell(req.refId, req.position || 'after', 'image', {
|
|
9541
|
+
src: pickedPath,
|
|
9542
|
+
refIndex: req.refIndex,
|
|
9543
|
+
});
|
|
9544
|
+
return;
|
|
9545
|
+
}
|
|
9546
|
+
if (req.purpose === 'replace-src') {
|
|
9547
|
+
// Re-point an authored <img>/<video> src via /_api/edit-attr (+ undo).
|
|
9548
|
+
const canvas = activePath;
|
|
9549
|
+
if (!canvas || !req.id) return;
|
|
9550
|
+
editApplyChainRef.current = editApplyChainRef.current
|
|
9551
|
+
.catch(() => {})
|
|
9552
|
+
.then(() =>
|
|
9553
|
+
fetch('/_api/edit-attr', {
|
|
9554
|
+
method: 'POST',
|
|
9555
|
+
headers: { 'content-type': 'application/json' },
|
|
9556
|
+
body: JSON.stringify({ canvas, id: req.id, attr: 'src', value: pickedPath }),
|
|
9557
|
+
})
|
|
9558
|
+
.then((r) => r.json().catch(() => ({})))
|
|
9559
|
+
.then((j) => {
|
|
9560
|
+
if (j.ok) {
|
|
9561
|
+
recordSourceEdit({
|
|
9562
|
+
op: 'attr',
|
|
9563
|
+
canvas,
|
|
9564
|
+
id: req.id,
|
|
9565
|
+
key: 'src',
|
|
9566
|
+
before: req.before ?? null,
|
|
9567
|
+
after: pickedPath,
|
|
9568
|
+
});
|
|
9569
|
+
} else {
|
|
9570
|
+
console.warn('[replace-src]', j.error || 'failed');
|
|
9571
|
+
}
|
|
9572
|
+
})
|
|
9573
|
+
.catch(() => {})
|
|
9574
|
+
);
|
|
9575
|
+
return;
|
|
9576
|
+
}
|
|
9577
|
+
if (req.purpose === 'replace-annotation-media') {
|
|
9578
|
+
// Stage F3 — relay the picked path DOWN to the canvas; the annotation
|
|
9579
|
+
// model owns its own strokes + persistence + undo (`commitStrokes`), so
|
|
9580
|
+
// unlike `replace-src` the shell performs no write of its own here.
|
|
9581
|
+
postToActiveCanvas({ dgn: 'replace-annotation-media', id: req.id, path: pickedPath });
|
|
9582
|
+
}
|
|
9583
|
+
},
|
|
9584
|
+
[assetPickerReq, insertElementShell, activePath, recordSourceEdit, postToActiveCanvas]
|
|
9585
|
+
);
|
|
9586
|
+
// Media-section "Replace…" (CssKnobs) → open the picker in replace mode with
|
|
9587
|
+
// the element's current src as the undo before-value (captured from the
|
|
9588
|
+
// Selection's `attrs.src`, not the resolved URL).
|
|
9589
|
+
const onReplaceMedia = useCallback(
|
|
9590
|
+
(elSel) => {
|
|
9591
|
+
if (!elSel?.id) return;
|
|
9592
|
+
setAssetPickerReq({
|
|
9593
|
+
purpose: 'replace-src',
|
|
9594
|
+
canvas: activePath,
|
|
9595
|
+
id: elSel.id,
|
|
9596
|
+
before: elSel.attrs?.src ?? null,
|
|
9597
|
+
});
|
|
9598
|
+
},
|
|
9599
|
+
[activePath]
|
|
9600
|
+
);
|
|
9601
|
+
|
|
7553
9602
|
const resolveComment = useCallback((id) => {
|
|
7554
9603
|
wsSend({ type: 'comments-patch', id, patch: { status: 'resolved' } });
|
|
7555
9604
|
}, []);
|
|
@@ -7625,6 +9674,18 @@ function App() {
|
|
|
7625
9674
|
// makes inspector CSS / inline text / attr edits undoable from anywhere.
|
|
7626
9675
|
if (meta && !e.altKey && (e.key === 'z' || e.key === 'Z' || e.key === 'y' || e.key === 'Y')) {
|
|
7627
9676
|
if (!inEditable && !inCanvasIframe && activePath && activePath !== SYSTEM_TAB) {
|
|
9677
|
+
// DDR-150 dogfood #1 (team finding) — SINGLE OWNER per keypress. With
|
|
9678
|
+
// the Timeline open on a video-comp canvas, the timeline undo stack
|
|
9679
|
+
// owns Cmd+Z/Shift+Cmd+Z (the timeline keydown effect performs it).
|
|
9680
|
+
// Without this skip, BOTH handlers fired on one keypress — undoing a
|
|
9681
|
+
// clip op AND popping the canvas's annotation undo simultaneously.
|
|
9682
|
+
if (
|
|
9683
|
+
(e.key === 'z' || e.key === 'Z') &&
|
|
9684
|
+
tlKeyRef.current.open &&
|
|
9685
|
+
tlKeyRef.current.comps?.length
|
|
9686
|
+
) {
|
|
9687
|
+
return; // the timeline shortcuts effect claims it
|
|
9688
|
+
}
|
|
7628
9689
|
e.preventDefault();
|
|
7629
9690
|
const redo = e.key === 'y' || e.key === 'Y' || e.shiftKey;
|
|
7630
9691
|
postToActiveCanvas({ dgn: redo ? 'redo' : 'undo' });
|
|
@@ -8007,6 +10068,12 @@ function App() {
|
|
|
8007
10068
|
inspectorOpen={inspectorOpen}
|
|
8008
10069
|
inspectorTab={inspectorTab}
|
|
8009
10070
|
onToggleInspector={() => toggleRightPanel('inspector')}
|
|
10071
|
+
autoOpenInspector={autoOpenInspector}
|
|
10072
|
+
onToggleAutoOpenInspector={() => setAutoOpenInspector((v) => !v)}
|
|
10073
|
+
onInsertArtboard={onInsertArtboard}
|
|
10074
|
+
timelineOpen={timelineOpen}
|
|
10075
|
+
onToggleTimeline={toggleTimeline}
|
|
10076
|
+
hasComps={activeComps.length > 0}
|
|
8010
10077
|
assistantOpen={assistantOpen}
|
|
8011
10078
|
onToggleAssistant={() => toggleRightPanel('assistant')}
|
|
8012
10079
|
assistantBusy={assistantBusy}
|
|
@@ -8028,6 +10095,7 @@ function App() {
|
|
|
8028
10095
|
60
|
|
8029
10096
|
);
|
|
8030
10097
|
}}
|
|
10098
|
+
onAssembleVideo={assembleVideo}
|
|
8031
10099
|
onOpenExport={(mode) => setExportDialog({ mode })}
|
|
8032
10100
|
onReload={reloadActive}
|
|
8033
10101
|
onCloseCanvas={() => activePath && closeTab(activePath)}
|
|
@@ -8160,8 +10228,12 @@ function App() {
|
|
|
8160
10228
|
onClose={() => setInspectorOpen(false)}
|
|
8161
10229
|
onOptimistic={applyOptimisticStyle}
|
|
8162
10230
|
onRecordEdit={recordSourceEdit}
|
|
10231
|
+
onReplaceMedia={onReplaceMedia}
|
|
10232
|
+
onResizeArtboard={resizeArtboardShell}
|
|
10233
|
+
editScope={editScope}
|
|
8163
10234
|
onUndoRedo={(dir) => postToActiveCanvas({ dgn: dir })}
|
|
8164
10235
|
layersTree={layersTree}
|
|
10236
|
+
canvasFile={activePath}
|
|
8165
10237
|
onSelectLayer={(n) =>
|
|
8166
10238
|
postToActiveCanvas({
|
|
8167
10239
|
dgn: 'select-by-id',
|
|
@@ -8219,6 +10291,347 @@ function App() {
|
|
|
8219
10291
|
/>
|
|
8220
10292
|
)}
|
|
8221
10293
|
</div>
|
|
10294
|
+
{/* DDR-148 — Timeline is a BOTTOM dock (full-width strip below the stage,
|
|
10295
|
+
above the status bar) — video timelines are horizontal. */}
|
|
10296
|
+
{timelineOpen && (
|
|
10297
|
+
<TimelinePanel
|
|
10298
|
+
comps={activeComps}
|
|
10299
|
+
sequences={timelineSequences}
|
|
10300
|
+
audio={timelineAudio}
|
|
10301
|
+
total={timelineTotal}
|
|
10302
|
+
frame={timelineFrame}
|
|
10303
|
+
playing={timelinePlaying}
|
|
10304
|
+
loop={timelineLoop}
|
|
10305
|
+
onSeek={(f) => {
|
|
10306
|
+
setTimelineFrame(f);
|
|
10307
|
+
setTimelinePlaying(false);
|
|
10308
|
+
postToActiveCanvas({ dgn: 'timeline-seek', frame: f, id: timelineCompId });
|
|
10309
|
+
}}
|
|
10310
|
+
onPlay={() => {
|
|
10311
|
+
setTimelinePlaying(true);
|
|
10312
|
+
// Sync mute + loop to the Player, then play (the artboard has no
|
|
10313
|
+
// chrome — the Timeline owns transport/sound/loop now).
|
|
10314
|
+
postToActiveCanvas({ dgn: 'timeline-mute', muted: timelineMuted, id: timelineCompId });
|
|
10315
|
+
postToActiveCanvas({ dgn: 'timeline-loop', loop: timelineLoop, id: timelineCompId });
|
|
10316
|
+
postToActiveCanvas({ dgn: 'timeline-play', id: timelineCompId });
|
|
10317
|
+
}}
|
|
10318
|
+
onPause={() => {
|
|
10319
|
+
setTimelinePlaying(false);
|
|
10320
|
+
postToActiveCanvas({ dgn: 'timeline-pause', id: timelineCompId });
|
|
10321
|
+
}}
|
|
10322
|
+
onToggleLoop={() =>
|
|
10323
|
+
setTimelineLoop((v) => {
|
|
10324
|
+
const next = !v;
|
|
10325
|
+
postToActiveCanvas({ dgn: 'timeline-loop', loop: next, id: timelineCompId });
|
|
10326
|
+
return next;
|
|
10327
|
+
})
|
|
10328
|
+
}
|
|
10329
|
+
muted={timelineMuted}
|
|
10330
|
+
onToggleMute={() => {
|
|
10331
|
+
setTimelineMuted((v) => {
|
|
10332
|
+
const next = !v;
|
|
10333
|
+
postToActiveCanvas({ dgn: 'timeline-mute', muted: next, id: timelineCompId });
|
|
10334
|
+
return next;
|
|
10335
|
+
});
|
|
10336
|
+
}}
|
|
10337
|
+
volume={timelineVolume}
|
|
10338
|
+
onVolume={(v) => {
|
|
10339
|
+
setTimelineVolume(v);
|
|
10340
|
+
// Dragging volume implies "I want to hear it" — unmute.
|
|
10341
|
+
if (v > 0 && timelineMuted) {
|
|
10342
|
+
setTimelineMuted(false);
|
|
10343
|
+
postToActiveCanvas({ dgn: 'timeline-mute', muted: false, id: timelineCompId });
|
|
10344
|
+
}
|
|
10345
|
+
postToActiveCanvas({ dgn: 'timeline-volume', volume: v, id: timelineCompId });
|
|
10346
|
+
}}
|
|
10347
|
+
onRetime={(index, patch) => {
|
|
10348
|
+
if (!activePath || activePath === SYSTEM_TAB) return;
|
|
10349
|
+
// DDR-150 P2 — address the clip by its comp-scoped stableId (from
|
|
10350
|
+
// /_api/comp-clips), NOT the raw row index, so a retime on a
|
|
10351
|
+
// multi-comp canvas can never mis-hit a clip in another comp. The
|
|
10352
|
+
// row index counts sequence rows only, so map it to the index-th
|
|
10353
|
+
// sequence clip (transitions filtered). Fall back to the legacy
|
|
10354
|
+
// index if the enumerator is unavailable.
|
|
10355
|
+
const artboardId = timelineArtboardId || undefined;
|
|
10356
|
+
const ccUrl = `/_api/comp-clips?canvas=${encodeURIComponent(activePath)}${artboardId ? `&artboardId=${encodeURIComponent(artboardId)}` : ''}`;
|
|
10357
|
+
fetch(ccUrl)
|
|
10358
|
+
.then((r) => r.json().catch(() => ({})))
|
|
10359
|
+
.then((cc) => {
|
|
10360
|
+
const seqs =
|
|
10361
|
+
cc?.ok && Array.isArray(cc.clips)
|
|
10362
|
+
? cc.clips.filter((c) => c.kind === 'sequence')
|
|
10363
|
+
: [];
|
|
10364
|
+
const clip = seqs[index] || null;
|
|
10365
|
+
const body = clip?.stableId
|
|
10366
|
+
? {
|
|
10367
|
+
canvas: activePath,
|
|
10368
|
+
artboardId,
|
|
10369
|
+
stableId: clip.stableId,
|
|
10370
|
+
contentHash: clip.contentHash,
|
|
10371
|
+
...patch,
|
|
10372
|
+
}
|
|
10373
|
+
: { canvas: activePath, index, ...patch };
|
|
10374
|
+
return fetch('/_api/retime-sequence', {
|
|
10375
|
+
method: 'POST',
|
|
10376
|
+
headers: { 'content-type': 'application/json' },
|
|
10377
|
+
body: JSON.stringify(body),
|
|
10378
|
+
});
|
|
10379
|
+
})
|
|
10380
|
+
.then((r) => r.json())
|
|
10381
|
+
.then((j) => {
|
|
10382
|
+
if (!j?.ok) {
|
|
10383
|
+
console.warn('[retime]', j?.error || 'failed');
|
|
10384
|
+
shellToast(`Retime refused: ${j?.error || 'failed'}`);
|
|
10385
|
+
} else if (j?.seq != null) {
|
|
10386
|
+
pushTlUndo(activePath, j.seq, patch.from != null ? 'move clip' : 'trim clip');
|
|
10387
|
+
}
|
|
10388
|
+
// The file watcher reloads the canvas → re-announce → the
|
|
10389
|
+
// source-fetch effect re-parses the new timing.
|
|
10390
|
+
})
|
|
10391
|
+
.catch(() => {});
|
|
10392
|
+
}}
|
|
10393
|
+
onRemove={(index) => {
|
|
10394
|
+
if (!activePath || activePath === SYSTEM_TAB) return;
|
|
10395
|
+
// DDR-150 P3 — remove the clip addressed by its comp-scoped stableId
|
|
10396
|
+
// (the sequence-row index → the index-th sequence clip). The engine
|
|
10397
|
+
// fingerprint + semantic gate refuse a stale/raced or series-breaking
|
|
10398
|
+
// removal; the file watcher reloads the canvas after.
|
|
10399
|
+
const artboardId = timelineArtboardId || undefined;
|
|
10400
|
+
const ccUrl = `/_api/comp-clips?canvas=${encodeURIComponent(activePath)}${artboardId ? `&artboardId=${encodeURIComponent(artboardId)}` : ''}`;
|
|
10401
|
+
fetch(ccUrl)
|
|
10402
|
+
.then((r) => r.json().catch(() => ({})))
|
|
10403
|
+
.then((cc) => {
|
|
10404
|
+
const seqs =
|
|
10405
|
+
cc?.ok && Array.isArray(cc.clips)
|
|
10406
|
+
? cc.clips.filter((c) => c.kind === 'sequence')
|
|
10407
|
+
: [];
|
|
10408
|
+
const clip = seqs[index] || null;
|
|
10409
|
+
if (!clip?.stableId) return null;
|
|
10410
|
+
return fetch('/_api/remove-sequence', {
|
|
10411
|
+
method: 'POST',
|
|
10412
|
+
headers: { 'content-type': 'application/json' },
|
|
10413
|
+
body: JSON.stringify({
|
|
10414
|
+
canvas: activePath,
|
|
10415
|
+
artboardId,
|
|
10416
|
+
stableId: clip.stableId,
|
|
10417
|
+
contentHash: clip.contentHash,
|
|
10418
|
+
}),
|
|
10419
|
+
});
|
|
10420
|
+
})
|
|
10421
|
+
.then((r) => (r ? r.json() : null))
|
|
10422
|
+
.then((j) => {
|
|
10423
|
+
if (j && !j.ok) {
|
|
10424
|
+
console.warn('[remove-clip]', j.error || 'failed');
|
|
10425
|
+
shellToast(`Remove refused: ${j.error || 'failed'}`);
|
|
10426
|
+
} else if (j?.seq != null) {
|
|
10427
|
+
pushTlUndo(activePath, j.seq, 'remove clip');
|
|
10428
|
+
}
|
|
10429
|
+
})
|
|
10430
|
+
.catch(() => {});
|
|
10431
|
+
}}
|
|
10432
|
+
onReplace={(index) => {
|
|
10433
|
+
if (!activePath || activePath === SYSTEM_TAB) return;
|
|
10434
|
+
// DDR-150 P3 + dogfood #5 — replace a clip's media. The file picker
|
|
10435
|
+
// MUST open synchronously inside the click gesture: browsers revoke
|
|
10436
|
+
// the transient user-activation after an await/fetch round-trip, so
|
|
10437
|
+
// the old fetch-then-click() silently no-oped ("replace neotevře
|
|
10438
|
+
// žádné okno"). Picker first; resolve the target + upload in the
|
|
10439
|
+
// change handler.
|
|
10440
|
+
replaceMediaViaPicker({
|
|
10441
|
+
accept: 'video/*,image/*',
|
|
10442
|
+
resolveTarget: (cc) => {
|
|
10443
|
+
const seqs =
|
|
10444
|
+
cc?.ok && Array.isArray(cc.clips)
|
|
10445
|
+
? cc.clips.filter((c) => c.kind === 'sequence')
|
|
10446
|
+
: [];
|
|
10447
|
+
const clip = seqs[index];
|
|
10448
|
+
if (clip?.mediaArrayRef) return { arrayRef: clip.mediaArrayRef };
|
|
10449
|
+
if (clip?.mediaCdId) return { cdId: clip.mediaCdId };
|
|
10450
|
+
return null;
|
|
10451
|
+
},
|
|
10452
|
+
});
|
|
10453
|
+
}}
|
|
10454
|
+
onReplaceAudio={(index) => {
|
|
10455
|
+
if (!activePath || activePath === SYSTEM_TAB) return;
|
|
10456
|
+
// DDR-150 dogfood #5 — audio beds are addressable too: the
|
|
10457
|
+
// enumerator lists loose media (an <Audio> under the reel) with a
|
|
10458
|
+
// cd-id; ⇄ on the audio row swaps its src.
|
|
10459
|
+
replaceMediaViaPicker({
|
|
10460
|
+
accept: 'audio/*',
|
|
10461
|
+
resolveTarget: (cc) => {
|
|
10462
|
+
const beds =
|
|
10463
|
+
cc?.ok && Array.isArray(cc.media)
|
|
10464
|
+
? cc.media.filter((m) => m.tag === 'Audio')
|
|
10465
|
+
: [];
|
|
10466
|
+
return beds[index]?.cdId ? { cdId: beds[index].cdId } : null;
|
|
10467
|
+
},
|
|
10468
|
+
});
|
|
10469
|
+
}}
|
|
10470
|
+
onReplaceLayer={(clipIndex, layerIndex) => {
|
|
10471
|
+
if (!activePath || activePath === SYSTEM_TAB) return;
|
|
10472
|
+
// DDR-150 dogfood — replace a SPECIFIC layer inside an expanded clip
|
|
10473
|
+
// (the mp4 background separately from the title layer). Targets the
|
|
10474
|
+
// layer's own media (array-fed or literal-src) from the enumerator.
|
|
10475
|
+
const kind =
|
|
10476
|
+
timelineSequences[clipIndex]?.layers?.[layerIndex]?.kind === 'audio'
|
|
10477
|
+
? 'audio/*'
|
|
10478
|
+
: timelineSequences[clipIndex]?.layers?.[layerIndex]?.kind === 'image'
|
|
10479
|
+
? 'image/*'
|
|
10480
|
+
: 'video/*';
|
|
10481
|
+
replaceMediaViaPicker({
|
|
10482
|
+
accept: kind,
|
|
10483
|
+
resolveTarget: (cc) => {
|
|
10484
|
+
const seqs =
|
|
10485
|
+
cc?.ok && Array.isArray(cc.clips)
|
|
10486
|
+
? cc.clips.filter((c) => c.kind === 'sequence')
|
|
10487
|
+
: [];
|
|
10488
|
+
const ly = seqs[clipIndex]?.layers?.[layerIndex];
|
|
10489
|
+
if (ly?.mediaArrayRef) return { arrayRef: ly.mediaArrayRef };
|
|
10490
|
+
if (ly?.mediaCdId) return { cdId: ly.mediaCdId };
|
|
10491
|
+
return null;
|
|
10492
|
+
},
|
|
10493
|
+
});
|
|
10494
|
+
}}
|
|
10495
|
+
onReorder={(index, direction) => {
|
|
10496
|
+
if (!activePath || activePath === SYSTEM_TAB) return;
|
|
10497
|
+
// DDR-150 P5 — z-order reorder: move a standalone <Sequence> before/
|
|
10498
|
+
// after a sibling (render stacking; later sibling paints on top).
|
|
10499
|
+
// ▲ forward = move AFTER the next sibling; ▼ backward = move BEFORE
|
|
10500
|
+
// the previous sibling. Both clips addressed by comp-scoped stableId +
|
|
10501
|
+
// fingerprint (via /_api/comp-clips); the engine refuses a TransitionSeries
|
|
10502
|
+
// clip + a stale/raced target, then reloads via the file watcher.
|
|
10503
|
+
const artboardId = timelineArtboardId || undefined;
|
|
10504
|
+
const ccUrl = `/_api/comp-clips?canvas=${encodeURIComponent(activePath)}${artboardId ? `&artboardId=${encodeURIComponent(artboardId)}` : ''}`;
|
|
10505
|
+
fetch(ccUrl)
|
|
10506
|
+
.then((r) => r.json().catch(() => ({})))
|
|
10507
|
+
.then((cc) => {
|
|
10508
|
+
const seqs =
|
|
10509
|
+
cc?.ok && Array.isArray(cc.clips)
|
|
10510
|
+
? cc.clips.filter((c) => c.kind === 'sequence')
|
|
10511
|
+
: [];
|
|
10512
|
+
const moved = seqs[index] || null;
|
|
10513
|
+
const refIdx = direction === 'forward' ? index + 1 : index - 1;
|
|
10514
|
+
const ref = seqs[refIdx] || null;
|
|
10515
|
+
const position = direction === 'forward' ? 'after' : 'before';
|
|
10516
|
+
if (!moved?.stableId || !ref?.stableId) return null;
|
|
10517
|
+
return fetch('/_api/reorder-sequence', {
|
|
10518
|
+
method: 'POST',
|
|
10519
|
+
headers: { 'content-type': 'application/json' },
|
|
10520
|
+
body: JSON.stringify({
|
|
10521
|
+
canvas: activePath,
|
|
10522
|
+
artboardId,
|
|
10523
|
+
stableId: moved.stableId,
|
|
10524
|
+
contentHash: moved.contentHash,
|
|
10525
|
+
refStableId: ref.stableId,
|
|
10526
|
+
refContentHash: ref.contentHash,
|
|
10527
|
+
position,
|
|
10528
|
+
}),
|
|
10529
|
+
});
|
|
10530
|
+
})
|
|
10531
|
+
.then((r) => (r ? r.json() : null))
|
|
10532
|
+
.then((j) => {
|
|
10533
|
+
if (j && !j.ok) {
|
|
10534
|
+
console.warn('[reorder-clip]', j.error || 'failed');
|
|
10535
|
+
shellToast(`Reorder refused: ${j.error || 'failed'}`);
|
|
10536
|
+
} else if (j?.seq != null) {
|
|
10537
|
+
pushTlUndo(activePath, j.seq, 'reorder clip');
|
|
10538
|
+
}
|
|
10539
|
+
})
|
|
10540
|
+
.catch(() => {});
|
|
10541
|
+
}}
|
|
10542
|
+
onToggleHide={(index) => {
|
|
10543
|
+
if (!activePath || activePath === SYSTEM_TAB) return;
|
|
10544
|
+
// DDR-150 dogfood — hide/show a clip (gates its body behind
|
|
10545
|
+
// {false && …}; the tag + time slot stay). Addressed by comp-scoped
|
|
10546
|
+
// stableId + fingerprint.
|
|
10547
|
+
const artboardId = timelineArtboardId || undefined;
|
|
10548
|
+
const ccUrl = `/_api/comp-clips?canvas=${encodeURIComponent(activePath)}${artboardId ? `&artboardId=${encodeURIComponent(artboardId)}` : ''}`;
|
|
10549
|
+
fetch(ccUrl)
|
|
10550
|
+
.then((r) => r.json().catch(() => ({})))
|
|
10551
|
+
.then((cc) => {
|
|
10552
|
+
const seqs =
|
|
10553
|
+
cc?.ok && Array.isArray(cc.clips)
|
|
10554
|
+
? cc.clips.filter((c) => c.kind === 'sequence')
|
|
10555
|
+
: [];
|
|
10556
|
+
const clip = seqs[index] || null;
|
|
10557
|
+
if (!clip?.stableId) return null;
|
|
10558
|
+
return fetch('/_api/toggle-hide', {
|
|
10559
|
+
method: 'POST',
|
|
10560
|
+
headers: { 'content-type': 'application/json' },
|
|
10561
|
+
body: JSON.stringify({
|
|
10562
|
+
canvas: activePath,
|
|
10563
|
+
artboardId,
|
|
10564
|
+
stableId: clip.stableId,
|
|
10565
|
+
contentHash: clip.contentHash,
|
|
10566
|
+
}),
|
|
10567
|
+
});
|
|
10568
|
+
})
|
|
10569
|
+
.then((r) => (r ? r.json() : null))
|
|
10570
|
+
.then((j) => {
|
|
10571
|
+
if (j && !j.ok) shellToast(`Hide refused: ${j.error || 'failed'}`);
|
|
10572
|
+
else if (j && j.ok) {
|
|
10573
|
+
shellToast(j.hidden ? 'Clip hidden.' : 'Clip shown.', true);
|
|
10574
|
+
if (j.seq != null) pushTlUndo(activePath, j.seq, j.hidden ? 'hide clip' : 'show clip');
|
|
10575
|
+
}
|
|
10576
|
+
})
|
|
10577
|
+
.catch(() => shellToast('Hide failed: network error'));
|
|
10578
|
+
}}
|
|
10579
|
+
onDropMedia={(file) => {
|
|
10580
|
+
if (!activePath || activePath === SYSTEM_TAB) return;
|
|
10581
|
+
// DDR-150 P4 Task 11 — drop a media file onto the timeline to
|
|
10582
|
+
// insert a clip: upload to assets/, then append a <Sequence> with
|
|
10583
|
+
// the media (playing after existing content). fps*3 default length.
|
|
10584
|
+
const artboardId = timelineArtboardId || undefined;
|
|
10585
|
+
const mediaTag = file.type.startsWith('video/')
|
|
10586
|
+
? 'Video'
|
|
10587
|
+
: file.type.startsWith('audio/')
|
|
10588
|
+
? 'Audio'
|
|
10589
|
+
: file.type.startsWith('image/')
|
|
10590
|
+
? 'Img'
|
|
10591
|
+
: null;
|
|
10592
|
+
const fps = activeComps[0]?.fps || 30;
|
|
10593
|
+
fetch('/_api/asset', {
|
|
10594
|
+
method: 'POST',
|
|
10595
|
+
headers: { 'Content-Type': file.type || 'application/octet-stream' },
|
|
10596
|
+
body: file,
|
|
10597
|
+
})
|
|
10598
|
+
.then((r) => r.json().catch(() => ({})))
|
|
10599
|
+
.then((up) => {
|
|
10600
|
+
if (!up?.path) {
|
|
10601
|
+
console.warn('[insert] upload failed', up?.error);
|
|
10602
|
+
shellToast(`Upload failed: ${up?.error || 'unknown error'}`);
|
|
10603
|
+
return null;
|
|
10604
|
+
}
|
|
10605
|
+
return fetch('/_api/insert-sequence', {
|
|
10606
|
+
method: 'POST',
|
|
10607
|
+
headers: { 'content-type': 'application/json' },
|
|
10608
|
+
body: JSON.stringify({
|
|
10609
|
+
canvas: activePath,
|
|
10610
|
+
artboardId,
|
|
10611
|
+
from: Math.max(0, timelineTotal),
|
|
10612
|
+
durationInFrames: Math.round(fps * 3),
|
|
10613
|
+
mediaTag,
|
|
10614
|
+
src: up.path,
|
|
10615
|
+
}),
|
|
10616
|
+
});
|
|
10617
|
+
})
|
|
10618
|
+
.then((r) => (r ? r.json() : null))
|
|
10619
|
+
.then((j) => {
|
|
10620
|
+
if (j && !j.ok) {
|
|
10621
|
+
console.warn('[insert-clip]', j.error || 'failed');
|
|
10622
|
+
shellToast(`Insert refused: ${j.error || 'failed'}`);
|
|
10623
|
+
} else if (j && j.ok) {
|
|
10624
|
+
shellToast('Clip added to the timeline.', true);
|
|
10625
|
+
if (j.seq != null) pushTlUndo(activePath, j.seq, 'add clip');
|
|
10626
|
+
}
|
|
10627
|
+
})
|
|
10628
|
+
.catch(() => {});
|
|
10629
|
+
}}
|
|
10630
|
+
height={timelineHeight}
|
|
10631
|
+
onResize={setTimelineHeight}
|
|
10632
|
+
onClose={() => setTimelineOpen(false)}
|
|
10633
|
+
/>
|
|
10634
|
+
)}
|
|
8222
10635
|
<StatusBar
|
|
8223
10636
|
activePath={activePath}
|
|
8224
10637
|
selected={selected}
|
|
@@ -8264,9 +10677,24 @@ function App() {
|
|
|
8264
10677
|
mode={exportDialog.mode}
|
|
8265
10678
|
initialScope={exportDialog.scope}
|
|
8266
10679
|
activePath={activePath}
|
|
10680
|
+
hasComps={activeComps.length > 0}
|
|
10681
|
+
// Which artboard "Active artboard" scope targets. The shell can't query
|
|
10682
|
+
// the cross-origin canvas DOM (the in-canvas dialog's activeArtboardId),
|
|
10683
|
+
// so use the tracked signals: an explicit selection wins, else the
|
|
10684
|
+
// viewport-active artboard canvas-lib reports on pan. Without this,
|
|
10685
|
+
// scope=artboard fell back to `:first-of-type` (always the first).
|
|
10686
|
+
activeArtboardId={selected?.artboardId ?? canvasActiveArtboard ?? null}
|
|
10687
|
+
selection={selected?.selector ? { selector: selected.selector, file: selected.file } : null}
|
|
8267
10688
|
onClose={() => setExportDialog(null)}
|
|
8268
10689
|
/>
|
|
8269
10690
|
)}
|
|
10691
|
+
{assetPickerReq && (
|
|
10692
|
+
<AssetPicker
|
|
10693
|
+
designRel={(cfg?.designRel || cfg?.designRoot || '.design').replace(/^\/+|\/+$/g, '')}
|
|
10694
|
+
onPick={onAssetPicked}
|
|
10695
|
+
onClose={() => setAssetPickerReq(null)}
|
|
10696
|
+
/>
|
|
10697
|
+
)}
|
|
8270
10698
|
{diffTarget && (
|
|
8271
10699
|
<DiffView
|
|
8272
10700
|
target={diffTarget}
|