diff --git a/package/components/presentation-mode/presentation-mode.tsx b/package/components/presentation-mode/presentation-mode.tsx index 9647730b..2dda6621 100644 --- a/package/components/presentation-mode/presentation-mode.tsx +++ b/package/components/presentation-mode/presentation-mode.tsx @@ -1,4 +1,11 @@ -import { useEffect, useState, useCallback, useMemo } from 'react'; +import { + useEffect, + useState, + useCallback, + useMemo, + useRef, + CSSProperties, +} from 'react'; import { Editor, EditorContent } from '@tiptap/react'; import { AnimatedLoader, @@ -8,13 +15,12 @@ import { Tooltip, } from '@fileverse/ui'; import { EditingProvider } from '../../hooks/use-editing-context'; -import { convertToMarkdown } from '../../utils/md-to-slides'; +import { buildSlidesFromDoc } from '../../utils/doc-to-slides'; import { handlePrint } from '../../utils/handle-print'; import { PreviewPanel } from './preview-panel'; import { cn } from '@fileverse/ui'; -import { motion, AnimatePresence } from 'framer-motion'; +import { motion, useAnimationControls } from 'framer-motion'; import copy from 'copy-to-clipboard'; -import { convertMarkdownToHTML } from '../../utils/md-to-html'; import { useResponsive } from '../../utils/responsive'; import { IpfsImageFetchPayload, DdocProps, ThemeKey } from '../../types'; import { dedupeResolvedExtensions } from '../../utils/helpers'; @@ -48,6 +54,43 @@ interface PresentationModeProps { theme?: ThemeKey; } +/** + * Font scaling multiplies the presentation stylesheet's sizes via the + * `--slide-font-scale` custom property, so every element keeps its relative + * proportions instead of each one needing its own override. + */ +const FONT_SCALE_MIN = 0.6; +const FONT_SCALE_MAX = 1.8; +const FONT_SCALE_STEP = 0.1; + +const clampFontScale = (scale: number) => + Math.min(FONT_SCALE_MAX, Math.max(FONT_SCALE_MIN, Number(scale.toFixed(2)))); + +const SlideNumber = ({ + current, + total, + isFullscreen, +}: { + current: number; + total: number; + isFullscreen: boolean; +}) => { + if (!total) return null; + + return ( +
+ {current} / {total} +
+ ); +}; + const SlideContent = ({ content, editor, @@ -112,8 +155,11 @@ const SlideContent = ({ return ( (null); const [touchEnd, setTouchEnd] = useState(null); const minSwipeDistance = 50; - const [slideDirection, setSlideDirection] = useState<'forward' | 'backward'>( - 'forward', - ); + // Direction is only read when the slide-change animation fires, so a ref + // keeps it out of the render cycle and out of the effect's dependencies. + const slideDirectionRef = useRef<'forward' | 'backward'>('forward'); + const [fontScale, setFontScale] = useState(1); + const slideAnimation = useAnimationControls(); + const containerRef = useRef(null); + + // The document editor stays mounted behind this overlay and keeps DOM focus, + // so every keystroke meant for navigation was also being typed into the + // document. Surrender focus once the deck opens. + useEffect(() => { + editor.commands.blur(); + (document.activeElement as HTMLElement | null)?.blur(); + }, [editor]); + + const adjustFontScale = useCallback((delta: number) => { + setFontScale((previous) => clampFontScale(previous + delta)); + }, []); + + // Replays the enter transition on each slide change without remounting the + // editor that renders the slide. + useEffect(() => { + slideAnimation.set({ + opacity: 0, + x: slideDirectionRef.current === 'forward' ? 50 : -50, + }); + slideAnimation.start({ + opacity: 1, + x: 0, + transition: { duration: 0.2 }, + }); + }, [currentSlide, slideAnimation]); const themeCanvasBackground = getThemeStyle( documentStyling?.canvasBackground, @@ -171,14 +246,16 @@ export const PresentationMode = ({ // The presentation editor reuses the source editor's extension // instances. In a shared/viewer context the editor is in suggestion // mode, where suggestionTracking's filterTransaction blocks every - // doc-changing transaction — including the setContent that loads each - // slide. That left the active slide empty while the deck (rendered - // via dangerouslySetInnerHTML) still showed content. The presentation + // doc-changing transaction — including the setContent that loads + // each slide, which would leave every slide blank. The presentation // editor only renders slides read-only, so it never needs tracking. 'suggestionTracking', ].includes(b.name), ), - editable: !isPreviewMode, + // Slides are a read-only view of the document. Left editable, the + // navigation shortcuts double as text input — pressing `f` to go + // fullscreen also types an "f" into the slide. + editable: false, }); }, [isPreviewMode]); const handlePresentationMode = useCallback(async () => { @@ -197,62 +274,25 @@ export const PresentationMode = ({ } setIsLoading(true); - const markdown = await convertToMarkdown( - editor, - ipfsImageFetchFn, - fetchV1ImageFn, - ); - // First convert markdown to HTML with proper page breaks - const html = convertMarkdownToHTML(markdown, { - preserveNewlines: true, - sanitize: true, - maxCharsPerSlide: 1000, - maxWordsPerSlide: 250, - maxLinesPerSlide: 7, - }); - // Create a temporary div to properly parse the HTML - const tempDiv = document.createElement('div'); - tempDiv.innerHTML = html; - - // Find all page breaks and split content - const slideArray: string[] = []; - let currentSlideContent: Node[] = []; - - // Iterate through all nodes - tempDiv.childNodes.forEach((node) => { - if ( - node instanceof HTMLElement && - node.getAttribute('data-type') === 'page-break' && - node.getAttribute('data-page-break') === 'true' - ) { - // When we hit a page break, save the current slide content - if (currentSlideContent.length > 0) { - const slideDiv = document.createElement('div'); - currentSlideContent.forEach((n) => - slideDiv.appendChild(n.cloneNode(true)), - ); - slideArray.push(slideDiv.innerHTML); - currentSlideContent = []; - } - } else { - currentSlideContent.push(node.cloneNode(true)); - } - }); - - // Don't forget to add the last slide - if (currentSlideContent.length > 0) { - const slideDiv = document.createElement('div'); - currentSlideContent.forEach((n) => - slideDiv.appendChild(n.cloneNode(true)), - ); - slideArray.push(slideDiv.innerHTML); + try { + // Slides are derived straight from the document nodes. The previous + // doc -> Markdown -> HTML route silently dropped everything Markdown + // cannot express, most visibly multi-column blocks and font sizes. + const slideArray = await buildSlidesFromDoc(editor, { + ipfsImageFetchFn, + fetchV1ImageFn, + fontScale, + }); + + setSlides(slideArray); + } catch (error) { + // Without this the loader spins forever and the failure is invisible. + console.error('Failed to build slides from document', error); + onError?.('Could not build slides from this document'); + } finally { + setIsLoading(false); } - - // Filter out empty slides and set the state - setSlides(slideArray.filter((slide) => slide.trim().length > 0)); - - setIsLoading(false); }, [isPreviewMode, editor.state.doc]); // Add check for empty editor useEffect(() => { @@ -299,6 +339,18 @@ export const PresentationMode = ({ const handleKeyDown = useCallback( (e: KeyboardEvent) => { + // Yield to a comment box or similar field *inside* the deck, but not to + // the document editor sitting behind the overlay — keystrokes there are + // navigation, not typing. + const target = e.target as HTMLElement | null; + const isTextEntry = + target?.isContentEditable || + ['INPUT', 'TEXTAREA', 'SELECT'].includes(target?.tagName ?? ''); + + if (isTextEntry && target && containerRef.current?.contains(target)) { + return; + } + if ( e.key === 'ArrowRight' || e.key === 'ArrowDown' || @@ -306,18 +358,27 @@ export const PresentationMode = ({ ) { e.preventDefault(); e.stopPropagation(); - setSlideDirection('forward'); + slideDirectionRef.current = 'forward'; setCurrentSlide((prev) => Math.min(prev + 1, slides.length - 1)); } else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') { - setSlideDirection('backward'); + slideDirectionRef.current = 'backward'; setCurrentSlide((prev) => Math.max(prev - 1, 0)); } else if (e.key === 'Escape') { !isPreviewMode && onClose(); } else if (e.key === 'f' || e.key === 'F') { toggleFullscreen(); + } else if (e.key === '+' || e.key === '=') { + e.preventDefault(); + adjustFontScale(FONT_SCALE_STEP); + } else if (e.key === '-' || e.key === '_') { + e.preventDefault(); + adjustFontScale(-FONT_SCALE_STEP); + } else if (e.key === '0') { + e.preventDefault(); + setFontScale(1); } }, - [slides.length, onClose, toggleFullscreen], + [slides.length, onClose, toggleFullscreen, adjustFontScale], ); useEffect(() => { @@ -356,11 +417,11 @@ export const PresentationMode = ({ const isRightSwipe = distance < -minSwipeDistance; if (isLeftSwipe) { - setSlideDirection('forward'); + slideDirectionRef.current = 'forward'; setCurrentSlide((prev) => Math.min(prev + 1, slides.length - 1)); } if (isRightSwipe) { - setSlideDirection('backward'); + slideDirectionRef.current = 'backward'; setCurrentSlide((prev) => Math.max(prev - 1, 0)); } }, [touchStart, touchEnd, slides.length, minSwipeDistance]); @@ -395,6 +456,7 @@ export const PresentationMode = ({ return (
{renderThemeToggle?.()} +
+ + adjustFontScale(-FONT_SCALE_STEP)} + /> + + + + + + = FONT_SCALE_MAX} + onClick={() => adjustFontScale(FONT_SCALE_STEP)} + /> + +
{!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('

    Trailing text

    Title

    Body copy

    '), + ); + + expect(slides).toHaveLength(2); + expect(slideText(slides[0])).toBe('Trailing text'); + expect(slideText(slides[1])).toBe('Title Body copy'); + }); + + it('leaves splitting to measurement when overflow limits are disabled', () => { + const paragraphs = Array.from( + { length: 30 }, + (_, i) => `

    Paragraph number ${i}

    `, + ).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
  • , but the size lives on the mark + // inside it, so a resized item kept a base-size bullet. + it('copies a size carried by a textStyle mark onto the item', () => { + const result = matchListMarkersToText({ + type: 'bulletList', + content: [ + listItem({ + type: 'text', + text: 'big', + marks: [{ type: 'textStyle', attrs: { fontSize: '30px' } }], + }), + ], + }); + + expect(result.content?.[0].attrs?.fontSize).toBe('30px'); + }); + + it('copies a size carried as a paragraph attribute', () => { + const result = matchListMarkersToText({ + type: 'bulletList', + content: [ + { + type: 'listItem', + content: [ + { + type: 'paragraph', + attrs: { fontSize: '24px' }, + content: [{ type: 'text', text: 'big' }], + }, + ], + }, + ], + }); + + expect(result.content?.[0].attrs?.fontSize).toBe('24px'); + }); + + it('leaves an item with no explicit size untouched', () => { + const result = matchListMarkersToText({ + type: 'bulletList', + content: [listItem({ type: 'text', text: 'plain' })], + }); + + expect(result.content?.[0].attrs?.fontSize).toBeUndefined(); + }); + + it('sizes each item independently', () => { + const result = matchListMarkersToText({ + type: 'bulletList', + content: [ + listItem({ + type: 'text', + text: 'small', + marks: [{ type: 'textStyle', attrs: { fontSize: '12px' } }], + }), + listItem({ + type: 'text', + text: 'large', + marks: [{ type: 'textStyle', attrs: { fontSize: '40px' } }], + }), + ], + }); + + expect(result.content?.[0].attrs?.fontSize).toBe('12px'); + expect(result.content?.[1].attrs?.fontSize).toBe('40px'); + }); + + it('reaches items nested inside another list', () => { + const result = matchListMarkersToText({ + type: 'bulletList', + content: [ + { + type: 'listItem', + content: [ + { + type: 'bulletList', + content: [ + listItem({ + type: 'text', + text: 'nested', + marks: [{ type: 'textStyle', attrs: { fontSize: '18px' } }], + }), + ], + }, + ], + }, + ], + }); + + const nested = result.content?.[0].content?.[0].content?.[0]; + expect(nested?.attrs?.fontSize).toBe('18px'); + }); + + it('preserves content and other node types', () => { + const doc: JSONContent = { + type: 'doc', + content: [ + { type: 'paragraph', content: [{ type: 'text', text: 'before' }] }, + { + type: 'bulletList', + content: [listItem({ type: 'text', text: 'item' })], + }, + ], + }; + + expect(JSON.stringify(matchListMarkersToText(doc))).toContain('before'); + expect(JSON.stringify(matchListMarkersToText(doc))).toContain('item'); + }); +}); + +describe('scaleInlineFontSizes', () => { + // Inline font-size beats any stylesheet rule, so explicitly-sized text used + // to ignore the presenter font scale while everything around it responded. + it('routes an explicit size through the scale variable', () => { + expect(scaleInlineFontSizes('

    big

    ')).toBe( + '

    big

    ', + ); + }); + + it.each(['px', 'rem', 'em', 'pt'])('handles %s units', (unit) => { + expect( + scaleInlineFontSizes(`x`), + ).toBe( + `x`, + ); + }); + + it('scales every occurrence, not just the first', () => { + const scaled = scaleInlineFontSizes( + '

    a

    b

    ', + ); + + expect(scaled).toContain('* 12px)'); + expect(scaled).toContain('* 30px)'); + }); + + // Running twice must not nest calc() inside calc(). + it('is idempotent', () => { + const once = scaleInlineFontSizes('

    x

    '); + 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; +};