Skip to content
Star on GitHub
ConversationRich text editor
Style
Accent
Sound

Built by DesignMe, free to use under the MIT licence.

Expert design and dev support for startups and enterprises.

Book a call

Rich text editor

A minimal editor with an inline AI menu that streams a suggestion to accept or discard. Free and MIT licensed, as HTML or a typed React component, in two styles with light and dark mode.

$npx designme add rich-text-editor


0 wordsSelect text to ask AI

01

Install

Add it to your project with the CLI.

HTML, CSS and JavaScript

$npx designme add rich-text-editor

React

$npx designme add rich-text-editor --react

The CLI copies the source into your project, so you own it and can change anything. The HTML version lands in ./designme with the shared core and a demo.html. The React version lands in src/components/designme and needs React 19; it works in Next.js, Vite and Astro.

02

About

What it is for and what it does.

Rich text editor is a small editor for drafts an agent helps write. It has a compact toolbar for a heading, bold, italic, inline code, links, lists and quotes, a quiet word count, and undo and redo. Select some text and ask the AI to improve it, shorten it or fix its grammar, or to continue writing, and the suggestion streams in beside your text, with the old words struck through, for you to accept or discard.

  • When people write something an agent can improve, like an announcement, a reply or a brief, and should stay in charge of every change.
  • When the agent's suggestion should be read in place, next to the words it replaces, before anything is kept.
  • When a few formats are enough and a full document editor would be too much.
  • Not for long documents with tables, images or comments: use a full document editor then.
03

Usage

Import it and drive it from your own state.

Import

import { RichTextEditor } from '@/components/designme/rich-text-editor/RichTextEditor';

Example

The demo above as a React component, 34 lines you can copy.

'use client';
// The animated preview from designme.dev, rebuilt from the React component: an example of driving it from your own state.
import { useRef, useState } from 'react';
import { RichTextEditor, type RichTextEditorHandle } from './RichTextEditor';
import { useStory } from '../lib/useScript';

/* ---------- Rich text editor: a heading and a short draft about the pricing launch, then the last sentence is made shorter
   by the AI, streamed in and accepted ---------- */
const DRAFT = 'New pricing goes live on March 3. Starter teams get two more seats for free, and every plan now includes the agent. We know pricing changes can feel like a lot, so we want to make sure everyone has plenty of time to look at the new plans and ask us questions before anything changes.';
const LAST = DRAFT.slice(DRAFT.indexOf('We know'));

export default function RichTextEditorDemo() {
  const ref = useRef<HTMLDivElement>(null), editor = useRef<RichTextEditorHandle>(null);
  const [html, setHtml] = useState('');
  // the preview's story: the person types a heading and the draft, selects the last sentence, asks the AI to make it shorter
  // and accepts the suggestion. Your first click or key stops it, and the editor is yours from wherever it got to
  useStory(ref, async ({ wait, press }) => {
    const type = async (text: string) => { for (const w of text.match(/\S+\s*/g) || []) { editor.current?.type(w); await wait(120); } };
    await wait(1500); editor.current?.format('h2');
    await type('Pricing launch'); await wait(500);
    editor.current?.newLine(); await wait(250);
    await type(DRAFT); await wait(1500);
    editor.current?.select(LAST); await wait(1000);
    press('.dm-rte-ask'); await wait(1000);
    editor.current?.ask('shorter'); await wait(5000);
    editor.current?.accept();
  });
  return (
    <div ref={ref} style={{ width: '100%' }}>
      <RichTextEditor ref={editor} value={html} onValueChange={setHtml} />
    </div>
  );
}
04

Props

Everything RichTextEditor takes.

PropTypeWhat it does
valuestringThe text as HTML: paragraphs, one heading level (`h2`), bulleted and numbered lists, quotes, and inside them bold, italic, inline code and links. Anything else is cleaned out, as on paste. Leave it out to let the editor keep the text
defaultValuestring
onValueChange(html: string) => voidCalled with the HTML after each change the person makes: a key typed, a format, a paste, undo or redo, an accepted suggestion. Not while a suggestion is on screen, and not for text you pass in
onChange(html: string) => voidThe same as onValueChange, for code that expects the usual name
placeholderstringShown while the editor is empty
labelstringThe text box's accessible name. Defaults to Draft
hintReactNodeThe quiet hint beside the word count, on wide editors
aiRichTextAi | falseWrites the AI's suggestions. Defaults to a demo that streams canned text; pass your model call, or false to hide the AI
aiActionsRichTextAiAction[]The AI menu. Defaults to Improve writing, Make shorter, Fix grammar and Continue writing
onSuggestion(result: { action: string; text: string; accepted: boolean }) => voidCalled when a suggestion is accepted or discarded
refRef<RichTextEditorHandle>
classNamestring
05

Code

The full source, as the CLI installs it.

RichTextEditor.tsxReact · 909 lines
'use client';
import { useEffect, useId, useImperativeHandle, useRef, useState, type KeyboardEvent as ReactKeyboardEvent, type ReactNode, type Ref } from 'react';
import { cx } from '../lib/cx';
import { useReducedMotion } from '../lib/hooks';
import { Icon, type IconName } from '../lib/icons';
import { Tip } from '../lib/motion';
import '../designme.css';
import './rich-text-editor.css';

/** A formatting command, as the toolbar and the shortcuts apply it. `p` turns lines back into plain paragraphs */
export type RichTextFormat = 'h2' | 'b' | 'i' | 'code' | 'ul' | 'ol' | 'quote' | 'p';

/** An item in the AI menu */
export interface RichTextAiAction {
  key: string;
  label: string;
  icon?: IconName;
  /** `swap` (the default) replaces the selection, or the paragraph the caret is in, and shows the old words struck through.
      `add` writes on after the caret or the selection, and is the only kind offered on an empty line */
  kind?: 'swap' | 'add';
}

/** What the AI is asked to do. `text` is what it acts on (empty for an `add`), `before` and `after` the text around it, `document` all of it */
export interface RichTextAiRequest {
  action: string;
  text: string;
  before: string;
  after: string;
  document: string;
  /** Aborted when the person discards the suggestion or the editor unmounts */
  signal: AbortSignal;
}

/** Writes a suggestion: return the text, a promise of it, or an async iterable of chunks to stream it in as your model answers.
    Blank lines in it start new paragraphs */
export type RichTextAi = (request: RichTextAiRequest) => AsyncIterable<string> | Promise<string> | string;

/** What a ref to the editor can do: drive it from your own shortcuts, a command menu or a scripted demo. None of these take the
    focus unless the editor already has it, except `focus` */
export interface RichTextEditorHandle {
  focus: () => void;
  /** The text as HTML, in the editor's few tags */
  getHTML: () => string;
  /** The text as plain text, a line break between lines */
  getText: () => string;
  /** Replaces the text, cleaned like a paste, and starts a new undo history */
  setHTML: (html: string) => void;
  /** Selects the first place the text appears and marks it, as a person's selection is marked. Returns false when it isn't there */
  select: (text: string) => boolean;
  /** Types text at the caret, as a person would */
  type: (text: string) => void;
  /** Starts a new line at the caret, as Enter does */
  newLine: () => void;
  format: (format: RichTextFormat) => void;
  /** Opens the AI menu at the selection, or closes it when it is open */
  openAi: () => void;
  /** Runs an AI action on the selection, or the caret's paragraph, as the menu does */
  ask: (action: string) => void;
  /** Keeps the suggestion once it has finished, as Tab does */
  accept: () => void;
  /** Leaves the text as it was, as Escape does */
  discard: () => void;
  undo: () => void;
  redo: () => void;
}

export interface RichTextEditorProps {
  /** The text as HTML: paragraphs, one heading level (`h2`), bulleted and numbered lists, quotes, and inside them bold, italic,
      inline code and links. Anything else is cleaned out, as on paste. Leave it out to let the editor keep the text */
  value?: string;
  defaultValue?: string;
  /** Called with the HTML after each change the person makes: a key typed, a format, a paste, undo or redo, an accepted suggestion.
      Not while a suggestion is on screen, and not for text you pass in */
  onValueChange?: (html: string) => void;
  /** The same as onValueChange, for code that expects the usual name */
  onChange?: (html: string) => void;
  /** Shown while the editor is empty */
  placeholder?: string;
  /** The text box's accessible name. Defaults to Draft */
  label?: string;
  /** The quiet hint beside the word count, on wide editors */
  hint?: ReactNode;
  /** Writes the AI's suggestions. Defaults to a demo that streams canned text; pass your model call, or false to hide the AI */
  ai?: RichTextAi | false;
  /** The AI menu. Defaults to Improve writing, Make shorter, Fix grammar and Continue writing */
  aiActions?: RichTextAiAction[];
  /** Called when a suggestion is accepted or discarded */
  onSuggestion?: (result: { action: string; text: string; accepted: boolean }) => void;
  ref?: Ref<RichTextEditorHandle>;
  className?: string;
}

const ACTIONS: RichTextAiAction[] = [
  { key: 'improve', label: 'Improve writing', icon: 'wand' },
  { key: 'shorter', label: 'Make shorter', icon: 'minus' },
  { key: 'grammar', label: 'Fix grammar', icon: 'check' },
  { key: 'continue', label: 'Continue writing', icon: 'edit', kind: 'add' }
];

type Range2 = { s: number; e: number };
type Line = { t: string; frag: DocumentFragment; len: number };

/* ---------- the editing engine ----------
   The text is a list of lines (paragraphs, a heading, list items and quote lines) holding bold, italic, inline code and links,
   and every change goes through it: Enter, Backspace at a line's start, formats, paste and drop, so pasted or browser-made
   markup is cleaned into those few tags. Undo and redo step through its own history. A selection is kept as two numbers, the
   characters before each end with a line break counting one, so it survives every rebuild, and the editor works the same with
   or without the focus (a story types into it without taking the focus from the page) */
const LEAF = 'p,h2,blockquote,li';
const BLOCK = /^(P|DIV|H[1-6]|LI|BLOCKQUOTE|PRE|TR|DT|DD|SECTION|ARTICLE|HEADER|FOOTER|ASIDE|MAIN|NAV|FIGURE|FIGCAPTION|UL|OL|DL|TABLE|TBODY|THEAD|TFOOT|ADDRESS|HR)$/;
const SKIP = /^(SCRIPT|STYLE|TEMPLATE|HEAD|META|LINK|TITLE|IFRAME|OBJECT|EMBED|SVG|MATH|IMG|PICTURE|VIDEO|AUDIO|CANVAS|NOSCRIPT|BUTTON|INPUT|SELECT|TEXTAREA|OPTION)$/;
const isMac = () => typeof navigator !== 'undefined' && /Mac|iP(hone|ad|od)/.test(navigator.platform || navigator.userAgent);
const safeUrl = (h: string | null) => { const v = (h || '').trim(); return /^(https?:\/\/|mailto:)/i.test(v) ? v : null; };

interface Hooks { change?: () => void; select?: () => void; input?: (e: InputEvent) => void; key?: (e: KeyboardEvent) => boolean }

