{!isPreviewMode && (
- {isFullscreen ? (
-
-
-
-
-
- ) : (
+ {/*
+ Both windowed and fullscreen render through the editor. They
+ used to diverge — fullscreen injected raw HTML — which meant
+ custom nodes could render in one and not the other. The wrapper
+ is animated via controls rather than remounted, because
+ EditorContent owns the editor's DOM node and re-parenting it on
+ every slide change is what made the two paths drift apart.
+ */}
+
- )}
+
+
+
@@ -578,22 +660,17 @@ export const PresentationMode = ({
)}
+ {/* The slide counter that used to live here now renders for every
+ viewport via SlideNumber. */}
{isFullscreen && isNativeMobile && (
- <>
-
-
-
-
-
- {currentSlide + 1} / {slides.length}
-
-
- >
+
+
+
)}
diff --git a/package/ddoc-editor.tsx b/package/ddoc-editor.tsx
index afb81c2b..732cbc0c 100644
--- a/package/ddoc-editor.tsx
+++ b/package/ddoc-editor.tsx
@@ -572,6 +572,41 @@ const DdocEditor = forwardRef(
commentDrawerOpen && setCommentDrawerOpen?.(false);
};
+ // Mod-Alt-P opens the deck; presentation mode already owns Escape to leave
+ // it. This cannot live in the TipTap keymap because entering presentation
+ // mode is React state rather than an editor command.
+ useEffect(() => {
+ const handlePresentationShortcut = (event: KeyboardEvent) => {
+ const isModifier = navigator.platform.includes('Mac')
+ ? event.metaKey
+ : event.ctrlKey;
+
+ // `code` rather than `key`: Option-P emits "π" on macOS, so matching
+ // on the character would never fire there.
+ if (!isModifier || !event.altKey || event.code !== 'KeyP') return;
+
+ // Suppress the browser's own Ctrl/Cmd-P print binding.
+ event.preventDefault();
+ event.stopPropagation();
+
+ if (isPresentationMode) return;
+
+ setIsPresentationMode?.(true);
+ commentDrawerOpen && setCommentDrawerOpen?.(false);
+ };
+
+ // Capture phase: the print shortcut has to be cancelled before anything
+ // else in the page gets a chance to act on the event.
+ window.addEventListener('keydown', handlePresentationShortcut, true);
+ return () =>
+ window.removeEventListener('keydown', handlePresentationShortcut, true);
+ }, [
+ isPresentationMode,
+ commentDrawerOpen,
+ setIsPresentationMode,
+ setCommentDrawerOpen,
+ ]);
+
useEffect(() => {
if (!editor) return;
if (isNativeMobile) {
diff --git a/package/extensions/font-size/font-size.ts b/package/extensions/font-size/font-size.ts
index 1765ffe8..9407da83 100644
--- a/package/extensions/font-size/font-size.ts
+++ b/package/extensions/font-size/font-size.ts
@@ -42,6 +42,30 @@ export const FontSize = Extension.create({
},
},
},
+ {
+ // A list marker is sized by its
, but the size lives on the mark
+ // or paragraph inside it, so a resized item ended up with a marker
+ // that no longer matched its own text. Carrying the size on the item
+ // lets the native marker follow it. Nothing sets this during editing —
+ // it defaults to null and is applied when building slides — so normal
+ // document output is unchanged.
+ types: ['listItem'],
+ attributes: {
+ fontSize: {
+ default: null,
+ parseHTML: (element) =>
+ element.style.fontSize?.replace(/['"]+/g, '') || null,
+ renderHTML: (attributes) => {
+ if (!attributes.fontSize) {
+ return {};
+ }
+ return {
+ style: `font-size: ${attributes.fontSize}`,
+ };
+ },
+ },
+ } as Attributes,
+ },
{
types: this.options.types,
attributes: {
diff --git a/package/styles/editor.css b/package/styles/editor.css
index f83a6c1e..3cc4057c 100644
--- a/package/styles/editor.css
+++ b/package/styles/editor.css
@@ -814,18 +814,31 @@ ul[data-type='taskList'] li[data-checked='true'] > div > p > span {
outline: 2px solid transparent;
outline-offset: 2px;
+ /* Type and spacing are set proportionally to the 1080x608 stage, matching
+ the ratios the fullscreen stylesheet already uses (fullscreen sets body
+ text at ~1.6% of stage width; this block used to set it at ~2.2%). The
+ previous values were document styling — an 82px heading and 24px
+ paragraph gaps on a 608px-tall slide left room for barely a few lines,
+ so slides split with obvious space to spare and the windowed preview
+ looked nothing like the deck being presented. */
h1 {
- font-size: 5.125rem;
+ font-size: calc(var(--slide-font-scale, 1) * 2.25rem);
+ line-height: 1.2;
+ margin: 0 0 0.75rem;
font-weight: 700;
}
h2 {
- font-size: 2.5rem;
+ font-size: calc(var(--slide-font-scale, 1) * 1.6875rem);
+ line-height: 1.2;
+ margin: 0 0 0.75rem;
font-weight: 700;
}
h3 {
- font-size: 1.5rem;
+ font-size: calc(var(--slide-font-scale, 1) * 1.125rem);
+ line-height: 1.2;
+ margin: 0 0 0.75rem;
font-weight: 700;
}
@@ -840,12 +853,29 @@ ul[data-type='taskList'] li[data-checked='true'] > div > p > span {
object-fit: contain;
}
+ /* Inside a column the image is one half of a layout rather than the
+ subject of the slide: it fills the column and keeps its own shape,
+ instead of being capped at 32rem and letterboxed into 16/9. */
+ [data-type='column'] img {
+ max-width: 100%;
+ aspect-ratio: auto;
+ object-fit: contain;
+ }
+
+ /* Columns are a deliberate side-by-side layout, so centre the two halves
+ against each other rather than leaving them top-aligned. */
+ [data-type='columns'] {
+ align-items: center;
+ gap: 1.5rem;
+ }
+
p {
- line-height: 36px;
- font-size: 1.5rem;
+ /* Unitless so it tracks font-size, and with it the font scale. */
+ line-height: 1.5;
+ font-size: calc(var(--slide-font-scale, 1) * 1.0625rem);
font-weight: 400;
- margin-top: 0.75rem;
- margin-bottom: 0.75rem;
+ margin-top: 0.375rem;
+ margin-bottom: 0.375rem;
&:first-child {
margin-top: 0;
@@ -865,8 +895,8 @@ ul[data-type='taskList'] li[data-checked='true'] > div > p > span {
}
& > p {
- margin-top: 1.5rem;
- margin-bottom: 1.5rem;
+ margin-top: 0.375rem;
+ margin-bottom: 0.375rem;
&:first-child {
margin-top: 0;
@@ -878,13 +908,51 @@ ul[data-type='taskList'] li[data-checked='true'] > div > p > span {
}
& > * + * {
- margin-top: 1rem;
- margin-bottom: 1rem;
+ margin-top: 0.375rem;
+ margin-bottom: 0.375rem;
}
ol,
ul {
- font-size: 1.5rem;
+ font-size: calc(var(--slide-font-scale, 1) * 1.0625rem);
+ line-height: 1.5;
+ }
+
+ /* Keep list text on the same line as its marker; see the matching rule in
+ the fullscreen block. */
+ li > p {
+ margin: 0;
+ }
+
+ /*
+ * A native ::marker takes its size from the
, while the text takes
+ * its size from the mark or paragraph inside it — so an item with an
+ * explicit font size got a marker that no longer matched its own text,
+ * increasingly visibly as the presenter font scale went up.
+ *
+ * The size is copied onto the list item itself when slides are built (see
+ * matchListMarkersToText), which the schema now carries, so these rules
+ * can stay as plain native markers.
+ *
+ * They live in the shared .ProseMirror block and so apply in fullscreen
+ * too; the fullscreen block only adjusts spacing.
+ */
+ ul:not([data-type='taskList']) {
+ list-style-type: disc;
+ padding-left: 2rem;
+
+ li {
+ list-style-type: inherit;
+ }
+ }
+
+ ol {
+ list-style-type: decimal;
+ padding-left: 2rem;
+
+ li {
+ display: list-item;
+ }
}
ul[data-type='taskList'],
@@ -1080,6 +1148,16 @@ ul[data-type='taskList'] li[data-checked='true'] > div > p > span {
max-width: 100vw;
align-items: start;
+ /* Slide content is rendered by the editor, so it arrives wrapped in a
+ single .ProseMirror element rather than as loose blocks. As a lone flex
+ item under `align-items: start` it would shrink to its content width,
+ collapsing column grids to one character per line and taking
+ full-width images down with them. */
+ > .ProseMirror {
+ width: 100%;
+ align-self: stretch;
+ }
+
@media (max-width: 640px) {
padding-top: max(env(safe-area-inset-top), 15vh);
touch-action: pan-y pinch-zoom;
@@ -1088,26 +1166,34 @@ ul[data-type='taskList'] li[data-checked='true'] > div > p > span {
}
h1 {
- font-size: min(5vw, 64px);
+ font-size: calc(var(--slide-font-scale, 1) * min(5vw, 64px));
line-height: 1.2;
margin: 0 0 2vh;
font-weight: 700;
}
h2 {
- font-size: min(3vw, 48px);
+ font-size: calc(var(--slide-font-scale, 1) * min(3vw, 48px));
line-height: 1.2;
margin: 0 0 2vh;
font-weight: 700;
}
h3 {
- font-size: min(2vw, 32px);
+ font-size: calc(var(--slide-font-scale, 1) * min(2vw, 32px));
line-height: 1.2;
margin: 0 0 2vh;
font-weight: 700;
}
+ /* A list item wraps its text in a paragraph, which would otherwise take
+ the block-level `p` margin. The marker stays anchored to the top of the
+ item, so that margin drops the text below its own bullet — visibly so
+ here, where the margin is viewport-relative. */
+ li > p {
+ margin: 0;
+ }
+
blockquote {
padding-left: 1rem;
font-style: italic;
@@ -1127,7 +1213,7 @@ ul[data-type='taskList'] li[data-checked='true'] > div > p > span {
p,
ul,
ol {
- font-size: min(2vw, 30.72px);
+ font-size: calc(var(--slide-font-scale, 1) * min(2vw, 30.72px));
line-height: 1.5;
margin: 1vh 0;
max-width: 80vw;
@@ -1142,39 +1228,31 @@ ul[data-type='taskList'] li[data-checked='true'] > div > p > span {
margin-top: 1rem;
}
+ /* Markers are drawn by the shared .ProseMirror rules so they inherit each
+ item's own font size; this block only sets spacing. `data-tight`
+ describes spacing, not nesting depth — it previously switched the marker
+ to a hollow circle, which made a top-level tight list read as a nested
+ one and disagreed with the windowed view. */
ul:not([data-type='taskList']) {
- list-style-type: disc;
- padding-left: 2rem;
margin: 1vh 0;
&[data-tight='true'] {
margin: 0;
- list-style-type: circle;
> li {
margin: 0;
padding: 0;
- list-style-type: circle !important;
}
}
li {
- list-style-type: inherit;
margin: 0.5vh 0;
}
}
- ol {
- list-style-type: decimal;
- padding-left: 2rem;
- li {
- display: list-item;
- }
- }
-
.task-list-item {
list-style-type: none;
margin: 0.5rem 0;
- font-size: min(2vw, 30.72px);
+ font-size: calc(var(--slide-font-scale, 1) * min(2vw, 30.72px));
line-height: 1.5;
input[type='checkbox'] {
@@ -1212,14 +1290,14 @@ ul[data-type='taskList'] li[data-checked='true'] > div > p > span {
padding: 1rem;
code {
- font-size: min(1.5vw, 23.04px);
+ font-size: calc(var(--slide-font-scale, 1) * min(1.5vw, 23.04px));
line-height: 1.5;
background: transparent !important;
}
}
code {
- font-size: min(1.5vw, 18px);
+ font-size: calc(var(--slide-font-scale, 1) * min(1.5vw, 18px));
background: hsla(var(--color-bg-tertiary));
padding: 2px 6px;
border-radius: 4px;
@@ -1247,7 +1325,7 @@ ul[data-type='taskList'] li[data-checked='true'] > div > p > span {
td {
padding: 12px;
border: 1px solid hsla(var(--color-border-default));
- font-size: min(1.5vw, 23.04px) !important;
+ font-size: calc(var(--slide-font-scale, 1) * min(1.5vw, 23.04px)) !important;
}
th {
@@ -1260,19 +1338,19 @@ ul[data-type='taskList'] li[data-checked='true'] > div > p > span {
padding: 5vh 5vw;
h1 {
- font-size: min(8vw, 42px);
+ font-size: calc(var(--slide-font-scale, 1) * min(8vw, 42px));
}
h2 {
- font-size: min(6vw, 32px);
+ font-size: calc(var(--slide-font-scale, 1) * min(6vw, 32px));
}
h3 {
- font-size: min(5vw, 24px);
+ font-size: calc(var(--slide-font-scale, 1) * min(5vw, 24px));
}
p,
ul,
ol {
- font-size: min(4vw, 18px);
+ font-size: calc(var(--slide-font-scale, 1) * min(4vw, 18px));
max-width: 90vw;
}
@@ -1287,13 +1365,13 @@ ul[data-type='taskList'] li[data-checked='true'] > div > p > span {
th,
td {
padding: 8px;
- font-size: min(3.5vw, 16px);
+ font-size: calc(var(--slide-font-scale, 1) * min(3.5vw, 16px));
}
}
pre code,
code {
- font-size: min(3.5vw, 16px);
+ font-size: calc(var(--slide-font-scale, 1) * min(3.5vw, 16px));
}
}
}
diff --git a/package/utils/doc-to-slides.test.ts b/package/utils/doc-to-slides.test.ts
new file mode 100644
index 00000000..8fd7252c
--- /dev/null
+++ b/package/utils/doc-to-slides.test.ts
@@ -0,0 +1,414 @@
+import { describe, it, expect, beforeEach, afterEach } from 'vitest';
+import { Editor } from '@tiptap/react';
+import { JSONContent } from '@tiptap/core';
+import {
+ splitDocIntoSlides,
+ isSoloMediaSlide,
+ scaleInlineFontSizes,
+ matchListMarkersToText,
+} from './doc-to-slides';
+// Same extension assembly the headless editor uses, so custom nodes
+// (dBlock, columns, pageBreak) are registered and the documents below are
+// validated against the real schema rather than hand-rolled JSON.
+import { getHeadlessExtensions } from '../hooks/use-headless-editor';
+
+/** Collect all text on a slide, for order-independent content assertions. */
+const slideText = (slide: JSONContent): string => {
+ const walk = (node?: JSONContent): string => {
+ if (!node) return '';
+ if (node.type === 'text') return node.text ?? '';
+ return (node.content ?? []).map(walk).join(' ');
+ };
+ return walk(slide).replace(/\s+/g, ' ').trim();
+};
+
+/** Depth-first search for a node type anywhere in a slide. */
+const hasNodeType = (slide: JSONContent, type: string): boolean => {
+ const walk = (node?: JSONContent): boolean => {
+ if (!node) return false;
+ if (node.type === type) return true;
+ return (node.content ?? []).some(walk);
+ };
+ return walk(slide);
+};
+
+describe('splitDocIntoSlides', () => {
+ let editor: Editor;
+
+ beforeEach(() => {
+ editor = new Editor({ extensions: getHeadlessExtensions() });
+ });
+
+ afterEach(() => {
+ editor.destroy();
+ });
+
+ /** Round-trips content through the editor so it conforms to the schema. */
+ const docFrom = (content: string | JSONContent): JSONContent => {
+ editor.commands.setContent(content);
+ return editor.getJSON();
+ };
+
+ // Titles used to be stranded on a slide of their own even when the content
+ // after them plainly fitted alongside.
+ it('keeps a heading together with the content that follows it', () => {
+ const slides = splitDocIntoSlides(
+ docFrom('
Title
Body copy
'),
+ );
+
+ expect(slides).toHaveLength(1);
+ expect(slideText(slides[0])).toBe('Title Body copy');
+ });
+
+ it('starts a new slide at a heading', () => {
+ const slides = splitDocIntoSlides(
+ docFrom('
`,
+ ).join('');
+
+ const slides = splitDocIntoSlides(docFrom(paragraphs), {
+ applyOverflowLimits: false,
+ });
+
+ // No structural breaks in the document, so it stays a single slide for
+ // the measurement pass to divide against the real stage.
+ expect(slides).toHaveLength(1);
+ });
+
+ it('starts a new slide at each H2 and keeps the heading on it', () => {
+ const slides = splitDocIntoSlides(
+ docFrom('
One
First
Two
Second
'),
+ );
+
+ expect(slides).toHaveLength(2);
+ expect(slideText(slides[0])).toBe('One First');
+ expect(slideText(slides[1])).toBe('Two Second');
+ });
+
+ it('breaks on an explicit page break without rendering the break itself', () => {
+ const slides = splitDocIntoSlides(
+ docFrom(
+ '
Before
After
',
+ ),
+ );
+
+ expect(slides).toHaveLength(2);
+ expect(slideText(slides[0])).toBe('Before');
+ expect(slideText(slides[1])).toBe('After');
+ expect(slides.some((slide) => hasNodeType(slide, 'pageBreak'))).toBe(false);
+ });
+
+ it('breaks a long run of paragraphs once it overflows the slide', () => {
+ const paragraphs = Array.from(
+ { length: 12 },
+ (_, i) => `
Paragraph number ${i}
`,
+ ).join('');
+
+ const slides = splitDocIntoSlides(docFrom(paragraphs), {
+ maxLinesPerSlide: 4,
+ });
+
+ expect(slides.length).toBeGreaterThan(1);
+ // Nothing may be dropped on the way to the stage.
+ const allText = slides.map(slideText).join(' ');
+ for (let i = 0; i < 12; i++) {
+ expect(allText).toContain(`Paragraph number ${i}`);
+ }
+ });
+
+ it('never emits an empty slide', () => {
+ const slides = splitDocIntoSlides(
+ docFrom(
+ '
Only
',
+ ),
+ );
+
+ expect(slides).toHaveLength(1);
+ expect(slideText(slides[0])).toBe('Only');
+ });
+
+ // The reason this module exists: the Markdown pipeline flattens a columns
+ // block into sequential paragraphs, which is what makes "image left, text
+ // right" impossible on a slide today.
+ it('preserves a multi-column block instead of flattening it', () => {
+ const doc = docFrom({
+ type: 'doc',
+ content: [
+ {
+ type: 'dBlock',
+ content: [
+ {
+ type: 'columns',
+ content: [
+ {
+ type: 'column',
+ content: [
+ {
+ type: 'dBlock',
+ content: [
+ {
+ type: 'paragraph',
+ content: [{ type: 'text', text: 'Left side' }],
+ },
+ ],
+ },
+ ],
+ },
+ {
+ type: 'column',
+ content: [
+ {
+ type: 'dBlock',
+ content: [
+ {
+ type: 'paragraph',
+ content: [{ type: 'text', text: 'Right side' }],
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ });
+
+ // Guard: if the schema rejected the structure the assertion below would
+ // pass vacuously, so confirm the source document really has columns.
+ expect(hasNodeType(doc, 'columns')).toBe(true);
+
+ const slides = splitDocIntoSlides(doc);
+
+ expect(slides).toHaveLength(1);
+ expect(hasNodeType(slides[0], 'columns')).toBe(true);
+ expect(hasNodeType(slides[0], 'column')).toBe(true);
+ expect(slideText(slides[0])).toContain('Left side');
+ expect(slideText(slides[0])).toContain('Right side');
+ });
+
+ it('measures a columns block by its tallest column, not the sum', () => {
+ const column = (lines: number) => ({
+ type: 'column',
+ content: Array.from({ length: lines }, (_, i) => ({
+ type: 'dBlock',
+ content: [
+ {
+ type: 'paragraph',
+ content: [{ type: 'text', text: `line ${i}` }],
+ },
+ ],
+ })),
+ });
+
+ const doc = docFrom({
+ type: 'doc',
+ content: [
+ {
+ type: 'dBlock',
+ content: [{ type: 'columns', content: [column(3), column(3)] }],
+ },
+ ],
+ });
+
+ // Six paragraphs total but only three lines tall, so it fits a 4-line
+ // slide. Summing the columns would wrongly split it.
+ const slides = splitDocIntoSlides(doc, { maxLinesPerSlide: 4 });
+ expect(slides).toHaveLength(1);
+ });
+});
+
+describe('matchListMarkersToText', () => {
+ const listItem = (text: JSONContent): JSONContent => ({
+ type: 'listItem',
+ content: [{ type: 'paragraph', content: [text] }],
+ });
+
+ // A native marker is sized by its
');
+ expect(scaleInlineFontSizes(once)).toBe(once);
+ });
+
+ it('leaves markup without an inline size untouched', () => {
+ const html = '
plain
item
';
+ expect(scaleInlineFontSizes(html)).toBe(html);
+ });
+});
+
+describe('isSoloMediaSlide', () => {
+ it('is false for a slide carrying text alongside media', () => {
+ const slide: JSONContent = {
+ type: 'doc',
+ content: [
+ { type: 'dBlock', content: [{ type: 'resizableMedia', attrs: {} }] },
+ {
+ type: 'dBlock',
+ content: [
+ { type: 'paragraph', content: [{ type: 'text', text: 'caption' }] },
+ ],
+ },
+ ],
+ };
+
+ expect(isSoloMediaSlide(slide)).toBe(false);
+ });
+
+ it('is true for a slide holding only media', () => {
+ const slide: JSONContent = {
+ type: 'doc',
+ content: [
+ { type: 'dBlock', content: [{ type: 'resizableMedia', attrs: {} }] },
+ ],
+ };
+
+ expect(isSoloMediaSlide(slide)).toBe(true);
+ });
+});
diff --git a/package/utils/doc-to-slides.ts b/package/utils/doc-to-slides.ts
new file mode 100644
index 00000000..c2c7545c
--- /dev/null
+++ b/package/utils/doc-to-slides.ts
@@ -0,0 +1,656 @@
+import { JSONContent } from '@tiptap/core';
+import { Editor } from '@tiptap/react';
+import { searchForSecureImageNodeAndEmbedImageContent } from '../extensions/mardown-paste-handler';
+import { IpfsImageFetchPayload } from '../types';
+import { dedupeResolvedExtensions } from './helpers';
+
+/**
+ * Splits a ProseMirror document straight into per-slide documents.
+ *
+ * The existing presentation pipeline goes doc -> HTML -> Markdown -> HTML,
+ * which silently drops every construct Markdown cannot express: multi-column
+ * blocks, paragraph-level font sizes, callouts and other custom nodes. This
+ * module walks the document nodes instead, so a slide is always a real
+ * ProseMirror doc and nothing is lost on the way to the stage.
+ */
+
+export interface DocToSlidesOptions {
+ /** Soft cap on rendered lines before a slide is broken. */
+ maxLinesPerSlide?: number;
+ /** Soft cap on characters before a slide is broken. */
+ maxCharsPerSlide?: number;
+ /** Soft cap on words before a slide is broken. */
+ maxWordsPerSlide?: number;
+ /** Characters that fit on one rendered line, used to estimate wrapping. */
+ charsPerLine?: number;
+ /**
+ * Whether to guess at overflow from character and line counts. Disabled when
+ * the deck will afterwards be measured against the real stage, since counting
+ * characters splits slides that visibly had room to spare.
+ */
+ applyOverflowLimits?: boolean;
+}
+
+/** The slide stage: 1080px wide, 16/9, with `py-[48px]` above and below. */
+const STAGE_WIDTH_PX = 1080;
+const STAGE_HEIGHT_PX = Math.round((STAGE_WIDTH_PX * 9) / 16);
+const STAGE_VERTICAL_PADDING_PX = 96;
+const STAGE_CONTENT_HEIGHT_PX = STAGE_HEIGHT_PX - STAGE_VERTICAL_PADDING_PX;
+
+export const SLIDE_SPLIT_DEFAULTS: Required = {
+ maxLinesPerSlide: 7,
+ maxCharsPerSlide: 1000,
+ maxWordsPerSlide: 250,
+ charsPerLine: 60,
+ applyOverflowLimits: true,
+};
+
+/** Top-level nodes are wrapped in dBlock; unwrap to the node that matters. */
+const getInnerNode = (node: JSONContent): JSONContent =>
+ node?.type === 'dBlock' && node.content?.length ? node.content[0] : node;
+
+const getNodeText = (node?: JSONContent): string => {
+ if (!node) return '';
+ if (node.type === 'text') return node.text ?? '';
+ if (!node.content?.length) return '';
+ return node.content.map(getNodeText).join('');
+};
+
+const countWords = (text: string): number =>
+ text.trim().split(/\s+/).filter(Boolean).length;
+
+const MEDIA_TYPES = new Set([
+ 'resizableMedia',
+ 'image',
+ 'secureImage',
+ 'iframe',
+ 'twitterEmbed',
+]);
+
+const LIST_TYPES = new Set(['bulletList', 'orderedList', 'taskList']);
+
+const isHeading = (node: JSONContent, level: number): boolean =>
+ node.type === 'heading' && node.attrs?.level === level;
+
+const isMedia = (node: JSONContent): boolean =>
+ MEDIA_TYPES.has(node.type ?? '');
+
+/**
+ * Nodes worth a slide even with no text of their own. Everything else that is
+ * textless is padding — the trailing-node extension keeps an empty paragraph
+ * at the end of every document, which must not become a blank final slide.
+ */
+const RENDERS_WITHOUT_TEXT = new Set([
+ ...MEDIA_TYPES,
+ 'table',
+ 'horizontalRule',
+ 'codeBlock',
+]);
+
+const hasRenderableContent = (node?: JSONContent): boolean => {
+ if (!node) return false;
+ if (RENDERS_WITHOUT_TEXT.has(node.type ?? '')) return true;
+ if (node.type === 'text' && (node.text ?? '').trim().length > 0) return true;
+ return (node.content ?? []).some(hasRenderableContent);
+};
+
+/**
+ * Rough height of a node in "lines". Headings and media are weighted heavier
+ * because the presentation stylesheet renders them much larger than body text.
+ */
+const estimateLines = (node: JSONContent, charsPerLine: number): number => {
+ const inner = getInnerNode(node);
+
+ switch (inner.type) {
+ case 'heading':
+ return inner.attrs?.level === 1 ? 3 : inner.attrs?.level === 2 ? 2 : 1;
+
+ case 'table':
+ return (inner.content?.length ?? 1) + 1;
+
+ case 'codeBlock':
+ return Math.max(1, getNodeText(inner).split('\n').length);
+
+ case 'columns':
+ // Columns sit side by side, so the block is only as tall as its
+ // tallest column rather than the sum of all of them.
+ return Math.max(
+ 1,
+ ...(inner.content ?? []).map((column) =>
+ (column.content ?? []).reduce(
+ (sum, child) => sum + estimateLines(child, charsPerLine),
+ 0,
+ ),
+ ),
+ );
+
+ default:
+ break;
+ }
+
+ if (isMedia(inner)) return 4;
+
+ if (LIST_TYPES.has(inner.type ?? '')) {
+ return Math.max(1, inner.content?.length ?? 1);
+ }
+
+ const text = getNodeText(inner);
+ return Math.max(1, Math.ceil(text.length / charsPerLine));
+};
+
+interface SlideAccumulator {
+ blocks: JSONContent[];
+ lines: number;
+ chars: number;
+ words: number;
+}
+
+const emptyAccumulator = (): SlideAccumulator => ({
+ blocks: [],
+ lines: 0,
+ chars: 0,
+ words: 0,
+});
+
+const toSlideDoc = (blocks: JSONContent[]): JSONContent => ({
+ type: 'doc',
+ content: blocks,
+});
+
+/**
+ * A slide holding nothing but a single media node is rendered edge to edge
+ * rather than as body content, matching the previous `solo-slide-image`
+ * behaviour of the Markdown pipeline.
+ */
+export const isSoloMediaSlide = (slide: JSONContent): boolean => {
+ const blocks = slide.content ?? [];
+ if (blocks.length !== 1) return false;
+ return isMedia(getInnerNode(blocks[0]));
+};
+
+export const splitDocIntoSlides = (
+ doc: JSONContent,
+ options: DocToSlidesOptions = {},
+): JSONContent[] => {
+ const {
+ maxLinesPerSlide,
+ maxCharsPerSlide,
+ maxWordsPerSlide,
+ charsPerLine,
+ applyOverflowLimits,
+ } = { ...SLIDE_SPLIT_DEFAULTS, ...options };
+
+ const slides: JSONContent[] = [];
+ let current = emptyAccumulator();
+
+ const flush = () => {
+ if (current.blocks.length > 0) {
+ slides.push(toSlideDoc(current.blocks));
+ }
+ current = emptyAccumulator();
+ };
+
+ const push = (block: JSONContent) => {
+ const inner = getInnerNode(block);
+ const text = getNodeText(inner);
+ current.blocks.push(block);
+ current.lines += estimateLines(block, charsPerLine);
+ current.chars += text.length;
+ current.words += countWords(text);
+ };
+
+ const overflows = (block: JSONContent): boolean => {
+ if (current.blocks.length === 0) return false;
+ const inner = getInnerNode(block);
+ const text = getNodeText(inner);
+ return (
+ current.lines + estimateLines(block, charsPerLine) > maxLinesPerSlide ||
+ current.chars + text.length > maxCharsPerSlide ||
+ current.words + countWords(text) > maxWordsPerSlide
+ );
+ };
+
+ (doc.content ?? []).forEach((block) => {
+ const inner = getInnerNode(block);
+
+ // Explicit author-controlled break; the node itself is not rendered.
+ if (inner.type === 'pageBreak') {
+ flush();
+ return;
+ }
+
+ // A heading opens a new slide and sits at the top of it. Whatever follows
+ // packs in underneath for as long as there is room, so a title and its
+ // content stay together instead of the title being stranded alone.
+ if (isHeading(inner, 1) || isHeading(inner, 2)) {
+ flush();
+ push(block);
+ return;
+ }
+
+ // Without measurement the only way to keep a full-bleed image slide from
+ // absorbing the text around it is to promote it eagerly. When the deck is
+ // measured afterwards, real overflow decides instead.
+ if (applyOverflowLimits && isMedia(inner) && current.blocks.length === 0) {
+ slides.push(toSlideDoc([block]));
+ return;
+ }
+
+ if (applyOverflowLimits && overflows(block)) flush();
+ push(block);
+ });
+
+ flush();
+
+ return slides.filter(hasRenderableContent);
+};
+
+/**
+ * Whether the environment performs layout. jsdom parses markup but reports
+ * every height as 0, so measurement has to fall back to the estimates there.
+ */
+const canMeasureLayout = (): boolean => {
+ if (typeof document === 'undefined' || !document.body) return false;
+
+ const probe = document.createElement('div');
+ probe.style.cssText = 'position:absolute;left:-99999px;top:0;width:100px;';
+ probe.innerHTML = '
probe
';
+ document.body.appendChild(probe);
+
+ const measurable = probe.scrollHeight > 0;
+ probe.remove();
+
+ return measurable;
+};
+
+/**
+ * Hidden stand-in for the slide stage, styled identically so measurements
+ * reflect what the presentation will actually render.
+ */
+const createStageMeasurementHost = (fontScale: number) => {
+ const host = document.createElement('div');
+ host.className = 'presentation-mode';
+ host.setAttribute('aria-hidden', 'true');
+ host.style.cssText = `
+ position: absolute;
+ left: -99999px;
+ top: 0;
+ width: ${STAGE_WIDTH_PX}px;
+ visibility: hidden;
+ pointer-events: none;
+ `;
+ host.style.setProperty('--slide-font-scale', String(fontScale));
+
+ const content = document.createElement('div');
+ content.className = 'ProseMirror';
+ host.appendChild(content);
+ document.body.appendChild(host);
+
+ return { host, content };
+};
+
+/**
+ * Descends through single-child wrappers to the element whose children can
+ * actually be divided. A slide holding one list arrives as
+ * `div > ul > li…`, so the list items are the only useful break points.
+ */
+/**
+ * Breaking these apart would destroy the layout rather than paginate it: the
+ * two halves of a side-by-side block belong on the same slide, and a table
+ * split mid-way loses its header row.
+ */
+const NEVER_DIVIDE_SELECTOR =
+ '[data-type="columns"], [data-type="column"], table';
+
+/** Only list items are safe to paginate between. */
+const DIVISIBLE_TAGS = new Set(['UL', 'OL']);
+
+const findDivisibleElement = (root: Element): Element | null => {
+ let node: Element | null = root;
+
+ while (node) {
+ if (node.matches(NEVER_DIVIDE_SELECTOR)) return null;
+
+ if (DIVISIBLE_TAGS.has(node.tagName) && node.children.length > 1) {
+ return node;
+ }
+
+ if (node.children.length !== 1) return null;
+
+ node = node.firstElementChild;
+ }
+
+ return null;
+};
+
+/** A block whose only real content is a heading. */
+const isHeadingBlock = (element: Element): boolean =>
+ /^H[1-6]$/.test(element.tagName) ||
+ !!element.querySelector('h1, h2, h3, h4, h5, h6');
+
+/** Rebuilds a block keeping only children in `[from, to)`. */
+const withChildRange = (html: string, from: number, to: number): string => {
+ const wrapper = document.createElement('div');
+ wrapper.innerHTML = html;
+
+ const root = wrapper.firstElementChild;
+ if (!root) return html;
+
+ const target = findDivisibleElement(root);
+ if (!target) return html;
+
+ Array.from(target.children).forEach((child, index) => {
+ if (index < from || index >= to) child.remove();
+ });
+
+ // Keep numbering continuous when an ordered list spans slides.
+ if (target.tagName === 'OL' && from > 0) {
+ const start = Number(target.getAttribute('start') ?? '1');
+ target.setAttribute('start', String(start + from));
+ }
+
+ return wrapper.innerHTML;
+};
+
+/**
+ * Divides one oversized block — typically a long list — by breaking between
+ * its children rather than letting it run off the slide.
+ *
+ * `precedingHtml` is whatever already sits on the slide, so the split accounts
+ * for the space a heading above it has already used.
+ */
+const divideToFit = (
+ html: string,
+ precedingHtml: string,
+ heightOf: (html: string) => number,
+): { head: string; tail: string } | null => {
+ const wrapper = document.createElement('div');
+ wrapper.innerHTML = html;
+
+ const root = wrapper.firstElementChild;
+ if (!root) return null;
+
+ const target = findDivisibleElement(root);
+ if (!target) return null;
+
+ const total = target.children.length;
+ if (total <= 1) return null;
+
+ let take = total - 1;
+ while (
+ take >= 1 &&
+ heightOf(precedingHtml + withChildRange(html, 0, take)) >
+ STAGE_CONTENT_HEIGHT_PX
+ ) {
+ take--;
+ }
+
+ // Not even one child fits alongside what is already there.
+ if (take < 1) return null;
+
+ return {
+ head: withChildRange(html, 0, take),
+ tail: withChildRange(html, take, total),
+ };
+};
+
+/**
+ * Breaks slides that genuinely overflow the stage, and only those.
+ *
+ * Character and line counts are a poor proxy for height: they split slides
+ * that plainly had room left. Measuring the rendered result means a title and
+ * its content stay on one slide whenever they actually fit.
+ */
+export const fitSlidesToStage = (
+ slides: string[],
+ fontScale: number = 1,
+): string[] => {
+ if (slides.length === 0 || !canMeasureLayout()) return slides;
+
+ const { host, content } = createStageMeasurementHost(fontScale);
+
+ const heightOf = (html: string): number => {
+ content.innerHTML = html;
+ return content.scrollHeight;
+ };
+
+ const htmlOf = (elements: Element[]): string =>
+ elements.map((element) => element.outerHTML).join('');
+
+ try {
+ const fitted: string[] = [];
+ const pending = [...slides];
+
+ while (pending.length > 0) {
+ const slide = pending.shift() as string;
+
+ const measuredHeight = heightOf(slide);
+
+ if (measuredHeight <= STAGE_CONTENT_HEIGHT_PX) {
+ fitted.push(slide);
+ continue;
+ }
+
+ const container = document.createElement('div');
+ container.innerHTML = slide;
+ const blocks = Array.from(container.children);
+
+ // One oversized block, typically a long list: break between its
+ // children instead of letting it run off the slide.
+ if (blocks.length <= 1) {
+ const divided = divideToFit(slide, '', heightOf);
+
+ if (divided) {
+ fitted.push(divided.head);
+ pending.unshift(divided.tail);
+ } else {
+ // Genuinely indivisible — a single paragraph or image that is
+ // simply taller than the stage.
+ fitted.push(slide);
+ }
+
+ continue;
+ }
+
+ // Largest run of whole blocks that still fits.
+ let fitCount = blocks.length - 1;
+ while (
+ fitCount > 0 &&
+ heightOf(htmlOf(blocks.slice(0, fitCount))) > STAGE_CONTENT_HEIGHT_PX
+ ) {
+ fitCount--;
+ }
+
+ const headHtml = htmlOf(blocks.slice(0, fitCount));
+ const nextBlock = blocks[fitCount];
+
+ // Whole blocks alone would strand a heading on a slide of its own with
+ // its content pushed to the next one. Carry as much of the following
+ // block as the remaining space allows.
+ const carried = nextBlock
+ ? divideToFit(nextBlock.outerHTML, headHtml, heightOf)
+ : null;
+
+ if (carried) {
+ fitted.push(headHtml + carried.head);
+ pending.unshift(carried.tail + htmlOf(blocks.slice(fitCount + 1)));
+ continue;
+ }
+
+ // The next block cannot be divided — a columns layout, a table, an
+ // image. If everything that fits so far is just headings, keep them with
+ // that block and accept the overflow: a title alone on a slide with its
+ // content on the next one is a worse outcome than a slide that runs a
+ // little long.
+ if (nextBlock && blocks.slice(0, fitCount).every(isHeadingBlock)) {
+ fitted.push(htmlOf(blocks.slice(0, fitCount + 1)));
+ pending.unshift(htmlOf(blocks.slice(fitCount + 1)));
+ continue;
+ }
+
+ // Otherwise emit at least one whole block so the remainder always
+ // shrinks and the loop terminates.
+ const emitCount = Math.max(fitCount, 1);
+ fitted.push(htmlOf(blocks.slice(0, emitCount)));
+ pending.unshift(htmlOf(blocks.slice(emitCount)));
+ }
+
+ return fitted;
+ } finally {
+ host.remove();
+ }
+};
+
+export interface BuildSlidesOptions extends DocToSlidesOptions {
+ /** Current presenter font scale, so measurement matches what is on screen. */
+ fontScale?: number;
+ ipfsImageFetchFn?: (
+ _data: IpfsImageFetchPayload,
+ ) => Promise<{ url: string; file: File }>;
+ fetchV1ImageFn?: (url: string) => Promise;
+}
+
+/**
+ * The font size in effect for a node's first run of text.
+ *
+ * Sizes arrive two ways: as a `textStyle` mark on the text itself, and as an
+ * attribute on the paragraph. Both are checked, nearest first.
+ */
+const firstFontSize = (node?: JSONContent): string | null => {
+ if (!node) return null;
+
+ const markSize = node.marks?.find(
+ (mark) => mark.type === 'textStyle' && mark.attrs?.fontSize,
+ )?.attrs?.fontSize;
+ if (markSize) return String(markSize);
+
+ if (node.attrs?.fontSize) return String(node.attrs.fontSize);
+
+ for (const child of node.content ?? []) {
+ const found = firstFontSize(child);
+ if (found) return found;
+ }
+
+ return null;
+};
+
+/**
+ * Copies each list item's own text size onto the item.
+ *
+ * A native list marker is sized by its `
`, but an explicit size lives on
+ * the mark or paragraph inside it. The marker therefore kept the base size
+ * while its text grew, and the mismatch widened as the presenter font scale
+ * went up. Giving the item the same size lets the marker follow its text.
+ */
+export const matchListMarkersToText = (node: JSONContent): JSONContent => {
+ const content = node.content?.map(matchListMarkersToText);
+
+ if (node.type !== 'listItem') {
+ return content ? { ...node, content } : node;
+ }
+
+ const fontSize = firstFontSize(node);
+
+ return {
+ ...node,
+ ...(content ? { content } : {}),
+ ...(fontSize ? { attrs: { ...node.attrs, fontSize } } : {}),
+ };
+};
+
+/**
+ * Makes explicitly-sized text obey the presenter font scale.
+ *
+ * An inline `font-size` beats any stylesheet rule, so text carrying its own
+ * size ignored the scale entirely while everything around it grew and shrank.
+ * Rewriting the value into the same multiplication the stylesheet uses puts
+ * both under one control.
+ *
+ * Values already expressed as `calc(...)` are left alone: the pattern requires
+ * a digit after the colon, so it cannot wrap its own output twice.
+ */
+export const scaleInlineFontSizes = (html: string): string =>
+ html.replace(
+ /font-size:\s*(-?[\d.]+)(px|rem|em|pt)/gi,
+ (_match, value, unit) =>
+ `font-size: calc(var(--slide-font-scale, 1) * ${value}${unit})`,
+ );
+
+/**
+ * Serialises slide documents back to HTML through the editor's own schema.
+ *
+ * Round-tripping via renderHTML/parseHTML is lossless by construction, so
+ * columns, font sizes and other custom nodes survive — unlike the Markdown
+ * detour this replaces. Slides stay `string[]`, which keeps the preview panel,
+ * PDF export and share links working unchanged.
+ */
+const renderSlideDocsToHtml = (
+ editor: Editor,
+ slideDocs: JSONContent[],
+): string[] => {
+ const temporaryEditor = new Editor({
+ extensions: dedupeResolvedExtensions(
+ editor.extensionManager.extensions,
+ ).filter(
+ (extension) =>
+ ![
+ 'collaboration',
+ 'aiAutocomplete',
+ // suggestionTracking's filterTransaction rejects every doc-changing
+ // transaction while the source editor is in suggestion mode, which
+ // would silently leave each slide empty.
+ 'suggestionTracking',
+ ].includes(extension.name),
+ ),
+ });
+
+ try {
+ return slideDocs.map((slideDoc) => {
+ temporaryEditor.commands.setContent(matchListMarkersToText(slideDoc));
+ return scaleInlineFontSizes(temporaryEditor.getHTML());
+ });
+ } finally {
+ temporaryEditor.destroy();
+ }
+};
+
+/**
+ * Builds the presentation deck straight from the editor document.
+ *
+ * Secure images are still inlined first, matching the behaviour of the
+ * Markdown pipeline this supersedes, so IPFS-backed images render on a slide.
+ */
+export const buildSlidesFromDoc = async (
+ editor: Editor,
+ options: BuildSlidesOptions = {},
+): Promise => {
+ const {
+ ipfsImageFetchFn,
+ fetchV1ImageFn,
+ fontScale = 1,
+ ...splitOptions
+ } = options;
+
+ const docWithEmbeddedImages =
+ await searchForSecureImageNodeAndEmbedImageContent(
+ editor.state.doc,
+ ipfsImageFetchFn,
+ fetchV1ImageFn,
+ true,
+ );
+
+ // When the stage can be measured, structural breaks are the only ones worth
+ // guessing at — real overflow decides the rest.
+ const measurable = canMeasureLayout();
+
+ const slideDocs = splitDocIntoSlides(docWithEmbeddedImages.toJSON(), {
+ applyOverflowLimits: !measurable,
+ ...splitOptions,
+ });
+
+ if (slideDocs.length === 0) return [];
+
+ const slidesHtml = renderSlideDocsToHtml(editor, slideDocs);
+
+ return measurable ? fitSlidesToStage(slidesHtml, fontScale) : slidesHtml;
+};