Rich text — notebooks, journals, wikis and documents with Tiptap
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
textfor 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 —
callAIovertextfor a summary or a continuation, inserted witheditor.commands.insertContentat 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 andflush. - Opening a note saves it again, forever —
setContentemitted an update (Tiptap 3's default) and the save loaded it back.emitUpdate: falseinload. - 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
writeas 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.
writehands 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.