function createEngine(ed: HTMLElement, hook: Hooks) {
  const offs: (() => void)[] = [];
  const on = <K extends keyof HTMLElementEventMap>(el: HTMLElement | Document, ev: K | string, fn: (e: HTMLElementEventMap[K]) => void) => { el.addEventListener(ev, fn as EventListener); offs.push(() => el.removeEventListener(ev, fn as EventListener)); };
  const lines = () => [...ed.querySelectorAll<HTMLElement>(LEAF)];
  const typeOf = (b: Element) => b.tagName === 'LI' ? (b.parentNode as Element).tagName.toLowerCase() : b.tagName === 'BLOCKQUOTE' ? 'quote' : b.tagName.toLowerCase();
  const lineOf = (n: Node | null): HTMLElement | null => { for (let x = n; x && x !== ed; x = x.parentNode) if (x.nodeType === 1 && (x as Element).matches(LEAF)) return x as HTMLElement; return null; };
  const len = (b: Node) => (b.textContent || '').length;
  const total = () => lines().reduce((n, b, i) => n + len(b) + (i ? 1 : 0), 0);
  const startOf = (i: number) => lines().slice(0, i).reduce((n, b) => n + len(b) + 1, 0);
  const lineAt = (at: number) => { const all = lines(); let n = 0; for (let i = 0; i < all.length; i++) { if (at <= n + len(all[i])) return i; n += len(all[i]) + 1; } return all.length - 1; };
  const text = (o: Range2) => lines().map(b => b.textContent).join('\n').slice(o.s, o.e);
  // the nearest element of a kind around a node, inside its line
  const up = (n: Node | null, tag: string): Element | null => { for (let x = n && n.nodeType === 3 ? n.parentNode : n; x && x !== ed && x.nodeType === 1 && !(x as Element).matches(LEAF); x = x.parentNode) if ((x as Element).tagName === tag) return x as Element; return null; };
  const wrap = (n: Node, tag: string, href?: string | null) => { const w = document.createElement(tag.toLowerCase()); if (href) w.setAttribute('href', href); (n as ChildNode).replaceWith(w); w.appendChild(n); return w; };
  const unwrap = (el: Element) => el.replaceWith(...el.childNodes);

  /* ---- a point in the DOM as one number, and back ---- */
  function offsetOf(node: Node, off: number): number {
    let b = lineOf(node);
    if (!b) {
      if (!ed.contains(node)) return 0;
      // a point between lines: the start of the line after it, or the very end
      const after = node.childNodes[off], first = after && after.nodeType === 1 ? ((after as Element).matches(LEAF) ? after as HTMLElement : (after as Element).querySelector<HTMLElement>(LEAF)) : null;
      if (!first) return total();
      b = first; node = first; off = 0;
    }
    let n = 0; for (const x of lines()) { if (x === b) break; n += len(x) + 1; }
    const rg = document.createRange(); rg.setStart(b, 0); rg.setEnd(node, off);
    return n + rg.toString().length;
  }
  // late: where two text nodes meet, the start of the second (for the start of a range) rather than the end of the first
  function pointAt(at: number, late = false): [Node, number] {
    const all = lines(); let n = Math.max(0, at);
    for (let i = 0; i < all.length; i++) {
      const b = all[i];
      if (n <= len(b) || i === all.length - 1) {
        n = Math.min(n, len(b));
        const w = document.createTreeWalker(b, NodeFilter.SHOW_TEXT); let t: Text | null, last: Text | null = null;
        while ((t = w.nextNode() as Text | null)) { if (late ? n < t.data.length : n <= t.data.length) return [t, n]; n -= t.data.length; last = t; }
        return last ? [last, last.data.length] : [b, 0];
      }
      n -= len(b) + 1;
    }
    return [ed, 0];
  }
  const range = (o: Range2) => { const [a, ao] = pointAt(o.s, o.s !== o.e), [b, bo] = pointAt(o.e); const rg = document.createRange(); rg.setStart(a, ao); rg.setEnd(b, bo); return rg; };
  // scrolls the text, and only the text, so a part of it is in view
  function show(x: Element | Range) {
    const r = x.getBoundingClientRect(), b = ed.getBoundingClientRect(); if (!r.height) return;
    if (r.bottom > b.bottom - 12) ed.scrollTop += Math.min(r.bottom - b.bottom + 12, r.top - b.top - 12);
    else if (r.top < b.top + 12) ed.scrollTop -= b.top + 12 - r.top;
  }

  /* ---- the selection: the browser's while the editor has the focus, otherwise the last one it had ---- */
  let caret: Range2 | null = null;
  const live = () => { const s = getSelection(); if (!s || !s.rangeCount) return null; const g = s.getRangeAt(0); return ed.contains(g.startContainer) && ed.contains(g.endContainer) ? g : null; };
  function get(): Range2 {
    const g = live();
    if (g) { const a = offsetOf(g.startContainer, g.startOffset), b = offsetOf(g.endContainer, g.endOffset); return { s: Math.min(a, b), e: Math.max(a, b) }; }
    if (!caret) { const t = total(); caret = { s: t, e: t }; }
    return { s: caret.s, e: caret.e };
  }
  function set(o: Range2, focus = false) {
    caret = { s: o.s, e: o.e };
    if (focus && document.activeElement !== ed) ed.focus({ preventScroll: true });
    if (document.activeElement !== ed) return;
    const s = getSelection(); if (!s) return; s.removeAllRanges(); s.addRange(range(o));
  }

  /* ---- tidying: the few tags the editor keeps, in the shape it keeps them ---- */
  function tidy() {
    for (const el of ed.querySelectorAll('b,i,code,a')) {
      if (!el.isConnected) continue;
      if (!el.textContent) { el.remove(); continue; }
      // a mark inside one of its own kind adds nothing
      if (up(el.parentNode, el.tagName)) unwrap(el);
    }
    // two marks of a kind side by side become one
    for (const el of ed.querySelectorAll('b,i,code,a')) {
      if (!el.isConnected) continue;
      for (;;) {
        let nx = el.nextSibling;
        while (nx && nx.nodeType === 3 && !(nx as Text).data) { const d = nx; nx = nx.nextSibling; d.remove(); }
        if (!nx || nx.nodeType !== 1 || (nx as Element).tagName !== el.tagName || (nx as Element).getAttribute('href') !== el.getAttribute('href')) break;
        el.append(...nx.childNodes); nx.remove();
      }
    }
    // an empty line keeps one <br> so it has a height and a caret; a line with text needs none
    for (const b of lines()) { const br = [...b.querySelectorAll('br')]; if (b.textContent) br.forEach(x => x.remove()); else if (!br.length) b.appendChild(document.createElement('br')); else br.slice(1).forEach(x => x.remove()); }
    if (!lines().length) { const p = document.createElement('p'); p.appendChild(document.createElement('br')); ed.replaceChildren(p); }
    ed.normalize();
  }
  // spec: [type, a node whose children become the line][]; list items of a kind side by side share one list
  function rebuild(spec: [string, Node][]) {
    const frag = document.createDocumentFragment(); let list: HTMLElement | null = null;
    for (const [t, b] of spec) {
      const el = document.createElement(t === 'quote' ? 'blockquote' : t === 'ul' || t === 'ol' ? 'li' : t);
      el.append(...b.childNodes);
      if (t === 'ul' || t === 'ol') { if (!list || list.tagName.toLowerCase() !== t) frag.appendChild(list = document.createElement(t)); list.appendChild(el); }
      else { list = null; frag.appendChild(el); }
    }
    ed.replaceChildren(frag); tidy();
  }
  const specOf = (): [string, Node][] => lines().map(b => [typeOf(b), b]);
  // anything the browser made that the editor doesn't keep: a stray <div>, a <span style>, text outside a line
  function messy() {
    for (const n of ed.childNodes) {
      if (n.nodeType === 3 ? (n as Text).data.trim() : n.nodeType === 1 && !/^(P|H2|BLOCKQUOTE|UL|OL)$/.test((n as Element).tagName)) return true;
      if (n.nodeType === 1 && /^(UL|OL)$/.test((n as Element).tagName) && [...n.childNodes].some(x => x.nodeType !== 1 || (x as Element).tagName !== 'LI')) return true;
    }
    return !!ed.querySelector('[style],[class],span,font,div,a:not([href]),:is(p,h2,blockquote,li) :not(b,i,code,a,br)');
  }

  /* ---- reading HTML, pasted or the editor's own, into lines of the few tags it keeps. keep: the editor's own, every
     character and empty line as it is; otherwise whitespace collapses as a page shows it and empty lines go ---- */
  function parse(root: Node, keep: boolean): Line[] {
    const out: { t: string; frag: DocumentFragment }[] = []; let cur: { t: string; frag: DocumentFragment } | null = null;
    const walk = (node: Node, t: string, pre: boolean, marks: [string, string?][]) => {
      for (const n of node.childNodes) {
        if (n.nodeType === 3) {
          const d = (n as Text).data;
          let s = keep ? d : pre ? d.replace(/\r\n?/g, '\n') : d.replace(/[\s ]+/g, ' ');
          if (!keep) s = s.replace(/ /g, ' ');
          if (!cur && !s.trim() && (node === root || /^(UL|OL|DL|TABLE|TBODY|THEAD|TFOOT|TR)$/.test((node as Element).tagName))) continue;
          (pre && !keep ? s.split('\n') : [s]).forEach((p, i) => {
            if (i) cur = null;
            if (!p || (!cur && !keep && !p.trim())) return;
            if (!cur) out.push(cur = { t, frag: document.createDocumentFragment() });
            let x: Node = document.createTextNode(p);
            for (let m = marks.length - 1; m >= 0; m--) { const w = document.createElement(marks[m][0].toLowerCase()); if (marks[m][1]) w.setAttribute('href', marks[m][1]!); w.appendChild(x); x = w; }
            cur.frag.appendChild(x);
          });
          continue;
        }
        if (n.nodeType !== 1) continue;
        const el = n as Element, tg = el.tagName;
        if (SKIP.test(tg) || el.classList.contains('dm-rte-new')) continue;
        const css = (el.getAttribute('style') || '').toLowerCase();
        if (tg === 'BR') { cur = null; continue; }
        if (BLOCK.test(tg)) {
          const nt = /^H[1-6]$/.test(tg) ? 'h2' : tg === 'LI' ? (el.parentElement && el.parentElement.tagName === 'OL' ? 'ol' : 'ul') : tg === 'BLOCKQUOTE' ? 'quote' : t;
          cur = null; const was = out.length;
          walk(el, nt, pre || tg === 'PRE', marks);
          if (keep && out.length === was && !el.querySelector('p,div,h1,h2,h3,h4,h5,h6,li,blockquote,pre,ul,ol')) out.push({ t: nt, frag: document.createDocumentFragment() });
          cur = null; continue;
        }
        let m = marks; const add = (tag: string, href?: string) => { if (!m.some(x => x[0] === tag)) m = [...m, [tag, href]]; };
        if (tg === 'B' || tg === 'STRONG' ? !/font-weight\s*:\s*(normal|[1-4]00)/.test(css) : /font-weight\s*:\s*(bold|[6-9]00)/.test(css)) add('B');
        if (tg === 'I' || tg === 'EM' || /font-style\s*:\s*italic/.test(css)) add('I');
        if (/^(CODE|KBD|SAMP|TT)$/.test(tg)) add('CODE');
        if (tg === 'A') { const h = safeUrl(el.getAttribute('href')); if (h) add('A', h); }
        walk(el, t, pre, m);
      }
    };
    walk(root, 'p', false, []);
    if (!keep) for (const x of out) {
      const ts: Text[] = [], w = document.createTreeWalker(x.frag, NodeFilter.SHOW_TEXT); let t: Text | null; while ((t = w.nextNode() as Text | null)) ts.push(t);
      if (ts.length) { ts[0].data = ts[0].data.replace(/^\s+/, ''); ts[ts.length - 1].data = ts[ts.length - 1].data.replace(/\s+$/, ''); }
    }
    return out.filter(x => keep || x.frag.textContent).map(x => ({ ...x, len: (x.frag.textContent || '').length }));
  }
  const plain = (s: string): Line[] => s.replace(/\r\n?/g, '\n').split('\n').filter(l => l.trim()).map(l => { const frag = document.createDocumentFragment(); frag.append(l); return { t: 'p', frag, len: l.length }; });
  function clean() { const o = get(); rebuild(parse(ed, true).map(x => [x.t, x.frag])); set(o); }

  /* ---- history ---- */
  const H = { list: [] as { html: string; sel: Range2; pre: Range2 | null }[], at: 0, dirty: false, pre: null as Range2 | null, t: 0 as ReturnType<typeof setTimeout> | 0 };
  const changed = () => hook.change?.();
  function commit(pre?: Range2) {
    clearTimeout(H.t); const html = ed.innerHTML, top = H.list[H.at], before = H.pre || pre || null;
    H.dirty = false; H.pre = null;
    if (!top || top.html !== html) { H.list.splice(H.at + 1); H.list.push({ html, sel: get(), pre: before }); if (H.list.length > 300) H.list.shift(); H.at = H.list.length - 1; }
    changed();
  }
  const flush = () => { if (H.dirty) commit(); };
  // typing settles into one step once it pauses
  function typed(pre?: Range2 | null) { if (!H.dirty) { H.pre = pre || null; H.list.splice(H.at + 1); } H.dirty = true; clearTimeout(H.t); H.t = setTimeout(() => commit(), 700); changed(); }
  function restore(html: string, sel: Range2 | null) { ed.innerHTML = html; pend = null; set(sel || { s: 0, e: 0 }); changed(); }
  function undo() { flush(); if (H.at <= 0) return; const cur = H.list[H.at]; H.at--; restore(H.list[H.at].html, cur.pre || H.list[H.at].sel); }
  function redo() { flush(); if (H.at >= H.list.length - 1) return; H.at++; restore(H.list[H.at].html, H.list[H.at].sel); }
  function load(html: string) {
    const t = document.createElement('template'); t.innerHTML = html || '';
    rebuild(parse(t.content, false).map(x => [x.t, x.frag])); caret = null; pend = null; rev = null;
    H.list = [{ html: ed.innerHTML, sel: { s: 0, e: 0 }, pre: null }]; H.at = 0; H.dirty = false; clearTimeout(H.t); changed();
  }

  /* ---- edits ---- */
  function texts() { const out: [Text, number][] = []; let n = 0; lines().forEach((b, i) => { if (i) n++; const w = document.createTreeWalker(b, NodeFilter.SHOW_TEXT); let t: Text | null; while ((t = w.nextNode() as Text | null)) { out.push([t, n]); n += t.data.length; } }); return out; }
  function cut(at: number) { for (const [t, n] of texts()) if (at > n && at < n + t.data.length) { t.splitText(at - n); return; } }
  // the text nodes a selection covers, split at its ends so each is wholly inside it
  function covered(o: Range2) { cut(o.e); cut(o.s); return texts().filter(([t, n]) => t.data.length && n >= o.s && n + t.data.length <= o.e).map(x => x[0]); }
  // formats picked with nothing selected wait for the next thing typed at that spot
  let pend: { at: number; m: Record<string, boolean> } | null = null;
  function has(tag: string, o: Range2) {
    if (o.s === o.e) {
      if (pend && pend.at === o.s && tag in pend.m) return pend.m[tag];
      const g = live(); return !!up(g && g.collapsed ? g.startContainer : pointAt(o.s)[0], tag);
    }
    const ts = texts().filter(([t, n]) => t.data.length && n < o.e && n + t.data.length > o.s);
    return ts.length > 0 && ts.every(([t]) => up(t, tag));
  }
  // bold (B), italic (I), code (CODE) or a link (A, with href; null takes it off) on the selection, or off it when it all has it
  function inline(tag: string, href?: string | null, o = get()) {
    if (o.s === o.e) {
      if (tag === 'A') return;
      if (!pend || pend.at !== o.s) pend = { at: o.s, m: {} };
      pend.m[tag] = !has(tag, o); changed(); return;
    }
    flush();
    const ts = covered(o); if (!ts.length) return;
    const add = href !== undefined ? href !== null : !ts.every(t => up(t, tag));
    if (add) ts.forEach(t => { const had = up(t, tag); if (!had) wrap(t, tag, href); else if (href) had.setAttribute('href', href); });
    else new Set(ts.map(t => up(t, tag))).forEach(el => {
      if (!el) return;
      const keep: Text[] = [], w = document.createTreeWalker(el, NodeFilter.SHOW_TEXT), h = el.getAttribute('href'); let t: Text | null;
      while ((t = w.nextNode() as Text | null)) if (!ts.includes(t)) keep.push(t);
      unwrap(el); keep.forEach(x => wrap(x, tag, h));
    });
    tidy(); set(o); commit(o);
  }
  // typing with formats picked at the caret: the text goes in with them, and the caret stays inside them
  function typeStyled(s: string) {
    const o = get(), m = pend!.m; pend = null;
    const g = live(), [n, k] = g && g.collapsed ? [g.startContainer, g.startOffset] as [Node, number] : pointAt(o.s);
    const rg = document.createRange(); rg.setStart(n, k); rg.collapse(true);
    for (const tag of Object.keys(m)) if (!m[tag]) {
      const el = up(rg.startContainer, tag); if (!el) continue;
      const tail = document.createRange(); tail.setStart(rg.startContainer, rg.startOffset); tail.setEndAfter(el);
      el.after(tail.extractContents()); rg.setStartAfter(el); rg.collapse(true);
    }
    let x: Node = document.createTextNode(s);
    for (const tag of Object.keys(m)) if (m[tag] && !up(rg.startContainer, tag)) { const w = document.createElement(tag.toLowerCase()); w.appendChild(x); x = w; }
    rg.insertNode(x); tidy(); set({ s: o.s + s.length, e: o.s + s.length }); typed(o);
  }
  // a heading, a list or a quote on the lines in the selection, or plain lines again when they all have it
  function block(type: string, o = get()) {
    flush(); const all = lines(), a = lineAt(o.s), z = lineAt(o.e);
    const to = all.slice(a, z + 1).every(b => typeOf(b) === type) ? 'p' : type;
    rebuild(all.map((b, i) => [i >= a && i <= z ? to : typeOf(b), b])); set(o); commit(o);
  }
  function del(o: Range2): Range2 {
    const all = lines(), i = lineAt(o.s), j = lineAt(o.e), [a, ao] = pointAt(o.s, true), [b, bo] = pointAt(o.e);
    if (i === j) { const rg = document.createRange(); rg.setStart(a, ao); rg.setEnd(b, bo); rg.deleteContents(); tidy(); return { s: o.s, e: o.s }; }
    const head = document.createRange(); head.setStart(a, ao); head.setEnd(all[i], all[i].childNodes.length); head.deleteContents();
    const tail = document.createRange(); tail.setStart(all[j], 0); tail.setEnd(b, bo); tail.deleteContents();
    all[i].append(...all[j].childNodes);
    rebuild(all.filter((_, k) => k <= i || k > j).map(x => [typeOf(x), x]));
    return { s: o.s, e: o.s };
  }
  // Enter: the text after the caret starts a new line. A list or a quote carries on, a heading is followed by text, and Enter
  // on an empty list item or quote line leaves the list
  function split(o = get()) {
    flush(); const pre = o; if (o.s !== o.e) o = del(o);
    const all = lines(), i = lineAt(o.s), b = all[i], t = typeOf(b), spec = specOf(), next = t === 'h2' ? 'p' : t;
    if (!len(b) && t !== 'p') { spec[i][0] = 'p'; rebuild(spec); set(o); commit(pre); return; }
    if (len(b) && o.s === startOf(i)) { spec.splice(i, 0, [next, document.createElement('p')]); rebuild(spec); set({ s: o.s + 1, e: o.s + 1 }); commit(pre); return; }
    const [n, k] = pointAt(o.s), rg = document.createRange(); rg.setStart(n, k); rg.setEnd(b, b.childNodes.length);
    const nb = document.createElement('p'); nb.appendChild(rg.extractContents());
    spec.splice(i + 1, 0, [next, nb]); rebuild(spec); set({ s: o.s + 1, e: o.s + 1 }); commit(pre);
  }
  // Backspace at the start of a line: a heading, list item or quote line turns plain first, then the line joins the one above
  function joinBack(o: Range2) {
    const all = lines(), i = lineAt(o.s), spec = specOf();
    if (spec[i][0] !== 'p') { spec[i][0] = 'p'; rebuild(spec); set(o); commit(o); return; }
    if (!i) return;
    all[i - 1].append(...all[i].childNodes); spec.splice(i, 1); rebuild(spec); set({ s: o.s - 1, e: o.s - 1 }); commit(o);
  }
  function joinNext(o: Range2) {
    const all = lines(), i = lineAt(o.s), spec = specOf(); if (i >= all.length - 1) return;
    all[i].append(...all[i + 1].childNodes); spec.splice(i + 1, 1); rebuild(spec); set(o); commit(o);
  }
  // plain text at a spot; returns where it ends
  function insert(s: string, o: Range2) {
    const [n, k] = pointAt(o.s);
    if (n.nodeType === 3) (n as Text).insertData(k, s); else n.insertBefore(document.createTextNode(s), n.childNodes[k] || null);
    tidy(); return o.s + s.length;
  }
  // lines read from a paste (or a suggestion) into the text: the first joins the line at the caret, the rest follow it, and
  // what came after the caret goes on the end. Returns where they end
  function insertLines(items: Line[], o: Range2) {
    if (!items.length) return o.s;
    if (o.s !== o.e) o = del(o);
    const all = lines(), i = lineAt(o.s), b = all[i], [n, k] = pointAt(o.s), spec = specOf();
    const rg = document.createRange(); rg.setStart(n, k); rg.setEnd(b, b.childNodes.length); const tail = rg.extractContents();
    if (!b.textContent && items[0].t !== 'p') spec[i][0] = items[0].t;
    let at = o.s + items[0].len; b.append(items[0].frag);
    const rest = items.slice(1).map((x): [string, Node] => { const p = document.createElement('p'); p.append(x.frag); at += 1 + x.len; return [x.t, p]; });
    spec.splice(i + 1, 0, ...rest); (rest.length ? rest[rest.length - 1][1] as HTMLElement : b).append(tail);
    rebuild(spec); return at;
  }
  // a link on the selection; with nothing selected, on the link at the caret, or the address itself goes in, linked
  function link(o: Range2, href: string | null) {
    flush();
    if (!href) { const rg = range(o); [...ed.querySelectorAll('a')].filter(a => rg.intersectsNode(a)).forEach(unwrap); tidy(); set(o); commit(o); return; }
    if (o.s !== o.e) return inline('A', href, o);
    const a = up(pointAt(o.s)[0], 'A');
    if (a) { a.setAttribute('href', href); set(o); commit(o); return; }
    const label = href.replace(/^mailto:/i, ''), end = insert(label, o);
    covered({ s: o.s, e: end }).forEach(t => wrap(t, 'A', href)); tidy(); set({ s: end, e: end }); commit(o);
  }

  /* ---- a suggestion: the text it replaces (swap) is struck through, or it goes after the selection (add). The new words
     arrive in a span the caret can't enter; Accept puts them in as plain text, Discard leaves the text as it was ---- */
  let rev: { o: Range2; kind: 'swap' | 'add'; olds: HTMLElement[]; nw: HTMLElement } | null = null;
  function suggest(o: Range2, kind: 'swap' | 'add') {
    flush(); pend = null;
    const nw = document.createElement('span'); nw.className = 'dm-rte-new'; nw.contentEditable = 'false';
    const olds: HTMLElement[] = [];
    if (kind === 'swap') {
      const all = lines(), a = lineAt(o.s), z = lineAt(o.e);
      for (let i = z; i >= a; i--) {
        const s = Math.max(o.s, startOf(i)), e = Math.min(o.e, startOf(i) + len(all[i])); if (e <= s) continue;
        const rg = range({ s, e }), sp = document.createElement('span'); sp.className = 'dm-rte-old';
        sp.appendChild(rg.extractContents()); rg.insertNode(sp); olds.unshift(sp);
      }
    }
    if (olds.length) olds[olds.length - 1].after(nw); else range({ s: o.e, e: o.e }).insertNode(nw);
    rev = { o, kind, olds, nw };
    return (s: string) => { if (rev && rev.nw === nw) { const w = document.createElement('span'); w.className = 'dm-w'; w.textContent = s; nw.appendChild(w); show(w); } };
  }
  function drop() { if (!rev) return null; const r = rev; r.olds.forEach(unwrap); r.nw.remove(); rev = null; tidy(); return r; }
  function accept() {
    const s = rev ? rev.nw.textContent || '' : ''; const r = drop(); if (!r) return;
    const items = s.split(/\n+/).filter(Boolean).map(l => { const frag = document.createDocumentFragment(); frag.append(l); return { t: 'p', frag, len: l.length }; });
    const at = insertLines(items, r.kind === 'swap' ? r.o : { s: r.o.e, e: r.o.e });
    set({ s: at, e: at }); commit(r.o);
  }
  function discard() { const r = drop(); if (r) set(r.o); changed(); }

  /* ---- what the browser does, routed through the edits above ---- */
  on(ed, 'beforeinput', (e: InputEvent) => {
    const t = e.inputType;
    if (rev) { e.preventDefault(); return; }
    if (t === 'historyUndo' || t === 'historyRedo') { e.preventDefault(); if (t === 'historyUndo') undo(); else redo(); return; }
    if (t.startsWith('format')) { e.preventDefault(); if (t === 'formatBold') inline('B'); else if (t === 'formatItalic') inline('I'); return; }
    if (t === 'insertParagraph' || t === 'insertLineBreak') { e.preventDefault(); split(); return; }
    if (t === 'insertFromPaste' || t === 'insertFromDrop' || t === 'deleteByDrag' || t === 'deleteByCut') return;
    const o = get(), i = lineAt(o.s);
    if (t === 'insertText' && e.data && pend && o.s === o.e && pend.at === o.s) { e.preventDefault(); typeStyled(e.data); return; }
    if (o.s === o.e && /^delete.*Backward$/.test(t) && o.s === startOf(i)) { e.preventDefault(); flush(); joinBack(o); return; }
    if (o.s === o.e && /^delete.*Forward$/.test(t) && o.s === startOf(i) + len(lines()[i])) { e.preventDefault(); flush(); joinNext(o); return; }
    // a selection over several lines: removing it joins them, so the editor does it
    if (o.s !== o.e && i !== lineAt(o.e) && (t.startsWith('delete') || t === 'insertText' || t === 'insertReplacementText')) {
      e.preventDefault(); flush(); const p = del(o); let at = p.s;
      const s = e.data || (e.dataTransfer && e.dataTransfer.getData('text/plain')) || '';
      if (s && t.startsWith('insert')) at = insert(s.replace(/\s*\n\s*/g, ' '), p);
      set({ s: at, e: at }); commit(o); return;
    }
    if (!H.dirty) H.pre = o;
  });
  on(ed, 'input', (e: InputEvent) => { if (rev) return; if (messy()) clean(); typed(H.pre); hook.input?.(e); });
  on(ed, 'paste', (e: ClipboardEvent) => {
    e.preventDefault(); if (rev || !e.clipboardData) return;
    const h = e.clipboardData.getData('text/html'), s = e.clipboardData.getData('text/plain');
    let items = h ? parse(new DOMParser().parseFromString(h, 'text/html').body, false) : [];
    if (!items.length) items = plain(s); if (!items.length) return;
    flush(); const o = get(), at = insertLines(items, o); set({ s: at, e: at }); commit(o);
  });
  on(ed, 'cut', (e: ClipboardEvent) => {
    e.preventDefault(); if (rev || !e.clipboardData) return; const o = get(); if (o.s === o.e) return;
    const g = live(); e.clipboardData.setData('text/plain', text(o)); if (g) { const d = document.createElement('div'); d.appendChild(g.cloneContents()); e.clipboardData.setData('text/html', d.innerHTML); }
    flush(); const p = del(o); set(p); commit(o);
  });
  // a drop goes in as plain text where it lands; dragging inside the text is off, so a drag can't move half a list
  on(ed, 'dragstart', (e: DragEvent) => e.preventDefault());
  on(ed, 'drop', (e: DragEvent) => {
    e.preventDefault(); if (rev || !e.dataTransfer) return;
    const items = plain(e.dataTransfer.getData('text/plain')); if (!items.length) return;
    const doc = document as Document & { caretRangeFromPoint?: (x: number, y: number) => Range | null; caretPositionFromPoint?: (x: number, y: number) => { offsetNode: Node; offset: number } | null };
    let at: [Node, number] | null = null;
    if (doc.caretPositionFromPoint) { const pp = doc.caretPositionFromPoint(e.clientX, e.clientY); if (pp) at = [pp.offsetNode, pp.offset]; }
    else if (doc.caretRangeFromPoint) { const r = doc.caretRangeFromPoint(e.clientX, e.clientY); if (r) at = [r.startContainer, r.startOffset]; }
    if (!at || !ed.contains(at[0])) return;
    flush(); const a0 = offsetOf(at[0], at[1]), end = insertLines(items, { s: a0, e: a0 }); set({ s: end, e: end }, true); commit({ s: a0, e: a0 });
  });
  on(ed, 'keydown', (e: KeyboardEvent) => {
    if (e.isComposing || hook.key?.(e)) return;
    if (rev) return;
    const mod = isMac() ? e.metaKey && !e.ctrlKey : e.ctrlKey && !e.metaKey;
    if (!mod) return;
    const k = e.key.toLowerCase(), c = e.code;
    const act = e.altKey ? (c === 'Digit1' ? () => block('h2') : c === 'Digit0' ? () => block('p') : null)
      : e.shiftKey ? (c === 'Digit7' ? () => block('ol') : c === 'Digit8' ? () => block('ul') : c === 'Digit9' ? () => block('quote') : k === 'z' ? redo : null)
      : k === 'b' ? () => inline('B') : k === 'i' ? () => inline('I') : k === 'e' ? () => inline('CODE') : k === 'z' ? undo : k === 'y' ? redo : null;
    if (act) { e.preventDefault(); e.stopPropagation(); act(); }
  });
  on(document, 'selectionchange', () => {
    if (!live() || rev) return;
    caret = get(); if (pend && (caret.s !== pend.at || caret.e !== pend.at)) pend = null;
    hook.select?.();
  });

  tidy(); H.list = [{ html: ed.innerHTML, sel: { s: 0, e: 0 }, pre: null }];
  return {
    get, set, text, range, total, load, undo, redo, inline, block, split, link, suggest, accept, discard, flush,
    focus: () => set(get(), true),
    html: () => ed.innerHTML,
    reviewing: () => !!rev,
    canUndo: () => H.at > 0 || H.dirty,
    canRedo: () => H.at < H.list.length - 1,
    empty: () => { const all = lines(); return all.length === 1 && !len(all[0]) && typeOf(all[0]) === 'p'; },
    words: () => { const s = lines().map(b => b.textContent).join(' ').trim(); return s ? s.split(/\s+/).length : 0; },
    // the line around a spot, as a selection
    line: (at: number): Range2 => { const i = lineAt(at), s = startOf(i); return { s, e: s + len(lines()[i]) }; },
    // what the selection or the caret has: bold, italic, code, a link (and its address), and the lines' type when they share one
    state() {
      const o = get(), all = lines(), ts = all.slice(lineAt(o.s), lineAt(o.e) + 1).map(typeOf), g = live();
      const a = up(o.s === o.e && g ? g.startContainer : pointAt(o.s, o.s !== o.e)[0], 'A');
      return { B: has('B', o), I: has('I', o), CODE: has('CODE', o), A: o.s === o.e ? !!a : has('A', o), href: a ? a.getAttribute('href') : null, block: ts.every(t => t === ts[0]) ? ts[0] : null };
    },
    // removes some text, as one step of the history
    remove(o: Range2) { flush(); set(del(o)); commit(o); },
    // text typed at the caret, as a person would: for a story, which leaves the page's focus where it is
    type(s: string) { const o = get(); flush(); const at = insert(s, o); set({ s: at, e: at }); typed(o); show(range({ s: at, e: at })); },
    show: (o: Range2) => show(range(o)),
    destroy() { clearTimeout(H.t); offs.forEach(f => f()); }
  };
}
type Engine = ReturnType<typeof createEngine>;

