React rich text editor
Text editing without a package. A document with full toolbar and word count, comments with @ mentions, Markdown with preview, a selection toolbar and email.
Writing tools that need no extra library
A rich text editor lets people format while they write, headings, lists, links and quotes. This one comes without an editor package, so the bundle stays small, and its toolbar follows the APG toolbar pattern with active states, shortcuts and full keyboard reach.
Document has a full toolbar with headings, lists, quote, code, link and alignment, active states from the selection, shortcuts, autosave, word count and reading time. Comment with mentions is a compact editor under a thread, type @ to pick a person from a list and post with Ctrl and Enter. Markdown with preview writes Markdown around the selection and shows the preview beside it on desktops and as a second tab on phones. Selection toolbar keeps the page quiet and brings a small toolbar above selected words, Alt and F10 reach it by keyboard. Email composer has recipients as checked chips with Cc, subject, attachments, a signature to pick and a few seconds to undo the send.
Offer only the formats your content needs, a comment box rarely needs headings. Keep shortcuts the ones people already know, bold, italic and link. Show the word count where length matters, in articles and product texts, and leave it out of chats.
Variants
Details
What Rich text editor brings along, read from the registry and the source.
- Category
- Team and collaboration
- Area
- App
- Variants
- 5
- Version
- 1.0.0 · since 1.2.0
- File
- components/ui/rich-text-editor.tsx
- Brings along
- utilskitavatarbuttoniconicon-buttonmotionsidebar
- npm packages
- None
- Languages
- English and German, 73 texts of its own
- Studio
- Adjust in code
- In collections
- SaaS app screens
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The RichTextEditorProps interface from components/ui/rich-text-editor.tsx, with the comments from the code.
variantoptionalRichTextEditorVariantdocumentcommentmarkdownbubbleemail
sidebaroptionalOmit<SidebarProps, 'children'> | nullThe app frame. Leave it out (or null) for a standalone screen.
titleoptionalstringsubtitleoptionalstringdocTitleoptionalstringdocument: the title of the document
valueoptionalstringThe starting text. HTML for every variant except markdown, which takes Markdown.
placeholderoptionalstringnowoptionalstringISO date and time that counts as now
meoptionalstringcomment: you and the people @ can mention
peopleoptionalEditorPerson[]commentsoptionalEditorComment[]fromoptionalstring[]email
tooptionalstring[]subjectoptionalstringsignaturesoptionalEditorSignature[]wpmoptionalnumberWords per minute for the reading time
onActionoptional(action: string, data: Record<string, unknown>) => void | Promise<void>Called on save, send and post with the content. Without it the block simulates the request.
Built-in texts
Rich text editor writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.
Show all 73 texts
| Key | English | German |
|---|---|---|
| toolbar | English Formatting | German Formatierung |
| editor | English Text | German Text |
| paragraph | English Normal text | German Normaler Text |
| h1 | English Title | German Titel |
| h2 | English Heading | German Überschrift |
| h3 | English Subheading | German Unterüberschrift |
| bold | English Bold | German Fett |
| italic | English Italic | German Kursiv |
| underline | English Underline | German Unterstrichen |
| strike | English Strikethrough | German Durchgestrichen |
| ul | English Bulleted list | German Aufzählung |
| ol | English Numbered list | German Nummerierte Liste |
| quote | English Quote | German Zitat |
| code | English Code | German Code |
| codeBlock | English Code block | German Codeblock |
| link | English Link | German Link |
| left | English Align left | German Linksbündig |
| center | English Align center | German Zentriert |
| right | English Align right | German Rechtsbündig |
| undo | English Undo | German Rückgängig |
| redo | English Redo | German Wiederholen |
| linkUrl | English Link address | German Linkadresse |
| apply | English Apply link | German Link setzen |
| unlink | English Remove link | German Link entfernen |
| cancel | English Cancel | German Abbrechen |
| badUrl | English Start the address with https:// or mailto: | German Beginne die Adresse mit https:// oder mailto: |
| words | English {n} words | German {n} Wörter |
| oneWord | English 1 word | German 1 Wort |
| chars | English {n} characters | German {n} Zeichen |
| read | English {n} min read | German {n} Min. Lesezeit |
| saving | English Saving… | German Wird gespeichert… |
| saved | English Saved | German Gespeichert |
| edited | English Changes not saved yet | German Änderungen noch nicht gespeichert |
| docTitle | English Title | German Titel |
| write | English Write | German Schreiben |
| preview | English Preview | German Vorschau |
| mdHint | English Markdown works. Ctrl B, I and K format the selection. | German Markdown funktioniert. Strg B, I und K formatieren die Auswahl. |
| copyMd | English Copy Markdown | German Markdown kopieren |
| copied | English Markdown copied. | German Markdown kopiert. |
| lines | English {n} lines | German {n} Zeilen |
| previewEmpty | English The preview appears as you write. | German Die Vorschau erscheint beim Schreiben. |
| bubbleHint | English Select words to format them. Alt and F10 reach the toolbar. | German Markiere Wörter, um sie zu formatieren. Alt und F10 erreichen die Leiste. |
| mention | English Mention someone | German Jemanden erwähnen |
| people | English People | German Personen |
| noPeople | English Nobody with that name | German Niemand mit diesem Namen |
| comments | English Comments | German Kommentare |
| post | English Comment | German Kommentieren |
| posting | English Posting… | German Wird gesendet… |
| posted | English Comment posted. | German Kommentar veröffentlicht. |
| commentHint | English Ctrl and Enter post, @ mentions someone | German Strg und Enter senden, @ erwähnt jemanden |
| you | English You | German Du |
| from | English From | German Von |
| to | English To | German An |
| cc | English Cc | German Cc |
| addCc | English Add Cc | German Cc hinzufügen |
| subject | English Subject | German Betreff |
| recipients | English Recipients | German Empfänger |
| removeRecipient | English Remove {name} | German {name} entfernen |
| badEmail | English Check this address | German Adresse prüfen |
| needTo | English Add at least one recipient. | German Füge mindestens einen Empfänger hinzu. |
| signature | English Signature | German Signatur |
| noSignature | English No signature | German Keine Signatur |
| attach | English Attach files | German Dateien anhängen |
| removeFile | English Remove {name} | German {name} entfernen |
| send | English Send | German Senden |
| sending | English Sending… | German Wird gesendet… |
| sentIn | English Sending in {n} s | German Wird in {n} s gesendet |
| undoSend | English Undo | German Rückgängig |
| sent | English Email sent to {to}. | German E-Mail an {to} gesendet. |
| unsent | English Sending stopped. The email is back in the editor. | German Senden gestoppt. Die E-Mail ist wieder im Editor. |
| discard | English Discard draft | German Entwurf verwerfen |
| discarded | English Draft discarded. | German Entwurf verworfen. |
| newMail | English New email | German Neue E-Mail |
Accessibility
Read from the source of the component.
- Rich text editor handles the keys Escape, Enter, Arrow up, Arrow down, Arrow left, Arrow right, Home, End, Tab, Backspace and Space itself.
- Sets aria-controls, aria-describedby, aria-invalid, aria-keyshortcuts, aria-label, aria-labelledby, aria-live, aria-multiline, aria-orientation, aria-pressed and aria-selected, so screen readers announce purpose and state.
- Uses the roles alert, listbox, option, separator, status, tab, tablist, tabpanel, textbox and toolbar.
- Announces changes through a live region without moving focus.
- Every field has a label, visible or for screen readers.
- Adds text for screen readers where icons and graphics stand alone.
- Shows a visible focus ring when used with the keyboard.
- The server renders the starting state in full, JavaScript only adds the interaction.
Motion
- Brings animations of its own, calmly timed and without wobble.
- With reduced motion (prefers-reduced-motion) it stays still. 1 places in the code switch motion off then.
Source
The file aiw add puts into your project. From then on it is yours.
Show the file, 1,588 lines
'use client';
import { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { cn, type Locale, prefersReducedMotion, round, useLocale, useTexts } from '@/lib/kit';
import { Avatar } from '@/components/ui/avatar';
import { Button } from '@/components/ui/button';
import { Icon, type IconName } from '@/components/ui/icon';
import { IconButton } from '@/components/ui/icon-button';
import { useScrollFade } from '@/components/ui/motion';
import { Sidebar, type SidebarProps } from '@/components/ui/sidebar';
export type RichTextEditorVariant = 'document' | 'comment' | 'markdown' | 'bubble' | 'email';
export interface EditorPerson {
id: string;
name: string;
role?: string;
}
export interface EditorComment {
id: string;
/** Person id */
author: string;
/** ISO date and time */
time: string;
/** Formatted text as HTML (b, i, u, s, a, code, ul, ol, blockquote, p) */
html: string;
}
export interface EditorSignature {
id: string;
label: string;
/** Lines of the signature */
lines: string[];
}
export interface RichTextEditorProps {
variant?: RichTextEditorVariant;
/** The app frame. Leave it out (or null) for a standalone screen. */
sidebar?: Omit<SidebarProps, 'children'> | null;
title?: string;
subtitle?: string;
/** document: the title of the document */
docTitle?: string;
/** The starting text. HTML for every variant except markdown, which takes Markdown. */
value?: string;
placeholder?: string;
/** ISO date and time that counts as now */
now?: string;
/** comment: you and the people @ can mention */
me?: string;
people?: EditorPerson[];
comments?: EditorComment[];
/** email */
from?: string[];
to?: string[];
subject?: string;
signatures?: EditorSignature[];
/** Words per minute for the reading time */
wpm?: number;
/** Called on save, send and post with the content. Without it the block simulates the request. */
onAction?: (action: string, data: Record<string, unknown>) => void | Promise<void>;
labels?: Partial<(typeof TEXTS)['en']>;
className?: string;
}
const TEXTS = {
en: {
toolbar: 'Formatting',
editor: 'Text',
paragraph: 'Normal text',
h1: 'Title',
h2: 'Heading',
h3: 'Subheading',
bold: 'Bold',
italic: 'Italic',
underline: 'Underline',
strike: 'Strikethrough',
ul: 'Bulleted list',
ol: 'Numbered list',
quote: 'Quote',
code: 'Code',
codeBlock: 'Code block',
link: 'Link',
left: 'Align left',
center: 'Align center',
right: 'Align right',
undo: 'Undo',
redo: 'Redo',
linkUrl: 'Link address',
apply: 'Apply link',
unlink: 'Remove link',
cancel: 'Cancel',
badUrl: 'Start the address with https:// or mailto:',
words: '{n} words',
oneWord: '1 word',
chars: '{n} characters',
read: '{n} min read',
saving: 'Saving…',
saved: 'Saved',
edited: 'Changes not saved yet',
docTitle: 'Title',
write: 'Write',
preview: 'Preview',
mdHint: 'Markdown works. Ctrl B, I and K format the selection.',
copyMd: 'Copy Markdown',
copied: 'Markdown copied.',
lines: '{n} lines',
previewEmpty: 'The preview appears as you write.',
bubbleHint: 'Select words to format them. Alt and F10 reach the toolbar.',
mention: 'Mention someone',
people: 'People',
noPeople: 'Nobody with that name',
comments: 'Comments',
post: 'Comment',
posting: 'Posting…',
posted: 'Comment posted.',
commentHint: 'Ctrl and Enter post, @ mentions someone',
you: 'You',
from: 'From',
to: 'To',
cc: 'Cc',
addCc: 'Add Cc',
subject: 'Subject',
recipients: 'Recipients',
removeRecipient: 'Remove {name}',
badEmail: 'Check this address',
needTo: 'Add at least one recipient.',
signature: 'Signature',
noSignature: 'No signature',
attach: 'Attach files',
removeFile: 'Remove {name}',
send: 'Send',
sending: 'Sending…',
sentIn: 'Sending in {n} s',
undoSend: 'Undo',
sent: 'Email sent to {to}.',
unsent: 'Sending stopped. The email is back in the editor.',
discard: 'Discard draft',
discarded: 'Draft discarded.',
newMail: 'New email',
},
de: {
toolbar: 'Formatierung',
editor: 'Text',
paragraph: 'Normaler Text',
h1: 'Titel',
h2: 'Überschrift',
h3: 'Unterüberschrift',
bold: 'Fett',
italic: 'Kursiv',
underline: 'Unterstrichen',
strike: 'Durchgestrichen',
ul: 'Aufzählung',
ol: 'Nummerierte Liste',
quote: 'Zitat',
code: 'Code',
codeBlock: 'Codeblock',
link: 'Link',
left: 'Linksbündig',
center: 'Zentriert',
right: 'Rechtsbündig',
undo: 'Rückgängig',
redo: 'Wiederholen',
linkUrl: 'Linkadresse',
apply: 'Link setzen',
unlink: 'Link entfernen',
cancel: 'Abbrechen',
badUrl: 'Beginne die Adresse mit https:// oder mailto:',
words: '{n} Wörter',
oneWord: '1 Wort',
chars: '{n} Zeichen',
read: '{n} Min. Lesezeit',
saving: 'Wird gespeichert…',
saved: 'Gespeichert',
edited: 'Änderungen noch nicht gespeichert',
docTitle: 'Titel',
write: 'Schreiben',
preview: 'Vorschau',
mdHint: 'Markdown funktioniert. Strg B, I und K formatieren die Auswahl.',
copyMd: 'Markdown kopieren',
copied: 'Markdown kopiert.',
lines: '{n} Zeilen',
previewEmpty: 'Die Vorschau erscheint beim Schreiben.',
bubbleHint: 'Markiere Wörter, um sie zu formatieren. Alt und F10 erreichen die Leiste.',
mention: 'Jemanden erwähnen',
people: 'Personen',
noPeople: 'Niemand mit diesem Namen',
comments: 'Kommentare',
post: 'Kommentieren',
posting: 'Wird gesendet…',
posted: 'Kommentar veröffentlicht.',
commentHint: 'Strg und Enter senden, @ erwähnt jemanden',
you: 'Du',
from: 'Von',
to: 'An',
cc: 'Cc',
addCc: 'Cc hinzufügen',
subject: 'Betreff',
recipients: 'Empfänger',
removeRecipient: '{name} entfernen',
badEmail: 'Adresse prüfen',
needTo: 'Füge mindestens einen Empfänger hinzu.',
signature: 'Signatur',
noSignature: 'Keine Signatur',
attach: 'Dateien anhängen',
removeFile: '{name} entfernen',
send: 'Senden',
sending: 'Wird gesendet…',
sentIn: 'Wird in {n} s gesendet',
undoSend: 'Rückgängig',
sent: 'E-Mail an {to} gesendet.',
unsent: 'Senden gestoppt. Die E-Mail ist wieder im Editor.',
discard: 'Entwurf verwerfen',
discarded: 'Entwurf verworfen.',
newMail: 'Neue E-Mail',
},
};
type Texts = (typeof TEXTS)['en'];
const fill = (s: string, v: Record<string, string | number>) => s.replace(/\{(\w+)\}/g, (_, k) => String(v[k] ?? ''));
/* ------------------------------------------------------------------ */
/* Styles: the written text and the motion */
/* ------------------------------------------------------------------ */
const CSS =
// Text inside editors and previews
'.b5r-doc{line-height:1.7;overflow-wrap:anywhere}.b5r-doc>*+*{margin-top:.75em}.b5r-doc h1{font-size:1.75em;line-height:1.2;font-weight:650;letter-spacing:-.03em;color:var(--foreground)}' +
'.b5r-doc h2{font-size:1.35em;line-height:1.3;font-weight:650;letter-spacing:-.02em;color:var(--foreground);margin-top:1.2em}.b5r-doc h3{font-size:1.1em;line-height:1.4;font-weight:600;color:var(--foreground);margin-top:1em}' +
'.b5r-doc ul{list-style:disc;padding-inline-start:1.4em}.b5r-doc ol{list-style:decimal;padding-inline-start:1.5em}.b5r-doc li+li{margin-top:.25em}.b5r-doc li::marker{color:var(--subtle-foreground)}' +
'.b5r-doc blockquote{border-inline-start:3px solid color-mix(in oklab,var(--primary) 55%,var(--border));padding-inline-start:1em;color:var(--muted-foreground);font-style:italic}' +
'.b5r-doc code{border-radius:.35em;background:color-mix(in oklab,var(--foreground) 8%,transparent);padding:.1em .35em;font-size:.9em}' +
'.b5r-doc pre{border-radius:.6em;background:color-mix(in oklab,var(--foreground) 6%,var(--card));border:1px solid var(--border);padding:.8em 1em;font-size:.9em;white-space:pre-wrap}.b5r-doc pre code{background:none;padding:0}' +
'.b5r-doc a{color:color-mix(in oklab,var(--primary) 85%,var(--foreground));text-decoration:underline;text-underline-offset:3px;text-decoration-color:color-mix(in oklab,var(--primary) 45%,transparent)}' +
'.b5r-doc b,.b5r-doc strong{font-weight:650;color:var(--foreground)}.b5r-doc s,.b5r-doc strike,.b5r-doc del{text-decoration-color:color-mix(in oklab,var(--foreground) 55%,transparent)}' +
'.b5r-doc .b5r-mention{border-radius:.4em;background:color-mix(in oklab,var(--primary) 13%,transparent);color:color-mix(in oklab,var(--primary) 85%,var(--foreground));padding:.05em .3em;font-weight:550;white-space:nowrap}' +
'[contenteditable].b5r-doc{outline:none}[contenteditable].b5r-doc:focus-visible{outline:1.5px solid color-mix(in oklab,var(--primary) 35%,transparent);outline-offset:6px;border-radius:4px}' +
// Motion
'@keyframes b5r-rise{from{opacity:0;transform:translateY(12px)}}@keyframes b5r-pop{from{opacity:0;transform:translateY(4px) scale(.96)}}@keyframes b5r-press{50%{transform:scale(.88)}}' +
'@media (prefers-reduced-motion:no-preference){.b5r-rise{animation:b5r-rise .6s var(--ease-kit) var(--b5r-d,0ms) both}.b5r-pop{animation:b5r-pop .25s var(--ease-kit) both}.b5r-press[aria-pressed=true]>svg{animation:b5r-press .25s var(--ease-kit)}}';
function Style() {
return (
<style href="kitb5-rich-text-editor" precedence="default">
{CSS}
</style>
);
}
/* ------------------------------------------------------------------ */
/* Helpers */
/* ------------------------------------------------------------------ */
const MIN = 60000;
const toMs = (iso: string) => Date.parse(iso.length <= 10 ? iso + 'T00:00:00Z' : iso + (iso.endsWith('Z') ? '' : 'Z'));
const intl = (l: Locale) => (l === 'de' ? 'de-DE' : 'en-US');
const wait = (ms: number) => new Promise((r) => setTimeout(r, prefersReducedMotion() ? 0 : ms));
async function run(onAction: RichTextEditorProps['onAction'], action: string, data: Record<string, unknown>, ms = 700) {
if (onAction) await onAction(action, data);
else await wait(ms);
}
function ago(iso: string, now: string, l: Locale) {
const rtf = new Intl.RelativeTimeFormat(intl(l), { numeric: 'auto' });
const m = Math.round((toMs(iso) - toMs(now)) / MIN);
const a = Math.abs(m);
if (a < 1) return rtf.format(0, 'minute');
if (a < 60) return rtf.format(m, 'minute');
if (a < 1440) return rtf.format(Math.round(m / 60), 'hour');
return rtf.format(Math.round(m / 1440), 'day');
}
const esc = (s: string) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
const SAFE_URL = /^(https?:\/\/|mailto:|\/|#)/i;
const EMAIL = /^[^\s@,;]+@[^\s@,;]+\.[a-z]{2,}$/i;
function countWords(text: string) {
const w = text.trim().split(/\s+/).filter(Boolean);
return w.length;
}
const ring = 'outline-none focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-2 focus-visible:outline-[color-mix(in_oklab,var(--primary)_70%,transparent)]';
const ringIn = 'outline-none focus-visible:outline-2 focus-visible:outline-solid focus-visible:-outline-offset-2 focus-visible:outline-[color-mix(in_oklab,var(--primary)_70%,transparent)]';
const field = 'h-11 w-full min-w-0 rounded-lg border border-input bg-background px-3 text-base text-foreground shadow-[var(--shadow-sm)] outline-none transition-[border-color,box-shadow] duration-300 placeholder:text-subtle-foreground focus:border-[color-mix(in_oklab,var(--primary)_60%,var(--border-strong))] focus:shadow-[0_0_0_3px_color-mix(in_oklab,var(--primary)_16%,transparent)] sm:h-9 sm:text-[13px]';
function Live({ text }: { text: string }) {
return (
<p role="status" aria-live="polite" className="sr-only">
{text}
</p>
);
}
function Header({ title, subtitle, children }: { title?: string; subtitle?: string; children?: React.ReactNode }) {
if (!title && !subtitle && !children) return null;
return (
<div className="b5r-rise flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
<div className="min-w-0">
{subtitle && <p className="text-[13px] text-muted-foreground">{subtitle}</p>}
{title && <h1 className="mt-0.5 text-balance text-[clamp(1.5rem,1.2rem+1vw,1.875rem)] font-semibold tracking-[-0.035em] text-foreground">{title}</h1>}
</div>
{children && <div className="flex flex-wrap items-center gap-2">{children}</div>}
</div>
);
}
function Main({ children, className }: { children: React.ReactNode; className?: string }) {
return <main className={cn('mx-auto w-full max-w-[80rem] flex-1 p-4 sm:p-6 lg:p-8', className)}>{children}</main>;
}
/* ------------------------------------------------------------------ */
/* The editing core on contentEditable */
/* ------------------------------------------------------------------ */
type Fmt = { bold: boolean; italic: boolean; underline: boolean; strike: boolean; ul: boolean; ol: boolean; block: string; align: 'left' | 'center' | 'right'; link: string | null; code: boolean };
const NONE: Fmt = { bold: false, italic: false, underline: false, strike: false, ul: false, ol: false, block: 'p', align: 'left', link: null, code: false };
function readFmt(root: HTMLElement | null): Fmt {
const sel = document.getSelection();
if (!root || !sel || !sel.rangeCount || !root.contains(sel.anchorNode)) return NONE;
const q = (c: string) => {
try {
return document.queryCommandState(c);
} catch {
return false;
}
};
const node = sel.anchorNode?.nodeType === 1 ? (sel.anchorNode as Element) : sel.anchorNode?.parentElement;
const a = node?.closest('a');
let block = '';
try {
block = String(document.queryCommandValue('formatBlock') || '').toLowerCase().replace(/[<>]/g, '');
} catch {
/* older engines */
}
return {
bold: q('bold'),
italic: q('italic'),
underline: q('underline'),
strike: q('strikeThrough'),
ul: q('insertUnorderedList'),
ol: q('insertOrderedList'),
block: block === 'div' || !block ? 'p' : block,
align: q('justifyCenter') ? 'center' : q('justifyRight') ? 'right' : 'left',
link: a && root.contains(a) ? a.getAttribute('href') : null,
code: !!node?.closest('code') && root.contains(node.closest('code')),
};
}
/**
* Keeps the selection of an editable element, reads the formatting at the
* caret and runs commands. The browser's own editing keeps undo and redo.
*/
function useEditor(onChange?: () => void) {
const ref = useRef<HTMLDivElement>(null);
const saved = useRef<Range | null>(null);
const [fmt, setFmt] = useState<Fmt>(NONE);
const refresh = useCallback(() => setFmt(readFmt(ref.current)), []);
useEffect(() => {
const on = () => {
const el = ref.current;
const sel = document.getSelection();
if (!el || !sel || !sel.rangeCount || !el.contains(sel.anchorNode)) return;
saved.current = sel.getRangeAt(0).cloneRange();
setFmt(readFmt(el));
};
document.addEventListener('selectionchange', on);
return () => document.removeEventListener('selectionchange', on);
}, []);
/** Focus the text again and put the caret back where it was */
const restore = useCallback(() => {
const el = ref.current;
if (!el) return;
if (document.activeElement !== el) el.focus({ preventScroll: true });
const sel = document.getSelection();
if (saved.current && sel && el.contains(saved.current.startContainer)) {
sel.removeAllRanges();
sel.addRange(saved.current);
}
}, []);
const exec = useCallback(
(cmd: string, value?: string) => {
restore();
try {
document.execCommand('styleWithCSS', false, 'false');
document.execCommand('defaultParagraphSeparator', false, 'p');
} catch {
/* not supported */
}
document.execCommand(cmd, false, value);
refresh();
onChange?.();
},
[restore, refresh, onChange],
);
const block = useCallback((tag: string) => exec('formatBlock', `<${fmtBlockSame(tag) ? 'p' : tag}>`), [exec]); // eslint-disable-line react-hooks/exhaustive-deps
function fmtBlockSame(tag: string) {
return readFmt(ref.current).block === tag;
}
const toggleCode = useCallback(() => {
restore();
const sel = document.getSelection();
if (!sel || !sel.rangeCount) return;
const node = sel.anchorNode?.nodeType === 1 ? (sel.anchorNode as Element) : sel.anchorNode?.parentElement;
const code = node?.closest('code');
if (code && ref.current?.contains(code)) {
const r = document.createRange();
r.selectNode(code);
sel.removeAllRanges();
sel.addRange(r);
document.execCommand('insertText', false, code.textContent ?? '');
} else {
const text = sel.toString() || '';
document.execCommand('insertHTML', false, `<code>${esc(text)}</code>​`);
}
refresh();
onChange?.();
}, [restore, refresh, onChange]);
const setLink = useCallback(
(url: string | null) => {
if (!url) return exec('unlink');
restore();
const sel = document.getSelection();
if (sel && sel.isCollapsed) {
// A link without selected words: the address becomes the text
document.execCommand('insertHTML', false, `<a href="${esc(url)}">${esc(url.replace(/^mailto:/, ''))}</a> `);
refresh();
onChange?.();
} else exec('createLink', url);
},
[exec, restore, refresh, onChange],
);
return { ref, fmt, exec, block, toggleCode, setLink, restore, refresh, saved };
}
type Editor = ReturnType<typeof useEditor>;
type Tool = { id: string; label: string; icon?: IconName; text?: string; pressed?: boolean; onRun: () => void; keys?: string } | { sep: true; id: string };
/** Shortcut text per platform, read after hydration */
function useMod() {
const [mod, setMod] = useState('Ctrl');
useEffect(() => {
if (/Mac|iPhone|iPad/.test(navigator.platform)) setMod('⌘');
}, []);
return mod;
}
function buildTools(e: Editor, t: Texts, set: 'full' | 'compact' | 'bubble' | 'mail', openLink: () => void, mod: string): Tool[] {
const f = e.fmt;
const tools: Record<string, Tool> = {
p: { id: 'p', label: t.paragraph, icon: 'pilcrow', pressed: f.block === 'p' && !f.ul && !f.ol, onRun: () => e.exec('formatBlock', '<p>') },
h2: { id: 'h2', label: t.h2, text: 'H2', pressed: f.block === 'h2', onRun: () => e.block('h2'), keys: `${mod} Alt 2` },
h3: { id: 'h3', label: t.h3, text: 'H3', pressed: f.block === 'h3', onRun: () => e.block('h3'), keys: `${mod} Alt 3` },
bold: { id: 'bold', label: t.bold, icon: 'bold', pressed: f.bold, onRun: () => e.exec('bold'), keys: `${mod} B` },
italic: { id: 'italic', label: t.italic, icon: 'italic', pressed: f.italic, onRun: () => e.exec('italic'), keys: `${mod} I` },
underline: { id: 'underline', label: t.underline, icon: 'underline', pressed: f.underline, onRun: () => e.exec('underline'), keys: `${mod} U` },
strike: { id: 'strike', label: t.strike, icon: 'strikethrough', pressed: f.strike, onRun: () => e.exec('strikeThrough'), keys: `${mod} Shift X` },
ul: { id: 'ul', label: t.ul, icon: 'list', pressed: f.ul, onRun: () => e.exec('insertUnorderedList') },
ol: { id: 'ol', label: t.ol, icon: 'list-ordered', pressed: f.ol, onRun: () => e.exec('insertOrderedList') },
quote: { id: 'quote', label: t.quote, icon: 'quote', pressed: f.block === 'blockquote', onRun: () => e.block('blockquote') },
code: { id: 'code', label: t.code, icon: 'code', pressed: f.code, onRun: e.toggleCode, keys: `${mod} E` },
pre: { id: 'pre', label: t.codeBlock, icon: 'terminal', pressed: f.block === 'pre', onRun: () => e.block('pre') },
link: { id: 'link', label: t.link, icon: 'link', pressed: !!f.link, onRun: openLink, keys: `${mod} K` },
left: { id: 'left', label: t.left, icon: 'align-left', pressed: f.align === 'left', onRun: () => e.exec('justifyLeft') },
center: { id: 'center', label: t.center, icon: 'align-center', pressed: f.align === 'center', onRun: () => e.exec('justifyCenter') },
right: { id: 'right', label: t.right, icon: 'align-right', pressed: f.align === 'right', onRun: () => e.exec('justifyRight') },
undo: { id: 'undo', label: t.undo, icon: 'undo', onRun: () => e.exec('undo'), keys: `${mod} Z` },
redo: { id: 'redo', label: t.redo, icon: 'redo', onRun: () => e.exec('redo'), keys: `${mod} Shift Z` },
};
const sep = (n: number): Tool => ({ sep: true, id: `s${n}` });
const pick = (ids: (string | number)[]) => ids.map((x) => (typeof x === 'number' ? sep(x) : tools[x]));
if (set === 'full') return pick(['undo', 'redo', 1, 'p', 'h2', 'h3', 2, 'bold', 'italic', 'underline', 'strike', 3, 'ul', 'ol', 'quote', 4, 'code', 'pre', 'link', 5, 'left', 'center', 'right']);
if (set === 'compact') return pick(['bold', 'italic', 'strike', 1, 'ul', 'ol', 2, 'code', 'link']);
if (set === 'bubble') return pick(['bold', 'italic', 'underline', 'strike', 1, 'h2', 'quote', 'code', 2, 'link']);
return pick(['bold', 'italic', 'underline', 1, 'ul', 'ol', 'quote', 2, 'link', 3, 'undo', 'redo']);
}
/**
* A toolbar after the APG pattern: one tab stop, arrow keys move, Home and
* End jump. A press keeps the selection in the text. Toggles carry aria-pressed.
*/
function Toolbar({ tools, label, controls, className, size = 'md', onEscape, autoFocus }: { tools: Tool[]; label: string; controls: string; className?: string; size?: 'sm' | 'md'; onEscape?: () => void; autoFocus?: boolean }) {
const ref = useRef<HTMLDivElement>(null);
const [cur, setCur] = useState(0);
const items = tools.filter((x): x is Exclude<Tool, { sep: true }> => !('sep' in x));
useEffect(() => {
if (autoFocus) ref.current?.querySelector<HTMLButtonElement>('button[tabindex="0"]')?.focus();
}, [autoFocus]);
const move = (i: number) => {
const n = (i + items.length) % items.length;
setCur(n);
ref.current?.querySelectorAll<HTMLButtonElement>('[data-tool]')[n]?.focus();
};
let k = -1;
return (
<div
ref={ref}
role="toolbar"
aria-label={label}
aria-controls={controls}
aria-orientation="horizontal"
onKeyDown={(e) => {
const i = cur;
if (e.key === 'ArrowRight') move(i + 1);
else if (e.key === 'ArrowLeft') move(i - 1);
else if (e.key === 'Home') move(0);
else if (e.key === 'End') move(items.length - 1);
else if (e.key === 'Escape' && onEscape) onEscape();
else return;
e.preventDefault();
}}
className={cn('flex items-center gap-0.5', className)}
>
{tools.map((x) => {
if ('sep' in x) return <span key={x.id} role="separator" aria-orientation="vertical" className="mx-1 h-5 w-px shrink-0 bg-border" />;
k += 1;
const idx = k;
return (
<button
key={x.id}
type="button"
data-tool
tabIndex={idx === cur ? 0 : -1}
aria-pressed={x.pressed === undefined ? undefined : x.pressed}
aria-label={x.label}
aria-keyshortcuts={x.keys?.replace(/ /g, '+').replace('⌘', 'Meta').replace('Ctrl', 'Control')}
title={x.keys ? `${x.label} (${x.keys})` : x.label}
onMouseDown={(e) => e.preventDefault()}
onFocus={() => setCur(idx)}
onClick={(e) => {
const keyboard = e.detail === 0;
x.onRun();
if (keyboard && x.id !== 'link') requestAnimationFrame(() => ref.current?.querySelectorAll<HTMLButtonElement>('[data-tool]')[idx]?.focus());
}}
className={cn(
'b5r-press relative grid shrink-0 place-items-center rounded-md text-muted-foreground transition-[background-color,color] duration-150 after:absolute hover:bg-accent hover:text-foreground aria-pressed:bg-[color-mix(in_oklab,var(--primary)_13%,transparent)] aria-pressed:text-[color-mix(in_oklab,var(--primary)_85%,var(--foreground))]',
size === 'md' ? 'size-11 sm:size-8 sm:after:-inset-1' : 'size-11 sm:size-7 sm:after:-inset-1.5',
ring,
)}
>
{x.icon ? <Icon name={x.icon} size={size === 'md' ? 17 : 15} /> : <span className="text-[12.5px] font-bold tracking-[-0.02em]">{x.text}</span>}
</button>
);
})}
</div>
);
}
/** Link field shown under a toolbar or inside the bubble */
function LinkField({ t, initial, onApply, onClose, className }: { t: Texts; initial: string; onApply: (url: string | null) => void; onClose: () => void; className?: string }) {
const [url, setUrl] = useState(initial || 'https://');
const [bad, setBad] = useState(false);
const id = useId();
const input = useRef<HTMLInputElement>(null);
useEffect(() => {
input.current?.focus();
input.current?.select();
}, []);
const apply = () => {
const v = url.trim();
if (!SAFE_URL.test(v) || v === 'https://') return setBad(true);
onApply(v);
};
return (
<div className={cn('b5r-pop flex flex-wrap items-center gap-2', className)} onKeyDown={(e) => e.key === 'Escape' && (e.stopPropagation(), onClose())}>
<label htmlFor={id} className="sr-only">
{t.linkUrl}
</label>
<input
ref={input}
id={id}
type="url"
value={url}
aria-invalid={bad || undefined}
aria-describedby={bad ? `${id}-e` : undefined}
onChange={(e) => (setUrl(e.target.value), setBad(false))}
onKeyDown={(e) => e.key === 'Enter' && (e.preventDefault(), apply())}
className={cn(field, 'w-0 flex-1 basis-48 aria-invalid:border-destructive')}
/>
<Button size="sm" onClick={apply}>
{t.apply}
</Button>
{initial && (
<Button size="sm" variant="ghost" onClick={() => onApply(null)}>
{t.unlink}
</Button>
)}
<IconButton icon="x" size="sm" label={t.cancel} onClick={onClose} />
{bad && (
<p id={`${id}-e`} className="w-full text-[12.5px] text-destructive">
{t.badUrl}
</p>
)}
</div>
);
}
/** Shortcuts the browser does not bring: strike, code, link, headings */
function shortcuts(e: React.KeyboardEvent, ed: Editor, openLink: () => void) {
const mod = e.metaKey || e.ctrlKey;
if (!mod) return;
const k = e.key.toLowerCase();
if (e.shiftKey && k === 'x') ed.exec('strikeThrough');
else if (k === 'e') ed.toggleCode();
else if (k === 'k') openLink();
else if (e.altKey && (k === '2' || e.code === 'Digit2')) ed.block('h2');
else if (e.altKey && (k === '3' || e.code === 'Digit3')) ed.block('h3');
else if (e.altKey && (k === '0' || e.code === 'Digit0')) ed.exec('formatBlock', '<p>');
else return;
e.preventDefault();
}
/** The editable surface, server rendered with its starting HTML */
function Surface({ ed, id, html, label, placeholder, className, onInput, onKeyDown, describedBy, extra }: { ed: Editor; id: string; html: string; label: string; placeholder?: string; className?: string; onInput?: () => void; onKeyDown?: (e: React.KeyboardEvent<HTMLDivElement>) => void; describedBy?: string; extra?: React.HTMLAttributes<HTMLDivElement> }) {
const [empty, setEmpty] = useState(!html.replace(/<[^>]+>/g, '').trim());
const start = useMemo(() => html, []); // eslint-disable-line react-hooks/exhaustive-deps
return (
<div className="relative">
{empty && placeholder && (
<p aria-hidden className={cn('pointer-events-none absolute inset-x-0 top-0 text-subtle-foreground', className?.match(/\bp[xyst]?-[\w.[\]]+/g)?.join(' '))}>
{placeholder}
</p>
)}
<div
ref={ed.ref}
id={id}
role="textbox"
aria-multiline="true"
aria-label={label}
aria-describedby={describedBy}
contentEditable
suppressContentEditableWarning
spellCheck
onInput={() => {
const el = ed.ref.current;
setEmpty(!el?.textContent?.trim() && !el?.querySelector('img,li,hr'));
onInput?.();
}}
onKeyDown={onKeyDown}
onFocus={ed.refresh}
dangerouslySetInnerHTML={{ __html: start }}
className={cn('b5r-doc', className)}
{...extra}
/>
</div>
);
}
function useCount(ed: Editor, wpm: number) {
const [n, setN] = useState(0);
const update = useCallback(() => setN(countWords(ed.ref.current?.innerText ?? '')), [ed.ref]);
useEffect(update, [update]);
return [n, update, Math.max(1, Math.round(n / wpm))] as const;
}
/* ------------------------------------------------------------------ */
/* document */
/* ------------------------------------------------------------------ */
type V = { t: Texts; locale: Locale; props: RichTextEditorProps; framed: boolean };
function Document({ t, locale, props, framed }: V) {
const id = useId();
const [state, setState] = useState<'saved' | 'edited' | 'saving'>('saved');
const timer = useRef(0);
const [linkOpen, setLinkOpen] = useState(false);
const [docTitle, setDocTitle] = useState(props.docTitle ?? '');
const mod = useMod();
const onChange = useCallback(() => {
setState('edited');
window.clearTimeout(timer.current);
timer.current = window.setTimeout(async () => {
setState('saving');
await run(props.onAction, 'save', { title: docTitle, html: ed.ref.current?.innerHTML }, 600);
setState('saved');
}, 900);
}, []); // eslint-disable-line react-hooks/exhaustive-deps
const ed = useEditor(onChange);
const [words, count, minutes] = useCount(ed, props.wpm ?? 200);
useEffect(() => () => window.clearTimeout(timer.current), []);
const openLink = () => setLinkOpen(true);
const tools = buildTools(ed, t, 'full', openLink, mod);
const [rowRef, fade] = useScrollFade<HTMLDivElement>();
const nf = new Intl.NumberFormat(intl(locale));
return (
<Main className="max-w-[56rem]">
<Header title={props.title} subtitle={props.subtitle}>
<span className="inline-flex items-center gap-1.5 text-[12.5px] text-muted-foreground" aria-live="polite">
<Icon name={state === 'saved' ? 'cloud' : state === 'saving' ? 'loader' : 'edit'} size={14} className={cn(state === 'saving' && 'motion-safe:animate-spin')} />
{state === 'saved' ? t.saved : state === 'saving' ? t.saving : t.edited}
</span>
</Header>
<div className="b5r-rise mt-5 rounded-xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)] [--b5r-d:80ms]">
<div className={cn('sticky z-10 rounded-t-xl border-b border-border bg-card/95 backdrop-blur', framed ? 'top-14 md:top-0' : 'top-0')}>
<div ref={rowRef} className={cn('kit-no-scrollbar overflow-x-auto px-2 py-1.5 sm:px-3', fade)}>
<Toolbar tools={tools} label={t.toolbar} controls={id} className="w-max" />
</div>
{linkOpen && (
<LinkField
t={t}
initial={ed.fmt.link ?? ''}
className="border-t border-border px-3 py-2.5"
onApply={(u) => {
setLinkOpen(false);
ed.setLink(u);
}}
onClose={() => (setLinkOpen(false), ed.restore())}
/>
)}
</div>
<div className="px-5 pb-8 pt-6 sm:px-10 sm:pt-8 lg:px-14">
<label htmlFor={`${id}-t`} className="sr-only">
{t.docTitle}
</label>
<input id={`${id}-t`} value={docTitle} onChange={(e) => (setDocTitle(e.target.value), onChange())} placeholder={t.docTitle} className="min-h-11 w-full rounded-md bg-transparent transition-[background-color] focus-visible:bg-[color-mix(in_oklab,var(--foreground)_4%,transparent)] focus-visible:shadow-[inset_0_0_0_1.5px_color-mix(in_oklab,var(--primary)_55%,transparent)] text-[clamp(1.6rem,1.3rem+1.2vw,2.25rem)] font-semibold leading-tight tracking-[-0.035em] text-foreground outline-none placeholder:text-subtle-foreground" />
<Surface
ed={ed}
id={id}
html={props.value ?? ''}
label={docTitle || t.editor}
placeholder={props.placeholder}
className="mt-5 min-h-[22rem] text-[16px] text-foreground/90"
onInput={() => (count(), onChange())}
onKeyDown={(e) => shortcuts(e, ed, openLink)}
/>
</div>
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 rounded-b-xl border-t border-border bg-surface px-5 py-2.5 text-[12.5px] tabular-nums text-muted-foreground sm:px-10 lg:px-14">
<span>{words === 1 ? t.oneWord : fill(t.words, { n: nf.format(words) })}</span>
<span aria-hidden className="size-1 rounded-full bg-border-strong" />
<span>{fill(t.read, { n: minutes })}</span>
</div>
</div>
</Main>
);
}
/* ------------------------------------------------------------------ */
/* comment */
/* ------------------------------------------------------------------ */
function Comment({ t, locale, props }: V) {
const id = useId();
const mod = useMod();
const people = props.people ?? [];
const person = (pid: string) => people.find((p) => p.id === pid) ?? { id: pid, name: pid };
const [list, setList] = useState(props.comments ?? []);
const [fresh, setFresh] = useState('');
const [busy, setBusy] = useState(false);
const [empty, setEmpty] = useState(!(props.value ?? '').trim());
const [linkOpen, setLinkOpen] = useState(false);
const [mention, setMention] = useState<{ q: string; x: number; y: number } | null>(null);
const [act, setAct] = useState(0);
const [live, setLive] = useState('');
const wrap = useRef<HTMLDivElement>(null);
const ed = useEditor();
const now = props.now ?? '2026-10-23T14:30';
const hits = mention ? people.filter((p) => p.id !== props.me && p.name.toLowerCase().includes(mention.q.toLowerCase())).slice(0, 5) : [];
const openLink = () => setLinkOpen(true);
const tools = buildTools(ed, t, 'compact', openLink, mod);
/** Find "@word" right before the caret */
const scan = () => {
const sel = document.getSelection();
const el = ed.ref.current;
setEmpty(!el?.textContent?.trim());
if (!sel || !sel.rangeCount || !el || !sel.isCollapsed) return setMention(null);
const node = sel.anchorNode;
if (!node || node.nodeType !== 3) return setMention(null);
const before = (node.textContent ?? '').slice(0, sel.anchorOffset);
const m = before.match(/(?:^|\s)@([\p{L}]{0,20})$/u);
if (!m) return setMention(null);
const r = sel.getRangeAt(0).getBoundingClientRect();
const b = wrap.current!.getBoundingClientRect();
setMention({ q: m[1], x: round(Math.max(0, Math.min(r.left - b.left, b.width - 240))), y: round(r.bottom - b.top + 6) });
setAct(0);
};
const insert = (p: EditorPerson) => {
const sel = document.getSelection();
if (!sel || !sel.rangeCount || !mention) return;
const node = sel.anchorNode!;
const r = document.createRange();
r.setStart(node, sel.anchorOffset - mention.q.length - 1);
r.setEnd(node, sel.anchorOffset);
sel.removeAllRanges();
sel.addRange(r);
document.execCommand('insertHTML', false, `<span class="b5r-mention" contenteditable="false" data-person="${esc(p.id)}">@${esc(p.name)}</span> `);
setMention(null);
};
const post = async () => {
const el = ed.ref.current;
if (!el || !el.textContent?.trim()) return;
const html = el.innerHTML;
setBusy(true);
await run(props.onAction, 'post', { html }, 600);
const c: EditorComment = { id: `c${Date.now()}`, author: props.me ?? '', time: now, html };
setList((l) => [...l, c]);
setFresh(c.id);
el.innerHTML = '';
setEmpty(true);
setBusy(false);
setLive(t.posted);
el.focus();
};
const me = person(props.me ?? '');
return (
<Main className="max-w-[46rem]">
<Header title={props.title} subtitle={props.subtitle} />
<section aria-label={t.comments} className="mt-6">
<ol className="grid gap-5">
{list.map((c, i) => {
const p = person(c.author);
return (
<li key={c.id} className="b5r-rise flex gap-3" style={{ '--b5r-d': `${c.id === fresh ? 0 : 80 + i * 70}ms` } as React.CSSProperties}>
<Avatar name={p.name} size="sm" alt="" />
<article className="min-w-0 flex-1">
<header className="flex flex-wrap items-baseline gap-x-2">
<span className="text-[14px] font-semibold text-foreground">{c.author === props.me ? t.you : p.name}</span>
{p.role && <span className="text-[12.5px] text-subtle-foreground">{p.role}</span>}
<time dateTime={c.time} className="text-[12.5px] tabular-nums text-subtle-foreground">
{ago(c.time, now, locale)}
</time>
</header>
<div className={cn('b5r-doc mt-1.5 rounded-xl rounded-tl-md border px-4 py-3 text-[14.5px] text-foreground/90', c.id === fresh ? 'border-[color-mix(in_oklab,var(--primary)_40%,var(--border))] bg-[color-mix(in_oklab,var(--primary)_4%,var(--card))]' : 'border-border bg-card')} dangerouslySetInnerHTML={{ __html: c.html }} />
</article>
</li>
);
})}
</ol>
<div className="b5r-rise mt-6 flex gap-3 [--b5r-d:260ms]">
<Avatar name={me.name} size="sm" alt="" className="max-sm:hidden" />
<div ref={wrap} className="relative min-w-0 flex-1 rounded-xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)] transition-[border-color,box-shadow] duration-300 focus-within:border-[color-mix(in_oklab,var(--primary)_50%,var(--border-strong))] focus-within:shadow-[var(--highlight),0_0_0_3px_color-mix(in_oklab,var(--primary)_14%,transparent)]">
<Surface
ed={ed}
id={id}
html={props.value ?? ''}
label={props.placeholder ?? t.post}
placeholder={props.placeholder}
describedBy={`${id}-h`}
className="max-h-64 min-h-24 overflow-y-auto px-4 py-3 text-[14.5px] text-foreground"
onInput={scan}
extra={{
'aria-autocomplete': 'list',
'aria-controls': mention ? `${id}-m` : undefined,
'aria-activedescendant': mention && hits[act] ? `${id}-m-${hits[act].id}` : undefined,
onKeyUp: (e) => (e.key === 'ArrowLeft' || e.key === 'ArrowRight' || e.key === 'Home' || e.key === 'End') && scan(),
onClick: scan,
} as React.HTMLAttributes<HTMLDivElement>}
onKeyDown={(e) => {
if (mention && hits.length) {
if (e.key === 'ArrowDown') return e.preventDefault(), setAct((a) => (a + 1) % hits.length);
if (e.key === 'ArrowUp') return e.preventDefault(), setAct((a) => (a - 1 + hits.length) % hits.length);
if (e.key === 'Enter' || e.key === 'Tab') return e.preventDefault(), insert(hits[act]);
if (e.key === 'Escape') return e.preventDefault(), setMention(null);
}
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) return e.preventDefault(), void post();
shortcuts(e, ed, openLink);
}}
/>
{mention && (
<ul id={`${id}-m`} role="listbox" aria-label={t.people} className="b5r-pop absolute z-20 w-60 overflow-hidden rounded-lg border border-border bg-popover p-1 shadow-[var(--highlight),var(--shadow-lg)]" style={{ left: mention.x, top: mention.y }}>
{hits.length ? (
hits.map((p, i) => (
<li
key={p.id}
id={`${id}-m-${p.id}`}
role="option"
aria-selected={i === act}
onMouseDown={(e) => (e.preventDefault(), insert(p))}
onMouseEnter={() => setAct(i)}
className={cn('flex min-h-11 cursor-pointer items-center gap-2.5 rounded-md px-2 py-1.5 sm:min-h-9', i === act ? 'bg-accent' : '')}
>
<Avatar name={p.name} size="xs" alt="" />
<span className="min-w-0">
<span className="block truncate text-[13px] font-medium text-foreground">{p.name}</span>
{p.role && <span className="block truncate text-[11.5px] text-muted-foreground">{p.role}</span>}
</span>
</li>
))
) : (
<li className="px-2 py-2 text-[12.5px] text-muted-foreground">{t.noPeople}</li>
)}
</ul>
)}
{linkOpen && (
<LinkField
t={t}
initial={ed.fmt.link ?? ''}
className="border-t border-border px-3 py-2.5"
onApply={(u) => {
setLinkOpen(false);
ed.setLink(u);
}}
onClose={() => (setLinkOpen(false), ed.restore())}
/>
)}
<div className="flex items-center gap-2 border-t border-border px-1.5 py-1.5">
<div className="kit-no-scrollbar min-w-0 flex-1 overflow-x-auto">
<Toolbar tools={[...tools, { sep: true, id: 'sm' }, { id: 'at', label: t.mention, icon: 'at-sign', onRun: () => ed.exec('insertText', '@') }]} label={t.toolbar} controls={id} size="sm" className="w-max" />
</div>
<Button size="sm" iconLeft={busy ? 'loader' : 'send'} disabled={empty || busy} onClick={post} aria-keyshortcuts="Control+Enter Meta+Enter" className="shrink-0">
{busy ? t.posting : t.post}
</Button>
</div>
</div>
</div>
<p id={`${id}-h`} className="mt-2 text-[12px] text-subtle-foreground sm:ps-11">
{t.commentHint}
</p>
</section>
<Live text={live} />
</Main>
);
}
/* ------------------------------------------------------------------ */
/* markdown */
/* ------------------------------------------------------------------ */
/** A small Markdown reader: everything is escaped first, links only to safe addresses */
function inline(s: string) {
return s
.replace(/`([^`]+)`/g, (_, c) => `<code>${c}</code>`)
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
.replace(/~~([^~]+)~~/g, '<del>$1</del>')
.replace(/(^|[^\w*])[_*]([^_*]+)[_*](?![\w*])/g, '$1<em>$2</em>')
.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_, text, url) => (SAFE_URL.test(url.replace(/&/g, '&')) ? `<a href="${url}">${text}</a>` : text));
}
function markdown(src: string) {
const lines = esc(src).split('\n');
const out: string[] = [];
let i = 0;
while (i < lines.length) {
const l = lines[i];
if (/^```/.test(l)) {
const body: string[] = [];
i++;
while (i < lines.length && !/^```/.test(lines[i])) body.push(lines[i++]);
i++;
out.push(`<pre><code>${body.join('\n')}</code></pre>`);
continue;
}
const h = l.match(/^(#{1,3})\s+(.*)$/);
if (h) {
out.push(`<h${h[1].length}>${inline(h[2])}</h${h[1].length}>`);
i++;
continue;
}
if (/^>\s?/.test(l)) {
const body: string[] = [];
while (i < lines.length && /^>\s?/.test(lines[i])) body.push(lines[i++].replace(/^>\s?/, ''));
out.push(`<blockquote><p>${inline(body.join(' '))}</p></blockquote>`);
continue;
}
if (/^\s*[-*]\s+/.test(l) || /^\s*\d+\.\s+/.test(l)) {
const ordered = /^\s*\d+\./.test(l);
const re = ordered ? /^\s*\d+\.\s+/ : /^\s*[-*]\s+/;
const items: string[] = [];
while (i < lines.length && re.test(lines[i])) items.push(`<li>${inline(lines[i++].replace(re, ''))}</li>`);
out.push(ordered ? `<ol>${items.join('')}</ol>` : `<ul>${items.join('')}</ul>`);
continue;
}
if (!l.trim()) {
i++;
continue;
}
const para: string[] = [];
while (i < lines.length && lines[i].trim() && !/^(#{1,3}\s|>|```|\s*[-*]\s|\s*\d+\.\s)/.test(lines[i])) para.push(lines[i++]);
out.push(`<p>${inline(para.join(' '))}</p>`);
}
return out.join('');
}
function Markdown({ t, locale, props }: V) {
const id = useId();
const mod = useMod();
const [text, setText] = useState(props.value ?? '');
const [tab, setTab] = useState<'write' | 'preview'>('write');
const [live, setLive] = useState('');
const area = useRef<HTMLTextAreaElement>(null);
const html = useMemo(() => markdown(text), [text]);
const nf = new Intl.NumberFormat(intl(locale));
/** Wrap the selection, or put a mark in front of each selected line */
const apply = (kind: 'wrap' | 'line', a: string, b = a, sample = '') => {
const el = area.current;
if (!el) return;
const s = el.selectionStart;
const e = el.selectionEnd;
el.focus();
if (kind === 'wrap') {
const sel = text.slice(s, e) || sample;
const has = text.slice(s - a.length, s) === a && text.slice(e, e + b.length) === b;
if (has) {
el.setSelectionRange(s - a.length, e + b.length);
document.execCommand('insertText', false, sel);
el.setSelectionRange(s - a.length, s - a.length + sel.length);
} else {
el.setSelectionRange(s, e);
document.execCommand('insertText', false, a + sel + b);
el.setSelectionRange(s + a.length, s + a.length + sel.length);
}
} else {
const from = text.lastIndexOf('\n', s - 1) + 1;
const to = e;
const block = text.slice(from, to);
const lines = block.split('\n');
const all = lines.every((x) => x.startsWith(a));
const next = lines.map((x, k) => (all ? x.slice(a.length) : (a === '1. ' ? `${k + 1}. ` : a) + x.replace(/^(#{1,3} |> |> |- |\d+\. )/, ''))).join('\n');
el.setSelectionRange(from, to);
document.execCommand('insertText', false, next);
el.setSelectionRange(from, from + next.length);
}
setText(el.value);
};
const tools: Tool[] = [
{ id: 'h2', label: t.h2, text: 'H2', onRun: () => apply('line', '## ') },
{ id: 'bold', label: t.bold, icon: 'bold', onRun: () => apply('wrap', '**', '**', t.bold), keys: `${mod} B` },
{ id: 'italic', label: t.italic, icon: 'italic', onRun: () => apply('wrap', '_', '_', t.italic), keys: `${mod} I` },
{ id: 'strike', label: t.strike, icon: 'strikethrough', onRun: () => apply('wrap', '~~', '~~', t.strike) },
{ sep: true, id: 's1' },
{ id: 'ul', label: t.ul, icon: 'list', onRun: () => apply('line', '- ') },
{ id: 'ol', label: t.ol, icon: 'list-ordered', onRun: () => apply('line', '1. ') },
{ id: 'quote', label: t.quote, icon: 'quote', onRun: () => apply('line', '> ') },
{ sep: true, id: 's2' },
{ id: 'code', label: t.code, icon: 'code', onRun: () => apply('wrap', '`', '`', 'code') },
{ id: 'pre', label: t.codeBlock, icon: 'terminal', onRun: () => apply('wrap', '```\n', '\n```', 'code') },
{ id: 'link', label: t.link, icon: 'link', onRun: () => apply('wrap', '[', '](https://)', t.link), keys: `${mod} K` },
];
const tabsRef = useRef<HTMLDivElement>(null);
const tabBtn = (v: 'write' | 'preview', label: string) => (
<button
key={v}
type="button"
role="tab"
id={`${id}-tab-${v}`}
aria-selected={tab === v}
aria-controls={`${id}-${v}`}
tabIndex={tab === v ? 0 : -1}
onClick={() => setTab(v)}
onKeyDown={(e) => {
if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return;
e.preventDefault();
const n = tab === 'write' ? 'preview' : 'write';
setTab(n);
document.getElementById(`${id}-tab-${n}`)?.focus();
}}
className={cn('relative h-11 flex-1 px-4 text-[13.5px] font-medium transition-colors', tab === v ? 'text-foreground' : 'text-muted-foreground hover:text-foreground', ringIn)}
>
{label}
{tab === v && <span aria-hidden className="absolute inset-x-3 -bottom-px h-0.5 rounded-full bg-primary" />}
</button>
);
return (
<Main>
<Header title={props.title} subtitle={props.subtitle}>
<Button
size="sm"
variant="outline"
iconLeft="copy"
onClick={() => {
void navigator.clipboard?.writeText(text).catch(() => undefined);
setLive(t.copied);
}}
>
{t.copyMd}
</Button>
</Header>
<div className="b5r-rise mt-5 overflow-hidden rounded-xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)] [--b5r-d:80ms]">
<div ref={tabsRef} role="tablist" aria-label={t.editor} className="flex border-b border-border lg:hidden">
{tabBtn('write', t.write)}
{tabBtn('preview', t.preview)}
</div>
<div className="grid lg:grid-cols-2">
<div id={`${id}-write`} role="tabpanel" aria-labelledby={`${id}-tab-write`} className={cn('flex min-w-0 flex-col lg:border-e lg:border-border', tab !== 'write' && 'max-lg:hidden')}>
<div className="flex items-center justify-between gap-2 border-b border-border px-2 py-1.5">
<div className="kit-no-scrollbar min-w-0 overflow-x-auto">
<Toolbar tools={tools} label={t.toolbar} controls={`${id}-ta`} className="w-max" />
</div>
<span className="hidden shrink-0 pe-2 text-[12px] font-medium text-subtle-foreground lg:block">{t.write}</span>
</div>
<label htmlFor={`${id}-ta`} className="sr-only">
{t.write}
</label>
<textarea
ref={area}
id={`${id}-ta`}
value={text}
spellCheck
aria-describedby={`${id}-h`}
onChange={(e) => setText(e.target.value)}
onKeyDown={(e) => {
const mk = e.metaKey || e.ctrlKey;
if (!mk) return;
const k = e.key.toLowerCase();
if (k === 'b') apply('wrap', '**', '**', t.bold);
else if (k === 'i') apply('wrap', '_', '_', t.italic);
else if (k === 'k') apply('wrap', '[', '](https://)', t.link);
else return;
e.preventDefault();
}}
className="min-h-[26rem] w-full flex-1 resize-none bg-transparent px-5 py-4 text-[14.5px] leading-[1.75] text-foreground outline-none placeholder:text-subtle-foreground focus-visible:shadow-[inset_0_0_0_1.5px_color-mix(in_oklab,var(--primary)_45%,transparent)] lg:min-h-[32rem]"
placeholder={props.placeholder}
/>
</div>
<div id={`${id}-preview`} role="tabpanel" aria-labelledby={`${id}-tab-preview`} aria-label={t.preview} className={cn('flex min-w-0 flex-col bg-[color-mix(in_oklab,var(--surface)_60%,var(--card))]', tab !== 'preview' && 'max-lg:hidden')}>
<div className="hidden h-[3.25rem] items-center border-b border-border px-5 lg:flex">
<span className="flex items-center gap-1.5 text-[12px] font-medium text-subtle-foreground">
<Icon name="eye" size={14} />
{t.preview}
</span>
</div>
{text.trim() ? <div className="b5r-doc min-h-[26rem] flex-1 px-5 py-4 text-[15px] text-foreground/90 sm:px-7" dangerouslySetInnerHTML={{ __html: html }} /> : <p className="min-h-[26rem] px-5 py-4 text-[14px] text-subtle-foreground">{t.previewEmpty}</p>}
</div>
</div>
<div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-1 border-t border-border bg-surface px-5 py-2.5 text-[12.5px] text-muted-foreground">
<span id={`${id}-h`}>{t.mdHint}</span>
<span className="tabular-nums">
{fill(t.words, { n: nf.format(countWords(text)) })} · {fill(t.lines, { n: nf.format(text.split('\n').length) })}
</span>
</div>
</div>
<Live text={live} />
</Main>
);
}
/* ------------------------------------------------------------------ */
/* bubble */
/* ------------------------------------------------------------------ */
function Bubble({ t, locale, props }: V) {
const id = useId();
const mod = useMod();
const wrap = useRef<HTMLDivElement>(null);
const bar = useRef<HTMLDivElement>(null);
const [pos, setPos] = useState<{ x: number; y: number; below: boolean } | null>(null);
const [linkOpen, setLinkOpen] = useState(false);
const [focusBar, setFocusBar] = useState(0);
const ed = useEditor();
const [words, count, minutes] = useCount(ed, props.wpm ?? 200);
const nf = new Intl.NumberFormat(intl(locale));
const place = useCallback(() => {
const sel = document.getSelection();
const el = ed.ref.current;
const w = wrap.current;
if (!sel || !sel.rangeCount || !el || !w || sel.isCollapsed || !el.contains(sel.anchorNode)) {
if (!linkOpen && !bar.current?.contains(document.activeElement)) setPos(null);
return;
}
const r = sel.getRangeAt(0).getBoundingClientRect();
const b = w.getBoundingClientRect();
const bw = bar.current?.offsetWidth ?? 300;
const x = Math.max(4, Math.min(b.width - bw - 4, r.left + r.width / 2 - b.left - bw / 2));
const below = r.top - b.top < 56;
setPos({ x: round(x), y: round(below ? r.bottom - b.top + 10 : r.top - b.top - 50), below });
}, [ed.ref, linkOpen]);
useEffect(() => {
document.addEventListener('selectionchange', place);
window.addEventListener('resize', place);
return () => {
document.removeEventListener('selectionchange', place);
window.removeEventListener('resize', place);
};
}, [place]);
useLayoutEffect(() => {
if (pos) place();
}, [linkOpen]); // eslint-disable-line react-hooks/exhaustive-deps
const openLink = () => {
setLinkOpen(true);
place();
};
const tools = buildTools(ed, t, 'bubble', openLink, mod);
const shown = !!pos;
return (
<Main className="max-w-[52rem]">
<Header title={props.title} subtitle={props.subtitle} />
<article className="b5r-rise mt-6 [--b5r-d:80ms]">
<h2 className="text-[clamp(1.7rem,1.3rem+1.4vw,2.4rem)] font-semibold leading-tight tracking-[-0.035em] text-foreground">{props.docTitle}</h2>
<p className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-[13px] tabular-nums text-muted-foreground">
<span>{words === 1 ? t.oneWord : fill(t.words, { n: nf.format(words) })}</span>
<span aria-hidden className="size-1 rounded-full bg-border-strong" />
<span>{fill(t.read, { n: minutes })}</span>
</p>
<p id={`${id}-h`} className="mt-5 flex items-start gap-2 rounded-lg border border-dashed border-border px-3 py-2 text-[12.5px] text-muted-foreground">
<Icon name="sparkle" size={14} className="mt-0.5 shrink-0 text-[color-mix(in_oklab,var(--primary)_80%,var(--foreground))]" />
{t.bubbleHint}
</p>
<div ref={wrap} className="relative mt-6">
<Surface
ed={ed}
id={id}
html={props.value ?? ''}
label={props.docTitle ?? t.editor}
placeholder={props.placeholder}
describedBy={`${id}-h`}
className="min-h-[20rem] text-[17px] leading-[1.75] text-foreground/90"
onInput={count}
onKeyDown={(e) => {
if (e.key === 'F10' && e.altKey && pos) {
e.preventDefault();
setFocusBar((n) => n + 1);
} else if (e.key === 'Escape') setPos(null);
shortcuts(e, ed, openLink);
}}
/>
<div
ref={bar}
aria-hidden={!shown}
className={cn(
'absolute z-20 rounded-xl border border-border bg-popover p-1 shadow-[var(--highlight),var(--shadow-lg)] transition-[opacity,visibility,translate] duration-200 ease-[var(--ease-kit)] motion-reduce:transition-none',
shown ? 'visible translate-y-0 opacity-100' : 'invisible translate-y-1 opacity-0',
linkOpen ? 'w-[min(26rem,calc(100%-0.5rem))]' : 'w-max max-w-[calc(100%-0.5rem)]',
)}
style={{ left: pos?.x ?? 0, top: pos?.y ?? 0 }}
onKeyDown={(e) => {
if (e.key === 'Escape') {
e.preventDefault();
setLinkOpen(false);
ed.restore();
}
}}
>
{linkOpen ? (
<LinkField
t={t}
initial={ed.fmt.link ?? ''}
className="p-1"
onApply={(u) => {
setLinkOpen(false);
ed.setLink(u);
}}
onClose={() => (setLinkOpen(false), ed.restore())}
/>
) : (
<Toolbar key={focusBar} autoFocus={focusBar > 0} tools={tools} label={t.toolbar} controls={id} size="sm" onEscape={() => ed.restore()} className="kit-no-scrollbar max-w-full overflow-x-auto" />
)}
<span aria-hidden className={cn('absolute left-1/2 size-2.5 -translate-x-1/2 rotate-45 border-border bg-popover', pos?.below ? '-top-[6px] border-l border-t' : '-bottom-[6px] border-b border-r')} />
</div>
</div>
</article>
</Main>
);
}
/* ------------------------------------------------------------------ */
/* email */
/* ------------------------------------------------------------------ */
type Rcpt = { value: string; bad?: boolean };
function Recipients({ label, list, setList, t, id, invalid, autoFocus }: { label: string; list: Rcpt[]; setList: (fn: (l: Rcpt[]) => Rcpt[]) => void; t: Texts; id: string; invalid?: boolean; autoFocus?: boolean }) {
const [text, setText] = useState('');
const input = useRef<HTMLInputElement>(null);
useEffect(() => {
if (autoFocus) input.current?.focus();
}, [autoFocus]);
const add = (raw: string) => {
const parts = raw.split(/[\s,;]+/).map((s) => s.trim()).filter(Boolean);
if (!parts.length) return;
setList((l) => [...l, ...parts.filter((p) => !l.some((x) => x.value.toLowerCase() === p.toLowerCase())).map((p) => ({ value: p, bad: !EMAIL.test(p) }))]);
setText('');
};
return (
<div className="flex min-h-12 items-center gap-2 border-b border-border px-4" onClick={() => input.current?.focus()}>
<label htmlFor={id} className="w-14 shrink-0 text-[13px] text-muted-foreground">
{label}
</label>
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-1.5 py-1.5">
{list.map((r, i) => (
<span key={r.value} className={cn('b5r-pop inline-flex h-8 max-w-full items-center gap-1 rounded-md border ps-2 pe-0.5 text-[13px]', r.bad ? 'border-[color-mix(in_oklab,var(--destructive)_45%,var(--border))] bg-[color-mix(in_oklab,var(--destructive)_8%,var(--card))] text-foreground' : 'border-border bg-surface text-foreground')}>
{r.bad && <Icon name="alert-circle" size={13} className="shrink-0 text-destructive" label={t.badEmail} />}
<span className="truncate">{r.value}</span>
<IconButton icon="x" size="xs" label={fill(t.removeRecipient, { name: r.value })} onClick={(e) => (e.stopPropagation(), setList((l) => l.filter((_, k) => k !== i)), input.current?.focus())} className="size-7" />
</span>
))}
<input
ref={input}
id={id}
type="email"
multiple
value={text}
aria-invalid={invalid || list.some((r) => r.bad) || undefined}
onChange={(e) => (/[\s,;]$/.test(e.target.value) ? add(e.target.value) : setText(e.target.value))}
onKeyDown={(e) => {
if ((e.key === 'Enter' || e.key === 'Tab') && text.trim()) {
e.preventDefault();
add(text);
} else if (e.key === 'Backspace' && !text && list.length) setList((l) => l.slice(0, -1));
}}
onBlur={() => text.trim() && add(text)}
className="h-11 min-w-[8rem] flex-1 bg-transparent text-base text-foreground outline-none sm:h-9 sm:text-[14px]"
/>
</div>
</div>
);
}
function Email({ t, locale, props, framed }: V) {
const id = useId();
const mod = useMod();
const sigs = props.signatures ?? [];
const [from, setFrom] = useState(props.from?.[0] ?? '');
const [to, setTo] = useState<Rcpt[]>((props.to ?? []).map((v) => ({ value: v, bad: !EMAIL.test(v) })));
const [cc, setCc] = useState<Rcpt[]>([]);
const [showCc, setShowCc] = useState(false);
const [subject, setSubject] = useState(props.subject ?? '');
const [sig, setSig] = useState(sigs[0]?.id ?? '');
const [files, setFiles] = useState<{ name: string; size: number }[]>([]);
const [linkOpen, setLinkOpen] = useState(false);
const [phase, setPhase] = useState<'edit' | 'countdown' | 'sending' | 'sent'>('edit');
const [left, setLeft] = useState(5);
const [error, setError] = useState('');
const [live, setLive] = useState('');
const [mount, setMount] = useState(0);
const picker = useRef<HTMLInputElement>(null);
const draft = useRef<string>(props.value ?? '');
const ed = useEditor();
const openLink = () => setLinkOpen(true);
const tools = buildTools(ed, t, 'mail', openLink, mod);
const signature = sigs.find((s) => s.id === sig);
useEffect(() => {
if (phase !== 'countdown') return;
if (left <= 0) {
setPhase('sending');
void (async () => {
await run(props.onAction, 'send', { from, to: to.map((r) => r.value), cc: cc.map((r) => r.value), subject, html: draft.current, signature: sig, files: files.map((f) => f.name) }, 700);
setPhase('sent');
setLive(fill(t.sent, { to: to.map((r) => r.value).join(', ') }));
})();
return;
}
const timer = window.setTimeout(() => setLeft((n) => n - 1), 1000);
return () => window.clearTimeout(timer);
}, [phase, left]); // eslint-disable-line react-hooks/exhaustive-deps
const send = () => {
if (!to.length || to.some((r) => r.bad) || cc.some((r) => r.bad)) {
setError(!to.length ? t.needTo : t.badEmail);
document.getElementById(`${id}-to`)?.focus();
return;
}
setError('');
draft.current = ed.ref.current?.innerHTML ?? '';
setLeft(5);
setPhase('countdown');
};
const nf = new Intl.NumberFormat(intl(locale));
if (phase === 'sent')
return (
<Main className="max-w-[48rem]">
<Header title={props.title} subtitle={props.subtitle} />
<div className="b5r-rise mt-6 grid place-items-center rounded-xl border border-border bg-card px-6 py-16 text-center shadow-[var(--highlight),var(--shadow-sm)]">
<span className="grid size-12 place-items-center rounded-full bg-[color-mix(in_oklab,var(--success)_15%,var(--card))] text-success-foreground">
<Icon name="check" size={22} strokeWidth={2.2} />
</span>
<p className="mt-4 max-w-sm text-pretty text-[15px] font-medium text-foreground">{fill(t.sent, { to: to.map((r) => r.value).join(', ') })}</p>
<Button
className="mt-6"
variant="outline"
iconLeft="edit"
onClick={() => {
setPhase('edit');
setTo([]);
setCc([]);
setSubject('');
setFiles([]);
draft.current = '';
setMount((m) => m + 1);
}}
>
{t.newMail}
</Button>
</div>
<Live text={live} />
</Main>
);
const locked = phase !== 'edit';
return (
<Main className="max-w-[48rem]">
<Header title={props.title} subtitle={props.subtitle} />
<div className={cn('b5r-rise mt-5 rounded-xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)] [--b5r-d:80ms]', locked && 'pointer-events-none opacity-70 transition-opacity')} inert={locked}>
{props.from && props.from.length > 1 ? (
<div className="flex min-h-12 items-center gap-2 border-b border-border px-4">
<label htmlFor={`${id}-from`} className="w-14 shrink-0 text-[13px] text-muted-foreground">
{t.from}
</label>
<span className="relative min-w-0 flex-1">
<select id={`${id}-from`} value={from} onChange={(e) => setFrom(e.target.value)} className={cn('h-11 w-full cursor-pointer appearance-none truncate rounded-md bg-transparent pe-7 text-base text-foreground sm:h-9 sm:text-[14px]', ringIn)}>
{props.from.map((f) => (
<option key={f}>{f}</option>
))}
</select>
<Icon name="chevrons-up-down" size={14} className="pointer-events-none absolute end-1 top-1/2 -translate-y-1/2 text-subtle-foreground" />
</span>
</div>
) : null}
<div className="relative">
<Recipients label={t.to} list={to} setList={setTo} t={t} id={`${id}-to`} invalid={!!error} />
{!showCc && (
<Button size="sm" variant="ghost" className="absolute end-2 top-1.5 max-sm:top-0.5" onClick={() => setShowCc(true)}>
{t.cc}
<span className="sr-only">{t.addCc}</span>
</Button>
)}
</div>
{showCc && <Recipients label={t.cc} list={cc} setList={setCc} t={t} id={`${id}-cc`} autoFocus />}
{error && (
<p role="alert" className="flex items-center gap-1.5 border-b border-border bg-[color-mix(in_oklab,var(--destructive)_6%,var(--card))] px-4 py-2 text-[13px] text-destructive">
<Icon name="alert-circle" size={14} />
{error}
</p>
)}
<div className="flex min-h-12 items-center gap-2 border-b border-border px-4">
<label htmlFor={`${id}-s`} className="w-14 shrink-0 text-[13px] text-muted-foreground">
{t.subject}
</label>
<input id={`${id}-s`} value={subject} onChange={(e) => setSubject(e.target.value)} className="h-11 min-w-0 flex-1 bg-transparent text-base font-medium text-foreground outline-none sm:text-[14px]" />
</div>
<div className="kit-no-scrollbar overflow-x-auto border-b border-border px-2 py-1">
<Toolbar tools={tools} label={t.toolbar} controls={id} size="sm" className="w-max" />
</div>
{linkOpen && (
<LinkField
t={t}
initial={ed.fmt.link ?? ''}
className="border-b border-border px-3 py-2.5"
onApply={(u) => {
setLinkOpen(false);
ed.setLink(u);
}}
onClose={() => (setLinkOpen(false), ed.restore())}
/>
)}
<div className="px-4 py-4 sm:px-5">
<Surface key={mount} ed={ed} id={id} html={mount ? '' : props.value ?? ''} label={subject || t.editor} placeholder={props.placeholder} className="min-h-[14rem] text-[15px] text-foreground/90" onKeyDown={(e) => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) return e.preventDefault(), send();
shortcuts(e, ed, openLink);
}} />
{signature && signature.lines.length > 0 && (
<div key={sig} className="b5r-pop mt-6 border-t border-dashed border-border pt-3 text-[13.5px] leading-relaxed text-muted-foreground" aria-label={t.signature}>
{signature.lines.map((l, i) => (
<p key={i} className={i === 0 ? 'font-medium text-foreground' : ''}>
{l}
</p>
))}
</div>
)}
{files.length > 0 && (
<ul className="mt-4 flex flex-wrap gap-1.5" aria-label={t.attach}>
{files.map((f, i) => (
<li key={f.name + i} className="b5r-pop inline-flex h-9 max-w-full items-center gap-2 rounded-lg border border-border bg-surface ps-2.5 pe-0.5 text-[13px] text-foreground">
<Icon name="paperclip" size={14} className="shrink-0 text-muted-foreground" />
<span className="max-w-[14rem] truncate">{f.name}</span>
<span className="text-[12px] tabular-nums text-subtle-foreground">{new Intl.NumberFormat(intl(locale), { style: 'unit', unit: 'kilobyte', maximumFractionDigits: 0 }).format(Math.max(1, f.size / 1024))}</span>
<IconButton icon="x" size="xs" label={fill(t.removeFile, { name: f.name })} onClick={() => setFiles((l) => l.filter((_, k) => k !== i))} className="size-7" />
</li>
))}
</ul>
)}
</div>
<div className="flex flex-wrap items-center gap-2 rounded-b-xl border-t border-border bg-surface px-3 py-2.5 sm:px-4">
<Button iconLeft="send" onClick={send} aria-keyshortcuts="Control+Enter Meta+Enter" className="max-sm:flex-1">
{t.send}
</Button>
<input
ref={picker}
type="file"
multiple
tabIndex={-1}
aria-hidden
className="sr-only"
onChange={(e) => {
setFiles((l) => [...l, ...[...(e.target.files ?? [])].map((f) => ({ name: f.name, size: f.size }))]);
e.target.value = '';
}}
/>
<IconButton icon="paperclip" variant="ghost" label={t.attach} onClick={() => picker.current?.click()} className="sm:size-9" />
{sigs.length > 0 && (
<label className="relative ms-auto block min-w-0 max-sm:order-last max-sm:w-full">
<span className="sr-only">{t.signature}</span>
<select value={sig} onChange={(e) => setSig(e.target.value)} className={cn(field, 'cursor-pointer appearance-none pe-8 sm:w-44')}>
<option value="">{t.noSignature}</option>
{sigs.map((s) => (
<option key={s.id} value={s.id}>
{s.label}
</option>
))}
</select>
<Icon name="chevrons-up-down" size={14} className="pointer-events-none absolute end-2.5 top-1/2 -translate-y-1/2 text-subtle-foreground" />
</label>
)}
<IconButton
icon="trash"
variant="ghost"
label={t.discard}
onClick={() => {
setSubject('');
setFiles([]);
if (ed.ref.current) ed.ref.current.innerHTML = '';
setLive(t.discarded);
}}
className="hover:text-destructive sm:size-9"
/>
</div>
</div>
{/* Undo send: a short window before the email leaves */}
{locked && (
<div className={cn('b5r-pop fixed inset-x-3 bottom-3 z-40 mx-auto flex max-w-md items-center gap-3 rounded-xl border border-border bg-popover py-2 pe-2 ps-4 text-[13.5px] text-foreground shadow-[var(--highlight),var(--shadow-lg)] sm:bottom-6', framed && 'md:start-[4.25rem] lg:start-64')} role="status">
{phase === 'countdown' ? (
<>
<span className="relative grid size-7 shrink-0 place-items-center">
<svg viewBox="0 0 28 28" className="absolute inset-0 -rotate-90" aria-hidden>
<circle cx="14" cy="14" r="12" fill="none" strokeWidth="2.5" className="stroke-track" />
<circle cx="14" cy="14" r="12" fill="none" strokeWidth="2.5" strokeLinecap="round" className="stroke-primary transition-[stroke-dashoffset] duration-1000 ease-linear" strokeDasharray={round(2 * Math.PI * 12)} strokeDashoffset={round(2 * Math.PI * 12 * (1 - left / 5))} />
</svg>
<span className="relative text-[11px] font-semibold tabular-nums">{nf.format(left)}</span>
</span>
<span className="min-w-0 flex-1">{fill(t.sentIn, { n: left })}</span>
<Button
size="sm"
variant="outline"
autoFocus
onClick={() => {
setPhase('edit');
setLive(t.unsent);
ed.restore();
}}
>
{t.undoSend}
</Button>
</>
) : (
<>
<Icon name="loader" size={16} className="shrink-0 motion-safe:animate-spin" />
<span>{t.sending}</span>
</>
)}
</div>
)}
<Live text={live} />
</Main>
);
}
/* ------------------------------------------------------------------ */
/* RichTextEditor */
/* ------------------------------------------------------------------ */
/**
* Text editors without a package: a document with the full toolbar, word count
* and reading time, a comment box with @ mentions, Markdown with a live
* preview, a quiet page with a toolbar over the selection and an email composer.
* Toolbars follow the APG pattern, shortcuts work, the text is readable without JavaScript.
* <RichTextEditor variant="document" docTitle="Brief" value="<p>…</p>" />
*/
export function RichTextEditor({ variant = 'document', sidebar, labels, className, ...props }: RichTextEditorProps) {
const t = useTexts(TEXTS, labels);
const locale = useLocale();
const v: V = { t, locale, props, framed: !!sidebar };
const body = (() => {
switch (variant) {
case 'comment':
return <Comment {...v} />;
case 'markdown':
return <Markdown {...v} />;
case 'bubble':
return <Bubble {...v} />;
case 'email':
return <Email {...v} />;
default:
return <Document {...v} />;
}
})();
if (!sidebar)
return (
<div className={cn('min-h-dvh bg-background', className)}>
<Style />
{body}
</div>
);
return (
<Sidebar mobile="drawer" {...sidebar} className={className}>
<Style />
{body}
</Sidebar>
);
}Frequent questions
How do I install Rich text editor?
With aiw add @ai-website/rich-text-editor. The command puts components/ui/rich-text-editor.tsx into your project, the code is yours from then on. It brings along avatar, button, icon, icon-button, motion and sidebar. It needs no further npm packages.
Which variants does Rich text editor have?
5 variants, Document, Comment with mentions, Markdown with preview, Selection toolbar and Email composer. You pick one with the variant prop, for example variant="comment".
Can I translate the texts of Rich text editor?
Yes. Rich text editor carries 73 texts of its own in English and German, such as “Formatting”, “Text” and “Normal text”. The language follows the KitProvider, single texts change through the labels prop.
Can an AI assistant build with Rich text editor?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Rich text editor with aiw_components and install it with aiw_add @ai-website/rich-text-editor. Name the variant in your prompt, for example variant="document", and the assistant writes your content straight into the props.