Aisha Adeyemi · Finance
Hi team — the September close is ready for review.
Before Friday
Check the accruals in
ledger/2026-09Sign off the variance report
Anything over 2% gets a line of explanation.
Thanks,
Aisha
A formatting toolbar, markdown shortcuts and a character count over Tiptap, saving a JSON document that parseRichDoc checks and RichText draws.
The value is a document, not HTML: the JSON Tiptap (on ProseMirror) holds, read through parseRichDoc on every change, so what onValueChange hands you and what the hidden name input posts is already the shape a server accepts — paragraphs, headings 2 and 3, bullet and numbered lists, quotes, code blocks and line breaks, marked bold, italic, strike, code or as a link. Nothing else can get in. A paste from a word processor or a web page keeps that formatting and drops the rest — images, colours, fonts, a table's grid (its rows paste as lines) — and a link whose address is not http, https or mailto loses its link and keeps its words, as does one in a document the editor is handed; the Link field refuses such an address beside itself. A change that would leave more than 2,000 nodes or blocks nested past six (RICH_LIMITS) is refused as it is made, so the editor never holds a document parseRichDoc would turn away.
The server re-reads it all the same: an action calls parseRichDoc(JSON.parse(formData.get(name)), { maxLength }) and refuses null, because a form field is whatever the browser chose to send. Store the document and, for a list's excerpt or a search index, richTextToPlain(doc). RichText draws a stored document with React elements — never dangerouslySetInnerHTML, never an attribute the document carries but a link's address — and is server-compatible, so a page renders a sent message where it reads it; import it from rich-text-editor/rich-text, not from the editor, and pass headingLevel so the document's headings sit under the page's own.
The toolbar is the kit's Toolbar — one tab stop walked with the arrow keys — and every control is a toggle that says whether it is on (aria-pressed) and announces its chord: ⌘B, ⌘I, ⌘⇧X, ⌘E, ⌘⌥2, ⌘⌥3, ⌘⇧8, ⌘⇧7, ⌘⇧B, ⌘K for the link field — in the text it is the editor's, so a page's own ⌘K palette does not open over it — ⌘Z and ⌘⇧Z (Ctrl on Windows and Linux). Markdown works as you type: ## and ### for headings, - or 1. for lists, > for a quote, **bold**, *italic*, ~~strike~~ and code. toolbar="minimal" keeps the inline marks and the link. With maxLength typing stops at the limit and a counter reads N / max — a line break counts as one there, so the counter is a touch stricter than the server's maxLength, which counts text — and it is spoken only within the last tenth. The editor renders on the client only (the server sends the frame and the toolbar), and Tiptap is heavy: in a page, load the component with next/dynamic or an import() when the reader first opens it, so the page's first load does not carry it. value is controlled — set only when it differs from what the editor holds, so your own echo never moves the caret — and a document it cannot read opens empty.
npx shadcn@latest add @vibra/rich-text-editorNeeds the @vibra registry in your components.json — set it up once.
"use client"
import * as React from "react"
import { Label } from "@/components/ui/label"
import { RichTextEditor } from "@/components/ui/rich-text-editor"
import type { RichDoc } from "@/components/ui/rich-text-editor/document"
const DRAFT: RichDoc = {
type: "doc",
content: [
{ type: "heading", attrs: { level: 2 }, content: [{ type: "text", text: "Invoices, faster" }] },
{
type: "paragraph",
content: [
{ type: "text", text: "Exports now finish in " },
{ type: "text", text: "under a minute", marks: [{ type: "bold" }] },
{ type: "text", text: " for most workspaces. The " },
{ type: "text", text: "migration notes", marks: [{ type: "link", attrs: { href: "https://northwind.example/notes/exports" } }] },
{ type: "text", text: " cover the rest." },
],
},
{
type: "bulletList",
content: [
{ type: "listItem", content: [{ type: "paragraph", content: [{ type: "text", text: "CSV and PDF in one run" }] }] },
{ type: "listItem", content: [{ type: "paragraph", content: [{ type: "text", text: "Totals rounded per line, as finance asked" }] }] },
],
},
],
}
export default function RichTextEditorDemo() {
const id = React.useId()
const [doc, setDoc] = React.useState<RichDoc>(DRAFT)
return (
<div className="flex w-full max-w-xl flex-col gap-2">
<Label id={`${id}-label`}>Release note</Label>
<RichTextEditor
aria-labelledby={`${id}-label`}
aria-describedby={`${id}-hint`}
value={doc}
onValueChange={setDoc}
maxLength={1200}
placeholder="What changed, and who it is for"
/>
<p id={`${id}-hint`} className="text-xs text-muted-foreground">
Markdown works as you type: ## for a heading, - for a list, > for a quote, **bold** and `code`.
</p>
</div>
)
}The minimal toolbar in a form: the posted field read back through parseRichDoc.
"use client"
import * as React from "react"
import { Button } from "@/components/ui/button"
import { RichTextEditor } from "@/components/ui/rich-text-editor"
import { parseRichDoc, richTextToPlain } from "@/components/ui/rich-text-editor/document"
export default function RichTextEditorMinimal() {
const [sent, setSent] = React.useState("")
// A new key empties the field after a send, the way a reply box resets.
const [round, setRound] = React.useState(0)
return (
<form
className="flex w-full max-w-md flex-col gap-2"
onSubmit={(event) => {
event.preventDefault()
// What a server action would do with the posted field: read it back
// through the same check, then keep it — here, print its text.
const doc = parseRichDoc(JSON.parse(String(new FormData(event.currentTarget).get("reply") ?? "null")), { maxLength: 280 })
setSent(doc ? richTextToPlain(doc) : "That reply could not be read.")
setRound((value) => value + 1)
}}
>
<RichTextEditor
key={round}
name="reply"
aria-label="Reply to Aisha Adeyemi"
toolbar="minimal"
size="sm"
maxLength={280}
placeholder="Reply to Aisha…"
/>
<div className="flex items-center justify-between gap-3">
<p role="status" aria-live="polite" className="min-w-0 truncate text-xs text-muted-foreground">
{sent ? `Sent: ${sent}` : null}
</p>
<Button type="submit" size="sm">
Send reply
</Button>
</div>
</form>
)
}RichText drawing a stored document, and the excerpt an inbox prints.
import { richTextToPlain, type RichDoc } from "@/components/ui/rich-text-editor/document"
import { RichText } from "@/components/ui/rich-text-editor/rich-text"
/** A sent message, as a server stores it once parseRichDoc has read it. */
const MESSAGE: RichDoc = {
type: "doc",
content: [
{
type: "paragraph",
content: [
{ type: "text", text: "Hi team — the " },
{ type: "text", text: "September close", marks: [{ type: "bold" }] },
{ type: "text", text: " is ready for review." },
],
},
{ type: "heading", attrs: { level: 2 }, content: [{ type: "text", text: "Before Friday" }] },
{
type: "orderedList",
attrs: { start: 1 },
content: [
{ type: "listItem", content: [{ type: "paragraph", content: [{ type: "text", text: "Check the accruals in " }, { type: "text", text: "ledger/2026-09", marks: [{ type: "code" }] }] }] },
{ type: "listItem", content: [{ type: "paragraph", content: [{ type: "text", text: "Sign off the " }, { type: "text", text: "variance report", marks: [{ type: "link", attrs: { href: "https://northwind.example/reports/variance" } }] }] }] },
],
},
{ type: "blockquote", content: [{ type: "paragraph", content: [{ type: "text", text: "Anything over 2% gets a line of explanation.", marks: [{ type: "italic" }] }] }] },
{ type: "paragraph", content: [{ type: "text", text: "Thanks," }, { type: "hardBreak" }, { type: "text", text: "Aisha" }] },
],
}
export default function RichTextDemo() {
return (
<div className="flex w-full max-w-xl flex-col gap-4">
<article aria-label="Message from Aisha Adeyemi" className="panel flex flex-col gap-3 p-4">
<p className="text-xs text-muted-foreground">Aisha Adeyemi · Finance</p>
<RichText doc={MESSAGE} headingLevel={3} />
</article>
<div className="flex flex-col gap-1">
<p className="type-eyebrow">In the inbox list</p>
<p className="line-clamp-1 text-sm text-muted-foreground">{richTextToPlain(MESSAGE)}</p>
</div>
</div>
)
}| Prop | Type | Default | Description |
|---|---|---|---|
| value | RichDoc | — | The document, controlled. Leave it out and the editor keeps its own. |
| defaultValue | RichDoc | EMPTY_DOC | Where an uncontrolled editor starts. |
| onValueChange | (doc: RichDoc) => void | — | Every change the reader makes, already read through parseRichDoc. Setting value is not reported back. |
| placeholder | string | — | Drawn in the empty field and given to it as aria-placeholder. |
| maxLength | number | — | Characters of text the document may hold. Typing and pasting stop at it, and a counter shows N / max under the text. |
| toolbar | "full" | "minimal" | "full" | minimal keeps bold, italic, strike, code and the link. |
| name | string | — | Posts the document with its form, as JSON in a hidden input of this name. |
| disabled | boolean | false | Read-only text and an inert toolbar. |
| autoFocus | boolean | false | Puts the caret at the end of the text once the editor is ready. |
| size | "sm" | "default" | "default" | sm is a shorter field with tighter padding, for a reply box. |
| aria-label / aria-labelledby / aria-describedby / aria-invalid | string / string / string / boolean | — | Land on the writing surface — the textbox — not on the frame around it. aria-invalid also draws the frame in the danger tone. |
| className | string | — | Merged onto the frame; the rest of the div props are spread there too. |
| RichText | React.ComponentProps<"div"> & { doc: RichDoc | string; headingLevel?: 2 | 3 | 4 } | — | The server-compatible renderer, from rich-text-editor/rich-text. headingLevel (3 by default) is where the document's heading 2 lands; its heading 3 is one below. A string renders as a paragraph per blank line. |
| parseRichDoc | (input: unknown, options?: { maxLength?: number }) => RichDoc | null | — | The check, from rich-text-editor/document: the document rebuilt from the allowed nodes and marks, or null for anything else. |
| isSafeHref | (href: string) => boolean | — | Whether a link may point there: an absolute http, https or mailto address. |
| richTextToPlain / richTextLength | (doc: RichDoc) => string / number | — | The text alone — blocks a blank line apart, list items a line each — and its length, which maxLength counts. |
| plainToRichDoc | (text: string) => RichDoc | — | Typed text as a document: a paragraph per blank line, a line break per new line. |
| EMPTY_DOC / RICH_LIMITS | RichDoc / { maxNodes: 2000; maxDepth: 6 } | — | The editor's empty document, and the size a document may reach. |
"use client"
import * as React from "react"
import { EditorContent, Extension, useEditor, useEditorState } from "@tiptap/react"
import { Plugin } from "@tiptap/pm/state"
import StarterKit from "@tiptap/starter-kit"
import { CharacterCount, Placeholder } from "@tiptap/extensions"
import { cn } from "@/lib/utils"
import { EMPTY_DOC, isSafeHref, parseRichDoc, type RichDoc } from "./document"
import { RICH_TEXT_EDITOR_HEADINGS, RICH_TEXT_PROSE } from "./rich-text"
import { RichTextToolbar } from "./toolbar"
export type RichTextEditorProps = Omit<React.ComponentProps<"div">, "defaultValue" | "onChange"> & {
/** The document, controlled. Leave it out and the editor keeps its own, starting from `defaultValue`. */
value?: RichDoc
defaultValue?: RichDoc
/** Every change the reader makes, as a document parseRichDoc accepts. */
onValueChange?: (doc: RichDoc) => void
placeholder?: string
/** Characters of text the document may hold; typing stops there and a counter shows how many are left. */
maxLength?: number
toolbar?: "full" | "minimal"
/** Posts the document with a form, as JSON in a hidden input of this name. */
name?: string
disabled?: boolean
autoFocus?: boolean
size?: "sm" | "default"
"aria-label"?: string
"aria-labelledby"?: string
"aria-describedby"?: string
"aria-invalid"?: boolean
}
/** A link mark whose address is not safe, as the editor would be handed it. */
const unsafeLink = (mark: unknown) =>
typeof mark === "object" &&
mark !== null &&
(mark as { type?: unknown }).type === "link" &&
!isSafeHref(String((mark as { attrs?: { href?: unknown } }).attrs?.href ?? ""))
/** The same JSON with every unsafe link taken off its words, which stay. */
function withoutUnsafeLinks(node: unknown): unknown {
if (Array.isArray(node)) return node.map(withoutUnsafeLinks)
if (typeof node !== "object" || node === null) return node
const record = node as Record<string, unknown>
return {
...record,
...(Array.isArray(record.marks) ? { marks: record.marks.filter((mark) => !unsafeLink(mark)) } : {}),
...(Array.isArray(record.content) ? { content: record.content.map(withoutUnsafeLinks) } : {}),
}
}
/**
* What the editor opens on: the document it was handed, with any link to an
* address that is not safe reduced to its words — a document from somewhere
* else is shown, never trusted — or the empty document when it cannot be read.
*/
function openable(input: RichDoc | undefined): RichDoc {
return input === undefined ? EMPTY_DOC : (parseRichDoc(withoutUnsafeLinks(input)) ?? EMPTY_DOC)
}
/** Characters as the counter counts them: text, and a line break as one. */
function countOf(doc: RichDoc): number {
let total = 0
const walk = (nodes: readonly unknown[] | undefined) => {
for (const node of nodes ?? []) {
const { type, text, content } = node as { type: string; text?: string; content?: unknown[] }
if (type === "text") total += text?.length ?? 0
else if (type === "hardBreak") total += 1
else walk(content)
}
}
walk(doc.content)
return total
}
/**
* Refuses any change that would leave a document parseRichDoc refuses — more
* nodes than it allows, lists nested past its depth — so the editor never
* holds what a server would turn away.
*/
const WithinLimits = Extension.create({
name: "richTextLimits",
addProseMirrorPlugins() {
return [new Plugin({ filterTransaction: (transaction) => !transaction.docChanged || parseRichDoc(transaction.doc.toJSON()) !== null })]
},
})
/** ⌘⇧X strikes through, as the toolbar says (Tiptap's own chord, ⌘⇧S, still works). */
const EditorKeys = Extension.create({
name: "richTextEditorKeys",
addKeyboardShortcuts() {
return { "Mod-Shift-x": () => this.editor.commands.toggleStrike() }
},
})
/**
* A table pasted from a sheet or a page, flattened before the editor reads
* it: the document has no tables, and ProseMirror would otherwise run every
* cell's words into one another. Each row becomes a line and each cell a
* word group, inside one paragraph; every tag is dropped, so the browser's
* own repair of misplaced table content never reorders it.
*/
function flattenTables(html: string): string {
return html
.replace(/<\/t[dh]\s*>\s*(?=<t[dh]\b)/gi, " ")
.replace(/<\/tr\s*>(?:\s*<\/?(?:thead|tbody|tfoot)\b[^>]*>)*\s*(?=<tr\b)/gi, "<br>")
.replace(/<table\b[^>]*>/gi, "<p>")
.replace(/<\/table\s*>/gi, "</p>")
.replace(/<\/?(?:caption|colgroup|col|thead|tbody|tfoot|tr|td|th)\b[^>]*>/gi, "")
}
/** ⌘K on a Mac, Ctrl+K elsewhere — the platforms ProseMirror reads "Mod" by. */
function isLinkChord(event: KeyboardEvent): boolean {
const mac = typeof navigator !== "undefined" && /Mac|iP(hone|[oa]d)/.test(navigator.platform)
const mod = mac ? event.metaKey && !event.ctrlKey : event.ctrlKey && !event.metaKey
return mod && !event.altKey && !event.shiftKey && event.key.toLowerCase() === "k"
}
// Tiptap draws a placeholder as a decoration on the empty first paragraph;
// these set it in the field's own placeholder ink, at the start of the line.
const PLACEHOLDER =
"[&_.is-editor-empty:first-child]:before:pointer-events-none [&_.is-editor-empty:first-child]:before:float-start [&_.is-editor-empty:first-child]:before:h-0 [&_.is-editor-empty:first-child]:before:text-muted-foreground [&_.is-editor-empty:first-child]:before:content-[attr(data-placeholder)]"
/**
* A rich-text field on Tiptap: a toolbar, markdown shortcuts, a placeholder
* and a character count, whose value is a JSON document — RichDoc, which
* parseRichDoc checks and RichText draws.
*/
function RichTextEditor({
value,
defaultValue,
onValueChange,
placeholder,
maxLength,
toolbar = "full",
name,
disabled = false,
autoFocus = false,
size = "default",
"aria-label": ariaLabel,
"aria-labelledby": ariaLabelledBy,
"aria-describedby": ariaDescribedBy,
"aria-invalid": ariaInvalid,
className,
...props
}: RichTextEditorProps) {
const [linkOpen, setLinkOpen] = React.useState(false)
const [initial] = React.useState(() => openable(value ?? defaultValue))
const [written, setWritten] = React.useState(initial)
const opened = React.useMemo(() => (value === undefined ? undefined : openable(value)), [value])
const posted = opened ?? written
// Built once: an editor's schema and plugins are fixed when it is created.
const [extensions] = React.useState(() => [
StarterKit.configure({
heading: { levels: [2, 3] },
horizontalRule: false,
underline: false,
// No empty paragraph kept after a final list or code block: a saved
// document ends where the writer stopped.
trailingNode: false,
link: { openOnClick: false, autolink: true, isAllowedUri: (url) => isSafeHref(url) },
}),
...(placeholder ? [Placeholder.configure({ placeholder })] : []),
CharacterCount.configure({ limit: maxLength }),
WithinLimits,
EditorKeys,
])
const attributes = React.useMemo(() => {
const out: Record<string, string> = {
role: "textbox",
"aria-multiline": "true",
class: cn(
RICH_TEXT_PROSE,
RICH_TEXT_EDITOR_HEADINGS,
PLACEHOLDER,
"outline-none",
size === "sm" ? "min-h-16 px-2.5 py-2" : "min-h-28 px-3 py-2.5"
),
}
if (ariaLabel) out["aria-label"] = ariaLabel
if (ariaLabelledBy) out["aria-labelledby"] = ariaLabelledBy
if (ariaDescribedBy) out["aria-describedby"] = ariaDescribedBy
if (ariaInvalid) out["aria-invalid"] = "true"
if (placeholder) out["aria-placeholder"] = placeholder
if (disabled) out["aria-disabled"] = "true"
return out
}, [ariaLabel, ariaLabelledBy, ariaDescribedBy, ariaInvalid, placeholder, disabled, size])
const editorProps = React.useMemo(
() => ({
attributes,
transformPastedHTML: flattenTables,
// ⌘K in the text opens the link field. A page's own ⌘K — AppShell's
// command palette — listens on the window, so the chord stops here.
handleKeyDown: (_view: unknown, event: KeyboardEvent) => {
if (!isLinkChord(event)) return false
event.stopPropagation()
setLinkOpen(true)
return true
},
}),
[attributes]
)
const editor = useEditor({
// Rendered on the client only: the server sends the frame and the toolbar.
immediatelyRender: false,
extensions,
content: initial,
editable: !disabled,
autofocus: autoFocus ? "end" : false,
editorProps,
onUpdate: ({ editor: current }) => {
const next = parseRichDoc(current.getJSON(), { maxLength })
if (!next) return
if (value === undefined) setWritten(next)
onValueChange?.(next)
},
})
// A controlled document is set only when it differs from what the editor
// holds, so the reader's own keystrokes coming back as `value` never move
// the caret — and setting it is not reported as a change.
React.useEffect(() => {
if (!editor || !opened) return
if (JSON.stringify(parseRichDoc(editor.getJSON())) !== JSON.stringify(opened)) {
editor.commands.setContent(opened, { emitUpdate: false })
}
}, [editor, opened])
React.useEffect(() => {
if (editor && editor.isEditable === disabled) editor.setEditable(!disabled, false)
}, [editor, disabled])
const counted = useEditorState({ editor, selector: ({ editor: current }) => current?.storage.characterCount.characters() ?? null })
const count = counted ?? countOf(posted)
const nearLimit = maxLength !== undefined && count >= maxLength * 0.9
return (
<div
data-slot="rich-text-editor"
data-size={size}
data-toolbar={toolbar}
data-disabled={disabled || undefined}
className={cn(
"flex w-full min-w-0 flex-col rounded-lg border border-input bg-card transition-colors has-[[contenteditable=true]:focus-visible]:focus-outline has-[[role=textbox][aria-invalid=true]]:border-destructive data-disabled:bg-muted data-disabled:opacity-50",
className
)}
{...props}
>
<RichTextToolbar editor={editor} variant={toolbar} disabled={disabled} linkOpen={linkOpen} onLinkOpenChange={setLinkOpen} />
<EditorContent editor={editor} className="flex min-w-0 flex-1 flex-col *:flex-1" />
{maxLength !== undefined ? (
<div className="flex justify-end px-3 pb-2">
<span
data-slot="rich-text-editor-count"
// A count reads left to right on either page: in a right-to-left run "5 / 20" would show as "20 / 5".
dir="ltr"
// Spoken only once the limit is close: a count read out on every
// keystroke would drown out what is being typed.
aria-live={nearLimit ? "polite" : undefined}
className="text-xs text-muted-foreground tabular-nums"
>
{count} / {maxLength}
</span>
</div>
) : null}
{name ? <input type="hidden" name={name} value={JSON.stringify(posted)} /> : null}
</div>
)
}
export { RichTextEditor }"use client"
import * as React from "react"
import { useEditorState, type ChainedCommands, type Editor } from "@tiptap/react"
import {
BoldIcon,
CodeIcon,
Heading2Icon,
Heading3Icon,
ItalicIcon,
ListIcon,
ListOrderedIcon,
Redo2Icon,
StrikethroughIcon,
TextQuoteIcon,
Undo2Icon,
type LucideIcon,
} from "lucide-react"
import { Button } from "@/components/ui/button"
import { KbdShortcut } from "@/components/ui/kbd-shortcut"
import { Toggle } from "@/components/ui/toggle"
import { Toolbar, ToolbarGroup, ToolbarSeparator, ToolbarSpacer } from "@/components/ui/toolbar"
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
import { LinkPopover } from "./link-popover"
/** One formatting control: its name, its chord, whether it is on, and what it does. */
type Tool = {
label: string
icon: LucideIcon
/** The chord, as KbdShortcut writes it — "mod" is ⌘ on a Mac and Ctrl elsewhere. */
keys: string
active: (editor: Editor) => boolean
run: (chain: ChainedCommands) => ChainedCommands
}
const MARKS: Tool[] = [
{ label: "Bold", icon: BoldIcon, keys: "mod+b", active: (e) => e.isActive("bold"), run: (c) => c.toggleBold() },
{ label: "Italic", icon: ItalicIcon, keys: "mod+i", active: (e) => e.isActive("italic"), run: (c) => c.toggleItalic() },
{ label: "Strikethrough", icon: StrikethroughIcon, keys: "mod+shift+x", active: (e) => e.isActive("strike"), run: (c) => c.toggleStrike() },
{ label: "Inline code", icon: CodeIcon, keys: "mod+e", active: (e) => e.isActive("code"), run: (c) => c.toggleCode() },
]
const HEADINGS: Tool[] = [
{ label: "Heading 2", icon: Heading2Icon, keys: "mod+alt+2", active: (e) => e.isActive("heading", { level: 2 }), run: (c) => c.toggleHeading({ level: 2 }) },
{ label: "Heading 3", icon: Heading3Icon, keys: "mod+alt+3", active: (e) => e.isActive("heading", { level: 3 }), run: (c) => c.toggleHeading({ level: 3 }) },
]
const BLOCKS: Tool[] = [
{ label: "Bulleted list", icon: ListIcon, keys: "mod+shift+8", active: (e) => e.isActive("bulletList"), run: (c) => c.toggleBulletList() },
{ label: "Numbered list", icon: ListOrderedIcon, keys: "mod+shift+7", active: (e) => e.isActive("orderedList"), run: (c) => c.toggleOrderedList() },
{ label: "Quote", icon: TextQuoteIcon, keys: "mod+shift+b", active: (e) => e.isActive("blockquote"), run: (c) => c.toggleBlockquote() },
]
/** The chord as aria-keyshortcuts writes it: both platforms' spellings of "mod", modifiers first. */
function ariaKeys(keys: string): string {
const spell = (mod: string) =>
keys
.split("+")
.map((key) => (key === "mod" ? mod : key === "shift" ? "Shift" : key === "alt" ? "Alt" : key.toUpperCase()))
.join("+")
return `${spell("Meta")} ${spell("Control")}`
}
/** The arrow keys this toolbar has turned round, so it hands each on only once. */
const turned = new WeakSet<Event>()
/**
* The kit's Toolbar reads ArrowRight as the next control in the DOM. In a
* right-to-left page the row runs the other way, so the arrow a reader
* presses is handed to the Toolbar as the other one — on the control that
* has the focus — and the one pressed goes no further.
*/
function inReadingOrder(event: React.KeyboardEvent<HTMLDivElement>) {
if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return
// The link field is portalled but still reached through this tree: its arrows move its caret.
if (turned.has(event.nativeEvent) || !event.currentTarget.contains(event.target as Node)) return
if (event.currentTarget.closest("[dir]")?.getAttribute("dir") !== "rtl") return
event.preventDefault()
const other = new KeyboardEvent("keydown", { key: event.key === "ArrowLeft" ? "ArrowRight" : "ArrowLeft", bubbles: true, cancelable: true })
turned.add(other)
event.target.dispatchEvent(other)
}
// A press on a control must not take the focus from the text, or the
// selection the control is about to format would be drawn as lost.
const keepFocus = (event: React.MouseEvent) => event.preventDefault()
function ToolToggle({ tool, editor, pressed, disabled }: { tool: Tool; editor: Editor | null; pressed: boolean; disabled: boolean }) {
const Icon = tool.icon
return (
<Tooltip>
<TooltipTrigger
render={
<Toggle
size="sm"
aria-label={tool.label}
aria-keyshortcuts={ariaKeys(tool.keys)}
pressed={pressed}
disabled={disabled}
onMouseDown={keepFocus}
onPressedChange={() => {
if (editor) tool.run(editor.chain().focus()).run()
}}
className="w-7 px-0"
>
<Icon aria-hidden="true" />
</Toggle>
}
/>
<TooltipContent className="flex items-center gap-2">
{tool.label}
<KbdShortcut keys={tool.keys} size="sm" className="opacity-80" />
</TooltipContent>
</Tooltip>
)
}
export type RichTextToolbarProps = {
editor: Editor | null
variant: "full" | "minimal"
disabled: boolean
linkOpen: boolean
onLinkOpenChange: (open: boolean) => void
}
/**
* The editor's formatting row: one tab stop, walked with the arrow keys (the
* kit's Toolbar), each control a pressed-or-not toggle that names itself and
* its chord. `minimal` keeps the inline marks and the link; lists, headings
* and quotes are still a markdown shortcut away.
*/
export function RichTextToolbar({ editor, variant, disabled, linkOpen, onLinkOpenChange }: RichTextToolbarProps) {
const tools = variant === "full" ? [...MARKS, ...HEADINGS, ...BLOCKS] : MARKS
const state = useEditorState({
editor,
selector: ({ editor: current }) => ({
active: tools.map((tool) => (current ? tool.active(current) : false)),
canUndo: current?.can().undo() ?? false,
canRedo: current?.can().redo() ?? false,
}),
})
const pressed = (tool: Tool) => state?.active[tools.indexOf(tool)] ?? false
const group = (list: Tool[]) =>
list.map((tool) => <ToolToggle key={tool.label} tool={tool} editor={editor} pressed={pressed(tool)} disabled={disabled} />)
const run = (command: (chain: ChainedCommands) => ChainedCommands) => {
if (editor) command(editor.chain().focus()).run()
}
return (
<TooltipProvider delay={400}>
<Toolbar
aria-label="Formatting"
size="sm"
onKeyDown={inReadingOrder}
// The field's frame is the box: the row is a strip across its top,
// ruled off from the text, and wraps on a phone rather than overflowing.
className="w-full flex-wrap rounded-none border-b border-border bg-transparent shadow-none ring-0"
>
<ToolbarGroup>{group(MARKS)}</ToolbarGroup>
{variant === "full" ? (
<>
<ToolbarSeparator />
<ToolbarGroup>{group(HEADINGS)}</ToolbarGroup>
<ToolbarSeparator />
<ToolbarGroup>{group(BLOCKS)}</ToolbarGroup>
</>
) : null}
<ToolbarSeparator />
<LinkPopover editor={editor} disabled={disabled} open={linkOpen} onOpenChange={onLinkOpenChange} />
{variant === "full" ? (
<>
<ToolbarSpacer />
<ToolbarGroup>
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label="Undo"
tooltip="Undo"
shortcut="mod+z"
disabled={disabled || !state?.canUndo}
onMouseDown={keepFocus}
onClick={() => run((chain) => chain.undo())}
className="text-muted-foreground"
>
<Undo2Icon aria-hidden="true" className="rtl:-scale-x-100" />
</Button>
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label="Redo"
tooltip="Redo"
shortcut="mod+shift+z"
disabled={disabled || !state?.canRedo}
onMouseDown={keepFocus}
onClick={() => run((chain) => chain.redo())}
className="text-muted-foreground"
>
<Redo2Icon aria-hidden="true" className="rtl:-scale-x-100" />
</Button>
</ToolbarGroup>
</>
) : null}
</Toolbar>
</TooltipProvider>
)
}"use client"
import * as React from "react"
import type { Editor } from "@tiptap/react"
import { LinkIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import { FieldError } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { KbdShortcut } from "@/components/ui/kbd-shortcut"
import { Label } from "@/components/ui/label"
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
import { isSafeHref } from "./document"
/** What the field says of an address it will not link to. */
export const UNSAFE_LINK_MESSAGE = "Links start with https://, http:// or mailto:"
/**
* The address field, mounted each time the popover opens, so it starts on
* the link under the selection — or empty, for a new one.
*/
function LinkForm({ editor, onDone }: { editor: Editor | null; onDone: () => void }) {
const fieldId = React.useId()
const errorId = React.useId()
const [href, setHref] = React.useState<string>(() => editor?.getAttributes("link").href ?? "")
const [inLink] = React.useState(() => editor?.isActive("link") ?? false)
const [refused, setRefused] = React.useState(false)
function remove() {
editor?.chain().focus().extendMarkRange("link").unsetLink().run()
onDone()
}
function apply(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
// The popover is portalled out of any form the editor sits in, but React
// bubbles a submit through the tree it was rendered in: this one is the
// link field's alone.
event.stopPropagation()
const address = href.trim()
if (address === "") return remove()
// The editor's own check would drop it silently; the reader is told why.
if (!isSafeHref(address)) return setRefused(true)
if (editor) {
const chain = editor.chain().focus()
// With nothing selected and no link under the caret, the address is
// also the words: it goes in as linked text rather than as a mark on
// whatever is typed next.
if (editor.state.selection.empty && !editor.isActive("link")) {
chain.insertContent({ type: "text", text: address, marks: [{ type: "link", attrs: { href: address } }] }).run()
} else {
chain.extendMarkRange("link").setLink({ href: address }).run()
}
}
onDone()
}
return (
<form noValidate onSubmit={apply} className="flex flex-col gap-2">
<Label htmlFor={fieldId}>Link address</Label>
<Input
id={fieldId}
value={href}
inputMode="url"
autoComplete="off"
spellCheck={false}
placeholder="https://"
aria-invalid={refused || undefined}
aria-describedby={refused ? errorId : undefined}
onChange={(event) => {
setHref(event.target.value)
setRefused(false)
}}
/>
{refused ? (
<FieldError id={errorId} className="text-xs">
{UNSAFE_LINK_MESSAGE}
</FieldError>
) : null}
<div className="flex justify-end gap-2">
{inLink ? (
<Button type="button" variant="ghost" size="sm" onClick={remove}>
Remove
</Button>
) : null}
<Button type="submit" size="sm">
Apply
</Button>
</div>
</form>
)
}
export type LinkPopoverProps = {
editor: Editor | null
disabled?: boolean
open: boolean
onOpenChange: (open: boolean) => void
}
/**
* The toolbar's Link button and the field it opens. Open from the button or
* from ⌘K in the text; Apply links the selection (or writes the address as a
* link at the caret), Remove unlinks it, and an address that is not http,
* https or mailto is refused beside the field. The focus goes back to the
* text whenever the popover closes on something done in it.
*/
export function LinkPopover({ editor, disabled = false, open, onOpenChange }: LinkPopoverProps) {
return (
<Popover
open={open}
onOpenChange={(next, details) => {
onOpenChange(next)
if (!next && details.reason === "escape-key") editor?.commands.focus()
}}
>
<Tooltip>
<TooltipTrigger
render={
<PopoverTrigger
render={
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label="Link"
aria-keyshortcuts="Meta+K Control+K"
disabled={disabled}
className="text-muted-foreground"
>
<LinkIcon aria-hidden="true" />
</Button>
}
/>
}
/>
<TooltipContent className="flex items-center gap-2">
Link
<KbdShortcut keys="mod+k" size="sm" className="opacity-80" />
</TooltipContent>
</Tooltip>
<PopoverContent align="start" className="w-72" finalFocus={false}>
<LinkForm editor={editor} onDone={() => onOpenChange(false)} />
</PopoverContent>
</Popover>
)
}/**
* The rich-text editor's document, as it is saved: the JSON the editor hands
* back, held to what the editor can make. No React and no editor in here, so a
* server action reads a posted document with the same rules the browser wrote
* it under — `parseRichDoc` is the check, and anything it does not know is a
* refusal, never a guess.
*/
export type RichMark = { type: "bold" | "italic" | "strike" | "code" } | { type: "link"; attrs: { href: string } }
export type RichInline = { type: "text"; text: string; marks?: RichMark[] } | { type: "hardBreak" }
export type RichBlock =
| { type: "paragraph"; content?: RichInline[] }
| { type: "heading"; attrs: { level: 2 | 3 }; content?: RichInline[] }
| { type: "codeBlock"; content?: { type: "text"; text: string }[] }
| { type: "blockquote"; content: RichBlock[] }
| { type: "bulletList"; content: RichListItem[] }
| { type: "orderedList"; attrs?: { start: number }; content: RichListItem[] }
export type RichListItem = { type: "listItem"; content: RichBlock[] }
export type RichDoc = { type: "doc"; content: RichBlock[] }
/** How big a document may be: nodes of any kind, and blocks inside blocks. */
export const RICH_LIMITS = { maxNodes: 2000, maxDepth: 6 } as const
/** What the editor holds before anything is typed: one empty paragraph. */
export const EMPTY_DOC: RichDoc = { type: "doc", content: [{ type: "paragraph" }] }
const SAFE_PROTOCOLS = new Set(["http:", "https:", "mailto:"])
/**
* Whether a link may point here: an absolute http, https or mailto address,
* written without spaces or control characters. Everything else — a
* `javascript:` or `data:` address, a relative or protocol-relative one —
* is refused.
*/
export function isSafeHref(href: string): boolean {
if (href.length === 0 || href.length > 2_048 || href.trim() !== href) return false
if (/[\u0000-\u001F\u007F\s]/.test(href)) return false
try {
return SAFE_PROTOCOLS.has(new URL(href).protocol)
} catch {
return false // relative and protocol-relative addresses have no protocol of their own
}
}
type Budget = { nodes: number; chars: number }
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value)
const SIMPLE_MARKS = new Set(["bold", "italic", "strike", "code"])
const MAX_MARKS = 5
/** One more node read; false once the document has more than it may. */
function count(budget: Budget): boolean {
budget.nodes += 1
return budget.nodes <= RICH_LIMITS.maxNodes
}
function parseMarks(input: unknown): RichMark[] | null {
if (!Array.isArray(input) || input.length > MAX_MARKS) return null
const marks: RichMark[] = []
for (const mark of input) {
if (!isRecord(mark) || typeof mark.type !== "string") return null
// A mark twice over is a document the editor never wrote.
if (marks.some((kept) => kept.type === mark.type)) return null
if (SIMPLE_MARKS.has(mark.type)) {
marks.push({ type: mark.type as "bold" | "italic" | "strike" | "code" })
} else if (mark.type === "link" && isRecord(mark.attrs) && typeof mark.attrs.href === "string" && isSafeHref(mark.attrs.href)) {
marks.push({ type: "link", attrs: { href: mark.attrs.href } })
} else {
return null
}
}
return marks
}
function parseText(node: Record<string, unknown>, budget: Budget): { type: "text"; text: string } | null {
if (typeof node.text !== "string" || node.text.length === 0) return null
budget.chars += node.text.length
return { type: "text", text: node.text }
}
function parseInline(node: unknown, budget: Budget): RichInline | null {
if (!isRecord(node) || !count(budget)) return null
if (node.type === "hardBreak") return { type: "hardBreak" }
if (node.type !== "text") return null
const text = parseText(node, budget)
if (!text) return null
if (node.marks === undefined) return text
const marks = parseMarks(node.marks)
if (!marks) return null
return marks.length > 0 ? { ...text, marks } : text
}
/** A list of children, each read by `parse`; null if any is refused, or if it must hold one and holds none. */
function parseAll<T>(input: unknown, parse: (node: unknown) => T | null, required: boolean): T[] | null {
if (input === undefined && !required) return []
if (!Array.isArray(input) || (required && input.length === 0)) return null
const out: T[] = []
for (const node of input) {
const parsed = parse(node)
if (!parsed) return null
out.push(parsed)
}
return out
}
function parseListItem(node: unknown, depth: number, budget: Budget): RichListItem | null {
if (!isRecord(node) || node.type !== "listItem" || !count(budget) || depth > RICH_LIMITS.maxDepth) return null
const content = parseAll(node.content, (child) => parseBlock(child, depth + 1, budget), true)
return content ? { type: "listItem", content } : null
}
function parseBlock(node: unknown, depth: number, budget: Budget): RichBlock | null {
if (!isRecord(node) || !count(budget) || depth > RICH_LIMITS.maxDepth) return null
const inlines = () => parseAll(node.content, (child) => parseInline(child, budget), false)
const blocks = () => parseAll(node.content, (child) => parseBlock(child, depth + 1, budget), true)
const items = () => parseAll(node.content, (child) => parseListItem(child, depth + 1, budget), true)
// Inline content is omitted when there is none: the editor writes an empty
// paragraph without it.
switch (node.type) {
case "paragraph": {
const content = inlines()
if (!content) return null
return content.length > 0 ? { type: "paragraph", content } : { type: "paragraph" }
}
case "heading": {
const level = isRecord(node.attrs) ? node.attrs.level : undefined
if (level !== 2 && level !== 3) return null
const content = inlines()
if (!content) return null
return content.length > 0 ? { type: "heading", attrs: { level }, content } : { type: "heading", attrs: { level } }
}
case "codeBlock": {
// Code is text as typed: no marks, no breaks — a new line is a "\n".
const content = parseAll(
node.content,
(child) =>
isRecord(child) && child.type === "text" && (child.marks === undefined || (Array.isArray(child.marks) && child.marks.length === 0)) && count(budget)
? parseText(child, budget)
: null,
false
)
return content ? (content.length > 0 ? { type: "codeBlock", content } : { type: "codeBlock" }) : null
}
case "blockquote": {
const content = blocks()
return content ? { type: "blockquote", content } : null
}
case "bulletList": {
const content = items()
return content ? { type: "bulletList", content } : null
}
case "orderedList": {
const content = items()
if (!content) return null
const start = isRecord(node.attrs) ? node.attrs.start : undefined
return typeof start === "number" && Number.isInteger(start) && start >= 1 && start <= 10_000
? { type: "orderedList", attrs: { start }, content }
: { type: "orderedList", content }
}
default:
return null
}
}
/**
* The document, rebuilt from what was posted with only what the editor can
* make — or null. Every node is one the editor writes (paragraphs, headings 2
* and 3, lists, quotes, code blocks, line breaks, text), every mark is bold,
* italic, strike, code or a link to a safe address, and nothing else is
* copied across: an attribute the editor adds (a link's target, a list's
* type) is dropped rather than trusted. A document past `maxLength`
* characters of text, past RICH_LIMITS's nodes or nesting, is refused whole.
*/
export function parseRichDoc(input: unknown, options: { maxLength?: number } = {}): RichDoc | null {
if (!isRecord(input) || input.type !== "doc") return null
const budget: Budget = { nodes: 0, chars: 0 }
const content = parseAll(input.content, (node) => parseBlock(node, 1, budget), true)
if (!content) return null
if (options.maxLength !== undefined && budget.chars > options.maxLength) return null
return { type: "doc", content }
}
function inlineToPlain(content: RichInline[] | undefined): string {
return (content ?? []).map((node) => (node.type === "text" ? node.text : "\n")).join("")
}
function itemToPlain(item: RichListItem, marker: string): string {
// The item's own lines under its marker, and a nested list indented under them.
return item.content
.map(blockToPlain)
.join("\n")
.split("\n")
.map((line, index) => (index === 0 ? `${marker}${line}` : ` ${line}`))
.join("\n")
}
function blockToPlain(block: RichBlock): string {
switch (block.type) {
case "paragraph":
case "heading":
return inlineToPlain(block.content)
case "codeBlock":
return (block.content ?? []).map((node) => node.text).join("")
case "blockquote":
return block.content.map(blockToPlain).join("\n\n")
case "bulletList":
return block.content.map((item) => itemToPlain(item, "• ")).join("\n")
case "orderedList": {
const start = block.attrs?.start ?? 1
return block.content.map((item, index) => itemToPlain(item, `${start + index}. `)).join("\n")
}
}
}
/** The document as plain text: blocks a blank line apart, list items a line each, a line break a new line. */
export function richTextToPlain(doc: RichDoc): string {
return doc.content.map(blockToPlain).join("\n\n")
}
function blockLength(block: RichBlock | RichListItem): number {
if (block.type === "paragraph" || block.type === "heading" || block.type === "codeBlock") {
return (block.content ?? []).reduce((sum, node) => sum + (node.type === "text" ? node.text.length : 0), 0)
}
return (block.content as (RichBlock | RichListItem)[]).reduce((sum, child) => sum + blockLength(child), 0)
}
/** How many characters of text the document holds — what `maxLength` counts. */
export function richTextLength(doc: RichDoc): number {
return doc.content.reduce((sum, block) => sum + blockLength(block), 0)
}
/**
* Plain text as a document: a paragraph per run of blank lines, a line break
* per single new line. The new lines at either end of the text start and end
* nothing, so they are left out.
*/
export function plainToRichDoc(text: string): RichDoc {
const paragraphs = text
.replace(/\r\n?/g, "\n")
.replace(/^\n+|\n+$/g, "")
.split(/\n(?:[ \t]*\n)+/)
.filter((paragraph) => paragraph.length > 0)
.map((paragraph): RichBlock => {
const content: RichInline[] = []
paragraph.split("\n").forEach((line, index) => {
if (index > 0) content.push({ type: "hardBreak" })
if (line.length > 0) content.push({ type: "text", text: line })
})
return content.length > 0 ? { type: "paragraph", content } : { type: "paragraph" }
})
return { type: "doc", content: paragraphs.length > 0 ? paragraphs : [{ type: "paragraph" }] }
}import * as React from "react"
import { cn } from "@/lib/utils"
import {
isSafeHref,
plainToRichDoc,
type RichBlock,
type RichDoc,
type RichInline,
type RichListItem,
type RichMark,
} from "./document"
/**
* How a document reads, set on the element that holds it: the space between
* blocks, lists, quotes, code and links. RichText's root wears it, and so does
* the editor's writing surface, so a message reads after it is sent the way it
* did while it was written.
*/
export const RICH_TEXT_PROSE =
"text-sm text-foreground text-pretty wrap-break-word [&>*+*]:mt-3 [&_ul]:list-disc [&_ul]:ps-5 [&_ol]:list-decimal [&_ol]:ps-5 [&_li]:marker:text-muted-foreground [&_li+li]:mt-1 [&_li>*+*]:mt-1 [&_blockquote]:border-s-2 [&_blockquote]:border-rule [&_blockquote]:ps-3 [&_blockquote]:text-muted-foreground [&_blockquote>*+*]:mt-2 [&_pre]:rounded-lg [&_pre]:bg-muted [&_pre]:p-3 [&_pre]:font-mono [&_pre]:text-label [&_pre]:whitespace-pre-wrap [&_:not(pre)>code]:rounded-sm [&_:not(pre)>code]:bg-muted [&_:not(pre)>code]:px-1 [&_:not(pre)>code]:py-0.5 [&_:not(pre)>code]:font-mono [&_:not(pre)>code]:text-label [&_strong]:font-semibold [&_a]:text-brand [&_a]:underline [&_a]:underline-offset-4"
/** A document's two heading levels. RichText sets them on the element, at whatever tag the page needs. */
const HEADING_CLASSES = { 2: "text-base font-semibold", 3: "text-sm font-semibold" } as const
/** The same two levels where the editor draws them, as its h2 and h3. Keep in step with HEADING_CLASSES. */
export const RICH_TEXT_EDITOR_HEADINGS = "[&_h2]:text-base [&_h2]:font-semibold [&_h3]:text-sm [&_h3]:font-semibold"
export type RichTextProps = React.ComponentProps<"div"> & {
/** A saved document — read through parseRichDoc on its way in — or plain text, a paragraph per blank line. */
doc: RichDoc | string
/**
* The level the document's own first heading level is drawn at; its second
* level is one below. 3 by default, for a document under a card's h2.
*/
headingLevel?: 2 | 3 | 4
}
const linkOf = (node: RichInline) =>
node.type === "text" ? node.marks?.find((mark): mark is Extract<RichMark, { type: "link" }> => mark.type === "link") : undefined
/** One text node with every mark but the link, innermost first. */
function renderText(node: Extract<RichInline, { type: "text" }>, key: React.Key): React.ReactNode {
let out: React.ReactNode = node.text
const has = (type: RichMark["type"]) => node.marks?.some((mark) => mark.type === type)
if (has("code")) out = <code>{out}</code>
if (has("strike")) out = <s>{out}</s>
if (has("italic")) out = <em>{out}</em>
if (has("bold")) out = <strong>{out}</strong>
return <React.Fragment key={key}>{out}</React.Fragment>
}
/**
* A run of inline nodes. Neighbours that share a link are drawn inside one
* anchor, so a link with a bold word in it is still one link; a link to an
* address that is not safe is drawn as its words alone.
*/
function renderInlines(content: RichInline[] | undefined): React.ReactNode[] {
const out: React.ReactNode[] = []
const nodes = content ?? []
for (let index = 0; index < nodes.length; ) {
const href = linkOf(nodes[index])?.attrs.href
let end = index + 1
while (href !== undefined && end < nodes.length && linkOf(nodes[end])?.attrs.href === href) end += 1
const run = nodes.slice(index, end).map((node, offset) =>
node.type === "hardBreak" ? <br key={index + offset} /> : renderText(node, index + offset)
)
out.push(
href !== undefined && isSafeHref(href) ? (
<a key={index} href={href} rel="noopener noreferrer" className="focus-ring">
{run}
</a>
) : (
<React.Fragment key={index}>{run}</React.Fragment>
)
)
index = end
}
return out
}
function renderItem(item: RichListItem, headingLevel: number, key: React.Key) {
return <li key={key}>{item.content.map((block, index) => renderBlock(block, headingLevel, index))}</li>
}
function renderBlock(block: RichBlock, headingLevel: number, key: React.Key): React.ReactNode {
switch (block.type) {
case "paragraph":
// An empty paragraph is a blank line the writer left: a line high, as the editor drew it.
return <p key={key}>{block.content?.length ? renderInlines(block.content) : <br />}</p>
case "heading": {
const Tag = `h${Math.min(headingLevel + block.attrs.level - 2, 6)}` as "h2" | "h3" | "h4" | "h5"
return (
<Tag key={key} className={HEADING_CLASSES[block.attrs.level]}>
{renderInlines(block.content)}
</Tag>
)
}
case "codeBlock":
return (
<pre key={key}>
<code>{(block.content ?? []).map((node) => node.text).join("")}</code>
</pre>
)
case "blockquote":
return <blockquote key={key}>{block.content.map((child, index) => renderBlock(child, headingLevel, index))}</blockquote>
case "bulletList":
return <ul key={key}>{block.content.map((item, index) => renderItem(item, headingLevel, index))}</ul>
case "orderedList":
return (
<ol key={key} start={block.attrs?.start}>
{block.content.map((item, index) => renderItem(item, headingLevel, index))}
</ol>
)
default:
// A block the document type does not know is not drawn: nothing it holds is trusted.
return null
}
}
/**
* A saved document, drawn with React elements and nothing else — never
* `dangerouslySetInnerHTML`, and never an attribute the document carries
* beyond a link's address. Server-compatible: no hooks and no client
* boundary, so a page renders a sent message where it reads it.
*/
function RichText({ doc, headingLevel = 3, className, ...props }: RichTextProps) {
const document = typeof doc === "string" ? plainToRichDoc(doc) : doc
return (
<div data-slot="rich-text" className={cn(RICH_TEXT_PROSE, className)} {...props}>
{document.content.map((block, index) => renderBlock(block, headingLevel, index))}
</div>
)
}
export { RichText }Markdown works as you type: ## for a heading, - for a list, > for a quote, **bold** and `code`.
Aisha Adeyemi · Finance
Hi team — the September close is ready for review.
Check the accruals in ledger/2026-09
Sign off the variance report
Anything over 2% gets a line of explanation.
Thanks,
Aisha
In the inbox list
Hi team — the September close is ready for review. Before Friday 1. Check the accruals in ledger/2026-09 2. Sign off the variance report Anything over 2% gets a line of explanation. Thanks, Aisha