Page
The page extension renders the editor content in a paginated layout, similar to traditional word processors like Microsoft Word or Google Docs. Each page has a fixed width, height, and margins. When the content exceeds the available height on a page, it automatically flows to the next page.
import 'prosekit/basic/style.css'
import 'prosekit/basic/typography.css'
import 'prosekit/extensions/page/style.css'
import { clsx, createEditor, type NodeJSON } from 'prosekit/core'
import { ProseKit } from 'prosekit/react'
import { useMemo } from 'react'
import { sampleContent } from '../../sample/sample-doc-page'
import { defineExtension } from './extension'
import { useZoom } from './use-zoom'
interface EditorProps {
initialContent?: NodeJSON
}
export default function Editor(props: EditorProps) {
const defaultContent = props.initialContent ?? sampleContent
const editor = useMemo(() => {
const extension = defineExtension()
return createEditor({
extension,
defaultContent,
})
}, [defaultContent])
const { zoom, zoomIn, zoomOut, canZoomIn, canZoomOut } = useZoom()
return (
<ProseKit editor={editor}>
<div className="absolute top-4 left-4 flex items-center justify-center gap-1 border-gray-300 dark:border-gray-600 border p-2 text-sm text-gray-500 select-none print:hidden z-50 bg-gray-50 rounded">
<button className="size-6 flex justify-center items-center rounded hover:bg-gray-200 dark:hover:bg-gray-700 disabled:opacity-30" onClick={zoomOut} disabled={!canZoomOut}>-</button>
<span className="w-12 text-center tabular-nums">{zoom}%</span>
<button className="size-6 flex justify-center items-center rounded hover:bg-gray-200 dark:hover:bg-gray-700 disabled:opacity-30" onClick={zoomIn} disabled={!canZoomIn}>+</button>
</div>
<div className="relative w-full flex-1 box-border overflow-y-auto">
<div
ref={editor.mount}
className={clsx('ProseMirror box-border min-h-full px-[max(4rem,calc(50%-20rem))] py-8 outline-hidden outline-0 [&_span[data-mention=user]]:text-blue-500 [&_span[data-mention=tag]]:text-violet-500', 'print:transform-none! print:min-h-full! print:p-0! print:m-0!')}
style={{
transform: `scale(${zoom / 100})`,
transformOrigin: 'top',
minHeight: `${100 / (zoom / 100)}%`,
}}
>
</div>
</div>
</ProseKit>
)
}import { defineBasicExtension } from 'prosekit/basic'
import { union } from 'prosekit/core'
import { definePageBreak, definePageRendering } from 'prosekit/extensions/page'
export function defineExtension() {
return union(
defineBasicExtension(),
definePageBreak(),
definePageRendering(),
)
}
export type EditorExtension = ReturnType<typeof defineExtension>'use client'
export { default as ExampleEditor } from './editor'import { useCallback, useState } from 'react'
const ZOOM_STEPS = [25, 50, 75, 100, 125, 150, 200]
const DEFAULT_ZOOM = 50
export function useZoom() {
const [zoom, setZoom] = useState(DEFAULT_ZOOM)
const zoomIn = useCallback(() => {
setZoom((z) => {
const next = ZOOM_STEPS.find((s) => s > z)
return next ?? z
})
}, [])
const zoomOut = useCallback(() => {
setZoom((z) => {
const prev = [...ZOOM_STEPS].reverse().find((s) => s < z)
return prev ?? z
})
}, [])
const canZoomIn = zoom < ZOOM_STEPS[ZOOM_STEPS.length - 1]
const canZoomOut = zoom > ZOOM_STEPS[0]
return { zoom, zoomIn, zoomOut, canZoomIn, canZoomOut }
}import type { NodeJSON } from 'prosekit/core'
export const sampleContent: NodeJSON = {
type: 'doc',
content: [
{
type: 'heading',
attrs: { level: 1 },
content: [{ type: 'text', text: 'Page Layout Demo' }],
},
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'This is the first page.',
},
],
},
{
type: 'paragraph',
content: [
{
type: 'text',
text:
'The content below will be on a new page because of a page break. You can insert a page break by pressing Command+Enter on Mac or Ctrl+Enter on Windows and Linux.',
},
],
},
{ type: 'pageBreak' },
{
type: 'heading',
attrs: { level: 1 },
content: [{ type: 'text', text: 'Page 2' }],
},
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'This is the second page.',
},
],
},
{
type: 'paragraph',
content: [
{
type: 'text',
text:
'When the content on a page exceeds the available height, it will automatically flow to the next page. This is similar to how traditional word processors like Microsoft Word handle pagination.',
},
],
},
{ type: 'image', attrs: { src: 'https://placehold.co/600x500' } },
{ type: 'image', attrs: { src: 'https://placehold.co/600x500' } },
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'The images above exceed the available height on the second page, so they automatically flow to the next page.',
},
],
},
{
type: 'heading',
attrs: { level: 2 },
content: [{ type: 'text', text: 'Known Limitation' }],
},
{
type: 'paragraph',
content: [
{
type: 'text',
text:
'Page breaks only occur between block elements. A single block taller than the remaining space on the page will overflow to the next page rather than split. In other words, you cannot split a node like paragraph or a table across pages. The paragraph below demonstrates this.',
},
],
},
{
type: 'paragraph',
content: [
{
type: 'text',
marks: [{ type: 'bold' }],
text: 'This is a very long paragraph that demonstrates the limitation of block-level pagination.',
},
{
type: 'text',
text: ' ',
},
{
type: 'text',
marks: [{ type: 'italic' }],
text:
'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt.',
},
],
},
],
}import 'prosekit/basic/style.css'
import 'prosekit/basic/typography.css'
import 'prosekit/extensions/page/style.css'
import { useMemo } from 'preact/hooks'
import { clsx, createEditor, type NodeJSON } from 'prosekit/core'
import { ProseKit } from 'prosekit/preact'
import { sampleContent } from '../../sample/sample-doc-page'
import { defineExtension } from './extension'
import { useZoom } from './use-zoom'
interface EditorProps {
initialContent?: NodeJSON
}
export default function Editor(props: EditorProps) {
const defaultContent = props.initialContent ?? sampleContent
const editor = useMemo(() => {
const extension = defineExtension()
return createEditor({
extension,
defaultContent,
})
}, [defaultContent])
const { zoom, zoomIn, zoomOut, canZoomIn, canZoomOut } = useZoom()
return (
<ProseKit editor={editor}>
<div className="absolute top-4 left-4 flex items-center justify-center gap-1 border-gray-300 dark:border-gray-600 border p-2 text-sm text-gray-500 select-none print:hidden z-50 bg-gray-50 rounded">
<button className="size-6 flex justify-center items-center rounded hover:bg-gray-200 dark:hover:bg-gray-700 disabled:opacity-30" onClick={zoomOut} disabled={!canZoomOut}>-</button>
<span className="w-12 text-center tabular-nums">{zoom}%</span>
<button className="size-6 flex justify-center items-center rounded hover:bg-gray-200 dark:hover:bg-gray-700 disabled:opacity-30" onClick={zoomIn} disabled={!canZoomIn}>+</button>
</div>
<div className="relative w-full flex-1 box-border overflow-y-auto">
<div
ref={editor.mount}
className={clsx('ProseMirror box-border min-h-full px-[max(4rem,calc(50%-20rem))] py-8 outline-hidden outline-0 [&_span[data-mention=user]]:text-blue-500 [&_span[data-mention=tag]]:text-violet-500', 'print:transform-none! print:min-h-full! print:p-0! print:m-0!')}
style={{
transform: `scale(${zoom / 100})`,
transformOrigin: 'top',
minHeight: `${100 / (zoom / 100)}%`,
}}
>
</div>
</div>
</ProseKit>
)
}import { defineBasicExtension } from 'prosekit/basic'
import { union } from 'prosekit/core'
import { definePageBreak, definePageRendering } from 'prosekit/extensions/page'
export function defineExtension() {
return union(
defineBasicExtension(),
definePageBreak(),
definePageRendering(),
)
}
export type EditorExtension = ReturnType<typeof defineExtension>export { default as ExampleEditor } from './editor'import { useCallback, useState } from 'preact/hooks'
const ZOOM_STEPS = [25, 50, 75, 100, 125, 150, 200]
const DEFAULT_ZOOM = 50
export function useZoom() {
const [zoom, setZoom] = useState(DEFAULT_ZOOM)
const zoomIn = useCallback(() => {
setZoom((z) => {
const next = ZOOM_STEPS.find((s) => s > z)
return next ?? z
})
}, [])
const zoomOut = useCallback(() => {
setZoom((z) => {
const prev = [...ZOOM_STEPS].reverse().find((s) => s < z)
return prev ?? z
})
}, [])
const canZoomIn = zoom < ZOOM_STEPS[ZOOM_STEPS.length - 1]
const canZoomOut = zoom > ZOOM_STEPS[0]
return { zoom, zoomIn, zoomOut, canZoomIn, canZoomOut }
}import type { NodeJSON } from 'prosekit/core'
export const sampleContent: NodeJSON = {
type: 'doc',
content: [
{
type: 'heading',
attrs: { level: 1 },
content: [{ type: 'text', text: 'Page Layout Demo' }],
},
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'This is the first page.',
},
],
},
{
type: 'paragraph',
content: [
{
type: 'text',
text:
'The content below will be on a new page because of a page break. You can insert a page break by pressing Command+Enter on Mac or Ctrl+Enter on Windows and Linux.',
},
],
},
{ type: 'pageBreak' },
{
type: 'heading',
attrs: { level: 1 },
content: [{ type: 'text', text: 'Page 2' }],
},
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'This is the second page.',
},
],
},
{
type: 'paragraph',
content: [
{
type: 'text',
text:
'When the content on a page exceeds the available height, it will automatically flow to the next page. This is similar to how traditional word processors like Microsoft Word handle pagination.',
},
],
},
{ type: 'image', attrs: { src: 'https://placehold.co/600x500' } },
{ type: 'image', attrs: { src: 'https://placehold.co/600x500' } },
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'The images above exceed the available height on the second page, so they automatically flow to the next page.',
},
],
},
{
type: 'heading',
attrs: { level: 2 },
content: [{ type: 'text', text: 'Known Limitation' }],
},
{
type: 'paragraph',
content: [
{
type: 'text',
text:
'Page breaks only occur between block elements. A single block taller than the remaining space on the page will overflow to the next page rather than split. In other words, you cannot split a node like paragraph or a table across pages. The paragraph below demonstrates this.',
},
],
},
{
type: 'paragraph',
content: [
{
type: 'text',
marks: [{ type: 'bold' }],
text: 'This is a very long paragraph that demonstrates the limitation of block-level pagination.',
},
{
type: 'text',
text: ' ',
},
{
type: 'text',
marks: [{ type: 'italic' }],
text:
'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt.',
},
],
},
],
}Page Rendering
Section titled “Page Rendering”Use definePageRendering to enable the paginated layout. You also need to import the page stylesheet.
import 'prosekit/extensions/page/style.css'
import { definePageRendering function definePageRendering(options?: PageRenderingOptions): PageRenderingExtension@public } from 'prosekit/extensions/page'
const extension const extension: PageRenderingExtension = definePageRendering function definePageRendering(options?: PageRenderingOptions): PageRenderingExtension@public ()Page Options
Section titled “Page Options”You can customize the page dimensions and margins:
import { definePageRendering function definePageRendering(options?: PageRenderingOptions): PageRenderingExtension@public } from 'prosekit/extensions/page'
const extension const extension: PageRenderingExtension = definePageRendering function definePageRendering(options?: PageRenderingOptions): PageRenderingExtension@public ({
pageWidth PageRenderingOptions.pageWidth?: number | undefinedThe width of the page in px.@default794 (Portrait A4 paper size in 96 DPI) : 794, // A4 width at 96 DPI (default)
pageHeight PageRenderingOptions.pageHeight?: number | undefinedThe height of the page in px.@default1123 (Portrait A4 paper size in 96 DPI) : 1123, // A4 height at 96 DPI (default)
marginTop PageRenderingOptions.marginTop?: number | undefinedThe top margin of the page in px.@default70 : 70,
marginRight PageRenderingOptions.marginRight?: number | undefinedThe right margin of the page in px.@default70 : 70,
marginBottom PageRenderingOptions.marginBottom?: number | undefinedThe bottom margin of the page in px.@default70 : 70,
marginLeft PageRenderingOptions.marginLeft?: number | undefinedThe left margin of the page in px.@default70 : 70,
})Page Break
Section titled “Page Break”Use definePageBreak to add support for manual page breaks. This adds a pageBreak node type to the schema and registers a keyboard shortcut.
import { definePageBreak function definePageBreak(): PageBreakExtension@public , definePageRendering function definePageRendering(options?: PageRenderingOptions): PageRenderingExtension@public } from 'prosekit/extensions/page'
import { union function union<const E extends readonly Extension[]>(...exts: E): Union<E> (+1 overload)Merges multiple extensions into one. You can pass multiple extensions as
arguments or a single array containing multiple extensions.@throwsIf no extensions are provided.@example```ts
function defineFancyNodes() {
return union(
defineFancyParagraph(),
defineFancyHeading(),
)
}
```@example```ts
function defineFancyNodes() {
return union([
defineFancyParagraph(),
defineFancyHeading(),
])
}
```@public } from 'prosekit/core'
const extension const extension: Union<readonly [PageRenderingExtension, PageBreakExtension]> = union union<readonly [PageRenderingExtension, PageBreakExtension]>(exts_0: PageRenderingExtension, exts_1: PageBreakExtension): Union<readonly [PageRenderingExtension, PageBreakExtension]> (+1 overload)Merges multiple extensions into one. You can pass multiple extensions as
arguments or a single array containing multiple extensions.@throwsIf no extensions are provided.@example```ts
function defineFancyNodes() {
return union(
defineFancyParagraph(),
defineFancyHeading(),
)
}
```@example```ts
function defineFancyNodes() {
return union([
defineFancyParagraph(),
defineFancyHeading(),
])
}
```@public (
definePageRendering function definePageRendering(options?: PageRenderingOptions): PageRenderingExtension@public (),
definePageBreak function definePageBreak(): PageBreakExtension@public (),
)Commands
Section titled “Commands”insertPageBreak
Section titled “insertPageBreak”Insert a manual page break at the current cursor position. The page break forces content after it onto a new page regardless of remaining space.
editor const editor: Editor<Union<readonly [PageRenderingExtension, PageBreakExtension]>> .commands Editor<Union<readonly [PageRenderingExtension, PageBreakExtension]>>.commands: ToCommandAction<{
insertPageBreak: [];
}>
All
{@link
CommandAction
}
s defined by the editor. .insertPageBreak insertPageBreak: CommandAction
() => boolean
Execute the current command. Return `true` if the command was successfully
executed, otherwise `false`. ()Keyboard Shortcuts
Section titled “Keyboard Shortcuts”| Non-Apple | Apple | Description |
|---|---|---|
| CtrlEnter | CommandEnter | Insert a page break |
Printing
Section titled “Printing”The page extension supports printing via the browser's print dialog. The print output matches the on-screen page layout — each page in the editor becomes a page in the printed document.
Try it out by opening this link and pressing CtrlP (Windows/Linux) or CommandP (Mac).
Known Limitations
Section titled “Known Limitations”- Block-level pagination only. Page breaks can only occur between top-level block elements (paragraphs, headings, images, etc.). A single block element that is taller than the available page height will overflow rather than split across pages. For example, a very long paragraph or a large table cannot be split at a line break.