Scrivr
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/react

The Component

DocumentEditor.tsx
'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-basedDocumentEditor accepts initialDoc as a prop. The parent component decides how to fetch (API, localStorage, React Query, etc.) and passes the result down.
  • useState initializer captures initialDoc once at mount time so it is available to addInitialDoc() 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. Returning null starts with an empty document instead.
  • onUpdate surfaces changes back to the parent. Debounce or batch this in production before writing to your backend.
  • useScrivrState makes the toolbar reactive — it re-renders only the toolbar, not the canvas, on each state change.
  • BubbleMenu and SlashMenu are standalone React components from @scrivr/react — no manual wiring needed.

Further reading

On this page