Skip to content
Closed
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
7 changes: 7 additions & 0 deletions apps/daemon/src/routes/project/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,12 @@ const URL_PREVIEW_SCROLL_BRIDGE = `<script data-od-url-scroll-bridge>
function requestRestore(){
window.parent.postMessage({ type: 'od:preview-scroll-request' }, '*');
}
function postWheel(ev){
var deltaY = Number(ev && ev.deltaY);
if (!Number.isFinite(deltaY) || deltaY === 0) return;
window.parent.postMessage({ type: 'od:preview-wheel', deltaY: deltaY }, '*');
ev.preventDefault();
}
window.addEventListener('message', function(ev){
var data = ev && ev.data;
if (!data || !data.type) return;
Expand All @@ -173,6 +179,7 @@ const URL_PREVIEW_SCROLL_BRIDGE = `<script data-od-url-scroll-bridge>
});
window.addEventListener('scroll', schedule, true);
document.addEventListener('scroll', schedule, true);
document.addEventListener('wheel', postWheel, { passive: false });
window.addEventListener('resize', schedule);
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', function(){
Expand Down
1 change: 1 addition & 0 deletions apps/daemon/tests/project-file-range.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -349,6 +349,7 @@ describe('GET /api/projects/:id/raw/* range request route', () => {
const html = await bridged.text();
expect(html).toContain('data-od-url-scroll-bridge');
expect(html).toContain("type: 'od:preview-scroll'");
expect(html).toContain("type: 'od:preview-wheel'");
});

it('injects the URL preview scroll bridge before the closing body tag', async () => {
Expand Down
48 changes: 42 additions & 6 deletions apps/web/src/components/FileViewer.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useId, useMemo, useRef, useState, type ClipboardEvent as ReactClipboardEvent, type CSSProperties, type DragEvent as ReactDragEvent, type MouseEvent as ReactMouseEvent, type ReactNode } from 'react';
import { useCallback, useEffect, useId, useMemo, useRef, useState, type ClipboardEvent as ReactClipboardEvent, type CSSProperties, type DragEvent as ReactDragEvent, type MouseEvent as ReactMouseEvent, type ReactNode, type WheelEvent as ReactWheelEvent } from 'react';
import { createPortal, flushSync } from 'react-dom';
import { Button, Input, Select } from '@open-design/components';
import { APP_CHROME_FILE_ACTIONS_ID, APP_CHROME_FILE_ACTIONS_SELECTOR } from './AppChromeHeader';
Expand Down Expand Up @@ -282,6 +282,7 @@ const PREVIEW_VIEWPORT_PRESETS: PreviewViewportPreset[] = [
titleKey: 'fileViewer.viewportMobileTitle',
},
];
const PREVIEW_ZOOM_LEVELS = [50, 75, 100, 125, 150, 200] as const;

function previewViewportIcon(viewport: PreviewViewportId): string {
if (viewport === 'tablet') return 'tablet-line';
Expand Down Expand Up @@ -920,6 +921,18 @@ function previewScaleShellStyle(
};
}

function previewZoomFromWheel(currentZoom: number, deltaY: number): number {
if (!Number.isFinite(deltaY) || deltaY === 0) return currentZoom;
if (deltaY < 0) {
return PREVIEW_ZOOM_LEVELS.find((level) => level > currentZoom) ?? PREVIEW_ZOOM_LEVELS[PREVIEW_ZOOM_LEVELS.length - 1]!;
}
for (let index = PREVIEW_ZOOM_LEVELS.length - 1; index >= 0; index -= 1) {
const level = PREVIEW_ZOOM_LEVELS[index]!;
if (level < currentZoom) return level;
}
return PREVIEW_ZOOM_LEVELS[0]!;
}

