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