/* ---------- the demo's writer: canned rewrites for the preview's draft, and simple rules for anything else ---------- */
const CANNED: Record<string, Record<string, string>> = {
  'We know pricing changes can feel like a lot, so we want to make sure everyone has plenty of time to look at the new plans and ask us questions before anything changes.':
    { shorter: "We're giving everyone time to review the new plans and ask questions before anything changes.", improve: 'Pricing changes can feel like a lot, so everyone gets plenty of time to review the new plans and ask questions before anything changes.' },
  'New pricing goes live on March 3.': { improve: 'Our new pricing goes live on March 3.', shorter: 'New pricing starts March 3.' },
  'Starter teams get two more seats for free, and every plan now includes the agent.': { improve: 'Starter teams get two extra seats at no cost, and every plan now includes the agent.', shorter: 'Starter teams get two free seats, and every plan includes the agent.' }
};
const TYPO: Record<string, string> = { teh: 'the', recieve: 'receive', seperate: 'separate', definately: 'definitely', alot: 'a lot', dont: "don't", cant: "can't", wont: "won't", im: "I'm", ive: "I've", thier: 'their', untill: 'until', occured: 'occurred', wich: 'which', becuase: 'because', youre: "you're", doesnt: "doesn't", isnt: "isn't", tommorow: 'tomorrow', accross: 'across' };
const FILLER: [RegExp, string][] = [[/\b(really|very|just|actually|basically|quite|simply|definitely|certainly|literally|totally)\s+/gi, ''], [/\bin order to\b/gi, 'to'], [/\bmake sure (that )?/gi, 'ensure '], [/\bdue to the fact that\b/gi, 'because'], [/\bat this point in time\b/gi, 'now'], [/\ba lot of\b/gi, 'many'], [/\bplenty of\b/gi, 'enough'], [/\bis able to\b/gi, 'can']];
const BETTER: [RegExp, string][] = [[/\bwe will\b/gi, "we'll"], [/\bwe are\b/gi, "we're"], [/\bit is\b/gi, "it's"], [/\bdo not\b/gi, "don't"], [/\bcannot\b/gi, "can't"], [/\bvery important\b/gi, 'essential'], [/\bgood\b/gi, 'strong'], [/\bbig\b/gi, 'major'], [/\bstuff\b/gi, 'details'], [/\bkind of\s+/gi, ''], [/\bat no extra cost\b/gi, 'free']];
/** The demo's stand-in for a model: canned rewrites for the preview's draft about a pricing launch, and simple rules (filler words
    out, common typos fixed) for anything else. Exported so you can see what the default does; pass your own `ai` in a product */
export function demoWrite(action: string, text: string, doc: string) {
  const fix = (s: string) => s.replace(/[ \t]{2,}/g, ' ').replace(/\s+([,.!?;:])/g, '$1').replace(/\bi\b/g, 'I')
    .replace(/\b[a-z]+\b/gi, w => { const f = TYPO[w.toLowerCase()]; return f ? (w[0] === w[0].toUpperCase() ? f[0].toUpperCase() + f.slice(1) : f) : w; })
    .replace(/(^|[.!?]\s+)([a-z])/g, (_, a: string, b: string) => a + b.toUpperCase()).replace(/([a-z0-9])\s*$/i, (m, a: string) => s.trim().split(/\s+/).length > 3 ? a + '.' : m);
  if (action === 'continue') {
    const next = /pric|plan|seat|launch/i.test(doc)
      ? ['Current customers keep their price until their next renewal, and every admin gets an email that sums up what changes for their team.', 'If anything is unclear, reply to this email and we will walk you through it.']
      : ['Next, we will share a short summary so everyone knows what changes and when.', 'Reply with any questions and we will get back to you within a day.'];
    return next.find(x => !doc.includes(x)) || next[next.length - 1];
  }
  if (action === 'grammar') return fix(text);
  const rules = (s: string, list: [RegExp, string][]) => list.reduce((x, [a, b]) => x.replace(a, b), s);
  // sentences end at . ! or ? before a space, so a file name like plan.yaml stays in one
  return (text.match(/[^]*?(?:[.!?]+(?=\s|$)|$)\s*/g) || [text]).filter(Boolean).map(s => {
    const k = s.trim(), hit = CANNED[k] && CANNED[k][action]; if (hit) return s.replace(k, hit);
    let x = fix(rules(s, action === 'shorter' ? FILLER : [...FILLER.slice(0, 2), ...BETTER]));
    // a long sentence that is still long keeps its main clause
    if (action === 'shorter' && x.trim().length > 60 && x.length > s.length * .85 && x.includes(', ')) x = x.slice(0, x.indexOf(', ')) + '.' + (/\s$/.test(s) ? ' ' : '');
    return x;
  }).join('');
}
/** The default `ai`: demoWrite's answer, streamed a word at a time after a short pause, as a model would */
export const demoAi: RichTextAi = async function* ({ action, text, document: doc, signal }) {
  const wait = (ms: number) => new Promise<void>(res => setTimeout(res, ms));
  const reduce = typeof matchMedia !== 'undefined' && matchMedia('(prefers-reduced-motion: reduce)').matches;
  const out = demoWrite(action, text, doc);
  if (reduce) { yield out; return; }
  await wait(450);
  for (const w of out.match(/\S+\s*|\s+/g) || []) { if (signal.aborted) return; yield w; await wait(48); }
};

const keysOf = (k: string, mac: boolean) => k === 'redo' ? (mac ? '⇧⌘Z' : 'Ctrl+Y')
  : k.split(' ').map(x => ({ mod: mac ? '⌘' : 'Ctrl', alt: mac ? '⌥' : 'Alt', shift: mac ? '⇧' : 'Shift' } as Record<string, string>)[x] || x).join(mac ? '' : '+');
const TOOLS: { f: RichTextFormat | 'a'; label: string; icon: IconName; k: string }[] = [
  { f: 'h2', label: 'Heading', icon: 'heading', k: 'mod alt 1' }, { f: 'b', label: 'Bold', icon: 'bold', k: 'mod B' }, { f: 'i', label: 'Italic', icon: 'italic', k: 'mod I' },
  { f: 'code', label: 'Inline code', icon: 'code', k: 'mod E' }, { f: 'a', label: 'Link', icon: 'link', k: 'mod K' },
  { f: 'ul', label: 'Bulleted list', icon: 'list', k: 'mod shift 8' }, { f: 'ol', label: 'Numbered list', icon: 'list-ol', k: 'mod shift 7' }, { f: 'quote', label: 'Quote', icon: 'quote', k: 'mod shift 9' }
];
// btn: opened from its toolbar button with the keyboard, so Escape gives the focus back to the button rather than the text
type Job = { o: Range2; t: Range2; slash: number | null; sel: boolean; btn?: boolean };
type Ui = { B: boolean; I: boolean; CODE: boolean; A: boolean; href: string | null; block: string | null; undo: boolean; redo: boolean; words: number; empty: boolean };

/** A minimal editor for a draft an agent helps with: a formatting toolbar, the text, and a quiet foot with the word count and undo.
    Select text and press Ask AI (or ⌘J, or type / on a new line), and the suggestion streams in beside the text, the old words
    struck through, to accept with Tab or Enter, or discard with Escape. */
