Skip to content

feat(slides): image beside text, slide numbers, and presenter font scaling - #550

Draft
vpavlin wants to merge 6 commits into
fileverse:mainfrom
vpavlin:feat/slides-direct-render
Draft

feat(slides): image beside text, slide numbers, and presenter font scaling#550
vpavlin wants to merge 6 commits into
fileverse:mainfrom
vpavlin:feat/slides-direct-render

Conversation

@vpavlin

@vpavlin vpavlin commented Aug 5, 2026

Copy link
Copy Markdown

Summary

Slides were derived by serialising the document to Markdown and parsing it back:

doc → getHTML() → turndown → Markdown → convertMarkdownToHTML() → HTML → split

Everything Markdown cannot express was silently dropped on the way to the stage. multi-column and font-size extensions already exist in the editor, but no Turndown rule carries them, and md-to-html.ts contains the string column zero times — so an image beside text was impossible on a slide, and author-set font sizes never arrived.

This builds the deck straight from the ProseMirror document instead. Slides remain string[], rendered back through the editor's own schema, so the preview panel, PDF export and share links are untouched.

Three user-facing additions: image beside text, slide numbers, and presenter font scaling.


Screenshots

1. Image beside text (the headline change)

Before After
image image

2. Slide numbers and font-size control

image

4. Windowed vs fullscreen parity

image image

What changed

package/utils/doc-to-slides.ts (new) — splits the document at structural boundaries (page breaks, headings), then decides breaks by measuring against the real 1080×608 stage rather than counting characters. The old heuristics (7 lines / 1000 chars / 250 words) split slides that visibly had room to spare. Character counting survives as a fallback for environments without layout.

Fitting rules:

  • lists paginate between <li> items, with start carried over so ordered numbering stays continuous
  • columns and tables are never divided — breaking a side-by-side layout destroys it rather than paginating it
  • a heading is never stranded: if what fits is only headings and the next block is indivisible, they stay together and the slide is allowed to run long. A title alone with its content on the next slide is a worse outcome than a slightly tall slide.

presentation-mode.tsx — fullscreen used dangerouslySetInnerHTML while windowed 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 per slide is what let the two paths drift apart.

editor.css — windowed presentation type was set at ~2.2% of stage width while fullscreen already used ~1.6%. Aligned to fullscreen's proportions, and --slide-font-scale now threads through both.

ddoc-editor.tsxMod+Alt+P opens the deck.


Bugs found along the way

Not part of the original scope, but worth flagging:

  1. Presenting a ddoc could silently edit it. The document editor stays mounted behind the presentation overlay and kept DOM focus, so f, +, -, 0, arrows and space were typed into the document while presenting. Fixed by releasing focus on open and making the presentation editor read-only.
  2. Fullscreen ignored font scaling entirely — its 14 viewport-relative sizes never referenced the scale variable.
  3. Slide build failures left the loader spinning with nothing logged. Now surfaced.
  4. processMarkdownContent in md-to-slides.ts (~200 lines) is exported but never imported, and disagrees with the live path (maxLines 8 vs 7). Left alone here, but it's dead code.

Behaviour changes reviewers should weigh

  • An H1 no longer produces a bare title slide. A heading now opens a slide and content packs beneath it; use an explicit page break for a title-only slide.
  • Slides may overflow slightly rather than stranding a heading (see fitting rules above).
  • Windowed slide typography is substantially smaller — see screenshot 2.

Testing

npm run build passes. 11 unit tests added, covering the structural splitter against the real schema via getHeadlessExtensions. Lint unchanged from main (8 pre-existing warnings, 0 errors).

Known coverage gap, stated plainly: the measurement path is unreachable in unit tests — jsdom parses markup but reports every height as 0, so fitSlidesToStage falls back to heuristics there. Everything in that path was verified by hand in the demo app. The rules most worth protecting (columns never split, headings never stranded, lists paginate) regressed at least once during development and would each have been caught instantly by a browser test. A Playwright test asserting real geometry is the natural follow-up.


Follow-up work, deliberately not in this PR

  • Windowed and fullscreen are still two independent stylesheets. This PR aligns their numbers; it does not merge them, so they can drift again — and the PDF path is a third set of rules. Four of the bugs above trace to that split. Defining the slide once in stage-proportional units and having fullscreen scale it would remove the class entirely.
  • Font scaling does not re-split the deck. The scale is read when slides are built, so scaling up afterwards can overflow.
  • Page numbers are not in the PDF exporthandle-print.ts is a separate path with its own stylesheet.
  • Images inside columns can under-measure if they have not decoded when the slide is measured.

🤖 Generated with Claude Code

vpavlin and others added 4 commits August 5, 2026 12:14
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
@vercel

vercel Bot commented Aug 5, 2026

Copy link
Copy Markdown

@vpavlin is attempting to deploy a commit to the fileverse Team on Vercel.

A member of the Team first needs to authorize it.

vpavlin and others added 2 commits August 5, 2026 15:46
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
<p>, 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) <noreply@anthropic.com>
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 <li>, 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) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant