Zum Inhalt springen
UI Kit
Accent

Rich Text Editor für React

Texte bearbeiten ohne Paket. Ein Dokument mit voller Leiste und Wortzahl, Kommentare mit @-Erwähnungen, Markdown mit Vorschau, eine Auswahlleiste und E-Mail.

5 VariantsEine von 4 Components in Team und Zusammenarbeit

Schreibwerkzeuge ohne zusätzliche Bibliothek

Ein Rich Text Editor lässt Menschen beim Schreiben formatieren, Überschriften, Listen, Links und Zitate. Dieser kommt ohne Editor-Paket aus, das Bundle bleibt also klein, und seine Leiste folgt dem APG-Muster für Werkzeugleisten mit aktiven Zuständen, Tastenkürzeln und voller Bedienung per Tastatur.

Das „Dokument“ hat eine volle Leiste mit Überschriften, Listen, Zitat, Code, Link und Ausrichtung, aktive Zustände aus der Auswahl, Tastenkürzel, automatisches Speichern, Wörter und Lesezeit. „Kommentar mit Erwähnungen“ ist ein kompakter Editor unter einem Verlauf, mit @ wählst du eine Person aus einer Liste, mit Strg und Enter wird gesendet. „Markdown mit Vorschau“ schreibt Markdown um die Auswahl und zeigt die Vorschau am Desktop daneben, am Telefon als zweiten Reiter. „Leiste über der Auswahl“ hält die Seite ruhig und holt eine kleine Leiste über markierte Wörter, Alt und F10 erreichen sie per Tastatur. „E-Mail schreiben“ hat Empfänger als geprüfte Chips mit Cc, Betreff, Anhänge, eine wählbare Signatur und ein paar Sekunden, um das Senden zurückzunehmen.

Biete nur die Formate an, die dein Inhalt braucht, ein Kommentarfeld braucht selten Überschriften. Bleib bei Kürzeln, die Menschen schon kennen, fett, kursiv und Link. Zeig die Wortzahl dort, wo Länge zählt, in Artikeln und Produkttexten, und lass sie im Chat weg.

Variants

DokumentNeu in 1.2

Volle Leiste mit Überschriften, Listen, Zitat, Code, Link und Ausrichtung, aktive Zustände aus der Auswahl, Tastenkürzel, Speichern, Wörter und Lesezeit.

Kommentar mit ErwähnungenNeu in 1.2

Ein kompakter Editor unter einem Verlauf. Mit @ eine Person aus einer Liste wählen, mit kleiner Leiste formatieren und mit Strg und Enter senden.

Markdown mit VorschauNeu in 1.2

Ein Textfeld, dessen Leiste Markdown um die Auswahl schreibt, mit der Vorschau daneben am Desktop und als zweitem Reiter am Telefon.

Leiste über der AuswahlNeu in 1.2

Eine ruhige Seite zum Schreiben. Markierte Wörter holen eine kleine Leiste darüber, Alt und F10 erreichen sie per Tastatur, Escape führt zurück.

E-Mail schreibenNeu in 1.2

Absender, Empfänger als geprüfte Chips mit Cc, Betreff, Formatierungsleiste, Anhänge, wählbare Signatur und ein paar Sekunden, um das Senden zurückzunehmen.

Details

Was Texteditor mitbringt, aus Registry und Quellcode gelesen.

Bereich
App
Variants
5
Version
1.0.0 · seit 1.2.0
Datei
components/ui/rich-text-editor.tsx
Bringt mit
utilskitavatarbuttoniconicon-buttonmotionsidebar
npm-Pakete
Keine
Sprachen
Englisch und Deutsch, 73 eigene Texte
Studio
Im Code anpassen
In Sammlungen
SaaS-App-Screens

Props

Die Schnittstelle RichTextEditorProps aus components/ui/rich-text-editor.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.

  • 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.

Eingebaute Texte

Diese Texte schreibt Texteditor selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.

