mirror of
https://github.com/nexu-io/open-design.git
synced 2026-05-31 19:04:39 +07:00
fix(web): preserve preview scroll across tools (#3313)
* fix(web): preserve preview scroll across tools Capture URL-loaded preview scroll state before tool handoff and restore it through an opt-in raw HTML bridge to avoid jumping back to the top. Agent-Model: gpt-5 Agent-Family: openai Agent-Session: 019e6ceb-c33d-7cd3-bff0-cbc20c642197 Agent-Step: 0.0.6 * test(daemon): cover scroll bridge injection paths Agent-Model: gpt-5 Agent-Family: openai Agent-Session: 019e6ceb-c33d-7cd3-bff0-cbc20c642197 Agent-Step: 0.0.6 --------- Co-authored-by: Codex <gpt-5@openai.com>
This commit is contained in:
parent
778010bcf9
commit
c88a83cd5e
4 changed files with 195 additions and 12 deletions
|
|
@ -21,6 +21,106 @@ import { auditDesignSystemPackage } from './tools-connectors-cli.js';
|
|||
|
||||
export interface RegisterProjectRoutesDeps extends RouteDeps<'db' | 'design' | 'http' | 'paths' | 'projectStore' | 'projectFiles' | 'conversations' | 'templates' | 'status' | 'events' | 'ids' | 'telemetry' | 'validation'> {}
|
||||
|
||||
const URL_PREVIEW_SCROLL_BRIDGE = `<script data-od-url-scroll-bridge>
|
||||
(function(){
|
||||
if (window.__odUrlScrollBridge) return;
|
||||
window.__odUrlScrollBridge = true;
|
||||
var pending = false;
|
||||
function scrollElement(){
|
||||
return document.querySelector('.design-canvas') || document.scrollingElement || document.documentElement;
|
||||
}
|
||||
function num(value){
|
||||
var next = Number(value || 0);
|
||||
return Number.isFinite(next) ? next : 0;
|
||||
}
|
||||
function post(){
|
||||
var el = scrollElement();
|
||||
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 schedule(){
|
||||
if (pending) return;
|
||||
pending = true;
|
||||
window.requestAnimationFrame(function(){
|
||||
pending = false;
|
||||
post();
|
||||
});
|
||||
}
|
||||
function scrollTo(el, left, top){
|
||||
if (!el) return;
|
||||
if (typeof el.scrollTo === 'function') el.scrollTo(num(left), num(top));
|
||||
else {
|
||||
el.scrollLeft = num(left);
|
||||
el.scrollTop = num(top);
|
||||
}
|
||||
}
|
||||
function scrollBy(el, left, top){
|
||||
if (!el) return;
|
||||
var dx = num(left);
|
||||
var dy = num(top);
|
||||
if (!dx && !dy) return;
|
||||
if (typeof el.scrollBy === 'function') el.scrollBy({ left: dx, top: dy, behavior: 'auto' });
|
||||
else {
|
||||
el.scrollLeft = (el.scrollLeft || 0) + dx;
|
||||
el.scrollTop = (el.scrollTop || 0) + dy;
|
||||
}
|
||||
}
|
||||
function requestRestore(){
|
||||
window.parent.postMessage({ type: 'od:preview-scroll-request' }, '*');
|
||||
}
|
||||
window.addEventListener('message', function(ev){
|
||||
var data = ev && ev.data;
|
||||
if (!data || !data.type) return;
|
||||
if (data.type === 'od:preview-scroll-restore') {
|
||||
scrollTo(document.scrollingElement || document.documentElement, data.frameLeft, data.frameTop);
|
||||
scrollTo(scrollElement(), data.canvasLeft, data.canvasTop);
|
||||
setTimeout(post, 0);
|
||||
return;
|
||||
}
|
||||
if (data.type === 'od:preview-scroll-by') {
|
||||
scrollBy(scrollElement(), data.left, data.top);
|
||||
schedule();
|
||||
}
|
||||
});
|
||||
window.addEventListener('scroll', schedule, true);
|
||||
document.addEventListener('scroll', schedule, true);
|
||||
window.addEventListener('resize', schedule);
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', function(){
|
||||
requestRestore();
|
||||
schedule();
|
||||
});
|
||||
} else {
|
||||
setTimeout(function(){
|
||||
requestRestore();
|
||||
schedule();
|
||||
}, 0);
|
||||
}
|
||||
})();
|
||||
</script>`;
|
||||
|
||||
function wantsUrlPreviewScrollBridge(value: unknown): boolean {
|
||||
if (Array.isArray(value)) return value.some(wantsUrlPreviewScrollBridge);
|
||||
if (typeof value !== 'string') return false;
|
||||
return value === 'scroll' || value === '1' || value === 'true';
|
||||
}
|
||||
|
||||
function injectUrlPreviewScrollBridge(html: string): string {
|
||||
if (html.includes('data-od-url-scroll-bridge')) return html;
|
||||
const bodyCloseIndex = html.search(/<\/body\s*>/i);
|
||||
if (bodyCloseIndex >= 0) {
|
||||
return `${html.slice(0, bodyCloseIndex)}${URL_PREVIEW_SCROLL_BRIDGE}${html.slice(bodyCloseIndex)}`;
|
||||
}
|
||||
return `${html}${URL_PREVIEW_SCROLL_BRIDGE}`;
|
||||
}
|
||||
|
||||
export function registerProjectRoutes(app: Express, ctx: RegisterProjectRoutesDeps) {
|
||||
const { db, design } = ctx;
|
||||
const { sendApiError, createSseResponse } = ctx.http;
|
||||
|
|
@ -959,6 +1059,13 @@ export function registerProjectFileRoutes(app: Express, ctx: RegisterProjectFile
|
|||
}
|
||||
|
||||
const file = await readProjectFile(PROJECTS_DIR, projectId, relPath, project?.metadata);
|
||||
if (
|
||||
wantsUrlPreviewScrollBridge(req.query.odPreviewBridge) &&
|
||||
/^text\/html(?:;|$)/i.test(file.mime)
|
||||
) {
|
||||
res.type(file.mime).send(injectUrlPreviewScrollBridge(file.buffer.toString('utf8')));
|
||||
return;
|
||||
}
|
||||
res.type(file.mime).send(file.buffer);
|
||||
} catch (err: any) {
|
||||
const status = err && err.code === 'ENOENT' ? 404 : 400;
|
||||
|
|
|
|||
|
|
@ -165,6 +165,11 @@ describe('GET /api/projects/:id/raw/* range request route', () => {
|
|||
await writeFile(path.join(dir, 'clip.mp4'), Buffer.alloc(FILE_SIZE, 0x42));
|
||||
await writeFile(path.join(dir, 'audio.mp3'), Buffer.alloc(FILE_SIZE, 0x43));
|
||||
await writeFile(path.join(dir, 'page.html'), Buffer.from('<html/>'));
|
||||
await writeFile(path.join(dir, 'body.html'), Buffer.from('<html><body><main>Preview</main></body></html>'));
|
||||
await writeFile(
|
||||
path.join(dir, 'bridged.html'),
|
||||
Buffer.from('<html><body><script data-od-url-scroll-bridge></script><main>Preview</main></body></html>'),
|
||||
);
|
||||
});
|
||||
|
||||
afterAll(() => new Promise<void>((resolve) => server.close(() => resolve())));
|
||||
|
|
@ -226,6 +231,32 @@ describe('GET /api/projects/:id/raw/* range request route', () => {
|
|||
expect(text).toBe('<html/>');
|
||||
});
|
||||
|
||||
it('injects the URL preview scroll bridge only when requested', async () => {
|
||||
const plain = await fetch(rawUrl('page.html'));
|
||||
expect(await plain.text()).toBe('<html/>');
|
||||
|
||||
const bridged = await fetch(`${rawUrl('page.html')}?odPreviewBridge=scroll`);
|
||||
expect(bridged.status).toBe(200);
|
||||
const html = await bridged.text();
|
||||
expect(html).toContain('data-od-url-scroll-bridge');
|
||||
expect(html).toContain("type: 'od:preview-scroll'");
|
||||
});
|
||||
|
||||
it('injects the URL preview scroll bridge before the closing body tag', async () => {
|
||||
const bridged = await fetch(`${rawUrl('body.html')}?odPreviewBridge=scroll`);
|
||||
expect(bridged.status).toBe(200);
|
||||
const html = await bridged.text();
|
||||
expect(html.indexOf('data-od-url-scroll-bridge')).toBeGreaterThan(-1);
|
||||
expect(html.indexOf('data-od-url-scroll-bridge')).toBeLessThan(html.indexOf('</body>'));
|
||||
});
|
||||
|
||||
it('does not inject the URL preview scroll bridge twice', async () => {
|
||||
const bridged = await fetch(`${rawUrl('bridged.html')}?odPreviewBridge=scroll`);
|
||||
expect(bridged.status).toBe(200);
|
||||
const html = await bridged.text();
|
||||
expect(html.match(/data-od-url-scroll-bridge/g)?.length).toBe(1);
|
||||
});
|
||||
|
||||
it('returns 404 for a missing file', async () => {
|
||||
const res = await fetch(rawUrl('missing.mp4'));
|
||||
expect(res.status).toBe(404);
|
||||
|
|
|
|||
|
|
@ -4519,7 +4519,7 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([
|
|||
needsFocusGuard,
|
||||
});
|
||||
const basePreviewSrcUrl = useMemo(
|
||||
() => `${projectRawUrl(projectId, file.name)}?v=${Math.round(file.mtime)}&r=${reloadKey}`,
|
||||
() => `${projectRawUrl(projectId, file.name)}?v=${Math.round(file.mtime)}&r=${reloadKey}&odPreviewBridge=scroll`,
|
||||
[projectId, file.name, file.mtime, reloadKey],
|
||||
);
|
||||
const [previewSrcUrl, setPreviewSrcUrl] = useState(basePreviewSrcUrl);
|
||||
|
|
@ -4692,7 +4692,7 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([
|
|||
|
||||
useEffect(() => {
|
||||
restorePreviewScrollPosition();
|
||||
}, [boardMode, manualEditMode, srcDoc, restorePreviewScrollPosition]);
|
||||
}, [boardMode, drawOverlayOpen, manualEditMode, srcDoc, restorePreviewScrollPosition]);
|
||||
|
||||
useEffect(() => {
|
||||
function onMessage(ev: MessageEvent) {
|
||||
|
|
@ -6016,6 +6016,7 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([
|
|||
setAgentToolsOpen(false);
|
||||
return;
|
||||
}
|
||||
capturePreviewScrollPosition();
|
||||
const activateDraw = () => {
|
||||
setCommentPanelOpen(false);
|
||||
setCommentCreateMode(false);
|
||||
|
|
|
|||
|
|
@ -441,7 +441,7 @@ describe('FileViewer SVG artifacts', () => {
|
|||
const { container } = render(<Shell />);
|
||||
|
||||
const firstFrame = screen.getByTestId('artifact-preview-frame') as HTMLIFrameElement;
|
||||
expect(firstFrame.getAttribute('src')).toBe('/api/projects/project-1/raw/page.html?v=1710000000&r=0');
|
||||
expect(firstFrame.getAttribute('src')).toBe('/api/projects/project-1/raw/page.html?v=1710000000&r=0&odPreviewBridge=scroll');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Leave project' }));
|
||||
|
||||
|
|
@ -449,7 +449,7 @@ describe('FileViewer SVG artifacts', () => {
|
|||
expect(screen.getByTestId('home-view')).toBeTruthy();
|
||||
const parkedFrame = container.querySelector<HTMLIFrameElement>('.iframe-keep-alive-pool iframe');
|
||||
expect(parkedFrame).toBe(firstFrame);
|
||||
expect(parkedFrame?.getAttribute('src')).toBe('/api/projects/project-1/raw/page.html?v=1710000000&r=0');
|
||||
expect(parkedFrame?.getAttribute('src')).toBe('/api/projects/project-1/raw/page.html?v=1710000000&r=0&odPreviewBridge=scroll');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Return project' }));
|
||||
|
||||
|
|
@ -596,7 +596,7 @@ describe('FileViewer SVG artifacts', () => {
|
|||
expect(markup).toContain('data-od-render-mode="url-load"');
|
||||
expect(markup).toContain('data-od-render-mode="url-load" data-od-active="true"');
|
||||
expect(markup).toContain('data-od-render-mode="srcdoc" data-od-active="false"');
|
||||
expect(markup).toContain('src="/api/projects/project-1/raw/page.html?v=1710000000&r=0"');
|
||||
expect(markup).toContain('src="/api/projects/project-1/raw/page.html?v=1710000000&r=0&odPreviewBridge=scroll"');
|
||||
expect(markup).toContain('sandbox="allow-scripts allow-downloads"');
|
||||
});
|
||||
|
||||
|
|
@ -774,7 +774,8 @@ describe('FileViewer SVG artifacts', () => {
|
|||
|
||||
const { container } = render(<Switcher />);
|
||||
const getFrame = () => container.querySelector<HTMLIFrameElement>('[data-testid="artifact-preview-frame"]');
|
||||
expect(getFrame()?.getAttribute('src')).toBe('/api/projects/project-1/raw/first.html?v=1710000000&r=0');
|
||||
const initialFrame = getFrame();
|
||||
expect(initialFrame?.getAttribute('src')).toBe('/api/projects/project-1/raw/first.html?v=1710000000&r=0&odPreviewBridge=scroll');
|
||||
|
||||
const observationsBeforeSwitch = observedCommittedSrcs.length;
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Switch file' }));
|
||||
|
|
@ -782,9 +783,9 @@ describe('FileViewer SVG artifacts', () => {
|
|||
const nextFrame = getFrame();
|
||||
expect(nextFrame).toBeTruthy();
|
||||
expect(observedCommittedSrcs[observationsBeforeSwitch]).toBe(
|
||||
'/api/projects/project-1/raw/second.html?v=1710000000&r=0',
|
||||
'/api/projects/project-1/raw/second.html?v=1710000000&r=0&odPreviewBridge=scroll',
|
||||
);
|
||||
expect(nextFrame?.getAttribute('src')).toBe('/api/projects/project-1/raw/second.html?v=1710000000&r=0');
|
||||
expect(nextFrame?.getAttribute('src')).toBe('/api/projects/project-1/raw/second.html?v=1710000000&r=0&odPreviewBridge=scroll');
|
||||
});
|
||||
|
||||
it('allows downloads in the in-tab HTML presentation iframe', async () => {
|
||||
|
|
@ -1782,11 +1783,54 @@ describe('FileViewer tweaks toolbar', () => {
|
|||
expect((screen.getByTestId('artifact-preview-frame') as HTMLIFrameElement).srcdoc).toBe(frame.srcdoc);
|
||||
});
|
||||
|
||||
it('keeps Draw queue available while disabling direct send during a running task', async () => {
|
||||
const annotationSpy = vi.fn((event: Event) => {
|
||||
const detail = (event as CustomEvent<{ ack?: (result: { ok: boolean }) => void }>).detail;
|
||||
detail.ack?.({ ok: true });
|
||||
it('preserves URL-loaded preview scroll when opening Draw', async () => {
|
||||
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
|
||||
cb(0);
|
||||
return 1;
|
||||
});
|
||||
vi.stubGlobal('cancelAnimationFrame', vi.fn());
|
||||
|
||||
render(
|
||||
<FileViewer projectId="project-1" projectKind="prototype" file={htmlPreviewFile()}
|
||||
liveHtml='<html><body><main data-od-id="hero">Hero</main></body></html>'
|
||||
/>,
|
||||
);
|
||||
|
||||
const urlFrame = screen.getByTestId('artifact-preview-frame') as HTMLIFrameElement;
|
||||
expect(urlFrame.getAttribute('data-od-render-mode')).toBe('url-load');
|
||||
expect(urlFrame.getAttribute('src')).toContain('odPreviewBridge=scroll');
|
||||
|
||||
const srcDocFrame = screen.getByTestId('artifact-preview-frame-srcdoc') as HTMLIFrameElement;
|
||||
const postSpy = vi.spyOn(srcDocFrame.contentWindow!, 'postMessage');
|
||||
window.dispatchEvent(new MessageEvent('message', {
|
||||
source: urlFrame.contentWindow,
|
||||
data: {
|
||||
type: 'od:preview-scroll',
|
||||
frameLeft: 4,
|
||||
frameTop: 640,
|
||||
canvasLeft: 0,
|
||||
canvasTop: 640,
|
||||
},
|
||||
}));
|
||||
|
||||
clickAgentTool('draw-overlay-toggle');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(postSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'od:preview-scroll-restore',
|
||||
frameLeft: 4,
|
||||
frameTop: 640,
|
||||
canvasTop: 640,
|
||||
}),
|
||||
'*',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('lets Draw direct send emit a queued annotation while a task is running', async () => {
|
||||
const annotationSpy = vi.fn();
|
||||
|
||||
window.addEventListener(ANNOTATION_EVENT, annotationSpy);
|
||||
|
||||
render(
|
||||
|
|
|
|||
Loading…
Reference in a new issue