From fe6130b25f22f8e82887a9bc3b8469a11831c80e Mon Sep 17 00:00:00 2001 From: PathGao <42336971+PathGao@users.noreply.github.com> Date: Mon, 9 Nov 2026 01:46:17 +0800 Subject: [PATCH] fix(preview): zoom content by font size, and sync the preview to the editor only when the reader leads Content zoom was CSS zoom around both panes, so in split view the editor was zoomed twice and Monaco's minimap drag ran ahead of the pointer. It now scales the preview's font sizes and measure, as the editor's font already was, and a zoom step keeps the line at the top. Typing near the top of the editor made it bounce: the preview re-render's scroll anchoring nudge was synced back to the editor, whose cursor reveal pulled it back. Preview scrolls now reach the editor only while the reader's last input in the panes was outside the editor. Closes #1071. --- scripts/layout.test.ts | 15 ++++++++-- scripts/preview.test.ts | 8 ++++++ src/lib/MarkdownViewer.svelte | 51 ++++++++++++++++++++++++++++++---- src/lib/utils/foldLayout.ts | 9 +++++- src/lib/utils/previewAnchor.ts | 8 +++--- src/lib/utils/previewLayout.ts | 6 ++-- src/styles.css | 6 ++-- 7 files changed, 86 insertions(+), 17 deletions(-) diff --git a/scripts/layout.test.ts b/scripts/layout.test.ts index 6d5e754b..8f35266f 100644 --- a/scripts/layout.test.ts +++ b/scripts/layout.test.ts @@ -339,7 +339,7 @@ describe('fold measurement batching', () => { return { root, events }; } - async function runFoldLayout(specs: { id: string; height: number | null }[], zoom = 1) { + async function runFoldLayout(specs: { id: string; height: number | null }[], zoom = 1, now = false) { // Imported before the stubs go in, so only the synchronous run below sees them. const { observeFoldLayout } = await import('../src/lib/utils/foldLayout.js'); const { root, events } = createFoldRoot(specs, zoom); @@ -362,7 +362,8 @@ describe('fold measurement batching', () => { try { const observation = observeFoldLayout(root as unknown as HTMLElement); - for (const frame of frames.splice(0)) frame(); + if (now) observation.measureNow(); + else for (const frame of frames.splice(0)) frame(); observation.stop(); return events; } finally { @@ -370,6 +371,16 @@ describe('fold measurement batching', () => { } } + test('measureNow publishes the heights without waiting for a frame', async () => { + // #1071: a zoom step places the preview's scroll in the same task as the + // reflow, and a fold wrapper still pinned to its old height moved the line. + const events = await runFoldLayout([{ id: 'section', height: 240 }], 1, true); + assert.deepEqual( + events.filter((event) => event.type === 'write'), + [{ type: 'write', id: 'section', value: '--fold-content-height:240px' }], + ); + }); + test('fold measurement reads every height before it writes any of them', async () => { const events = await runFoldLayout([ { id: 'outer', height: 300 }, diff --git a/scripts/preview.test.ts b/scripts/preview.test.ts index 53905289..03ead60b 100644 --- a/scripts/preview.test.ts +++ b/scripts/preview.test.ts @@ -932,6 +932,14 @@ describe('preview layout', () => { const full = previewLayout({ ...base, previewFullWidth: true }, reading); assert.deepEqual([full.maxWidthCss, full.isOverhanging], ['100%', true]); }); + + test('content zoom widens the measure, and the outline judges the gutter that is left', () => { + // #1071: zoom scales the text by font size, so the measure has to scale with + // it. At 150% an 880px measure is 1320px of a 1400px pane, a 40px gutter. + const base = { previewMaxWidth: 880, previewFullWidth: false, tocSide: 'left' as const, splitEditorSide: 'left' as const, tocWidth: 240 }; + const zoomed = previewLayout(base, { isEditing: false, isSplit: false, viewerWidth: 1400, zoom: 1.5 }); + assert.deepEqual([zoomed.maxWidthCss, zoomed.isOverhanging], ['1320px', true]); + }); }); describe('webview permissions the preview depends on', () => { diff --git a/src/lib/MarkdownViewer.svelte b/src/lib/MarkdownViewer.svelte index 2fd9c758..bcad8c21 100644 --- a/src/lib/MarkdownViewer.svelte +++ b/src/lib/MarkdownViewer.svelte @@ -376,7 +376,29 @@ import { followActiveFile } from './sessions/fileWatch.svelte.js'; hasEditorPane && settings.showEditorToolbar ? editorToolbarHeight : 0, ); let previewAppearance = $derived(previewAppearanceFrom(settings)); - let layout = $derived(previewLayout(settings, { isEditing, isSplit, viewerWidth })); + // Content zoom by font size, as Zed and JetBrains do it: nothing on screen carries a CSS zoom (#1071). + let contentZoom = $derived(settings.zoomLevel / 100); + // A zoom step reflows the preview under a scrollTop that stays put. CSS zoom used to + // scale that offset with the text; now the line at the top is taken before the + // reflow and put back after it, where WebKit has no scroll anchoring to do it. + let lineBeforeZoom: ScrollSyncPosition | null = null; + $effect.pre(() => { + void contentZoom; + untrack(() => { + lineBeforeZoom = markdownBody && hasPreviewPane ? getPreviewScrollSyncPosition(markdownBody) : null; + }); + }); + $effect(() => { + void contentZoom; + const line = lineBeforeZoom; + lineBeforeZoom = null; + if (!line) return; + // The fold wrappers are still pinned to heights measured at the old size; placed + // before they re-measure, the line drifted a paragraph up per step. + foldLayout.current?.measureNow(); + scrollPreviewToSyncPosition(line); + }); + let layout = $derived(previewLayout(settings, { isEditing, isSplit, viewerWidth, zoom: contentZoom })); let isOverhanging = $derived(layout.isOverhanging); /** @@ -1230,8 +1252,11 @@ import { followActiveFile } from './sessions/fileWatch.svelte.js'; ); if (targetScroll === null) return; + const before = markdownBody.scrollTop; isProgrammaticScroll = true; markdownBody.scrollTop = targetScroll; + // Unmoved, there is no scroll event to clear the flag, and it would swallow the reader's next scroll. + if (markdownBody.scrollTop === before) isProgrammaticScroll = false; } let bufferLines = $derived(rawContent.split('\n')); @@ -1389,6 +1414,18 @@ import { followActiveFile } from './sessions/fileWatch.svelte.js'; let isScrolling = $state(false); let scrollIdleTimer: ReturnType; + // Whether the reader's last input in the panes was outside the editor: in the + // preview, its outline or find bar. Only then is a preview scroll theirs for the + // editor to follow; one that arrives while they type is the browser keeping + // re-rendered content in place, and following it fought Monaco's cursor reveal on + // every keystroke (#1071). Input elsewhere, the title bar or a dialog, leaves it. + let previewLeads = true; + function noteInput(e: Event) { + const target = e.target as Node; + if (editorPaneEl?.contains(target)) previewLeads = false; + else if (layoutContainerEl?.contains(target)) previewLeads = true; + } + function handleScroll(e: Event) { const target = e.target as HTMLElement; @@ -1417,7 +1454,7 @@ import { followActiveFile } from './sessions/fileWatch.svelte.js'; followPreviewScroll(target); } - syncEditorToPreviewScroll(target); + if (previewLeads) syncEditorToPreviewScroll(target); } /** @@ -2309,7 +2346,9 @@ import { followActiveFile } from './sessions/fileWatch.svelte.js'; // moves to another document. const pos = popScrollHistory(direction); if (pos !== null && markdownBody) { - isProgrammaticScroll = true; + // Only a jump that moves gets a scroll event to clear the flag (see `scrollPreviewToSyncPosition`). + const reachable = Math.min(pos, markdownBody.scrollHeight - markdownBody.clientHeight); + isProgrammaticScroll = Math.abs(markdownBody.scrollTop - reachable) >= 1; markdownBody.scrollTo({ top: pos, behavior: jumpBehavior }); } else { await navigateFileHistory(direction); @@ -2783,7 +2822,7 @@ import { followActiveFile } from './sessions/fileWatch.svelte.js'; {#if tabManager.activeTab && !showsHomeScreen(() => tabManager.activeTab, showHome)}