Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/mesh/ct/harness/variant-harnesses.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,12 +35,14 @@ export function SectionVariantListHarness({
return (
<div data-testid="harness">
<SectionVariantList
listKey="ct"
variants={variants}
selectedIndex={selectedIndex}
onSelect={(index) => push({ type: "select", index })}
onDuplicate={(index) => push({ type: "duplicate", index })}
onDelete={(index) => push({ type: "delete", index })}
onRemoveAll={() => push({ type: "removeAll" })}
onReorder={(from, to) => push({ type: "reorder", from, to })}
onAdd={() => push({ type: "add" })}
/>
<EventLog events={events} />
Expand Down
39 changes: 39 additions & 0 deletions apps/mesh/src/web/components/sandbox/preview/cms-editor-script.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,45 @@ export const CMS_EDITOR_SCRIPT = `(function() {
if (window.__cmsEditorActive) return;
window.__cmsEditorActive = true;

// Preserve scroll across variant-override navigations and save-reloads.
// Selecting a variant rebuilds the iframe src (new x-deco-matchers-override)
// or reloads it, which would otherwise jump to the top. We persist the scroll
// offset to sessionStorage (survives reload, same-origin) keyed by pathname
// (the override only changes the query, so the key is stable), then restore it
// on the next load — retried for ~1s so it outlasts re-hydration and lazy
// sections that grow the page after load. The recency guard avoids restoring a
// stale offset on a genuinely fresh visit.
var SCROLL_KEY = "__cms_preview_scroll:" + location.pathname;
var saveScroll = function() {
try {
sessionStorage.setItem(SCROLL_KEY, JSON.stringify({
x: window.scrollX, y: window.scrollY, t: Date.now()
}));
} catch (_) {}
};
var saveScrollPending = false;
window.addEventListener("scroll", function() {
if (saveScrollPending) return;
saveScrollPending = true;
requestAnimationFrame(function() { saveScrollPending = false; saveScroll(); });
}, { passive: true });
(function restoreScroll() {
var raw = null;
try { raw = sessionStorage.getItem(SCROLL_KEY); } catch (_) {}
if (!raw) return;
var saved = null;
try { saved = JSON.parse(raw); } catch (_) { return; }
if (!saved || (Date.now() - saved.t) > 10000) return;
if (!saved.x && !saved.y) return;
var attempts = 0;
var apply = function() {
attempts++;
window.scrollTo(saved.x || 0, saved.y || 0);
if (attempts < 8) setTimeout(apply, 120);
};
apply();
})();

var highlight = document.createElement("div");
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;";
document.body.appendChild(highlight);
Expand Down
86 changes: 56 additions & 30 deletions apps/mesh/src/web/components/sandbox/preview/preview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import {
DropdownMenuTrigger,
} from "@deco/ui/components/dropdown-menu.tsx";
import { useDecofile } from "@/web/components/sections-editor/use-decofile";
import { withVariantMatcherOverride } from "@/web/components/sections-editor/variant-matcher-override";
import { parseSections } from "@/web/components/sections-editor/parse-sections";
import { getPageVariantSectionsAt } from "@/web/components/sections-editor/page-variants";
import { useLiveMeta } from "@/web/components/sections-editor/use-live-meta";
Expand Down Expand Up @@ -209,6 +210,11 @@ export function PreviewContent() {
const [cmsSelectedSectionIndex, setCmsSelectedSectionIndex] = useState<
number | null
>(null);
// `x-deco-matchers-override` params forcing the preview to render the section
// variant currently selected in the sections editor (CMS mode only).
const [variantOverrideParams, setVariantOverrideParams] = useState<
string[] | null
>(null);
const [panelWidth, setPanelWidth] = useState(384);
const isResizingRef = useRef(false);
const resizeStartXRef = useRef(0);
Expand Down Expand Up @@ -358,10 +364,13 @@ export function PreviewContent() {

const iframeSrc =
previewState.kind === "iframe"
? withDeviceHint(
directPreviewUrl ??
new URL(currentPath, previewState.previewUrl).href,
previewDeviceSize,
? withVariantMatcherOverride(
withDeviceHint(
directPreviewUrl ??
new URL(currentPath, previewState.previewUrl).href,
previewDeviceSize,
),
sectionsOpen && variantOverrideParams ? variantOverrideParams : [],
)
: null;

Expand Down Expand Up @@ -533,7 +542,10 @@ export function PreviewContent() {
// Leaving code mode clears the "View JSON" deep-link so re-entering code
// mode later opens the file tree, not the previously-viewed page JSON.
if (mode !== "code") setCodeFilePath(null);
if (mode !== "cms") setCmsSelectedSectionIndex(null);
if (mode !== "cms") {
setCmsSelectedSectionIndex(null);
setVariantOverrideParams(null);
}
if (prev === "visual") deactivateVisualEditor();
if (prev === "cms") deactivateCmsEditor();
if (mode === "visual") injectVisualEditor();
Expand All @@ -548,6 +560,40 @@ export function PreviewContent() {
iframe.src = iframeSrc;
};

// Selecting/reordering a variant changes `iframeSrc` (override params), which
// re-navigates the iframe. Scroll position is preserved entirely inside the
// injected CMS script (it persists scroll to sessionStorage and restores it
// after the reload, surviving re-hydration), so nothing to capture here.
const handleVariantPreviewOverride = (params: string[] | null) => {
setVariantOverrideParams(params);
};

// Reload the preview after a save while preventing the iframe from stealing
// focus. Scroll is restored by the injected CMS script (see above).
const reloadPreviewPreservingScroll = () => {
const iframe = previewIframeRef.current;
if (!iframe) return;
const focused = document.activeElement as HTMLElement | null;
const prevTabIndex = iframe.tabIndex;
iframe.tabIndex = -1;
iframe.style.pointerEvents = "none";
iframe.blur();
try {
iframe.contentWindow?.location.reload();
} catch {
const src = iframeSrcRef.current;
if (src) iframe.src = src;
}
const restore = () => {
iframe.tabIndex = prevTabIndex;
iframe.style.pointerEvents = "";
focused?.focus();
iframe.removeEventListener("load", restore);
};
iframe.addEventListener("load", restore);
setTimeout(restore, 3000);
};

const handleHardReload = () => {
if (!previewIframeRef.current || !iframeSrc) return;
const sep = iframeSrc.includes("?") ? "&" : "?";
Expand Down Expand Up @@ -1084,31 +1130,10 @@ export function PreviewContent() {
activeGlobalSection ? null : cmsSelectedSectionIndex
}
onSaved={() => {
setTimeout(() => {
const iframe = previewIframeRef.current;
if (!iframe) return;
// Prevent iframe from stealing focus during reload
const focused =
document.activeElement as HTMLElement | null;
const prevTabIndex = iframe.tabIndex;
iframe.tabIndex = -1;
iframe.style.pointerEvents = "none";
iframe.blur();
try {
iframe.contentWindow?.location.reload();
} catch {
const src = iframeSrcRef.current;
if (src) iframe.src = src;
}
const restore = () => {
iframe.tabIndex = prevTabIndex;
iframe.style.pointerEvents = "";
focused?.focus();
iframe.removeEventListener("load", restore);
};
iframe.addEventListener("load", restore);
setTimeout(restore, 3000);
}, DEV_SERVER_SETTLE_MS);
setTimeout(
reloadPreviewPreservingScroll,
DEV_SERVER_SETTLE_MS,
);
}}
initialEditSeo={cmsInitialEditSeo}
onExitSeo={() => setCmsInitialEditSeo(false)}
Expand All @@ -1120,6 +1145,7 @@ export function PreviewContent() {
toast.error("Invalid page block key");
}
}}
onVariantPreviewOverride={handleVariantPreviewOverride}
/>
</Suspense>
</div>
Expand Down
Loading
Loading