@1agh/maude 0.40.0 → 0.42.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/apps/studio/ai-banner.tsx +2 -2
- package/apps/studio/annotations-layer.tsx +53 -2
- package/apps/studio/api.ts +504 -44
- 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 +26 -4
- package/apps/studio/bin/_pdf-playwright.mjs +13 -2
- package/apps/studio/bin/_png-playwright.mjs +15 -2
- package/apps/studio/bin/_pptx-playwright.mjs +17 -4
- package/apps/studio/bin/_screenshot-playwright.mjs +17 -2
- package/apps/studio/bin/_svg-playwright.mjs +26 -4
- 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/read-annotations.mjs +138 -7
- package/apps/studio/bin/screenshot.sh +53 -4
- 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 +744 -11
- package/apps/studio/canvas-lib.tsx +219 -2
- package/apps/studio/canvas-shell.tsx +487 -20
- package/apps/studio/client/app.jsx +1476 -76
- package/apps/studio/client/comments-overlay.css +130 -126
- package/apps/studio/client/export-center.jsx +426 -0
- package/apps/studio/client/github.js +8 -0
- package/apps/studio/client/styles/3-shell-maude.css +65 -0
- package/apps/studio/client/styles/4-components.css +150 -0
- package/apps/studio/comments-overlay.tsx +148 -41
- package/apps/studio/config.schema.json +2 -2
- package/apps/studio/context-menu.tsx +15 -5
- package/apps/studio/contextual-toolbar.tsx +262 -4
- package/apps/studio/cursors-overlay.tsx +4 -4
- package/apps/studio/dist/client.bundle.js +20 -20
- package/apps/studio/dist/comment-mount.js +59 -1
- 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 +19 -26
- package/apps/studio/exporters/_runtime.ts +104 -0
- package/apps/studio/exporters/html.ts +12 -20
- package/apps/studio/exporters/index.ts +14 -2
- package/apps/studio/exporters/jobs.ts +334 -0
- package/apps/studio/exporters/pdf.ts +16 -20
- package/apps/studio/exporters/png.ts +12 -20
- package/apps/studio/exporters/pptx.ts +22 -23
- package/apps/studio/exporters/scope.ts +1 -0
- package/apps/studio/exporters/svg.ts +14 -22
- package/apps/studio/exporters/video.ts +15 -17
- package/apps/studio/git/service.ts +3 -1
- package/apps/studio/history.ts +47 -1
- package/apps/studio/http.ts +368 -50
- package/apps/studio/input-router.tsx +12 -0
- 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/server.ts +3 -1
- 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/camera-reveal.test.tsx +173 -0
- package/apps/studio/test/canvas-edit.test.ts +50 -0
- package/apps/studio/test/canvas-origin-gate.test.ts +24 -0
- package/apps/studio/test/canvas-rects.test.ts +198 -0
- package/apps/studio/test/comments-overlay.test.ts +117 -0
- package/apps/studio/test/dns-rebinding-guard.test.ts +27 -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-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/export-center.test.tsx +287 -0
- package/apps/studio/test/export-shim-multi-capture.test.ts +83 -0
- package/apps/studio/test/exporters/history.test.ts +32 -3
- package/apps/studio/test/exporters/jobs.test.ts +263 -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/tool-palette-insert-anchor.test.ts +84 -0
- package/apps/studio/test/undo-sequence-byte-compare.test.ts +211 -0
- package/apps/studio/tool-palette.tsx +122 -2
- package/apps/studio/undo-hud.tsx +2 -2
- 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/whats-new.json +45 -0
- package/apps/studio/ws.ts +6 -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/cli/lib/gitignore-block.mjs +1 -0
- package/package.json +8 -8
- package/plugins/design/dependencies.json +10 -2
|
@@ -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';
|
|
@@ -26,12 +27,13 @@ import {
|
|
|
26
27
|
appIsFirstRun,
|
|
27
28
|
isNativeApp,
|
|
28
29
|
onUpdateReady,
|
|
30
|
+
pickMediaFile,
|
|
29
31
|
restartToUpdate,
|
|
30
|
-
saveExport,
|
|
31
32
|
} from './github.js';
|
|
32
33
|
import { COLLAB_TOUR } from './tour/collab-tour.js';
|
|
33
34
|
import { TourOverlay } from './tour/overlay.jsx';
|
|
34
35
|
import { USAGE_TOUR } from './tour/usage-tour.js';
|
|
36
|
+
import { ExportBadge, ExportPanel, ExportToast, useExportCenter } from './export-center.jsx';
|
|
35
37
|
import { useWhatsNew, WhatsNewPanel, WhatsNewToast } from './whats-new.jsx';
|
|
36
38
|
|
|
37
39
|
const USAGE_TOUR_STORE = 'mdcc-usage-tour-seen';
|
|
@@ -839,6 +841,167 @@ const PNG_SCALES = [
|
|
|
839
841
|
{ value: 3, label: '3× (max)' },
|
|
840
842
|
];
|
|
841
843
|
|
|
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
|
+
|
|
842
1005
|
function ExportDialog({
|
|
843
1006
|
mode,
|
|
844
1007
|
initialScope,
|
|
@@ -916,7 +1079,10 @@ function ExportDialog({
|
|
|
916
1079
|
if (activeArtboardId) options.artboardId = activeArtboardId;
|
|
917
1080
|
if (selection?.selector) options.selection = selection;
|
|
918
1081
|
try {
|
|
919
|
-
|
|
1082
|
+
// feature-background-export-notification-center — enqueue and close
|
|
1083
|
+
// immediately; the menubar notification center owns status, progress,
|
|
1084
|
+
// and completion (download / native Save…) from here on.
|
|
1085
|
+
const r = await fetch('/_api/export-jobs', {
|
|
920
1086
|
method: 'POST',
|
|
921
1087
|
headers: { 'content-type': 'application/json' },
|
|
922
1088
|
body: JSON.stringify({ format: card.format, scope, options }),
|
|
@@ -926,38 +1092,11 @@ function ExportDialog({
|
|
|
926
1092
|
setBusy(false);
|
|
927
1093
|
return;
|
|
928
1094
|
}
|
|
929
|
-
|
|
930
|
-
const fn = /filename="([^"]+)"/.exec(disp);
|
|
931
|
-
const filename = (fn && fn[1]) || `export.${card.format}`;
|
|
932
|
-
const blob = await r.blob();
|
|
933
|
-
if (isNativeApp()) {
|
|
934
|
-
// Native: WKWebView swallows the `<a download>` blob (the file lands in
|
|
935
|
-
// an unknown default with no prompt). Route through a real OS save dialog
|
|
936
|
-
// so the export OFFERS a location. Cancel → soft no-op.
|
|
937
|
-
const bytes = Array.from(new Uint8Array(await blob.arrayBuffer()));
|
|
938
|
-
const savedPath = await saveExport(filename, bytes);
|
|
939
|
-
if (savedPath) {
|
|
940
|
-
setStatus({ ok: true, msg: `Saved to ${savedPath}` });
|
|
941
|
-
loadRecent();
|
|
942
|
-
} else {
|
|
943
|
-
setStatus({ ok: true, msg: 'Save cancelled' });
|
|
944
|
-
}
|
|
945
|
-
} else {
|
|
946
|
-
const url = URL.createObjectURL(blob);
|
|
947
|
-
const a = document.createElement('a');
|
|
948
|
-
a.href = url;
|
|
949
|
-
a.download = filename;
|
|
950
|
-
document.body.appendChild(a);
|
|
951
|
-
a.click();
|
|
952
|
-
a.remove();
|
|
953
|
-
URL.revokeObjectURL(url);
|
|
954
|
-
setStatus({ ok: true, msg: `Exported ${filename}` });
|
|
955
|
-
loadRecent();
|
|
956
|
-
}
|
|
1095
|
+
onClose();
|
|
957
1096
|
} catch (err) {
|
|
958
1097
|
setStatus({ ok: false, msg: err && err.message ? err.message : String(err) });
|
|
1098
|
+
setBusy(false);
|
|
959
1099
|
}
|
|
960
|
-
setBusy(false);
|
|
961
1100
|
}
|
|
962
1101
|
|
|
963
1102
|
return (
|
|
@@ -2456,7 +2595,7 @@ function FileDropdown({ onAction, onClose, hasCanvas }) {
|
|
|
2456
2595
|
);
|
|
2457
2596
|
}
|
|
2458
2597
|
|
|
2459
|
-
function EditDropdown({ onAction, onClose }) {
|
|
2598
|
+
function EditDropdown({ onAction, onClose, hasCanvas }) {
|
|
2460
2599
|
return (
|
|
2461
2600
|
<DropdownMenu
|
|
2462
2601
|
label="Edit"
|
|
@@ -2469,6 +2608,17 @@ function EditDropdown({ onAction, onClose }) {
|
|
|
2469
2608
|
{ sep: true },
|
|
2470
2609
|
{ id: 'deselect-all', label: 'Deselect all', shortcut: 'Esc' },
|
|
2471
2610
|
{ id: 'select-all-annotations', label: 'Select all annotations', shortcut: '⇧⌘A' },
|
|
2611
|
+
// Stage I4 — insert an empty artboard from a device-size preset into the
|
|
2612
|
+
// active canvas. Only meaningful with a canvas open.
|
|
2613
|
+
...(hasCanvas
|
|
2614
|
+
? [
|
|
2615
|
+
{ sep: true },
|
|
2616
|
+
{ id: 'new-artboard:desktop', label: 'New artboard: Desktop' },
|
|
2617
|
+
{ id: 'new-artboard:laptop', label: 'New artboard: Laptop' },
|
|
2618
|
+
{ id: 'new-artboard:tablet', label: 'New artboard: Tablet' },
|
|
2619
|
+
{ id: 'new-artboard:mobile', label: 'New artboard: Mobile' },
|
|
2620
|
+
]
|
|
2621
|
+
: []),
|
|
2472
2622
|
]}
|
|
2473
2623
|
/>
|
|
2474
2624
|
);
|
|
@@ -2506,11 +2656,14 @@ function Menubar({
|
|
|
2506
2656
|
onOpenWhatsNew,
|
|
2507
2657
|
onOpenReadiness,
|
|
2508
2658
|
whatsNewCount,
|
|
2659
|
+
exportCenter,
|
|
2509
2660
|
artboardCount = 0,
|
|
2510
2661
|
presence = null,
|
|
2511
2662
|
inspectorOpen,
|
|
2512
2663
|
inspectorTab,
|
|
2513
2664
|
onToggleInspector,
|
|
2665
|
+
autoOpenInspector,
|
|
2666
|
+
onToggleAutoOpenInspector,
|
|
2514
2667
|
onOpenLayers,
|
|
2515
2668
|
timelineOpen,
|
|
2516
2669
|
onToggleTimeline,
|
|
@@ -2524,6 +2677,7 @@ function Menubar({
|
|
|
2524
2677
|
onOpenExport,
|
|
2525
2678
|
onReload,
|
|
2526
2679
|
onCloseCanvas,
|
|
2680
|
+
onInsertArtboard,
|
|
2527
2681
|
}) {
|
|
2528
2682
|
const isSystem = activePath === SYSTEM_TAB;
|
|
2529
2683
|
const stamp = isSystem ? 'SYSTEM' : activePath ? 'CANVAS' : 'IDLE';
|
|
@@ -2574,6 +2728,13 @@ function Menubar({
|
|
|
2574
2728
|
checked: inspectorOpen,
|
|
2575
2729
|
disabled: false,
|
|
2576
2730
|
},
|
|
2731
|
+
{
|
|
2732
|
+
id: 'autoopen',
|
|
2733
|
+
label: 'Auto-open Inspector on select',
|
|
2734
|
+
shortcut: '',
|
|
2735
|
+
checked: !!autoOpenInspector,
|
|
2736
|
+
disabled: false,
|
|
2737
|
+
},
|
|
2577
2738
|
// DDR-148 — Timeline (video-comp scrub). Phase-tag hints when the active
|
|
2578
2739
|
// canvas actually has a comp; the panel itself shows an empty state otherwise.
|
|
2579
2740
|
{
|
|
@@ -2728,12 +2889,15 @@ function Menubar({
|
|
|
2728
2889
|
)}
|
|
2729
2890
|
{openMenu === 'edit' && (
|
|
2730
2891
|
<EditDropdown
|
|
2892
|
+
hasCanvas={!!activePath && !isSystem}
|
|
2731
2893
|
onAction={(id) => {
|
|
2732
2894
|
if (id === 'undo') postToActiveCanvas({ dgn: 'undo' });
|
|
2733
2895
|
else if (id === 'redo') postToActiveCanvas({ dgn: 'redo' });
|
|
2734
2896
|
else if (id === 'deselect-all') postToActiveCanvas({ dgn: 'selection-clear' });
|
|
2735
2897
|
else if (id === 'select-all-annotations')
|
|
2736
2898
|
postToActiveCanvas({ dgn: 'annotation-select-all' });
|
|
2899
|
+
else if (id.startsWith('new-artboard:'))
|
|
2900
|
+
onInsertArtboard?.(id.slice('new-artboard:'.length));
|
|
2737
2901
|
}}
|
|
2738
2902
|
onClose={() => setOpenMenu(null)}
|
|
2739
2903
|
/>
|
|
@@ -2748,6 +2912,7 @@ function Menubar({
|
|
|
2748
2912
|
else if (id === 'hidden') onToggleShowHidden();
|
|
2749
2913
|
else if (id === 'annotate') onToggleAnnotations();
|
|
2750
2914
|
else if (id === 'inspector') onToggleInspector();
|
|
2915
|
+
else if (id === 'autoopen') onToggleAutoOpenInspector?.();
|
|
2751
2916
|
else if (id === 'timeline') onToggleTimeline?.();
|
|
2752
2917
|
else if (id === 'assistant') onToggleAssistant?.();
|
|
2753
2918
|
else if (id === 'layers') onOpenLayers?.();
|
|
@@ -2805,6 +2970,7 @@ function Menubar({
|
|
|
2805
2970
|
<StIcon name="sparkle" size={15} />
|
|
2806
2971
|
</button>
|
|
2807
2972
|
)}
|
|
2973
|
+
{exportCenter && <ExportBadge center={exportCenter} />}
|
|
2808
2974
|
<button
|
|
2809
2975
|
type="button"
|
|
2810
2976
|
className="st-whatsnew"
|
|
@@ -3636,7 +3802,10 @@ function SyncBanner({ status }) {
|
|
|
3636
3802
|
|
|
3637
3803
|
const CSS_DISPLAYS = ['block', 'inline-block', 'flex', 'inline-flex', 'grid', 'inline', 'none'];
|
|
3638
3804
|
const CSS_FLEX_DIR = ['row', 'row-reverse', 'column', 'column-reverse'];
|
|
3805
|
+
const CSS_FLEX_WRAP = ['nowrap', 'wrap', 'wrap-reverse'];
|
|
3639
3806
|
const CSS_ALIGN = ['stretch', 'flex-start', 'center', 'flex-end', 'baseline'];
|
|
3807
|
+
// Stage M — flex-CHILD align-self (adds `auto` to the container align-items set).
|
|
3808
|
+
const CSS_ALIGN_SELF = ['auto', 'stretch', 'flex-start', 'center', 'flex-end', 'baseline'];
|
|
3640
3809
|
const CSS_JUSTIFY = [
|
|
3641
3810
|
'flex-start',
|
|
3642
3811
|
'center',
|
|
@@ -3675,6 +3844,27 @@ const PROP_LEAD = {
|
|
|
3675
3844
|
opacity: { icon: 'p-opacity' },
|
|
3676
3845
|
};
|
|
3677
3846
|
const CSS_ALIGN_OPTS = ['left', 'center', 'right', 'justify'];
|
|
3847
|
+
// feature-element-editing-robustness Stage B — enum option lists for the promoted
|
|
3848
|
+
// DDR-104 OUT-list knobs (Position / Typography extras / Media framing).
|
|
3849
|
+
const CSS_POSITION = ['static', 'relative', 'absolute', 'fixed', 'sticky'];
|
|
3850
|
+
const CSS_FONT_STYLE = ['normal', 'italic', 'oblique'];
|
|
3851
|
+
const CSS_TEXT_TRANSFORM = ['none', 'uppercase', 'lowercase', 'capitalize'];
|
|
3852
|
+
const CSS_TEXT_DECORATION = ['none', 'underline', 'line-through', 'overline'];
|
|
3853
|
+
const CSS_WHITE_SPACE = ['normal', 'nowrap', 'pre', 'pre-wrap', 'pre-line', 'break-spaces'];
|
|
3854
|
+
const CSS_OBJECT_FIT = ['fill', 'contain', 'cover', 'none', 'scale-down'];
|
|
3855
|
+
const CSS_OVERFLOW = ['visible', 'hidden', 'auto', 'scroll'];
|
|
3856
|
+
// Common aspect ratios for the Media dropdown (dogfood request — a select, not a
|
|
3857
|
+
// free-text field). Canonical spaced form so a dropdown-set value round-trips.
|
|
3858
|
+
const CSS_ASPECT_RATIO = ['auto', '1 / 1', '4 / 3', '3 / 2', '16 / 9', '21 / 9', '3 / 4', '2 / 3', '9 / 16'];
|
|
3859
|
+
|
|
3860
|
+
// feature-element-editing-robustness Stage I4 — device presets for the "New
|
|
3861
|
+
// artboard" menu (inserts an empty <DCArtboard> of these dims into the canvas).
|
|
3862
|
+
const SCREEN_PRESETS = {
|
|
3863
|
+
desktop: { label: 'Desktop', width: 1440, height: 1024 },
|
|
3864
|
+
laptop: { label: 'Laptop', width: 1280, height: 800 },
|
|
3865
|
+
tablet: { label: 'Tablet', width: 834, height: 1194 },
|
|
3866
|
+
mobile: { label: 'Mobile', width: 390, height: 844 },
|
|
3867
|
+
};
|
|
3678
3868
|
|
|
3679
3869
|
let _cssColorCtx = null;
|
|
3680
3870
|
// Normalize any CSS color string to #rrggbb for the native color input via a
|
|
@@ -4277,7 +4467,7 @@ function TokenPopover({ kind, groups, current, onPick, label, swatchBg, seedHex,
|
|
|
4277
4467
|
);
|
|
4278
4468
|
}
|
|
4279
4469
|
|
|
4280
|
-
function CssKnobs({ el, cfg, onOptimistic, onRecordEdit, onUndoRedo }) {
|
|
4470
|
+
function CssKnobs({ el, cfg, onOptimistic, onRecordEdit, onReplaceMedia, onUndoRedo }) {
|
|
4281
4471
|
const editable = !!el.id;
|
|
4282
4472
|
const computed = el.computed || {};
|
|
4283
4473
|
// Phase 12.3 — optimistic local overlay over the selection's authored / custom
|
|
@@ -4321,9 +4511,11 @@ function CssKnobs({ el, cfg, onOptimistic, onRecordEdit, onUndoRedo }) {
|
|
|
4321
4511
|
const [status, setStatus] = useState({});
|
|
4322
4512
|
const [open, setOpen] = useState({
|
|
4323
4513
|
Layout: true,
|
|
4514
|
+
Position: true,
|
|
4324
4515
|
Typography: true,
|
|
4325
4516
|
Spacing: true,
|
|
4326
4517
|
Size: true,
|
|
4518
|
+
Media: true,
|
|
4327
4519
|
Appearance: true,
|
|
4328
4520
|
Advanced: false,
|
|
4329
4521
|
});
|
|
@@ -4437,6 +4629,48 @@ function CssKnobs({ el, cfg, onOptimistic, onRecordEdit, onUndoRedo }) {
|
|
|
4437
4629
|
post('/_api/edit-attr', { canvas: el.file, id: el.id, attr, reset: true }, `@${attr}`);
|
|
4438
4630
|
record('attr', attr, before, null);
|
|
4439
4631
|
};
|
|
4632
|
+
// Stage M1 — apply a Fixed / Hug / Fill sizing mode to one axis. The pure
|
|
4633
|
+
// `sizingModePatch` returns the exact writes (context-aware Fill: flex main axis
|
|
4634
|
+
// → flex-grow, cross axis → align-self, block/grid → 100%) + the fill-props to
|
|
4635
|
+
// clear. Each ride the same commit/reset lanes (per-prop edit-css + undo record);
|
|
4636
|
+
// the server's per-file lock serializes them, so the resulting box is coherent.
|
|
4637
|
+
const parentLayout = { display: el.parentDisplay, flexDirection: el.parentFlexDirection };
|
|
4638
|
+
const applySizing = (axis, mode) => {
|
|
4639
|
+
if (!editable) return;
|
|
4640
|
+
const px = Math.round((axis === 'width' ? el.bounds?.w : el.bounds?.h) || 0);
|
|
4641
|
+
const patch = sizingModePatch(axis, mode, parentLayout, px);
|
|
4642
|
+
for (const p of patch.reset) if (authored[p]) reset(p);
|
|
4643
|
+
for (const [prop, value] of patch.set) commit(prop, value);
|
|
4644
|
+
};
|
|
4645
|
+
const parentIsFlexChild =
|
|
4646
|
+
el.parentDisplay === 'flex' || el.parentDisplay === 'inline-flex';
|
|
4647
|
+
const sizeModeSeg = (axis) => {
|
|
4648
|
+
const cur = sizingModeOf(axis, authored, computed, parentLayout);
|
|
4649
|
+
return (
|
|
4650
|
+
<div className="st-cp-modeseg" role="group" aria-label={`${axis} sizing mode`}>
|
|
4651
|
+
<span className="st-cp-modeax" aria-hidden="true">
|
|
4652
|
+
{axis === 'width' ? 'W' : 'H'}
|
|
4653
|
+
</span>
|
|
4654
|
+
{[
|
|
4655
|
+
['fixed', 'Fixed'],
|
|
4656
|
+
['hug', 'Hug'],
|
|
4657
|
+
['fill', 'Fill'],
|
|
4658
|
+
].map(([m, label]) => (
|
|
4659
|
+
<button
|
|
4660
|
+
key={m}
|
|
4661
|
+
type="button"
|
|
4662
|
+
className={`st-cp-modebtn${cur === m ? ' is-active' : ''}`}
|
|
4663
|
+
aria-pressed={cur === m}
|
|
4664
|
+
disabled={!editable}
|
|
4665
|
+
onClick={() => applySizing(axis, m)}
|
|
4666
|
+
title={`${label} ${axis}`}
|
|
4667
|
+
>
|
|
4668
|
+
{label}
|
|
4669
|
+
</button>
|
|
4670
|
+
))}
|
|
4671
|
+
</div>
|
|
4672
|
+
);
|
|
4673
|
+
};
|
|
4440
4674
|
// Cmd+Z / Cmd+Shift+Z (or Cmd+Y) inside the inspector forwards to the canvas
|
|
4441
4675
|
// undo stack — Figma-parity: a property field reverts the last DOCUMENT edit,
|
|
4442
4676
|
// not field text. Without this, an edit committed with focus still in the
|
|
@@ -4595,7 +4829,9 @@ function CssKnobs({ el, cfg, onOptimistic, onRecordEdit, onUndoRedo }) {
|
|
|
4595
4829
|
|
|
4596
4830
|
// Props each section owns — drives the per-section "reset section" affordance.
|
|
4597
4831
|
const SECTION_PROPS = {
|
|
4598
|
-
Layout: ['display', 'flex-direction', 'align-items', 'justify-content', 'gap'],
|
|
4832
|
+
Layout: ['display', 'flex-direction', 'flex-wrap', 'align-items', 'justify-content', 'gap'],
|
|
4833
|
+
// feature-element-editing-robustness Stage B — promoted DDR-104 OUT-list.
|
|
4834
|
+
Position: ['position', 'top', 'right', 'bottom', 'left', 'z-index'],
|
|
4599
4835
|
Typography: [
|
|
4600
4836
|
'font-family',
|
|
4601
4837
|
'color',
|
|
@@ -4604,6 +4840,10 @@ function CssKnobs({ el, cfg, onOptimistic, onRecordEdit, onUndoRedo }) {
|
|
|
4604
4840
|
'line-height',
|
|
4605
4841
|
'letter-spacing',
|
|
4606
4842
|
'text-align',
|
|
4843
|
+
'font-style',
|
|
4844
|
+
'text-transform',
|
|
4845
|
+
'text-decoration',
|
|
4846
|
+
'white-space',
|
|
4607
4847
|
],
|
|
4608
4848
|
Spacing: [
|
|
4609
4849
|
'margin-top',
|
|
@@ -4615,7 +4855,22 @@ function CssKnobs({ el, cfg, onOptimistic, onRecordEdit, onUndoRedo }) {
|
|
|
4615
4855
|
'padding-bottom',
|
|
4616
4856
|
'padding-left',
|
|
4617
4857
|
],
|
|
4618
|
-
Size: [
|
|
4858
|
+
Size: [
|
|
4859
|
+
'width',
|
|
4860
|
+
'height',
|
|
4861
|
+
'min-width',
|
|
4862
|
+
'min-height',
|
|
4863
|
+
'max-width',
|
|
4864
|
+
'max-height',
|
|
4865
|
+
'overflow',
|
|
4866
|
+
// Stage M — flex-child sizing props (written by the Fill mode + shown as rows
|
|
4867
|
+
// when the parent is flex). Included so a section-reset clears them too.
|
|
4868
|
+
'flex-grow',
|
|
4869
|
+
'flex-shrink',
|
|
4870
|
+
'flex-basis',
|
|
4871
|
+
'align-self',
|
|
4872
|
+
],
|
|
4873
|
+
Media: ['object-fit', 'aspect-ratio', 'object-position'],
|
|
4619
4874
|
Appearance: [
|
|
4620
4875
|
'background-color',
|
|
4621
4876
|
'border-radius',
|
|
@@ -4628,6 +4883,8 @@ function CssKnobs({ el, cfg, onOptimistic, onRecordEdit, onUndoRedo }) {
|
|
|
4628
4883
|
'border-color',
|
|
4629
4884
|
'box-shadow',
|
|
4630
4885
|
'opacity',
|
|
4886
|
+
'transform',
|
|
4887
|
+
'transform-origin',
|
|
4631
4888
|
],
|
|
4632
4889
|
};
|
|
4633
4890
|
const resetSection = (name) => {
|
|
@@ -4863,6 +5120,41 @@ function CssKnobs({ el, cfg, onOptimistic, onRecordEdit, onUndoRedo }) {
|
|
|
4863
5120
|
);
|
|
4864
5121
|
};
|
|
4865
5122
|
|
|
5123
|
+
// feature-element-editing-robustness Stage B (Task B3) — a position INSET side
|
|
5124
|
+
// (top/right/bottom/left). Mirrors `side()` but for the bare inset longhands
|
|
5125
|
+
// (no group prefix), allowing NEGATIVE values and an `auto` default, and reuses
|
|
5126
|
+
// the same box-model scrub grammar: alt = the axis pair, alt+shift = all four.
|
|
5127
|
+
const inset = (prop) => {
|
|
5128
|
+
const a = authored[prop];
|
|
5129
|
+
const shown =
|
|
5130
|
+
a != null && a !== '' && a !== 'auto'
|
|
5131
|
+
? cssSplitUnit(a).n || a
|
|
5132
|
+
: cssSplitUnit(cssHint(computed[prop]) ?? '').n || '';
|
|
5133
|
+
const isZero = !a || a === 'auto' || a === '0' || a === '0px';
|
|
5134
|
+
const pair = prop === 'top' || prop === 'bottom' ? ['top', 'bottom'] : ['left', 'right'];
|
|
5135
|
+
const all = ['top', 'right', 'bottom', 'left'];
|
|
5136
|
+
return (
|
|
5137
|
+
<input
|
|
5138
|
+
className={`st-cp-boxv st-cp-scrub st-cp-boxv--i${prop[0]}${isZero ? ' is-zero' : ''}`}
|
|
5139
|
+
key={`${prop}:${a ?? ''}`}
|
|
5140
|
+
aria-label={prop}
|
|
5141
|
+
defaultValue={shown}
|
|
5142
|
+
placeholder="auto"
|
|
5143
|
+
title="drag to scrub · alt = axis pair · alt+shift = all sides · type auto"
|
|
5144
|
+
onPointerDown={makeScrub(prop, { sides: { pair, all }, min: -Infinity })}
|
|
5145
|
+
onKeyDown={(e) => {
|
|
5146
|
+
if (e.key === 'Enter') e.currentTarget.blur();
|
|
5147
|
+
}}
|
|
5148
|
+
onBlur={(e) => {
|
|
5149
|
+
const raw = e.currentTarget.value.trim();
|
|
5150
|
+
if (!raw) return;
|
|
5151
|
+
const val = /[a-z%]/i.test(raw) ? raw : `${raw}px`;
|
|
5152
|
+
commit(prop, val);
|
|
5153
|
+
}}
|
|
5154
|
+
/>
|
|
5155
|
+
);
|
|
5156
|
+
};
|
|
5157
|
+
|
|
4866
5158
|
const corner = (label, prop) => (
|
|
4867
5159
|
<label className="st-cp-cornerf">
|
|
4868
5160
|
<span>{label}</span>
|
|
@@ -4899,12 +5191,66 @@ function CssKnobs({ el, cfg, onOptimistic, onRecordEdit, onUndoRedo }) {
|
|
|
4899
5191
|
|
|
4900
5192
|
{sec(
|
|
4901
5193
|
'Layout',
|
|
5194
|
+
(() => {
|
|
5195
|
+
// Stage M2 — auto-layout editor. Present the flex vocabulary (Direction ·
|
|
5196
|
+
// Wrap · Distribution · Align · Gap) only when the element IS a flex/grid
|
|
5197
|
+
// container, so a plain block doesn't carry knobs that do nothing; a
|
|
5198
|
+
// non-container gets a one-click "make it a flex layout" instead (the
|
|
5199
|
+
// DDR-104 gap-degrades-gracefully precedent). align-items / justify-content
|
|
5200
|
+
// / gap apply to grid too; flex-direction / flex-wrap are flex-only.
|
|
5201
|
+
const disp = (authored.display || cssHint(computed.display) || '').trim();
|
|
5202
|
+
const isFlex = disp === 'flex' || disp === 'inline-flex';
|
|
5203
|
+
const isGrid = disp === 'grid' || disp === 'inline-grid';
|
|
5204
|
+
return (
|
|
5205
|
+
<>
|
|
5206
|
+
{row('display', csel('display', CSS_DISPLAYS))}
|
|
5207
|
+
{isFlex ? (
|
|
5208
|
+
<>
|
|
5209
|
+
{row('flex-direction', csel('flex-direction', CSS_FLEX_DIR))}
|
|
5210
|
+
{row('flex-wrap', csel('flex-wrap', CSS_FLEX_WRAP))}
|
|
5211
|
+
</>
|
|
5212
|
+
) : null}
|
|
5213
|
+
{isFlex || isGrid ? (
|
|
5214
|
+
<>
|
|
5215
|
+
{row('align-items', csel('align-items', CSS_ALIGN))}
|
|
5216
|
+
{row('justify-content', csel('justify-content', CSS_JUSTIFY))}
|
|
5217
|
+
{row('gap', num('gap', 'space'))}
|
|
5218
|
+
</>
|
|
5219
|
+
) : (
|
|
5220
|
+
<button
|
|
5221
|
+
type="button"
|
|
5222
|
+
className="st-cp-makeflex"
|
|
5223
|
+
disabled={!editable}
|
|
5224
|
+
onClick={() => commit('display', 'flex')}
|
|
5225
|
+
>
|
|
5226
|
+
+ Auto layout (flex)
|
|
5227
|
+
</button>
|
|
5228
|
+
)}
|
|
5229
|
+
</>
|
|
5230
|
+
);
|
|
5231
|
+
})()
|
|
5232
|
+
)}
|
|
5233
|
+
|
|
5234
|
+
{sec(
|
|
5235
|
+
'Position',
|
|
4902
5236
|
<>
|
|
4903
|
-
{row('
|
|
4904
|
-
|
|
4905
|
-
|
|
4906
|
-
|
|
4907
|
-
|
|
5237
|
+
{row('position', csel('position', CSS_POSITION))}
|
|
5238
|
+
<div className="st-cp-box st-cp-box--inset" aria-label="position inset (top / right / bottom / left)">
|
|
5239
|
+
<span className="st-cp-boxtag st-cp-boxtag--i">{prov(provOf('top'))}inset</span>
|
|
5240
|
+
{inset('top')}
|
|
5241
|
+
{inset('right')}
|
|
5242
|
+
{inset('bottom')}
|
|
5243
|
+
{inset('left')}
|
|
5244
|
+
<div className="st-cp-boxcore st-cp-boxcore--pos">
|
|
5245
|
+
{authored.position || cssHint(computed.position) || 'static'}
|
|
5246
|
+
</div>
|
|
5247
|
+
</div>
|
|
5248
|
+
{(authored.position || cssHint(computed.position) || 'static') === 'static' ? (
|
|
5249
|
+
<div className="st-cp-note">
|
|
5250
|
+
top / right / bottom / left apply once position is relative, absolute, fixed, or sticky
|
|
5251
|
+
</div>
|
|
5252
|
+
) : null}
|
|
5253
|
+
{row('z-index', num('z-index'))}
|
|
4908
5254
|
</>
|
|
4909
5255
|
)}
|
|
4910
5256
|
|
|
@@ -4938,6 +5284,11 @@ function CssKnobs({ el, cfg, onOptimistic, onRecordEdit, onUndoRedo }) {
|
|
|
4938
5284
|
))}
|
|
4939
5285
|
</div>
|
|
4940
5286
|
)}
|
|
5287
|
+
{/* Stage B (Task B4) — promoted typography knobs (was DDR-104 OUT-list). */}
|
|
5288
|
+
{row('font-style', csel('font-style', CSS_FONT_STYLE))}
|
|
5289
|
+
{row('text-transform', csel('text-transform', CSS_TEXT_TRANSFORM))}
|
|
5290
|
+
{row('text-decoration', csel('text-decoration', CSS_TEXT_DECORATION))}
|
|
5291
|
+
{row('white-space', csel('white-space', CSS_WHITE_SPACE))}
|
|
4941
5292
|
</>
|
|
4942
5293
|
)}
|
|
4943
5294
|
|
|
@@ -4971,12 +5322,103 @@ function CssKnobs({ el, cfg, onOptimistic, onRecordEdit, onUndoRedo }) {
|
|
|
4971
5322
|
{sec(
|
|
4972
5323
|
'Size',
|
|
4973
5324
|
<>
|
|
5325
|
+
{/* Stage M1 — per-axis Fixed / Hug / Fill sizing mode (Figma parity). The
|
|
5326
|
+
Fixed case leaves the numeric width/height knobs below in control. */}
|
|
5327
|
+
<div className="st-cp-modes">
|
|
5328
|
+
{sizeModeSeg('width')}
|
|
5329
|
+
{sizeModeSeg('height')}
|
|
5330
|
+
</div>
|
|
4974
5331
|
{row('width', num('width'))}
|
|
4975
5332
|
{row('height', num('height'))}
|
|
5333
|
+
{row('min-width', num('min-width'))}
|
|
4976
5334
|
{row('max-width', num('max-width'))}
|
|
5335
|
+
{row('min-height', num('min-height'))}
|
|
5336
|
+
{row('max-height', num('max-height'))}
|
|
5337
|
+
{row('overflow', csel('overflow', CSS_OVERFLOW))}
|
|
5338
|
+
{/* Stage M1 — flex-CHILD controls, only meaningful when the parent is a
|
|
5339
|
+
flex container. align-self is the cross-axis override; flex-grow/shrink/
|
|
5340
|
+
basis are the main-axis behavior the Fill mode writes for you. */}
|
|
5341
|
+
{parentIsFlexChild ? (
|
|
5342
|
+
<>
|
|
5343
|
+
<div className="st-cp-subhd">In flex parent</div>
|
|
5344
|
+
{row('align-self', csel('align-self', CSS_ALIGN_SELF))}
|
|
5345
|
+
{row('flex-grow', num('flex-grow', null, { unitless: true }))}
|
|
5346
|
+
{row('flex-shrink', num('flex-shrink', null, { unitless: true }))}
|
|
5347
|
+
{row('flex-basis', num('flex-basis'))}
|
|
5348
|
+
</>
|
|
5349
|
+
) : null}
|
|
4977
5350
|
</>
|
|
4978
5351
|
)}
|
|
4979
5352
|
|
|
5353
|
+
{/* Stage B (Task B5) — Media framing. Rendered only for a media element
|
|
5354
|
+
(img / video / picture / svg / canvas) or a selection that already
|
|
5355
|
+
carries a framing prop, so a plain <div> doesn't grow object-fit rows.
|
|
5356
|
+
Media = box/framing/source (this plan); the photo-editor plan's "Photo"
|
|
5357
|
+
tab owns pixels/look — they are separate DOM slots by design. */}
|
|
5358
|
+
{(() => {
|
|
5359
|
+
const t = (el.tag || '').toLowerCase();
|
|
5360
|
+
const isMediaEl = t === 'img' || t === 'video' || t === 'picture' || t === 'svg' || t === 'canvas';
|
|
5361
|
+
const showMedia =
|
|
5362
|
+
isMediaEl ||
|
|
5363
|
+
!!authored['object-fit'] ||
|
|
5364
|
+
!!authored['object-position'] ||
|
|
5365
|
+
!!authored['aspect-ratio'];
|
|
5366
|
+
// Stage F2 — "Replace…" opens the AssetPicker to re-point src (authored
|
|
5367
|
+
// <img>/<video> only; a template-expression src can't be string-swapped,
|
|
5368
|
+
// so gate on a real src attr being present).
|
|
5369
|
+
const canReplace = (t === 'img' || t === 'video') && !!el.attrs?.src && !!onReplaceMedia;
|
|
5370
|
+
return showMedia
|
|
5371
|
+
? sec(
|
|
5372
|
+
'Media',
|
|
5373
|
+
<>
|
|
5374
|
+
{canReplace && (
|
|
5375
|
+
<div className="st-cp-mediabtn">
|
|
5376
|
+
<button
|
|
5377
|
+
type="button"
|
|
5378
|
+
className="st-btn st-cp-replace"
|
|
5379
|
+
onClick={() => onReplaceMedia(el)}
|
|
5380
|
+
>
|
|
5381
|
+
Replace…
|
|
5382
|
+
</button>
|
|
5383
|
+
</div>
|
|
5384
|
+
)}
|
|
5385
|
+
{row('object-fit', csel('object-fit', CSS_OBJECT_FIT))}
|
|
5386
|
+
{row('object-position', text('object-position'))}
|
|
5387
|
+
{row(
|
|
5388
|
+
'aspect-ratio',
|
|
5389
|
+
<select
|
|
5390
|
+
className="st-cp-nsel"
|
|
5391
|
+
aria-label="aspect-ratio"
|
|
5392
|
+
value={
|
|
5393
|
+
CSS_ASPECT_RATIO.includes(authored['aspect-ratio'])
|
|
5394
|
+
? authored['aspect-ratio']
|
|
5395
|
+
: ''
|
|
5396
|
+
}
|
|
5397
|
+
onChange={(e) => {
|
|
5398
|
+
const v = e.target.value;
|
|
5399
|
+
commit('aspect-ratio', v);
|
|
5400
|
+
// A fixed height overrides aspect-ratio (CSS: explicit
|
|
5401
|
+
// width+height win). When applying a real ratio, release the
|
|
5402
|
+
// height so the ratio actually reshapes the box (dogfood:
|
|
5403
|
+
// "nastavil jsem 16/9 a nic se nestalo").
|
|
5404
|
+
if (v && v !== 'auto' && authored.height) reset('height');
|
|
5405
|
+
}}
|
|
5406
|
+
>
|
|
5407
|
+
<option value="" disabled>
|
|
5408
|
+
{cssHint(computed['aspect-ratio']) || '—'}
|
|
5409
|
+
</option>
|
|
5410
|
+
{CSS_ASPECT_RATIO.map((v) => (
|
|
5411
|
+
<option key={v} value={v}>
|
|
5412
|
+
{v}
|
|
5413
|
+
</option>
|
|
5414
|
+
))}
|
|
5415
|
+
</select>
|
|
5416
|
+
)}
|
|
5417
|
+
</>
|
|
5418
|
+
)
|
|
5419
|
+
: null;
|
|
5420
|
+
})()}
|
|
5421
|
+
|
|
4980
5422
|
{sec(
|
|
4981
5423
|
'Appearance',
|
|
4982
5424
|
<>
|
|
@@ -5060,6 +5502,9 @@ function CssKnobs({ el, cfg, onOptimistic, onRecordEdit, onUndoRedo }) {
|
|
|
5060
5502
|
/>
|
|
5061
5503
|
</div>
|
|
5062
5504
|
)}
|
|
5505
|
+
{/* Stage B (Task B4) — transform as a free-value row (mirrors box-shadow). */}
|
|
5506
|
+
{row('transform', text('transform'))}
|
|
5507
|
+
{row('transform-origin', text('transform-origin'))}
|
|
5063
5508
|
</>
|
|
5064
5509
|
)}
|
|
5065
5510
|
|
|
@@ -5325,7 +5770,7 @@ function LayerRow({
|
|
|
5325
5770
|
selectedId,
|
|
5326
5771
|
selectedIndex,
|
|
5327
5772
|
collapsed,
|
|
5328
|
-
|
|
5773
|
+
hiddenOverride,
|
|
5329
5774
|
onToggle,
|
|
5330
5775
|
onSelect,
|
|
5331
5776
|
onHover,
|
|
@@ -5342,7 +5787,7 @@ function LayerRow({
|
|
|
5342
5787
|
// clones at once. Fall back to id-only when the selection carries no index.
|
|
5343
5788
|
const isSel =
|
|
5344
5789
|
node.id === selectedId && (selectedIndex == null || node.index === selectedIndex);
|
|
5345
|
-
const isHidden =
|
|
5790
|
+
const isHidden = hiddenOverride?.has(key) ? hiddenOverride.get(key) : !!node.hidden;
|
|
5346
5791
|
// A shared data-cd-id (reused component instance) IS reorderable now — the
|
|
5347
5792
|
// server maps the occurrence index to the parent <Component> usage — so these
|
|
5348
5793
|
// are no longer greyed/blocked. A `.map()`ed single-usage element still can't
|
|
@@ -5435,7 +5880,7 @@ function LayerRow({
|
|
|
5435
5880
|
selectedId={selectedId}
|
|
5436
5881
|
selectedIndex={selectedIndex}
|
|
5437
5882
|
collapsed={collapsed}
|
|
5438
|
-
|
|
5883
|
+
hiddenOverride={hiddenOverride}
|
|
5439
5884
|
onToggle={onToggle}
|
|
5440
5885
|
onSelect={onSelect}
|
|
5441
5886
|
onHover={onHover}
|
|
@@ -5515,10 +5960,123 @@ function InspectComputed({ el }) {
|
|
|
5515
5960
|
);
|
|
5516
5961
|
}
|
|
5517
5962
|
|
|
5963
|
+
// Dogfood 2026-07-07 — resolve the artboard id for a whole-artboard selection
|
|
5964
|
+
// defensively: prefer `el.artboardId` (set by `hoverTargetToSelection` for a
|
|
5965
|
+
// live chrome click), but fall back to parsing it out of `el.selector` — some
|
|
5966
|
+
// selection-construction paths (Layers-tree artboard row, a restored
|
|
5967
|
+
// `_active.json` selection) may not carry `artboardId` even though the
|
|
5968
|
+
// selector is always the `[data-dc-screen="…"]` chrome form for one.
|
|
5969
|
+
function resolveArtboardIdFromSelection(el) {
|
|
5970
|
+
if (el.artboardId) return el.artboardId;
|
|
5971
|
+
const m = /^\[data-dc-screen="([^"]+)"\]$/.exec(el.selector || '');
|
|
5972
|
+
return m ? m[1] : null;
|
|
5973
|
+
}
|
|
5974
|
+
|
|
5975
|
+
// Dogfood 2026-07-07 — the CSS tab showed everything disabled for a
|
|
5976
|
+
// whole-ARTBOARD selection (`CssKnobs`'s `editable = !!el.id`, and an artboard
|
|
5977
|
+
// chrome click has NO data-cd-id — DCArtboard doesn't forward it to the DOM).
|
|
5978
|
+
// A dedicated, MUCH smaller panel: exact width/height fields (writes via
|
|
5979
|
+
// /_api/resize-artboard, NOT edit-css — DDR-027 numeric JSX props) + the SAME
|
|
5980
|
+
// SCREEN_PRESETS the "+ Artboard" menu uses, so picking "Tablet" resizes the
|
|
5981
|
+
// CURRENT artboard to 834×1194 in one click instead of typing both fields.
|
|
5982
|
+
function ArtboardKnobs({ el, onResizeArtboard }) {
|
|
5983
|
+
const artboardId = resolveArtboardIdFromSelection(el);
|
|
5984
|
+
// Dogfood 2026-07-07 (round 2) — `worldW`/`worldH` (zoom-independent) are
|
|
5985
|
+
// undefined for a selection that reached here via a code path predating
|
|
5986
|
+
// that field (a canvas iframe that hasn't remounted since); fall back to
|
|
5987
|
+
// `bounds` (the SCREEN rect — always populated, but wrong at any zoom other
|
|
5988
|
+
// than 100%) so the fields show SOMETHING rather than sit empty. Self-heals
|
|
5989
|
+
// to the exact value the moment `worldW`/`worldH` are present.
|
|
5990
|
+
const w = Number.isFinite(el.worldW)
|
|
5991
|
+
? el.worldW
|
|
5992
|
+
: Number.isFinite(el.bounds?.w)
|
|
5993
|
+
? el.bounds.w
|
|
5994
|
+
: null;
|
|
5995
|
+
const h = Number.isFinite(el.worldH)
|
|
5996
|
+
? el.worldH
|
|
5997
|
+
: Number.isFinite(el.bounds?.h)
|
|
5998
|
+
? el.bounds.h
|
|
5999
|
+
: null;
|
|
6000
|
+
const commitSize = (width, height) => {
|
|
6001
|
+
if (!artboardId) return;
|
|
6002
|
+
const nw = Number.isFinite(width) && width > 0 ? Math.round(width) : undefined;
|
|
6003
|
+
const nh = Number.isFinite(height) && height > 0 ? Math.round(height) : undefined;
|
|
6004
|
+
if (nw == null && nh == null) return;
|
|
6005
|
+
onResizeArtboard?.(artboardId, nw, nh);
|
|
6006
|
+
};
|
|
6007
|
+
const activePreset = Object.entries(SCREEN_PRESETS).find(
|
|
6008
|
+
([, p]) => p.width === w && p.height === h
|
|
6009
|
+
)?.[0];
|
|
6010
|
+
return (
|
|
6011
|
+
<section className="st-cp-sec">
|
|
6012
|
+
<div className="st-cp-sechd-row">
|
|
6013
|
+
<span className="st-cp-sechd">Artboard</span>
|
|
6014
|
+
</div>
|
|
6015
|
+
<div style={{ display: 'flex', gap: 8, padding: '4px 12px' }}>
|
|
6016
|
+
<div className="st-cp-num">
|
|
6017
|
+
<span className="st-cp-numlead" aria-hidden="true">
|
|
6018
|
+
W
|
|
6019
|
+
</span>
|
|
6020
|
+
<input
|
|
6021
|
+
className="st-cp-numin"
|
|
6022
|
+
type="number"
|
|
6023
|
+
min="1"
|
|
6024
|
+
aria-label="artboard width"
|
|
6025
|
+
key={`w:${w ?? ''}`}
|
|
6026
|
+
defaultValue={w ?? ''}
|
|
6027
|
+
onKeyDown={(e) => {
|
|
6028
|
+
if (e.key === 'Enter') e.currentTarget.blur();
|
|
6029
|
+
}}
|
|
6030
|
+
onBlur={(e) => commitSize(Number.parseFloat(e.currentTarget.value), null)}
|
|
6031
|
+
/>
|
|
6032
|
+
</div>
|
|
6033
|
+
<div className="st-cp-num">
|
|
6034
|
+
<span className="st-cp-numlead" aria-hidden="true">
|
|
6035
|
+
H
|
|
6036
|
+
</span>
|
|
6037
|
+
<input
|
|
6038
|
+
className="st-cp-numin"
|
|
6039
|
+
type="number"
|
|
6040
|
+
min="1"
|
|
6041
|
+
aria-label="artboard height"
|
|
6042
|
+
key={`h:${h ?? ''}`}
|
|
6043
|
+
defaultValue={h ?? ''}
|
|
6044
|
+
onKeyDown={(e) => {
|
|
6045
|
+
if (e.key === 'Enter') e.currentTarget.blur();
|
|
6046
|
+
}}
|
|
6047
|
+
onBlur={(e) => commitSize(null, Number.parseFloat(e.currentTarget.value))}
|
|
6048
|
+
/>
|
|
6049
|
+
</div>
|
|
6050
|
+
</div>
|
|
6051
|
+
<div style={{ padding: '0 12px 8px' }}>
|
|
6052
|
+
<select
|
|
6053
|
+
className="st-cp-nsel"
|
|
6054
|
+
aria-label="artboard size preset"
|
|
6055
|
+
value={activePreset ?? ''}
|
|
6056
|
+
onChange={(e) => {
|
|
6057
|
+
const p = SCREEN_PRESETS[e.currentTarget.value];
|
|
6058
|
+
if (p) commitSize(p.width, p.height);
|
|
6059
|
+
}}
|
|
6060
|
+
>
|
|
6061
|
+
<option value="" disabled>
|
|
6062
|
+
{activePreset ? SCREEN_PRESETS[activePreset].label : 'Preset size…'}
|
|
6063
|
+
</option>
|
|
6064
|
+
{Object.entries(SCREEN_PRESETS).map(([key, p]) => (
|
|
6065
|
+
<option key={key} value={key}>
|
|
6066
|
+
{p.label} — {p.width}×{p.height}
|
|
6067
|
+
</option>
|
|
6068
|
+
))}
|
|
6069
|
+
</select>
|
|
6070
|
+
</div>
|
|
6071
|
+
</section>
|
|
6072
|
+
);
|
|
6073
|
+
}
|
|
6074
|
+
|
|
5518
6075
|
function InspectorPanel({
|
|
5519
6076
|
selected,
|
|
5520
6077
|
onClose,
|
|
5521
6078
|
layersTree,
|
|
6079
|
+
canvasFile,
|
|
5522
6080
|
onSelectLayer,
|
|
5523
6081
|
onHoverLayer,
|
|
5524
6082
|
onReorderLayer,
|
|
@@ -5526,7 +6084,10 @@ function InspectorPanel({
|
|
|
5526
6084
|
cfg,
|
|
5527
6085
|
onOptimistic,
|
|
5528
6086
|
onRecordEdit,
|
|
6087
|
+
onReplaceMedia,
|
|
6088
|
+
onResizeArtboard,
|
|
5529
6089
|
onUndoRedo,
|
|
6090
|
+
editScope,
|
|
5530
6091
|
tab: tabProp,
|
|
5531
6092
|
onTabChange,
|
|
5532
6093
|
width,
|
|
@@ -5543,9 +6104,18 @@ function InspectorPanel({
|
|
|
5543
6104
|
onTabChange?.(t);
|
|
5544
6105
|
};
|
|
5545
6106
|
const [collapsed, setCollapsed] = useState(() => new Set());
|
|
5546
|
-
// Phase 12.3 (W3.1) — per-layer visibility toggle.
|
|
5547
|
-
//
|
|
5548
|
-
|
|
6107
|
+
// Phase 12.3 (W3.1) — per-layer visibility toggle. Persists via /_api/edit-css
|
|
6108
|
+
// (property 'display', mirroring CssKnobs' commit/reset) and is undoable via
|
|
6109
|
+
// the same edit-source command (see the undo/redo coverage RCA:
|
|
6110
|
+
// .ai/logs/rca/issue-undo-redo-coverage-gaps.md). Keyed by `${id}:${index}`;
|
|
6111
|
+
// holds only OPTIMISTIC overrides (either direction) over the tree-reported
|
|
6112
|
+
// `node.hidden` (the authoritative source value) so a first render of an
|
|
6113
|
+
// already-hidden element shows the correct eye-icon state without a click.
|
|
6114
|
+
const [hiddenOverride, setHiddenOverride] = useState(() => new Map());
|
|
6115
|
+
const isNodeHidden = (node) => {
|
|
6116
|
+
const key = `${node.id}:${node.index}`;
|
|
6117
|
+
return hiddenOverride.has(key) ? hiddenOverride.get(key) : !!node.hidden;
|
|
6118
|
+
};
|
|
5549
6119
|
// Phase 12.1 (DDR-138) — drag-to-reorder state (lifted so every row sees the
|
|
5550
6120
|
// same drop target) + an aria-live announcement for keyboard moves.
|
|
5551
6121
|
const [dragState, setDragState] = useState(null);
|
|
@@ -5786,7 +6356,9 @@ function InspectorPanel({
|
|
|
5786
6356
|
});
|
|
5787
6357
|
const toggleVisibility = (node) => {
|
|
5788
6358
|
const key = `${node.id}:${node.index}`;
|
|
5789
|
-
const
|
|
6359
|
+
const wasHidden = isNodeHidden(node);
|
|
6360
|
+
const willHide = !wasHidden;
|
|
6361
|
+
setHiddenOverride((prev) => new Map(prev).set(key, willHide));
|
|
5790
6362
|
onOptimistic?.({
|
|
5791
6363
|
id: node.id,
|
|
5792
6364
|
artboardId: layersTree?.artboardId ?? null,
|
|
@@ -5794,11 +6366,26 @@ function InspectorPanel({
|
|
|
5794
6366
|
prop: 'display',
|
|
5795
6367
|
value: willHide ? 'none' : null,
|
|
5796
6368
|
});
|
|
5797
|
-
|
|
5798
|
-
|
|
5799
|
-
|
|
5800
|
-
|
|
5801
|
-
|
|
6369
|
+
if (!canvasFile || !node.id) return;
|
|
6370
|
+
fetch('/_api/edit-css', {
|
|
6371
|
+
method: 'POST',
|
|
6372
|
+
headers: { 'content-type': 'application/json' },
|
|
6373
|
+
body: JSON.stringify(
|
|
6374
|
+
willHide
|
|
6375
|
+
? { canvas: canvasFile, id: node.id, property: 'display', value: 'none' }
|
|
6376
|
+
: { canvas: canvasFile, id: node.id, property: 'display', reset: true }
|
|
6377
|
+
),
|
|
6378
|
+
}).catch(() => {});
|
|
6379
|
+
// Record onto the canvas undo stack (Cmd+Z), same as any other inline CSS
|
|
6380
|
+
// edit — fire-and-forget alongside the POST above, mirroring CssKnobs'
|
|
6381
|
+
// commit()/reset() (which don't gate the record on the fetch resolving).
|
|
6382
|
+
onRecordEdit?.({
|
|
6383
|
+
op: 'css',
|
|
6384
|
+
canvas: canvasFile,
|
|
6385
|
+
id: node.id,
|
|
6386
|
+
key: 'display',
|
|
6387
|
+
before: wasHidden ? 'none' : null,
|
|
6388
|
+
after: willHide ? 'none' : null,
|
|
5802
6389
|
});
|
|
5803
6390
|
};
|
|
5804
6391
|
// `selected` may be a single element, an array (multi-select), or null.
|
|
@@ -5835,6 +6422,30 @@ function InspectorPanel({
|
|
|
5835
6422
|
<StIcon name="x" size={14} />
|
|
5836
6423
|
</button>
|
|
5837
6424
|
</div>
|
|
6425
|
+
{/* Stage H (INV-3) — edit-scope strip: is an edit local to this element or
|
|
6426
|
+
shared across N rendered places? Visible across every tab so it's never
|
|
6427
|
+
a surprise. Only for a single element selection with a resolved verdict. */}
|
|
6428
|
+
{el?.id && !(Array.isArray(selected) && selected.length > 1) && editScope ? (
|
|
6429
|
+
<div
|
|
6430
|
+
className={`st-scope st-scope--${editScope.scope}`}
|
|
6431
|
+
title={
|
|
6432
|
+
editScope.scope === 'shared'
|
|
6433
|
+
? `Editing this element's style changes ${editScope.affects} place${
|
|
6434
|
+
editScope.affects === 1 ? '' : 's'
|
|
6435
|
+
}${
|
|
6436
|
+
editScope.componentName ? ` (component ${editScope.componentName})` : ''
|
|
6437
|
+
}. Move/resize a whole instance to keep it local.`
|
|
6438
|
+
: 'This edit affects only this element.'
|
|
6439
|
+
}
|
|
6440
|
+
>
|
|
6441
|
+
<span className="st-scope-dot" aria-hidden="true" />
|
|
6442
|
+
{editScope.scope === 'shared'
|
|
6443
|
+
? `Shared${editScope.componentName ? ` · ${editScope.componentName}` : ''} · edits ${
|
|
6444
|
+
editScope.affects
|
|
6445
|
+
} place${editScope.affects === 1 ? '' : 's'}`
|
|
6446
|
+
: 'Local · this element only'}
|
|
6447
|
+
</div>
|
|
6448
|
+
) : null}
|
|
5838
6449
|
<div className="st-rp-body">
|
|
5839
6450
|
{!el ? (
|
|
5840
6451
|
<div className="st-rp-empty">
|
|
@@ -5920,7 +6531,7 @@ function InspectorPanel({
|
|
|
5920
6531
|
selectedId={el.id}
|
|
5921
6532
|
selectedIndex={el.index}
|
|
5922
6533
|
collapsed={collapsed}
|
|
5923
|
-
|
|
6534
|
+
hiddenOverride={hiddenOverride}
|
|
5924
6535
|
onToggle={toggleCollapse}
|
|
5925
6536
|
onSelect={(node) => {
|
|
5926
6537
|
onSelectLayer?.(node);
|
|
@@ -5966,12 +6577,15 @@ function InspectorPanel({
|
|
|
5966
6577
|
</div>
|
|
5967
6578
|
)}
|
|
5968
6579
|
</>
|
|
6580
|
+
) : !el.id && resolveArtboardIdFromSelection(el) ? (
|
|
6581
|
+
<ArtboardKnobs el={el} onResizeArtboard={onResizeArtboard} />
|
|
5969
6582
|
) : (
|
|
5970
6583
|
<CssKnobs
|
|
5971
6584
|
el={el}
|
|
5972
6585
|
cfg={cfg}
|
|
5973
6586
|
onOptimistic={onOptimistic}
|
|
5974
6587
|
onRecordEdit={onRecordEdit}
|
|
6588
|
+
onReplaceMedia={onReplaceMedia}
|
|
5975
6589
|
onUndoRedo={onUndoRedo}
|
|
5976
6590
|
/>
|
|
5977
6591
|
)}
|
|
@@ -5995,6 +6609,32 @@ function App() {
|
|
|
5995
6609
|
useEffect(() => {
|
|
5996
6610
|
selectedRef.current = selected;
|
|
5997
6611
|
}, [selected]);
|
|
6612
|
+
|
|
6613
|
+
// Stage H (INV-3) — resolve the edit-scope (local vs shared component instance)
|
|
6614
|
+
// for the current single selection so the Inspector can show whether an edit
|
|
6615
|
+
// stays here or changes N places. Read-only GET, debounced by the selection id;
|
|
6616
|
+
// aborts a stale in-flight fetch when the selection changes. Fetched with
|
|
6617
|
+
// rendered=1 (source-usage-driven; the .map() refinement is a follow-up).
|
|
6618
|
+
const [editScope, setEditScope] = useState(null);
|
|
6619
|
+
useEffect(() => {
|
|
6620
|
+
const one = Array.isArray(selected) ? (selected.length === 1 ? selected[0] : null) : selected;
|
|
6621
|
+
const id = one && typeof one.id === 'string' ? one.id : null;
|
|
6622
|
+
if (!id || !activePath) {
|
|
6623
|
+
setEditScope(null);
|
|
6624
|
+
return;
|
|
6625
|
+
}
|
|
6626
|
+
const ac = new AbortController();
|
|
6627
|
+
const q = new URLSearchParams({ canvas: activePath, id });
|
|
6628
|
+
fetch(`/_api/edit-scope?${q}`, { signal: ac.signal })
|
|
6629
|
+
.then((r) => (r.ok ? r.json() : null))
|
|
6630
|
+
.then((j) => {
|
|
6631
|
+
if (j?.ok) setEditScope(j);
|
|
6632
|
+
})
|
|
6633
|
+
.catch(() => {
|
|
6634
|
+
/* aborted / offline — leave the last verdict, the badge just won't show */
|
|
6635
|
+
});
|
|
6636
|
+
return () => ac.abort();
|
|
6637
|
+
}, [selected, activePath]);
|
|
5998
6638
|
// feature-acp-context-hardening — halo re-apply retry ladder. A single
|
|
5999
6639
|
// select-by-id post races the fresh iframe: dgn:'loaded' fires from the
|
|
6000
6640
|
// inline inspector script at HTML-parse time, BEFORE the React canvas-shell
|
|
@@ -6039,6 +6679,14 @@ function App() {
|
|
|
6039
6679
|
// (same freshness idiom as selectedRef; avoids a render-time TDZ on the
|
|
6040
6680
|
// later useCallback).
|
|
6041
6681
|
const reorderLayerRef = useRef(null);
|
|
6682
|
+
// Same freshness idiom for `repositionElement` (defined far below), read
|
|
6683
|
+
// when the in-canvas drag posts dgn:'reposition-request' — the coordinate-
|
|
6684
|
+
// mode commit for out-of-flow (position:absolute/fixed) elements.
|
|
6685
|
+
const repositionElementRef = useRef(null);
|
|
6686
|
+
// feature-element-editing-robustness Stage D — sibling ref for `resizeElement`
|
|
6687
|
+
// (defined far below), read when the in-canvas resize overlay posts
|
|
6688
|
+
// dgn:'resize-request' on pointer-up. Same origin-split as reposition.
|
|
6689
|
+
const resizeElementRef = useRef(null);
|
|
6042
6690
|
// Phase 12.1 — true between a layers-panel reorder and the fresh layers-tree
|
|
6043
6691
|
// landing. A reorder churns positional data-cd-ids, so a rapid 2nd drag would
|
|
6044
6692
|
// target stale ids; the Layers tree gates new drags on this until the rebuilt
|
|
@@ -6277,6 +6925,33 @@ function App() {
|
|
|
6277
6925
|
// Inspector tab is lifted so View ▸ Layers can open the panel ON the Layers
|
|
6278
6926
|
// tab (the menu item sat disabled as "Phase 12" long after the tab shipped).
|
|
6279
6927
|
const [inspectorTab, setInspectorTab] = useState('inspect');
|
|
6928
|
+
// feature-element-editing-robustness Stage C — auto-open the Inspector on the
|
|
6929
|
+
// CSS tab when a fresh single selection arrives AND no right panel is already
|
|
6930
|
+
// open. Preference-backed (default ON); disable it in the View menu. Refs keep
|
|
6931
|
+
// the postMessage selection listener stale-closure-safe.
|
|
6932
|
+
const [autoOpenInspector, setAutoOpenInspector] = useState(() => {
|
|
6933
|
+
try {
|
|
6934
|
+
return localStorage.getItem('maude-auto-open-inspector') !== '0';
|
|
6935
|
+
} catch {
|
|
6936
|
+
return true;
|
|
6937
|
+
}
|
|
6938
|
+
});
|
|
6939
|
+
const autoOpenInspectorRef = useRef(autoOpenInspector);
|
|
6940
|
+
useEffect(() => {
|
|
6941
|
+
autoOpenInspectorRef.current = autoOpenInspector;
|
|
6942
|
+
try {
|
|
6943
|
+
localStorage.setItem('maude-auto-open-inspector', autoOpenInspector ? '1' : '0');
|
|
6944
|
+
} catch {
|
|
6945
|
+
/* private mode / storage disabled */
|
|
6946
|
+
}
|
|
6947
|
+
}, [autoOpenInspector]);
|
|
6948
|
+
// Whether ANY right-dock panel is open — the auto-open guard reads this ref so
|
|
6949
|
+
// it never steals focus from an already-open panel (the user's explicit rule).
|
|
6950
|
+
const anyRightPanelOpenRef = useRef(false);
|
|
6951
|
+
useEffect(() => {
|
|
6952
|
+
anyRightPanelOpenRef.current =
|
|
6953
|
+
inspectorOpen || commentsPanelOpen || changesOpen || assistantOpen;
|
|
6954
|
+
}, [inspectorOpen, commentsPanelOpen, changesOpen, assistantOpen]);
|
|
6280
6955
|
// The right dock holds exactly ONE panel (Changes / Inspector / Comments) at
|
|
6281
6956
|
// a time — opening any panel REPLACES whatever was there. These two helpers
|
|
6282
6957
|
// are the single source of that invariant; every open/toggle path routes
|
|
@@ -6292,6 +6967,21 @@ function App() {
|
|
|
6292
6967
|
// NOTE: the Timeline is a BOTTOM dock (DDR-148), NOT part of the right-rail
|
|
6293
6968
|
// mutual-exclusion — it stays open when a right panel opens.
|
|
6294
6969
|
}, []);
|
|
6970
|
+
// feature-element-editing-robustness Stage C (Task C1) — open the Inspector on
|
|
6971
|
+
// the CSS tab for a fresh SINGLE selection, but only when nothing is already
|
|
6972
|
+
// docked on the right (never override an open Layers/Inspect/Comments panel)
|
|
6973
|
+
// and only when the preference is on. Idempotent: once the inspector is open,
|
|
6974
|
+
// the `anyRightPanelOpen` guard stops it re-firing on the next selection.
|
|
6975
|
+
const maybeAutoOpenInspectorOnSelect = useCallback(
|
|
6976
|
+
(sel) => {
|
|
6977
|
+
if (!autoOpenInspectorRef.current) return;
|
|
6978
|
+
if (!sel || !sel.id) return; // need a stable element id to inspect
|
|
6979
|
+
if (anyRightPanelOpenRef.current) return; // don't steal from an open panel
|
|
6980
|
+
openRightPanel('inspector');
|
|
6981
|
+
setInspectorTab('css');
|
|
6982
|
+
},
|
|
6983
|
+
[openRightPanel]
|
|
6984
|
+
);
|
|
6295
6985
|
// Functional updates so this is stale-closure-safe inside the keydown /
|
|
6296
6986
|
// postMessage listeners; opening always clears the sibling panels.
|
|
6297
6987
|
const toggleRightPanel = useCallback((which) => {
|
|
@@ -6337,6 +7027,7 @@ function App() {
|
|
|
6337
7027
|
// toggles on its own and coexists with Inspector/Changes/Comments/Chat.
|
|
6338
7028
|
const toggleTimeline = useCallback(() => setTimelineOpen((v) => !v), []);
|
|
6339
7029
|
const whatsNew = useWhatsNew(MDCC_VERSION);
|
|
7030
|
+
const exportCenter = useExportCenter();
|
|
6340
7031
|
// Phase 29 (E4) — first-run onboarding wizard. The native shell boots a minimal
|
|
6341
7032
|
// "welcome" project on first launch; we ask it whether this is a first run and, if
|
|
6342
7033
|
// so, show the wizard OVER the (empty) canvas browser. Completing any door switches
|
|
@@ -6984,6 +7675,10 @@ function App() {
|
|
|
6984
7675
|
);
|
|
6985
7676
|
} catch {}
|
|
6986
7677
|
}
|
|
7678
|
+
} else if (m.type === 'export:job' && m.payload) {
|
|
7679
|
+
// feature-background-export-notification-center — full-snapshot
|
|
7680
|
+
// job state on every queued/running/progress/done/failed change.
|
|
7681
|
+
exportCenter.upsert(m.payload);
|
|
6987
7682
|
} else if (m.type === 'sync:status' && m.payload) {
|
|
6988
7683
|
// Phase 9 Task 8 — hub connection state for the offline banner.
|
|
6989
7684
|
setSyncStatus(m.payload);
|
|
@@ -7560,6 +8255,7 @@ function App() {
|
|
|
7560
8255
|
if (m.dgn === 'select' && m.selection) {
|
|
7561
8256
|
wsSend({ type: 'select', selection: m.selection });
|
|
7562
8257
|
setSelected(m.selection);
|
|
8258
|
+
maybeAutoOpenInspectorOnSelect(m.selection); // Stage C
|
|
7563
8259
|
} else if (m.dgn === 'select-set') {
|
|
7564
8260
|
// Canvas multi-select. Payload shape:
|
|
7565
8261
|
// null → empty selection
|
|
@@ -7576,9 +8272,12 @@ function App() {
|
|
|
7576
8272
|
const head = payload[0] ?? null;
|
|
7577
8273
|
if (head) wsSend({ type: 'select', selection: head });
|
|
7578
8274
|
setSelected(head);
|
|
8275
|
+
// Stage C — auto-open only for a genuine SINGLE selection, never multi.
|
|
8276
|
+
if (payload.length === 1) maybeAutoOpenInspectorOnSelect(head);
|
|
7579
8277
|
} else {
|
|
7580
8278
|
wsSend({ type: 'select', selection: payload });
|
|
7581
8279
|
setSelected(payload);
|
|
8280
|
+
maybeAutoOpenInspectorOnSelect(payload); // Stage C
|
|
7582
8281
|
}
|
|
7583
8282
|
} else if (m.dgn === 'clear-select') {
|
|
7584
8283
|
wsSend({ type: 'clear-select' });
|
|
@@ -7739,6 +8438,175 @@ function App() {
|
|
|
7739
8438
|
refIndex: Number.isInteger(m.refIndex) ? m.refIndex : undefined,
|
|
7740
8439
|
});
|
|
7741
8440
|
}
|
|
8441
|
+
} else if (m.dgn === 'reposition-request') {
|
|
8442
|
+
// Free-XY reposition for out-of-flow elements (position:absolute/
|
|
8443
|
+
// fixed) — the in-canvas drag switches from reorder-mode to
|
|
8444
|
+
// coordinate-mode when the dragged element is out of flow, because
|
|
8445
|
+
// reordering an absolute/fixed element's JSX siblings never changes
|
|
8446
|
+
// where it paints (the "moves then snaps back" RCA). Same trust
|
|
8447
|
+
// model + confused-deputy guard as reorder-request: untrusted canvas
|
|
8448
|
+
// REQUESTS, shell WRITES, pinned to the ACTIVE canvas (never
|
|
8449
|
+
// `m.canvas`, which an untrusted iframe could spoof).
|
|
8450
|
+
const activeWin = activePath ? iframesRef.current.get(activePath)?.contentWindow : null;
|
|
8451
|
+
const okShape =
|
|
8452
|
+
typeof m.id === 'string' &&
|
|
8453
|
+
Number.isFinite(m.left) &&
|
|
8454
|
+
Number.isFinite(m.top) &&
|
|
8455
|
+
Number.isFinite(m.beforeLeft) &&
|
|
8456
|
+
Number.isFinite(m.beforeTop);
|
|
8457
|
+
if (e.source === activeWin && okShape) {
|
|
8458
|
+
repositionElementRef.current?.(
|
|
8459
|
+
m.id,
|
|
8460
|
+
m.left,
|
|
8461
|
+
m.top,
|
|
8462
|
+
m.beforeLeft,
|
|
8463
|
+
m.beforeTop,
|
|
8464
|
+
Number.isInteger(m.idIndex) ? m.idIndex : undefined
|
|
8465
|
+
);
|
|
8466
|
+
}
|
|
8467
|
+
} else if (m.dgn === 'resize-request') {
|
|
8468
|
+
// feature-element-editing-robustness Stage D — in-canvas drag-resize
|
|
8469
|
+
// commit. Same trust model + confused-deputy guard as reposition-request:
|
|
8470
|
+
// untrusted canvas REQUESTS, shell WRITES, pinned to the ACTIVE canvas
|
|
8471
|
+
// (never `m.canvas`). Payload: { id, patch:{width,height,left?,top?},
|
|
8472
|
+
// before:{width,height,left,top} } — px strings, before values null when
|
|
8473
|
+
// the prop was unset (reset on undo).
|
|
8474
|
+
const activeWin = activePath ? iframesRef.current.get(activePath)?.contentWindow : null;
|
|
8475
|
+
const okShape =
|
|
8476
|
+
typeof m.id === 'string' && m.patch && typeof m.patch === 'object';
|
|
8477
|
+
if (e.source === activeWin && okShape) {
|
|
8478
|
+
resizeElementRef.current?.(
|
|
8479
|
+
m.id,
|
|
8480
|
+
m.patch,
|
|
8481
|
+
m.before,
|
|
8482
|
+
Number.isInteger(m.idIndex) ? m.idIndex : undefined
|
|
8483
|
+
);
|
|
8484
|
+
}
|
|
8485
|
+
} else if (m.dgn === 'delete-request') {
|
|
8486
|
+
// feature-element-editing-robustness Stage I — delete an element (Del key
|
|
8487
|
+
// / context menu / toolbar in the canvas). Same confused-deputy guard as
|
|
8488
|
+
// reorder/reposition/resize: untrusted canvas REQUESTS, shell WRITES,
|
|
8489
|
+
// pinned to the ACTIVE canvas (never `m.canvas`).
|
|
8490
|
+
const activeWin = activePath ? iframesRef.current.get(activePath)?.contentWindow : null;
|
|
8491
|
+
if (e.source === activeWin && typeof m.id === 'string') {
|
|
8492
|
+
deleteElementShellRef.current?.(m.id, Number.isInteger(m.idIndex) ? m.idIndex : undefined);
|
|
8493
|
+
}
|
|
8494
|
+
} else if (m.dgn === 'duplicate-request') {
|
|
8495
|
+
// Cmd+D (Task L3) — duplicate the selected element. Confused-deputy gated
|
|
8496
|
+
// + pinned to the active canvas, like the other structural verbs.
|
|
8497
|
+
const activeWin = activePath ? iframesRef.current.get(activePath)?.contentWindow : null;
|
|
8498
|
+
if (e.source === activeWin && typeof m.id === 'string') {
|
|
8499
|
+
duplicateElementShellRef.current?.(
|
|
8500
|
+
m.id,
|
|
8501
|
+
Number.isInteger(m.idIndex) ? m.idIndex : undefined
|
|
8502
|
+
);
|
|
8503
|
+
}
|
|
8504
|
+
} else if (m.dgn === 'copy-style') {
|
|
8505
|
+
// Task L4 — capture the current selection's authored style (shell-side).
|
|
8506
|
+
const activeWin = activePath ? iframesRef.current.get(activePath)?.contentWindow : null;
|
|
8507
|
+
if (e.source === activeWin) copyStyleRef.current?.();
|
|
8508
|
+
} else if (m.dgn === 'paste-style') {
|
|
8509
|
+
// Task L4 — apply the copied style to the target element.
|
|
8510
|
+
const activeWin = activePath ? iframesRef.current.get(activePath)?.contentWindow : null;
|
|
8511
|
+
if (e.source === activeWin && typeof m.id === 'string') pasteStyleRef.current?.(m.id);
|
|
8512
|
+
} else if (m.dgn === 'insert-request') {
|
|
8513
|
+
// Stage I3 — insert a synthesized div/text/image relative to `refId`.
|
|
8514
|
+
const activeWin = activePath ? iframesRef.current.get(activePath)?.contentWindow : null;
|
|
8515
|
+
const okShape =
|
|
8516
|
+
typeof m.refId === 'string' &&
|
|
8517
|
+
(m.kind === 'div' || m.kind === 'text' || m.kind === 'image') &&
|
|
8518
|
+
(m.position === 'before' ||
|
|
8519
|
+
m.position === 'after' ||
|
|
8520
|
+
m.position === 'inside-start' ||
|
|
8521
|
+
m.position === 'inside-end');
|
|
8522
|
+
if (e.source === activeWin && okShape) {
|
|
8523
|
+
insertElementShellRef.current?.(m.refId, m.position, m.kind, {
|
|
8524
|
+
src: typeof m.src === 'string' ? m.src : undefined,
|
|
8525
|
+
refIndex: Number.isInteger(m.refIndex) ? m.refIndex : undefined,
|
|
8526
|
+
});
|
|
8527
|
+
}
|
|
8528
|
+
} else if (m.dgn === 'insert-image-request') {
|
|
8529
|
+
// Stage F/I3 — "Insert ▸ Image" from the canvas context menu. An image
|
|
8530
|
+
// needs a contained asset src, so the shell opens the AssetPicker; the
|
|
8531
|
+
// pick then drives insertElementShell(kind:'image'). Confused-deputy
|
|
8532
|
+
// gated + pinned to the active canvas like the other request verbs.
|
|
8533
|
+
const activeWin = activePath ? iframesRef.current.get(activePath)?.contentWindow : null;
|
|
8534
|
+
const okShape =
|
|
8535
|
+
typeof m.refId === 'string' &&
|
|
8536
|
+
(m.position === 'before' ||
|
|
8537
|
+
m.position === 'after' ||
|
|
8538
|
+
m.position === 'inside-start' ||
|
|
8539
|
+
m.position === 'inside-end');
|
|
8540
|
+
if (e.source === activeWin && okShape) {
|
|
8541
|
+
openAssetPickerRef.current?.({
|
|
8542
|
+
purpose: 'insert-image',
|
|
8543
|
+
canvas: activePath,
|
|
8544
|
+
refId: m.refId,
|
|
8545
|
+
position: m.position,
|
|
8546
|
+
refIndex: Number.isInteger(m.refIndex) ? m.refIndex : undefined,
|
|
8547
|
+
});
|
|
8548
|
+
}
|
|
8549
|
+
} else if (m.dgn === 'replace-media-request') {
|
|
8550
|
+
// Stage F2 — "Replace image…" from the canvas context menu. Opens the
|
|
8551
|
+
// AssetPicker in replace mode; the pick re-points src via edit-attr. The
|
|
8552
|
+
// context menu supplies the current src as the undo before-value.
|
|
8553
|
+
const activeWin = activePath ? iframesRef.current.get(activePath)?.contentWindow : null;
|
|
8554
|
+
if (e.source === activeWin && typeof m.id === 'string') {
|
|
8555
|
+
openAssetPickerRef.current?.({
|
|
8556
|
+
purpose: 'replace-src',
|
|
8557
|
+
canvas: activePath,
|
|
8558
|
+
id: m.id,
|
|
8559
|
+
before: typeof m.before === 'string' ? m.before : null,
|
|
8560
|
+
});
|
|
8561
|
+
}
|
|
8562
|
+
} else if (m.dgn === 'replace-annotation-media-request') {
|
|
8563
|
+
// Stage F3 — "Replace…" on an annotation ImageStroke/MediaRefStroke.
|
|
8564
|
+
// Opens the SAME AssetPicker; unlike F2 the pick is posted BACK DOWN to
|
|
8565
|
+
// the canvas (no data-cd-id to ride edit-attr — the annotation model owns
|
|
8566
|
+
// its own strokes, so the canvas iframe performs the write itself).
|
|
8567
|
+
const activeWin = activePath ? iframesRef.current.get(activePath)?.contentWindow : null;
|
|
8568
|
+
if (e.source === activeWin && typeof m.id === 'string') {
|
|
8569
|
+
openAssetPickerRef.current?.({
|
|
8570
|
+
purpose: 'replace-annotation-media',
|
|
8571
|
+
canvas: activePath,
|
|
8572
|
+
id: m.id,
|
|
8573
|
+
before: typeof m.before === 'string' ? m.before : null,
|
|
8574
|
+
});
|
|
8575
|
+
}
|
|
8576
|
+
} else if (m.dgn === 'insert-artboard-request') {
|
|
8577
|
+
// Stage I4 — insert a new empty artboard from a screen-size preset.
|
|
8578
|
+
const activeWin = activePath ? iframesRef.current.get(activePath)?.contentWindow : null;
|
|
8579
|
+
const okShape =
|
|
8580
|
+
typeof m.id === 'string' &&
|
|
8581
|
+
Number.isFinite(m.width) &&
|
|
8582
|
+
Number.isFinite(m.height);
|
|
8583
|
+
if (e.source === activeWin && okShape) {
|
|
8584
|
+
insertArtboardShellRef.current?.({
|
|
8585
|
+
id: m.id,
|
|
8586
|
+
label: typeof m.label === 'string' ? m.label : m.id,
|
|
8587
|
+
width: m.width,
|
|
8588
|
+
height: m.height,
|
|
8589
|
+
});
|
|
8590
|
+
}
|
|
8591
|
+
} else if (m.dgn === 'resize-artboard-request') {
|
|
8592
|
+
// Stage D4 — free-hand artboard resize (numeric width/height props).
|
|
8593
|
+
const activeWin = activePath ? iframesRef.current.get(activePath)?.contentWindow : null;
|
|
8594
|
+
const okShape =
|
|
8595
|
+
typeof m.artboardId === 'string' &&
|
|
8596
|
+
(Number.isFinite(m.width) || Number.isFinite(m.height));
|
|
8597
|
+
if (e.source === activeWin && okShape) {
|
|
8598
|
+
resizeArtboardShellRef.current?.(
|
|
8599
|
+
m.artboardId,
|
|
8600
|
+
Number.isFinite(m.width) ? m.width : undefined,
|
|
8601
|
+
Number.isFinite(m.height) ? m.height : undefined
|
|
8602
|
+
);
|
|
8603
|
+
}
|
|
8604
|
+
} else if (m.dgn === 'delete-artboard-request') {
|
|
8605
|
+
// Backspace / context-menu delete of a whole artboard (by its id prop).
|
|
8606
|
+
const activeWin = activePath ? iframesRef.current.get(activePath)?.contentWindow : null;
|
|
8607
|
+
if (e.source === activeWin && typeof m.artboardId === 'string') {
|
|
8608
|
+
deleteArtboardShellRef.current?.(m.artboardId);
|
|
8609
|
+
}
|
|
7742
8610
|
} else if (m.dgn === 'open-inspector') {
|
|
7743
8611
|
// Phase 12 — context-menu "Inspect" / tool-palette Inspect opens the right panel.
|
|
7744
8612
|
openRightPanel('inspector');
|
|
@@ -7969,7 +8837,12 @@ function App() {
|
|
|
7969
8837
|
} catch {}
|
|
7970
8838
|
};
|
|
7971
8839
|
try {
|
|
7972
|
-
|
|
8840
|
+
// feature-background-export-notification-center — enqueue and reply
|
|
8841
|
+
// with the job id immediately; the notification center (which
|
|
8842
|
+
// already owns the WS connection) is the single place status/
|
|
8843
|
+
// progress/completion live from here, regardless of which dialog
|
|
8844
|
+
// created the job.
|
|
8845
|
+
const r = await fetch('/_api/export-jobs', {
|
|
7973
8846
|
method: 'POST',
|
|
7974
8847
|
headers: { 'content-type': 'application/json' },
|
|
7975
8848
|
body: JSON.stringify(payload),
|
|
@@ -7978,28 +8851,8 @@ function App() {
|
|
|
7978
8851
|
reply({ ok: false, error: (await r.text()) || String(r.status) });
|
|
7979
8852
|
return;
|
|
7980
8853
|
}
|
|
7981
|
-
const
|
|
7982
|
-
|
|
7983
|
-
const filename = (fn && fn[1]) || 'export';
|
|
7984
|
-
const blob = await r.blob();
|
|
7985
|
-
if (isNativeApp()) {
|
|
7986
|
-
// Native: route the bridged in-canvas export through the OS save dialog
|
|
7987
|
-
// too (WKWebView swallows the `<a download>` blob). Mirror the shell
|
|
7988
|
-
// ExportDialog's doExport native branch.
|
|
7989
|
-
const bytes = Array.from(new Uint8Array(await blob.arrayBuffer()));
|
|
7990
|
-
const savedPath = await saveExport(filename, bytes);
|
|
7991
|
-
reply(savedPath ? { ok: true, filename } : { ok: false, error: 'Save cancelled' });
|
|
7992
|
-
return;
|
|
7993
|
-
}
|
|
7994
|
-
const url = URL.createObjectURL(blob);
|
|
7995
|
-
const a = document.createElement('a');
|
|
7996
|
-
a.href = url;
|
|
7997
|
-
a.download = filename;
|
|
7998
|
-
document.body.appendChild(a);
|
|
7999
|
-
a.click();
|
|
8000
|
-
a.remove();
|
|
8001
|
-
URL.revokeObjectURL(url);
|
|
8002
|
-
reply({ ok: true, filename });
|
|
8854
|
+
const { jobId } = await r.json();
|
|
8855
|
+
reply({ ok: true, jobId });
|
|
8003
8856
|
} catch (err) {
|
|
8004
8857
|
reply({ ok: false, error: err && err.message ? err.message : String(err) });
|
|
8005
8858
|
}
|
|
@@ -8184,6 +9037,536 @@ function App() {
|
|
|
8184
9037
|
reorderLayerRef.current = reorderLayer;
|
|
8185
9038
|
}, [reorderLayer]);
|
|
8186
9039
|
|
|
9040
|
+
// Commit an in-canvas coordinate-mode drag (out-of-flow element: absolute/
|
|
9041
|
+
// fixed) as two sequential single-property writes through the SAME
|
|
9042
|
+
// main-origin endpoint the CSS panel already uses (`/_api/edit-css`, Phase
|
|
9043
|
+
// 12 / DDR-103) — no new write surface. `editStyleProp` upserts into the
|
|
9044
|
+
// existing `style={{}}` object, so the second call (top) lands next to the
|
|
9045
|
+
// first (left) rather than clobbering it. Serialized on the shared
|
|
9046
|
+
// apply-edit chain so it can't race an in-flight write to the same file.
|
|
9047
|
+
const repositionElement = useCallback(
|
|
9048
|
+
(id, left, top, beforeLeft, beforeTop, idIndex) => {
|
|
9049
|
+
if (!id || !activePath) return;
|
|
9050
|
+
const canvas = activePath;
|
|
9051
|
+
// Stage H3 — when the dragged target is a whole component INSTANCE the canvas
|
|
9052
|
+
// passes its DOM-occurrence index; the server routes the left/top write to
|
|
9053
|
+
// that instance's own `<Component/>` usage so moving one instance stays local.
|
|
9054
|
+
const occ = Number.isInteger(idIndex) ? idIndex : undefined;
|
|
9055
|
+
// INV-2 (DDR-105) — arm the reload-suppression window BEFORE the edit-css
|
|
9056
|
+
// writes so the HMR reload is skipped and the canvas doesn't remount + drop
|
|
9057
|
+
// the selection (dogfood: "když pohnu elementem myší, ztratím focus"). Same
|
|
9058
|
+
// fix as resizeElement; also covers the keyboard nudge (L1) which reuses this.
|
|
9059
|
+
applyOptimisticStyle({ id, prop: 'left', value: `${left}px` });
|
|
9060
|
+
applyOptimisticStyle({ id, prop: 'top', value: `${top}px` });
|
|
9061
|
+
const writeProp = (property, value) =>
|
|
9062
|
+
fetch('/_api/edit-css', {
|
|
9063
|
+
method: 'POST',
|
|
9064
|
+
headers: { 'content-type': 'application/json' },
|
|
9065
|
+
body: JSON.stringify({ canvas, id, property, value: `${value}px`, idIndex: occ }),
|
|
9066
|
+
}).then((r) => r.json().catch(() => ({})));
|
|
9067
|
+
editApplyChainRef.current = editApplyChainRef.current
|
|
9068
|
+
.catch(() => {})
|
|
9069
|
+
.then(() => writeProp('left', left))
|
|
9070
|
+
.then((j1) => {
|
|
9071
|
+
if (!j1.ok) throw new Error(j1.error || 'left write failed');
|
|
9072
|
+
return writeProp('top', top);
|
|
9073
|
+
})
|
|
9074
|
+
.then((j2) => {
|
|
9075
|
+
if (!j2.ok) throw new Error(j2.error || 'top write failed');
|
|
9076
|
+
// Record BOTH properties onto the canvas undo stack — two Cmd+Z
|
|
9077
|
+
// steps (top, then left), same as committing two CSS knobs by hand.
|
|
9078
|
+
recordSourceEdit({
|
|
9079
|
+
op: 'css',
|
|
9080
|
+
canvas,
|
|
9081
|
+
id,
|
|
9082
|
+
key: 'left',
|
|
9083
|
+
before: `${beforeLeft}px`,
|
|
9084
|
+
after: `${left}px`,
|
|
9085
|
+
});
|
|
9086
|
+
recordSourceEdit({
|
|
9087
|
+
op: 'css',
|
|
9088
|
+
canvas,
|
|
9089
|
+
id,
|
|
9090
|
+
key: 'top',
|
|
9091
|
+
before: `${beforeTop}px`,
|
|
9092
|
+
after: `${top}px`,
|
|
9093
|
+
});
|
|
9094
|
+
})
|
|
9095
|
+
.catch((err) => {
|
|
9096
|
+
console.warn('[reposition]', err?.message || err);
|
|
9097
|
+
// Nothing (or only `left`) persisted — tell the canvas to restore
|
|
9098
|
+
// the pre-drag inline style so a phantom move doesn't linger.
|
|
9099
|
+
postToActiveCanvas({ dgn: 'reposition-failed' });
|
|
9100
|
+
});
|
|
9101
|
+
},
|
|
9102
|
+
[activePath, postToActiveCanvas, recordSourceEdit, applyOptimisticStyle]
|
|
9103
|
+
);
|
|
9104
|
+
useEffect(() => {
|
|
9105
|
+
repositionElementRef.current = repositionElement;
|
|
9106
|
+
}, [repositionElement]);
|
|
9107
|
+
|
|
9108
|
+
// feature-element-editing-robustness Stage D (Task D3) — commit an in-canvas
|
|
9109
|
+
// drag-resize. `patch` = { width, height, left?, top? } (px strings); left/top
|
|
9110
|
+
// are present only for a top/left-edge drag on an out-of-flow element. Writes
|
|
9111
|
+
// each present property through the SAME main-origin `/_api/edit-css` endpoint
|
|
9112
|
+
// the CSS panel + reposition use (no new write surface), serialized on the
|
|
9113
|
+
// shared apply-edit chain, and records one undo entry per property (as
|
|
9114
|
+
// reposition records left+top). Pinned to `activePath` (never `m.canvas`) —
|
|
9115
|
+
// the confused-deputy guard DDR-138/DDR-054 established for reorder/reposition.
|
|
9116
|
+
const resizeElement = useCallback(
|
|
9117
|
+
(id, patch, before, idIndex) => {
|
|
9118
|
+
if (!id || !activePath || !patch || typeof patch !== 'object') return;
|
|
9119
|
+
const canvas = activePath;
|
|
9120
|
+
const b = before && typeof before === 'object' ? before : {};
|
|
9121
|
+
// Stage H3 — a whole-instance resize carries its DOM-occurrence index so the
|
|
9122
|
+
// width/height/left/top write lands on that instance's own `<Component/>`
|
|
9123
|
+
// usage (local), not the shared inner definition. undefined for a plain element.
|
|
9124
|
+
const occ = Number.isInteger(idIndex) ? idIndex : undefined;
|
|
9125
|
+
// `transform` rides the same lane for the rotate handle (Task L8);
|
|
9126
|
+
// `padding-*`/`gap` ride it for the on-canvas spacing drag (Stage J) — same
|
|
9127
|
+
// single-prop /_api/edit-css write + per-prop undo record, no new lane.
|
|
9128
|
+
const props = [
|
|
9129
|
+
'width',
|
|
9130
|
+
'height',
|
|
9131
|
+
'left',
|
|
9132
|
+
'top',
|
|
9133
|
+
'transform',
|
|
9134
|
+
'padding-top',
|
|
9135
|
+
'padding-right',
|
|
9136
|
+
'padding-bottom',
|
|
9137
|
+
'padding-left',
|
|
9138
|
+
'gap',
|
|
9139
|
+
].filter((p) => typeof patch[p] === 'string' && patch[p]);
|
|
9140
|
+
if (!props.length) return;
|
|
9141
|
+
// Dogfood 2026-07-07 — "resize paddingu nebo gap" was still deselecting on
|
|
9142
|
+
// completion. `applyOptimisticStyle` was posting {id, prop, value} only —
|
|
9143
|
+
// missing artboardId/index — so the canvas-side `apply-style` handler
|
|
9144
|
+
// resolved the UNSCOPED `[data-cd-id]` selector (dom-selection.ts
|
|
9145
|
+
// `resolveSelectionEl`/`scopedCdSelector`), which is a latent multi-
|
|
9146
|
+
// artboard/reused-component miss. Pull the current selection's own
|
|
9147
|
+
// artboardId/index (when it matches `id`) so every optimistic apply +
|
|
9148
|
+
// the belt-and-suspenders reselect below target the SAME instance.
|
|
9149
|
+
const curSel = selectedRef.current;
|
|
9150
|
+
const curOne = Array.isArray(curSel)
|
|
9151
|
+
? curSel.length === 1
|
|
9152
|
+
? curSel[0]
|
|
9153
|
+
: null
|
|
9154
|
+
: curSel;
|
|
9155
|
+
const selArtboardId = curOne && curOne.id === id ? (curOne.artboardId ?? null) : null;
|
|
9156
|
+
const selIndex = curOne && curOne.id === id ? (curOne.index ?? 0) : 0;
|
|
9157
|
+
// INV-2 (DDR-105) — arm the reload-suppression window BEFORE the edit-css
|
|
9158
|
+
// writes so the follow-up HMR is skipped. Without this the canvas remounts
|
|
9159
|
+
// and drops the selection (the "resize deselects the element" dogfood bug).
|
|
9160
|
+
for (const p of props) {
|
|
9161
|
+
applyOptimisticStyle({
|
|
9162
|
+
id,
|
|
9163
|
+
artboardId: selArtboardId,
|
|
9164
|
+
index: selIndex,
|
|
9165
|
+
prop: p,
|
|
9166
|
+
value: patch[p],
|
|
9167
|
+
});
|
|
9168
|
+
}
|
|
9169
|
+
const writeProp = (property, value) =>
|
|
9170
|
+
fetch('/_api/edit-css', {
|
|
9171
|
+
method: 'POST',
|
|
9172
|
+
headers: { 'content-type': 'application/json' },
|
|
9173
|
+
body: JSON.stringify({ canvas, id, property, value, idIndex: occ }),
|
|
9174
|
+
}).then((r) => r.json().catch(() => ({})));
|
|
9175
|
+
let chain = editApplyChainRef.current.catch(() => {});
|
|
9176
|
+
for (const p of props) {
|
|
9177
|
+
chain = chain.then((prev) => {
|
|
9178
|
+
if (prev && prev.ok === false) throw new Error(prev.error || `${p} write failed`);
|
|
9179
|
+
return writeProp(p, patch[p]);
|
|
9180
|
+
});
|
|
9181
|
+
}
|
|
9182
|
+
editApplyChainRef.current = chain
|
|
9183
|
+
.then((last) => {
|
|
9184
|
+
if (last && last.ok === false) throw new Error(last.error || 'resize write failed');
|
|
9185
|
+
// Record one undo entry per written property (each Cmd+Z reverts one).
|
|
9186
|
+
for (const p of props) {
|
|
9187
|
+
recordSourceEdit({ op: 'css', canvas, id, key: p, before: b[p] ?? null, after: patch[p] });
|
|
9188
|
+
}
|
|
9189
|
+
// Dogfood 2026-07-07 — belt-and-suspenders reselect, mirroring the
|
|
9190
|
+
// structural ops' `pendingReorderRef` re-settle. The DDR-105 suppression
|
|
9191
|
+
// window should make this a no-op (no reload → the selection was never
|
|
9192
|
+
// lost) — idempotent per `scheduleHaloRestore`'s own doc comment, so it's
|
|
9193
|
+
// safe to always fire, and it's the backstop if suppression ever misses
|
|
9194
|
+
// (a slow FS-watcher round trip past the 1.5s window, for example).
|
|
9195
|
+
scheduleHaloRestore({ id, file: canvas, artboardId: selArtboardId, index: selIndex });
|
|
9196
|
+
})
|
|
9197
|
+
.catch((err) => {
|
|
9198
|
+
console.warn('[resize]', err?.message || err);
|
|
9199
|
+
// Nothing (or only a prefix) persisted — tell the canvas to restore the
|
|
9200
|
+
// pre-drag inline style so a phantom resize doesn't linger.
|
|
9201
|
+
postToActiveCanvas({ dgn: 'resize-failed' });
|
|
9202
|
+
});
|
|
9203
|
+
},
|
|
9204
|
+
[activePath, postToActiveCanvas, recordSourceEdit, applyOptimisticStyle, scheduleHaloRestore]
|
|
9205
|
+
);
|
|
9206
|
+
useEffect(() => {
|
|
9207
|
+
resizeElementRef.current = resizeElement;
|
|
9208
|
+
}, [resizeElement]);
|
|
9209
|
+
|
|
9210
|
+
// feature-element-editing-robustness Stage I — general element structural edits
|
|
9211
|
+
// (delete / insert element / insert artboard) + Stage D4 (artboard resize).
|
|
9212
|
+
// Each is a main-origin-only write the untrusted canvas can only REQUEST over
|
|
9213
|
+
// the dgn:* bus; the shell performs it, pinned to `activePath` (confused-deputy
|
|
9214
|
+
// guard, DDR-054/138). Undo reuses the reorder command's whole-file seq revert
|
|
9215
|
+
// (record-edit op:'reorder') — a structural edit renumbers positional ids, so an
|
|
9216
|
+
// inverse descriptor goes stale (same reason reorder uses the server seq log).
|
|
9217
|
+
const structuralWriteRef = useRef(null);
|
|
9218
|
+
const structuralWrite = useCallback(
|
|
9219
|
+
(route, body, { label, onOk, onFail } = {}) => {
|
|
9220
|
+
if (!activePath) return;
|
|
9221
|
+
const canvas = activePath;
|
|
9222
|
+
editApplyChainRef.current = editApplyChainRef.current
|
|
9223
|
+
.catch(() => {})
|
|
9224
|
+
.then(() =>
|
|
9225
|
+
fetch(route, {
|
|
9226
|
+
method: 'POST',
|
|
9227
|
+
headers: { 'content-type': 'application/json' },
|
|
9228
|
+
body: JSON.stringify({ ...body, canvas }),
|
|
9229
|
+
})
|
|
9230
|
+
.then((r) => r.json().catch(() => ({})))
|
|
9231
|
+
.then((j) => {
|
|
9232
|
+
if (!j.ok) {
|
|
9233
|
+
console.warn(`[${route}]`, j.error || 'failed');
|
|
9234
|
+
onFail?.(j);
|
|
9235
|
+
return;
|
|
9236
|
+
}
|
|
9237
|
+
if (typeof j.seq === 'number') {
|
|
9238
|
+
postToActiveCanvas({
|
|
9239
|
+
dgn: 'record-edit',
|
|
9240
|
+
payload: { op: 'reorder', canvas, seq: j.seq, label: label || 'edit' },
|
|
9241
|
+
});
|
|
9242
|
+
}
|
|
9243
|
+
onOk?.(j, canvas);
|
|
9244
|
+
})
|
|
9245
|
+
.catch((err) => onFail?.({ error: err?.message }))
|
|
9246
|
+
);
|
|
9247
|
+
},
|
|
9248
|
+
[activePath, postToActiveCanvas]
|
|
9249
|
+
);
|
|
9250
|
+
useEffect(() => {
|
|
9251
|
+
structuralWriteRef.current = structuralWrite;
|
|
9252
|
+
}, [structuralWrite]);
|
|
9253
|
+
|
|
9254
|
+
const deleteElementShell = useCallback(
|
|
9255
|
+
(id, idIndex) => {
|
|
9256
|
+
// After the delete lands + HMR reloads, clear the (now-gone) selection.
|
|
9257
|
+
structuralWrite(
|
|
9258
|
+
'/_api/delete-element',
|
|
9259
|
+
{ id, idIndex: Number.isInteger(idIndex) ? idIndex : undefined },
|
|
9260
|
+
{ label: 'delete element', onOk: () => postToActiveCanvas({ dgn: 'selection-clear' }) }
|
|
9261
|
+
);
|
|
9262
|
+
},
|
|
9263
|
+
[structuralWrite, postToActiveCanvas]
|
|
9264
|
+
);
|
|
9265
|
+
|
|
9266
|
+
const insertElementShell = useCallback(
|
|
9267
|
+
(refId, position, kind, opts = {}) => {
|
|
9268
|
+
structuralWrite(
|
|
9269
|
+
'/_api/insert-element',
|
|
9270
|
+
{
|
|
9271
|
+
refId,
|
|
9272
|
+
position,
|
|
9273
|
+
kind,
|
|
9274
|
+
src: typeof opts.src === 'string' ? opts.src : undefined,
|
|
9275
|
+
refIndex: Number.isInteger(opts.refIndex) ? opts.refIndex : undefined,
|
|
9276
|
+
},
|
|
9277
|
+
{
|
|
9278
|
+
label: `insert ${kind}`,
|
|
9279
|
+
// Select the new element once the HMR reload lands (its id is stamped
|
|
9280
|
+
// on transpile — best-effort, like reorder's pendingReorderRef).
|
|
9281
|
+
onOk: (j, canvas) => {
|
|
9282
|
+
if (j.newId) pendingReorderRef.current = { file: canvas, movedId: j.newId, artboardId: null };
|
|
9283
|
+
},
|
|
9284
|
+
}
|
|
9285
|
+
);
|
|
9286
|
+
},
|
|
9287
|
+
[structuralWrite]
|
|
9288
|
+
);
|
|
9289
|
+
|
|
9290
|
+
const duplicateElementShell = useCallback(
|
|
9291
|
+
(id, idIndex) => {
|
|
9292
|
+
structuralWrite(
|
|
9293
|
+
'/_api/duplicate-element',
|
|
9294
|
+
{ id, idIndex: Number.isInteger(idIndex) ? idIndex : undefined },
|
|
9295
|
+
{
|
|
9296
|
+
label: 'duplicate element',
|
|
9297
|
+
// Select the copy once the HMR reload lands (best-effort, like insert).
|
|
9298
|
+
onOk: (j, canvas) => {
|
|
9299
|
+
if (j.newId) pendingReorderRef.current = { file: canvas, movedId: j.newId, artboardId: null };
|
|
9300
|
+
},
|
|
9301
|
+
}
|
|
9302
|
+
);
|
|
9303
|
+
},
|
|
9304
|
+
[structuralWrite]
|
|
9305
|
+
);
|
|
9306
|
+
|
|
9307
|
+
// Task L4 — copy-style / paste-style. Captures a selection's AUTHORED inline
|
|
9308
|
+
// styles (not resolved/computed, so DS-token + inherited values aren't baked in)
|
|
9309
|
+
// minus layout/geometry props, then applies them to another element via chained
|
|
9310
|
+
// edit-css writes (one per prop, like resize — N-step undo). Clipboard lives in
|
|
9311
|
+
// the shell so it survives selection + canvas changes.
|
|
9312
|
+
const copiedStyleRef = useRef(null);
|
|
9313
|
+
// Appearance-only: exclude position/size/margin so paste-style copies the LOOK,
|
|
9314
|
+
// not the layout (Figma parity — padding/color/border/shadow/font/… carry over).
|
|
9315
|
+
const PASTE_STYLE_EXCLUDE = useMemo(
|
|
9316
|
+
() =>
|
|
9317
|
+
new Set([
|
|
9318
|
+
'position',
|
|
9319
|
+
'top',
|
|
9320
|
+
'right',
|
|
9321
|
+
'bottom',
|
|
9322
|
+
'left',
|
|
9323
|
+
'inset',
|
|
9324
|
+
'width',
|
|
9325
|
+
'height',
|
|
9326
|
+
'min-width',
|
|
9327
|
+
'max-width',
|
|
9328
|
+
'min-height',
|
|
9329
|
+
'max-height',
|
|
9330
|
+
'margin',
|
|
9331
|
+
'margin-top',
|
|
9332
|
+
'margin-right',
|
|
9333
|
+
'margin-bottom',
|
|
9334
|
+
'margin-left',
|
|
9335
|
+
]),
|
|
9336
|
+
[]
|
|
9337
|
+
);
|
|
9338
|
+
const copyStyle = useCallback(() => {
|
|
9339
|
+
const sel = selectedRef.current;
|
|
9340
|
+
const one = Array.isArray(sel) ? (sel.length === 1 ? sel[0] : null) : sel;
|
|
9341
|
+
if (!one) return;
|
|
9342
|
+
const map = {};
|
|
9343
|
+
for (const [k, v] of Object.entries(one.authored || {})) {
|
|
9344
|
+
if (!PASTE_STYLE_EXCLUDE.has(k)) map[k] = v;
|
|
9345
|
+
}
|
|
9346
|
+
for (const [k, v] of Object.entries(one.customStyles || {})) {
|
|
9347
|
+
if (!PASTE_STYLE_EXCLUDE.has(k)) map[k] = v;
|
|
9348
|
+
}
|
|
9349
|
+
copiedStyleRef.current = Object.keys(map).length ? map : null;
|
|
9350
|
+
}, [PASTE_STYLE_EXCLUDE]);
|
|
9351
|
+
const pasteStyle = useCallback(
|
|
9352
|
+
(id) => {
|
|
9353
|
+
const canvas = activePath;
|
|
9354
|
+
const map = copiedStyleRef.current;
|
|
9355
|
+
if (!canvas || !id || !map) return;
|
|
9356
|
+
const entries = Object.entries(map);
|
|
9357
|
+
let chain = editApplyChainRef.current.catch(() => {});
|
|
9358
|
+
for (const [property, value] of entries) {
|
|
9359
|
+
applyOptimisticStyle({ id, prop: property, value }); // preview + arm no-flicker
|
|
9360
|
+
chain = chain.then(() =>
|
|
9361
|
+
fetch('/_api/edit-css', {
|
|
9362
|
+
method: 'POST',
|
|
9363
|
+
headers: { 'content-type': 'application/json' },
|
|
9364
|
+
body: JSON.stringify({ canvas, id, property, value }),
|
|
9365
|
+
})
|
|
9366
|
+
.then((r) => r.json().catch(() => ({})))
|
|
9367
|
+
.then((j) => {
|
|
9368
|
+
if (j.ok)
|
|
9369
|
+
recordSourceEdit({ op: 'css', canvas, id, key: property, before: null, after: value });
|
|
9370
|
+
})
|
|
9371
|
+
.catch(() => {})
|
|
9372
|
+
);
|
|
9373
|
+
}
|
|
9374
|
+
editApplyChainRef.current = chain;
|
|
9375
|
+
},
|
|
9376
|
+
[activePath, applyOptimisticStyle, recordSourceEdit]
|
|
9377
|
+
);
|
|
9378
|
+
|
|
9379
|
+
const insertArtboardShell = useCallback(
|
|
9380
|
+
({ id, label, width, height }) => {
|
|
9381
|
+
structuralWrite(
|
|
9382
|
+
'/_api/insert-artboard',
|
|
9383
|
+
{ id, label, width, height },
|
|
9384
|
+
{ label: 'insert artboard' }
|
|
9385
|
+
);
|
|
9386
|
+
},
|
|
9387
|
+
[structuralWrite]
|
|
9388
|
+
);
|
|
9389
|
+
|
|
9390
|
+
// Stage I4 — "New artboard: <preset>" from the Edit menu. Generates a unique
|
|
9391
|
+
// id (server 422s a dup, negligible with a random suffix) + a preset label/dims
|
|
9392
|
+
// and inserts an empty <DCArtboard> after the last one (runtime default-grid
|
|
9393
|
+
// places it; DDR-027). The new frame is then selectable + resizable.
|
|
9394
|
+
const onInsertArtboard = useCallback(
|
|
9395
|
+
(presetKey) => {
|
|
9396
|
+
const p = SCREEN_PRESETS[presetKey];
|
|
9397
|
+
if (!p) return;
|
|
9398
|
+
const id = `s${Math.random().toString(36).slice(2, 8)}`;
|
|
9399
|
+
insertArtboardShell({ id, label: p.label, width: p.width, height: p.height });
|
|
9400
|
+
},
|
|
9401
|
+
[insertArtboardShell]
|
|
9402
|
+
);
|
|
9403
|
+
|
|
9404
|
+
const resizeArtboardShell = useCallback(
|
|
9405
|
+
(artboardId, width, height) => {
|
|
9406
|
+
structuralWrite(
|
|
9407
|
+
'/_api/resize-artboard',
|
|
9408
|
+
{ artboardId, width, height },
|
|
9409
|
+
{
|
|
9410
|
+
label: 'resize artboard',
|
|
9411
|
+
// Stage D4 — the resize overlay applies a live inline-style preview
|
|
9412
|
+
// during the drag (no HMR yet); on a rejected/failed write, tell it to
|
|
9413
|
+
// restore the pre-drag box so a phantom resize doesn't linger.
|
|
9414
|
+
onFail: () => postToActiveCanvas({ dgn: 'resize-artboard-failed' }),
|
|
9415
|
+
}
|
|
9416
|
+
);
|
|
9417
|
+
},
|
|
9418
|
+
[structuralWrite, postToActiveCanvas]
|
|
9419
|
+
);
|
|
9420
|
+
|
|
9421
|
+
const deleteArtboardShell = useCallback(
|
|
9422
|
+
(artboardId) => {
|
|
9423
|
+
structuralWrite('/_api/delete-artboard', { artboardId }, { label: 'delete artboard' });
|
|
9424
|
+
},
|
|
9425
|
+
[structuralWrite]
|
|
9426
|
+
);
|
|
9427
|
+
// Refs the (stale-closure) onMessage handlers below read.
|
|
9428
|
+
const deleteElementShellRef = useRef(null);
|
|
9429
|
+
const insertElementShellRef = useRef(null);
|
|
9430
|
+
const insertArtboardShellRef = useRef(null);
|
|
9431
|
+
const resizeArtboardShellRef = useRef(null);
|
|
9432
|
+
const deleteArtboardShellRef = useRef(null);
|
|
9433
|
+
const duplicateElementShellRef = useRef(null);
|
|
9434
|
+
const copyStyleRef = useRef(null);
|
|
9435
|
+
const pasteStyleRef = useRef(null);
|
|
9436
|
+
useEffect(() => {
|
|
9437
|
+
deleteElementShellRef.current = deleteElementShell;
|
|
9438
|
+
insertElementShellRef.current = insertElementShell;
|
|
9439
|
+
insertArtboardShellRef.current = insertArtboardShell;
|
|
9440
|
+
resizeArtboardShellRef.current = resizeArtboardShell;
|
|
9441
|
+
deleteArtboardShellRef.current = deleteArtboardShell;
|
|
9442
|
+
duplicateElementShellRef.current = duplicateElementShell;
|
|
9443
|
+
copyStyleRef.current = copyStyle;
|
|
9444
|
+
pasteStyleRef.current = pasteStyle;
|
|
9445
|
+
}, [
|
|
9446
|
+
deleteElementShell,
|
|
9447
|
+
insertElementShell,
|
|
9448
|
+
insertArtboardShell,
|
|
9449
|
+
resizeArtboardShell,
|
|
9450
|
+
deleteArtboardShell,
|
|
9451
|
+
duplicateElementShell,
|
|
9452
|
+
copyStyle,
|
|
9453
|
+
pasteStyle,
|
|
9454
|
+
]);
|
|
9455
|
+
|
|
9456
|
+
// Shell-level Backspace/Delete guard. CRITICAL: in the Tauri desktop app, an
|
|
9457
|
+
// unhandled Backspace triggers WKWebView back-navigation, which reloads the
|
|
9458
|
+
// WHOLE app to "Starting…" (dogfood crash). When an artboard is selected, focus
|
|
9459
|
+
// sits on the shell (not the canvas iframe), so the in-canvas key handler never
|
|
9460
|
+
// sees the keydown — the shell must catch it. Preventing the default here stops
|
|
9461
|
+
// the back-nav universally; if a single artboard is the selection, also delete
|
|
9462
|
+
// it (Backspace parity with the context menu). Element delete stays in the
|
|
9463
|
+
// canvas iframe (which has focus when an element is selected; its keydown never
|
|
9464
|
+
// reaches this window, so there's no double-handling).
|
|
9465
|
+
useEffect(() => {
|
|
9466
|
+
const onKey = (e) => {
|
|
9467
|
+
if (e.key !== 'Backspace' && e.key !== 'Delete') return;
|
|
9468
|
+
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
|
9469
|
+
const t = e.target;
|
|
9470
|
+
const editable =
|
|
9471
|
+
t &&
|
|
9472
|
+
(t.tagName === 'INPUT' ||
|
|
9473
|
+
t.tagName === 'TEXTAREA' ||
|
|
9474
|
+
t.tagName === 'SELECT' ||
|
|
9475
|
+
t.isContentEditable);
|
|
9476
|
+
if (editable) return;
|
|
9477
|
+
if (!activePath || activePath === SYSTEM_TAB) return;
|
|
9478
|
+
e.preventDefault();
|
|
9479
|
+
const one = Array.isArray(selected) ? (selected.length === 1 ? selected[0] : null) : selected;
|
|
9480
|
+
if (one?.artboardId && !one.id) deleteArtboardShell(one.artboardId);
|
|
9481
|
+
};
|
|
9482
|
+
window.addEventListener('keydown', onKey);
|
|
9483
|
+
return () => window.removeEventListener('keydown', onKey);
|
|
9484
|
+
}, [activePath, selected, deleteArtboardShell]);
|
|
9485
|
+
|
|
9486
|
+
// feature-element-editing-robustness Stage F — AssetPicker request. `req` is
|
|
9487
|
+
// { purpose:'insert-image', refId, position, refIndex } (Insert ▸ Image) or
|
|
9488
|
+
// { purpose:'replace-src', id, before } (Media-section "Replace…"). null = closed.
|
|
9489
|
+
const [assetPickerReq, setAssetPickerReq] = useState(null);
|
|
9490
|
+
const openAssetPickerRef = useRef(null);
|
|
9491
|
+
useEffect(() => {
|
|
9492
|
+
openAssetPickerRef.current = (req) => setAssetPickerReq(req);
|
|
9493
|
+
}, []);
|
|
9494
|
+
const onAssetPicked = useCallback(
|
|
9495
|
+
(pickedPath) => {
|
|
9496
|
+
const req = assetPickerReq;
|
|
9497
|
+
setAssetPickerReq(null);
|
|
9498
|
+
if (!req || !pickedPath) return;
|
|
9499
|
+
// G3 security (DDR-152) — the request captured refId/id against the canvas
|
|
9500
|
+
// that was active when the picker opened; if the user switched canvases
|
|
9501
|
+
// while the modal was up, those ids are meaningless (or worse, collide) on
|
|
9502
|
+
// the now-active canvas. Abort rather than write to the wrong file.
|
|
9503
|
+
if (req.canvas && req.canvas !== activePath) {
|
|
9504
|
+
console.warn('[asset-picker] active canvas changed since request — aborting');
|
|
9505
|
+
return;
|
|
9506
|
+
}
|
|
9507
|
+
if (req.purpose === 'insert-image') {
|
|
9508
|
+
insertElementShell(req.refId, req.position || 'after', 'image', {
|
|
9509
|
+
src: pickedPath,
|
|
9510
|
+
refIndex: req.refIndex,
|
|
9511
|
+
});
|
|
9512
|
+
return;
|
|
9513
|
+
}
|
|
9514
|
+
if (req.purpose === 'replace-src') {
|
|
9515
|
+
// Re-point an authored <img>/<video> src via /_api/edit-attr (+ undo).
|
|
9516
|
+
const canvas = activePath;
|
|
9517
|
+
if (!canvas || !req.id) return;
|
|
9518
|
+
editApplyChainRef.current = editApplyChainRef.current
|
|
9519
|
+
.catch(() => {})
|
|
9520
|
+
.then(() =>
|
|
9521
|
+
fetch('/_api/edit-attr', {
|
|
9522
|
+
method: 'POST',
|
|
9523
|
+
headers: { 'content-type': 'application/json' },
|
|
9524
|
+
body: JSON.stringify({ canvas, id: req.id, attr: 'src', value: pickedPath }),
|
|
9525
|
+
})
|
|
9526
|
+
.then((r) => r.json().catch(() => ({})))
|
|
9527
|
+
.then((j) => {
|
|
9528
|
+
if (j.ok) {
|
|
9529
|
+
recordSourceEdit({
|
|
9530
|
+
op: 'attr',
|
|
9531
|
+
canvas,
|
|
9532
|
+
id: req.id,
|
|
9533
|
+
key: 'src',
|
|
9534
|
+
before: req.before ?? null,
|
|
9535
|
+
after: pickedPath,
|
|
9536
|
+
});
|
|
9537
|
+
} else {
|
|
9538
|
+
console.warn('[replace-src]', j.error || 'failed');
|
|
9539
|
+
}
|
|
9540
|
+
})
|
|
9541
|
+
.catch(() => {})
|
|
9542
|
+
);
|
|
9543
|
+
return;
|
|
9544
|
+
}
|
|
9545
|
+
if (req.purpose === 'replace-annotation-media') {
|
|
9546
|
+
// Stage F3 — relay the picked path DOWN to the canvas; the annotation
|
|
9547
|
+
// model owns its own strokes + persistence + undo (`commitStrokes`), so
|
|
9548
|
+
// unlike `replace-src` the shell performs no write of its own here.
|
|
9549
|
+
postToActiveCanvas({ dgn: 'replace-annotation-media', id: req.id, path: pickedPath });
|
|
9550
|
+
}
|
|
9551
|
+
},
|
|
9552
|
+
[assetPickerReq, insertElementShell, activePath, recordSourceEdit, postToActiveCanvas]
|
|
9553
|
+
);
|
|
9554
|
+
// Media-section "Replace…" (CssKnobs) → open the picker in replace mode with
|
|
9555
|
+
// the element's current src as the undo before-value (captured from the
|
|
9556
|
+
// Selection's `attrs.src`, not the resolved URL).
|
|
9557
|
+
const onReplaceMedia = useCallback(
|
|
9558
|
+
(elSel) => {
|
|
9559
|
+
if (!elSel?.id) return;
|
|
9560
|
+
setAssetPickerReq({
|
|
9561
|
+
purpose: 'replace-src',
|
|
9562
|
+
canvas: activePath,
|
|
9563
|
+
id: elSel.id,
|
|
9564
|
+
before: elSel.attrs?.src ?? null,
|
|
9565
|
+
});
|
|
9566
|
+
},
|
|
9567
|
+
[activePath]
|
|
9568
|
+
);
|
|
9569
|
+
|
|
8187
9570
|
const resolveComment = useCallback((id) => {
|
|
8188
9571
|
wsSend({ type: 'comments-patch', id, patch: { status: 'resolved' } });
|
|
8189
9572
|
}, []);
|
|
@@ -8596,6 +9979,7 @@ function App() {
|
|
|
8596
9979
|
<UpdateBanner update={updateReady} onDismiss={() => setUpdateReady(null)} />
|
|
8597
9980
|
<SyncBanner status={syncStatus} />
|
|
8598
9981
|
{!usageNudge && !tourSteps && <WhatsNewToast wn={whatsNew} />}
|
|
9982
|
+
{!usageNudge && !tourSteps && <ExportToast center={exportCenter} />}
|
|
8599
9983
|
{gitLifecycle && (
|
|
8600
9984
|
<div role="status" aria-live="polite" className="st-banner st-banner--info">
|
|
8601
9985
|
<span className="st-banner-dot" aria-hidden="true" />
|
|
@@ -8649,10 +10033,14 @@ function App() {
|
|
|
8649
10033
|
onOpenReadiness={() => setReadinessOpen(true)}
|
|
8650
10034
|
onOpenWhatsNew={whatsNew.openPanel}
|
|
8651
10035
|
whatsNewCount={whatsNew.unseen.length}
|
|
10036
|
+
exportCenter={exportCenter}
|
|
8652
10037
|
artboardCount={activeArtboards}
|
|
8653
10038
|
inspectorOpen={inspectorOpen}
|
|
8654
10039
|
inspectorTab={inspectorTab}
|
|
8655
10040
|
onToggleInspector={() => toggleRightPanel('inspector')}
|
|
10041
|
+
autoOpenInspector={autoOpenInspector}
|
|
10042
|
+
onToggleAutoOpenInspector={() => setAutoOpenInspector((v) => !v)}
|
|
10043
|
+
onInsertArtboard={onInsertArtboard}
|
|
8656
10044
|
timelineOpen={timelineOpen}
|
|
8657
10045
|
onToggleTimeline={toggleTimeline}
|
|
8658
10046
|
hasComps={activeComps.length > 0}
|
|
@@ -8810,8 +10198,12 @@ function App() {
|
|
|
8810
10198
|
onClose={() => setInspectorOpen(false)}
|
|
8811
10199
|
onOptimistic={applyOptimisticStyle}
|
|
8812
10200
|
onRecordEdit={recordSourceEdit}
|
|
10201
|
+
onReplaceMedia={onReplaceMedia}
|
|
10202
|
+
onResizeArtboard={resizeArtboardShell}
|
|
10203
|
+
editScope={editScope}
|
|
8813
10204
|
onUndoRedo={(dir) => postToActiveCanvas({ dgn: dir })}
|
|
8814
10205
|
layersTree={layersTree}
|
|
10206
|
+
canvasFile={activePath}
|
|
8815
10207
|
onSelectLayer={(n) =>
|
|
8816
10208
|
postToActiveCanvas({
|
|
8817
10209
|
dgn: 'select-by-id',
|
|
@@ -9266,6 +10658,13 @@ function App() {
|
|
|
9266
10658
|
onClose={() => setExportDialog(null)}
|
|
9267
10659
|
/>
|
|
9268
10660
|
)}
|
|
10661
|
+
{assetPickerReq && (
|
|
10662
|
+
<AssetPicker
|
|
10663
|
+
designRel={(cfg?.designRel || cfg?.designRoot || '.design').replace(/^\/+|\/+$/g, '')}
|
|
10664
|
+
onPick={onAssetPicked}
|
|
10665
|
+
onClose={() => setAssetPickerReq(null)}
|
|
10666
|
+
/>
|
|
10667
|
+
)}
|
|
9269
10668
|
{diffTarget && (
|
|
9270
10669
|
<DiffView
|
|
9271
10670
|
target={diffTarget}
|
|
@@ -9301,6 +10700,7 @@ function App() {
|
|
|
9301
10700
|
}}
|
|
9302
10701
|
/>
|
|
9303
10702
|
<WhatsNewPanel wn={whatsNew} onStartTour={startTour} />
|
|
10703
|
+
<ExportPanel center={exportCenter} />
|
|
9304
10704
|
<ReadinessDialog open={readinessOpen} onClose={() => setReadinessOpen(false)} />
|
|
9305
10705
|
{usageNudge && !tourSteps && !collabNudge && (
|
|
9306
10706
|
<div className="mdcc-tour-nudge" role="status" aria-live="polite">
|