Vibes DIY
Vibes DIY / Docs / Reference
creator docs · reference · generated

Rich text — notebooks, journals, wikis and documents with Tiptap

This is the same document the Vibes code generator reads when it builds apps — rendered from prompts/pkg/llms/richtext.md on every deploy, so it can't drift from what the platform actually does. Read it from your terminal any time with npx vibes-diy skills.

When someone asks for a notebook, a journal, a wiki, a notes app, a document editor, a place to write with headings, lists, checklists, links and images, a shared page a team edits, meeting notes, a recipe book with real pages, or any app whose heart is writing more than a sentence, this is the recipe. Tiptap (MIT, on ProseMirror) is the editor: the app mounts it on a plain element through a small helper module, keeps each document as Tiptap's JSON in Fireproof, saves after typing pauses, loads a document without echoing it back as a save, and styles the written text with Tailwind on the host.

The sibling boundary: a short field (a title, a comment, a caption) is pouf's Textarea in a Field; a feed of posts is pouf-feed; a pipeline of stories with stages is pouf-editorial. Rich text is the right call when the person is writing a page.

The worked examples below were built and measured for this doc (Chromium, 2026-10-07, Tiptap 3.31.4 from esm.sh): a notebook with a list of notes and one editor, and a shared page a team edits together.

Project shape

App.jsx          the notebook: a list of documents, one editor, a toolbar, save state
lib/editor.js    createEditor(host, …): the pinned imports, change events, load, flush, commands, destroy
access.js        who may write which documents

One ProseMirror, pinned

Import every Tiptap package by its full esm.sh URL, and every Tiptap package is pinned to the same version, in lib/editor.js at the top of the file. ProseMirror is unforgiving about copies: two versions of @tiptap/pm in one editor throw on the first keyed plugin, and nothing in the runtime keeps a family of packages on one version (each bare import resolves to its own latest). Measured: with @tiptap/core, starter-kit, extension-placeholder, extension-task-list, extension-task-item, extension-link and extension-image all at 3.31.4, the editor's state and view are instances of the one @tiptap/pm the pinned URLs share, and the whole set arrives in about 2 s cold. The core entry is the one to use: new Editor({ element }) mounts into a ref with no React of its own, so the app's one React stays the only one. (@tiptap/react exists; the core entry does the same job here with one fewer thing to keep in step.)

lib/editor.js

js// lib/editor.js — a Tiptap editor behind a small surface: create it on a host element, hear about
// changes as JSON plus plain text (debounced for saving), load a document without echoing it back
// as a change, run toolbar commands, and destroy it on unmount. One ProseMirror: every package is
// pinned to the same version so esm.sh serves one copy of @tiptap/pm.
import { Editor } from "https://esm.sh/@tiptap/[email protected]";
import StarterKit from "https://esm.sh/@tiptap/[email protected]";
import Placeholder from "https://esm.sh/@tiptap/[email protected]";
import TaskList from "https://esm.sh/@tiptap/[email protected]";
import TaskItem from "https://esm.sh/@tiptap/[email protected]";
import Link from "https://esm.sh/@tiptap/[email protected]";
import Image from "https://esm.sh/@tiptap/[email protected]";

export const EMPTY_DOC = { type: "doc", content: [{ type: "paragraph" }] };

/** The first heading's text, else the first words of the body: what a document list shows. */
export function titleOf(json, fallback = "Untitled") {
  const blocks = json?.content ?? [];
  const heading = blocks.find((b) => b.type === "heading");
  const text = (node) => (node.content ?? []).map((n) => n.text ?? text(n)).join("");
  const fromHeading = heading ? text(heading).trim() : "";
  if (fromHeading) return fromHeading.slice(0, 80);
  const first = blocks.map(text).find((t) => t.trim());
  return first ? first.trim().slice(0, 80) : fallback;
}

/**
 * createEditor(host, { content, placeholder, editable, onChange, debounceMs })
 * `onChange(json, text)` fires after typing pauses for `debounceMs`; `load(json)` replaces the
 * document without firing it, so a document arriving from the database lands as a load, with no save.
 */
