Skip to content
UI Kit
Accent color

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.

5 variantsOne of 4 components in Team and collaboration

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

DocumentNew in 1.2

A full toolbar with headings, lists, quote, code, link and alignment, active states from the selection, shortcuts, autosave, words and reading time.

Comment with mentionsNew in 1.2

A compact editor under a thread. Type @ to pick a person from a list, format with a small toolbar and post with Ctrl and Enter.

Markdown with previewNew in 1.2

A text field whose toolbar writes Markdown around the selection, with the preview beside it on desktops and as a second tab on phones.

Selection toolbarNew in 1.2

A quiet page for writing. Selecting words brings a small toolbar above them, Alt and F10 reach it by keyboard, Escape goes back to the text.

Email composerNew in 1.2

Sender, recipients as checked chips with Cc, subject, a formatting row, attachments, a signature to pick and a few seconds to undo the send.

Details

What Rich text editor brings along, read from the registry and the source.

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

Props

The RichTextEditorProps interface from components/ui/rich-text-editor.tsx, with the comments from the code.

  • variantoptional
    RichTextEditorVariant

    documentcommentmarkdownbubbleemail

  • sidebaroptional
    Omit<SidebarProps, 'children'> | null

    The app frame. Leave it out (or null) for a standalone screen.

  • titleoptional
    string
  • subtitleoptional
    string
  • docTitleoptional
    string

    document: the title of the document

  • valueoptional
    string

    The starting text. HTML for every variant except markdown, which takes Markdown.

  • placeholderoptional
    string
  • nowoptional
    string

    ISO date and time that counts as now

  • meoptional
    string

    comment: you and the people @ can mention

  • peopleoptional
    EditorPerson[]
  • commentsoptional
    EditorComment[]
  • fromoptional
    string[]

    email

  • tooptional
    string[]
  • subjectoptional
    string
  • signaturesoptional
    EditorSignature[]
  • wpmoptional
    number

    Words 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
toolbarEnglish FormattingGerman Formatierung
editorEnglish TextGerman Text
paragraphEnglish Normal textGerman Normaler Text
h1English TitleGerman Titel
h2English HeadingGerman Überschrift
h3English SubheadingGerman Unterüberschrift
boldEnglish BoldGerman Fett
italicEnglish ItalicGerman Kursiv
underlineEnglish UnderlineGerman Unterstrichen
strikeEnglish StrikethroughGerman Durchgestrichen
ulEnglish Bulleted listGerman Aufzählung
olEnglish Numbered listGerman Nummerierte Liste
quoteEnglish QuoteGerman Zitat
codeEnglish CodeGerman Code
codeBlockEnglish Code blockGerman Codeblock
linkEnglish LinkGerman Link
leftEnglish Align leftGerman Linksbündig
centerEnglish Align centerGerman Zentriert
rightEnglish Align rightGerman Rechtsbündig
undoEnglish UndoGerman Rückgängig
redoEnglish RedoGerman Wiederholen
linkUrlEnglish Link addressGerman Linkadresse
applyEnglish Apply linkGerman Link setzen
unlinkEnglish Remove linkGerman Link entfernen
cancelEnglish CancelGerman Abbrechen
badUrlEnglish Start the address with https:// or mailto:German Beginne die Adresse mit https:// oder mailto:
wordsEnglish {n} wordsGerman {n} Wörter
oneWordEnglish 1 wordGerman 1 Wort
charsEnglish {n} charactersGerman {n} Zeichen
readEnglish {n} min readGerman {n} Min. Lesezeit
savingEnglish Saving…German Wird gespeichert…
savedEnglish SavedGerman Gespeichert
editedEnglish Changes not saved yetGerman Änderungen noch nicht gespeichert
docTitleEnglish TitleGerman Titel
writeEnglish WriteGerman Schreiben
previewEnglish PreviewGerman Vorschau
mdHintEnglish Markdown works. Ctrl B, I and K format the selection.German Markdown funktioniert. Strg B, I und K formatieren die Auswahl.
copyMdEnglish Copy MarkdownGerman Markdown kopieren
copiedEnglish Markdown copied.German Markdown kopiert.
linesEnglish {n} linesGerman {n} Zeilen
previewEmptyEnglish The preview appears as you write.German Die Vorschau erscheint beim Schreiben.
bubbleHintEnglish 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.
mentionEnglish Mention someoneGerman Jemanden erwähnen
peopleEnglish PeopleGerman Personen
noPeopleEnglish Nobody with that nameGerman Niemand mit diesem Namen
commentsEnglish CommentsGerman Kommentare
postEnglish CommentGerman Kommentieren
postingEnglish Posting…German Wird gesendet…
postedEnglish Comment posted.German Kommentar veröffentlicht.
commentHintEnglish Ctrl and Enter post, @ mentions someoneGerman Strg und Enter senden, @ erwähnt jemanden
youEnglish YouGerman Du
fromEnglish FromGerman Von
toEnglish ToGerman An
ccEnglish CcGerman Cc
addCcEnglish Add CcGerman Cc hinzufügen
subjectEnglish SubjectGerman Betreff
recipientsEnglish RecipientsGerman Empfänger
removeRecipientEnglish Remove {name}German {name} entfernen
badEmailEnglish Check this addressGerman Adresse prüfen
needToEnglish Add at least one recipient.German Füge mindestens einen Empfänger hinzu.
signatureEnglish SignatureGerman Signatur
noSignatureEnglish No signatureGerman Keine Signatur
attachEnglish Attach filesGerman Dateien anhängen
removeFileEnglish Remove {name}German {name} entfernen
sendEnglish SendGerman Senden
sendingEnglish Sending…German Wird gesendet…
sentInEnglish Sending in {n} sGerman Wird in {n} s gesendet
undoSendEnglish UndoGerman Rückgängig
sentEnglish Email sent to {to}.German E-Mail an {to} gesendet.
unsentEnglish Sending stopped. The email is back in the editor.German Senden gestoppt. Die E-Mail ist wieder im Editor.
discardEnglish Discard draftGerman Entwurf verwerfen
discardedEnglish Draft discarded.German Entwurf verworfen.
newMailEnglish New emailGerman 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
components/ui/rich-text-editor.tsx
'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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
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>&#8203;`);
    }
    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>&nbsp;`);
        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>&nbsp;`);
    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(/&amp;/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 (/^&gt;\s?/.test(l)) {
      const body: string[] = [];
      while (i < lines.length && /^&gt;\s?/.test(lines[i])) body.push(lines[i++].replace(/^&gt;\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|&gt;|```|\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} |&gt; |> |- |\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.

Often searched

Jump to

Copied to the clipboard