Skip to content

Print

Print sends the document to the browser’s own print dialog, which is also the one place a reader can save a PDF that looks exactly like the editor: the same engine paints both, so floats, columns and fonts survive untouched. What it cannot do is hand a file back to code, so it complements a file exporter rather than replacing one.

Without a paper layer, a printed page is the editor screenshot rather than the document. The toolbar prints. An open bubble menu or slash menu prints. The editor’s border, shadow and drag-handle gutter print, and the gutter costs the same ~48px on every sheet. Worse, content goes missing: a closed accordion’s body carries hidden, so its clauses are simply not on the paper; a long code line is cut at the edge of its scroll window; a wide table is cut at the edge of its scroll wrapper; a dark theme prints near-white text on white paper, which is not a wrong page but a blank one.

Two independent layers fix that, and the split matters:

  1. The paper stylesheet, shipped in @domternal/theme as _print.scss. It applies to the reader’s own Ctrl/Cmd+P with no code involved, because an editor that prints its own toolbar and silently drops a closed accordion’s body is broken, not missing a premium feature.
  2. This extension, which adds the two things CSS cannot do on its own: a button, and isolating the document from the host application’s chrome.

Not included in StarterKit. Add it separately.

Use Print when you need:

  • A print action inside your editor UI rather than only the browser’s page-level one
  • Ctrl/Cmd+P inside the editor to mean “print this document”, not “print this page”
  • The host application’s sidebar, header and navigation gone from the printed sheet
  • A hook to set document.title or inject an @page rule just before the dialog opens
  • Print-fidelity output that keeps floats and browser-rendered layout that a file backend cannot reproduce

Skip it if:

  • You need a file back in code (a saved .docx or .pdf to upload or attach); the browser dialog produces no file it can hand you
  • You need page numbers or running headers printed in the page margins, which no browser can do (see Limits)
  • Your editor is embedded in a wider page you never want erased, and the reader’s own Ctrl/Cmd+P is the correct behaviour; in that case load @domternal/theme and skip the extension
import { Document, Paragraph, Text, Print } from '@domternal/core';
import { DomternalEditor } from '@domternal/vanilla';
import '@domternal/theme';
const dm = new DomternalEditor(document.getElementById('editor')!, {
extensions: [Document, Paragraph, Text, Print],
});
// Same thing the toolbar button does.
dm.editor.commands.printDocument();

The toolbar renders a printer button in its own document group. @domternal/theme carries the paper stylesheet, so the printed page is already free of chrome.

OptionTypeDefaultDescription
toolbarbooleantrueContribute the printer button to the toolbar
root((editor: ExtensionEditor) => HTMLElement | null) | nullnullResolve the element whose subtree is the document being printed. null uses the editor’s closest .dm-editor wrapper, falling back to the ProseMirror element itself
isolateNativePrintbooleanfalseAlso isolate the document when the reader presses Ctrl/Cmd+P instead of using the command
Print.configure({
toolbar: true,
root: null,
isolateNativePrint: false,
})

The resolver names the element that becomes the print root. Everything inside it prints; everything beside it, at every level up to <html>, is collapsed when isolation is active.

Print.configure({
// Print the document together with its own title bar and byline,
// which live outside the editor.
root: () => document.querySelector<HTMLElement>('.document-shell'),
})

Returning null makes printDocument() return false without opening a dialog.

Off by default, and deliberately: isolating means erasing everything else on the page. That is obviously right for an app that is the editor, and obviously wrong for an article with an editor embedded in it, and only the host knows which one it is. With it off, a reader-initiated print still gets the whole paper stylesheet, just not the erasure.

Turn it on and the extension attaches beforeprint and afterprint listeners on window, so a reader pressing Ctrl/Cmd+P anywhere on the page gets the same isolated document the command produces, and the same beforePrint / afterPrint events fire. A matchMedia('print') change listener is attached alongside as a Safari backstop; marking and unmarking are idempotent, so the two paths are harmless together. The media listener is attached defensively, because an environment without matchMedia must not take the two event listeners down with it.

The extension guards against announcing a print twice. window.print() fires the window’s own beforeprint event, so without the guard the native listener would run inside a command-driven print and announce a second beforePrint to every subscriber. That is not cosmetic: a listener that saves document.title on the first announcement would capture its own replacement on the second, and restore the wrong title afterwards.

The listeners are removed when the editor is destroyed.

Opens the browser’s print dialog with the document marked for printing.

editor.commands.printDocument();
  • No transaction is dispatched. The document is being read out, not changed, so nothing lands in the undo stack and the content is byte-identical afterwards.
  • A can() probe never opens a dialog. The command guards on dispatch, so editor.can().printDocument() returns true without printing. That is what keeps the toolbar button enabled: without the guard, merely rendering a toolbar would open a print dialog.
  • Returns false when there is no window (server-side rendering) or when the configured root resolver returns null. Otherwise true.
  • Cleanup is in a finally. The marks come off and afterPrint fires even when a beforePrint listener throws or the browser blocks printing. The exception still propagates, but it cannot leave the page in the state where everything except the editor is hidden.