export function createEditor(host, { content = EMPTY_DOC, placeholder = "Start writing…", editable = true, onChange, debounceMs = 600 } = {}) {
  let timer = 0;
  let loading = false;
  const editor = new Editor({
    element: host,
    editable,
    content,
    extensions: [
      StarterKit,
      Placeholder.configure({ placeholder }),
      TaskList,
      TaskItem.configure({ nested: true }),
      Link.configure({ openOnClick: false, autolink: true }),
      Image.configure({ inline: false }),
    ],
    onUpdate: ({ transaction }) => {
      if (loading || !transaction.docChanged || !onChange) return;
      clearTimeout(timer);
      timer = setTimeout(() => {
        timer = 0; // the pause has landed; flush has nothing left to send
        onChange(editor.getJSON(), editor.getText());
      }, debounceMs);
    },
  });
  const surface = {
    editor,
    /** Replace the document (from the database); no change event follows. Declined while dirty unless forced. */
    load(json, { force = false } = {}) {
      if (JSON.stringify(json) === JSON.stringify(editor.getJSON())) return true;
      if (timer && !force) return false; // words are waiting to save: the person's keystrokes win
      clearTimeout(timer);
      timer = 0;
      loading = true;
      editor.commands.setContent(json ?? EMPTY_DOC, { emitUpdate: false });
      loading = false;
      return true;
    },
    /** True while a pause's words are still waiting to be saved. */
    isDirty() {
      return timer !== 0;
    },
    /** Save now, ahead of the debounce (on blur, on switching documents, on unmount). */
    flush() {
      if (!timer) return;
      clearTimeout(timer);
      timer = 0;
      onChange?.(editor.getJSON(), editor.getText());
    },
    setEditable(on) {
      editor.setEditable(on);
    },
    focus() {
      editor.commands.focus();
    },
    /** Toolbar commands by name, so buttons stay dumb. */
    run(command, value) {
      const c = editor.chain().focus();
      ({
        bold: () => c.toggleBold(),
        italic: () => c.toggleItalic(),
        heading: () => c.toggleHeading({ level: value ?? 2 }),
        bullets: () => c.toggleBulletList(),
        numbers: () => c.toggleOrderedList(),
        tasks: () => c.toggleTaskList(),
        quote: () => c.toggleBlockquote(),
        code: () => c.toggleCodeBlock(),
        link: () => (value ? c.setLink({ href: value }) : c.unsetLink()),
        image: () => c.setImage({ src: value }),
        undo: () => c.undo(),
        redo: () => c.redo(),
      })[command]?.().run();
    },
    isActive(name, attrs) {
      return editor.isActive(name, attrs);
    },
    destroy() {
      clearTimeout(timer);
      editor.destroy();
    },
  };
  return surface;
}

The notebook

App.jsx

jsximport React, { useEffect, useMemo, useRef, useState } from "react";
import { useFireproof } from "use-fireproof";
import { useVibe, useViewer } from "use-vibes";
import { Badge, Button, Card, Heading, IconButton, Row, Skeleton, Spacer, Stack, Text } from "@vibes.diy/pouf";
import { EMPTY_DOC, createEditor, titleOf } from "./lib/editor.js";

// The written page is styled from the host: Tailwind reaches into the editor with arbitrary
// variants, so the app ships no stylesheet and the JSX stays class-only.
const PAGE =
  "min-h-64 [&_.tiptap]:outline-none [&_h1]:text-2xl [&_h1]:font-bold [&_h1]:mt-4 [&_h2]:text-xl [&_h2]:font-semibold [&_h2]:mt-3 " +
  "[&_p]:my-2 [&_ul]:list-disc [&_ul]:pl-6 [&_ol]:list-decimal [&_ol]:pl-6 [&_blockquote]:border-l-4 [&_blockquote]:pl-3 [&_blockquote]:text-muted-foreground " +
  "[&_a]:underline [&_a]:text-primary [&_pre]:bg-muted [&_pre]:p-3 [&_pre]:rounded [&_img]:max-w-full [&_img]:rounded " +
  "[&_ul[data-type=taskList]]:list-none [&_ul[data-type=taskList]]:pl-0 [&_li[data-type=taskItem]]:flex [&_li[data-type=taskItem]]:gap-2 " +
  "[&_.is-editor-empty:first-child::before]:content-[attr(data-placeholder)] [&_.is-editor-empty:first-child::before]:text-muted-foreground [&_.is-editor-empty:first-child::before]:float-left [&_.is-editor-empty:first-child::before]:pointer-events-none";

const TOOLS = [
  ["bold", "B", "Bold"],
  ["italic", "I", "Italic"],
  ["heading", "H", "Heading"],
  ["bullets", "•", "Bullet list"],
  ["tasks", "☑", "Checklist"],
  ["quote", "❝", "Quote"],
];

export default function App() {
  const { database, useLiveQuery } = useFireproof("notebook");
  const { me, can, ready } = useVibe("notebook");
  const { requestLogin } = useViewer();
  const { docs } = useLiveQuery("type", { key: "note" });
  const notes = useMemo(() => [...docs].sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0)), [docs]);

  const [openId, setOpenId] = useState(null);
  const [saved, setSaved] = useState("idle"); // idle | typing | saved
  const [, bump] = useState(0); // re-render after a command so active states follow the cursor
  const hostRef = useRef(null);
  const surfaceRef = useRef(null);
  const openRef = useRef(null); // the open note as last saved, read by the save callback
  const open = notes.find((n) => n._id === openId) ?? null;

  // One editor for the page's life: created once, destroyed in the effect's cleanup. The save
  // callback reads the open note through a ref, so one editor serves every note without a rebuild.
  useEffect(() => {
    const surface = createEditor(hostRef.current, {
      placeholder: "Write your note…",
      onChange: async (json, text) => {
        const current = openRef.current;
        if (!current) return;
        // Each save carries its own id, so the copy the live query hands back is recognised as ours
        // whatever the clocks say.
        const next = { ...current, title: titleOf(json), body: json, text, updatedAt: Date.now(), writeId: crypto.randomUUID() };
        openRef.current = next;
        await database.put(next);
        setSaved("saved");
      },
    });
    surface.editor.on("transaction", () => bump((n) => n + 1));
    surface.editor.on("update", () => setSaved("typing"));
    surfaceRef.current = surface;
    return () => {
      surface.flush();
      surface.destroy();
      surfaceRef.current = null;
    };
  }, []);

  // A note arrives from the list (opened, or changed elsewhere): load it without a save echo. The
  // app's own save comes back with the writeId it sent and is left alone; another device's save
  // loads when nothing here is waiting to save, and otherwise waits for the next delivery (the
  // words typed here land first; theirs arrive on the round trip after).
  useEffect(() => {
    const surface = surfaceRef.current;
    if (!surface) return;
    if (!open) {
      openRef.current = null;
      surface.load(EMPTY_DOC, { force: true });
      surface.setEditable(false);
      return;
    }
    const switched = !openRef.current || openRef.current._id !== open._id;
    const theirs = !switched && open.writeId !== openRef.current.writeId;
    if (switched) {
      openRef.current = open;
      surface.load(open.body ?? EMPTY_DOC, { force: true });
    } else if (theirs && surface.load(open.body ?? EMPTY_DOC)) {
      openRef.current = open;
    }
    surface.setEditable(Boolean(ready && can.edit(open).ok));
  }, [open, ready]);

  async function newNote() {
    const draft = { type: "note", title: "Untitled", body: EMPTY_DOC, text: "", authorHandle: me?.userHandle };
    if (!can.create(draft).ok) return;
    surfaceRef.current?.flush();
    const doc = { ...draft, _id: crypto.randomUUID(), updatedAt: Date.now(), writeId: crypto.randomUUID() };
    await database.put(doc);
    setOpenId(doc._id);
    surfaceRef.current?.focus();
  }

  function pick(id) {
    surfaceRef.current?.flush();
    setOpenId(id);
  }

  const surface = surfaceRef.current;
  const editable = Boolean(open && ready && can.edit(open).ok);

  return (
    <main className="min-h-screen bg-background text-foreground p-4 md:p-8">
      <div className="grid gap-4 md:grid-cols-4">
        <div className="md:col-span-1">
        <Card variant="tight">
          <Stack gap={2}>
            <Row gap={2} align="center">
              <Heading level={1}>Notebook</Heading>
              <Spacer />
              {ready && me && <Button size="sm" onClick={newNote}>New</Button>}
            </Row>
            {!ready && <div aria-busy="true"><Skeleton variant="row" count={3} /></div>}
            {ready && !me && <Row gap={2} align="center" wrap><Text muted>Sign in and your notes stay with you.</Text><Button size="sm" variant="quiet" onClick={requestLogin}>Sign in</Button></Row>}
            {notes.map((n) => (
              <Button key={n._id} size="sm" variant={n._id === openId ? "solid" : "quiet"} onClick={() => pick(n._id)}>
                {n.title || "Untitled"}
              </Button>
            ))}
          </Stack>
        </Card>
        </div>

        <div className="md:col-span-3">
        <Card variant="tight">
          <Stack gap={3}>
            <Row gap={1} align="center" wrap>
              {TOOLS.map(([command, glyph, label]) => (
                <IconButton key={command} label={label} tone={surface?.isActive(command === "heading" ? "heading" : command === "bullets" ? "bulletList" : command === "tasks" ? "taskList" : command === "quote" ? "blockquote" : command) ? "blue" : undefined} disabled={!editable} onClick={() => surface?.run(command)}>
                  {glyph}
                </IconButton>
              ))}
              <Spacer />
              {open && <Badge tone={saved === "saved" ? "up" : "idle"}>{saved === "typing" ? "Typing…" : saved === "saved" ? "Saved" : editable ? "Ready" : "Read only"}</Badge>}
              {open && can.delete(open).ok && <Button size="sm" variant="quiet" tone="down" onClick={() => database.del(open._id).then(() => setOpenId(null))}>Delete</Button>}
            </Row>
            {!open && ready && <Text muted>Pick a note, or start a new one.</Text>}
            <div ref={hostRef} className={PAGE} onBlur={() => surfaceRef.current?.flush()} />
          </Stack>
        </Card>
        </div>
      </div>
    </main>
  );
}

access.js

js// note: a page in someone's notebook. Its author reads, edits and removes it.
export function notebook(doc, oldDoc, user, ctx) {
  if (!user) throw { forbidden: "sign in to keep notes" };
  if (doc.type === "note" || (doc._deleted && oldDoc && oldDoc.type === "note")) {
    const author = oldDoc ? oldDoc.authorHandle : doc.authorHandle;
    if (author !== user.userHandle) throw { forbidden: "notes are kept as yourself" };
    if (oldDoc && !doc._deleted && doc.authorHandle !== oldDoc.authorHandle) throw { forbidden: "a note keeps its author" };
    return { channels: [`notes:${user.userHandle}`], grant: { users: { [user.userHandle]: [`notes:${user.userHandle}`] } } };
  }
  throw { forbidden: "unknown document type" };
}

A saved note is its JSON plus the plain text

A saved note is its JSON plus the plain text: body holds editor.getJSON() (what the editor loads back, marks and attributes intact), text holds editor.getText() (what search, previews and an AI summary read), and title holds the first heading or the first words. HTML is a rendering, so it is not what gets kept: JSON survives a change of extensions, diffs cleanly, and is what setContent takes back without a parse. The list shows title; a search box filters on text; callAI gets text. The whole document is one Fireproof document, which is right for a page a person edits alone; a page several people edit at once wants the shared-page shape below.

Saving waits for typing to pause

Saving waits for typing to pause: the editor's onUpdate resets a timer and the save runs once the keys have been quiet for 600 ms, with flush() saving at once on blur, on switching documents and on unmount. A database.put on every keystroke floods the server with writes to one _id, which collide on the document's sequence and get rejected; one save per pause keeps writes rare and sequential (measured: three insertions in a burst produced one change event, and flush produced the next one at once). The timer clears itself when it fires, so a blur after a save sends nothing twice. The save callback reads the open note through a ref rather than a closure, so the editor is created once and switching notes costs nothing.

A title field saves with the body

A title field beside the editor saves through the same write as the body: one write helper merges every change into the open document and puts it, one put at a time, so the title saves after its own pause and closing the document waits for the page and the title to land together. A blog with drafts, a journal with named entries or a recipe book with a name field keeps the title in an Input above the page, and the writer expects both to be there when they step back to the list. Publishing then changes status and nothing else, so a private draft keeps every word its writer typed, title included.

jsxconst [title, setTitle] = useState(post.title);
const docRef = useRef(post); // the open document as last written
const queue = useRef(Promise.resolve()); // puts to one _id run in turn

// The page's onChange and the title both write here: the change merges into
// the open document at once, and its put waits for the one before it.
function write(changes) {
  docRef.current = { ...docRef.current, ...changes, updatedAt: Date.now() };
  const next = docRef.current;
  queue.current = queue.current.then(() => database.put(next));
  return queue.current;
}

