Install the Export Toolbar Button component to add the menu to an editor toolbar.
'use client';
import { ArrowDownToLineIcon } from 'lucide-react';
import { createEditorView } from 'platejs';
import { DefaultAuthoredPlugin, isAuthoredEditor } from 'platejs/authored';
import { CommentsPlugin } from 'platejs/comments/react';
import { exportDocx, type DocxComment } from 'platejs/docx/export';
import { MarkdownPlugin } from 'platejs/markdown';
import { useEditor, useEditorSelector, useModelEditor } from 'platejs/react';
import { renderStaticHtml } from 'platejs/static';
import * as React from 'react';
import { toast } from 'sonner';
import { DOCX_EXPORT_STYLES } from '@/components/editor/docx-export';
import { useDocxSource } from '@/components/editor/docx-source';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/editor/dropdown-menu';
import { ToolbarButton } from '@/components/editor/toolbar';
import { EditorStatic } from './editor-static';
type CleanProjection = 'accepted' | 'proposed';
const downloadBlob = (blob: Blob, filename: string) => {
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.append(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
};
const absoluteCssUrls = (css: string, base: string) =>
css.replaceAll(
/url\((['"]?)([^'")]+)\1\)/g,
(match, quote: string, url: string) =>
url.startsWith('data:') || url.startsWith('#')
? match
: `url(${quote}${new URL(url, base).href}${quote})`
);
// Inline the app's own CSS so the file renders with the same styles without
// depending on a stylesheet URL that changes between deployments.
const readAppStyles = () =>
Array.from(document.styleSheets, (sheet) => {
try {
const css = Array.from(sheet.cssRules, (rule) => rule.cssText).join('\n');
return `<style>${absoluteCssUrls(css, sheet.href ?? document.baseURI)}</style>`;
} catch {
// Cross-origin rules are unreadable; keep the app's link instead.
return sheet.href ? `<link rel="stylesheet" href="${sheet.href}" />` : '';
}
}).join('\n');
const toastWarnings = (
diagnostics: ReadonlyArray<Readonly<{ severity: 'error' | 'warning' }>>
) => {
const warningCount = diagnostics.filter(
({ severity }) => severity === 'warning'
).length;
if (warningCount > 0) {
toast.warning(
`Exported with ${warningCount} warning${warningCount === 1 ? '' : 's'}.`
);
}
};
export function ExportToolbarButton() {
const editor = useEditor();
const model = useModelEditor();
const commentsInstalled = editor.plugin(CommentsPlugin).installed;
const docxSource = useDocxSource();
const [open, setOpen] = React.useState(false);
const [projectionChoice, setProjectionChoice] =
React.useState<CleanProjection>();
const authoredState = useEditorSelector((current) => {
const authored = current.plugin(DefaultAuthoredPlugin);
if (!authored.installed) return 'clean:proposed';
const unresolved = (['pending', 'conflicted'] as const).some(
(status) =>
authored.read.changes({
limit: 1,
status,
}).items.length > 0
);
return `${unresolved ? 'unresolved' : 'clean'}:${authored.read.view().projection}`;
});
const [authoredStatus, mountedProjection] = authoredState.split(':');
const unresolved = authoredStatus === 'unresolved';
const projection =
projectionChoice ??
(mountedProjection === 'accepted' ? 'accepted' : 'proposed');
const exportToHtml = async () => {
const result = await renderStaticHtml(model, {
component: EditorStatic,
projection,
props: { style: { padding: '0 calc(50% - 350px)', paddingBottom: '' } },
});
const html = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
${readAppStyles()}
</head>
<body class="${document.body.className}">
${result.data}
</body>
</html>`;
downloadBlob(new Blob([html], { type: 'text/html' }), 'plate.html');
toastWarnings(result.diagnostics);
};
const exportToMarkdown = () => {
const result = model.plugin(MarkdownPlugin).api.serialize({ projection });
if (!result.ok) {
toast.error(
result.diagnostics.find(({ severity }) => severity === 'error')
?.message ?? 'Markdown export failed.'
);
return;
}
downloadBlob(
new Blob([result.data], { type: 'text/markdown' }),
'plate.md'
);
toastWarnings(result.diagnostics);
};
const exportToWord = async (
requestedProjection?: CleanProjection | 'review'
) => {
let omittedCommentCount = 0;
const docxComments: DocxComment[] = [];
if (commentsInstalled) {
const commentEditor = isAuthoredEditor(model)
? createEditorView(model, {
authored: { intent: 'edit', projection: 'proposed' },
})
: model;
const comments = commentEditor.plugin(CommentsPlugin);
const users = comments.store.get('users');
comments.api.getThreads().forEach((thread) => {
if (thread.status !== 'published') return;
const attachment = comments.api.attachment(thread.id);
if (attachment?.type !== 'range' || attachment.status !== 'attached') {
omittedCommentCount += 1;
return;
}
const parentId = thread.messages[0]
? `${thread.id}:${thread.messages[0].id}`
: null;
thread.messages.forEach((message, index) => {
const user = users[message.userId];
const name = user?.name ?? 'Unknown';
const initials = name
.split(/\s+/)
.filter(Boolean)
.map((part) => part[0])
.join('')
.slice(0, 4);
docxComments.push({
author: { ...(initials ? { initials } : {}), name },
body: message.body,
createdAt: message.createdAt,
durableId: null,
id: `${thread.id}:${message.id}`,
parentId: index === 0 ? null : parentId,
resolved: index === 0 ? thread.resolution !== null : null,
target: { range: attachment.range },
});
});
});
}
const wordProjection =
requestedProjection ?? (unresolved ? projection : ('review' as const));
const result = await exportDocx(model, {
comments: docxComments,
component: EditorStatic,
// The download still completes; the toasts below report what was lost.
lossPolicy: 'allow',
projection: wordProjection,
source: docxSource?.source,
stylesheet: DOCX_EXPORT_STYLES,
});
if (!result.ok) {
toast.error(
result.diagnostics.find(({ severity }) => severity === 'error')
?.message ?? 'The Word document could not be exported.'
);
return;
}
downloadBlob(result.blob, 'plate.docx');
toastWarnings(result.diagnostics);
if (omittedCommentCount > 0) {
toast.warning(
`${omittedCommentCount} comment thread${omittedCommentCount === 1 ? '' : 's'} could not be attached to the Word document.`
);
}
};
return (
<DropdownMenu
open={open}
onOpenChange={(nextOpen) => {
if (nextOpen) setProjectionChoice(undefined);
setOpen(nextOpen);
}}
modal={false}
>
<DropdownMenuTrigger>
<ToolbarButton
aria-label="Export"
pressed={open}
tooltip="Export"
isDropdown
>
<ArrowDownToLineIcon className="size-4" />
</ToolbarButton>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
{unresolved && (
<>
<DropdownMenuLabel>Unresolved suggestions</DropdownMenuLabel>
<DropdownMenuRadioGroup
value={projection}
onValueChange={(value) =>
setProjectionChoice(
value === 'accepted' ? 'accepted' : 'proposed'
)
}
>
<DropdownMenuRadioItem
value="proposed"
onSelect={(event) => event.preventDefault()}
>
Include suggested changes
</DropdownMenuRadioItem>
<DropdownMenuRadioItem
value="accepted"
onSelect={(event) => event.preventDefault()}
>
Exclude suggested changes
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
<DropdownMenuSeparator />
</>
)}
<DropdownMenuGroup>
<DropdownMenuItem onSelect={exportToHtml}>
Export as HTML
</DropdownMenuItem>
<DropdownMenuItem onSelect={exportToMarkdown}>
Export as Markdown
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => exportToWord()}>
Export as Word
</DropdownMenuItem>
{unresolved && (
<DropdownMenuItem onSelect={() => exportToWord('review')}>
Export as Word with tracked changes
</DropdownMenuItem>
)}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
);
}'use client';
import { ArrowDownToLineIcon } from 'lucide-react';
import { createEditorView } from 'platejs';
import { DefaultAuthoredPlugin, isAuthoredEditor } from 'platejs/authored';
import { CommentsPlugin } from 'platejs/comments/react';
import { exportDocx, type DocxComment } from 'platejs/docx/export';
import { MarkdownPlugin } from 'platejs/markdown';
import { useEditor, useEditorSelector, useModelEditor } from 'platejs/react';
import { renderStaticHtml } from 'platejs/static';
import * as React from 'react';
import { toast } from
| Format | Implementation | Output |
|---|---|---|
| HTML | renderStaticHtml(model, { component: EditorStatic, projection }) from platejs/static | plate.html |
| Markdown | model.api.markdown.serialize({ projection }) | plate.md |
| Word | exportDocx(model, { component: EditorStatic, projection, stylesheet: DOCX_EXPORT_STYLES }) from platejs/docx/export | plate.docx |
Every format captures the same complete document and explicit authored
projection from useModelEditor(). Export reads model content rather than the
DOM, so selection, placeholders, and review decorations do not enter the file.
The current editor supplies its compiled schema, format mappings, and static component
bindings; export does not create a second conversion editor.
The HTML file inlines the app's own stylesheets and copies the page's body
classes. Cross-origin stylesheets stay as links.
When the document has unresolved suggestions, the menu shows an Unresolved suggestions choice:
| Choice | Projection | Result |
|---|---|---|
| Include suggested changes | proposed | The document with pending suggestions applied |
| Exclude suggested changes | accepted | The document without pending suggestions |
The initial choice follows the mounted view. HTML, Markdown, and Word use the
same choice. Export as Word with tracked changes uses review, which writes
pending suggestions as Word revisions. A conflict has no faithful Word
revision, so review export returns an error until the conflict is resolved.
Without unresolved suggestions, Word export uses review. A retained,
unchanged imported DOCX can then be returned byte for byte. Each exporter
returns diagnostics, which the menu presents as warning or error toasts.
DOCX owns semantic mappings for Plate code blocks, columns, equations, callouts, headings, and tables of contents. The registry keeps only the copied visual stylesheet:
export const DOCX_EXPORT_STYLES = `
body {
font-family: 'Calibri', 'Arial', sans-serif;
font-size: 11pt;
line-height: 1.5;
color: #000;
margin: 0;
padding: 20px;
}
h1 { font-size: 24pt; font-weight: bold; margin: 0 0 12pt 0; }
h2 { font-size: 18pt; font-weight: bold; margin: 0 0 10pt 0; }
h3 { font-size: 14pt; font-weight: bold; margin: 0 0 8pt 0; }
h4 { font-size: 12pt; font-weight: bold; margin: 0 0 6pt 0; }
h5 { font-size: 11pt; font-weight: bold; margin: 0 0 6pt 0; }
h6 { font-size: 10pt; font-weight: bold; margin: 0 0 6pt 0; }
p { margin: 0 0 8pt 0; }
ul, ol { margin: 0 0 8pt 0; padding-left: 20pt; }
li { margin: 0 0 4pt 0; }
strong, b { font-weight: bold; }
em, i { font-style: italic; }
u { text-decoration: underline; }
s, strike, del { text-decoration: line-through; }
code {
font-family: 'Courier New', Consolas, monospace;
background-color: #f5f5f5;
padding: 2px 4px;
border-radius: 3px;
}
pre {
font-family: 'Courier New', Consolas, monospace;
background-color: #f5f5f5;
padding: 10px;
margin: 0 0 8pt 0;
white-space: pre-wrap;
border-radius: 4px;
}
.hljs-addition, .hljs-name, .hljs-quote, .hljs-selector-pseudo, .hljs-selector-tag { color: #22863a; }
.hljs-attr, .hljs-attribute, .hljs-literal, .hljs-meta, .hljs-number, .hljs-operator,
.hljs-section, .hljs-selector-attr, .hljs-selector-class, .hljs-selector-id, .hljs-variable { color: #005cc5; }
.hljs-built_in, .hljs-symbol { color: #e36209; }
.hljs-bullet { color: #735c0f; }
.hljs-comment, .hljs-formula { color: #6a737d; }
.hljs-deletion { color: #b31d28; }
.hljs-doctag, .hljs-keyword, .hljs-template-tag, .hljs-template-variable, .hljs-type { color: #d73a49; }
.hljs-regexp, .hljs-string { color: #032f62; }
.hljs-title { color: #6f42c1; }
.hljs-emphasis { font-style: italic; }
.hljs-section, .hljs-strong { font-weight: bold; }
blockquote {
border-left: 3px solid #ccc;
margin: 0 0 8pt 0;
padding-left: 10pt;
color: #666;
font-style: italic;
}
table {
border-collapse: collapse;
width: 100%;
margin: 0 0 8pt 0;
}
th, td {
border: 1px solid #ccc;
padding: 6pt;
text-align: left;
}
th {
background-color: #f5f5f5;
font-weight: bold;
}
a {
color: #0066cc;
text-decoration: underline;
}
img {
max-width: 100%;
height: auto;
}
hr {
border: none;
border-top: 1px solid #ccc;
margin: 12pt 0;
}
sup { vertical-align: super; font-size: 8pt; }
sub { vertical-align: sub; font-size: 8pt; }
mark { background-color: #ffff00; }
`.trim();export const DOCX_EXPORT_STYLES = `
body {
font-family: 'Calibri', 'Arial', sans-serif;
font-size: 11pt;
line-height: 1.5;
color: #000;
margin: 0;
padding: 20px;
}
h1 { font-size: 24pt; font-weight: bold; margin: 0 0 12pt 0; }
h2 { font-size: 18pt; font-weight: bold; margin: 0 0 10pt 0; }
h3 { font-size: 14pt; font-weight: bold; margin: 0 0 8pt 0; }
h4 { font-size: 12pt; font-weight: bold; margin: 0 0 6pt 0; }
h5 { font-size: 11pt; font-weight: bold; margin: 0 0 6pt 0; }
h6 { font-size: 10pt; font-weight: bold; margin: 0 0 6pt 0; }
p { margin: 0 0 8pt 0; }
ul, ol { margin: 0 0 8pt 0; padding-left: 20pt; }
li { margin: 0 0 4pt 0; }
strong, b { font-weight: bold; }
em, i { font-style: italic; }
u { text-decoration: underline; }
s, strike, del { text-decoration: line-through; }
code {
font-family: 'Courier New', Consolas, monospace;
background-color: #f5f5f5;
Plate Plus includes a server-side export flow for PDF output with page settings.