function manualEditPreviewShellStyle(
viewport: PreviewViewportId,
previewScale: number,
Expand Down Expand Up @@ -1503,6 +1516,11 @@ export function LiveArtifactViewer({
[projectId, liveArtifact.artifactId, reloadKey],
);
const previewScale = zoom / 100;
const handlePreviewWheel = useCallback((event: ReactWheelEvent<HTMLElement>) => {
if (!Number.isFinite(event.deltaY) || event.deltaY === 0) return;
event.preventDefault();
setZoom((current) => previewZoomFromWheel(current, event.deltaY));
}, []);

// Instrument the live-artifact iframe so failed loads — usually a
// missing artifact file or a stuck `od://` resolver — surface in
Expand Down Expand Up @@ -1703,7 +1721,7 @@ export function LiveArtifactViewer({
</button>
{zoomMenuOpen && mode === 'preview' ? (
<div className="zoom-menu-popover" role="menu">
{[50, 75, 100, 125, 150, 200].map((level) => (
{PREVIEW_ZOOM_LEVELS.map((level) => (
<button
key={level}
type="button"
Expand Down Expand Up @@ -1787,6 +1805,7 @@ export function LiveArtifactViewer({
data-active={mode === 'preview' ? 'true' : 'false'}
aria-hidden={mode === 'preview' ? undefined : true}
style={previewViewportStyle(previewViewport, previewScale, previewBodySize)}
onWheel={handlePreviewWheel}
>
<div className="preview-frame-clip">
<div style={previewScaleShellStyle(previewViewport, previewScale)}>
Expand Down Expand Up @@ -6122,6 +6141,16 @@ function HtmlViewer({
const [strokePoints, setStrokePoints] = useState<StrokePoint[]>([]);
const previewStateKey = `${projectId}:${file.name}`;
const previewScale = zoom / 100;
const applyPreviewWheelZoom = useCallback((deltaY: number) => {
if (drawOverlayOpen) return;
if (!Number.isFinite(deltaY) || deltaY === 0) return;
setZoom((current) => previewZoomFromWheel(current, deltaY));
}, [drawOverlayOpen]);
const handlePreviewWheel = useCallback((event: ReactWheelEvent<HTMLElement>) => {
if (!Number.isFinite(event.deltaY) || event.deltaY === 0) return;
event.preventDefault();
applyPreviewWheelZoom(event.deltaY);
}, [applyPreviewWheelZoom]);
const localCommentSideDockActive = commentPanelOpen && !commentPortalHost;
const boardPreviewCanvasSize = commentPreviewCanvasSize(previewBodySize, {
boardMode: localCommentSideDockActive,
Expand Down Expand Up @@ -6901,8 +6930,14 @@ function HtmlViewer({
frameTop?: number;
canvasLeft?: number;
canvasTop?: number;
deltaY?: number;
} | null;
if (!data || data.type !== 'od:preview-scroll') return;
if (!data) return;
if (data.type === 'od:preview-wheel') {
applyPreviewWheelZoom(Number(data.deltaY || 0));
return;
}
if (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 ||
Expand Down Expand Up @@ -6979,7 +7014,7 @@ function HtmlViewer({
window.removeEventListener('message', onRestoreRequest);
window.removeEventListener('message', onDcViewportMessage);
};
}, [isActivePreviewIframeSource, isOurPreviewIframeSource]);
}, [applyPreviewWheelZoom, isActivePreviewIframeSource, isOurPreviewIframeSource]);

useEffect(() => {
if (!effectiveDeck) {
Expand Down Expand Up @@ -10198,7 +10233,7 @@ function HtmlViewer({
</button>
{zoomMenuOpen ? (
<div className="zoom-menu-popover" role="menu">
{[50, 75, 100, 125, 150, 200].map((level) => (
{PREVIEW_ZOOM_LEVELS.map((level) => (
<button
key={level}
type="button"
Expand Down Expand Up @@ -10393,7 +10428,7 @@ function HtmlViewer({
{source !== null && mode === 'preview' ? (
<>
<div className="viewer-toolbar-more-separator" role="separator" />
{[50, 75, 100, 125, 150, 200].map((level) => (
{PREVIEW_ZOOM_LEVELS.map((level) => (
<button
key={level}
type="button"
Expand Down Expand Up @@ -10791,6 +10826,7 @@ function HtmlViewer({
data-testid={manualEditMode ? undefined : 'comment-preview-layout'}
style={previewViewportStyle(previewViewport, previewScale, boardPreviewCanvasSize, boardPreviewScaleOptions)}
onMouseLeave={manualEditMode ? clearManualEditHover : undefined}
onWheel={handlePreviewWheel}
>
{manualEditPanel}
{manualEditHoverAffordance}
Expand Down
7 changes: 7 additions & 0 deletions apps/web/src/runtime/srcdoc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1593,6 +1593,12 @@ function meaningfulDomFallbackTarget(el) {
function requestPreviewScrollRestore(){
window.parent.postMessage({ type: 'od:preview-scroll-request' }, '*');
}
function postPreviewWheel(ev){
var deltaY = Number(ev && ev.deltaY);
if (!Number.isFinite(deltaY) || deltaY === 0) return;
window.parent.postMessage({ type: 'od:preview-wheel', deltaY: deltaY }, '*');
ev.preventDefault();
}
function findCommentTargetByIdentity(elementId, selector){
var el = null;
if (selector) {
Expand Down Expand Up @@ -1953,6 +1959,7 @@ function meaningfulDomFallbackTarget(el) {
schedulePostTargets();
schedulePostPreviewScroll();
}, true);
document.addEventListener('wheel', postPreviewWheel, { passive: false });
var mo = new MutationObserver(schedulePostTargets);
// childList only — NOT attributes/characterData. Re-walking every annotated
// target on every attribute/text mutation made an animated artifact (inline
Expand Down
65 changes: 65 additions & 0 deletions apps/web/tests/components/FileViewer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3788,6 +3788,71 @@ describe('FileViewer tweaks toolbar', () => {
expect((await screen.findByRole('button', { name: 'Preview viewport' })).textContent).toContain('Tablet');
});

it('updates HTML preview zoom when the mouse wheel zooms over the preview', async () => {
render(
<FileViewer
projectId="project-1"
projectKind="prototype"
file={htmlPreviewFile()}
liveHtml='<html><body><main data-od-id="hero">Hero</main></body></html>'
/>,
);

const layout = screen.getByTestId('comment-preview-layout');
const shell = screen
.getByTestId('comment-preview-canvas')
.querySelector<HTMLElement>('.comment-frame-clip > div');

expect(screen.getByRole('button', { name: '100%' })).toBeTruthy();
expect(shell?.style.transform).toBe('scale(1)');

fireEvent.wheel(layout, { deltaY: -100 });

expect(screen.getByRole('button', { name: '125%' })).toBeTruthy();
expect(shell?.style.transform).toBe('scale(1.25)');

fireEvent.wheel(layout, { deltaY: 100 });

expect(screen.getByRole('button', { name: '100%' })).toBeTruthy();
expect(shell?.style.transform).toBe('scale(1)');

fireEvent.wheel(layout, { ctrlKey: true, deltaY: -100 });

expect(screen.getByRole('button', { name: '125%' })).toBeTruthy();
expect(shell?.style.transform).toBe('scale(1.25)');

const frame = screen.getByTestId('artifact-preview-frame') as HTMLIFrameElement;
act(() => {
window.dispatchEvent(new MessageEvent('message', {
source: frame.contentWindow,
data: { type: 'od:preview-wheel', deltaY: 100 },
}));
});

expect(screen.getByRole('button', { name: '100%' })).toBeTruthy();
expect(shell?.style.transform).toBe('scale(1)');
});

it('keeps Draw overlay wheel scrolling from changing preview zoom', () => {
const { container } = render(
<FileViewer
projectId="project-1"
projectKind="prototype"
file={htmlPreviewFile()}
liveHtml='<html><body><main data-od-id="hero">Hero</main></body></html>'
/>,
);

fireEvent.click(screen.getByTestId('draw-overlay-toggle'));
const canvas = container.querySelector('canvas');
expect(canvas).toBeTruthy();

fireEvent.wheel(canvas!, { deltaY: -100 });

expect(screen.getByRole('button', { name: '100%' })).toBeTruthy();
expect(screen.queryByRole('button', { name: '125%' })).toBeNull();
});

it('keeps the Draw bar open after queueing an annotation', () => {
render(
<FileViewer projectId="project-1" projectKind="prototype" file={htmlPreviewFile()}
Expand Down
1 change: 1 addition & 0 deletions apps/web/tests/runtime/srcdoc.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,7 @@ describe('buildSrcdoc', () => {
expect(srcdoc).toContain('MutationObserver(schedulePostTargets)');
expect(srcdoc).toContain('schedulePostPreviewScroll');
expect(srcdoc).toContain("type: 'od:preview-scroll'");
expect(srcdoc).toContain("type: 'od:preview-wheel'");
expect(srcdoc).toContain("type: 'od:preview-scroll-request'");
expect(srcdoc).toContain("data.type === 'od:preview-scroll-by'");
expect(srcdoc).toContain('previewScrollBy(data.left, data.top)');
Expand Down
Loading