From bbacd5b435cf2bea42ff61a5e855b29489ac31ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=A1clav=20Pavl=C3=ADn?= Date: Wed, 5 Aug 2026 12:14:55 +0200 Subject: [PATCH 1/6] feat(slides): build the deck from document nodes and fit by measurement Slides were derived by serialising the document to Markdown and parsing it back: doc -> HTML -> Markdown -> HTML. Everything Markdown cannot express was silently dropped on the way to the stage, most visibly multi-column blocks and paragraph font sizes, so an image beside text was impossible on a slide. Split the ProseMirror document directly instead. Slides are still `string[]`, rendered back through the editor's own schema, so the preview panel, PDF export and share links are untouched. Breaks are decided by measuring against the real 1080x608 stage rather than by counting characters, which split slides that visibly had room to spare. The character heuristics remain as a fallback for environments without layout. Lists paginate between their items; columns and tables are never divided, and a heading is never left stranded without its content. Co-Authored-By: Claude Opus 5 (1M context) --- package/utils/doc-to-slides.test.ts | 255 ++++++++++++ package/utils/doc-to-slides.ts | 590 ++++++++++++++++++++++++++++ 2 files changed, 845 insertions(+) create mode 100644 package/utils/doc-to-slides.test.ts create mode 100644 package/utils/doc-to-slides.ts diff --git a/package/utils/doc-to-slides.test.ts b/package/utils/doc-to-slides.test.ts new file mode 100644 index 00000000..f49a75dd --- /dev/null +++ b/package/utils/doc-to-slides.test.ts @@ -0,0 +1,255 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { Editor } from '@tiptap/react'; +import { JSONContent } from '@tiptap/core'; +import { splitDocIntoSlides, isSoloMediaSlide } 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('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..450a60ea --- /dev/null +++ b/package/utils/doc-to-slides.ts @@ -0,0 +1,590 @@ +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; +} + +/** + * 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(slideDoc); + return 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; +}; From a40d5a54c92fb12a7355ce3074cebd550f022d53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=A1clav=20Pavl=C3=ADn?= Date: Wed, 5 Aug 2026 12:15:06 +0200 Subject: [PATCH 2/6] style(slides): size slides like a deck rather than a document The windowed presentation stylesheet set type at roughly 2.2% of stage width (82px headings, 24px body, 24px paragraph gaps on a 608px-tall slide) while the fullscreen stylesheet already used ~1.6%. Slides therefore held barely a few lines, split with obvious space to spare, and the preview looked nothing like the deck being presented. Match the windowed block to fullscreen's proportions. Also: - thread --slide-font-scale through both stylesheets, including the 14 viewport-relative sizes in the fullscreen block that previously ignored it, so the font-size control works in fullscreen as well as windowed - stretch .ProseMirror inside .fullscreen; the editor wraps slide content in a single element, which as a lone flex item under `align-items: start` shrank to its content width and collapsed column grids to one character per line - let images inside a column fill it at their natural aspect instead of being capped at 32rem and letterboxed into 16/9 - use a unitless line-height so spacing tracks the font scale Co-Authored-By: Claude Opus 5 (1M context) --- package/styles/editor.css | 93 ++++++++++++++++++++++++++++----------- 1 file changed, 67 insertions(+), 26 deletions(-) diff --git a/package/styles/editor.css b/package/styles/editor.css index f83a6c1e..ffe75521 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,14 @@ 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; } ul[data-type='taskList'], @@ -1080,6 +1111,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,21 +1129,21 @@ 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; @@ -1127,7 +1168,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; @@ -1174,7 +1215,7 @@ ul[data-type='taskList'] li[data-checked='true'] > div > p > span { .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 +1253,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 +1288,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 +1301,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 +1328,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)); } } } From 263b1800e824ef7c9b49fcf57dd5ae2963b8a88a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=A1clav=20Pavl=C3=ADn?= Date: Wed, 5 Aug 2026 12:15:18 +0200 Subject: [PATCH 3/6] feat(slides): unify rendering, add slide numbers and font scaling Fullscreen injected slide HTML with dangerouslySetInnerHTML while the windowed view rendered through the editor, so custom nodes could appear in one and not the other. Both now render through the editor. 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 let the paths drift. - slide numbers now render in both windowed and fullscreen; the counter was previously mobile-fullscreen only - presenter font scaling via the toolbar or +/-/0, applied as a CSS custom property so elements keep their relative proportions - the presentation editor is read-only: left editable, `f` toggled fullscreen *and* typed an "f" into the slide - release focus on open, so navigation keys no longer reach the document editor behind the overlay and edit the document while presenting - surface build failures instead of leaving the loader spinning silently Co-Authored-By: Claude Opus 5 (1M context) --- .../presentation-mode/presentation-mode.tsx | 305 +++++++++++------- 1 file changed, 191 insertions(+), 114 deletions(-) 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} - -
- +
+ +
)} From 50f6939ac6a8ce08e57de5e8300c587522c7d5e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=A1clav=20Pavl=C3=ADn?= Date: Wed, 5 Aug 2026 12:15:18 +0200 Subject: [PATCH 4/6] feat(slides): open presentation mode with Mod-Alt-P MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Presentation mode was reachable only by mouse, despite being keyboard-driven once open. Matches on event.code rather than event.key, since Option-P emits "π" on macOS and the shortcut would never fire there. Registered in the capture phase so the browser's own Ctrl/Cmd-P print binding is suppressed. Co-Authored-By: Claude Opus 5 (1M context) --- package/ddoc-editor.tsx | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) 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) { From d9c010c818321f1e0158dbb4cb21d6b3d55f16fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=A1clav=20Pavl=C3=ADn?= Date: Wed, 5 Aug 2026 15:46:13 +0200 Subject: [PATCH 5/6] fix(slides): keep list markers and text consistent across modes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bullets differed between windowed and fullscreen: the fullscreen block styled tight lists with a hollow `circle` while the windowed block had no list rules at all and fell back to the browser's `disc`. `data-tight` describes spacing, not nesting depth, so a top-level tight list was reading as a nested one. List rules now live once in the shared .ProseMirror block; the fullscreen block only adjusts spacing. Also zero the margin on a list item's paragraph. TipTap wraps item text in a

, which took the block-level paragraph margin while the marker stayed anchored to the top of the item, dropping the text below its own bullet — pronounced in fullscreen, where that margin is viewport-relative. Co-Authored-By: Claude Opus 5 (1M context) --- package/styles/editor.css | 63 +++++++++++++++++++++++++++++++-------- 1 file changed, 50 insertions(+), 13 deletions(-) diff --git a/package/styles/editor.css b/package/styles/editor.css index ffe75521..3cc4057c 100644 --- a/package/styles/editor.css +++ b/package/styles/editor.css @@ -918,6 +918,43 @@ ul[data-type='taskList'] li[data-checked='true'] > div > p > span { 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'], li[data-type='taskItem'] { list-style: none !important; @@ -1149,6 +1186,14 @@ ul[data-type='taskList'] li[data-checked='true'] > div > p > span { 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; @@ -1183,35 +1228,27 @@ 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; From 71ba918dafd86a2b562616da0c8f09200791eff0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=A1clav=20Pavl=C3=ADn?= Date: Wed, 5 Aug 2026 15:46:25 +0200 Subject: [PATCH 6/6] fix(slides): make explicit font sizes obey the presenter scale MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two problems with text carrying its own size, both newly reachable because this branch stops destroying inline sizes on the way to a slide. An inline `font-size` beats any stylesheet rule, so explicitly-sized text ignored the presenter font scale entirely while everything around it grew and shrank. Those values are now routed through the same multiplication the stylesheet uses, so they respond to the control and are accounted for when slides are measured. A native list marker is sized by its
  • , but an explicit size lives on a textStyle mark or paragraph attribute inside it, so a resized item kept a base-size bullet sitting off its own baseline — widening as the scale went up. listItem can now carry a fontSize, applied when slides are built. The attribute defaults to null and nothing sets it during editing, so document output is unchanged outside presentation mode. Both transforms are pure functions over HTML and the document tree, so unlike the measurement path they are covered by unit tests (14 added, 25 total). Co-Authored-By: Claude Opus 5 (1M context) --- package/extensions/font-size/font-size.ts | 24 ++++ package/utils/doc-to-slides.test.ts | 161 +++++++++++++++++++++- package/utils/doc-to-slides.ts | 70 +++++++++- 3 files changed, 252 insertions(+), 3 deletions(-) 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/utils/doc-to-slides.test.ts b/package/utils/doc-to-slides.test.ts index f49a75dd..8fd7252c 100644 --- a/package/utils/doc-to-slides.test.ts +++ b/package/utils/doc-to-slides.test.ts @@ -1,7 +1,12 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { Editor } from '@tiptap/react'; import { JSONContent } from '@tiptap/core'; -import { splitDocIntoSlides, isSoloMediaSlide } from './doc-to-slides'; +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. @@ -224,6 +229,160 @@ describe('splitDocIntoSlides', () => { }); }); +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 = { diff --git a/package/utils/doc-to-slides.ts b/package/utils/doc-to-slides.ts index 450a60ea..c2c7545c 100644 --- a/package/utils/doc-to-slides.ts +++ b/package/utils/doc-to-slides.ts @@ -510,6 +510,72 @@ export interface BuildSlidesOptions extends DocToSlidesOptions { 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. * @@ -540,8 +606,8 @@ const renderSlideDocsToHtml = ( try { return slideDocs.map((slideDoc) => { - temporaryEditor.commands.setContent(slideDoc); - return temporaryEditor.getHTML(); + temporaryEditor.commands.setContent(matchListMarkersToText(slideDoc)); + return scaleInlineFontSizes(temporaryEditor.getHTML()); }); } finally { temporaryEditor.destroy();