Prevent imported Claude canvases from zooming on scroll (#1726)

* Preserve HTML preview state across mode toggles

HTML previews could rebuild their iframe when switching into Edit or Comment, which reset scroll/canvas state and caused visible churn for multi-file artifacts. The viewer now keeps URL-loaded previews mounted when the artifact owns the mode bridge, relays file-refreshes through frame navigation, and restores preview scroll/viewport state across bridge mode changes.

Constraint: Generic srcdoc-only bridges are still required for unbridged artifacts, inspect mode, palette tweaks, decks, draw overlays, and forceInline.

Rejected: Keep all Edit/Comment previews on srcdoc | causes unnecessary iframe replacement for bridge-capable URL-loaded artifacts.

Confidence: high

Scope-risk: moderate

Directive: Do not enable URL-load for bridge-dependent modes unless the artifact has an owned postMessage bridge.

Tested: pnpm guard

Tested: pnpm --filter @open-design/web typecheck

Tested: pnpm --filter @open-design/web test

Tested: Playwright verified Edit and Comment toggles preserve iframe src and DOM node while receiving comment targets.

* Prevent preview wheel gestures from escaping into zoom

Trackpad pinch-like wheel events arrive with ctrl/meta modifiers on some platforms, which can make a normal vertical scroll feel like the preview zoomed. The preview now consumes those modified wheel events inside the host preview shell and in injected srcdoc previews, then maps the delta back to scroll where a scroll target exists.

Constraint: URL-loaded sandbox iframes cannot always be inspected by the host, so srcdoc previews need their own in-frame guard.

Rejected: Add allow-same-origin to preview iframes | weakens the sandbox boundary for generated artifacts.

Confidence: medium

Scope-risk: narrow

Directive: Do not broaden iframe sandbox permissions to fix gesture handling without a security review.

Tested: pnpm guard

Tested: pnpm --filter @open-design/web typecheck

Tested: pnpm --filter @open-design/web exec vitest run tests/components/FileViewer.test.tsx tests/runtime/srcdoc.test.ts

Tested: playwright-cli verified ctrl-wheel in preview keeps app zoom at 100% and prevents default in the iframe context

* Revert "Prevent preview wheel gestures from escaping into zoom"

This reverts commit 976407ab4c.

* Prevent imported Claude canvases from zooming on scroll

Claude Design exports can classify ordinary macOS two-finger vertical wheel events as mouse-wheel zoom clicks inside design-canvas.jsx. Normalize that imported canvas code so plain wheel input pans, while Cmd+wheel remains the explicit zoom gesture.

Constraint: The offending canvas code lives inside imported user artifacts rather than a tracked runtime component, so the fix belongs in the Claude Design zip import normalization path.\nRejected: Host-side wheel interception | wheel events inside the sandboxed iframe are handled by the artifact before the host can reliably classify them.\nRejected: Disable all wheel zoom | users still need Cmd+wheel as an explicit zoom control.\nConfidence: high\nScope-risk: narrow\nDirective: Keep plain wheel as pan-only for imported design-canvas.jsx unless a future bridge provides an explicit wheel-mode handshake.\nTested: pnpm --filter @open-design/daemon exec vitest run tests/claude-design-import.test.ts\nTested: pnpm --filter @open-design/daemon typecheck\nTested: pnpm guard

---------

Co-authored-by: nicejames <nicejames@gmail.com>
Co-authored-by: lefarcen <935902669@qq.com>
This commit is contained in:
leessju 2026-05-15 17:37:57 +09:00 committed by GitHub
parent d16acf6462
commit 4e19c3f4f3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 469 additions and 20 deletions

View file

@ -50,7 +50,7 @@ export async function importClaudeDesignZip(zipPath: string, projectDir: string)
totalBytes += body.length;
if (totalBytes > MAX_TOTAL_BYTES) throw new Error('zip is too large');
files.push({ path: relPath, body });
files.push({ path: relPath, body: normalizeImportedClaudeDesignFile(relPath, body) });
}
if (files.length === 0) throw new Error('zip contains no files');
@ -80,6 +80,58 @@ export async function importClaudeDesignZip(zipPath: string, projectDir: string)
};
}
function normalizeImportedClaudeDesignFile(relPath: string, body: Buffer): Buffer {
if (path.basename(relPath) !== 'design-canvas.jsx') return body;
const source = body.toString('utf8');
const normalized = normalizeDesignCanvasWheelHandling(source);
return normalized === source ? body : Buffer.from(normalized, 'utf8');
}
function normalizeDesignCanvasWheelHandling(source: string): string {
const wheelBlock = / \/\/ Mouse-wheel vs trackpad-scroll heuristic\.[\s\S]*? const onWheel = \(e\) => \{\n[\s\S]*? \};\n/;
if (!wheelBlock.test(source)) return source;
const normalizedWheel = source.replace(wheelBlock, ` // Plain wheel input should pan the infinite canvas. Claude Design exports
// previously guessed that large integer vertical deltas were mouse-wheel
// zoom clicks, but macOS trackpads can emit the same shape during ordinary
// two-finger scrolling. Keep zoom explicit via Cmd+wheel or the host
// toolbar so vertical navigation cannot accidentally scale the canvas.
const wheelDeltaToPixels = (delta, mode, axis) => {
const px = mode === 1 ? delta * 16 : mode === 2 ? delta * 160 : delta;
const limit = axis === 'y' ? 72 : 160;
return Math.max(-limit, Math.min(limit, px));
};
const panByWheel = (e) => {
const dx = wheelDeltaToPixels(e.deltaX || 0, e.deltaMode || 0, 'x');
const dy = wheelDeltaToPixels(e.deltaY || 0, e.deltaMode || 0, 'y');
tf.current.x -= dx;
tf.current.y -= dy;
apply();
};
const onWheel = (e) => {
e.preventDefault();
e.stopPropagation();
if (isGesturing) return;
if (e.metaKey) {
zoomAt(e.clientX, e.clientY, Math.exp(-e.deltaY * 0.01));
return;
}
panByWheel(e);
};
`);
const gestureBlock = / \/\/ Safari sends native gesture\* events for trackpad pinch with a smooth\n[\s\S]*? const onGestureEnd = \(e\) => \{ e\.preventDefault\(\); isGesturing = false; \};/;
return normalizedWheel.replace(gestureBlock, ` // Safari can emit native gesture* events while a user scrolls on a
// trackpad. Ignore those here; explicit zoom is Cmd+wheel or the host
// toolbar.
let isGesturing = false;
const onGestureStart = (e) => { e.preventDefault(); e.stopPropagation(); isGesturing = true; };
const onGestureChange = (e) => {
e.preventDefault();
e.stopPropagation();
};
const onGestureEnd = (e) => { e.preventDefault(); e.stopPropagation(); isGesturing = false; };`);
}
function readCentralDirectory(zip: Buffer): ZipEntry[] {
const eocdOffset = findEndOfCentralDirectory(zip);
const entryCount = zip.readUInt16LE(eocdOffset + 10);

View file

@ -173,4 +173,78 @@ describe('importClaudeDesignZip', () => {
rmSync(tmp, { recursive: true, force: true });
}
});
it('normalizes Claude Design canvas wheel handling so vertical scroll does not zoom', async () => {
const designCanvas = `
function DCViewport() {
const tf = { current: { x: 0, y: 0, scale: 1 } };
const apply = () => {};
const zoomAt = () => {};
React.useEffect(() => {
// Mouse-wheel vs trackpad-scroll heuristic. A physical wheel sends
// line-mode deltas (Firefox) or large integer pixel deltas with no X
// component (Chrome/Safari, typically multiples of 100/120). Trackpad
// two-finger scroll sends small/fractional pixel deltas, often with
// non-zero deltaX. ctrlKey is set by the browser for trackpad pinch.
const isMouseWheel = (e) =>
e.deltaMode !== 0 ||
(e.deltaX === 0 && Number.isInteger(e.deltaY) && Math.abs(e.deltaY) >= 40);
const onWheel = (e) => {
e.preventDefault();
if (isGesturing) return; // Safari: gesture* owns the pinch — discard concurrent wheels
if ((e.ctrlKey || e.metaKey) && !isMouseWheel(e)) {
// trackpad pinch, or ctrl/cmd + smooth-scroll mouse. Notched
// wheels fall through to the fixed-step branch below.
zoomAt(e.clientX, e.clientY, Math.exp(-e.deltaY * 0.01));
} else if (isMouseWheel(e)) {
// notched mouse wheel — fixed-ratio step per click
zoomAt(e.clientX, e.clientY, Math.exp(-Math.sign(e.deltaY) * 0.18));
} else {
// trackpad two-finger scroll — pan
tf.current.x -= e.deltaX;
tf.current.y -= e.deltaY;
apply();
}
};
// Safari sends native gesture* events for trackpad pinch with a smooth
// e.scale; preferring these over the ctrl+wheel fallback gives a much
// better feel there. No-ops on other browsers. Safari also fires
// ctrlKey wheel events during the same pinch — isGesturing makes
// onWheel drop those entirely so they neither zoom nor pan.
let gsBase = 1;
let isGesturing = false;
const onGestureStart = (e) => { e.preventDefault(); isGesturing = true; gsBase = tf.current.scale; };
const onGestureChange = (e) => {
e.preventDefault();
zoomAt(e.clientX, e.clientY, (gsBase * e.scale) / tf.current.scale);
};
const onGestureEnd = (e) => { e.preventDefault(); isGesturing = false; };
});
}
`;
const zip = buildZip([
{ name: 'index.html', body: Buffer.from('<html><script src="design-canvas.jsx"></script></html>') },
{ name: 'design-canvas.jsx', body: Buffer.from(designCanvas) },
]);
const tmp = mkdtempSync(path.join(os.tmpdir(), 'cd-import-'));
const zipPath = path.join(tmp, 'in.zip');
const projectDir = path.join(tmp, 'proj');
writeFileSync(zipPath, zip);
try {
const result = await importClaudeDesignZip(zipPath, projectDir);
expect(result.files).toContain('design-canvas.jsx');
const written = readFileSync(path.join(projectDir, 'design-canvas.jsx'), 'utf8');
expect(written).not.toContain('const isMouseWheel');
expect(written).not.toContain('Math.exp(-Math.sign(e.deltaY) * 0.18)');
expect(written).not.toContain('(gsBase * e.scale) / tf.current.scale');
expect(written).toContain('const panByWheel = (e) =>');
expect(written).toContain('if (e.metaKey)');
expect(written).toContain('zoomAt(e.clientX, e.clientY, Math.exp(-e.deltaY * 0.01));');
expect(written).toContain("const limit = axis === 'y' ? 72 : 160;");
} finally {
rmSync(tmp, { recursive: true, force: true });
}
});
});

