Files
Nicholas-Xiong 4de25d3f1b fix(web): 让长演讲者备注在 presenter 视图里滚动而非裁切 (#6271) (#6314)
问题:.presenter 视图里 .notes 这个 aside 作为 body 的 grid item 默认
min-height: auto,不收缩;body 的隐式 grid row 也 auto,跟着内容撑。
当备注文字到几百词时,.notes 把 body row 撑过 viewport,而 body
{ overflow: hidden } 直接切掉底部,.notes-body { overflow: auto }
永远不触发 —— 用户看不到下面的备注,也没有滚动条 (#6271)。

修复:
1. body 加 grid-template-rows: minmax(0, 1fr),把 implicit row 锁到
   viewport 高度,row 不再随内容增长。
2. .notes 加 min-height: 0,让 grid item 在 bounded row 里可以收缩,
   .notes-body { overflow: auto } 才能真正生效。这跟 .stage 已有的
   min-height: 0 模式对称。

回归测试:在 speaker-notes.test.ts 加一个 describe,80 行 ~800 词的
备注塞进 buildSpeakerNotesPresenterHtml,断言:
- body 的 CSS 里包含 grid-template-rows: minmax(0, 1fr)
- .notes 的 CSS 里包含 min-height: 0
- .notes-body 仍然带 overflow: auto
- 完整长备注文本被原样写入 od-presenter-data
锁住这几个不变量,后续 refactor 不会再让长备注被静默裁切。

Co-authored-by: Ray Xi <2667192167@qq.com>
2026-08-07 07:54:30 +00:00

523 lines
21 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import { JSDOM } from 'jsdom';
import {
buildSpeakerNotesPresenterHtml,
extractSpeakerNotesFromHtml,
PRESENTER_WINDOW_MIN_HEIGHT,
PRESENTER_WINDOW_MIN_WIDTH,
removeSpeakerNotesFromHtml,
sourcesDifferOnlyInSpeakerNotes,
upsertSpeakerNotesInHtml,
} from '../../src/runtime/speaker-notes';
describe('speaker notes HTML helpers', () => {
it('reads the shared #speaker-notes JSON array format', () => {
const html = [
'<!doctype html><html><body>',
'<section class="slide">One</section>',
'<script type="application/json" id="speaker-notes">',
'["Intro", "Details"]',
'</script>',
'</body></html>',
].join('');
expect(extractSpeakerNotesFromHtml(html, 3)).toEqual(['Intro', 'Details']);
});
it('falls back to per-slide .notes blocks', () => {
const html = [
'<section class="slide"><h1>One</h1><aside class="notes">Open<br>strong.</aside></section>',
'<section class="slide"><h1>Two</h1><div class="notes"><p>Close &amp; transition.</p></div></section>',
].join('');
expect(extractSpeakerNotesFromHtml(html, 2)).toEqual([
'Open\nstrong.',
'Close & transition.',
]);
});
it('upserts notes without mutating visible slide content', () => {
const source = '<!doctype html><html><body><section class="slide">Visible</section></body></html>';
const next = upsertSpeakerNotesInHtml(source, ['Private note']);
expect(next).toContain('<section class="slide">Visible</section>');
expect(next).toContain('id="speaker-notes"');
expect(extractSpeakerNotesFromHtml(next)).toEqual(['Private note']);
});
it('replaces an existing speaker notes script', () => {
const source = '<script type="application/json" id="speaker-notes">["Old"]</script>';
const next = upsertSpeakerNotesInHtml(source, ['New']);
expect(next).not.toContain('Old');
expect(extractSpeakerNotesFromHtml(next)).toEqual(['New']);
});
it('ignores invalid speaker notes JSON instead of showing script garbage', () => {
const source = [
'<deck-stage><section class="slide">One</section></deck-stage>',
'<script type="application/json" id="speaker-notes">',
' * and posts {slideIndexChanged: N} to the parent window on nav.',
' * keyboard navigation — arrows.',
'</script>',
].join('\n');
expect(extractSpeakerNotesFromHtml(source, 1)).toEqual([]);
});
it('removes only the real speaker notes script block', () => {
const source = [
'<script>',
'/* mentions <script type="application/json" id="speaker-notes"> in runtime docs */',
'window.RUNTIME = true;',
'</script>',
'<script type="application/json" id="speaker-notes">["Intro"]</script>',
].join('\n');
const next = removeSpeakerNotesFromHtml(source);
expect(next).toContain('window.RUNTIME = true;');
expect(next).toContain('mentions <script type="application/json" id="speaker-notes">');
expect(extractSpeakerNotesFromHtml(next)).toEqual([]);
});
// Regression: the deck-stage runtime's JSDoc header contains the literal
// string `<script type="application/json" id="speaker-notes">` (it documents
// that the component reads that tag). Script content is CDATA, so that text
// is NOT a real element — but a naive regex matches it and treats the deck
// runtime's own `</script>` as the block's close, clobbering the entire
// runtime on upsert and reading garbage on extract. The result is a deck
// that renders as a black screen (issue: deck black screen after notes save).
const deckWithRuntimeMention = [
'<!doctype html><html><body>',
'<deck-stage><section class="slide">One</section></deck-stage>',
'<script>',
'/**',
' * <deck-stage> — reusable web component for HTML decks.',
' * Handles:',
' * (a) speaker notes — reads <script type="application/json" id="speaker-notes">',
' * (b) keyboard navigation — arrows, PgUp/PgDn.',
' */',
"(() => { const DECK_RUNTIME_MARKER = 1; customElements.define('deck-stage', class extends HTMLElement {}); })();",
'</script>',
'<script type="application/json" id="speaker-notes">',
'["Real one", "Real two"]',
'</script>',
'</body></html>',
].join('\n');
it('reads the real notes tag, not the deck-stage JSDoc mention', () => {
expect(extractSpeakerNotesFromHtml(deckWithRuntimeMention, 2)).toEqual(['Real one', 'Real two']);
});
it('upserts without clobbering a deck-stage runtime that mentions the tag', () => {
const next = upsertSpeakerNotesInHtml(deckWithRuntimeMention, ['Edited']);
// The runtime and its JSDoc must survive untouched.
expect(next).toContain('const DECK_RUNTIME_MARKER = 1');
expect(next).toContain("customElements.define('deck-stage'");
expect(next).toContain('(b) keyboard navigation');
// And the edit lands in the real notes block.
expect(extractSpeakerNotesFromHtml(next)).toEqual(['Edited']);
// Exactly one real speaker-notes element still exists.
expect(next.match(/id="speaker-notes"/g)?.length).toBe(2); // one mention + one real tag
});
it('escapes script-closing text inside presenter data', () => {
const html = buildSpeakerNotesPresenterHtml({
previewHtml: '<script>console.log("</script>")</script>',
title: 'Deck',
projectId: 'project-1',
fileName: 'deck.html',
notes: ['Do not close </script>'],
initialSlideIndex: 0,
slideCount: 1,
labels: {
title: 'Speaker notes',
edit: 'Edit',
save: 'Save notes',
pause: 'Pause',
resume: 'Resume',
reset: 'Reset',
previous: 'Previous',
next: 'Next',
empty: 'Empty',
slide: 'Slide {current} / {total}',
},
});
expect(html).toContain('id="od-presenter-data"');
expect(html).not.toContain('Do not close </script>');
expect(html).toContain('\\u003c/script>');
});
it('hides deck chrome inside presenter slide frames', () => {
const html = buildSpeakerNotesPresenterHtml({
previewHtml: '<!doctype html><html><head></head><body><nav class="deck-counter"></nav></body></html>',
title: 'Deck',
projectId: 'project-1',
fileName: 'deck.html',
notes: ['Intro'],
initialSlideIndex: 0,
slideCount: 1,
labels: {
title: 'Speaker notes',
edit: 'Edit',
save: 'Save notes',
pause: 'Pause',
resume: 'Resume',
reset: 'Reset',
previous: 'Previous',
next: 'Next',
empty: 'Empty',
slide: 'Slide {current} / {total}',
},
});
const match = /<script type="application\/json" id="od-presenter-data">([\s\S]*?)<\/script>/.exec(html);
expect(match).not.toBeNull();
const data = JSON.parse(match![1] ?? '{}') as { previewHtml?: string };
expect(data.previewHtml).toContain('data-od-presenter-frame-chrome');
expect(data.previewHtml).toContain('.deck-counter,');
expect(data.previewHtml).toContain('.deck-floating-nav,');
expect(data.previewHtml).toContain('[role="navigation"][aria-label*="Deck"]');
expect(data.previewHtml).toContain('display: none !important');
});
it('can carry per-slide presenter frame HTML so previous and next previews stay in sync', () => {
const html = buildSpeakerNotesPresenterHtml({
previewHtml: '<!doctype html><html><head></head><body>fallback</body></html>',
previewHtmlBySlide: [
'<!doctype html><html><head></head><body>slide one</body></html>',
'<!doctype html><html><head></head><body>slide two</body></html>',
],
title: 'Deck',
projectId: 'project-1',
fileName: 'deck.html',
notes: ['Intro', 'Second'],
initialSlideIndex: 0,
slideCount: 2,
labels: {
title: 'Speaker notes',
edit: 'Edit',
save: 'Save notes',
pause: 'Pause',
resume: 'Resume',
reset: 'Reset',
previous: 'Previous',
next: 'Next',
empty: 'Empty',
slide: 'Slide {current} / {total}',
},
});
const match = /<script type="application\/json" id="od-presenter-data">([\s\S]*?)<\/script>/.exec(html);
expect(match).not.toBeNull();
const data = JSON.parse(match![1] ?? '{}') as { previewHtmlBySlide?: string[] };
expect(data.previewHtmlBySlide).toHaveLength(2);
expect(data.previewHtmlBySlide?.[0]).toContain('slide one');
expect(data.previewHtmlBySlide?.[1]).toContain('slide two');
expect(data.previewHtmlBySlide?.[0]).toContain('data-od-presenter-frame-chrome');
expect(html).toContain('data.previewHtmlBySlide[target]');
});
it('edits presenter notes from the notes body instead of a separate switch', () => {
const html = buildSpeakerNotesPresenterHtml({
previewHtml: '<!doctype html><html><head></head><body>slide</body></html>',
title: 'Deck',
projectId: 'project-1',
fileName: 'deck.html',
notes: ['Intro'],
initialSlideIndex: 0,
slideCount: 1,
labels: {
title: 'Speaker notes',
edit: 'Edit',
save: 'Save notes',
pause: 'Pause',
resume: 'Resume',
reset: 'Reset',
previous: 'Previous',
next: 'Next',
empty: 'Empty',
slide: 'Slide {current} / {total}',
},
});
expect(html).not.toContain('class="edit-toggle"');
expect(html).not.toContain('role="switch"');
expect(html).not.toContain('type="checkbox" id="edit"');
expect(html).toContain("els.notesBody.addEventListener('mousedown', handleNotesBodyMouseDown)");
expect(html).toContain("els.notesBody.addEventListener('click', handleNotesBodyClick)");
expect(html).toContain("div.setAttribute('role', 'textbox')");
});
it('does not immediately refocus presenter notes after clicking blank space during blur save', async () => {
const html = buildSpeakerNotesPresenterHtml({
previewHtml: '<!doctype html><html><head></head><body>slide</body></html>',
title: 'Deck',
projectId: 'project-1',
fileName: 'deck.html',
notes: ['Intro'],
initialSlideIndex: 0,
slideCount: 1,
labels: {
title: 'Speaker notes',
edit: 'Edit',
save: 'Save notes',
pause: 'Pause',
resume: 'Resume',
reset: 'Reset',
previous: 'Previous',
next: 'Next',
empty: 'Empty',
slide: 'Slide {current} / {total}',
},
});
const dom = new JSDOM(html, {
pretendToBeVisual: true,
runScripts: 'dangerously',
url: 'http://localhost',
});
try {
const notesBody = dom.window.document.getElementById('notes-body');
expect(notesBody).not.toBeNull();
notesBody!.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true }));
const textarea = notesBody!.querySelector('textarea');
expect(textarea).not.toBeNull();
textarea!.value = 'Edited note';
notesBody!.dispatchEvent(new dom.window.MouseEvent('mousedown', { bubbles: true }));
textarea!.blur();
await new Promise<void>((resolve) => dom.window.setTimeout(resolve, 0));
notesBody!.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true }));
expect(notesBody!.querySelector('textarea')).toBeNull();
expect(notesBody!.textContent).toContain('Edited note');
} finally {
dom.window.close();
}
});
it('pins the previous filmstrip cell to the left column and next to the right', () => {
// The first slide has no previous and the last has no next; each cell must
// keep its own column so "Next" always reads on the right, not collapsed
// into column 1 when its sibling is hidden.
const html = buildSpeakerNotesPresenterHtml({
previewHtml: '<!doctype html><html><head></head><body>slide</body></html>',
title: 'Deck',
projectId: 'project-1',
fileName: 'deck.html',
notes: ['Intro'],
initialSlideIndex: 0,
slideCount: 3,
labels: {
title: 'Speaker notes',
edit: 'Edit',
save: 'Save notes',
pause: 'Pause',
resume: 'Resume',
reset: 'Reset',
previous: 'Previous',
next: 'Next',
empty: 'Empty',
slide: 'Slide {current} / {total}',
},
});
expect(html).toContain('#previous-section { grid-column: 1; }');
expect(html).toContain('#next-section { grid-column: 2; }');
// The markup order must keep previous before next so the pinning above
// matches the DOM the presenter script drives.
expect(html.indexOf('id="previous-section"')).toBeLessThan(html.indexOf('id="next-section"'));
});
it('keeps presenter popup content responsive and enforces a minimum window size', () => {
const html = buildSpeakerNotesPresenterHtml({
previewHtml: '<!doctype html><html><head></head><body>slide</body></html>',
title: 'Deck',
projectId: 'project-1',
fileName: 'deck.html',
notes: ['Intro'],
initialSlideIndex: 0,
slideCount: 1,
labels: {
title: 'Speaker notes',
edit: 'Edit',
save: 'Save notes',
pause: 'Pause',
resume: 'Resume',
reset: 'Reset',
previous: 'Previous',
next: 'Next',
empty: 'Empty',
slide: 'Slide {current} / {total}',
},
});
expect(html).toContain(`--presenter-min-width: ${PRESENTER_WINDOW_MIN_WIDTH}px;`);
expect(html).toContain(`--presenter-min-height: ${PRESENTER_WINDOW_MIN_HEIGHT}px;`);
expect(html).toContain('html {');
expect(html).toContain('overflow: auto;');
expect(html).toContain('.topbar { display: flex; flex-wrap: wrap;');
expect(html).toContain('.filmstrip { gap: 10px; min-height: 96px; }');
expect(html).toContain('.thumb-frame { height: 86px; border-radius: 7px; }');
expect(html).toContain('@media (max-width: 720px), (max-height: 640px)');
expect(html).toContain('.filmstrip { gap: 8px; min-height: 82px; }');
expect(html).toContain('.thumb-frame { height: 74px; }');
expect(html).not.toContain('.filmstrip { display: none; }');
expect(html).toContain(`var minWindowWidth = ${PRESENTER_WINDOW_MIN_WIDTH};`);
expect(html).toContain(`var minWindowHeight = ${PRESENTER_WINDOW_MIN_HEIGHT};`);
expect(html).toContain('function enforceMinimumWindowSize(){');
expect(html).toContain('window.resizeTo(nextWidth, nextHeight)');
expect(html).toContain("window.addEventListener('resize', scheduleMinimumWindowSize)");
});
it('sandboxes the presenter slide iframes to an opaque origin', () => {
const html = buildSpeakerNotesPresenterHtml({
previewHtml: '<section class="slide">One</section>',
title: 'Deck',
projectId: 'project-1',
fileName: 'deck.html',
notes: ['Intro'],
initialSlideIndex: 0,
slideCount: 1,
labels: {
title: 'Speaker notes',
edit: 'Edit',
save: 'Save notes',
pause: 'Pause',
resume: 'Resume',
reset: 'Reset',
previous: 'Previous',
next: 'Next',
empty: 'Empty',
slide: 'Slide {current} / {total}',
},
});
// The srcdoc loaded into each frame is the full, untrusted deck (scripts
// included); the frames must run it in an opaque origin so it cannot reach
// the app's storage or daemon.
for (const id of ['current', 'previous', 'next']) {
const tag = html.match(new RegExp(`<iframe id="${id}"[^>]*>`))?.[0] ?? '';
expect(tag).toContain('sandbox="allow-scripts"');
expect(tag).not.toContain('allow-same-origin');
}
});
});
// Regression for issue #6271: when the speaker-notes panel holds a long note
// (hundreds of words), the notes must scroll *inside* `.notes-body` instead of
// growing the body grid row past the viewport. With `body { overflow: hidden }`
// the latter case clips the bottom of the note silently — the user cannot reach
// it. The fix has two parts: (1) `body { grid-template-rows: minmax(0, 1fr) }`
// locks the implicit grid row to the viewport height so the row cannot grow to
// natural content height, and (2) `.notes { min-height: 0 }` lets the grid item
// shrink inside its track so `.notes-body { overflow: auto }` actually fires.
describe('speaker-notes presenter long-note scrolling (#6271)', () => {
const labels = {
title: 'Speaker notes',
edit: 'Edit',
save: 'Save notes',
pause: 'Pause',
resume: 'Resume',
reset: 'Reset',
previous: 'Previous',
next: 'Next',
empty: 'Empty',
slide: 'Slide {current} / {total}',
};
// Roughly 800 words — well past any viewport's available height at the
// default 21px / 1.58 line-height (each line ~33px, 800 words ~60 lines
// ~2000px, easily taller than a 1080p viewport after subtracting chrome).
const longNote = Array.from({ length: 80 }, (_, i) =>
`Line ${i + 1}: keep talking past the bottom of the panel without losing the thread.`
).join('\n');
const buildHtml = () => buildSpeakerNotesPresenterHtml({
previewHtml: '<section class="slide">One</section>',
title: 'Deck',
projectId: 'project-1',
fileName: 'deck.html',
notes: [longNote],
initialSlideIndex: 0,
slideCount: 1,
labels,
});
it('locks the body grid row to the viewport so .notes cannot grow past it', () => {
const html = buildHtml();
// The body grid must constrain its implicit row to a `minmax(0, 1fr)`
// track; an auto row would expand to the natural content height and let
// long notes blow past the viewport (then body { overflow: hidden } clips
// them silently). The selector is specific enough to survive minification.
expect(html).toMatch(/body\s*\{[^}]*grid-template-rows:\s*minmax\(0,\s*1fr\)[^}]*\}/);
});
it('lets the .notes grid item shrink so .notes-body can scroll inside it', () => {
const html = buildHtml();
// `.notes` is a grid item whose parent row is now bounded, but without
// `min-height: 0` the item itself defaults to `min-height: auto` and
// refuses to shrink — pushing the overflow back to the body. Mirror the
// pattern already used on `.stage` (`min-height: 0`) so the sidebar can
// actually honor its bounded row.
expect(html).toMatch(/\.notes\s*\{[^}]*min-height:\s*0[^}]*\}/);
});
it('keeps the .notes-body scroll container that the long note lives in', () => {
const html = buildHtml();
// The actual scroll happens here — the inner body must keep `overflow: auto`
// (or `overflow-y: auto`) so the long note scrolls inside the panel rather
// than depending on the parent window to scroll.
expect(html).toMatch(/\.notes-body\s*\{[^}]*overflow:\s*auto[^}]*\}/);
});
it('ships the long note into the panel for the presenter to read', () => {
const html = buildHtml();
// The data payload must carry the full long note verbatim so the
// presenter-side render has something to scroll through; truncating here
// would silently hide the regression.
expect(html).toContain('Line 1: keep talking past the bottom');
expect(html).toContain('Line 80: keep talking past the bottom');
});
});
describe('speaker-notes-only difference detection', () => {
// Notes live in a <script type="application/json"> the browser never
// renders, so a change confined to it needs no preview repaint. Callers use
// this to skip an iframe reload — a white flash and a scroll-restore gamble
// — for an edit that is invisible on the page.
const page = '<!doctype html><html><body><section class="slide">Hi</section></body></html>';
it('sees adding notes to a page as notes-only, padding included', () => {
// upsertSpeakerNotesInHtml pads its insertion with newlines, so a plain
// block removal is NOT its inverse — the padding alone used to make an
// added-notes document compare as a real visual change.
const withNotes = upsertSpeakerNotesInHtml(page, ['Open on the ask.']);
expect(withNotes).not.toBe(page);
expect(sourcesDifferOnlyInSpeakerNotes(page, withNotes)).toBe(true);
expect(sourcesDifferOnlyInSpeakerNotes(withNotes, page)).toBe(true);
});
it('sees an edit between two notes payloads as notes-only', () => {
const first = upsertSpeakerNotesInHtml(page, ['First take']);
const second = upsertSpeakerNotesInHtml(page, ['Second take', 'And a next slide']);
expect(sourcesDifferOnlyInSpeakerNotes(first, second)).toBe(true);
});
it('never calls a visible body change notes-only', () => {
const edited = page.replace('Hi', 'Hello');
expect(sourcesDifferOnlyInSpeakerNotes(page, edited)).toBe(false);
// Even when the notes change in the SAME write, the body change must win.
const editedWithNotes = upsertSpeakerNotesInHtml(edited, ['Note']);
const pageWithNotes = upsertSpeakerNotesInHtml(page, ['Other note']);
expect(sourcesDifferOnlyInSpeakerNotes(pageWithNotes, editedWithNotes)).toBe(false);
});
it('reports identical documents as not differing at all', () => {
expect(sourcesDifferOnlyInSpeakerNotes(page, page)).toBe(false);
});
});