Examples
Document Editor
A full-featured document editor with toolbar, bubble menu, slash commands, and auto-save.
This example shows a production-ready document editor with a top toolbar, a bubble formatting
menu, a slash command palette, and auto-save to localStorage. The source is derived from the
reference implementation in apps/demo.
Setup
pnpm add @scrivr/core @scrivr/reactThe Component
'use client';
import { useState } from 'react';
import {
useScrivrEditor,
useScrivrState,
Scrivr,
BubbleMenu,
SlashMenu,
StarterKit,
} from '@scrivr/react';
import { Extension } from '@scrivr/core';
interface DocumentEditorProps {
/** ProseMirror JSON to open. Pass null for an empty document. */
initialDoc: Record<string, unknown> | null;
onUpdate?: (json: Record<string, unknown>) => void;
}
export function DocumentEditor({ initialDoc, onUpdate }: DocumentEditorProps) {
// useState initializer captures initialDoc once at mount time.
const [extensions] = useState(() => [
StarterKit,
Extension.create({
name: 'initialContent',
addInitialDoc() {
return initialDoc ? this.schema.nodeFromJSON(initialDoc) : null;
},
}),
]);
const editor = useScrivrEditor({
extensions,
onUpdate: ({ editor }) => {
onUpdate?.(editor.getState().doc.toJSON());
},
});
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100vh' }}>
<Toolbar editor={editor} />
<div style={{ flex: 1, overflow: 'auto', background: '#f0f0f0' }}>
<Scrivr editor={editor} style={{ minHeight: '100%' }} />
<BubbleMenu editor={editor}>
<FormatButtons editor={editor} />
</BubbleMenu>
<SlashMenu editor={editor} />
</div>
</div>
);
}
// ── Toolbar ───────────────────────────────────────────────────────────────────
function Toolbar({ editor }: { editor: ReturnType<typeof useScrivrEditor> }) {
const isBold = useScrivrState({
editor,
selector: ({ editor }) => editor?.isActive('bold') ?? false,
});
const isItalic = useScrivrState({
editor,
selector: ({ editor }) => editor?.isActive('italic') ?? false,
});
return (
<div style={{ padding: '8px 16px', borderBottom: '1px solid #ddd', display: 'flex', gap: 8 }}>
<button onClick={() => editor?.commands.toggleBold()} style={{ fontWeight: isBold ? 'bold' : 'normal' }}>
B
</button>
<button onClick={() => editor?.commands.toggleItalic()} style={{ fontStyle: isItalic ? 'italic' : 'normal' }}>
I
</button>
<button onClick={() => editor?.commands.setHeading({ level: 1 })}>H1</button>
<button onClick={() => editor?.commands.setHeading({ level: 2 })}>H2</button>
<button onClick={() => editor?.commands.toggleBulletList()}>• List</button>
<button onClick={() => editor?.commands.undo()}>↩ Undo</button>
<button onClick={() => editor?.commands.redo()}>↪ Redo</button>
</div>
);
}
// ── Bubble menu buttons ───────────────────────────────────────────────────────
function FormatButtons({ editor }: { editor: ReturnType<typeof useScrivrEditor> }) {
return (
<>
<button onClick={() => editor?.commands.toggleBold()}>B</button>
<button onClick={() => editor?.commands.toggleItalic()}>I</button>
<button onClick={() => editor?.commands.toggleUnderline()}>U</button>
</>
);
}Key points
- Props-based —
DocumentEditoracceptsinitialDocas a prop. The parent component decides how to fetch (API, localStorage, React Query, etc.) and passes the result down. useStateinitializer capturesinitialDoconce at mount time so it is available toaddInitialDoc()on construction — even if the parent re-renders before the effect fires.addInitialDoc()is the extension hook that seeds the editor with an initial ProseMirror document. Returningnullstarts with an empty document instead.onUpdatesurfaces changes back to the parent. Debounce or batch this in production before writing to your backend.useScrivrStatemakes the toolbar reactive — it re-renders only the toolbar, not the canvas, on each state change.BubbleMenuandSlashMenuare standalone React components from@scrivr/react— no manual wiring needed.