// The title waits for its own pause, like the page.
useEffect(() => {
  if (title === docRef.current.title) return;
  const timer = setTimeout(() => write({ title }), 600);
  return () => clearTimeout(timer);
}, [title]);

// Back to the list: flush the page, add the title, and wait for both.
async function close() {
  surfaceRef.current?.flush();
  if (title !== docRef.current.title) write({ title });
  await queue.current;
  onClose();
}

The page's onChange calls the same helper, write({ body: json, text }). Because each change merges into docRef before its put is queued, the last put carries the page and the title together, and puts to one _id never overlap (overlapping puts collide on the document's sequence, as in the saving section above). The Input stays onChange={setTitle}; the effect does the saving.

The first draft stays open through its first save

A draft the empty view opens stays open through its first save: which document is open is state, and the draft joins it before its first put, never by a reading of the list. A blog, journal or recipe book whose list is empty may open straight onto a blank draft, so the first post is front and centre. That draft's first autosave puts it in the list, so a view that chose the editor because the list was empty (editing ?? (mine.length === 0 ? blank(id) : null)) flips back to the list mid-sentence. The put shows the draft in the list at once, before the save is confirmed, so handing the draft back once the put resolves is one render too late: the editor unmounts and remounts, and the cursor is gone. Hand the editor the open-state setter and call it in write before the put: the draft keeps its _id, so the editor keeps its key, and the writer keeps typing until they choose Back.

jsxconst [editing, setEditing] = useState(null); // the open draft
const [firstId] = useState(() => crypto.randomUUID()); // the empty view's draft
const target = editing ?? (mine.length === 0 ? blank(firstId) : null);

// In App: the draft's first change makes it the open draft.
<PostEditor key={target._id} initial={target} onOpen={setEditing} onClose={() => setEditing(null)} />

// In PostEditor's write: the draft becomes the open one, then its put is queued.
onOpen(next);
queue.current = queue.current.then(() => database.put(next));

Loading a document lands without a save