export function RichTextEditor({
  value, defaultValue = '', onValueChange, onChange, placeholder = 'Write a draft, or type / on a new line for AI', label = 'Draft', hint = 'Select text to ask AI',
  ai = demoAi, aiActions = ACTIONS, onSuggestion, ref, className
}: RichTextEditorProps) {
  const uid = useId();
  const reduce = useReducedMotion();
  const root = useRef<HTMLDivElement>(null), ed = useRef<HTMLDivElement>(null), tb = useRef<HTMLDivElement>(null);
  const menuEl = useRef<HTMLDivElement>(null), askEl = useRef<HTMLButtonElement>(null), lnkEl = useRef<HTMLDivElement>(null), urlEl = useRef<HTMLInputElement>(null);
  const eng = useRef<Engine | null>(null);
  const [mac, setMac] = useState(false);
  const [ui, setUi] = useState<Ui>({ B: false, I: false, CODE: false, A: false, href: null, block: 'p', undo: false, redo: false, words: 0, empty: true });
  const [job, setJob] = useState<Job | null>(null);
  const [lk, setLk] = useState<{ o: Range2; btn?: boolean } | null>(null);
  const [url, setUrl] = useState(''), [bad, setBad] = useState(false);
  const [bubble, setBubble] = useState(false);
  const [review, setReview] = useState<{ action: string; ready: boolean } | null>(null);
  const [say, setSay] = useState('');
  const [focusTb, setFocusTb] = useState(0);
  const run = useRef<{ ctl: AbortController; action: string } | null>(null);
  const emitted = useRef<string | null>(null), quiet = useRef(false);
  // the newest props and state, for the engine's listeners, which are set up once
  const now = useRef({ onValueChange, onChange, onSuggestion, ai, aiActions, reduce, job, lk, review });
  useEffect(() => { now.current = { onValueChange, onChange, onSuggestion, ai, aiActions, reduce, job, lk, review }; });

  const announce = (t: string) => { setSay(''); setTimeout(() => setSay(t), 60); };
  // the toolbar, the word count and undo follow the text and the selection, and every change is reported
  const paint = () => {
    const E = eng.current; if (!E) return;
    const s = E.state();
    setUi({ ...s, undo: E.canUndo(), redo: E.canRedo(), words: E.words(), empty: E.empty() });
    if (!E.reviewing() && !quiet.current) { const html = E.html(); if (html !== emitted.current) { emitted.current = html; now.current.onValueChange?.(html); now.current.onChange?.(html); } }
  };

  // the first and last line boxes of a selection, or the caret's line
  const rectsOf = (o: Range2): [DOMRect, DOMRect] => {
    const E = eng.current!, rs = [...E.range(o).getClientRects()].filter(x => x.width || x.height);
    if (rs.length) return [rs[0], rs[rs.length - 1]];
    const n = E.range(E.line(o.s)).startContainer, el = (n.nodeType === 3 ? n.parentElement : n) as Element, rr = el.getBoundingClientRect();
    return [rr, rr];
  };
  // a popover sits under the end of the text it is for, or over its start when there's no room, always inside the editor; the
  // Ask AI button goes over the selection's first line
  const place = (el: HTMLElement | null, o: Range2, above = false) => {
    const box = root.current; if (!el || !box) return;
    const b = box.getBoundingClientRect(), w = el.offsetWidth, h = el.offsetHeight, top = (tb.current?.offsetHeight || 44) - 2, [f, l] = rectsOf(o);
    const over = f.top - b.top - h - 6, under = l.bottom - b.top + 6;
    const y = above ? (over >= top ? over : under) : under + h <= b.height - 4 || over < top ? under : over;
    el.style.left = Math.round(Math.max(6, Math.min((above ? f : l).left - b.left - (above ? 0 : 8), b.width - w - 6))) + 'px';
    el.style.top = Math.round(Math.max(4, Math.min(y, b.height - h - 4))) + 'px';
  };
  // the selection a menu or the link field is for stays marked while the focus is in them
  const picked = useRef<Range | null>(null);
  const pick = (o: Range2 | null) => {
    const reg = typeof CSS !== 'undefined' ? (CSS as unknown as { highlights?: Map<string, Set<Range>> }).highlights : undefined;
    const HL = typeof window !== 'undefined' ? (window as unknown as { Highlight?: new () => Set<Range> }).Highlight : undefined;
    if (!reg || !HL) return;
    let h = reg.get('rte-pick'); if (!h) { h = new HL(); reg.set('rte-pick', h); }
    if (picked.current) h.delete(picked.current);
    picked.current = o && o.s !== o.e && eng.current ? eng.current.range(o) : null;
    if (picked.current) h.add(picked.current);
  };
  const showBubble = (o?: Range2) => {
    const E = eng.current; if (!E) return;
    const g = getSelection(), sel = o || (document.activeElement === ed.current && g && g.rangeCount && !g.isCollapsed ? E.get() : null);
    const st = now.current;
    if (!sel || E.reviewing() || st.job || st.lk || !st.ai || !E.text(sel).trim()) { setBubble(false); return; }
    place(askEl.current, sel, true); setBubble(true);
  };

  /* ---- the AI menu ---- */
  const menuItems = () => [...(menuEl.current?.querySelectorAll<HTMLButtonElement>('[role="menuitem"]') || [])].filter(m => m.getAttribute('aria-disabled') !== 'true');
  const focusItem = (m?: HTMLButtonElement) => { if (!m) return; menuItems().forEach(x => { x.tabIndex = x === m ? 0 : -1; }); m.focus({ preventScroll: true }); };
  const openMenu = (how: 'key' | 'button' | 'click' | 'slash' | 'script') => {
    const E = eng.current; if (!E || E.reviewing() || !now.current.ai) return;
    closeLink(false); E.flush();
    if (now.current.job) { closeMenu(how !== 'script'); return; }
    const o = E.get(), slash = how === 'slash' ? o.s - 1 : null, sel = o.s !== o.e;
    let t = sel ? o : E.line(o.s); if (slash !== null) t = { s: slash + 1, e: t.e };
    const j = { o, t, slash, sel, btn: how === 'button' };
    now.current.job = j; setJob(j); setBubble(false); pick(sel ? o : null);
    requestAnimationFrame(() => { place(menuEl.current, o); if (how !== 'script') focusItem(menuItems()[0]); });
  };
  const closeMenu = (back: boolean) => {
    const j = now.current.job; if (!j) return;
    now.current.job = null; setJob(null); pick(null);
    if (back && j.btn) { eng.current?.set(j.o); root.current?.querySelector<HTMLElement>('.dm-rte-ai')?.focus({ preventScroll: true }); }
    else if (back) eng.current?.set(j.o, true);
  };
  // the text an action is for when it runs without the menu: the selection, or the caret's paragraph
  const here = (E: Engine): Job => { const o = E.get(), sel = o.s !== o.e; return { o, t: sel ? o : E.line(o.s), slash: null, sel }; };
  const ask = (key: string, mine: boolean) => {
    const E = eng.current, j = now.current.job || (E && here(E));
    const act = now.current.aiActions.find(a => a.key === key), writer = now.current.ai;
    if (!E || !j || !act || !writer || E.reviewing()) return;
    closeMenu(false);
    let t = j.t, end = j.o.e;
    // the / that opened the menu goes, and what comes after it moves back one
    if (j.slash !== null) { const sl = j.slash; E.remove({ s: sl, e: sl + 1 }); const back = (x: number) => x > sl ? x - 1 : x; t = { s: back(t.s), e: back(t.e) }; end = back(end); }
    const kind = act.kind || 'swap';
    if (kind === 'add') t = { s: end, e: end };
    else if (!E.text(t).trim()) return;
    const doc = E.text({ s: 0, e: E.total() }), before = E.text({ s: 0, e: t.s }), after = E.text({ s: t.e, e: E.total() }), said = E.text(t);
    const write = E.suggest(t, kind), ctl = new AbortController(), me = { ctl, action: key };
    run.current = me; let got = '', first = true;
    const put = (s: string) => { if (first && kind === 'add' && before && !/\s$/.test(before) && !/^\s/.test(s)) s = ' ' + s; first = false; got += s; write(s); };
    setReview({ action: key, ready: false }); setBubble(false); paint();
    announce(`${act.label}: writing a suggestion`);
    if (mine) E.focus();
    (async () => {
      try {
        const res = writer({ action: key, text: kind === 'add' ? '' : said, before, after, document: doc, signal: ctl.signal });
        if (typeof res === 'string' || res instanceof Promise) {
          const s = await res; if (run.current !== me) return;
          // an answer that comes all at once is still written in a word at a time
          if (now.current.reduce) put(s);
          else for (const w of s.match(/\S+\s*|\s+/g) || []) { if (run.current !== me) return; put(w); await new Promise(r => setTimeout(r, 48)); }
        } else for await (const chunk of res) { if (run.current !== me) return; put(chunk); }
      } catch (err) { if (run.current !== me) return; console.error(err); }
      if (run.current !== me) return;
      if (!got.trim()) { finish(false, true); return; }
      setReview({ action: key, ready: true });
      const s = got.trim(); announce(`Suggestion: ${s}${/[.!?]$/.test(s) ? '' : '.'} Press Tab or Enter to accept, or Escape to discard.`);
    })();
  };
  // accept keeps it; discard leaves the text as it was (and a failed or empty answer is discarded the same way)
  const finish = (keep: boolean, mine: boolean) => {
    const E = eng.current, r = run.current; if (!E || !E.reviewing() || !r) return;
    if (keep && !now.current.review?.ready) return;
    const said = (ed.current?.querySelector('.dm-rte-new')?.textContent) || '';
    run.current = null; r.ctl.abort();
    if (keep) E.accept(); else E.discard();
    setReview(null); paint(); announce(keep ? 'Suggestion accepted' : 'Suggestion discarded');
    now.current.onSuggestion?.({ action: r.action, text: said, accepted: keep });
    if (mine) E.focus();
  };

  /* ---- the link field ---- */
  const openLink = (mine: boolean, btn = false) => {
    const E = eng.current; if (!E || E.reviewing()) return;
    closeMenu(false); E.flush();
    const o = E.get(), s = E.state();
    setLk({ o, btn }); now.current.lk = { o, btn }; setUrl(s.href || ''); setBad(false); setBubble(false); pick(o);
    requestAnimationFrame(() => { place(lnkEl.current, o); if (mine) urlEl.current?.focus({ preventScroll: true }); });
  };
  const closeLink = (back: boolean) => {
    const j = now.current.lk; if (!j) return;
    now.current.lk = null; setLk(null); pick(null);
    if (back && j.btn) { eng.current?.set(j.o); tb.current?.querySelector<HTMLElement>('[data-f="a"]')?.focus({ preventScroll: true }); }
    else if (back) eng.current?.set(j.o, true);
  };
  const toUrl = (v: string) => { v = v.trim(); if (!v) return null; if (/^(https?:\/\/|mailto:)/i.test(v)) return v; if (/^[^\s@/]+@[^\s@]+\.[^\s@]+$/.test(v)) return 'mailto:' + v; if (/^[\w-]+(\.[\w-]+)+(:\d+)?([/?#]\S*)?$/.test(v)) return 'https://' + v; return null; };
  const applyLink = (href: string | null) => {
    const E = eng.current, j = now.current.lk; if (!E || !j) return;
    if (href === '') { const h = toUrl(url); if (!h) { setBad(true); urlEl.current?.focus({ preventScroll: true }); return; } href = h; }
    closeLink(false); E.set(j.o); E.link(j.o, href); E.focus();
  };

  // text given from outside goes in without being reported back
  const load = (html: string) => { const E = eng.current; if (!E) return; quiet.current = true; E.load(html); quiet.current = false; emitted.current = E.html(); };

  // the engine starts once, with the text it is given
  useEffect(() => {
    const el = ed.current!; setMac(isMac());
    const E = createEngine(el, {
      change: paint,
      select: () => { paint(); showBubble(); },
      // a / typed at the start of a line opens the menu
      input: e => { if (e.inputType === 'insertText' && e.data === '/') { const o = E.get(); if (o.s === o.e && E.line(o.s).s === o.s - 1) openMenu('slash'); } },
      key: e => {
        if (E.reviewing()) {
          if ((e.key === 'Tab' || e.key === 'Enter') && !e.shiftKey && !e.altKey && !e.metaKey && !e.ctrlKey) { e.preventDefault(); finish(true, true); return true; }
          if (e.key === 'Escape' || ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'z')) { e.preventDefault(); e.stopPropagation(); finish(false, true); return true; }
          return false;
        }
        const mod = isMac() ? e.metaKey && !e.ctrlKey : e.ctrlKey && !e.metaKey, k = e.key.toLowerCase();
        if (mod && !e.altKey && !e.shiftKey && (k === 'k' || (k === 'j' && now.current.ai))) { e.preventDefault(); e.stopPropagation(); if (k === 'k') openLink(true); else openMenu('key'); return true; }
        if (e.key === 'Escape') { setBubble(false); return false; }
        return false;
      }
    });
    eng.current = E;
    load(value ?? defaultValue);
    const down = (e: PointerEvent) => {
      const t = e.target as Node;
      if (now.current.job && !menuEl.current?.contains(t) && !askEl.current?.contains(t) && !(t as Element).closest?.('.dm-rte-ai')) closeMenu(false);
      if (now.current.lk && !lnkEl.current?.contains(t) && !(t as Element).closest?.('[data-f="a"]')) closeLink(false);
      if (e.isTrusted && !askEl.current?.contains(t) && !now.current.job) { setBubble(false); if (!root.current?.contains(t)) pick(null); }
    };
    const resize = () => { closeMenu(false); closeLink(false); setBubble(false); };
    document.addEventListener('pointerdown', down); addEventListener('resize', resize);
    return () => { run.current?.ctl.abort(); run.current = null; pick(null); E.destroy(); eng.current = null; document.removeEventListener('pointerdown', down); removeEventListener('resize', resize); };
  }, []); // eslint-disable-line react-hooks/exhaustive-deps
  // a new value from outside replaces the text; the value the editor itself just reported is already there
  useEffect(() => {
    const E = eng.current; if (!E || value === undefined || value === emitted.current) return;
    if (E.reviewing()) { run.current?.ctl.abort(); run.current = null; setReview(null); }
    load(value);
  }, [value]);

  useImperativeHandle(ref, () => ({
    focus: () => eng.current?.focus(),
    getHTML: () => eng.current?.html() || '',
    getText: () => { const E = eng.current; return E ? E.text({ s: 0, e: E.total() }) : ''; },
    setHTML: html => load(html),
    select: s => {
      const E = eng.current; if (!E) return false;
      const at = E.text({ s: 0, e: E.total() }).indexOf(s); if (at < 0 || !s) return false;
      const o = { s: at, e: at + s.length }; E.set(o); E.show(o); pick(o); showBubble(o); paint(); return true;
    },
    type: s => eng.current?.type(s),
    newLine: () => eng.current?.split(),
    format: f => { const E = eng.current; if (!E || E.reviewing()) return; if (f === 'b' || f === 'i' || f === 'code') E.inline(f.toUpperCase()); else E.block(f); },
    openAi: () => openMenu('script'),
    ask: a => ask(a, false),
    accept: () => finish(true, false),
    discard: () => finish(false, false),
    undo: () => eng.current?.undo(),
    redo: () => eng.current?.redo()
  }));

  const busy = !!review;
  const items = aiActions.map(a => ({ ...a, off: (a.kind || 'swap') === 'swap' && !!job && !(eng.current?.text(job.t).trim()) }));
  const tools = TOOLS.map(t => ({ ...t, on: t.f === 'a' ? ui.A : /^(h2|ul|ol|quote)$/.test(t.f) ? ui.block === t.f : !!ui[t.f.toUpperCase() as 'B' | 'I' | 'CODE'] }));
  const tbButtons = () => [...(tb.current?.querySelectorAll<HTMLButtonElement>('button:not(:disabled)') || [])];
  // the toolbar is one tab stop; the arrow keys move along it, Home and End jump to its ends
  const tbKey = (e: ReactKeyboardEvent) => {
    const all = tbButtons(), i = all.indexOf(document.activeElement as HTMLButtonElement); if (i < 0) return;
    const go = ({ ArrowRight: all[(i + 1) % all.length], ArrowLeft: all[(i - 1 + all.length) % all.length], Home: all[0], End: all[all.length - 1] } as Record<string, HTMLButtonElement | undefined>)[e.key];
    if (go) { e.preventDefault(); go.focus(); }
  };
  const apply = (f: RichTextFormat | 'a', mine: boolean, key = false) => {
    const E = eng.current; if (!E || E.reviewing()) return;
    if (f === 'a') { if (now.current.lk) closeLink(true); else openLink(mine, mine && key); return; }
    if (f === 'b' || f === 'i' || f === 'code') E.inline(f.toUpperCase()); else E.block(f);
    if (mine) E.focus();
  };
  const menuKey = (e: ReactKeyboardEvent) => {
    const all = menuItems(), i = all.indexOf(document.activeElement as HTMLButtonElement);
    const go = ({ ArrowDown: all[(i + 1) % all.length], ArrowUp: all[(i - 1 + all.length) % all.length], Home: all[0], End: all[all.length - 1] } as Record<string, HTMLButtonElement | undefined>)[e.key];
    if (go) { e.preventDefault(); focusItem(go); }
    else if (e.key === 'Escape' || e.key === 'Tab') { e.preventDefault(); e.stopPropagation(); closeMenu(true); }
    else if (e.key.length === 1 && /\S/.test(e.key) && !e.metaKey && !e.ctrlKey) { const k = e.key.toLowerCase(), hit = [...all.slice(i + 1), ...all.slice(0, i + 1)].find(m => (m.textContent || '').trim().toLowerCase().startsWith(k)); if (hit) focusItem(hit); }
  };
  const keep = (e: { preventDefault: () => void; target: EventTarget }) => { if ((e.target as Element).closest('button')) e.preventDefault(); };
  const act = review && aiActions.find(a => a.key === review.action);

  return (
    <div data-dm="" ref={root} className={cx('dm-rte', className)} data-st={busy ? 'review' : 'edit'}
      onKeyDown={e => { if (e.key === 'Escape' && busy && !ed.current?.contains(e.target as Node)) { e.preventDefault(); e.stopPropagation(); finish(false, true); } }}
      onPointerDownCapture={e => { if (e.isTrusted && !now.current.job) pick(null); }}>
      <div ref={tb} className="dm-rte-tb" role="toolbar" aria-label="Formatting" onMouseDown={keep} onKeyDown={tbKey}
        onFocus={e => { const all: Element[] = [...(tb.current?.querySelectorAll('button') || [])]; const i = all.indexOf(e.target); if (i >= 0) setFocusTb(i); }}>
        {tools.map((t, i) => (
          <span key={t.f} style={{ display: 'contents' }}>
            {i === 5 && <span className="dm-rte-sep" aria-hidden="true" />}
            <Tip label={t.label} kbd={keysOf(t.k, mac)}>
              <button type="button" className={cx('dm-rte-b', t.f === 'a' && t.on && !busy && 'dm-on')} data-f={t.f} aria-label={t.label} disabled={busy} tabIndex={focusTb === i ? 0 : -1}
                {...(t.f === 'a' ? { 'aria-haspopup': 'dialog' as const, 'aria-expanded': !!lk } : { 'aria-pressed': !busy && t.on })}
                onClick={e => apply(t.f, e.isTrusted, e.detail === 0)}><Icon name={t.icon} size="m" /></button>
            </Tip>
          </span>
        ))}
        <span className="dm-sp" />
        {ai && (
          <Tip label="Edit or write with AI" kbd={keysOf('mod J', mac)} off={!!job}>
            <button type="button" className="dm-rte-ai" aria-label="Ask AI" aria-haspopup="menu" aria-expanded={!!job} disabled={busy} tabIndex={focusTb === TOOLS.length ? 0 : -1}
              onClick={e => openMenu(e.isTrusted ? (e.detail ? 'click' : 'button') : 'script')}><Icon name="spark" /><span>Ask AI</span></button>
          </Tip>
        )}
      </div>
      <div ref={ed} className={cx('dm-rte-body', ui.empty && !busy && 'dm-empty')} contentEditable suppressContentEditableWarning role="textbox" aria-multiline="true"
        aria-label={label} aria-placeholder={placeholder} data-placeholder={placeholder} spellCheck />
      <div className="dm-rte-ft">
        <div className="dm-rte-idle" onMouseDown={keep}>
          <span className="dm-rte-wc dm-num">{ui.words === 1 ? '1 word' : ui.words + ' words'}</span>
          {ai && hint && <span className="dm-rte-hint">{hint}</span>}
          <span className="dm-sp" />
          <Tip label="Undo" kbd={keysOf('mod Z', mac)}><button type="button" className="dm-ib dm-rte-un" aria-label="Undo" disabled={busy || !ui.undo} onClick={e => { eng.current?.undo(); if (e.isTrusted) eng.current?.focus(); }}><Icon name="undo" /></button></Tip>
          <Tip label="Redo" kbd={keysOf('redo', mac)}><button type="button" className="dm-ib dm-rte-re" aria-label="Redo" disabled={busy || !ui.redo} onClick={e => { eng.current?.redo(); if (e.isTrusted) eng.current?.focus(); }}><Icon name="redo" /></button></Tip>
        </div>
        <div className="dm-rte-rev" onMouseDown={keep}>
          <span className="dm-rte-st">
            {review && !review.ready ? <><span className="dm-think dm-on" aria-hidden="true"><i /><i /><i /><i /></span><span className="dm-shim">{act?.label}</span></>
              : review ? <><Icon name="spark" /><span>Suggestion ready</span></> : null}
          </span>
          <span className="dm-sp" />
          <button type="button" className="dm-pill dm-ghost dm-sm dm-rte-no" onClick={e => finish(false, e.isTrusted)}>Discard<span className="dm-kbd">Esc</span></button>
          <button type="button" className="dm-pill dm-accent dm-sm dm-rte-ok" disabled={!review?.ready} onClick={e => finish(true, e.isTrusted)}>Accept<span className="dm-kbd">Tab</span></button>
        </div>
      </div>
      {ai && (
        <button ref={askEl} type="button" className={cx('dm-rte-ask', bubble && 'dm-show')} tabIndex={-1} aria-label="Ask AI about the selection"
          onMouseDown={e => e.preventDefault()} onClick={e => openMenu(e.isTrusted ? 'click' : 'script')}><Icon name="spark" /><span>Ask AI</span></button>
      )}
      {ai && (
        <div ref={menuEl} className={cx('dm-pop dm-rte-menu', job && 'dm-open')} role="menu" aria-labelledby={uid + '-ph'} inert={!job}
          onKeyDown={menuKey} onMouseDown={e => e.preventDefault()}>
          <div className="dm-ph" id={uid + '-ph'}>{job?.sel ? 'Edit the selection' : items.some(x => !x.off && (x.kind || 'swap') === 'swap') ? 'Edit this paragraph' : 'Write with AI'}</div>
          {items.map(a => (
            <button key={a.key} type="button" className="dm-mi" role="menuitem" tabIndex={-1} aria-disabled={a.off}
              onClick={e => { if (!a.off) ask(a.key, e.isTrusted); }}>{a.icon && <Icon name={a.icon} />}<span>{a.label}</span></button>
          ))}
        </div>
      )}
      <div ref={lnkEl} className={cx('dm-pop dm-rte-lnk', lk && 'dm-open')} role="dialog" aria-label="Link" inert={!lk}
        onKeyDown={e => { if (e.key === 'Enter' && e.target === urlEl.current) { e.preventDefault(); applyLink(''); } else if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); closeLink(true); } }}
        onBlur={e => { if (now.current.lk && e.relatedTarget && !lnkEl.current?.contains(e.relatedTarget as Node)) closeLink(false); }}>
        <input ref={urlEl} className="dm-rte-url" type="url" placeholder="Paste or type a link" aria-label="Link address" autoComplete="off" spellCheck={false}
          value={url} aria-invalid={bad || undefined} onChange={e => { setUrl(e.target.value); setBad(false); }} />
        <button type="button" className="dm-pill dm-accent dm-sm dm-rte-lok" onClick={() => applyLink('')}>Apply</button>
        {ui.href && lk && <Tip label="Remove link"><button type="button" className="dm-ib dm-rte-lrm" aria-label="Remove link" onClick={() => applyLink(null)}><Icon name="trash" /></button></Tip>}
      </div>
      <p className="dm-rte-live" aria-live="polite">{say}</p>
    </div>
  );
}
rich-text-editor.cssReact · 92 lines
/* Generated from designme.dev by scripts/sync.mjs. Edit the site, then sync. */
/* ---------- Rich text editor ---------- */
/* a minimal editor for a draft an agent helps with: a formatting toolbar, the text, and a quiet foot with the word count and
   undo. Select text and press Ask AI (or the shortcut, or type / on a new line), and the suggestion streams in beside the
   text, the old words struck through, to accept with Tab or discard with Esc. The editor keeps one height, so it sits still */
.dm-rte{position:relative;width:100%;display:flex;flex-direction:column;background:var(--dm-surface);border-radius:var(--dm-r-card);box-shadow:var(--dm-shadow);transition:box-shadow .32s;container:rte/inline-size;
  /* its quiet text (--rte-q: the word count, the placeholder, struck words) and secondary text (--rte-q2: the status, Discard,
     quotes). In dark mode the shared greys reach 4.5:1 but not APCA Lc 60 at these sizes, so they come up toward the text colour */
  --dm-rte-q:var(--dm-muted);--dm-rte-q2:var(--dm-fg2)}
@media (prefers-color-scheme:dark){:root:not([data-theme="light"]) .dm-rte{--dm-rte-q:color-mix(in srgb,var(--dm-fg) 74%,var(--dm-surface));--dm-rte-q2:color-mix(in srgb,var(--dm-fg) 80%,var(--dm-surface))}}
:root[data-theme="dark"] .dm-rte{--dm-rte-q:color-mix(in srgb,var(--dm-fg) 74%,var(--dm-surface));--dm-rte-q2:color-mix(in srgb,var(--dm-fg) 80%,var(--dm-surface))}
.dm-rte-tb{display:flex;align-items:center;gap:2px;min-width:0;padding:7px var(--dm-s-2);box-shadow:inset 0 -1px 0 color-mix(in srgb,var(--dm-line) 50%,transparent)}
.dm-rte-b{width:30px;height:30px;flex:none;display:grid;place-items:center;border-radius:var(--dm-r-sm);color:var(--dm-fg2);transition:background var(--dm-t-fast),color var(--dm-t-fast)}
@media (hover:hover) and (pointer:fine){.dm-rte-b:hover{background:var(--dm-row);color:var(--dm-fg)}}
/* a format the selection has: the accent's tint, with the icon in the accent's text colour */
.dm-rte-b:is([aria-pressed="true"],.dm-on){background:var(--dm-accent-bg);color:var(--dm-accent-fg)}
.dm-rte-b:disabled,.dm-rte-ai:disabled{opacity:1;cursor:not-allowed;background:none;color:var(--dm-dis)}
.dm-rte-sep{width:1px;height:16px;margin:0 5px;flex:none;background:var(--dm-line)}
.dm-rte-ai{display:inline-flex;align-items:center;gap:6px;height:30px;padding:0 11px 0 9px;flex:none;border-radius:var(--dm-r-pill);font-size:13px;color:var(--dm-fg);transition:background var(--dm-t-fast),color var(--dm-t-fast)}
.dm-rte-ai .dm-i{color:var(--dm-accent-fg)}
.dm-rte-ai[aria-expanded="true"]{background:var(--dm-row)}
@media (hover:hover) and (pointer:fine){.dm-rte-ai:hover{background:var(--dm-row)}}
.dm-rte-ai:disabled .dm-i{color:inherit}
/* the text: one quiet size, a heading set apart by weight and room rather than size, and nothing heavier than medium */
.dm-rte-body{position:relative;height:256px;overflow-y:auto;padding:var(--dm-s-4) 18px;outline:0;font-size:14px;line-height:1.65;color:var(--dm-fg);white-space:pre-wrap;overflow-wrap:break-word;caret-color:var(--dm-accent-fg);cursor:text}
.dm-rte-body>*{margin:0;padding:0}
.dm-rte-body>*+*{margin-top:6px}
.dm-rte-body>*+h2{margin-top:14px}
.dm-rte-body h2{font-size:14px;font-weight:500;line-height:1.5;color:var(--dm-fg)}
.dm-rte-body :is(b,strong){font-weight:500}
.dm-rte-body i{font-style:italic}
.dm-rte-body a{color:var(--dm-accent-fg);text-decoration:underline;text-decoration-color:var(--dm-accent-mark);text-underline-offset:2px;cursor:text}
.dm-rte-body code{font-family:var(--dm-mono);font-size:12.5px;padding:1px var(--dm-s-1);border-radius:5px;background:var(--dm-chip);color:var(--dm-fg)}
.dm-rte-body :is(ul,ol){padding-left:22px}
.dm-rte-body ul{list-style:disc}
.dm-rte-body ol{list-style:decimal}
.dm-rte-body li{padding-left:2px}
.dm-rte-body li+li{margin-top:2px}
.dm-rte-body li::marker{color:var(--dm-rte-q2)}
.dm-rte-body blockquote{padding-left:14px;box-shadow:inset 2px 0 0 var(--dm-rte-q);color:var(--dm-rte-q2)}
/* quote lines one after another read as one quote */
.dm-rte-body blockquote+blockquote{margin-top:0;padding-top:6px}
.dm-rte-body.dm-empty::before{content:attr(data-placeholder);position:absolute;left:18px;right:18px;top:16px;color:var(--dm-rte-q);pointer-events:none}
/* the suggestion: the words it replaces struck through, the new ones in the accent's tint as they arrive */
.dm-rte-old{color:var(--dm-rte-q);text-decoration:line-through;text-decoration-color:var(--dm-rte-q)}
.dm-rte-new{color:var(--dm-accent-fg);background:var(--dm-sel);border-radius:3px}
.dm-rte-old+.dm-rte-new{margin-left:.3em}
.dm-rte-new .dm-w{animation:dm-rtew .4s var(--dm-ease) both}
@keyframes dm-rtew{from{opacity:0;filter:blur(3px)}to{opacity:1;filter:none}}
/* the text a menu or the link field is for stays marked while the focus is in them */
::highlight(rte-pick){background-color:color-mix(in srgb,var(--dm-accent-mark) 24%,transparent)}
.dm-rte-ft{display:grid;align-items:center;min-height:46px;padding:6px var(--dm-s-2) 6px 18px;box-shadow:inset 0 1px 0 color-mix(in srgb,var(--dm-line) 50%,transparent)}
.dm-rte-idle,.dm-rte-rev{grid-area:1/1;display:flex;align-items:center;gap:var(--dm-s-2);min-width:0;transition:opacity .25s var(--dm-ease),transform .35s var(--dm-ease),visibility 0s}
.dm-rte-rev,.dm-rte[data-st="review"] .dm-rte-idle{opacity:0;visibility:hidden;pointer-events:none;transition:opacity .25s var(--dm-ease),transform .35s var(--dm-ease),visibility 0s .35s}
.dm-rte-rev{transform:translateY(4px)}
.dm-rte[data-st="review"] .dm-rte-idle{transform:translateY(-4px)}
.dm-rte[data-st="review"] .dm-rte-rev{opacity:1;visibility:visible;pointer-events:auto;transform:none;transition-delay:0s}
.dm-rte-wc,.dm-rte-hint{font-size:12px;color:var(--dm-rte-q);white-space:nowrap}
.dm-rte-hint{min-width:0;overflow:hidden;text-overflow:ellipsis}
.dm-rte-hint::before{content:"·";margin-right:var(--dm-s-2)}
.dm-rte-idle .dm-ib{width:28px;height:28px;flex:none}
.dm-rte-idle .dm-ib:disabled{opacity:1;cursor:not-allowed;background:none;color:var(--dm-dis)}
.dm-rte-st{display:flex;align-items:center;gap:7px;flex:0 1 auto;min-width:0;font-size:13px;color:var(--dm-rte-q2);white-space:nowrap}
.dm-rte-st>span:last-child{min-width:0;overflow:hidden;text-overflow:ellipsis}
.dm-rte-st .dm-i{color:var(--dm-accent-fg)}
.dm-rte-rev .dm-pill{flex:none}
.dm-rte-rev .dm-pill.dm-ghost,.dm-rte-rev .dm-kbd,.dm-rte-menu .dm-ph{color:var(--dm-rte-q2)}
@media (hover:hover) and (pointer:fine){.dm-rte-rev .dm-pill.dm-ghost:hover{color:var(--dm-fg)}}
.dm-rte-menu{width:228px}
.dm-rte-menu .dm-mi[aria-disabled="true"],.dm-rte-menu .dm-mi[aria-disabled="true"] .dm-i{opacity:1;cursor:not-allowed;background:none;color:var(--dm-dis)}
.dm-rte-menu .dm-mi:focus-visible{outline-offset:-2px;background:var(--dm-row)}
.dm-rte-lnk{display:flex;align-items:center;gap:6px;width:304px;max-width:calc(100% - 12px);min-width:0;padding:6px}
.dm-rte-url{flex:1;min-width:0;height:32px;padding:0 10px;border:0;border-radius:var(--dm-r-field);outline:0;background:var(--dm-surface);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--dm-field-line) 50%,transparent);color:var(--dm-fg);font-size:13px}
@supports (corner-shape:squircle){.dm-rte-url{corner-shape:squircle;border-radius:var(--dm-r-field-sq)}}
.dm-rte-url::placeholder{color:var(--dm-rte-q)}
.dm-rte-url:focus{box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--dm-field-line-strong) 50%,transparent)}
.dm-rte-url[aria-invalid="true"]{box-shadow:inset 0 0 0 1px var(--dm-bad)}
.dm-rte-lnk .dm-pill{flex:none}
.dm-rte-lnk .dm-ib{width:30px;height:30px;flex:none}
/* the small Ask AI button over a selection, for the mouse; the keyboard has the toolbar's button and its shortcut */
.dm-rte-ask{position:absolute;left:0;top:0;z-index:6;display:inline-flex;align-items:center;gap:6px;height:28px;padding:0 11px 0 9px;border-radius:var(--dm-r-pill);background:var(--dm-raised);box-shadow:var(--dm-shadow-pop);font-size:12.5px;color:var(--dm-fg);white-space:nowrap;opacity:0;visibility:hidden;transform:translateY(4px);transition:opacity .18s var(--dm-ease),transform .3s var(--dm-ease),visibility 0s .3s}
.dm-rte-ask .dm-i{color:var(--dm-accent-fg)}
@media (hover:hover) and (pointer:fine){.dm-rte-ask:hover{background:color-mix(in srgb,var(--dm-fg) 4%,var(--dm-raised))}}
.dm-rte-ask.dm-show{opacity:1;visibility:visible;transform:none;transition-delay:0s}
.dm-rte-live{position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip-path:inset(50%);white-space:nowrap}
@container rte (max-width:520px){.dm-rte-hint{display:none}}
@container rte (max-width:400px){.dm-rte-ai{width:30px;padding:0;justify-content:center}.dm-rte-ai span{display:none}.dm-rte-sep{margin:0 2px}.dm-rte-body{padding:14px}.dm-rte-body.dm-empty::before{left:14px;right:14px;top:14px}.dm-rte-ft{padding-left:14px}.dm-rte-rev .dm-kbd{display:none}}
@container rte (max-width:330px){.dm-rte-tb{gap:0;padding:7px var(--dm-s-1)}.dm-rte-b{width:28px}}
:root:not([data-style="soft"]) .dm-rte-b:is([aria-pressed="true"],.dm-on):not(:where([data-style="soft"],[data-style="soft"] *)){box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--dm-accent-mark) 50%,transparent)}
/* Blueprint's lime tint is faint on white, so the suggestion also gets a hairline of the accent's mark under it */
:root:not([data-style="soft"]) .dm-rte-new:not(:where([data-style="soft"],[data-style="soft"] *)){background:var(--dm-accent-bg);box-shadow:inset 0 -1px 0 var(--dm-accent-mark)}
rich-text-editor.htmlHTML · 39 lines
<div class="card wide" data-c="rich-text-editor">
  <div class="stage-in">
    <div class="rte" data-st="edit">
      <div class="rte-tb" role="toolbar" aria-label="Formatting">
        <button class="rte-b" data-f="h2" aria-pressed="false" aria-label="Heading" data-tip="Heading" data-k="mod alt 1"><svg class="i "><use href="#i-heading"/></svg></button>
        <button class="rte-b" data-f="b" aria-pressed="false" aria-label="Bold" data-tip="Bold" data-k="mod B"><svg class="i "><use href="#i-bold"/></svg></button>
        <button class="rte-b" data-f="i" aria-pressed="false" aria-label="Italic" data-tip="Italic" data-k="mod I"><svg class="i "><use href="#i-italic"/></svg></button>
        <button class="rte-b" data-f="code" aria-pressed="false" aria-label="Inline code" data-tip="Inline code" data-k="mod E"><svg class="i "><use href="#i-code"/></svg></button>
        <button class="rte-b" data-f="a" aria-haspopup="dialog" aria-expanded="false" aria-label="Link" data-tip="Link" data-k="mod K"><svg class="i "><use href="#i-link"/></svg></button>
        <span class="rte-sep" aria-hidden="true"></span>
        <button class="rte-b" data-f="ul" aria-pressed="false" aria-label="Bulleted list" data-tip="Bulleted list" data-k="mod shift 8"><svg class="i "><use href="#i-list"/></svg></button>
        <button class="rte-b" data-f="ol" aria-pressed="false" aria-label="Numbered list" data-tip="Numbered list" data-k="mod shift 7"><svg class="i "><use href="#i-list-ol"/></svg></button>
        <button class="rte-b" data-f="quote" aria-pressed="false" aria-label="Quote" data-tip="Quote" data-k="mod shift 9"><svg class="i "><use href="#i-quote"/></svg></button>
        <span class="sp"></span>
        <button class="rte-ai" aria-haspopup="menu" aria-expanded="false" aria-label="Ask AI" data-tip="Edit or write with AI" data-k="mod J"><svg class="i s"><use href="#i-spark"/></svg><span>Ask AI</span></button>
      </div>
      <div class="rte-body empty" contenteditable="true" role="textbox" aria-multiline="true" aria-label="Draft" aria-placeholder="Write a draft, or type / on a new line for AI" data-placeholder="Write a draft, or type / on a new line for AI" spellcheck="true"><p><br></p></div>
      <div class="rte-ft">
        <div class="rte-idle"><span class="rte-wc num">0 words</span><span class="rte-hint">Select text to ask AI</span><span class="sp"></span><button class="ib rte-un" aria-label="Undo" data-tip="Undo" data-k="mod Z" disabled><svg class="i s"><use href="#i-undo"/></svg></button><button class="ib rte-re" aria-label="Redo" data-tip="Redo" data-k="redo" disabled><svg class="i s"><use href="#i-redo"/></svg></button></div>
        <div class="rte-rev"><span class="rte-st"></span><span class="sp"></span><button class="pill ghost sm rte-no">Discard<span class="kbd">Esc</span></button><button class="pill accent sm rte-ok" disabled>Accept<span class="kbd">Tab</span></button></div>
      </div>
      <button class="rte-ask" tabindex="-1" aria-label="Ask AI about the selection"><svg class="i s"><use href="#i-spark"/></svg><span>Ask AI</span></button>
      <div class="pop rte-menu" role="menu">
        <div class="ph">Edit the selection</div>
        <button class="mi" role="menuitem" tabindex="-1" data-a="improve"><svg class="i s"><use href="#i-wand"/></svg><span>Improve writing</span></button>
        <button class="mi" role="menuitem" tabindex="-1" data-a="shorter"><svg class="i s"><use href="#i-minus"/></svg><span>Make shorter</span></button>
        <button class="mi" role="menuitem" tabindex="-1" data-a="grammar"><svg class="i s"><use href="#i-check"/></svg><span>Fix grammar</span></button>
        <button class="mi" role="menuitem" tabindex="-1" data-a="continue"><svg class="i s"><use href="#i-edit"/></svg><span>Continue writing</span></button>
      </div>
      <div class="pop rte-lnk" role="dialog" aria-label="Link">
        <input class="rte-url" type="url" placeholder="Paste or type a link" aria-label="Link address" autocomplete="off" spellcheck="false">
        <button class="pill accent sm rte-lok">Apply</button>
        <button class="ib rte-lrm" aria-label="Remove link" data-tip="Remove link"><svg class="i s"><use href="#i-trash"/></svg></button>
      </div>
      <p class="rte-live" aria-live="polite"></p>
    </div>
  </div>