Alle 73 Texte anzeigen
toolbarEnglisch FormattingDeutsch Formatierung
editorEnglisch TextDeutsch Text
paragraphEnglisch Normal textDeutsch Normaler Text
h1Englisch TitleDeutsch Titel
h2Englisch HeadingDeutsch Überschrift
h3Englisch SubheadingDeutsch Unterüberschrift
boldEnglisch BoldDeutsch Fett
italicEnglisch ItalicDeutsch Kursiv
underlineEnglisch UnderlineDeutsch Unterstrichen
strikeEnglisch StrikethroughDeutsch Durchgestrichen
ulEnglisch Bulleted listDeutsch Aufzählung
olEnglisch Numbered listDeutsch Nummerierte Liste
quoteEnglisch QuoteDeutsch Zitat
codeEnglisch CodeDeutsch Code
codeBlockEnglisch Code blockDeutsch Codeblock
linkEnglisch LinkDeutsch Link
leftEnglisch Align leftDeutsch Linksbündig
centerEnglisch Align centerDeutsch Zentriert
rightEnglisch Align rightDeutsch Rechtsbündig
undoEnglisch UndoDeutsch Rückgängig
redoEnglisch RedoDeutsch Wiederholen
linkUrlEnglisch Link addressDeutsch Linkadresse
applyEnglisch Apply linkDeutsch Link setzen
unlinkEnglisch Remove linkDeutsch Link entfernen
cancelEnglisch CancelDeutsch Abbrechen
badUrlEnglisch Start the address with https:// or mailto:Deutsch Beginne die Adresse mit https:// oder mailto:
wordsEnglisch {n} wordsDeutsch {n} Wörter
oneWordEnglisch 1 wordDeutsch 1 Wort
charsEnglisch {n} charactersDeutsch {n} Zeichen
readEnglisch {n} min readDeutsch {n} Min. Lesezeit
savingEnglisch Saving…Deutsch Wird gespeichert…
savedEnglisch SavedDeutsch Gespeichert
editedEnglisch Changes not saved yetDeutsch Änderungen noch nicht gespeichert
docTitleEnglisch TitleDeutsch Titel
writeEnglisch WriteDeutsch Schreiben
previewEnglisch PreviewDeutsch Vorschau
mdHintEnglisch Markdown works. Ctrl B, I and K format the selection.Deutsch Markdown funktioniert. Strg B, I und K formatieren die Auswahl.
copyMdEnglisch Copy MarkdownDeutsch Markdown kopieren
copiedEnglisch Markdown copied.Deutsch Markdown kopiert.
linesEnglisch {n} linesDeutsch {n} Zeilen
previewEmptyEnglisch The preview appears as you write.Deutsch Die Vorschau erscheint beim Schreiben.
bubbleHintEnglisch Select words to format them. Alt and F10 reach the toolbar.Deutsch Markiere Wörter, um sie zu formatieren. Alt und F10 erreichen die Leiste.
mentionEnglisch Mention someoneDeutsch Jemanden erwähnen
peopleEnglisch PeopleDeutsch Personen
noPeopleEnglisch Nobody with that nameDeutsch Niemand mit diesem Namen
commentsEnglisch CommentsDeutsch Kommentare
postEnglisch CommentDeutsch Kommentieren
postingEnglisch Posting…Deutsch Wird gesendet…
postedEnglisch Comment posted.Deutsch Kommentar veröffentlicht.
commentHintEnglisch Ctrl and Enter post, @ mentions someoneDeutsch Strg und Enter senden, @ erwähnt jemanden
youEnglisch YouDeutsch Du
fromEnglisch FromDeutsch Von
toEnglisch ToDeutsch An
ccEnglisch CcDeutsch Cc
addCcEnglisch Add CcDeutsch Cc hinzufügen
subjectEnglisch SubjectDeutsch Betreff
recipientsEnglisch RecipientsDeutsch Empfänger
removeRecipientEnglisch Remove {name}Deutsch {name} entfernen
badEmailEnglisch Check this addressDeutsch Adresse prüfen
needToEnglisch Add at least one recipient.Deutsch Füge mindestens einen Empfänger hinzu.
signatureEnglisch SignatureDeutsch Signatur
noSignatureEnglisch No signatureDeutsch Keine Signatur
attachEnglisch Attach filesDeutsch Dateien anhängen
removeFileEnglisch Remove {name}Deutsch {name} entfernen
sendEnglisch SendDeutsch Senden
sendingEnglisch Sending…Deutsch Wird gesendet…
sentInEnglisch Sending in {n} sDeutsch Wird in {n} s gesendet
undoSendEnglisch UndoDeutsch Rückgängig
sentEnglisch Email sent to {to}.Deutsch E-Mail an {to} gesendet.
unsentEnglisch Sending stopped. The email is back in the editor.Deutsch Senden gestoppt. Die E-Mail ist wieder im Editor.
discardEnglisch Discard draftDeutsch Entwurf verwerfen
discardedEnglisch Draft discarded.Deutsch Entwurf verworfen.
newMailEnglisch New emailDeutsch Neue E-Mail

Barrierefreiheit

Aus dem Quellcode der Component abgeleitet.

  • Texteditor reagiert selbst auf die Tasten Escape, Enter, Pfeil hoch, Pfeil runter, Pfeil links, Pfeil rechts, Pos1, Ende, Tab, Rücktaste und Leertaste.
  • Setzt aria-controls, aria-describedby, aria-invalid, aria-keyshortcuts, aria-label, aria-labelledby, aria-live, aria-multiline, aria-orientation, aria-pressed und aria-selected, damit Screenreader Zweck und Zustand ansagen.
  • Nutzt die Rollen alert, listbox, option, separator, status, tab, tablist, tabpanel, textbox und toolbar.
  • Meldet Änderungen über eine Live-Region, ohne den Fokus zu verschieben.
  • Jedes Feld hat ein Label, sichtbar oder für Screenreader.
  • Ergänzt Text für Screenreader, wo Icons und Grafiken allein stehen.
  • Zeigt bei Bedienung mit der Tastatur einen sichtbaren Fokusring.
  • Der Server rendert den Startzustand vollständig, JavaScript ergänzt nur die Interaktion.

Bewegung

  • Bringt eigene Animationen mit, ruhig getaktet und ohne Wackeln.
  • Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig. 1 Stellen im Code schalten Bewegung dann ab.

Quellcode

Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.

Datei anzeigen, 1.588 Zeilen
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>
  );
}

Häufige Fragen

Wie installiere ich Texteditor?

Mit aiw add @ai-website/rich-text-editor. Der Befehl legt die Datei components/ui/rich-text-editor.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind avatar, button, icon, icon-button, motion und sidebar. Weitere npm-Pakete braucht sie nicht.

Welche Varianten hat Texteditor?

5 Varianten, Dokument, Kommentar mit Erwähnungen, Markdown mit Vorschau, Leiste über der Auswahl und E-Mail schreiben. Du wählst sie mit der Prop variant, zum Beispiel variant="comment".

Kann ich die Texte von Texteditor übersetzen?

Ja. Texteditor bringt 73 eigene Texte auf Deutsch und Englisch mit, etwa „Formatierung“, „Text“ und „Normaler Text“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.

Kann ein KI-Assistent Texteditor einbauen?

Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Texteditor mit aiw_components und installieren sie mit aiw_add @ai-website/rich-text-editor. Nenn im Prompt die Variante, etwa variant="document", dann schreibt der Assistent deine Inhalte direkt in die Props.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert