mirror of
https://github.com/nexu-io/open-design.git
synced 2026-09-20 06:15:06 +08:00
4de25d3f1b
问题:.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>
523 lines
21 KiB
TypeScript
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 & 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);
|
|
});
|
|
});
|