A document arriving from the database is loaded with setContent and emitUpdate: false (Tiptap 3 emits by default; Tiptap 2 did not), and the helper also compares the incoming JSON with the editor's own, so a live query delivering the note the app just saved is a no-op (measured: loading a document produced zero change events, loading the same one again produced none, typing after it produced one). The app decides what counts as new: a different _id is a switch and loads at once; the same _id with a different writeId is another device's save, and it loads only when nothing here is waiting to save (load returns false while a pause's words are pending, and the effect tries again on the next delivery). A save is recognised by the writeId it carried out and back, which holds whatever the two devices' clocks say. Words typed here land first; theirs arrive on the round trip after, which is the honest order for one person on two devices.

Destroy the editor in the effect's cleanup

Create the editor once in useEffect(() => {…}, []) and destroy the editor in the effect's cleanup, flushing first so the last pause's words land. An editor that outlives its host keeps ProseMirror listeners on a detached node and keeps the debounce timer alive; a remount without a destroy is two editors on one element. The one editor serves every note: the open note travels through a ref, and only the content is swapped.

Styling the page

The editor renders real elements (h1, p, ul, li[data-type=taskItem]) inside a .tiptap root with no styles of its own, and a multi-file app ships no stylesheet, so the host element's className styles the page through Tailwind's arbitrary variants: [&_h1]:text-2xl, [&_ul]:list-disc, [&_a]:underline, and the placeholder through [&_.is-editor-empty:first-child::before]:content-[attr(data-placeholder)]. The JSX stays class-only and the theme tokens (text-primary, text-muted-foreground, bg-muted) carry the owner's restyle into the written page. Measured: the pouf lint passes a host carrying these classes.

Commands and the toolbar

surface.run("bold"), run("heading", 2), run("tasks"), run("link", href), run("image", src), run("undo"): the toolbar is buttons calling run, and surface.isActive("bold") colours the one that applies at the cursor. Tiptap's own shortcuts work out of the box (Mod+B, Mod+I, Mod+Shift+8 for a bullet list, # then space for a heading, [ ] then space for a task, Mod+Z). A global shortcut of the app's own checks event.target.isContentEditable first, so typing stays typing. An image is run("image", url) with a URL from the note's _files after a save (doc._files.photo.url, the stable platform-minted address), so a picture is bytes on the document rather than a base64 string in the JSON.

A shared page

lib/editor.js

js// The same helper module as the notebook, kept once per app; shown here in the part the shared
// page uses. Every Tiptap package is pinned to one version.
import { Editor } from "https://esm.sh/@tiptap/[email protected]";
import StarterKit from "https://esm.sh/@tiptap/[email protected]";
import Placeholder from "https://esm.sh/@tiptap/[email protected]";

export const EMPTY_DOC = { type: "doc", content: [{ type: "paragraph" }] };

export function createEditor(host, { content = EMPTY_DOC, placeholder = "Start writing…", editable = true, onChange, debounceMs = 600 } = {}) {
  let timer = 0;
  let loading = false;
  const editor = new Editor({
    element: host,
    editable,
    content,
    extensions: [StarterKit, Placeholder.configure({ placeholder })],
    onUpdate: ({ transaction }) => {
      if (loading || !transaction.docChanged || !onChange) return;
      clearTimeout(timer);
      timer = setTimeout(() => {
        timer = 0; // the pause has landed; flush has nothing left to send
        onChange(editor.getJSON(), editor.getText());
      }, debounceMs);
    },
  });
  return {
    editor,
    load(json, { force = false } = {}) {
      if (JSON.stringify(json) === JSON.stringify(editor.getJSON())) return true;
      if (timer && !force) return false; // words are waiting to save: the person's keystrokes win
      clearTimeout(timer);
      timer = 0;
      loading = true;
      editor.commands.setContent(json ?? EMPTY_DOC, { emitUpdate: false });
      loading = false;
      return true;
    },
    /** True while a pause's words are still waiting to be saved. */
    isDirty() {
      return timer !== 0;
    },
    flush() {
      if (!timer) return;
      clearTimeout(timer);
      timer = 0;
      onChange?.(editor.getJSON(), editor.getText());
    },
    setEditable(on) {
      editor.setEditable(on);
    },
    destroy() {
      clearTimeout(timer);
      editor.destroy();
    },
  };
}

App.jsx

jsximport React, { useEffect, useRef, useState } from "react";
import { useFireproof } from "use-fireproof";
import { useVibe, useViewer } from "use-vibes";
import { Badge, Button, Card, Heading, Row, Spacer, Stack, Text } from "@vibes.diy/pouf";
import { EMPTY_DOC, createEditor } from "./lib/editor.js";

const PAGE_ID = "page:home";
const PAGE =
  "min-h-64 [&_.tiptap]:outline-none [&_h1]:text-2xl [&_h1]:font-bold [&_h2]:text-xl [&_h2]:font-semibold [&_p]:my-2 [&_ul]:list-disc [&_ul]:pl-6 [&_ol]:list-decimal [&_ol]:pl-6 " +
  "[&_.is-editor-empty:first-child::before]:content-[attr(data-placeholder)] [&_.is-editor-empty:first-child::before]:text-muted-foreground [&_.is-editor-empty:first-child::before]:float-left";

export default function App() {
  const { database, useDocument } = useFireproof("teampage");
  const { me, can, ready } = useVibe("teampage");
  const { requestLogin, ViewerTag } = useViewer();
  const { doc: page } = useDocument({ _id: PAGE_ID, type: "page", body: EMPTY_DOC, text: "" });
  const [saved, setSaved] = useState("idle");
  const hostRef = useRef(null);
  const surfaceRef = useRef(null);
  const pageRef = useRef(page); // the latest page and identity, read by the save callback
  const meRef = useRef(me);
  const lastWriteRef = useRef(null);
  pageRef.current = page;
  meRef.current = me;

  useEffect(() => {
    const surface = createEditor(hostRef.current, {
      placeholder: "The team's page. Anyone signed in can write here.",
      onChange: async (json, text) => {
        const writeId = crypto.randomUUID();
        lastWriteRef.current = writeId;
        await database.put({ ...pageRef.current, _id: PAGE_ID, type: "page", body: json, text, updatedAt: Date.now(), writeId, editedBy: meRef.current?.userHandle });
        setSaved("saved");
      },
    });
    surface.editor.on("update", () => setSaved("typing"));
    surfaceRef.current = surface;
    return () => {
      surface.flush();
      surface.destroy();
      surfaceRef.current = null;
    };
  }, []);

  // Someone else's save lands here when nothing of ours is waiting; our own save (the writeId we
  // sent) is left alone.
  useEffect(() => {
    const surface = surfaceRef.current;
    if (!surface || !page) return;
    if (page.writeId && page.writeId !== lastWriteRef.current) surface.load(page.body ?? EMPTY_DOC);
    surface.setEditable(Boolean(ready && me && can.edit(page).ok));
  }, [page, ready, me]);

  return (
    <main className="min-h-screen bg-background text-foreground p-4 md:p-8">
      <Stack gap={4}>
        <Row gap={2} align="center" wrap>
          <Heading level={1}>Team page</Heading>
          <Spacer />
          {page?.editedBy && <Row gap={1} align="center"><Text size="sm" muted>last edited by</Text><ViewerTag userHandle={page.editedBy} /></Row>}
          <Badge tone={saved === "saved" ? "up" : "idle"}>{saved === "typing" ? "Typing…" : saved === "saved" ? "Saved" : ready && me ? "Ready" : "Read only"}</Badge>
        </Row>
        {ready && !me && <Row gap={2} align="center" wrap><Text muted>Sign in to write on the page.</Text><Button size="sm" variant="quiet" onClick={requestLogin}>Sign in</Button></Row>}
        <Card variant="tight">
          <div ref={hostRef} className={PAGE} onBlur={() => surfaceRef.current?.flush()} />
        </Card>
      </Stack>
    </main>
  );
}

access.js

js// page: the one shared page. Everyone reads it; anyone signed in writes it and is named as the editor.
export function teampage(doc, oldDoc, user, ctx) {
  if (doc.type === "page" || (oldDoc && oldDoc.type === "page")) {
    if (!user) throw { forbidden: "sign in to write on the page" };
    if (doc.editedBy !== user.userHandle) throw { forbidden: "a page edit is signed as yourself" };
    return { channels: ["page"], grant: { public: ["page"] } };
  }
  throw { forbidden: "unknown document type" };
}

A shared page is last-writer-wins at the pause: two people typing in the same minute each save their whole document, and the later save replaces the earlier one, with the other person's editor loading it on the next tick. That is honest for a team page edited in turns; say so in the app's copy ("saves as you pause; one writer at a time lands"), and for true simultaneous editing keep one document per section so two people on different sections land independently.

Mobile

The editor is a plain contenteditable, so the phone keyboard works as it does anywhere; keep the toolbar as IconButtons in a row that wraps under the page, and the page itself full width. Tiptap's input rules (# for a heading, - for a list, [ ] for a task) matter more on a phone than the toolbar does. Flush on blur, because a phone backgrounds the tab without warning.

Shapes a rich text vibe takes

  • A notebook — a list of notes and one editor, each note its own document, saved as you pause. The starter.
  • A journal — one note per day, _id: "day:YYYY-MM-DD", a calendar strip picking the day, yesterday's page one tap away.
  • A shared page or wiki — one document per page, everyone reads, signed-in people write, last editor named. The second example; several pages are several _ids with a page list.
  • Meeting notes with action items — a checklist in the page, and a query over text for lines that start with a box, listed in a sidebar.
  • A recipe book — a page per recipe with a heading, an ingredients checklist and numbered steps, the list filtered by text.
  • A writing app with an AI pass — callAI over text for a summary or a continuation, inserted with editor.commands.insertContent at the end.

Gotchas

  • The editor throws on the first key, about a keyed plugin — two copies of ProseMirror, from Tiptap packages at different versions. One version across every import.
  • Every keystroke writes to the database — the save sits in onUpdate. The debounce and flush.
  • Opening a note saves it again, forever — setContent emitted an update (Tiptap 3's default) and the save loaded it back. emitUpdate: false in load.
  • The list shows "Untitled" for every note — the title is waiting to be derived. titleOf(json) on every save.
  • Typing is lost when switching notes — the debounce had not fired. flush() before changing the open note.
  • Every draft comes back as "Untitled draft" — the title field lives in React state and only Publish writes it. Save the title after its own pause and in the close step, through the same queued write as the page, and let the close step wait for it.
  • The first draft closes itself after a pause — the empty list was all that held the editor open, and the draft's first save filled the list. write hands the draft to the open-state setter before the put (onOpen={setEditing}); a callback after the put lands too late.
  • Two editors after a hot reload — the effect's cleanup did not destroy the editor.
  • The page has no styles — Tiptap ships none, and a multi-file app ships no stylesheet. The host's arbitrary-variant classes.
  • A pasted image makes the document huge — a base64 image in the JSON. Bytes go in _files; the JSON holds the URL.