</div>
rich-text-editor.cssHTML · 91 lines
/* ---------- Rich text editor ---------- */
/* a minimal editor for a draft an agent helps with: a formatting toolbar, the text, and a quiet foot with the word count and
   undo. Select text and press Ask AI (or the shortcut, or type / on a new line), and the suggestion streams in beside the
   text, the old words struck through, to accept with Tab or discard with Esc. The editor keeps one height, so it sits still */
.rte{position:relative;width:100%;display:flex;flex-direction:column;background:var(--surface);border-radius:var(--r-card);box-shadow:var(--shadow);transition:box-shadow .32s;container:rte/inline-size;
  /* its quiet text (--rte-q: the word count, the placeholder, struck words) and secondary text (--rte-q2: the status, Discard,
     quotes). In dark mode the shared greys reach 4.5:1 but not APCA Lc 60 at these sizes, so they come up toward the text colour */
  --rte-q:var(--muted);--rte-q2:var(--fg2)}
@media (prefers-color-scheme:dark){:root:not([data-theme="light"]) .rte{--rte-q:color-mix(in srgb,var(--fg) 74%,var(--surface));--rte-q2:color-mix(in srgb,var(--fg) 80%,var(--surface))}}
:root[data-theme="dark"] .rte{--rte-q:color-mix(in srgb,var(--fg) 74%,var(--surface));--rte-q2:color-mix(in srgb,var(--fg) 80%,var(--surface))}
.rte-tb{display:flex;align-items:center;gap:2px;min-width:0;padding:7px var(--s-2);box-shadow:inset 0 -1px 0 color-mix(in srgb,var(--line) 50%,transparent)}
.rte-b{width:30px;height:30px;flex:none;display:grid;place-items:center;border-radius:var(--r-sm);color:var(--fg2);transition:background var(--t-fast),color var(--t-fast)}
@media (hover:hover) and (pointer:fine){.rte-b:hover{background:var(--row);color:var(--fg)}}
/* a format the selection has: the accent's tint, with the icon in the accent's text colour */
.rte-b:is([aria-pressed="true"],.on){background:var(--accent-bg);color:var(--accent-fg)}
.rte-b:disabled,.rte-ai:disabled{opacity:1;cursor:not-allowed;background:none;color:var(--dis)}
.rte-sep{width:1px;height:16px;margin:0 5px;flex:none;background:var(--line)}
.rte-ai{display:inline-flex;align-items:center;gap:6px;height:30px;padding:0 11px 0 9px;flex:none;border-radius:var(--r-pill);font-size:13px;color:var(--fg);transition:background var(--t-fast),color var(--t-fast)}
.rte-ai .i{color:var(--accent-fg)}
.rte-ai[aria-expanded="true"]{background:var(--row)}
@media (hover:hover) and (pointer:fine){.rte-ai:hover{background:var(--row)}}
.rte-ai:disabled .i{color:inherit}
/* the text: one quiet size, a heading set apart by weight and room rather than size, and nothing heavier than medium */
.rte-body{position:relative;height:256px;overflow-y:auto;padding:var(--s-4) 18px;outline:0;font-size:14px;line-height:1.65;color:var(--fg);white-space:pre-wrap;overflow-wrap:break-word;caret-color:var(--accent-fg);cursor:text}
.rte-body>*{margin:0;padding:0}
.rte-body>*+*{margin-top:6px}
.rte-body>*+h2{margin-top:14px}
.rte-body h2{font-size:14px;font-weight:500;line-height:1.5;color:var(--fg)}
.rte-body :is(b,strong){font-weight:500}
.rte-body i{font-style:italic}
.rte-body a{color:var(--accent-fg);text-decoration:underline;text-decoration-color:var(--accent-mark);text-underline-offset:2px;cursor:text}
.rte-body code{font-family:var(--mono);font-size:12.5px;padding:1px var(--s-1);border-radius:5px;background:var(--chip);color:var(--fg)}
.rte-body :is(ul,ol){padding-left:22px}
.rte-body ul{list-style:disc}
.rte-body ol{list-style:decimal}
.rte-body li{padding-left:2px}
.rte-body li+li{margin-top:2px}
.rte-body li::marker{color:var(--rte-q2)}
.rte-body blockquote{padding-left:14px;box-shadow:inset 2px 0 0 var(--rte-q);color:var(--rte-q2)}
/* quote lines one after another read as one quote */
.rte-body blockquote+blockquote{margin-top:0;padding-top:6px}
.rte-body.empty::before{content:attr(data-placeholder);position:absolute;left:18px;right:18px;top:16px;color:var(--rte-q);pointer-events:none}
/* the suggestion: the words it replaces struck through, the new ones in the accent's tint as they arrive */
.rte-old{color:var(--rte-q);text-decoration:line-through;text-decoration-color:var(--rte-q)}
.rte-new{color:var(--accent-fg);background:var(--sel);border-radius:3px}
.rte-old+.rte-new{margin-left:.3em}
.rte-new .w{animation:rtew .4s var(--ease) both}
@keyframes rtew{from{opacity:0;filter:blur(3px)}to{opacity:1;filter:none}}
/* the text a menu or the link field is for stays marked while the focus is in them */
::highlight(rte-pick){background-color:color-mix(in srgb,var(--accent-mark) 24%,transparent)}
.rte-ft{display:grid;align-items:center;min-height:46px;padding:6px var(--s-2) 6px 18px;box-shadow:inset 0 1px 0 color-mix(in srgb,var(--line) 50%,transparent)}
.rte-idle,.rte-rev{grid-area:1/1;display:flex;align-items:center;gap:var(--s-2);min-width:0;transition:opacity .25s var(--ease),transform .35s var(--ease),visibility 0s}
.rte-rev,.rte[data-st="review"] .rte-idle{opacity:0;visibility:hidden;pointer-events:none;transition:opacity .25s var(--ease),transform .35s var(--ease),visibility 0s .35s}
.rte-rev{transform:translateY(4px)}
.rte[data-st="review"] .rte-idle{transform:translateY(-4px)}
.rte[data-st="review"] .rte-rev{opacity:1;visibility:visible;pointer-events:auto;transform:none;transition-delay:0s}
.rte-wc,.rte-hint{font-size:12px;color:var(--rte-q);white-space:nowrap}
.rte-hint{min-width:0;overflow:hidden;text-overflow:ellipsis}
.rte-hint::before{content:"·";margin-right:var(--s-2)}
.rte-idle .ib{width:28px;height:28px;flex:none}
.rte-idle .ib:disabled{opacity:1;cursor:not-allowed;background:none;color:var(--dis)}
.rte-st{display:flex;align-items:center;gap:7px;flex:0 1 auto;min-width:0;font-size:13px;color:var(--rte-q2);white-space:nowrap}
.rte-st>span:last-child{min-width:0;overflow:hidden;text-overflow:ellipsis}
.rte-st .i{color:var(--accent-fg)}
.rte-rev .pill{flex:none}
.rte-rev .pill.ghost,.rte-rev .kbd,.rte-menu .ph{color:var(--rte-q2)}
@media (hover:hover) and (pointer:fine){.rte-rev .pill.ghost:hover{color:var(--fg)}}
.rte-menu{width:228px}
.rte-menu .mi[aria-disabled="true"],.rte-menu .mi[aria-disabled="true"] .i{opacity:1;cursor:not-allowed;background:none;color:var(--dis)}
.rte-menu .mi:focus-visible{outline-offset:-2px;background:var(--row)}
.rte-lnk{display:flex;align-items:center;gap:6px;width:304px;max-width:calc(100% - 12px);min-width:0;padding:6px}
.rte-url{flex:1;min-width:0;height:32px;padding:0 10px;border:0;border-radius:var(--r-field);outline:0;background:var(--surface);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--field-line) 50%,transparent);color:var(--fg);font-size:13px}
@supports (corner-shape:squircle){.rte-url{corner-shape:squircle;border-radius:var(--r-field-sq)}}
.rte-url::placeholder{color:var(--rte-q)}
.rte-url:focus{box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--field-line-strong) 50%,transparent)}
.rte-url[aria-invalid="true"]{box-shadow:inset 0 0 0 1px var(--bad)}
.rte-lnk .pill{flex:none}
.rte-lnk .ib{width:30px;height:30px;flex:none}
/* the small Ask AI button over a selection, for the mouse; the keyboard has the toolbar's button and its shortcut */
.rte-ask{position:absolute;left:0;top:0;z-index:6;display:inline-flex;align-items:center;gap:6px;height:28px;padding:0 11px 0 9px;border-radius:var(--r-pill);background:var(--raised);box-shadow:var(--shadow-pop);font-size:12.5px;color:var(--fg);white-space:nowrap;opacity:0;visibility:hidden;transform:translateY(4px);transition:opacity .18s var(--ease),transform .3s var(--ease),visibility 0s .3s}
.rte-ask .i{color:var(--accent-fg)}
@media (hover:hover) and (pointer:fine){.rte-ask:hover{background:color-mix(in srgb,var(--fg) 4%,var(--raised))}}
.rte-ask.show{opacity:1;visibility:visible;transform:none;transition-delay:0s}
.rte-live{position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip-path:inset(50%);white-space:nowrap}
@container rte (max-width:520px){.rte-hint{display:none}}
@container rte (max-width:400px){.rte-ai{width:30px;padding:0;justify-content:center}.rte-ai span{display:none}.rte-sep{margin:0 2px}.rte-body{padding:14px}.rte-body.empty::before{left:14px;right:14px;top:14px}.rte-ft{padding-left:14px}.rte-rev .kbd{display:none}}
@container rte (max-width:330px){.rte-tb{gap:0;padding:7px var(--s-1)}.rte-b{width:28px}}
:root:not([data-style="soft"]) .rte-b:is([aria-pressed="true"],.on):not(:where([data-style="soft"],[data-style="soft"] *)){box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--accent-mark) 50%,transparent)}
/* Blueprint's lime tint is faint on white, so the suggestion also gets a hairline of the accent's mark under it */
:root:not([data-style="soft"]) .rte-new:not(:where([data-style="soft"],[data-style="soft"] *)){background:var(--accent-bg);box-shadow:inset 0 -1px 0 var(--accent-mark)}
rich-text-editor.jsHTML · 664 lines
/* the editing engine. The text is a list of lines (paragraphs, a heading, list items and quote lines) holding bold, italic,
   inline code and links, and every change goes through it: Enter, Backspace at a line's start, formats, paste and drop, so
   pasted or browser-made markup is cleaned into those few tags. Undo and redo step through its own history. A selection is
   kept as two numbers, the characters before each end with a line break counting one, so it survives every rebuild, and the
   editor works the same with or without the focus (a story types into it without taking the focus from the page) */