The order is exact: mark the root and its ancestors, emit beforePrint, call window.print(), unmark, emit afterPrint.

KeyCommandDescription
Mod-pprintDocumentPrint the document

Mod is Cmd on macOS and Ctrl on Windows/Linux.

The binding is a ProseMirror keymap entry, so it is live only while the caret is in the editor, which is exactly when the reader means “print this document” rather than “print this page”. Everywhere else on the page the browser’s own Ctrl/Cmd+P is untouched.

The shortcut does not inherit the toolbar button’s read-only exemption. ProseMirror classes keydown as an edit handler and skips it entirely while view.editable is false, so in a read-only editor Mod-P falls through to the browser’s own dialog while the button still runs printDocument. With isolateNativePrint: true that fall-through is still isolated, because the native listener does the marking.

ButtonIconGroupPriorityShortcutRead-only
printprinterdocument100Mod-Penabled
  • The accessible name is Print; the tooltip appends the shortcut, so it reads Print (⌘P) on macOS and Print (Ctrl+P) elsewhere.
  • allowReadOnly: true. Every toolbar button is disabled in a read-only editor by default; this one is not, because reading a document out to paper is not editing it.
  • group: 'document' puts the button in its own visual segment, separated from the formatting groups. Groups appear in the order their first item does, and extensions are ordered by their own priority (default 100, higher first) and then by registration order, so moving Print in the extensions array moves the segment.

Set toolbar: false to contribute no item and drive printDocument() from your own UI.

Both events are declared on EditorEvents, so editor.on is typed.

EventPayloadWhen
beforePrint{ root: HTMLElement }After the document has been marked for printing, before the dialog opens
afterPrintundefinedOnce the dialog is done and the print marks have been removed

beforePrint listeners run synchronously: window.print() is called the moment the last one returns, so this is the last chance to change anything that must be on the page. Work deferred to a promise, a timeout or an animation frame will not be on the paper.

Two things a host typically does here. Set document.title, which is what the browser uses for the printed header and for the default filename of a saved PDF. And inject an @page rule, because the free theme ships none, so page size and margins otherwise come from the reader’s dialog.

let previousTitle: string | null = null;
let removeRule: (() => void) | null = null;
editor.on('beforePrint', ({ root }) => {
previousTitle = document.title;
document.title = 'Quarterly report';
const style = document.createElement('style');
style.textContent = '@page { size: A4; margin: 20mm; }';
document.head.appendChild(style);
removeRule = () => { style.remove(); };
// `root` is the element whose subtree is about to print.
root.setAttribute('data-print-variant', 'report');
});
editor.on('afterPrint', () => {
if (previousTitle !== null) document.title = previousTitle;
previousTitle = null;
removeRule?.();
removeRule = null;
});

With isolateNativePrint: true the same pair fires for a reader-initiated Ctrl/Cmd+P, and exactly once per print either way.

_print.scss is loaded last in the theme’s entry point, on purpose: @media print carries no specificity of its own, only source order, so being last is what lets it override everything above it. For the same reason, anything a node view writes as an inline style (table widths, table handles, popovers) needs !important here to be overridden at all.

Every piece of editor chrome, whether it mounts inside .dm-editor, on document.body, or outside both:

ChromeSelectors
Toolbar.dm-toolbar, .dm-toolbar-dropdown-panel
Floating menus.dm-bubble-menu, .dm-floating-menu, .dm-slash-command-menu
Popovers and pickers.dm-link-popover, .dm-image-popover, .dm-math-popover, .dm-emoji-picker, .dm-emoji-picker-host, .dm-emoji-suggestion, .dm-mention-suggestion, .dm-color-palette, .dm-notion-color-picker
Block controls.dm-block-handle, .dm-block-drop-indicator, .dm-block-context-menu
Outline.dm-toc-outline, .dm-toc-outline-shell
Accessibility plumbing.dm-live-region
ProseMirror cursors.prosemirror-dropcursor-block, .prosemirror-dropcursor-inline, .ProseMirror-gapcursor
Table and image affordances.dm-table-col-handle, .dm-table-row-handle, .dm-table-cell-handle, .dm-table-cell-toolbar, .dm-table-controls-dropdown, .dm-table-cell-dropdown, .dm-table-cell-align-dropdown, .dm-image-handle, .column-resize-handle

The table and image affordances get their display written inline by their node views as the pointer moves, which is why those rules carry !important: without it a plain rule loses to the element’s own style attribute and the handles print as grey bars down the side of the table.

