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
15 changes: 13 additions & 2 deletions scripts/layout.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -362,14 +362,25 @@ 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 {
Object.assign(globals, saved);
}
}

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 },
Expand Down
8 changes: 8 additions & 0 deletions scripts/preview.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
51 changes: 46 additions & 5 deletions src/lib/MarkdownViewer.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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);

/**
Expand Down Expand Up @@ -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'));
Expand Down Expand Up @@ -1389,6 +1414,18 @@ import { followActiveFile } from './sessions/fileWatch.svelte.js';
let isScrolling = $state(false);
let scrollIdleTimer: ReturnType<typeof setTimeout>;

// 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;

Expand Down Expand Up @@ -1417,7 +1454,7 @@ import { followActiveFile } from './sessions/fileWatch.svelte.js';
followPreviewScroll(target);
}

syncEditorToPreviewScroll(target);
if (previewLeads) syncEditorToPreviewScroll(target);
}

/**
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -2783,7 +2822,7 @@ import { followActiveFile } from './sessions/fileWatch.svelte.js';
{#if tabManager.activeTab && !showsHomeScreen(() => tabManager.activeTab, showHome)}
<div
class="markdown-container"
style="zoom: {hasPreviewPane ? settings.zoomLevel / 100 : 1}; --code-font: {previewAppearance.codeFontFamily}; --code-font-size: {previewAppearance.codeFontSize}px; --highlight-color: {previewAppearance.highlightColor};"
style="--code-font: {previewAppearance.codeFontFamily}; --code-font-size: {previewAppearance.codeFontSize * contentZoom}px; --highlight-color: {previewAppearance.highlightColor};"
onwheel={handleWheel}
role="presentation">
<div class="layout-container"
Expand Down Expand Up @@ -2876,7 +2915,7 @@ import { followActiveFile } from './sessions/fileWatch.svelte.js';
if(e.key === 'Enter' || e.key === ' ') handleLinkClick(e as unknown as MouseEvent);
}}
tabindex="-1"
style="outline: none; font-family: {previewAppearance.fontFamily}; font-size: {previewAppearance.fontSize}px; flex: 1; --preview-max-width: {layout.maxWidthCss};">
style="outline: none; font-family: {previewAppearance.fontFamily}; font-size: {previewAppearance.fontSize * contentZoom}px; flex: 1; --preview-max-width: {layout.maxWidthCss};">
{#if frontMatterInfo.exists}
<details
class="frontmatter-panel"
Expand Down Expand Up @@ -3228,6 +3267,8 @@ import { followActiveFile } from './sessions/fileWatch.svelte.js';
{/if}
{/if}

<svelte:window onwheelcapture={noteInput} onpointerdowncapture={noteInput} onkeydowncapture={noteInput} />

<ContextMenu {...docContextMenu} onhide={() => (docContextMenu.show = false)} />

<!-- What a PDF is rendered from: empty on screen, filled for the duration of a
Expand Down
9 changes: 8 additions & 1 deletion src/lib/utils/foldLayout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ function updateFoldHeights(root: HTMLElement) {
// eslint.config.js). Nothing is clipped by the smaller
// number: expanded wrappers are `overflow: visible`.
//
// Divide by the preview's CSS `zoom`: a rect is in viewport pixels, and
// Divide by any ancestor CSS `zoom`: a rect is in viewport pixels, and
// `height` has the zoom applied again (#807).
const zoom = previewZoomFactor(root);
const heights = pending.map(({ content }) => content.getBoundingClientRect().height / zoom);
Expand Down Expand Up @@ -88,6 +88,8 @@ export interface FoldLayoutObservation {
* survivor costs nothing either.
*/
observe(scope: Element): void;
/** Measure now rather than next frame, for a caller that places the scroll after a reflow. */
measureNow(): void;
stop(): void;
}

Expand Down Expand Up @@ -139,6 +141,11 @@ export function observeFoldLayout(root: HTMLElement): FoldLayoutObservation {

return {
observe,
measureNow: () => {
if (frame !== null) cancelAnimationFrame(frame);
frame = null;
updateFoldHeights(root);
},
stop: () => {
observer.disconnect();
window.removeEventListener('resize', scheduleUpdate);
Expand Down
8 changes: 4 additions & 4 deletions src/lib/utils/previewAnchor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,10 +160,10 @@ export const PREVIEW_ANCHOR_OFFSET = 60;
* The factor between the coordinates `getBoundingClientRect()` reports and the
* ones `scrollTop` is measured in, for anything inside `container`.
*
* The preview sets CSS `zoom` on `.markdown-container`, an ANCESTOR of the
* scrolling element. A rect is reported in viewport pixels, which have that
* zoom folded in; `scrollTop` counts in the scroller's own pixels, which do
* not. The two are the same number only at 100%.
* A CSS `zoom` on an ANCESTOR of the scrolling element folds into the rect,
* which is reported in viewport pixels, but not into `scrollTop`, which counts
* the scroller's own. Content zoom scales font sizes now (#1071), so this is 1
* unless a CSS `zoom` comes back.
*
* Measured off the container rather than read from the settings store, so this
* module keeps knowing nothing about where the zoom is applied or how many
Expand Down
6 changes: 4 additions & 2 deletions src/lib/utils/previewLayout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,15 +45,17 @@ export type PreviewLayoutSettings = {
tocWidth: number;
};

export type PreviewView = { isEditing: boolean; isSplit: boolean; viewerWidth: number };
/** `zoom` is the content zoom, which scales the preview's measure with its text (#1071). */
export type PreviewView = { isEditing: boolean; isSplit: boolean; viewerWidth: number; zoom?: number };

/**
* The preview's measure and whether the outline covers it. Both come from the
* one content width, so the outline cannot think there is a gutter the preview
* does not leave.
*/
export function previewLayout(settings: PreviewLayoutSettings, view: PreviewView) {
const contentWidth = getPreviewContentWidth(settings.previewMaxWidth, settings.previewFullWidth);
const measure = getPreviewContentWidth(settings.previewMaxWidth, settings.previewFullWidth);
const contentWidth = measure === null ? null : measure * (view.zoom ?? 1);
return {
contentWidth,
/** The live `.markdown-body`'s `--preview-max-width`. */
Expand Down
6 changes: 3 additions & 3 deletions src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -772,14 +772,14 @@ body {
display: inline-block;
padding: 3px 5px;
font:
11px ui-monospace,
0.6875em ui-monospace,
SFMono-Regular,
SF Mono,
Menlo,
Consolas,
Liberation Mono,
monospace;
line-height: 10px;
line-height: 0.91;
color: var(--color-fg-default);
vertical-align: middle;
background-color: var(--color-canvas-subtle);
Expand Down Expand Up @@ -1194,7 +1194,7 @@ body {
}

.markdown-body .footnotes {
font-size: 12px;
font-size: 0.75em;
color: var(--color-fg-muted);
border-top: 1px solid var(--color-border-default);
margin-top: 24px;
Expand Down
Loading