View file

@ -60,6 +60,7 @@ import {
import { buildReactComponentSrcdoc } from '../runtime/react-component';
import { buildSrcdoc } from '../runtime/srcdoc';
import {
hasUrlModeBridge,
htmlNeedsSandboxShim,
parseForceInline,
shouldUrlLoadHtmlPreview,
@ -582,6 +583,7 @@ interface Props {
projectKind: TrackingProjectKind;
file: ProjectFile;
liveHtml?: string;
filesRefreshKey?: number;
isDeck?: boolean;
onExportAsPptx?: ((fileName: string) => void) | undefined;
streaming?: boolean;
@ -597,6 +599,7 @@ export function FileViewer({
projectKind,
file,
liveHtml,
filesRefreshKey = 0,
isDeck,
onExportAsPptx,
streaming,
@ -643,6 +646,7 @@ export function FileViewer({
projectKind={projectKind}
file={file}
liveHtml={liveHtml}
filesRefreshKey={filesRefreshKey}
isDeck={rendererMatch.renderer.id === 'deck-html'}
onExportAsPptx={onExportAsPptx}
streaming={Boolean(streaming)}
@ -3507,6 +3511,7 @@ function HtmlViewer({
projectKind,
file,
liveHtml,
filesRefreshKey = 0,
isDeck,
onExportAsPptx,
streaming,
@ -3520,6 +3525,7 @@ function HtmlViewer({
projectKind: TrackingProjectKind;
file: ProjectFile;
liveHtml?: string;
filesRefreshKey?: number;
isDeck: boolean;
onExportAsPptx?: ((fileName: string) => void) | undefined;
streaming: boolean;
@ -3650,6 +3656,30 @@ function HtmlViewer({
const [manualEditMode, setManualEditModeRaw] = useState(false);
const [manualEditFrozenSource, setManualEditFrozenSource] = useState<string | null>(null);
const [manualEditViewportWidth, setManualEditViewportWidth] = useState<number | null>(null);
const [previewBodyRef, previewBodySize] = usePreviewCanvasSize<HTMLDivElement>();
const iframeRef = useRef<HTMLIFrameElement | null>(null);
const previewScrollRestoreRef = useRef<{
hostLeft: number;
hostTop: number;
frameLeft: number;
frameTop: number;
canvasLeft: number;
canvasTop: number;
expiresAt: number;
} | null>(null);
const previewScrollPositionRef = useRef({
frameLeft: 0,
frameTop: 0,
canvasLeft: 0,
canvasTop: 0,
});
const previewScrollRequestAtRef = useRef(0);
const dcViewportRef = useRef({
x: 0,
y: 0,
scale: 1,
});
const dcViewportRestoreAtRef = useRef(0);
const setManualEditMode = useCallback((next: boolean | ((prev: boolean) => boolean)) => {
setManualEditModeRaw((prev) => {
const value = typeof next === 'function' ? (next as (p: boolean) => boolean)(prev) : next;
@ -3660,6 +3690,73 @@ function HtmlViewer({
return value;
});
}, []);
const capturePreviewScrollPosition = useCallback(() => {
const host = previewBodyRef.current;
let frameLeft = 0;
let frameTop = 0;
let canvasLeft = 0;
let canvasTop = 0;
try {
const frameDocument = iframeRef.current?.contentWindow?.document;
const frameScroll = frameDocument?.scrollingElement;
const canvasScroll = frameDocument?.querySelector<HTMLElement>('.design-canvas');
frameLeft = frameScroll?.scrollLeft ?? 0;
frameTop = frameScroll?.scrollTop ?? 0;
canvasLeft = canvasScroll?.scrollLeft ?? 0;
canvasTop = canvasScroll?.scrollTop ?? 0;
} catch {
frameLeft = 0;
frameTop = 0;
canvasLeft = 0;
canvasTop = 0;
}
previewScrollRestoreRef.current = {
hostLeft: host?.scrollLeft ?? 0,
hostTop: host?.scrollTop ?? 0,
frameLeft: frameLeft || previewScrollPositionRef.current.frameLeft,
frameTop: frameTop || previewScrollPositionRef.current.frameTop,
canvasLeft: canvasLeft || previewScrollPositionRef.current.canvasLeft,
canvasTop: canvasTop || previewScrollPositionRef.current.canvasTop,
expiresAt: Date.now() + 5000,
};
}, []);
const restorePreviewScrollPosition = useCallback(() => {
const snapshot = previewScrollRestoreRef.current;
if (!snapshot) return;
if (Date.now() > snapshot.expiresAt) {
previewScrollRestoreRef.current = null;
return;
}
const apply = () => {
const previewBody = previewBodyRef.current;
if (typeof previewBody?.scrollTo === 'function') {
previewBody.scrollTo(snapshot.hostLeft, snapshot.hostTop);
}
try {
const frameDocument = iframeRef.current?.contentWindow?.document;
frameDocument?.scrollingElement?.scrollTo(snapshot.frameLeft, snapshot.frameTop);
frameDocument?.querySelector<HTMLElement>('.design-canvas')?.scrollTo(snapshot.canvasLeft, snapshot.canvasTop);
iframeRef.current?.contentWindow?.postMessage({
type: 'od:preview-scroll-restore',
frameLeft: snapshot.frameLeft,
frameTop: snapshot.frameTop,
canvasLeft: snapshot.canvasLeft,
canvasTop: snapshot.canvasTop,
}, '*');
} catch {}
};
window.requestAnimationFrame(() => {
window.requestAnimationFrame(() => {
apply();
window.setTimeout(apply, 80);
window.setTimeout(() => {
if (previewScrollRestoreRef.current === snapshot) {
apply();
}
}, 260);
});
});
}, []);
const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([]);
const [selectedManualEditTarget, setSelectedManualEditTarget] = useState<ManualEditTarget | null>(null);
const selectedManualEditTargetIdRef = useRef<string | null>(null);
@ -3842,9 +3939,7 @@ function HtmlViewer({
const [slideState, setSlideState] = useState<SlideState | null>(
() => htmlPreviewSlideState.get(previewStateKey) ?? null,
);
const [previewBodyRef, previewBodySize] = usePreviewCanvasSize<HTMLDivElement>();
const overlayPreviewScale = effectivePreviewScale(previewViewport, previewScale, previewBodySize);
const iframeRef = useRef<HTMLIFrameElement | null>(null);
const shareRef = useRef<HTMLDivElement | null>(null);
const [chromeActionsHost, setChromeActionsHost] = useState<HTMLElement | null>(null);
useEffect(() => {
@ -3880,7 +3975,7 @@ function HtmlViewer({
return () => {
cancelled = true;
};
}, [projectId, file.name, file.mtime, liveHtml, reloadKey]);
}, [projectId, file.name, file.mtime, liveHtml, reloadKey, filesRefreshKey]);
useEffect(() => {
let cancelled = false;
@ -3925,6 +4020,7 @@ function HtmlViewer({
: livePreviewSource;
const manualEditPageStylesEnabled = typeof source === 'string' && isManualEditFullHtmlDocument(source);
const drawClickSelectionMode = drawOverlayOpen && drawOverlayMode === 'click' && !manualEditMode;
const urlModeBridge = hasUrlModeBridge(source);
// When we URL-load the iframe directly, skip every in-host inlining /
// srcDoc-rebuilding step. The browser does the asset resolution itself,
// which is the whole point of the URL-load path.
@ -3942,16 +4038,36 @@ function HtmlViewer({
const useUrlLoadPreview = shouldUrlLoadHtmlPreview({
mode,
isDeck: effectiveDeck,
commentMode: boardMode || manualEditMode,
commentMode: boardMode || drawClickSelectionMode,
editMode: manualEditMode,
urlModeBridge,
inspectMode,
paletteActive: palettePopoverOpen || selectedPalette !== null,
drawMode: drawOverlayOpen,
forceInline: forceInline || needsSandboxShim,
});
const previewSrcUrl = useMemo(
const basePreviewSrcUrl = useMemo(
() => `${projectRawUrl(projectId, file.name)}?v=${Math.round(file.mtime)}&r=${reloadKey}`,
[projectId, file.name, file.mtime, reloadKey],
);
const [previewSrcUrl, setPreviewSrcUrl] = useState(basePreviewSrcUrl);
useEffect(() => {
setPreviewSrcUrl(basePreviewSrcUrl);
}, [basePreviewSrcUrl]);
useEffect(() => {
if (!useUrlLoadPreview) return;
if (filesRefreshKey === 0) return;
const nextSrc = `${basePreviewSrcUrl}&fr=${filesRefreshKey}`;
const timeout = window.setTimeout(() => {
if (iframeRef.current?.contentWindow) {
iframeRef.current.contentWindow.location.replace(nextSrc);
} else {
setPreviewSrcUrl(nextSrc);
}
}, 180);
return () => window.clearTimeout(timeout);
}, [basePreviewSrcUrl, filesRefreshKey, useUrlLoadPreview]);
useEffect(() => {
setInlinedSource(null);
@ -3979,6 +4095,96 @@ function HtmlViewer({
}) : ''),
[previewSource, effectiveDeck, projectId, file.name, previewStateKey, boardMode, manualEditMode, drawClickSelectionMode, inspectMode, selectedPalette],
);
useEffect(() => {
restorePreviewScrollPosition();
}, [boardMode, manualEditMode, srcDoc, restorePreviewScrollPosition]);
useEffect(() => {
function onMessage(ev: MessageEvent) {
if (ev.source !== iframeRef.current?.contentWindow) return;
const data = ev.data as {
type?: string;
frameLeft?: number;
frameTop?: number;
canvasLeft?: number;
canvasTop?: number;
} | null;
if (!data || data.type !== 'od:preview-scroll') return;
if (previewScrollRestoreRef.current && Number(data.canvasLeft || 0) === 0 && Number(data.canvasTop || 0) === 0) return;
if (
previewScrollPositionRef.current.canvasLeft !== 0 ||
previewScrollPositionRef.current.canvasTop !== 0
) {
const isInitialZeroReport = Number(data.canvasLeft || 0) === 0 && Number(data.canvasTop || 0) === 0;
if (isInitialZeroReport && Date.now() - previewScrollRequestAtRef.current < 1200) return;
}
previewScrollPositionRef.current = {
frameLeft: Number(data.frameLeft || 0),
frameTop: Number(data.frameTop || 0),
canvasLeft: Number(data.canvasLeft || 0),
canvasTop: Number(data.canvasTop || 0),
};
}
function onRestoreRequest(ev: MessageEvent) {
if (ev.source !== iframeRef.current?.contentWindow) return;
const data = ev.data as { type?: string } | null;
if (!data || data.type !== 'od:preview-scroll-request') return;
previewScrollRequestAtRef.current = Date.now();
const snapshot = previewScrollRestoreRef.current;
const scroll = snapshot ?? {
frameLeft: previewScrollPositionRef.current.frameLeft,
frameTop: previewScrollPositionRef.current.frameTop,
canvasLeft: previewScrollPositionRef.current.canvasLeft,
canvasTop: previewScrollPositionRef.current.canvasTop,
};
iframeRef.current?.contentWindow?.postMessage({
type: 'od:preview-scroll-restore',
frameLeft: scroll.frameLeft,
frameTop: scroll.frameTop,
canvasLeft: scroll.canvasLeft,
canvasTop: scroll.canvasTop,
}, '*');
}
function onDcViewportMessage(ev: MessageEvent) {
if (ev.source !== iframeRef.current?.contentWindow) return;
const data = ev.data as {
type?: string;
x?: number;
y?: number;
scale?: number;
} | null;
if (!data || !data.type) return;
if (data.type === '__dc_viewport') {
const x = Number(data.x || 0);
const y = Number(data.y || 0);
const scale = Number(data.scale || 1);
const hasExistingPosition = dcViewportRef.current.x !== 0 || dcViewportRef.current.y !== 0;
const isInitialZeroReport = x === 0 && y === 0 && scale === 1;
if (hasExistingPosition && isInitialZeroReport && Date.now() - dcViewportRestoreAtRef.current < 1500) return;
dcViewportRef.current = {
x: Number.isFinite(x) ? x : 0,
y: Number.isFinite(y) ? y : 0,
scale: Number.isFinite(scale) && scale > 0 ? scale : 1,
};
return;
}
if (data.type === '__dc_viewport_request') {
dcViewportRestoreAtRef.current = Date.now();
iframeRef.current?.contentWindow?.postMessage({
type: '__dc_set_viewport',
...dcViewportRef.current,
}, '*');
}
}
window.addEventListener('message', onMessage);
window.addEventListener('message', onRestoreRequest);
window.addEventListener('message', onDcViewportMessage);
return () => {
window.removeEventListener('message', onMessage);
window.removeEventListener('message', onRestoreRequest);
window.removeEventListener('message', onDcViewportMessage);
};
}, []);
useEffect(() => {
if (!effectiveDeck) {
@ -5348,15 +5554,26 @@ function HtmlViewer({
title={t('fileViewer.comment')}
aria-pressed={boardMode}
onClick={() => {
capturePreviewScrollPosition();
if (boardMode) {
setBoardMode(false);
clearBoardComposer();
return;
}
setManualEditMode(false);
setInspectMode(false);
setDrawOverlayOpen(false);
activateBoard(boardTool);
const activateComment = () => {
clearBoardComposer();
setInspectMode(false);
setDrawOverlayOpen(false);
setMode('preview');
activateBoard(boardTool);
};
if (manualEditMode) {
void exitManualEditModeAfterFlush().then((ok) => {
if (ok) activateComment();
});
return;
}
activateComment();
}}
>
<Icon name="comment" size={13} />
@ -5420,6 +5637,7 @@ function HtmlViewer({
title={t('fileViewer.edit')}
aria-pressed={manualEditMode}
onClick={() => {
capturePreviewScrollPosition();
if (!manualEditMode) {
setBoardMode(false);
clearBoardComposer();
@ -5790,7 +6008,15 @@ function HtmlViewer({
title={file.name}
sandbox="allow-scripts allow-downloads"
src={previewSrcUrl}
onLoad={syncBridgeModes}
onLoad={() => {
dcViewportRestoreAtRef.current = Date.now();
iframeRef.current?.contentWindow?.postMessage({
type: '__dc_set_viewport',
...dcViewportRef.current,
}, '*');
syncBridgeModes();
restorePreviewScrollPosition();
}}
style={{ width: '100%', height: '100%', border: 0 }}
/>
) : (
@ -5802,8 +6028,14 @@ function HtmlViewer({
sandbox="allow-scripts allow-downloads"
srcDoc={srcDoc}
onLoad={() => {
dcViewportRestoreAtRef.current = Date.now();
iframeRef.current?.contentWindow?.postMessage({
type: '__dc_set_viewport',
...dcViewportRef.current,
}, '*');
replayInspectOverridesToIframe();
syncBridgeModes();
restorePreviewScrollPosition();
}}
style={{ width: '100%', height: '100%', border: 0 }}
/>

View file

@ -46,6 +46,7 @@ interface Props {
projectKind: TrackingProjectKind;
files: ProjectFile[];
liveArtifacts: LiveArtifactSummary[];
filesRefreshKey?: number;
onRefreshFiles: () => Promise<void> | void;
isDeck: boolean;
onExportAsPptx?: ((fileName: string) => void) | undefined;
@ -83,6 +84,7 @@ export function FileWorkspace({
projectKind,
files,
liveArtifacts,
filesRefreshKey = 0,
onRefreshFiles,
isDeck,
onExportAsPptx,
@ -789,6 +791,7 @@ export function FileWorkspace({
projectId={projectId}
projectKind={projectKind}
file={activeFile}
filesRefreshKey={filesRefreshKey}
isDeck={isDeck}
onExportAsPptx={onExportAsPptx}
streaming={streaming}

View file

@ -2744,6 +2744,7 @@ export function ProjectView({
projectKind={projectKindToTracking(project.metadata?.kind) ?? 'prototype'}
files={projectFiles}
liveArtifacts={liveArtifacts}
filesRefreshKey={filesRefresh}
onRefreshFiles={() => {
void refreshWorkspaceItems();
}}

View file

@ -11,9 +11,9 @@
* - srcDoc inline: build a self-contained document (via buildSrcdoc),
* optionally with relative assets concatenated in by inlineRelative-
* Assets, and pass it via the iframe's srcDoc attribute. Required
* when we need to inject host-side bridges that have to run before
* user scripts (deck navigation, comment-mode targeting), and useful
* as an explicit opt-in for self-contained exports.
* when we need to inject host-side bridges that cannot be served from
* the artifact itself (deck navigation, inspect/tweak controls), and
* useful as an explicit opt-in for self-contained exports.
*
* The two helpers below isolate the decision so it's directly unit-
* testable without dragging the whole FileViewer React tree into a
@ -25,10 +25,14 @@ export interface UrlLoadDecision {
mode: 'preview' | 'source';
/** Treat as a slide deck — needs the deck postMessage bridge. */
isDeck: boolean;
/** Comment mode is active — needs the comment bridge. */
/** Comment mode is active. Needs either srcDoc injection or an artifact-owned URL-load bridge. */
commentMode: boolean;
/** Inspect mode is active — needs the selection bridge for live tuning. */
/** Inspect mode is active — needs the srcdoc selection bridge for live tuning. */
inspectMode?: boolean;
/** Direct text edit is active. Needs either srcDoc injection or an artifact-owned URL-load bridge. */
editMode?: boolean;
/** The artifact has its own script that listens for host mode postMessages while URL-loaded. */
urlModeBridge?: boolean;
/** Tweaks palette popover open or palette committed — needs the palette bridge. */
paletteActive?: boolean;
/** Draw annotations need the srcDoc snapshot bridge for screenshot export. */
@ -46,10 +50,11 @@ export interface UrlLoadDecision {
export function shouldUrlLoadHtmlPreview(d: UrlLoadDecision): boolean {
if (d.mode !== 'preview') return false;
if (d.isDeck) return false;
if (d.commentMode) return false;
if (d.commentMode && !d.urlModeBridge) return false;
// Inspect needs the selection bridge injected via buildSrcdoc; a raw
// URL-loaded iframe has no listener to apply per-element overrides.
if (d.inspectMode) return false;
if (d.editMode && !d.urlModeBridge) return false;
// Palette tweaks need the srcDoc-side bridge — `<iframe src=URL>` has
// no parent-injected listener to recolor against.
if (d.paletteActive) return false;
@ -58,6 +63,11 @@ export function shouldUrlLoadHtmlPreview(d: UrlLoadDecision): boolean {
return true;
}
export function hasUrlModeBridge(source: string | null | undefined): boolean {
if (!source) return false;
return /<script\b[^>]*\bsrc\s*=\s*["'][^"']*\bod-direct-edit\.js\b[^"']*["'][^>]*>/i.test(source);
}
/**
* Read the `forceInline` opt-out from a URL search string or an existing
* URLSearchParams. Accepts `1`, `true`, `yes`, `on` (case-insensitive).

View file

@ -810,6 +810,33 @@ function meaningfulDomFallbackTarget(el) {
return items;
}
var postTargetsPending = false;
var postPreviewScrollPending = false;
function previewScrollElement(){
return document.querySelector('.design-canvas') || document.scrollingElement || document.documentElement;
}
function postPreviewScroll(){
var el = previewScrollElement();
if (!el) return;
var frame = document.scrollingElement || document.documentElement;
window.parent.postMessage({
type: 'od:preview-scroll',
canvasLeft: Math.round(el.scrollLeft || 0),
canvasTop: Math.round(el.scrollTop || 0),
frameLeft: Math.round(frame.scrollLeft || 0),
frameTop: Math.round(frame.scrollTop || 0)
}, '*');
}
function schedulePostPreviewScroll(){
if (postPreviewScrollPending) return;
postPreviewScrollPending = true;
window.requestAnimationFrame(function(){
postPreviewScrollPending = false;
postPreviewScroll();
});
}
function requestPreviewScrollRestore(){
window.parent.postMessage({ type: 'od:preview-scroll-request' }, '*');
}
function postTargets(){
if (!active()) return;
window.parent.postMessage({ type: 'od:comment-targets', targets: allTargets() }, '*');
@ -889,6 +916,14 @@ if (!fallback && allowDomFallback && meaningfulDomFallbackTarget(el)) fallback =
}
return;
}
if (data.type === 'od:preview-scroll-restore') {
var frame = document.scrollingElement || document.documentElement;
var el = previewScrollElement();
if (frame) frame.scrollTo(Number(data.frameLeft || 0), Number(data.frameTop || 0));
if (el) el.scrollTo(Number(data.canvasLeft || 0), Number(data.canvasTop || 0));
setTimeout(postPreviewScroll, 0);
return;
}
if (data.type === 'od:inspect-mode') {
inspectEnabled = !!data.enabled;
document.documentElement.toggleAttribute('data-od-inspect-mode', inspectEnabled);
@ -1043,7 +1078,10 @@ if (!fallback && allowDomFallback && meaningfulDomFallbackTarget(el)) fallback =
document.addEventListener('pointerup', finishStroke, true);
document.addEventListener('pointercancel', finishStroke, true);
window.addEventListener('resize', schedulePostTargets);
document.addEventListener('scroll', schedulePostTargets, true);
document.addEventListener('scroll', function(){
schedulePostTargets();
schedulePostPreviewScroll();
}, true);
var mo = new MutationObserver(schedulePostTargets);
mo.observe(document.documentElement, { subtree: true, childList: true, attributes: true, characterData: true });
// Reflect the host-requested initial modes on the documentElement so
@ -1058,8 +1096,13 @@ if (!fallback && allowDomFallback && meaningfulDomFallbackTarget(el)) fallback =
// as save input — it parses the artifact source itself — but emitting it
// keeps the iframe → host channel symmetric across set/reset/extract.
if (Object.keys(overrides).length) setTimeout(postOverrides, 0);
setTimeout(requestPreviewScrollRestore, 0);
setTimeout(requestPreviewScrollRestore, 80);
setTimeout(requestPreviewScrollRestore, 240);
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', postTargets);
else setTimeout(postTargets, 0);
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', postPreviewScroll);
else setTimeout(postPreviewScroll, 0);
})();</script>`;
const style = `<style data-od-selection-bridge-style>
html[data-od-comment-mode] body * { cursor: crosshair !important; }

View file

@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest';
import {
hasUrlModeBridge,
htmlNeedsSandboxShim,
parseForceInline,
shouldUrlLoadHtmlPreview,
@ -17,10 +18,22 @@ describe('shouldUrlLoadHtmlPreview', () => {
expect(shouldUrlLoadHtmlPreview({ ...base, isDeck: true })).toBe(false);
});
it('falls back to srcDoc when comment mode is active (comment bridge required)', () => {
it('falls back to srcDoc when comment mode is active without an artifact-owned bridge', () => {
expect(shouldUrlLoadHtmlPreview({ ...base, commentMode: true })).toBe(false);
});
it('keeps URL-load when comment mode is active and the artifact owns the bridge', () => {
expect(shouldUrlLoadHtmlPreview({ ...base, commentMode: true, urlModeBridge: true })).toBe(true);
});
it('falls back to srcDoc when direct edit mode is active without an artifact-owned bridge', () => {
expect(shouldUrlLoadHtmlPreview({ ...base, editMode: true })).toBe(false);
});
it('keeps URL-load when direct edit mode is active and the artifact owns the bridge', () => {
expect(shouldUrlLoadHtmlPreview({ ...base, editMode: true, urlModeBridge: true })).toBe(true);
});
it('falls back to srcDoc when inspect mode is active (selection bridge required)', () => {
expect(shouldUrlLoadHtmlPreview({ ...base, inspectMode: true })).toBe(false);
});
@ -41,6 +54,25 @@ describe('shouldUrlLoadHtmlPreview', () => {
expect(shouldUrlLoadHtmlPreview({ ...base, isDeck: true, commentMode: true })).toBe(false);
expect(shouldUrlLoadHtmlPreview({ ...base, isDeck: true, forceInline: true })).toBe(false);
expect(shouldUrlLoadHtmlPreview({ ...base, commentMode: true, forceInline: true })).toBe(false);
expect(shouldUrlLoadHtmlPreview({ ...base, commentMode: true, urlModeBridge: true, inspectMode: true })).toBe(false);
});
});
describe('hasUrlModeBridge', () => {
it('detects an artifact-owned direct-edit bridge script', () => {
expect(hasUrlModeBridge('<script src="od-direct-edit.js"></script>')).toBe(true);
expect(hasUrlModeBridge('<script defer src="./assets/od-direct-edit.js?v=1"></script>')).toBe(true);
});
it('ignores comments, text nodes, and inline script bodies that only mention the bridge name', () => {
expect(hasUrlModeBridge('<!-- TODO: ship od-direct-edit.js -->')).toBe(false);
expect(hasUrlModeBridge('<p>Use od-direct-edit.js for editing</p>')).toBe(false);
expect(hasUrlModeBridge('<script>console.log("od-direct-edit.js")</script>')).toBe(false);
});
it('ignores unrelated script URLs', () => {
expect(hasUrlModeBridge('<script src="direct-edit.js"></script>')).toBe(false);
expect(hasUrlModeBridge(null)).toBe(false);
});
});

View file

@ -72,7 +72,9 @@ describe('buildSrcdoc', () => {
expect(srcdoc).toContain('data-od-comment-mode-kind');
expect(srcdoc).toContain("body * { cursor: crosshair !important; }");
expect(srcdoc).toContain('MutationObserver(schedulePostTargets)');
expect(srcdoc).toContain("document.addEventListener('scroll', schedulePostTargets, true);");
expect(srcdoc).toContain('schedulePostPreviewScroll');
expect(srcdoc).toContain("type: 'od:preview-scroll'");
expect(srcdoc).toContain("type: 'od:preview-scroll-request'");
expect(srcdoc).toContain('data-od-selection-bridge-style');
});