Skip to content

Commit 809d238

Browse files
aka-sacci-ccrdecobotcursoragent
authored
feat(cms-sections): variant drag-and-drop reorder + variant-aware preview (#4173)
- Reorder section and page variants via dnd-kit - Force the preview to render the selected variant through the deco runtime's x-deco-matchers-override (section + page level), only on explicit variant click - Preserve preview scroll across override navigations/save-reloads via the injected CMS script (sessionStorage + retried restore) Co-authored-by: decobot <capy@deco.cx> Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 81c3f37 commit 809d238

9 files changed

Lines changed: 917 additions & 100 deletions

File tree

apps/mesh/ct/harness/variant-harnesses.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,12 +35,14 @@ export function SectionVariantListHarness({
3535
return (
3636
<div data-testid="harness">
3737
<SectionVariantList
38+
listKey="ct"
3839
variants={variants}
3940
selectedIndex={selectedIndex}
4041
onSelect={(index) => push({ type: "select", index })}
4142
onDuplicate={(index) => push({ type: "duplicate", index })}
4243
onDelete={(index) => push({ type: "delete", index })}
4344
onRemoveAll={() => push({ type: "removeAll" })}
45+
onReorder={(from, to) => push({ type: "reorder", from, to })}
4446
onAdd={() => push({ type: "add" })}
4547
/>
4648
<EventLog events={events} />

apps/mesh/src/web/components/sandbox/preview/cms-editor-script.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,45 @@ export const CMS_EDITOR_SCRIPT = `(function() {
2424
if (window.__cmsEditorActive) return;
2525
window.__cmsEditorActive = true;
2626
27+
// Preserve scroll across variant-override navigations and save-reloads.
28+
// Selecting a variant rebuilds the iframe src (new x-deco-matchers-override)
29+
// or reloads it, which would otherwise jump to the top. We persist the scroll
30+
// offset to sessionStorage (survives reload, same-origin) keyed by pathname
31+
// (the override only changes the query, so the key is stable), then restore it
32+
// on the next load — retried for ~1s so it outlasts re-hydration and lazy
33+
// sections that grow the page after load. The recency guard avoids restoring a
34+
// stale offset on a genuinely fresh visit.
35+
var SCROLL_KEY = "__cms_preview_scroll:" + location.pathname;
36+
var saveScroll = function() {
37+
try {
38+
sessionStorage.setItem(SCROLL_KEY, JSON.stringify({
39+
x: window.scrollX, y: window.scrollY, t: Date.now()
40+
}));
41+
} catch (_) {}
42+
};
43+
var saveScrollPending = false;
44+
window.addEventListener("scroll", function() {
45+
if (saveScrollPending) return;
46+
saveScrollPending = true;
47+
requestAnimationFrame(function() { saveScrollPending = false; saveScroll(); });
48+
}, { passive: true });
49+
(function restoreScroll() {
50+
var raw = null;
51+
try { raw = sessionStorage.getItem(SCROLL_KEY); } catch (_) {}
52+
if (!raw) return;
53+
var saved = null;
54+
try { saved = JSON.parse(raw); } catch (_) { return; }
55+
if (!saved || (Date.now() - saved.t) > 10000) return;
56+
if (!saved.x && !saved.y) return;
57+
var attempts = 0;
58+
var apply = function() {
59+
attempts++;
60+
window.scrollTo(saved.x || 0, saved.y || 0);
61+
if (attempts < 8) setTimeout(apply, 120);
62+
};
63+
apply();
64+
})();
65+
2766
var highlight = document.createElement("div");
2867
highlight.style.cssText = "position:absolute;pointer-events:none;outline:2px solid #06b6d4;background:rgba(6,182,212,0.06);border-radius:2px;z-index:2147483647;display:none;";
2968
document.body.appendChild(highlight);

apps/mesh/src/web/components/sandbox/preview/preview.tsx

Lines changed: 56 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ import {
4242
DropdownMenuTrigger,
4343
} from "@deco/ui/components/dropdown-menu.tsx";
4444
import { useDecofile } from "@/web/components/sections-editor/use-decofile";
45+
import { withVariantMatcherOverride } from "@/web/components/sections-editor/variant-matcher-override";
4546
import { parseSections } from "@/web/components/sections-editor/parse-sections";
4647
import { getPageVariantSectionsAt } from "@/web/components/sections-editor/page-variants";
4748
import { useLiveMeta } from "@/web/components/sections-editor/use-live-meta";
@@ -209,6 +210,11 @@ export function PreviewContent() {
209210
const [cmsSelectedSectionIndex, setCmsSelectedSectionIndex] = useState<
210211
number | null
211212
>(null);
213+
// `x-deco-matchers-override` params forcing the preview to render the section
214+
// variant currently selected in the sections editor (CMS mode only).
215+
const [variantOverrideParams, setVariantOverrideParams] = useState<
216+
string[] | null
217+
>(null);
212218
const [panelWidth, setPanelWidth] = useState(384);
213219
const isResizingRef = useRef(false);
214220
const resizeStartXRef = useRef(0);
@@ -358,10 +364,13 @@ export function PreviewContent() {
358364

359365
const iframeSrc =
360366
previewState.kind === "iframe"
361-
? withDeviceHint(
362-
directPreviewUrl ??
363-
new URL(currentPath, previewState.previewUrl).href,
364-
previewDeviceSize,
367+
? withVariantMatcherOverride(
368+
withDeviceHint(
369+
directPreviewUrl ??
370+
new URL(currentPath, previewState.previewUrl).href,
371+
previewDeviceSize,
372+
),
373+
sectionsOpen && variantOverrideParams ? variantOverrideParams : [],
365374
)
366375
: null;
367376

@@ -533,7 +542,10 @@ export function PreviewContent() {
533542
// Leaving code mode clears the "View JSON" deep-link so re-entering code
534543
// mode later opens the file tree, not the previously-viewed page JSON.
535544
if (mode !== "code") setCodeFilePath(null);
536-
if (mode !== "cms") setCmsSelectedSectionIndex(null);
545+
if (mode !== "cms") {
546+
setCmsSelectedSectionIndex(null);
547+
setVariantOverrideParams(null);
548+
}
537549
if (prev === "visual") deactivateVisualEditor();
538550
if (prev === "cms") deactivateCmsEditor();
539551
if (mode === "visual") injectVisualEditor();
@@ -548,6 +560,40 @@ export function PreviewContent() {
548560
iframe.src = iframeSrc;
549561
};
550562

563+
// Selecting/reordering a variant changes `iframeSrc` (override params), which
564+
// re-navigates the iframe. Scroll position is preserved entirely inside the
565+
// injected CMS script (it persists scroll to sessionStorage and restores it
566+
// after the reload, surviving re-hydration), so nothing to capture here.
567+
const handleVariantPreviewOverride = (params: string[] | null) => {
568+
setVariantOverrideParams(params);
569+
};
570+
571+
// Reload the preview after a save while preventing the iframe from stealing
572+
// focus. Scroll is restored by the injected CMS script (see above).
573+
const reloadPreviewPreservingScroll = () => {
574+
const iframe = previewIframeRef.current;
575+
if (!iframe) return;
576+
const focused = document.activeElement as HTMLElement | null;
577+
const prevTabIndex = iframe.tabIndex;
578+
iframe.tabIndex = -1;
579+
iframe.style.pointerEvents = "none";
580+
iframe.blur();
581+
try {
582+
iframe.contentWindow?.location.reload();
583+
} catch {
584+
const src = iframeSrcRef.current;
585+
if (src) iframe.src = src;
586+
}
587+
const restore = () => {
588+
iframe.tabIndex = prevTabIndex;
589+
iframe.style.pointerEvents = "";
590+
focused?.focus();
591+
iframe.removeEventListener("load", restore);
592+
};
593+
iframe.addEventListener("load", restore);
594+
setTimeout(restore, 3000);
595+
};
596+
551597
const handleHardReload = () => {
552598
if (!previewIframeRef.current || !iframeSrc) return;
553599
const sep = iframeSrc.includes("?") ? "&" : "?";
@@ -1084,31 +1130,10 @@ export function PreviewContent() {
10841130
activeGlobalSection ? null : cmsSelectedSectionIndex
10851131
}
10861132
onSaved={() => {
1087-
setTimeout(() => {
1088-
const iframe = previewIframeRef.current;
1089-
if (!iframe) return;
1090-
// Prevent iframe from stealing focus during reload
1091-
const focused =
1092-
document.activeElement as HTMLElement | null;
1093-
const prevTabIndex = iframe.tabIndex;
1094-
iframe.tabIndex = -1;
1095-
iframe.style.pointerEvents = "none";
1096-
iframe.blur();
1097-
try {
1098-
iframe.contentWindow?.location.reload();
1099-
} catch {
1100-
const src = iframeSrcRef.current;
1101-
if (src) iframe.src = src;
1102-
}
1103-
const restore = () => {
1104-
iframe.tabIndex = prevTabIndex;
1105-
iframe.style.pointerEvents = "";
1106-
focused?.focus();
1107-
iframe.removeEventListener("load", restore);
1108-
};
1109-
iframe.addEventListener("load", restore);
1110-
setTimeout(restore, 3000);
1111-
}, DEV_SERVER_SETTLE_MS);
1133+
setTimeout(
1134+
reloadPreviewPreservingScroll,
1135+
DEV_SERVER_SETTLE_MS,
1136+
);
11121137
}}
11131138
initialEditSeo={cmsInitialEditSeo}
11141139
onExitSeo={() => setCmsInitialEditSeo(false)}
@@ -1120,6 +1145,7 @@ export function PreviewContent() {
11201145
toast.error("Invalid page block key");
11211146
}
11221147
}}
1148+
onVariantPreviewOverride={handleVariantPreviewOverride}
11231149
/>
11241150
</Suspense>
11251151
</div>

0 commit comments

Comments
 (0)