Decorations that live inside the document but describe editing state are stripped rather than hidden, so the text they sit on stays:

  • .ProseMirror-selectednode and .ProseMirror-selectednoderange lose their outline and box shadow, and their ::before markers
  • .selectedCell::after and the .dm-cell-focused outline go
  • .invisible-char, [data-char]::after (pilcrows, dots) and .is-empty::before (the placeholder) go
  • .dm-slash-command-query, .mention-suggestion, .dm-link-pending and .dm-block-context-active lose their background, border, shadow and text decoration; the words themselves stay

That last group resets transition first. A running transition outranks !important, and the block context tint animates over 0.12s, so without the reset a print taken just after the menu opened carries a fading grey block behind the text.

This is the half that matters most: anything clipped or collapsed on screen stays invisible on paper unless it is opened up, and invisible here means the reader never learns it existed.

ProblemFix
A closed <details> body carries hidden, so its content is not on the paperdisplay: block !important on div[data-details-content][hidden], and the chevron button is hidden: a toggle is meaningless on paper and would leave a hole in the grid
A code block scrolls horizontally, so a long line is cut mid-wordoverflow: visible, white-space: pre-wrap, word-break: break-word on pre
A wide table is cut by three independent clipsoverflow: visible on .tableWrapper; width: 100%, min-width: 0, table-layout: auto on the table; min-width: 0 on cells; width: auto on colgroup col
A wide formula is clipped on both axesoverflow: visible on .dm-math-block
The mount wrapper clips to the editor’s rounded cornersoverflow: visible on .dm-editor > div:has(> .ProseMirror)

The cell min-width reset is worth calling out: the 100px floor exists so a column stays grabbable with the mouse. There is no mouse on paper, and the floor is exactly what pushes a wide table off the sheet.

A border, a shadow and a tinted panel are screen furniture; the paper is already the page.

  • body loses its background, so the host page’s own canvas is not painted behind the black text this layer forces: clearing the editor panel alone left a dark theme printing black on black. The same rule also resets transition, and that reset is load-bearing. A property that is mid-transition takes its value from the running animation, which outranks even an !important declaration, so a print taken during or just after a theme toggle would keep repainting the canvas the toggle was animating away from. Killing the transition is what lets the background reset take effect at all. body has to be named here for the same reason it needs its own selector in the isolation rules below: the ancestor rule is a descendant selector, and no element is a descendant of itself.
  • body.dm-printing is forced to color: #000, so the black text covers everything the print root keeps, not only what is inside .dm-editor: a title bar or byline printed alongside the document, which is exactly what a custom root is for, would otherwise stay the dark theme’s near-white on a canvas that has just been cleared to white. It is scoped to a print this extension marks, where everything except the document is already hidden, so under the default isolateNativePrint: false a reader’s own Ctrl/Cmd+P on an article that merely embeds an editor leaves the article’s colours alone. With isolateNativePrint: true the native listener marks the body as well, and a reader-initiated print is isolated and recoloured the same way.
  • .dm-editor loses its border, radius, shadow, background and max-width, and is forced to color: #000. The text colour has to come with the background: a dark theme sets a near-white text colour, and dropping only the dark panel behind it leaves light grey on white paper. Colours the author applied explicitly are inline on their own spans and still win.
  • .dm-editor .ProseMirror loses its padding, min-height and max-width. The padding is the expensive one: it is the gutter reserved for the drag handle, --dm-block-handle-gutter, 3rem on every printed page.
  • In Notion mode, min-height, max-width and margin all go. 60vh of deliberate breathing room on screen is 60% of a blank first sheet.
  • The inline /toc block stays as content but loses its panel tint, keeping only a light border so it still reads as a unit.
RuleApplies to
break-after: avoid and break-inside: avoidh1-h6, and summary
break-inside: avoidpre, blockquote, figure, img, .dm-image-resizable, .dm-math-block, .dm-toc-block, tr, th, td, li
orphans: 2 and widows: 2p, li
break-before: auto and break-after: autofloated images, .dm-image-resizable[data-float="left"|"right"]

Two lines is the typographic minimum for widows and orphans, and what word processors default to. A floated picture must not straddle a break, or the text wrapping around it ends up on the sheet the picture left behind.

Browsers strip backgrounds when printing unless the page asks for them. For a code block or a coloured callout the background is the meaning, so losing it changes what the document says. print-color-adjust: exact (with the -webkit- prefix) is set on pre, code, mark, th, td, .mention, [data-bg-color], [data-text-color], div[data-type="details"] and input[type="checkbox"].

The checkbox rule is insurance rather than a fix: Chromium already computes exact for form controls on its own. It is there for engines that strip the tick along with every other background, where a printed task list would otherwise read as entirely undone.

