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
32 changes: 32 additions & 0 deletions samples/fold-resize-regression.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# Fold resize regression

## Overview

This short section must remain directly above the next heading after the window moves between displays or changes width.

> Wrapped blockquote text changes height when the preview width changes. It must remain in normal document flow without covering the next heading.

```text
Line one
Line two
Line three
```

## Workflow

1. Resize the preview from wide to narrow.
2. Move the window between displays with different scaling.
3. Fold and unfold this section.

### Nested section

This nested content exercises the inner fold wrapper.

| Column A | Column B |
| --- | --- |
| A long value that wraps at narrow widths | Another value |

## Final section

This heading must never overlap Workflow or be separated by a viewport-sized gap.

27 changes: 27 additions & 0 deletions scripts/foldLayout.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';

test('fold layout observes rendered content and publishes its measured height', () => {
const source = readFileSync('src/lib/utils/foldLayout.ts', 'utf8');

assert.match(source, /new ResizeObserver/);
assert.match(source, /--fold-content-height/);
assert.match(source, /requestAnimationFrame/);
});

test('fold wrapper animates an explicit measured height instead of a fractional grid track', () => {
const styles = readFileSync('src/styles.css', 'utf8');
const expandedRule = styles.match(/\.foldable-content-wrapper\s*\{([^}]*)\}/)?.[1] || '';

assert.match(expandedRule, /height:\s*var\(--fold-content-height/);
assert.doesNotMatch(expandedRule, /grid-template-rows:\s*1fr/);
assert.match(styles, /foldable-content-wrapper\.is-collapsed[\s\S]*height:\s*0/);
});

test('preview lifecycle starts and cleans up fold observation', () => {
const viewer = readFileSync('src/lib/MarkdownViewer.svelte', 'utf8');

assert.match(viewer, /observeFoldLayout\((?:markdownBody|body)\)/);
assert.match(viewer, /stopObservingFoldLayout\?\.\(\)/);
});
21 changes: 21 additions & 0 deletions src/lib/MarkdownViewer.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
import { askToOpenExportedFile } from './utils/openExportedFile.js';
import ZoomOverlay from './components/ZoomOverlay.svelte';
import { processMarkdownHtml } from './utils/markdown';
import { observeFoldLayout } from './utils/foldLayout.js';
import {
decodeLinkPath,
getMarkdownLinkTarget as getRelativeMarkdownTarget,
Expand Down Expand Up @@ -67,6 +68,7 @@ import { t } from './utils/i18n.js';
let markdownBody: HTMLElement | null = $state(null);
const renderDebounceMs = 50;
let renderTimeout: ReturnType<typeof setTimeout> | null = null;
let stopObservingFoldLayout: (() => void) | null = null;

const highlightColorMap: Record<string, string> = {
default: 'color-mix(in srgb, var(--color-accent-fg) 40%, transparent)',
Expand Down Expand Up @@ -755,6 +757,25 @@ import { t } from './utils/i18n.js';
if (sanitizedHtml && markdownBody && !isEditing && hljs && renderMathInElement && mermaid) renderRichContent();
});

$effect(() => {
const html = sanitizedHtml;
const body = markdownBody;
if (!html || !body) return;

let cancelled = false;
tick().then(() => {
if (cancelled || body !== markdownBody) return;
stopObservingFoldLayout?.();
stopObservingFoldLayout = observeFoldLayout(body);
});

return () => {
cancelled = true;
stopObservingFoldLayout?.();
stopObservingFoldLayout = null;
};
});

// Re-apply find highlights after the preview HTML is replaced. The
// `bind:innerHTML={sanitizedHtml}` on the article wipes the DOM on every
// edit/render pass; without this, highlights vanish until the user
Expand Down
63 changes: 63 additions & 0 deletions src/lib/utils/foldLayout.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
const FOLD_WRAPPER_SELECTOR = '.foldable-content-wrapper';
const FOLD_CONTENT_SELECTOR = ':scope > .content-inner';

function updateFoldHeights(root: HTMLElement) {
// Innermost wrappers first so a parent measures its child's freshly
// written height rather than a stale one.
const wrappers = Array.from(
root.querySelectorAll<HTMLElement>(FOLD_WRAPPER_SELECTOR),
).reverse();

// Reflow-driven height writes must land instantly. The `height` transition
// exists only to animate fold/unfold; letting it also animate every resize
// leaves the expanded wrapper shorter than its content for ~0.25s, so the
// following section overlaps the still-visible overflow. Suppress the
// transition around the write, force one layout to commit it, then restore
// the stylesheet transition for the next fold toggle.
const written: HTMLElement[] = [];
for (const wrapper of wrappers) {
const content = wrapper.querySelector<HTMLElement>(FOLD_CONTENT_SELECTOR);
if (!content) continue;

wrapper.style.transition = 'none';
wrapper.style.setProperty('--fold-content-height', `${Math.ceil(content.scrollHeight)}px`);
written.push(wrapper);
}

if (written.length === 0) return;

// Single forced reflow commits every suppressed height write at once.
void root.offsetHeight;

for (const wrapper of written) {
wrapper.style.transition = '';
}
}

export function observeFoldLayout(root: HTMLElement): () => void {
let frame: number | null = null;

const scheduleUpdate = () => {
if (frame !== null) return;
frame = requestAnimationFrame(() => {
frame = null;
updateFoldHeights(root);
});
};

const observer = new ResizeObserver(scheduleUpdate);
for (const content of root.querySelectorAll<HTMLElement>(
`${FOLD_WRAPPER_SELECTOR} > .content-inner`,
)) {
observer.observe(content);
}

window.addEventListener('resize', scheduleUpdate);
scheduleUpdate();

return () => {
observer.disconnect();
window.removeEventListener('resize', scheduleUpdate);
if (frame !== null) cancelAnimationFrame(frame);
};
}
20 changes: 17 additions & 3 deletions src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -548,14 +548,28 @@ body {
display: none !important;
}

.foldable-content-wrapper, .markdown-alert-content {
.foldable-content-wrapper {
height: var(--fold-content-height, auto);
transition: height 0.25s ease, opacity 0.25s ease;
opacity: 1;
overflow: visible; /* show carets in the gutter when expanded */
}

.foldable-content-wrapper.is-collapsed {
height: 0;
opacity: 0;
overflow: hidden; /* must hide when collapsed */
}

.markdown-alert-content {
display: grid;
grid-template-rows: 1fr;
transition: grid-template-rows 0.25s ease, opacity 0.25s ease;
opacity: 1;
overflow: visible; /* show carets in the gutter when expanded */
overflow: visible;
}
.foldable-content-wrapper.is-collapsed, .markdown-alert-content.is-collapsed {

.markdown-alert-content.is-collapsed {
grid-template-rows: 0fr;
opacity: 0;
overflow: hidden; /* must hide when collapsed */
Expand Down