function rteEngine(ed, hook = {}){
  const LEAF = 'p,h2,blockquote,li', BLOCK = /^(P|DIV|H[1-6]|LI|BLOCKQUOTE|PRE|TR|DT|DD|SECTION|ARTICLE|HEADER|FOOTER|ASIDE|MAIN|NAV|FIGURE|FIGCAPTION|UL|OL|DL|TABLE|TBODY|THEAD|TFOOT|ADDRESS|HR)$/;
  const SKIP = /^(SCRIPT|STYLE|TEMPLATE|HEAD|META|LINK|TITLE|IFRAME|OBJECT|EMBED|SVG|MATH|IMG|PICTURE|VIDEO|AUDIO|CANVAS|NOSCRIPT|BUTTON|INPUT|SELECT|TEXTAREA|OPTION)$/;
  const offs = [], on = (el, ev, fn, o) => { el.addEventListener(ev, fn, o); offs.push(() => el.removeEventListener(ev, fn, o)); };
  const lines = () => [...ed.querySelectorAll(LEAF)];
  const typeOf = b => b.tagName === 'LI' ? b.parentNode.tagName.toLowerCase() : b.tagName === 'BLOCKQUOTE' ? 'quote' : b.tagName.toLowerCase();
  const lineOf = n => { for (let x = n; x && x !== ed; x = x.parentNode) if (x.nodeType === 1 && x.matches(LEAF)) return x; return null; };
  const len = b => b.textContent.length;
  const total = () => lines().reduce((n, b, i) => n + len(b) + (i ? 1 : 0), 0);
  const startOf = i => lines().slice(0, i).reduce((n, b) => n + len(b) + 1, 0);
  const lineAt = at => { const all = lines(); let n = 0; for (let i = 0; i < all.length; i++){ if (at <= n + len(all[i])) return i; n += len(all[i]) + 1; } return all.length - 1; };
  const text = o => lines().map(b => b.textContent).join('\n').slice(o.s, o.e);
  // the nearest element of a kind around a node, inside its line
  const up = (n, tag) => { for (let x = n && n.nodeType === 3 ? n.parentNode : n; x && x !== ed && x.nodeType === 1 && !x.matches(LEAF); x = x.parentNode) if (x.tagName === tag) return x; return null; };
  const wrap = (n, tag, href) => { const w = document.createElement(tag.toLowerCase()); if (href) w.setAttribute('href', href); n.replaceWith(w); w.appendChild(n); return w; };
  const unwrap = el => el.replaceWith(...el.childNodes);

  /* ---- a point in the DOM as one number, and back ---- */
  function offsetOf(node, off){
    let b = lineOf(node);
    if (!b){
      if (!ed.contains(node)) return 0;
      // a point between lines: the start of the line after it, or the very end
      const after = node.childNodes[off], first = after && (after.nodeType === 1 ? (after.matches(LEAF) ? after : after.querySelector(LEAF)) : null);
      if (!first) return total();
      b = first; node = first; off = 0;
    }
    let n = 0; for (const x of lines()){ if (x === b) break; n += len(x) + 1; }
    const rg = document.createRange(); rg.setStart(b, 0); rg.setEnd(node, off);
    return n + rg.toString().length;
  }
  // late: where two text nodes meet, the start of the second (for the start of a range) rather than the end of the first
  function pointAt(at, late){
    const all = lines(); let n = Math.max(0, at);
    for (let i = 0; i < all.length; i++){
      const b = all[i];
      if (n <= len(b) || i === all.length - 1){
        n = Math.min(n, len(b));
        const w = document.createTreeWalker(b, NodeFilter.SHOW_TEXT); let t, last = null;
        while ((t = w.nextNode())){ if (late ? n < t.data.length : n <= t.data.length) return [t, n]; n -= t.data.length; last = t; }
        return last ? [last, last.data.length] : [b, 0];
      }
      n -= len(b) + 1;
    }
    return [ed, 0];
  }
  const range = o => { const [a, ao] = pointAt(o.s, o.s !== o.e), [b, bo] = pointAt(o.e); const rg = document.createRange(); rg.setStart(a, ao); rg.setEnd(b, bo); return rg; };
  // scrolls the text, and only the text, so a part of it is in view
  function show(x){
    const r = x.getBoundingClientRect(), b = ed.getBoundingClientRect(); if (!r.height) return;
    if (r.bottom > b.bottom - 12) ed.scrollTop += Math.min(r.bottom - b.bottom + 12, r.top - b.top - 12);
    else if (r.top < b.top + 12) ed.scrollTop -= b.top + 12 - r.top;
  }

  /* ---- the selection: the browser's while the editor has the focus, otherwise the last one it had ---- */
  let caret = null;
  const live = () => { const s = getSelection(); if (!s || !s.rangeCount) return null; const g = s.getRangeAt(0); return ed.contains(g.startContainer) && ed.contains(g.endContainer) ? g : null; };
  function get(){
    const g = live();
    if (g){ const a = offsetOf(g.startContainer, g.startOffset), b = offsetOf(g.endContainer, g.endOffset); return { s:Math.min(a, b), e:Math.max(a, b) }; }
    if (!caret){ const t = total(); caret = { s:t, e:t }; }
    return { s:caret.s, e:caret.e };
  }
  function set(o, focus){
    caret = { s:o.s, e:o.e };
    if (focus && document.activeElement !== ed) ed.focus({ preventScroll:true });
    if (document.activeElement !== ed) return;
    const s = getSelection(); s.removeAllRanges(); s.addRange(range(o));
  }

  /* ---- tidying: the few tags the editor keeps, in the shape it keeps them ---- */
  function tidy(){
    for (const el of ed.querySelectorAll('b,i,code,a')){
      if (!el.isConnected) continue;
      if (!el.textContent){ el.remove(); continue; }
      // a mark inside one of its own kind adds nothing
      if (up(el.parentNode, el.tagName)) unwrap(el);
    }
    // two marks of a kind side by side become one
    for (const el of ed.querySelectorAll('b,i,code,a')){
      if (!el.isConnected) continue;
      for (;;){
        let nx = el.nextSibling;
        while (nx && nx.nodeType === 3 && !nx.data){ const d = nx; nx = nx.nextSibling; d.remove(); }
        if (!nx || nx.nodeType !== 1 || nx.tagName !== el.tagName || nx.getAttribute('href') !== el.getAttribute('href')) break;
        el.append(...nx.childNodes); nx.remove();
      }
    }
    // an empty line keeps one <br> so it has a height and a caret; a line with text needs none
    for (const b of lines()){ const br = [...b.querySelectorAll('br')]; if (b.textContent) br.forEach(x => x.remove()); else if (!br.length) b.appendChild(document.createElement('br')); else br.slice(1).forEach(x => x.remove()); }
    if (!lines().length){ const p = document.createElement('p'); p.appendChild(document.createElement('br')); ed.replaceChildren(p); }
    ed.normalize();
  }
  // spec: [type, a node whose children become the line][]; list items of a kind side by side share one list
  function rebuild(spec){
    const frag = document.createDocumentFragment(); let list = null;
    for (const [t, b] of spec){
      const el = document.createElement(t === 'quote' ? 'blockquote' : t === 'ul' || t === 'ol' ? 'li' : t);
      el.append(...b.childNodes);
      if (t === 'ul' || t === 'ol'){ if (!list || list.tagName.toLowerCase() !== t) frag.appendChild(list = document.createElement(t)); list.appendChild(el); }
      else { list = null; frag.appendChild(el); }
    }
    ed.replaceChildren(frag); tidy();
  }
  const specOf = () => lines().map(b => [typeOf(b), b]);
  // anything the browser made that the editor doesn't keep: a stray <div>, a <span style>, text outside a line
  function messy(){
    for (const n of ed.childNodes){
      if (n.nodeType === 3 ? n.data.trim() : n.nodeType === 1 && !/^(P|H2|BLOCKQUOTE|UL|OL)$/.test(n.tagName)) return true;
      if (n.nodeType === 1 && /^(UL|OL)$/.test(n.tagName) && [...n.childNodes].some(x => x.nodeType !== 1 || x.tagName !== 'LI')) return true;
    }
    return !!ed.querySelector('[style],[class],span,font,div,a:not([href]),:is(p,h2,blockquote,li) :not(b,i,code,a,br)');
  }

  /* ---- reading HTML, pasted or the editor's own, into lines of the few tags it keeps. keep: the editor's own, every
     character and empty line as it is; otherwise whitespace collapses as a page shows it and empty lines go ---- */
  const safe = h => { h = (h || '').trim(); return /^(https?:\/\/|mailto:)/i.test(h) ? h : null; };
  function parse(root, keep){
    const out = []; let cur = null;
    const walk = (node, t, pre, marks) => {
      for (const n of node.childNodes){
        if (n.nodeType === 3){
          let s = keep ? n.data : pre ? n.data.replace(/\r\n?/g, '\n') : n.data.replace(/[\s ]+/g, ' ');
          if (!keep) s = s.replace(/ /g, ' ');
          if (!cur && !s.trim() && (node === root || /^(UL|OL|DL|TABLE|TBODY|THEAD|TFOOT|TR)$/.test(node.tagName))) continue;
          (pre && !keep ? s.split('\n') : [s]).forEach((p, i) => {
            if (i) cur = null;
            if (!p || (!cur && !keep && !p.trim())) return;
            if (!cur) out.push(cur = { t, frag:document.createDocumentFragment() });
            let x = document.createTextNode(p);
            for (let m = marks.length - 1; m >= 0; m--){ const w = document.createElement(marks[m][0].toLowerCase()); if (marks[m][1]) w.setAttribute('href', marks[m][1]); w.appendChild(x); x = w; }
            cur.frag.appendChild(x);
          });
          continue;
        }
        if (n.nodeType !== 1 || SKIP.test(n.tagName) || n.classList.contains('rte-new')) continue;
        const tg = n.tagName, css = (n.getAttribute('style') || '').toLowerCase();
        if (tg === 'BR'){ cur = null; continue; }
        if (BLOCK.test(tg)){
          const nt = /^H[1-6]$/.test(tg) ? 'h2' : tg === 'LI' ? (n.parentNode && n.parentNode.tagName === 'OL' ? 'ol' : 'ul') : tg === 'BLOCKQUOTE' ? 'quote' : t;
          cur = null; const was = out.length;
          walk(n, nt, pre || tg === 'PRE', marks);
          if (keep && out.length === was && !n.querySelector('p,div,h1,h2,h3,h4,h5,h6,li,blockquote,pre,ul,ol')) out.push({ t:nt, frag:document.createDocumentFragment() });
          cur = null; continue;
        }
        let m = marks; const add = (tag, href) => { if (!m.some(x => x[0] === tag)) m = [...m, [tag, href]]; };
        if (tg === 'B' || tg === 'STRONG' ? !/font-weight\s*:\s*(normal|[1-4]00)/.test(css) : /font-weight\s*:\s*(bold|[6-9]00)/.test(css)) add('B');
        if (tg === 'I' || tg === 'EM' || /font-style\s*:\s*italic/.test(css)) add('I');
        if (/^(CODE|KBD|SAMP|TT)$/.test(tg)) add('CODE');
        if (tg === 'A'){ const h = safe(n.getAttribute('href')); if (h) add('A', h); }
        walk(n, t, pre, m);
      }
    };
    walk(root, 'p', false, []);
    if (!keep) for (const x of out){
      const ts = [], w = document.createTreeWalker(x.frag, NodeFilter.SHOW_TEXT); let t; while ((t = w.nextNode())) ts.push(t);
      if (ts.length){ ts[0].data = ts[0].data.replace(/^\s+/, ''); ts[ts.length - 1].data = ts[ts.length - 1].data.replace(/\s+$/, ''); }
    }
    return out.filter(x => keep || x.frag.textContent).map(x => ({ ...x, len:x.frag.textContent.length }));
  }
  const plain = s => s.replace(/\r\n?/g, '\n').split('\n').filter(l => l.trim()).map(l => { const frag = document.createDocumentFragment(); frag.append(l); return { t:'p', frag, len:l.length }; });
  function clean(){ const o = get(); rebuild(parse(ed, true).map(x => [x.t, x.frag])); set(o); }
  function load(html){ const t = document.createElement('template'); t.innerHTML = html || ''; rebuild(parse(t.content, false).map(x => [x.t, x.frag])); caret = null; H.list = [{ html:ed.innerHTML, sel:{ s:0, e:0 }, pre:null }]; H.at = 0; H.dirty = false; pend = null; changed(); }

  /* ---- history ---- */
  const H = { list:[], at:0, dirty:false, pre:null, t:0 };
  const changed = () => hook.change && hook.change();
  function commit(pre){
    clearTimeout(H.t); const html = ed.innerHTML, top = H.list[H.at], before = H.pre || pre || null;
    H.dirty = false; H.pre = null;
    if (!top || top.html !== html){ H.list.splice(H.at + 1); H.list.push({ html, sel:get(), pre:before }); if (H.list.length > 300) H.list.shift(); H.at = H.list.length - 1; }
    changed();
  }
  const flush = () => { if (H.dirty) commit(); };
  // typing settles into one step once it pauses
  function typed(pre){ if (!H.dirty){ H.pre = pre || null; H.list.splice(H.at + 1); } H.dirty = true; clearTimeout(H.t); H.t = setTimeout(() => commit(), 700); changed(); }
  function restore(html, sel){ ed.innerHTML = html; pend = null; set(sel || { s:0, e:0 }); changed(); }
  function undo(){ flush(); if (H.at <= 0) return; const cur = H.list[H.at]; H.at--; restore(H.list[H.at].html, cur.pre || H.list[H.at].sel); }
  function redo(){ flush(); if (H.at >= H.list.length - 1) return; H.at++; restore(H.list[H.at].html, H.list[H.at].sel); }

  /* ---- edits ---- */
  function texts(){ const out = []; let n = 0; lines().forEach((b, i) => { if (i) n++; const w = document.createTreeWalker(b, NodeFilter.SHOW_TEXT); let t; while ((t = w.nextNode())){ out.push([t, n]); n += t.data.length; } }); return out; }
  function cut(at){ for (const [t, n] of texts()) if (at > n && at < n + t.data.length){ t.splitText(at - n); return; } }
  // the text nodes a selection covers, split at its ends so each is wholly inside it
  function covered(o){ cut(o.e); cut(o.s); return texts().filter(([t, n]) => t.data.length && n >= o.s && n + t.data.length <= o.e).map(x => x[0]); }
  // formats picked with nothing selected wait for the next thing typed at that spot
  let pend = null;
  function has(tag, o){
    if (o.s === o.e){
      if (pend && pend.at === o.s && tag in pend.m) return pend.m[tag];
      const g = live(); return !!up(g && g.collapsed ? g.startContainer : pointAt(o.s)[0], tag);
    }
    const ts = texts().filter(([t, n]) => t.data.length && n < o.e && n + t.data.length > o.s);
    return ts.length > 0 && ts.every(([t]) => up(t, tag));
  }
  // bold (B), italic (I), code (CODE) or a link (A, with href; null takes it off) on the selection, or off it when it all has it
  function inline(tag, href, o = get()){
    if (o.s === o.e){
      if (tag === 'A') return;
      if (!pend || pend.at !== o.s) pend = { at:o.s, m:{} };
      pend.m[tag] = !has(tag, o); changed(); return;
    }
    flush();
    const ts = covered(o); if (!ts.length) return;
    const add = href !== undefined ? href !== null : !ts.every(t => up(t, tag));
    if (add) ts.forEach(t => { const had = up(t, tag); if (!had) wrap(t, tag, href); else if (href) had.setAttribute('href', href); });
    else new Set(ts.map(t => up(t, tag))).forEach(el => {
      if (!el) return;
      const keep = [], w = document.createTreeWalker(el, NodeFilter.SHOW_TEXT), h = el.getAttribute('href'); let t;
      while ((t = w.nextNode())) if (!ts.includes(t)) keep.push(t);
      unwrap(el); keep.forEach(x => wrap(x, tag, h));
    });
    tidy(); set(o); commit(o);
  }
  // typing with formats picked at the caret: the text goes in with them, and the caret stays inside them
  function typeStyled(s){
    const o = get(), m = pend.m; pend = null;
    const g = live(), [n, k] = g && g.collapsed ? [g.startContainer, g.startOffset] : pointAt(o.s);
    const rg = document.createRange(); rg.setStart(n, k); rg.collapse(true);
    for (const tag of Object.keys(m)) if (!m[tag]){
      const el = up(rg.startContainer, tag); if (!el) continue;
      const tail = document.createRange(); tail.setStart(rg.startContainer, rg.startOffset); tail.setEndAfter(el);
      el.after(tail.extractContents()); rg.setStartAfter(el); rg.collapse(true);
    }
    let x = document.createTextNode(s);
    for (const tag of Object.keys(m)) if (m[tag] && !up(rg.startContainer, tag)){ const w = document.createElement(tag.toLowerCase()); w.appendChild(x); x = w; }
    rg.insertNode(x); tidy(); set({ s:o.s + s.length, e:o.s + s.length }); typed(o);
  }
  // a heading, a list or a quote on the lines in the selection, or plain lines again when they all have it
  function block(type, o = get()){
    flush(); const all = lines(), a = lineAt(o.s), z = lineAt(o.e);
    const to = all.slice(a, z + 1).every(b => typeOf(b) === type) ? 'p' : type;
    rebuild(all.map((b, i) => [i >= a && i <= z ? to : typeOf(b), b])); set(o); commit(o);
  }
  function del(o){
    const all = lines(), i = lineAt(o.s), j = lineAt(o.e), [a, ao] = pointAt(o.s, true), [b, bo] = pointAt(o.e);
    if (i === j){ const rg = document.createRange(); rg.setStart(a, ao); rg.setEnd(b, bo); rg.deleteContents(); tidy(); return { s:o.s, e:o.s }; }
    const head = document.createRange(); head.setStart(a, ao); head.setEnd(all[i], all[i].childNodes.length); head.deleteContents();
    const tail = document.createRange(); tail.setStart(all[j], 0); tail.setEnd(b, bo); tail.deleteContents();
    all[i].append(...all[j].childNodes);
    rebuild(all.filter((x, k) => k <= i || k > j).map(x => [typeOf(x), x]));
    return { s:o.s, e:o.s };
  }
  // Enter: the text after the caret starts a new line. A list or a quote carries on, a heading is followed by text, and Enter
  // on an empty list item or quote line leaves the list
  function split(o = get()){
    flush(); const pre = o; if (o.s !== o.e) o = del(o);
    const all = lines(), i = lineAt(o.s), b = all[i], t = typeOf(b), spec = specOf(), next = t === 'h2' ? 'p' : t;
    if (!len(b) && t !== 'p'){ spec[i][0] = 'p'; rebuild(spec); set(o); commit(pre); return; }
    if (len(b) && o.s === startOf(i)){ spec.splice(i, 0, [next, document.createElement('p')]); rebuild(spec); set({ s:o.s + 1, e:o.s + 1 }); commit(pre); return; }
    const [n, k] = pointAt(o.s), rg = document.createRange(); rg.setStart(n, k); rg.setEnd(b, b.childNodes.length);
    const nb = document.createElement('p'); nb.appendChild(rg.extractContents());
    spec.splice(i + 1, 0, [next, nb]); rebuild(spec); set({ s:o.s + 1, e:o.s + 1 }); commit(pre);
  }
  // Backspace at the start of a line: a heading, list item or quote line turns plain first, then the line joins the one above
  function joinBack(o){
    const all = lines(), i = lineAt(o.s), spec = specOf();
    if (spec[i][0] !== 'p'){ spec[i][0] = 'p'; rebuild(spec); set(o); commit(o); return; }
    if (!i) return;
    all[i - 1].append(...all[i].childNodes); spec.splice(i, 1); rebuild(spec); set({ s:o.s - 1, e:o.s - 1 }); commit(o);
  }
  function joinNext(o){
    const all = lines(), i = lineAt(o.s), spec = specOf(); if (i >= all.length - 1) return;
    all[i].append(...all[i + 1].childNodes); spec.splice(i + 1, 1); rebuild(spec); set(o); commit(o);
  }
  // plain text at a spot; returns where it ends
  function insert(s, o){
    const [n, k] = pointAt(o.s);
    if (n.nodeType === 3) n.insertData(k, s); else n.insertBefore(document.createTextNode(s), n.childNodes[k] || null);
    tidy(); return o.s + s.length;
  }
  // lines read from a paste (or a suggestion) into the text: the first joins the line at the caret, the rest follow it, and
  // what came after the caret goes on the end. Returns where they end
  function insertLines(items, o){
    if (!items.length) return o.s;
    if (o.s !== o.e) o = del(o);
    const all = lines(), i = lineAt(o.s), b = all[i], [n, k] = pointAt(o.s), spec = specOf();
    const rg = document.createRange(); rg.setStart(n, k); rg.setEnd(b, b.childNodes.length); const tail = rg.extractContents();
    if (!b.textContent && items[0].t !== 'p') spec[i][0] = items[0].t;
    let at = o.s + items[0].len; b.append(items[0].frag);
    const rest = items.slice(1).map(x => { const p = document.createElement('p'); p.append(x.frag); at += 1 + x.len; return [x.t, p]; });
    spec.splice(i + 1, 0, ...rest); (rest.length ? rest[rest.length - 1][1] : b).append(tail);
    rebuild(spec); return at;
  }
  // a link on the selection; with nothing selected, on the link at the caret, or the address itself goes in, linked
  function link(o, href){
    flush();
    if (!href){ const rg = range(o); [...ed.querySelectorAll('a')].filter(a => rg.intersectsNode(a)).forEach(unwrap); tidy(); set(o); commit(o); return; }
    if (o.s !== o.e) return inline('A', href, o);
    const a = up(pointAt(o.s)[0], 'A');
    if (a){ a.setAttribute('href', href); set(o); commit(o); return; }
    const label = href.replace(/^mailto:/i, ''), end = insert(label, o);
    covered({ s:o.s, e:end }).forEach(t => wrap(t, 'A', href)); tidy(); set({ s:end, e:end }); commit(o);
  }

  /* ---- a suggestion: the text it replaces (swap) is struck through, or it goes after the selection (add). The new words
     arrive in a span the caret can't enter; Accept puts them in as plain text, Discard leaves the text as it was ---- */
  let rev = null;
  function suggest(o, kind){
    flush(); pend = null;
    const nw = document.createElement('span'); nw.className = 'rte-new'; nw.contentEditable = 'false';
    const olds = [];
    if (kind === 'swap'){
      const all = lines(), a = lineAt(o.s), z = lineAt(o.e);
      for (let i = z; i >= a; i--){
        const s = Math.max(o.s, startOf(i)), e = Math.min(o.e, startOf(i) + len(all[i])); if (e <= s) continue;
        const rg = range({ s, e }), sp = document.createElement('span'); sp.className = 'rte-old';
        sp.appendChild(rg.extractContents()); rg.insertNode(sp); olds.unshift(sp);
      }
    }
    if (olds.length) olds[olds.length - 1].after(nw); else range({ s:o.e, e:o.e }).insertNode(nw);
    rev = { o, kind, olds, nw };
    return s => { if (rev && rev.nw === nw){ const w = document.createElement('span'); w.className = 'w'; w.textContent = s; nw.appendChild(w); show(w); } };
  }
  function drop(){ if (!rev) return null; const r = rev; r.olds.forEach(unwrap); r.nw.remove(); rev = null; tidy(); return r; }
  function accept(){
    const s = rev && rev.nw.textContent; const r = drop(); if (!r) return;
    const at = insertLines(s ? s.split(/\n+/).filter(Boolean).map(l => { const frag = document.createDocumentFragment(); frag.append(l); return { t:'p', frag, len:l.length }; }) : [], r.kind === 'swap' ? r.o : { s:r.o.e, e:r.o.e });
    set({ s:at, e:at }); commit(r.o);
  }
  function discard(){ const r = drop(); if (r) set(r.o); changed(); }

  /* ---- what the browser does, routed through the edits above ---- */
  on(ed, 'beforeinput', e => {
    const t = e.inputType;
    if (rev){ e.preventDefault(); return; }
    if (t === 'historyUndo' || t === 'historyRedo'){ e.preventDefault(); t === 'historyUndo' ? undo() : redo(); return; }
    if (t.startsWith('format')){ e.preventDefault(); if (t === 'formatBold') inline('B'); else if (t === 'formatItalic') inline('I'); return; }
    if (t === 'insertParagraph' || t === 'insertLineBreak'){ e.preventDefault(); split(); return; }
    if (t === 'insertFromPaste' || t === 'insertFromDrop' || t === 'deleteByDrag' || t === 'deleteByCut') return;
    const o = get(), i = lineAt(o.s);
    if (t === 'insertText' && e.data && pend && o.s === o.e && pend.at === o.s){ e.preventDefault(); typeStyled(e.data); return; }
    if (o.s === o.e && /^delete.*Backward$/.test(t) && o.s === startOf(i)){ e.preventDefault(); flush(); joinBack(o); return; }
    if (o.s === o.e && /^delete.*Forward$/.test(t) && o.s === startOf(i) + len(lines()[i])){ e.preventDefault(); flush(); joinNext(o); return; }
    // a selection over several lines: removing it joins them, so the editor does it
    if (o.s !== o.e && i !== lineAt(o.e) && (t.startsWith('delete') || t === 'insertText' || t === 'insertReplacementText')){
      e.preventDefault(); flush(); const p = del(o); let at = p.s;
      const s = e.data || (e.dataTransfer && e.dataTransfer.getData('text/plain')) || '';
      if (s && t.startsWith('insert')) at = insert(s.replace(/\s*\n\s*/g, ' '), p);
      set({ s:at, e:at }); commit(o); return;
    }
    if (!H.dirty) H.pre = o;
  });
  on(ed, 'input', e => { if (rev) return; if (messy()) clean(); typed(H.pre); hook.input && hook.input(e); });
  on(ed, 'paste', e => {
    e.preventDefault(); if (rev || !e.clipboardData) return;
    const h = e.clipboardData.getData('text/html'), s = e.clipboardData.getData('text/plain');
    let items = h ? parse(new DOMParser().parseFromString(h, 'text/html').body, false) : [];
    if (!items.length) items = plain(s); if (!items.length) return;
    flush(); const o = get(), at = insertLines(items, o); set({ s:at, e:at }); commit(o);
  });
  on(ed, 'cut', e => {
    e.preventDefault(); if (rev || !e.clipboardData) return; const o = get(); if (o.s === o.e) return;
    const g = live(); e.clipboardData.setData('text/plain', text(o)); if (g){ const d = document.createElement('div'); d.appendChild(g.cloneContents()); e.clipboardData.setData('text/html', d.innerHTML); }
    flush(); const p = del(o); set(p); commit(o);
  });
  // a drop goes in as plain text where it lands; dragging inside the text is off, so a drag can't move half a list
  on(ed, 'dragstart', e => e.preventDefault());
  on(ed, 'drop', e => {
    e.preventDefault(); if (rev || !e.dataTransfer) return;
    const items = plain(e.dataTransfer.getData('text/plain')); if (!items.length) return;
    const p = document.caretRangeFromPoint ? document.caretRangeFromPoint(e.clientX, e.clientY) : document.caretPositionFromPoint && (pp => { const r = document.createRange(); r.setStart(pp.offsetNode, pp.offset); return r; })(document.caretPositionFromPoint(e.clientX, e.clientY));
    if (!p || !ed.contains(p.startContainer)) return;
    flush(); const at0 = offsetOf(p.startContainer, p.startOffset), at = insertLines(items, { s:at0, e:at0 }); set({ s:at, e:at }, true); commit({ s:at0, e:at0 });
  });
  on(ed, 'keydown', e => {
    if (e.isComposing || (hook.key && hook.key(e))) return;
    if (rev) return;
    const mod = /Mac|iP(hone|ad|od)/.test(navigator.platform || navigator.userAgent) ? e.metaKey && !e.ctrlKey : e.ctrlKey && !e.metaKey;
    if (!mod) return;
    const k = e.key.toLowerCase(), c = e.code;
    const act = e.altKey ? (c === 'Digit1' ? () => block('h2') : c === 'Digit0' ? () => block('p') : null)
      : e.shiftKey ? (c === 'Digit7' ? () => block('ol') : c === 'Digit8' ? () => block('ul') : c === 'Digit9' ? () => block('quote') : k === 'z' ? redo : null)
      : k === 'b' ? () => inline('B') : k === 'i' ? () => inline('I') : k === 'e' ? () => inline('CODE') : k === 'z' ? undo : k === 'y' ? redo : null;
    if (act){ e.preventDefault(); e.stopPropagation(); act(); }
  });
  on(document, 'selectionchange', () => {
    if (!live() || rev) return;
    caret = get(); if (pend && (caret.s !== pend.at || caret.e !== pend.at)) pend = null;
    hook.select && hook.select();
  });

  tidy(); H.list = [{ html:ed.innerHTML, sel:{ s:0, e:0 }, pre:null }];
  return {
    get, set, text, range, total, lines, typeOf, load, undo, redo, inline, block, split, link, suggest, accept, discard, flush,
    focus: () => set(get(), true),
    html: () => ed.innerHTML,
    reviewing: () => !!rev,
    canUndo: () => H.at > 0 || H.dirty,
    canRedo: () => H.at < H.list.length - 1,
    empty: () => { const all = lines(); return all.length === 1 && !len(all[0]) && typeOf(all[0]) === 'p'; },
    words: () => { const s = lines().map(b => b.textContent).join(' ').trim(); return s ? s.split(/\s+/).length : 0; },
    // the line around a spot, as a selection
    line: at => { const i = lineAt(at), s = startOf(i); return { s, e:s + len(lines()[i]) }; },
    // what the selection or the caret has: bold, italic, code, a link (and its address), and the lines' type when they share one
    state(){
      const o = get(), all = lines(), ts = all.slice(lineAt(o.s), lineAt(o.e) + 1).map(typeOf), g = live();
      const a = up(o.s === o.e && g ? g.startContainer : pointAt(o.s, o.s !== o.e)[0], 'A');
      return { B:has('B', o), I:has('I', o), CODE:has('CODE', o), A:o.s === o.e ? !!a : has('A', o), href:a ? a.getAttribute('href') : null, block:ts.every(t => t === ts[0]) ? ts[0] : null };
    },
    // removes some text, as one step of the history
    remove(o){ flush(); set(del(o)); commit(o); },
    // text typed at the caret, as a person would: for a story, which leaves the page's focus where it is
    type(s){ const o = get(); flush(); const at = insert(s, o); set({ s:at, e:at }); typed(o); show(range({ s:at, e:at })); },
    show: o => show(range(o)),
    destroy(){ clearTimeout(H.t); offs.forEach(f => f()); }
  };
}
/* a stand-in for the agent, so the preview works on its own: canned rewrites for the story's draft, and simple rules for
   anything else. In your product, call your model where the component calls this */