The command marks three things, and the stylesheet keys on all three. They are ordinary class names, so your own CSS can key on them too.

ClassWhereMeaning
dm-print-rootThe resolved root elementThe subtree that is the document being printed
dm-print-ancestorEvery ancestor of the root, up to and including <body> and <html>Its other children collapse
dm-printing<body>A print driven by this extension is in progress

The isolation itself is two selectors:

@media print {
body.dm-printing .dm-print-ancestor > *:not(.dm-print-ancestor):not(.dm-print-root),
body.dm-printing > *:not(.dm-print-ancestor):not(.dm-print-root) {
display: none !important;
}
}

Hiding each ancestor’s other children collapses the host application away at every level, without the stylesheet ever knowing a single one of the host’s class names. The second selector is not redundant: <body> is marked as an ancestor like all the others, but no element is a descendant of itself, so the descendant rule never reaches its own children, and a sidebar or header sitting directly under <body> is the commonest shape of all.

The ancestors survive but stop imposing layout, because a sidebar grid, a centred max-width column or a scroll container would otherwise keep shaping a page that no longer has anything beside the document. They are forced to display: block, position: static, width: auto, max-width: none, height: auto, max-height: none, margin: 0, padding: 0, border: none, overflow: visible, background: none, transform: none, columns: auto. The root itself gets width: auto, max-width: none, margin: 0.

Two details of how the marks are managed:

  • Shadow DOM. The ancestor walk uses parentElement, which stops at a shadow boundary, and falls back to the shadow root’s host. An editor mounted inside a shadow tree therefore gets its real ancestors marked, instead of the marks stopping short and the isolation rules never reaching the page.
  • Exact unmarking. The extension keeps direct references to what it marked rather than sweeping the document with querySelectorAll, which cannot see inside a shadow root and would strand marks there. Nothing may outlive the dialog: one leftover mark hides the whole application.

Everything above is plain CSS, so you can extend or override it.

  • Key rules on body.dm-printing for anything that should apply only during a print this extension marks, which under the default isolateNativePrint: false excludes a reader’s own Ctrl/Cmd+P.
  • Load your own print rules after @domternal/theme, or use !important. @media print adds no specificity, so source order is the whole cascade here.
  • Ship your own @page rule if the page box matters. The free theme ships none, so size and margins come from the reader’s print dialog.
/* Loaded after @domternal/theme */
@page {
size: A4;
margin: 20mm;
}
@media print {
/* Chrome the theme cannot know about, gone even without isolation. */
.app-banner,
.app-cookie-bar {
display: none !important;
}
/* A little more room to annotate by hand. */
.dm-editor .ProseMirror p {
line-height: 1.7;
}
/* Only when the command drove the print, not the reader's own dialog. */
body.dm-printing .document-watermark {
display: block !important;
}
}
  • No file comes back. The browser dialog is the reader’s; there is no way to hand the resulting PDF to your code, so nothing downstream can be automated from it. That is the reason to pair this with a file exporter rather than replace one.
  • Page numbers and running headers in the page margins are impossible in every browser. They require CSS Paged Media margin boxes (@bottom-center { content: counter(page) }), which no browser implements. What to do instead, in order of how much control you need: leave it to the reader, whose print dialog has a headers-and-footers option that stamps the page number, date and URL, and whose numbering is theirs to own; put the information in the document body, where it prints as ordinary content on the first and last page; or, when the number must be baked into the file itself, export to a format whose own pagination model owns it, which is what Domternal Pro export does for Word and PDF.
  • Page size, orientation and margins are the reader’s unless you inject an @page rule yourself. The free theme ships none.
  • Isolation is off by default and has to be. Only the host knows whether its page is the editor or is an article with an editor in it, and erasing an article uninvited is the worse failure of the two.
  • One isolating editor per page. The double-announce guard is shared across editors, so if more than one editor sets isolateNativePrint: true, a reader-initiated print isolates whichever listener runs first, which is the editor created first. Point root at a wrapper containing everything you want on paper, or leave isolation off and drive printing with printDocument().
  • Without @domternal/theme there is no paper layer. The extension marks the DOM; the rules that read those marks live in the theme.
  • beforePrint listeners must be synchronous. window.print() runs the instant they return.
import { Print } from '@domternal/core';
import type { PrintOptions, PrintStorage } from '@domternal/core';
ExportTypeDescription
PrintExtensionThe print extension
PrintOptionsTypeScript typeOptions for Print.configure()
PrintStorageTypeScript typeShape of editor.storage.print: a single cleanup function, set only while the native print listeners are attached

@domternal/core - Print.ts

@domternal/theme - _print.scss

  • Theming - CSS custom properties and how the theme’s partials are layered
  • Toolbar - how toolbar items, groups and read-only state are assembled, including where the printer button lands and how to move it