/* ============================================================
   beedle — RichText: a tiny dependency-free rich-text editor
   --------------------------------------------------------------
   Used for the free-PROSE fields that flow into the exported report
   (model description, footer/disclaimer, manual-override reason).
   Values are stored as self-styling HTML — inline styles (font-size
   in px, color, text-align) + plain tags <b><i><u><br><div><span> —
   so the SAME markup renders in the app and in the standalone HTML
   export (which inlines report.css) with no report.css additions.

   Engine: contentEditable + document.execCommand (styleWithCSS on).
   The editor is UNCONTROLLED (innerHTML is written once on mount and
   only re-synced from the prop while NOT focused) to avoid caret jumps.

   Exposes: window.RichText and the pure helpers richHtml / isEmptyHtml
   / sanitizeHtml (used by the report at render time), plus
   window.RichTextInternals for tests.
   ============================================================ */
(function () {
  // Named font sizes → px. "normal" is the body default and emits NO span.
  const SIZE_PX = { small: 11, normal: 13, medium: 16, big: 20, title: 28 };
  // A fixed, on-brand colour palette (ink / blue / red / green / gold / grey).
  const COLORS = ["#14242E", "#15B4E7", "#C21626", "#1E9E6A", "#B07E0C", "#7E8E94"];

  const ALLOWED_STYLE = new Set(["color", "font-size", "text-align", "font-weight", "font-style", "text-decoration"]);

  // ---- per-profile tag policy ----------------------------------
  // The editor has two sanitisation profiles. "prose" is the original narrow
  // set used by the report-bound fields (description / footer / notes / reason);
  // "doc" is a superset used only by the data-room HTML-document editor. A tag
  // maps to `true` (allowed, only a filtered `style` survives) or to a map of
  // per-attribute validators (each returns a cleaned value to keep, or null/""
  // to drop the attribute).
  const PROSE_TAGS = { B: true, STRONG: true, I: true, EM: true, U: true, BR: true, DIV: true, P: true, SPAN: true };

  function escapeHtml(s) {
    return String(s == null ? "" : s).replace(/[&<>"]/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c]));
  }
  // Escape a value for safe emission inside a double-quoted attribute (regex path).
  function escapeAttr(s) {
    return String(s == null ? "" : s).replace(/[&"]/g, c => (c === "&" ? "&amp;" : "&quot;"));
  }

  // ---- asset-token helpers -------------------------------------
  // Stored doc HTML keeps bare /api/documents/asset/<key> srcs (no token); the
  // auth token is appended only for live display/editing and stripped on save,
  // so saved markup never carries a (user-specific, expiring) token.
  function stripAssetTokens(url) {
    return String(url == null ? "" : url).replace(
      /^(\/api\/documents\/asset\/[^?#]+)(?:\?token=[^#]*)?(#.*)?$/i,
      (m, path, frag) => path + (frag || "")
    );
  }
  function withAssetTokens(html) {
    const t = (typeof window !== "undefined" && window.api && window.api.token && window.api.token()) || "";
    if (!t) return String(html == null ? "" : html);
    return String(html).replace(
      /(\ssrc\s*=\s*["'])(\/api\/documents\/asset\/[^"'?#]+)(["'])/gi,
      (m, p1, url, p3) => p1 + url + "?token=" + t + p3
    );
  }

  // ---- attribute validators (pure; exported for tests) ----------
  function validHref(v) {
    v = String(v == null ? "" : v).trim();
    if (!v) return null;
    if (/^(javascript|data|vbscript|file):/i.test(v)) return null;   // block dangerous schemes
    if (/^(https?:|mailto:)/i.test(v)) return v;
    if (/^(\/|\.{0,2}\/|#)/.test(v)) return v;                        // root / relative / anchor
    if (!/:/.test(v)) return v;                                       // scheme-less relative
    return null;                                                      // unknown scheme → drop
  }
  function validImgSrc(v) {
    v = stripAssetTokens(String(v == null ? "" : v).trim());
    if (!v) return null;
    if (/^https?:\/\//i.test(v)) return v;                            // absolute http(s)
    if (/^data:image\/(png|jpe?g|gif|webp|avif|svg\+xml);/i.test(v)) return v; // data:image/* only
    if (/^\/[^/]/.test(v)) return v;                                  // root-relative /api/… (rejects //host)
    return null;
  }
  function validDim(v) { v = String(v == null ? "" : v).trim(); return /^\d{1,4}$/.test(v) ? v : null; }
  function keepShort(v) { const s = String(v == null ? "" : v); return s.length > 512 ? s.slice(0, 512) : s; }

  const DOC_TAGS = Object.assign({}, PROSE_TAGS, {
    H1: true, H2: true, H3: true,
    UL: true, OL: true, LI: true,
    BLOCKQUOTE: true, HR: true,
    TABLE: true, THEAD: true, TBODY: true, TR: true, TD: true, TH: true,
    A: { href: validHref },
    IMG: { src: validImgSrc, alt: keepShort, width: validDim, height: validDim },
  });

  const PROFILES = { prose: { tags: PROSE_TAGS }, doc: { tags: DOC_TAGS } };
  function profileOf(p) { return PROFILES[p] || PROFILES.prose; }

  // A value is "HTML" once it carries a real tag (an opener/closer that closes
  // with a >). "a<b" (no >) and "x < y" stay plain text and get escaped.
  function isHtml(v) { return /<\/?[a-z][\s\S]*>/i.test(v || ""); }

  // Treats <br>, <div><br></div>, &nbsp;, <p></p> etc. as empty so the cover's
  // `{description && …}` guard still hides the block for "blank" rich values.
  function isEmptyHtml(v) {
    if (v == null) return true;
    const txt = String(v)
      .replace(/<br\s*\/?>/gi, "")
      .replace(/<[^>]+>/g, "")
      .replace(/&nbsp;|&#160;/gi, " ")
      .replace(/\s+/g, "");
    return txt === "";
  }

  // Has the modal draft actually diverged from the value it opened with? Two
  // markups that are both "blank" (see isEmptyHtml) count as unchanged, so a
  // stray <br> the engine emits never triggers a spurious "discard?" prompt.
  function draftDirty(original, draft) {
    if (isEmptyHtml(original) && isEmptyHtml(draft)) return false;
    return String(original == null ? "" : original) !== String(draft == null ? "" : draft);
  }

  // Keep only whitelisted declarations; drop url()/expression/javascript: and
  // absurdly long values. Returns a clean "prop:val;prop:val" string.
  function safeStyle(decl) {
    const out = [];
    String(decl || "").split(";").forEach(part => {
      const i = part.indexOf(":"); if (i < 0) return;
      const prop = part.slice(0, i).trim().toLowerCase();
      const val = part.slice(i + 1).trim();
      if (!ALLOWED_STYLE.has(prop)) return;
      if (/url\s*\(|expression|javascript:/i.test(val)) return;
      if (!val || val.length > 64) return;
      out.push(prop + ":" + val);
    });
    return out.join(";");
  }

  // Regex sanitizer — the contract used in tests (the vm sandbox has no
  // DOMParser) and the fallback when DOMParser is unavailable. Strips
  // <script>/<style> with content, comments, all non-whitelisted tags
  // (unwrapped to their text), and every attribute except a filtered style.
  function sanitizeFallback(html, profile) {
    const cfg = profileOf(profile);
    let s = String(html == null ? "" : html);
    s = s.replace(/<(script|style)\b[\s\S]*?<\/\1\s*>/gi, "");
    s = s.replace(/<!--[\s\S]*?-->/g, "");
    s = s.replace(/<\/?(script|style)\b[^>]*>/gi, "");
    s = s.replace(/<(\/?)([a-zA-Z][a-zA-Z0-9]*)((?:[^>"']|"[^"]*"|'[^']*')*)>/g, (m, slash, tag, attrs) => {
      const t = tag.toUpperCase();
      const policy = cfg.tags[t];
      if (!policy) return "";                           // drop tag markup, keep inner text
      const lc = tag.toLowerCase();
      if (slash) return "</" + lc + ">";
      let out = "";
      const sm = attrs.match(/\sstyle\s*=\s*("([^"]*)"|'([^']*)')/i);
      if (sm) { const css = safeStyle(sm[2] != null ? sm[2] : sm[3]); if (css) out += ' style="' + css + '"'; }
      if (policy !== true) {                            // doc-only tags with per-attr validators
        Object.keys(policy).forEach(name => {
          const am = attrs.match(new RegExp("\\s" + name + "\\s*=\\s*(\"([^\"]*)\"|'([^']*)')", "i"));
          if (!am) return;
          const clean = policy[name](am[2] != null ? am[2] : am[3]);
          if (clean != null && clean !== "") out += " " + name + '="' + escapeAttr(clean) + '"';
        });
      }
      if (t === "IMG" && !/\ssrc=/i.test(out)) return ""; // an <img> with no surviving src is dropped
      return "<" + lc + out + ">";
    });
    return s;
  }

  // Browser sanitizer: parse, walk, keep whitelisted tags + a filtered style,
  // unwrap everything else to text. Falls back to the regex sanitizer when no
  // DOMParser (Node test sandbox).
  function sanitizeHtml(html, profile) {
    const cfg = profileOf(profile);
    if (typeof DOMParser === "undefined") return sanitizeFallback(html, profile);
    const doc = new DOMParser().parseFromString("<div>" + (html == null ? "" : html) + "</div>", "text/html");
    const root = doc.body.firstChild;
    const walk = (node) => {
      Array.prototype.slice.call(node.childNodes).forEach(child => {
        if (child.nodeType === 3) return;               // text — keep
        if (child.nodeType !== 1) { child.remove(); return; } // comments etc.
        if (child.tagName === "SCRIPT" || child.tagName === "STYLE") { child.remove(); return; }
        const policy = cfg.tags[child.tagName];
        if (!policy) {                                   // unwrap unknown → keep text
          child.replaceWith(doc.createTextNode(child.textContent || "")); return;
        }
        Array.prototype.slice.call(child.attributes).forEach(a => {
          const name = a.name.toLowerCase();
          if (name === "style") { const css = safeStyle(a.value); if (css) child.setAttribute("style", css); else child.removeAttribute("style"); }
          else if (policy !== true && policy[name]) { const clean = policy[name](a.value); if (clean != null && clean !== "") child.setAttribute(a.name, clean); else child.removeAttribute(a.name); }
          else child.removeAttribute(a.name);            // drop class/id/on*/everything not whitelisted
        });
        if (child.tagName === "IMG" && !child.getAttribute("src")) { child.remove(); return; } // no valid src
        walk(child);
      });
    };
    walk(root);
    return root.innerHTML;
  }

  // Render helper used by the report: rich values are sanitized; legacy plain
  // text is escaped and its newlines become <br> (no reliance on pre-wrap).
  function richHtml(value, profile) {
    const v = value == null ? "" : String(value);
    if (isHtml(v)) return sanitizeHtml(v, profile);     // profile undefined → "prose"
    return escapeHtml(v).replace(/\n/g, "<br>");
  }

  // Pure string core of the font-size apply (tested without a DOM): rewrite the
  // <font size="7"> wrappers execCommand emits into inline-px spans, or unwrap
  // them for "normal". The live editor uses applyNamedSize (DOM) which also
  // preserves any colour the <font> carried.
  function normalizeFontTags(html, name) {
    const px = SIZE_PX[name];
    return String(html == null ? "" : html).replace(
      /<font\b[^>]*\bsize\s*=\s*["']?7["']?[^>]*>([\s\S]*?)<\/font>/gi,
      (m, inner) => (name === "normal" || !px) ? inner : '<span style="font-size:' + px + 'px">' + inner + "</span>"
    );
  }

  // Apply a named size to the current selection: let the browser split the range
  // with the legacy size 7, then rewrite only those fresh <font size="7"> nodes.
  function applyNamedSize(rootEl, name) {
    const px = SIZE_PX[name];
    // styleWithCSS MUST be off here: with it on, fontSize "7" emits a single
    // <span style="font-size:xx-large"> (the size-7 keyword) instead of the
    // <font size="7"> we rewrite below — so every named size would collapse to
    // that one oversized keyword. Force it off, rewrite, then restore.
    try { document.execCommand("styleWithCSS", false, false); } catch (e) {}
    document.execCommand("fontSize", false, "7");
    Array.prototype.slice.call(rootEl.querySelectorAll('font[size="7"]')).forEach(f => {
      const span = document.createElement("span");
      if (name !== "normal" && px) span.style.fontSize = px + "px";
      const color = f.getAttribute("color"); if (color) span.style.color = color;
      while (f.firstChild) span.appendChild(f.firstChild);
      if (span.getAttribute("style")) f.replaceWith(span);
      else { const frag = document.createDocumentFragment(); while (span.firstChild) frag.appendChild(span.firstChild); f.replaceWith(frag); }
    });
    try { document.execCommand("styleWithCSS", false, true); } catch (e) {} // restore for colour/bold/etc.
  }

  // ---------------------------------------------------------------
  // Toolbar glyphs — inline SVGs (the shared icon registry has no
  // bold/italic/underline/align/type glyphs; keep them local here).
  // ---------------------------------------------------------------
  const Svg = (p) => React.createElement("svg", { width: p.size || 15, height: p.size || 15, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: p.sw || 1.9, strokeLinecap: "round", strokeLinejoin: "round" }, p.children);
  const GBold = () => <Svg sw={2.2}><path d="M7 5h6.5a3.5 3.5 0 0 1 0 7H7zM7 12h7.5a3.5 3.5 0 0 1 0 7H7z" /></Svg>;
  const GItalic = () => <Svg><path d="M15 5h-5M14 19H9M14.5 5l-4 14" /></Svg>;
  const GUnder = () => <Svg><path d="M7 4v6a5 5 0 0 0 10 0V4M5 21h14" /></Svg>;
  const GAlignL = () => <Svg><path d="M4 6h16M4 10h10M4 14h16M4 18h10" /></Svg>;
  const GAlignC = () => <Svg><path d="M4 6h16M7 10h10M4 14h16M7 18h10" /></Svg>;
  const GAlignR = () => <Svg><path d="M4 6h16M10 10h10M4 14h16M10 18h10" /></Svg>;
  const GSize = () => <Svg><path d="M4 18 9 6l5 12M5.5 14h7" /><path d="M15 18l3-8 3 8M16 15h4" /></Svg>;
  // doc-mode glyphs (heading / lists / quote / divider / link / image)
  const GHead = () => <Svg sw={2.1}><path d="M6 5v14M14 5v14M6 12h8" /><path d="M17.5 9.5 20 8.5V19" /></Svg>;
  const GUL = () => <Svg><path d="M8 6h12M8 12h12M8 18h12" /><path d="M3.6 6h.01M3.6 12h.01M3.6 18h.01" /></Svg>;
  const GOL = () => <Svg sw={1.7}><path d="M9 6h11M9 12h11M9 18h11M3.6 5l1.1-.6V8M3.3 8h2" /></Svg>;
  const GQuote = () => <Svg><path d="M7 7H4v5h3l-1.4 4M16 7h-3v5h3l-1.4 4" /></Svg>;
  const GHr = () => <Svg sw={2.2}><path d="M4 12h16" /></Svg>;
  const GLink = () => <Svg><path d="M10 13a4.5 4.5 0 0 0 6.5.3l2-2a4.5 4.5 0 0 0-6.4-6.4l-1 1M14 11a4.5 4.5 0 0 0-6.5-.3l-2 2a4.5 4.5 0 0 0 6.4 6.4l1-1" /></Svg>;
  const GImage = () => <Svg><path d="M4 5h16v14H4z" /><path d="M4 16l4.5-4.5 4 4 3-3L20 16" /><circle cx="9" cy="9" r="1.3" /></Svg>;

  // ---------------------------------------------------------------
  // RichText — compact inline editor (toolbar reveals on focus) with an
  // expand-to-modal affordance. Uncontrolled contentEditable.
  // ---------------------------------------------------------------
  function RichText({ value, onChange, placeholder, compact, minHeight = 64, ariaLabel, className, full, uploadAsset, onError }) {
    const ref = React.useRef(null);
    const fileRef = React.useRef(null);
    const focused = React.useRef(false);
    const [marks, setMarks] = React.useState({});
    const [sizeOpen, setSizeOpen] = React.useState(false);
    const [headOpen, setHeadOpen] = React.useState(false);
    const [expanded, setExpanded] = React.useState(false);
    const profile = full ? "doc" : "prose";
    // What the DOM should hold for a given prop value: doc mode shows live
    // image tokens (stripped again on save), prose mode is the raw value.
    const forDom = (v) => (full ? withAssetTokens(v || "") : (v || ""));

    // init innerHTML once
    React.useEffect(() => { if (ref.current) ref.current.innerHTML = forDom(value); }, []);
    // sync down from the prop only when unfocused & the DOM differs (no caret reset)
    React.useEffect(() => {
      const el = ref.current;
      if (el && !focused.current && el.innerHTML !== forDom(value)) el.innerHTML = forDom(value);
    }, [value]);

    // Emit token-free HTML: in doc mode sanitize (which strips asset tokens) so
    // the stored draft never carries a token; prose stays a raw passthrough.
    const emit = React.useCallback(() => {
      const el = ref.current; if (!el || !onChange) return;
      onChange(full ? sanitizeHtml(el.innerHTML, "doc") : el.innerHTML);
    }, [onChange, full]);
    const refresh = React.useCallback(() => {
      try { setMarks({ bold: document.queryCommandState("bold"), italic: document.queryCommandState("italic"), underline: document.queryCommandState("underline") }); }
      catch (e) {}
    }, []);
    // keep B/I/U highlight in sync with the caret while focused
    React.useEffect(() => {
      const onSel = () => { if (focused.current) refresh(); };
      document.addEventListener("selectionchange", onSel);
      return () => document.removeEventListener("selectionchange", onSel);
    }, [refresh]);

    const run = (fn) => (e) => { e.preventDefault(); ref.current.focus(); try { document.execCommand("styleWithCSS", false, true); } catch (x) {} fn(); emit(); refresh(); };
    const exec = (cmd, val) => run(() => document.execCommand(cmd, false, val));

    // ---- doc-mode image / paste / drop (no-ops unless `full`) ----
    const insertImageAtCaret = (bareSrc) => {
      const el = ref.current; if (!el) return;
      el.focus();
      try { document.execCommand("insertHTML", false, '<img src="' + escapeAttr(withAssetTokens(bareSrc)) + '">'); } catch (x) {}
      emit();
    };
    const handleFiles = async (files) => {
      if (!uploadAsset || !files) return;
      const imgs = Array.prototype.filter.call(files, f => /^image\//.test(f.type));
      for (const f of imgs) {
        try { const url = await uploadAsset(f); if (url) insertImageAtCaret(url); }
        catch (e) { if (onError) onError(e); }
      }
    };
    const onPaste = (e) => {
      if (!full) return;                                 // prose fields keep native paste
      const dt = e.clipboardData; if (!dt) return;
      const files = dt.files;
      if (files && files.length && Array.prototype.some.call(files, f => /^image\//.test(f.type))) {
        e.preventDefault(); handleFiles(files); return;  // pasted graphic(s) → upload + insert
      }
      const html = dt.getData && dt.getData("text/html");
      if (html) {                                        // sanitise rich paste before it lands
        e.preventDefault();
        ref.current.focus();
        try { document.execCommand("insertHTML", false, withAssetTokens(sanitizeHtml(html, "doc"))); } catch (x) {}
        emit();
      }                                                  // else: fall through to plain-text paste
    };
    const onDrop = (e) => {
      if (!full) return;
      const dt = e.dataTransfer;
      if (!dt || !dt.files || !dt.files.length) return;
      e.preventDefault();
      try {                                              // place the caret where the file was dropped
        let r = null;
        if (document.caretRangeFromPoint) r = document.caretRangeFromPoint(e.clientX, e.clientY);
        else if (document.caretPositionFromPoint) { const cp = document.caretPositionFromPoint(e.clientX, e.clientY); if (cp) { r = document.createRange(); r.setStart(cp.offsetNode, cp.offset); r.collapse(true); } }
        if (r) { const sel = window.getSelection(); sel.removeAllRanges(); sel.addRange(r); }
      } catch (x) {}
      handleFiles(dt.files);
    };
    const promptLink = (e) => {
      e.preventDefault(); ref.current.focus();
      const url = window.prompt(T("Link URL"), "https://");
      if (url) { try { document.execCommand("createLink", false, url); } catch (x) {} emit(); }
    };

    const T = window.T || ((s) => s);
    const SIZES = [["small", T("Small")], ["normal", T("Normal")], ["medium", T("Medium")], ["big", T("Big")], ["title", T("Title")]];

    return (
      <div className={"rt" + (compact ? " rt-compact" : "") + (className ? " " + className : "")}>
        <div className="rt-toolbar" onMouseDown={e => e.preventDefault()}>
          <button type="button" className={"rt-btn" + (marks.bold ? " on" : "")} title={T("Bold")} onClick={exec("bold")}><GBold /></button>
          <button type="button" className={"rt-btn" + (marks.italic ? " on" : "")} title={T("Italic")} onClick={exec("italic")}><GItalic /></button>
          <button type="button" className={"rt-btn" + (marks.underline ? " on" : "")} title={T("Underline")} onClick={exec("underline")}><GUnder /></button>
          <span className="rt-sep" />
          <div className="rt-size">
            <button type="button" className="rt-btn" title={T("Text size")} onClick={(e) => { e.preventDefault(); setSizeOpen(o => !o); }}><GSize /></button>
            {sizeOpen && (
              <div className="rt-menu">
                {SIZES.map(([k, lbl]) => (
                  <button key={k} type="button" className="rt-menu-item" data-size={k}
                    onClick={(e) => { e.preventDefault(); ref.current.focus(); applyNamedSize(ref.current, k); setSizeOpen(false); emit(); }}>{lbl}</button>
                ))}
              </div>
            )}
          </div>
          <span className="rt-sep" />
          <button type="button" className="rt-btn" title={T("Align left")} onClick={exec("justifyLeft")}><GAlignL /></button>
          <button type="button" className="rt-btn" title={T("Center")} onClick={exec("justifyCenter")}><GAlignC /></button>
          <button type="button" className="rt-btn" title={T("Align right")} onClick={exec("justifyRight")}><GAlignR /></button>
          <span className="rt-sep" />
          <span className="rt-colors">
            {COLORS.map(c => <button key={c} type="button" className="rt-sw" title={T("Text colour")} style={{ background: c }} onClick={exec("foreColor", c)} />)}
          </span>
          {full && (
            <span className="rt-docgrp" style={{ display: "contents" }}>
              <span className="rt-sep" />
              <div className="rt-size">
                <button type="button" className="rt-btn" title={T("Heading")} onClick={(e) => { e.preventDefault(); setHeadOpen(o => !o); }}><GHead /></button>
                {headOpen && (
                  <div className="rt-menu">
                    {[["<h1>", T("Title")], ["<h2>", T("Heading")], ["<h3>", T("Subheading")], ["<p>", T("Paragraph")]].map(([tag, lbl]) => (
                      <button key={tag} type="button" className="rt-menu-item" data-head={tag}
                        onClick={(e) => { e.preventDefault(); ref.current.focus(); try { document.execCommand("formatBlock", false, tag); } catch (x) {} setHeadOpen(false); emit(); }}>{lbl}</button>
                    ))}
                  </div>
                )}
              </div>
              <button type="button" className="rt-btn" title={T("Bulleted list")} onClick={exec("insertUnorderedList")}><GUL /></button>
              <button type="button" className="rt-btn" title={T("Numbered list")} onClick={exec("insertOrderedList")}><GOL /></button>
              <button type="button" className="rt-btn" title={T("Quote")} onClick={run(() => document.execCommand("formatBlock", false, "<blockquote>"))}><GQuote /></button>
              <button type="button" className="rt-btn" title={T("Divider")} onClick={exec("insertHorizontalRule")}><GHr /></button>
              <span className="rt-sep" />
              <button type="button" className="rt-btn" title={T("Link")} onClick={promptLink}><GLink /></button>
              <button type="button" className="rt-btn" title={T("Insert image")} onClick={(e) => { e.preventDefault(); if (fileRef.current) fileRef.current.click(); }}><GImage /></button>
              <input ref={fileRef} type="file" accept="image/*" multiple style={{ display: "none" }}
                onChange={(e) => { handleFiles(e.target.files); e.target.value = ""; }} />
            </span>
          )}
          <span className="rt-grow" />
          <button type="button" className="rt-btn" title={T("Expand")} onClick={(e) => { e.preventDefault(); setExpanded(true); }}><Icon name="expand" size={15} /></button>
        </div>
        <div className="rt-areawrap">
          <div ref={ref} className="rt-area" contentEditable suppressContentEditableWarning
            role="textbox" aria-multiline="true" aria-label={ariaLabel} style={{ minHeight }}
            onFocus={() => { focused.current = true; try { document.execCommand("styleWithCSS", false, true); } catch (e) {} refresh(); }}
            onBlur={() => {
              focused.current = false; setSizeOpen(false); setHeadOpen(false);
              const el = ref.current; if (!el) return;
              const clean = sanitizeHtml(el.innerHTML, profile);
              // re-token for the live DOM so doc-mode images keep rendering
              const dom = full ? withAssetTokens(clean) : clean;
              if (dom !== el.innerHTML) el.innerHTML = dom;
              if (onChange) onChange(clean);
            }}
            onPaste={onPaste} onDrop={onDrop} onDragOver={full ? (e => e.preventDefault()) : undefined}
            onInput={emit} onKeyUp={refresh} onMouseUp={refresh} />
          {isEmptyHtml(value) && <div className="rt-ph">{placeholder}</div>}
        </div>
        {expanded && ReactDOM.createPortal(
          <RichTextModal value={value} label={ariaLabel} full={full} uploadAsset={uploadAsset} onError={onError}
            onDone={(v) => { if (onChange) onChange(v); setExpanded(false); }} onCancel={() => setExpanded(false)} />,
          document.body)}
      </div>
    );
  }

  // Larger modal editing surface — edits a local draft; Done commits, Cancel
  // discards. Reuses the app's .scrim / .modal chrome.
  function RichTextModal({ value, label, onDone, onCancel, full, uploadAsset, onError }) {
    const [draft, setDraft] = React.useState(value || "");
    const [confirmDiscard, setConfirmDiscard] = React.useState(false);
    const T = window.T || ((s) => s);
    // Cancel / × / scrim / Escape all route here: with unsaved edits, raise an
    // in-app "discard?" prompt first so a stray click or keystroke can't lose
    // the draft; with no real change, close straight away.
    const requestCancel = React.useCallback(() => {
      if (draftDirty(value, draft)) setConfirmDiscard(true);
      else onCancel();
    }, [value, draft, onCancel]);
    // Escape dismisses the discard prompt if it's up, otherwise attempts to close.
    React.useEffect(() => {
      const h = (e) => {
        if (e.key !== "Escape") return;
        e.preventDefault();
        if (confirmDiscard) setConfirmDiscard(false);
        else requestCancel();
      };
      document.addEventListener("keydown", h);
      return () => document.removeEventListener("keydown", h);
    }, [confirmDiscard, requestCancel]);
    return (
      <React.Fragment>
        <div className="scrim rt-scrim" onClick={requestCancel}>
          <div className={"modal rt-modal" + (full ? " rt-modal-full" : "")} onClick={e => e.stopPropagation()}>
            <div className="modal-head">
              <div><h2>{label || T("Edit text")}</h2></div>
              <button className="xbtn" onClick={requestCancel}><Icon name="x" size={18} /></button>
            </div>
            <div className="modal-body">
              <RichText value={draft} onChange={setDraft} minHeight="62vh" ariaLabel={label} full={full} uploadAsset={uploadAsset} onError={onError} />
            </div>
            <div className="modal-foot">
              <div className="spacer" style={{ flex: 1 }} />
              <button className="btn btn-ghost" onClick={requestCancel}>{T("Cancel")}</button>
              <button className="btn btn-primary" onClick={() => onDone(draft)}>{T("Done")}</button>
            </div>
          </div>
        </div>
        <Confirm open={confirmDiscard} title={T("Discard changes?")}
          body={T("You have unsaved changes. Discard them?")} danger confirmLabel={T("Discard")}
          onConfirm={onCancel} onCancel={() => setConfirmDiscard(false)} />
      </React.Fragment>
    );
  }

  Object.assign(window, { RichText, richHtml, isEmptyHtml, sanitizeHtml, withAssetTokens });
  window.RichTextInternals = { sanitizeHtml, sanitizeFallback, richHtml, isEmptyHtml, draftDirty, isHtml, normalizeFontTags, escapeHtml, escapeAttr, SIZE_PX, validHref, validImgSrc, validDim, keepShort, stripAssetTokens, withAssetTokens };
})();