const RTE_CANNED = {
  "We know pricing changes can feel like a lot, so we want to make sure everyone has plenty of time to look at the new plans and ask us questions before anything changes.":
    { shorter:"We're giving everyone time to review the new plans and ask questions before anything changes.", improve:'Pricing changes can feel like a lot, so everyone gets plenty of time to review the new plans and ask questions before anything changes.' },
  'New pricing goes live on March 3.':{ improve:'Our new pricing goes live on March 3.', shorter:'New pricing starts March 3.' },
  'Starter teams get two more seats for free, and every plan now includes the agent.':{ improve:'Starter teams get two extra seats at no cost, and every plan now includes the agent.', shorter:'Starter teams get two free seats, and every plan includes the agent.' }
};
function rteWrite(action, text, doc){
  const TYPO = { teh:'the', recieve:'receive', seperate:'separate', definately:'definitely', alot:'a lot', dont:"don't", cant:"can't", wont:"won't", im:"I'm", ive:"I've", thier:'their', untill:'until', occured:'occurred', wich:'which', becuase:'because', youre:"you're", doesnt:"doesn't", isnt:"isn't", tommorow:'tomorrow', accross:'across' };
  const fix = s => s.replace(/[ \t]{2,}/g, ' ').replace(/\s+([,.!?;:])/g, '$1').replace(/\bi\b/g, 'I')
    .replace(/\b[a-z]+\b/gi, w => { const f = TYPO[w.toLowerCase()]; return f ? (w[0] === w[0].toUpperCase() ? f[0].toUpperCase() + f.slice(1) : f) : w; })
    .replace(/(^|[.!?]\s+)([a-z])/g, (m, a, b) => a + b.toUpperCase()).replace(/([a-z0-9])\s*$/i, (m, a) => s.trim().split(/\s+/).length > 3 ? a + '.' : m);
  // sentences end at . ! or ? before a space, so a file name like plan.yaml stays in one
  const sentences = s => s.match(/[^]*?(?:[.!?]+(?=\s|$)|$)\s*/g).filter(Boolean);
  const canned = s => { const k = s.trim(), hit = RTE_CANNED[k] && RTE_CANNED[k][action]; return hit ? s.replace(k, hit) : null; };
  if (action === 'continue'){
    const next = /pric|plan|seat|launch/i.test(doc)
      ? ['Current customers keep their price until their next renewal, and every admin gets an email that sums up what changes for their team.', 'If anything is unclear, reply to this email and we will walk you through it.']
      : ['Next, we will share a short summary so everyone knows what changes and when.', 'Reply with any questions and we will get back to you within a day.'];
    return next.find(x => !doc.includes(x)) || next[next.length - 1];
  }
  if (action === 'grammar') return fix(text);
  const FILLER = [[/\b(really|very|just|actually|basically|quite|simply|definitely|certainly|literally|totally)\s+/gi, ''], [/\bin order to\b/gi, 'to'], [/\bmake sure (that )?/gi, 'ensure '], [/\bdue to the fact that\b/gi, 'because'], [/\bat this point in time\b/gi, 'now'], [/\ba lot of\b/gi, 'many'], [/\bplenty of\b/gi, 'enough'], [/\bis able to\b/gi, 'can']];
  const BETTER = [[/\bwe will\b/gi, "we'll"], [/\bwe are\b/gi, "we're"], [/\bit is\b/gi, "it's"], [/\bdo not\b/gi, "don't"], [/\bcannot\b/gi, "can't"], [/\bvery important\b/gi, 'essential'], [/\bgood\b/gi, 'strong'], [/\bbig\b/gi, 'major'], [/\bstuff\b/gi, 'details'], [/\bkind of\s+/gi, ''], [/\bat no extra cost\b/gi, 'free']];
  const rules = (s, list) => list.reduce((x, [a, b]) => x.replace(a, b), s);
  return sentences(text).map(s => {
    const hit = canned(s); if (hit) return hit;
    let x = fix(rules(s, action === 'shorter' ? FILLER : [...FILLER.slice(0, 2), ...BETTER]));
    // a long sentence that is still long keeps its main clause
    if (action === 'shorter' && x.trim().length > 60 && x.length > s.length * .85 && x.includes(', ')) x = x.slice(0, x.indexOf(', ')) + '.' + (/\s$/.test(s) ? ' ' : '');
    return x;
  }).join('');
}
DM.def('rich-text-editor', (r, c) => {
  const MAC = /Mac|iP(hone|ad|od)/.test(navigator.platform || navigator.userAgent);
  const KEY = { mod:MAC ? '⌘' : 'Ctrl', alt:MAC ? '⌥' : 'Alt', shift:MAC ? '⇧' : 'Shift' };
  const keys = k => k === 'redo' ? (MAC ? '⇧⌘Z' : 'Ctrl+Y') : k.split(' ').map(x => KEY[x] || x).join(MAC ? '' : '+');
  const IC = { heading:I('heading', ''), bold:I('bold', ''), italic:I('italic', ''), code:I('code', ''), link:I('link', ''), list:I('list', ''), listol:I('list-ol', ''), quote:I('quote', ''), spark:I('spark'), undo:I('undo'), redo:I('redo'), wand:I('wand'), minus:I('minus'), check:I('check'), edit:I('edit'), trash:I('trash') };
  r.innerHTML = r.innerHTML.replace(/\{(\w+)\}/g, (m, k) => IC[k] || m);
  $$('[data-k]', r).forEach(b => b.dataset.tipK = keys(b.dataset.k));
  const root = $('.rte', r), tb = $('.rte-tb', r), ed = $('.rte-body', r), wc = $('.rte-wc', r), live = $('.rte-live', r), st = $('.rte-st', r);
  const menu = $('.rte-menu', r), ask = $('.rte-ask', r), aiB = $('.rte-ai', r), lnk = $('.rte-lnk', r), url = $('.rte-url', r), linkB = $('[data-f="a"]', tb);
  const okB = $('.rte-ok', r), noB = $('.rte-no', r), unB = $('.rte-un', r), reB = $('.rte-re', r);
  const uid = Math.random().toString(36).slice(2, 7), ph = $('.ph', menu); ph.id = 'rte-ph-' + uid; menu.setAttribute('aria-labelledby', ph.id);
  const ACT = { improve:'Improve writing', shorter:'Make shorter', grammar:'Fix grammar', continue:'Continue writing' };
  // the selection a menu or the link field is for stays marked while the focus is in them
  const HL = window.CSS && CSS.highlights && window.Highlight ? (CSS.highlights.get('rte-pick') || (h => (CSS.highlights.set('rte-pick', h), h))(new Highlight())) : null;
  let picked = null;
  const pick = o => { if (!HL) return; if (picked) HL.delete(picked); picked = o && o.s !== o.e ? E.range(o) : null; if (picked) HL.add(picked); };
  c.offs.push(() => pick(null));
  const say = t => { live.textContent = ''; c.later(60, () => { live.textContent = t; }); };
  let job = null, lk = null, rv = null;

  // the toolbar, the word count and undo follow the text and the selection
  function paint(){
    const s = E.state(), busy = E.reviewing();
    $$('.rte-b', tb).forEach(b => { b.disabled = busy; const f = b.dataset.f;
      if (f === 'a') b.classList.toggle('on', !busy && s.A); else b.setAttribute('aria-pressed', String(!busy && (/^(h2|ul|ol|quote)$/.test(f) ? s.block === f : !!s[f.toUpperCase()]))); });
    aiB.disabled = busy; unB.disabled = busy || !E.canUndo(); reB.disabled = busy || !E.canRedo();
    if (!busy){ const n = E.words(); wc.textContent = n === 1 ? '1 word' : n + ' words'; }
    ed.classList.toggle('empty', !busy && E.empty());
  }
  // a popover sits under the text it is for, or over it when there is no room under it, always inside the editor
  // (the bubble goes over the selection's first line; a menu under its last line, or over its first when there's no room)
  function place(el, o, above){
    const b = root.getBoundingClientRect(), w = el.offsetWidth, h = el.offsetHeight, top = tb.offsetHeight - 2, [f, l] = rectsOf(o);
    const over = f.top - b.top - h - 6, under = l.bottom - b.top + 6;
    const y = above ? (over >= top ? over : under) : under + h <= b.height - 4 || over < top ? under : over;
    el.style.left = Math.round(Math.max(6, Math.min((above ? f : l).left - b.left - (above ? 0 : 8), b.width - w - 6))) + 'px';
    el.style.top = Math.round(Math.max(4, Math.min(y, b.height - h - 4))) + 'px';
  }
  // the first and last line boxes of a selection, or the caret's line
  const rectsOf = o => {
    const rs = [...E.range(o).getClientRects()].filter(x => x.width || x.height);
    if (rs.length) return [rs[0], rs[rs.length - 1]];
    const n = E.range(E.line(o.s)).startContainer, el = n.nodeType === 3 ? n.parentElement : n, rr = el.getBoundingClientRect();
    return [rr, rr];
  };
  // the small Ask AI button over a selection made with the mouse or the keys
  function bubble(o){
    const g = getSelection(), sel = o || (document.activeElement === ed && g.rangeCount && !g.isCollapsed ? E.get() : null);
    if (!sel || E.reviewing() || job || lk || !E.text(sel).trim()){ ask.classList.remove('show'); return; }
    place(ask, sel, true); ask.classList.add('show');
  }
  const E = rteEngine(ed, {
    change(){ paint(); },
    select(){ paint(); bubble(); },
    // a / typed at the start of a line opens the menu
    input(e){ if (e.inputType === 'insertText' && e.data === '/'){ const o = E.get(); if (o.s === o.e && E.line(o.s).s === o.s - 1) openMenu('slash'); } },
    key(e){
      if (E.reviewing()){
        if ((e.key === 'Tab' || e.key === 'Enter') && !e.shiftKey && !e.altKey && !e.metaKey && !e.ctrlKey){ e.preventDefault(); accept(true); return true; }
        if (e.key === 'Escape' || ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'z')){ e.preventDefault(); e.stopPropagation(); discard(true); return true; }
        return false;
      }
      const mod = MAC ? e.metaKey && !e.ctrlKey : e.ctrlKey && !e.metaKey, k = e.key.toLowerCase();
      if (mod && !e.altKey && !e.shiftKey && (k === 'k' || k === 'j')){ e.preventDefault(); e.stopPropagation(); k === 'k' ? openLink(true) : openMenu('key'); return true; }
      if (e.key === 'Escape' && ask.classList.contains('show')){ ask.classList.remove('show'); e.stopPropagation(); return true; }
      return false;
    }
  });
  c.offs.push(() => E.destroy());

  /* ---- the toolbar: one tab stop, the arrow keys move along it; a click keeps the focus and the selection in the text ---- */
  const tbs = () => $$('button', tb).filter(b => !b.disabled);
  $$('button', tb).forEach((b, i) => b.tabIndex = i ? -1 : 0);
  c.on(tb, 'mousedown', e => { if (e.target.closest('button')) e.preventDefault(); });
  c.on(tb, 'focusin', e => { if (e.target.matches('button')) $$('button', tb).forEach(b => b.tabIndex = b === e.target ? 0 : -1); });
  c.on(tb, 'keydown', e => {
    const all = tbs(), i = all.indexOf(document.activeElement); if (i < 0) return;
    const go = { ArrowRight:all[(i + 1) % all.length], ArrowLeft:all[(i - 1 + all.length) % all.length], Home:all[0], End:all[all.length - 1] }[e.key];
    if (go){ e.preventDefault(); go.focus(); }
  });
  $$('.rte-b', tb).forEach(b => b.addEventListener('click', e => {
    const f = b.dataset.f; if (E.reviewing()) return;
    if (f === 'a') return lk ? closeLink(true) : openLink(e.isTrusted, e.isTrusted && !e.detail);
    if (/^(b|i|code)$/.test(f)) E.inline(f.toUpperCase()); else E.block(f);
    if (e.isTrusted) E.focus();
  }));
  // undo and redo in the foot
  c.on($('.rte-idle', r), 'mousedown', e => { if (e.target.closest('button')) e.preventDefault(); });
  unB.onclick = e => { E.undo(); if (e.isTrusted) E.focus(); };
  reB.onclick = e => { E.redo(); if (e.isTrusted) E.focus(); };

  /* ---- the AI menu: a menu of actions on the selection, or on the line the caret is in ---- */
  function openMenu(how){
    if (E.reviewing()) return; closeLink(false); E.flush();
    if (job) return closeMenu(how !== 'story');
    const o = E.get(), slash = how === 'slash' ? o.s - 1 : null, sel = o.s !== o.e;
    let t = sel ? o : E.line(o.s); if (slash !== null) t = { s:slash + 1, e:t.e };
    const has = !!E.text(t).trim();
    // opened from its button with the keyboard, Escape gives the focus back to the button; opened from the text, to the text
    job = { o, t, slash, sel, btn:how === 'button' };
    ph.textContent = sel ? 'Edit the selection' : has ? 'Edit this paragraph' : 'Write with AI';
    $$('.mi', menu).forEach(m => { m.setAttribute('aria-disabled', String(m.dataset.a !== 'continue' && !has)); m.tabIndex = -1; m.classList.remove('hl'); });
    ask.classList.remove('show'); pick(sel ? o : null);
    hideTip(); place(menu, o); menu.classList.add('open'); menu.inert = false; aiB.setAttribute('aria-expanded', 'true');
    if (how !== 'story') item(items()[0]);
  }
  function closeMenu(back){
    if (!job) return; const j = job; job = null;
    menu.classList.remove('open'); menu.inert = true; aiB.setAttribute('aria-expanded', 'false'); pick(null);
    if (back && j.btn){ E.set(j.o); aiB.focus({ preventScroll:true }); } else if (back) E.set(j.o, true);
  }
  menu.inert = true;
  const items = () => $$('.mi', menu).filter(m => m.getAttribute('aria-disabled') !== 'true');
  const item = m => { if (!m) return; $$('.mi', menu).forEach(x => x.tabIndex = x === m ? 0 : -1); m.focus({ preventScroll:true }); };
  // the arrow keys move and wrap, Home and End jump, a letter jumps to the item it starts, Escape and Tab go back to the text
  c.on(menu, 'keydown', e => {
    const all = items(), i = all.indexOf(document.activeElement);
    const go = { ArrowDown:all[(i + 1) % all.length], ArrowUp:all[(i - 1 + all.length) % all.length], Home:all[0], End:all[all.length - 1] }[e.key];
    if (go){ e.preventDefault(); item(go); }
    else if (e.key === 'Escape' || e.key === 'Tab'){ e.preventDefault(); e.stopPropagation(); closeMenu(true); }
    else if (e.key.length === 1 && /\S/.test(e.key) && !e.metaKey && !e.ctrlKey){ const k = e.key.toLowerCase(), hit = [...all.slice(i + 1), ...all.slice(0, i + 1)].find(m => m.textContent.trim().toLowerCase().startsWith(k)); if (hit) item(hit); }
  });
  c.on(menu, 'mousedown', e => e.preventDefault());
  $$('.mi', menu).forEach(m => m.addEventListener('click', e => { if (m.getAttribute('aria-disabled') !== 'true') run(m.dataset.a, e.isTrusted); }));
  aiB.addEventListener('click', e => openMenu(e.isTrusted ? (e.detail ? 'click' : 'button') : 'story'));
  c.on(ask, 'mousedown', e => e.preventDefault());
  ask.addEventListener('click', e => openMenu(e.isTrusted ? 'click' : 'story'));
  c.on(document, 'pointerdown', e => {
    if (job && !menu.contains(e.target) && !aiB.contains(e.target) && !ask.contains(e.target)) closeMenu(false);
    if (lk && !lnk.contains(e.target) && !linkB.contains(e.target)) closeLink(false);
    if (e.isTrusted && !ask.contains(e.target) && !job) ask.classList.remove('show');
  });
  c.on(window, 'resize', e => { if (!e.isTrusted) return; closeMenu(false); closeLink(false); ask.classList.remove('show'); });

  /* ---- a suggestion streams in; Accept (Tab or Enter) keeps it, Discard (Esc) leaves the text as it was ---- */
  function run(a, mine){
    const j = job; closeMenu(false); if (!j || E.reviewing()) return;
    let t = j.t, end = j.o.e;
    // the / that opened the menu goes, and what comes after it moves back one
    if (j.slash !== null){ E.remove({ s:j.slash, e:j.slash + 1 }); const back = x => x > j.slash ? x - 1 : x; t = { s:back(t.s), e:back(t.e) }; end = back(end); }
    const kind = a === 'continue' ? 'add' : 'swap';
    if (kind === 'add') t = { s:end, e:end };
    const doc = E.text({ s:0, e:E.total() }), before = E.text({ s:0, e:t.s }), said = E.text(t);
    let out = rteWrite(a, said, doc);
    if (kind === 'add' && before && !/\s$/.test(before)) out = ' ' + out;
    const write = E.suggest(t, kind), token = rv = { a, ready:false };
    root.dataset.st = 'review'; okB.disabled = true; paint(); ask.classList.remove('show');
    st.innerHTML = `<span class="think on" aria-hidden="true"><i></i><i></i><i></i><i></i></span><span class="shim">${ACT[a]}</span>`;
    say(`${ACT[a]}: writing a suggestion`);
    if (mine) E.focus();
    c.run(async () => {
      if (REDUCE) write(out);
      else { await c.wait(450); for (const w of out.match(/\S+\s*|\s+/g) || []){ if (rv !== token) return; write(w); await c.wait(48); } }
      if (rv !== token) return;
      token.ready = true; okB.disabled = false;
      st.innerHTML = `${I('spark')}<span>Suggestion ready</span>`;
      const said = out.trim(); say(`Suggestion: ${said}${/[.!?]$/.test(said) ? '' : '.'} Press Tab or Enter to accept, or Escape to discard.`);
    });
  }
  function accept(mine){ if (!rv || !rv.ready) return; rv = null; E.accept(); root.dataset.st = 'edit'; paint(); say('Suggestion accepted'); if (mine) E.focus(); }
  function discard(mine){ if (!E.reviewing()) return; rv = null; E.discard(); root.dataset.st = 'edit'; paint(); say('Suggestion discarded'); if (mine) E.focus(); }
  c.on($('.rte-rev', r), 'mousedown', e => { if (e.target.closest('button')) e.preventDefault(); });
  okB.addEventListener('click', e => accept(e.isTrusted));
  noB.addEventListener('click', e => discard(e.isTrusted));
  c.on(root, 'keydown', e => { if (e.key === 'Escape' && E.reviewing() && !ed.contains(e.target)){ e.preventDefault(); e.stopPropagation(); discard(true); } });

  /* ---- the link field ---- */
  lnk.inert = true;
  const toUrl = v => { v = v.trim(); if (!v) return null; if (/^(https?:\/\/|mailto:)/i.test(v)) return v; if (/^[^\s@/]+@[^\s@]+\.[^\s@]+$/.test(v)) return 'mailto:' + v; if (/^[\w-]+(\.[\w-]+)+(:\d+)?([/?#]\S*)?$/.test(v)) return 'https://' + v; return null; };
  function openLink(mine, btn){
    if (E.reviewing()) return; closeMenu(false); E.flush();
    const o = E.get(), s = E.state(); lk = { o, btn };
    url.value = s.href || ''; url.removeAttribute('aria-invalid'); $('.rte-lrm', lnk).hidden = !s.href;
    ask.classList.remove('show'); pick(o); hideTip();
    place(lnk, o); lnk.classList.add('open'); lnk.inert = false; linkB.setAttribute('aria-expanded', 'true');
    if (mine) url.focus({ preventScroll:true });
  }
  function closeLink(back){
    if (!lk) return; const j = lk; lk = null;
    lnk.classList.remove('open'); lnk.inert = true; linkB.setAttribute('aria-expanded', 'false'); pick(null);
    if (back && j.btn){ E.set(j.o); linkB.focus({ preventScroll:true }); } else if (back) E.set(j.o, true);
  }
  function applyLink(){
    if (!lk) return; const h = toUrl(url.value);
    if (!h){ url.setAttribute('aria-invalid', 'true'); url.focus({ preventScroll:true }); return; }
    const o = lk.o; closeLink(false); E.set(o); E.link(o, h); E.focus();
  }
  $('.rte-lok', lnk).onclick = applyLink;
  $('.rte-lrm', lnk).onclick = () => { if (!lk) return; const o = lk.o; closeLink(false); E.set(o); E.link(o, null); E.focus(); };
  c.on(url, 'input', () => url.removeAttribute('aria-invalid'));
  // Tab past its last button leaves the field, and it closes
  c.on(lnk, 'focusout', e => { if (lk && e.relatedTarget && !lnk.contains(e.relatedTarget)) closeLink(false); });
  c.on(lnk, 'keydown', e => { if (e.key === 'Enter' && e.target === url){ e.preventDefault(); applyLink(); } else if (e.key === 'Escape'){ e.preventDefault(); e.stopPropagation(); closeLink(true); } });
  paint();

  // the preview's story: a heading and a short draft about the pricing launch, then the last sentence is picked and made
  // shorter; the suggestion streams in and is accepted. A click or a key of yours takes over, and the editor is yours
  const DRAFT = 'New pricing goes live on March 3. Starter teams get two more seats for free, and every plan now includes the agent. We know pricing changes can feel like a lot, so we want to make sure everyone has plenty of time to look at the new plans and ask us questions before anything changes.';
  const LAST = DRAFT.slice(DRAFT.indexOf('We know'));
  c.on(r, 'pointerdown', e => { if (e.isTrusted && !job) pick(null); }, true);
  if (r === c.st) c.story(async s => {
    const type = async t => { for (const w of t.match(/\S+\s*/g)){ E.type(w); await s.beat(.24); } };
    await s.beat(3);
    await s.tap($('[data-f="h2"]', tb)); s.away();
    await type('Pricing launch'); await s.beat(1);
    E.split(); await s.beat(.5);
    await type(DRAFT); await s.beat(3);
    const at = E.text({ s:0, e:E.total() }).indexOf(LAST), o = { s:at, e:at + LAST.length };
    E.set(o); E.show(o); pick(o); bubble(o); await s.beat(2);
    await s.tap(ask); await s.beat(2);
    const it = $('.mi[data-a="shorter"]', menu); it.classList.add('hl'); await s.point(it); await s.beat(1);
    await s.tap(it); s.away();
    await s.until(() => rv && rv.ready); await s.beat(4);
    await s.tap(okB); s.away(); await s.beat(3);
  });
});
06

Questions

What people ask about it.

How do I connect it to my model?
Pass ai, a function that gets { action, text, before, after, document, signal }. Return an async iterable of text chunks to stream the answer, or a string or a promise of one, which is written in a word at a time. Stop your request when signal aborts, which happens when the person discards the suggestion.
What does the default AI do?
It is a demo, so the preview works on its own: canned rewrites for the preview's draft about a pricing launch, and for anything else simple rules that drop filler words and fix common typos. Pass your own ai in a product.
What format is the value in?
HTML with p, h2, ul, ol, li and blockquote for lines, and b, i, code and a href inside them. Anything else you pass in is cleaned out the same way a paste is.
Is pasted HTML safe?
The editor reads it with a parser that runs nothing, and rebuilds it from the few tags it keeps, without attributes other than a link's address. Links to anything but http, https and mailto lose their address and stay as text.
Can people keep typing while a suggestion streams in?
No. The text is held still until the suggestion is accepted or discarded, so the words it replaces can't change under it. Escape discards it straight away, even while it is still being written.
07

More in Conversation

Everything for asking, answering and replying, by text or by voice.

Live answerAnswers that stream in with numbered sources and next steps.
ComposerMessage input with attachments, what to work on, a mode and dictation.
Thread panelSwitch between agent threads and reply in place.
Artifact cardA file the agent made, with its versions, Open, Copy and Download.
Tool callEach tool the agent calls, with its input and output.
Voice captureTalk to the agent, then fix what it misheard before sending.
Voice orbA hands-free voice chat, with an orb that shows who is talking.
Answer action barCopy, regenerate, rate and listen to an answer from one row under it.
All 15 in Conversation →