Rich text editor
A lean, single-user WYSIWYG built on Tiptap — the STD answer to editing a description, a doc body, or an announcement without the weight of the design system's Yjs-backed CollabEditor (real-time, presence, multi-cursor). It's a controlled input: the host owns the value string and the editor emits the next string on every edit. It speaks markdown by default (parsed in, serialized back out via the tiptap-markdown extension) or HTML, carries a compact formatting toolbar (bold, italic, strike, H2/H3, bullet + ordered lists, link add/remove, undo/redo), and flips to a clean read-only render with editable={false}. Themed entirely from the semantic tokens and accessible — the surface is a labelled role="textbox", the toolbar a role="toolbar", and each control reports its toggle state via aria-pressed.
- import
- import { RichTextEditor } from "@/components/ds/editor" — the CLIENT barrel. It is deliberately NOT in the server-safe components/ds/index.ts, because Tiptap is client-only.
- value / onChange
- Controlled. value is the content string (markdown or HTML per format); onChange(next) fires on every edit with the next string in the same format. Changing value from outside re-seeds the editor without moving the caret on your own echoes.
- format?: "html" | "markdown"
- Default "markdown": seeds from a markdown string and emits editor.storage.markdown.getMarkdown(). "html" seeds from / emits editor.getHTML(). The wire format is symmetric — value in and onChange out are always the same shape.
- editable?: boolean
- Default true. When false the toolbar is hidden and the surface is read-only (aria-readonly) — a clean prose render of the same content.
- placeholder? / minHeight? / ariaLabel?
- placeholder shows muted hint text while empty (Tiptap Placeholder extension); minHeight (px, default 160) sets the minimum surface height; ariaLabel names the editable region for assistive tech (default "Rich text editor").
- extensions
- StarterKit (paragraphs, headings, lists, bold/italic/strike, undo/redo — its bundled Link disabled) + the standalone @tiptap/extension-link + tiptap-markdown's Markdown + Placeholder. Single-user only; no collaboration layer.
- client-only
- The editor touches the DOM, so it's a 'use client' component initialized with immediatelyRender:false (SSR-safe under the App Router). This page is a server component; each interactive editor lives in the colocated ./EditorDemo island.
Controlled markdown (with live output)
The default format. The editor is seeded from a markdown string and, on every keystroke or toolbar action, emits the re-serialized markdown via editor.storage.markdown.getMarkdown(). The panel below mirrors the live onChange value — type, bold a word, toggle a heading, and watch the markdown update in lockstep. This is the round-trip that makes the editor safe to persist: what you store is plain markdown, not editor-specific JSON.
editor.storage.markdown.getMarkdown() — live output
## Adoption day checklist A **rescue runs on logistics**. Before any transport leaves, confirm: - Health certificate signed and *in the folder* - Microchip scanned and ~~unregistered~~ registered to the new owner - Foster contact reachable See the [transport SOP](https://savethedogs.io/sop) for the full sequence.
- markdown round-trip
- value (markdown) → parsed into the document on mount → edited → editor.storage.markdown.getMarkdown() serializes back to markdown → onChange. Store the markdown, not the editor state.
- toolbar ↔ markdown
- Every toolbar control maps to a markdown construct: bold → **…**, H2 → ##, bullet list → -, link → [text](url). The output panel makes the mapping visible.
HTML format
Set format="html" and the same control surface reads and writes HTML instead: it seeds from an HTML string and emits editor.getHTML() on every edit. Reach for this when the consumer is an HTML sink — a richtext column, an email body, a dangerouslySetInnerHTML render — rather than a markdown store. The toolbar and prose styling are identical; only the wire format changes.
editor.getHTML() — live output
<h2>Volunteer welcome</h2><p>You're <strong>officially</strong> part of the pack. Here's what happens next:</p><ol><li>Watch for your <em>orientation email</em>.</li><li>Join your regional WhatsApp group.</li><li>Pick a first shift.</li></ol><p>Questions? <a href="mailto:hello@savethedogs.io">Email the team</a>.</p>
- getHTML()
- When format="html", onChange emits editor.getHTML() — sanitized prosemirror HTML (the editor never round-trips raw arbitrary markup; links carry rel="noopener noreferrer nofollow").
- when to choose HTML
- Use HTML when the storage/render target is HTML; prefer markdown (the default) for human-readable, diff-friendly, portable content.
Read-only render
With editable={false} the editor becomes a faithful, non-interactive render of the same content: the toolbar is gone, there's no caret, and the surface reports aria-readonly. It uses the exact same prose typography as the editable state, so a doc looks identical whether you're reading or writing it — no separate viewer component to drift.
- editable={false}
- Hides the toolbar and disables editing (the underlying editor is set non-editable). The content renders with the same .prose styles as the editable surface.
- one component, two modes
- Read and write share the same render path, so there's no viewer/editor visual drift. Flip editable to toggle between them in place.
Empty & placeholder
A fresh, empty editor isn't a blank box: pass placeholder and the empty document shows muted hint text (via Tiptap's Placeholder extension) until the first character lands. The minHeight prop keeps the surface a comfortable target size even when empty, so the layout doesn't jump as content grows.
- placeholder
- Muted hint text shown on the empty first paragraph; disappears on the first keystroke. Purely presentational — not part of the value.
- minHeight
- Minimum height (px, default 160) of the editable surface, so an empty or short document still presents a comfortable click target without layout shift.