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:
- The paper stylesheet, shipped in
@domternal/themeas_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. - 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.
When to use
Section titled “When to use”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.titleor inject an@pagerule 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
.docxor.pdfto 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/themeand 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.
import { Component, signal } from '@angular/core';import { DomternalEditorComponent, DomternalToolbarComponent } from '@domternal/angular';import { Editor, Document, Paragraph, Text, Print } from '@domternal/core';
@Component({ selector: 'app-editor', imports: [DomternalEditorComponent, DomternalToolbarComponent], templateUrl: './editor.html',})export class EditorComponent { editor = signal<Editor | null>(null); extensions = [Document, Paragraph, Text, Print];}@if (editor(); as ed) { <domternal-toolbar [editor]="ed" />}<domternal-editor [extensions]="extensions" (editorCreated)="editor.set($event)"/>import { Domternal } from '@domternal/react';import { Document, Paragraph, Text, Print } from '@domternal/core';
export default function Editor() { return ( <Domternal extensions={[Document, Paragraph, Text, Print]}> <Domternal.Toolbar /> <Domternal.Content /> </Domternal> );}<script setup lang="ts">import { Domternal } from '@domternal/vue';import { Document, Paragraph, Text, Print } from '@domternal/core';
const extensions = [Document, Paragraph, Text, Print];</script>
<template> <Domternal :extensions="extensions"> <Domternal.Toolbar /> <Domternal.Content /> </Domternal></template>import { Editor, Document, Paragraph, Text, Print } from '@domternal/core';
const editor = new Editor({ element: document.getElementById('editor')!, extensions: [ Document, Paragraph, Text, Print.configure({ toolbar: false, // no toolbar button, drive it yourself root: null, // default: the closest `.dm-editor` wrapper isolateNativePrint: true, // this app IS the editor }), ],});
document.getElementById('print')!.addEventListener('click', () => { editor.commands.printDocument();});Without @domternal/theme there is no paper layer at all: the command still marks the DOM and opens the dialog, but nothing reads the marks, so the browser prints the editor exactly as it appears on screen, chrome included. Write the rules from The paper stylesheet yourself, or import just that partial from the theme package.
Options
Section titled “Options”| Option | Type | Default | Description |
|---|---|---|---|
toolbar | boolean | true | Contribute the printer button to the toolbar |
root | ((editor: ExtensionEditor) => HTMLElement | null) | null | null | Resolve 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 |
isolateNativePrint | boolean | false | Also 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.
isolateNativePrint
Section titled “isolateNativePrint”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.
Commands
Section titled “Commands”printDocument
Section titled “printDocument”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 ondispatch, soeditor.can().printDocument()returnstruewithout printing. That is what keeps the toolbar button enabled: without the guard, merely rendering a toolbar would open a print dialog. - Returns
falsewhen there is nowindow(server-side rendering) or when the configuredrootresolver returnsnull. Otherwisetrue. - Cleanup is in a
finally. The marks come off andafterPrintfires even when abeforePrintlistener 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.
Keyboard shortcuts
Section titled “Keyboard shortcuts”| Key | Command | Description |
|---|---|---|
Mod-p | printDocument | Print 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.
Toolbar items
Section titled “Toolbar items”| Button | Icon | Group | Priority | Shortcut | Read-only |
|---|---|---|---|---|---|
print | printer | document | 100 | Mod-P | enabled |
- The accessible name is
Print; the tooltip appends the shortcut, so it readsPrint (⌘P)on macOS andPrint (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 ownpriority(default100, higher first) and then by registration order, so movingPrintin theextensionsarray moves the segment.
Set toolbar: false to contribute no item and drive printDocument() from your own UI.
Events
Section titled “Events”Both events are declared on EditorEvents, so editor.on is typed.
| Event | Payload | When |
|---|---|---|
beforePrint | { root: HTMLElement } | After the document has been marked for printing, before the dialog opens |
afterPrint | undefined | Once 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.
The paper stylesheet
Section titled “The paper stylesheet”_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.
What it hides
Section titled “What it hides”Every piece of editor chrome, whether it mounts inside .dm-editor, on document.body, or outside both:
| Chrome | Selectors |
|---|---|
| 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-selectednodeand.ProseMirror-selectednoderangelose their outline and box shadow, and their::beforemarkers.selectedCell::afterand the.dm-cell-focusedoutline go.invisible-char,[data-char]::after(pilcrows, dots) and.is-empty::before(the placeholder) go.dm-slash-command-query,.mention-suggestion,.dm-link-pendingand.dm-block-context-activelose 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.
What it reveals
Section titled “What it reveals”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.
| Problem | Fix |
|---|---|
A closed <details> body carries hidden, so its content is not on the paper | display: 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-word | overflow: visible, white-space: pre-wrap, word-break: break-word on pre |
| A wide table is cut by three independent clips | overflow: 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 axes | overflow: visible on .dm-math-block |
| The mount wrapper clips to the editor’s rounded corners | overflow: 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.
What it neutralises
Section titled “What it neutralises”A border, a shadow and a tinted panel are screen furniture; the paper is already the page.
bodyloses 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 resetstransition, and that reset is load-bearing. A property that is mid-transition takes its value from the running animation, which outranks even an!importantdeclaration, 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 thebackgroundreset take effect at all.bodyhas 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-printingis forced tocolor: #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 customrootis 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 defaultisolateNativePrint: falsea reader’s own Ctrl/Cmd+P on an article that merely embeds an editor leaves the article’s colours alone. WithisolateNativePrint: truethe native listener marks the body as well, and a reader-initiated print is isolated and recoloured the same way..dm-editorloses its border, radius, shadow, background andmax-width, and is forced tocolor: #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 .ProseMirrorloses its padding,min-heightandmax-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-widthandmarginall go. 60vh of deliberate breathing room on screen is 60% of a blank first sheet. - The inline
/tocblock stays as content but loses its panel tint, keeping only a light border so it still reads as a unit.
Page breaks
Section titled “Page breaks”| Rule | Applies to |
|---|---|
break-after: avoid and break-inside: avoid | h1-h6, and summary |
break-inside: avoid | pre, blockquote, figure, img, .dm-image-resizable, .dm-math-block, .dm-toc-block, tr, th, td, li |
orphans: 2 and widows: 2 | p, li |
break-before: auto and break-after: auto | floated 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.
Colour fidelity
Section titled “Colour fidelity”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.
Isolation and the marking classes
Section titled “Isolation and the marking classes”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.
| Class | Where | Meaning |
|---|---|---|
dm-print-root | The resolved root element | The subtree that is the document being printed |
dm-print-ancestor | Every 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’shost. 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.
Styling
Section titled “Styling”Everything above is plain CSS, so you can extend or override it.
- Key rules on
body.dm-printingfor anything that should apply only during a print this extension marks, which under the defaultisolateNativePrint: falseexcludes a reader’s own Ctrl/Cmd+P. - Load your own print rules after
@domternal/theme, or use!important.@media printadds no specificity, so source order is the whole cascade here. - Ship your own
@pagerule 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; }}Limits
Section titled “Limits”- 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
@pagerule 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. Pointrootat a wrapper containing everything you want on paper, or leave isolation off and drive printing withprintDocument(). - Without
@domternal/themethere is no paper layer. The extension marks the DOM; the rules that read those marks live in the theme. beforePrintlisteners must be synchronous.window.print()runs the instant they return.
Exports
Section titled “Exports”import { Print } from '@domternal/core';import type { PrintOptions, PrintStorage } from '@domternal/core';| Export | Type | Description |
|---|---|---|
Print | Extension | The print extension |
PrintOptions | TypeScript type | Options for Print.configure() |
PrintStorage | TypeScript type | Shape of editor.storage.print: a single cleanup function, set only while the native print listeners are attached |
Source
Section titled “Source”@domternal/core - Print.ts
@domternal/theme - _print.scss