/**
 * Editor abstraction — wraps Quill / tiptap / TinyMCE behind a common
 * interface. The compose window loads this module and calls
 * createEditor() based on the user's setting.
 *
 * rmf-tiny is dynamic-imported (not static) so a missing/unreachable
 * adapter fails ONLY the TinyMCE branch — Quill and tiptap stay
 * working. A static top-of-file import would crash the whole module
 * if the browser couldn't resolve "@bobfrankston/rmf-tiny" via the
 * import map, and compose.ts (which imports createEditor from here)
 * would silently fail to attach handlers.
 */
import { looksLikeMarkdown, markdownToEmailHtml, looksLikeHtml, sanitizeHtmlSource, wireMarkdownPaste } from "./paste-markdown.js";
/** URL-ish test: accepts http(s)://, mailto:, tel:, and bare domains with a dot. */
function looksLikeUrl(s) {
    const t = s.trim();
    if (!t)
        return false;
    if (/^(https?|mailto|tel):/i.test(t))
        return true;
    // bare domain (e.g. "example.com/path") — require a dot and no internal whitespace
    return /^[\w-]+(\.[\w-]+)+(\/\S*)?$/.test(t);
}
function normalizeUrl(s) {
    const t = s.trim();
    if (!t)
        return t;
    if (/^(https?|mailto|tel):/i.test(t))
        return t;
    if (/^[\w.+-]+@[\w-]+(\.[\w-]+)+$/.test(t))
        return `mailto:${t}`;
    return `https://${t}`;
}
/** Floating modal that edits both link text and URL. Returns null on Cancel,
 *  { text, url } on OK, or { text: "", url: "" } on "Remove link". */
function openLinkDialog(initialText, initialUrl) {
    return new Promise(resolve => {
        const backdrop = document.createElement("div");
        backdrop.className = "mailx-modal-backdrop";
        const panel = document.createElement("div");
        panel.className = "mailx-modal";
        panel.innerHTML = `
            <div class="mailx-modal-title">Edit link</div>
            <label class="mailx-modal-label">Text
                <input type="text" class="mailx-modal-input" id="mailx-link-text">
            </label>
            <label class="mailx-modal-label">URL
                <input type="text" class="mailx-modal-input" id="mailx-link-url" spellcheck="false" autocomplete="off">
            </label>
            <div class="mailx-modal-buttons">
                <button type="button" class="mailx-modal-btn" data-action="remove">Remove link</button>
                <span class="mailx-modal-spacer"></span>
                <button type="button" class="mailx-modal-btn" data-action="cancel">Cancel</button>
                <button type="button" class="mailx-modal-btn mailx-modal-btn-primary" data-action="ok">OK</button>
            </div>`;
        backdrop.appendChild(panel);
        document.body.appendChild(backdrop);
        const textInput = panel.querySelector("#mailx-link-text");
        const urlInput = panel.querySelector("#mailx-link-url");
        textInput.value = initialText;
        urlInput.value = initialUrl;
        const close = (result) => {
            backdrop.remove();
            document.removeEventListener("keydown", onKey, true);
            resolve(result);
        };
        const commit = () => close({ text: textInput.value, url: normalizeUrl(urlInput.value) });
        const onKey = (e) => {
            if (e.key === "Escape") {
                e.stopPropagation();
                e.preventDefault();
                close(null);
            }
            else if (e.key === "Enter") {
                e.stopPropagation();
                e.preventDefault();
                commit();
            }
        };
        document.addEventListener("keydown", onKey, true);
        panel.querySelectorAll(".mailx-modal-btn").forEach(btn => {
            btn.addEventListener("click", () => {
                const action = btn.dataset.action;
                if (action === "cancel")
                    close(null);
                else if (action === "remove")
                    close({ text: textInput.value, url: "", remove: true });
                else
                    commit();
            });
        });
        backdrop.addEventListener("mousedown", (e) => { if (e.target === backdrop)
            close(null); });
        // Focus URL if we have text, else text first
        (initialText ? urlInput : textInput).focus();
        (initialText ? urlInput : textInput).select();
    });
}
function createQuillEditor(container) {
    /** Open the link dialog for the current selection or cursor. If range has
     *  a selection, prefill text from the selected text; if cursor is inside
     *  a link, prefill both from the link's range. */
    const openLinkForRange = async (quill, range) => {
        if (!range)
            return;
        // Expand a bare cursor inside a link to the full link range
        let linkRange = range;
        const format = quill.getFormat(range);
        if (range.length === 0 && format.link) {
            // Walk left and right to find the link extent
            const [leaf, offset] = quill.getLeaf(range.index);
            if (leaf) {
                // Build a range that spans the whole link
                const text = quill.getText();
                let start = range.index, end = range.index;
                while (start > 0 && quill.getFormat(start - 1, 1).link === format.link)
                    start--;
                while (end < text.length - 1 && quill.getFormat(end, 1).link === format.link)
                    end++;
                linkRange = { index: start, length: end - start };
            }
        }
        const currentText = linkRange.length ? quill.getText(linkRange.index, linkRange.length).replace(/\n$/, "") : "";
        const currentUrl = format.link || "";
        const result = await openLinkDialog(currentText, currentUrl);
        if (!result)
            return;
        if (result.remove) {
            if (linkRange.length)
                quill.formatText(linkRange.index, linkRange.length, "link", false);
            return;
        }
        if (!result.url)
            return;
        const newText = result.text || result.url;
        if (linkRange.length) {
            // Replace the existing text+link with new text+link
            quill.deleteText(linkRange.index, linkRange.length);
            quill.insertText(linkRange.index, newText, { link: result.url });
            quill.setSelection(linkRange.index + newText.length, 0);
        }
        else {
            quill.insertText(linkRange.index, newText, { link: result.url });
            quill.setSelection(linkRange.index + newText.length, 0);
        }
    };
    // Extra keybindings for formatting that Quill doesn't wire up by default.
    // Ctrl+K (insert/edit link) is the one most users expect; we also add shortcuts
    // for strikethrough, lists, indent, color, and clear-formatting.
    const extraBindings = {
        insertLink: {
            key: "K", shortKey: true,
            handler: function (range) {
                openLinkForRange(this.quill, range);
            },
        },
        removeLink: {
            key: "K", shortKey: true, shiftKey: true,
            handler: function () { this.quill.format("link", false); },
        },
        strike: {
            key: "X", shortKey: true, shiftKey: true,
            handler: function (range) {
                if (!range)
                    return true;
                const cur = this.quill.getFormat(range).strike;
                this.quill.format("strike", !cur);
            },
        },
        orderedList: {
            key: "7", shortKey: true, shiftKey: true,
            handler: function () { this.quill.format("list", "ordered"); },
        },
        bulletList: {
            key: "8", shortKey: true, shiftKey: true,
            handler: function () { this.quill.format("list", "bullet"); },
        },
        indent: {
            key: "]", shortKey: true,
            handler: function (range, context) {
                this.quill.format("indent", (context.format.indent || 0) + 1);
            },
        },
        outdent: {
            key: "[", shortKey: true,
            handler: function (range, context) {
                this.quill.format("indent", Math.max(0, (context.format.indent || 0) - 1));
            },
        },
        color: {
            key: "C", shortKey: true, shiftKey: true,
            handler: function (range) {
                if (!range)
                    return true;
                const current = this.quill.getFormat(range).color || "";
                const color = prompt("Text color (name or #hex, blank to clear):", current);
                if (color === null)
                    return;
                this.quill.format("color", color || false);
            },
        },
        clearFormat: {
            key: "\\", shortKey: true,
            handler: function (range) {
                if (!range)
                    return true;
                this.quill.removeFormat(range.index, range.length || 0);
            },
        },
    };
    const q = new Quill(container, {
        theme: "snow",
        placeholder: "Write your message...",
        modules: {
            toolbar: [
                [{ font: [] }, { size: ["small", false, "large", "huge"] }],
                [{ header: [1, 2, 3, false] }],
                ["bold", "italic", "underline", "strike"],
                [{ color: [] }, { background: [] }],
                [{ list: "ordered" }, { list: "bullet" }],
                [{ align: [] }],
                ["blockquote", "link", "image"],
                ["clean"]
            ],
            keyboard: { bindings: extraBindings },
        }
    });
    // Make toolbar buttons non-tabbable so Tab goes straight to editor body
    document.querySelectorAll(".ql-toolbar button, .ql-toolbar select, .ql-toolbar .ql-picker-label").forEach(el => el.setAttribute("tabindex", "-1"));
    // Native spell-check: WebView2 / Chromium underlines misspellings and the
    // right-click menu offers "Add to dictionary". Quill clears spellcheck on
    // its root by default AND may re-clear it asynchronously as part of its
    // setup (user-reported "spell-check broken again" with the one-shot set).
    // Set once now, again after the frame flushes so post-init clears can't
    // win, and install a MutationObserver to re-assert if anything later
    // strips the attribute. Cheap — fires at most on each clear.
    const applySpellcheck = () => {
        if (q.root.getAttribute("spellcheck") !== "true")
            q.root.setAttribute("spellcheck", "true");
        if (q.root.getAttribute("autocorrect") !== "on")
            q.root.setAttribute("autocorrect", "on");
        if (q.root.getAttribute("autocapitalize") !== "on")
            q.root.setAttribute("autocapitalize", "on");
    };
    applySpellcheck();
    requestAnimationFrame(applySpellcheck);
    setTimeout(applySpellcheck, 100);
    const spellObs = new MutationObserver(() => applySpellcheck());
    spellObs.observe(q.root, { attributes: true, attributeFilter: ["spellcheck", "autocorrect", "autocapitalize"] });
    // Toolbar link button: open our modal instead of Quill's built-in URL prompt.
    const toolbar = q.getModule("toolbar");
    toolbar?.addHandler("link", function () {
        openLinkForRange(q, q.getSelection() || { index: q.getLength() - 1, length: 0 });
    });
    // Paste handling:
    // - text/html clipboard with exactly one anchor (the common "copy a link
    //   with anchor text from a webpage" case): take it over from Quill —
    //   Quill's clipboard module was producing duplicates ("click here" as
    //   text PLUS a separate "https://example.com" as a link tail). Insert
    //   the anchor's text content as a single linked run.
    // - text/html with richer content: defer to Quill (preserves formatting).
    // - text/plain that's a URL: insert as a link, optionally wrapping any
    //   currently-selected text.
    // - Anything else: default Quill behavior (verbatim plain or HTML).
    // C36: AI proofread on right-click → "Proofread selection" item.
    // Uses the existing aiTransform IPC. Gated by autocomplete.proofreadEnabled.
    // ── Spell-check right-click handler (Quill) ──
    // Runs FIRST so a right-click on a misspelled word shows our suggestions
    // menu (Add to dictionary / Ignore / replace) instead of falling through
    // to the WebView2 native menu (which on msger-hosted compose iframes was
    // unreliable for "Add to dictionary" — Bob 2026-05-27 "can't ignore nor
    // add an entry"). Shares the nspell dictionary + user-dict-mirror with
    // the TinyMCE editor's spellcheck.ts via spellcheck-core.ts — same
    // dictionary, same persistence, same suggestion ranking.
    //
    // Decoration (red wavy underlines) is NOT wired here yet — Quill's
    // MutationObserver-based delta tracking interferes with arbitrary
    // span-wrapping. The visible-on-hover suggestion is left to native
    // (WebView2 still shows red squiggles via OS spell-check on
    // spellcheck="true"); we just take over the right-click flow so the
    // user can ACT on a flagged word with persistence that actually works.
    let _spellSp = null;
    import("./spellcheck-core.js").then(m => m.getSpell()).then(sp => { _spellSp = sp; }).catch(() => { });
    q.root.addEventListener("contextmenu", async (e) => {
        try {
            if (!_spellSp)
                return; // dict still loading
            const sel = q.getSelection();
            if (sel && sel.length > 0)
                return; // selection path handled below
            const core = await import("./spellcheck-core.js");
            const hit = core.getWordAtPoint(q.root, e.clientX, e.clientY);
            if (!hit)
                return;
            const { word, node, start, end } = hit;
            if (_spellSp.correct(word))
                return; // word is fine — let native menu fire
            e.preventDefault();
            e.stopPropagation();
            const sugs = core.buildSuggestionList(word, _spellSp);
            const items = [];
            if (sugs.length === 0) {
                items.push({ label: "(no suggestions)", action: () => { } });
            }
            else {
                for (const s of sugs) {
                    items.push({
                        label: s,
                        emphasized: true,
                        action: () => {
                            // Replace via Range so the browser's text-edit
                            // handling kicks in and Quill sees it as a normal
                            // edit (undoable, Delta-tracked). Going through
                            // q.deleteText/insertText needs character offsets
                            // from the Quill index space — possible but more
                            // arithmetic; Range is simpler and works for the
                            // text-node case the misspelling always lives in.
                            try {
                                const range = document.createRange();
                                range.setStart(node, start);
                                range.setEnd(node, end);
                                const docSel = document.getSelection();
                                if (docSel) {
                                    docSel.removeAllRanges();
                                    docSel.addRange(range);
                                    if (!document.execCommand("insertText", false, s)) {
                                        range.deleteContents();
                                        range.insertNode(document.createTextNode(s));
                                    }
                                }
                            }
                            catch { /* */ }
                        },
                    });
                }
            }
            items.push({ label: "", action: () => { }, separator: true });
            items.push({
                label: `Add "${word}" to dictionary`,
                action: () => core.addToUserDict(word, _spellSp),
            });
            items.push({
                label: "Ignore (this session)",
                action: () => _spellSp.add(word),
            });
            // Same reasoning as the TinyMCE path in spellcheck.ts: this menu
            // replaced the native one, so it has to carry the standard editing
            // commands too, or right-clicking a misspelled word loses Cut,
            // Copy, Paste and every formatting entry.
            const { standardEditItems } = await import("./edit-commands.js");
            items.push(...standardEditItems(() => q));
            core.showSuggestionsMenu(document, e.clientX, e.clientY, items);
        }
        catch (err) {
            console.warn("[spellcheck] right-click handler error:", err?.message || err);
        }
    });
    q.root.addEventListener("contextmenu", async (e) => {
        try {
            const sel = q.getSelection();
            if (!sel || sel.length === 0)
                return; // no selection — let native menu handle
            const settingsRaw = localStorage.getItem("mailx-ai-proofread-enabled");
            if (settingsRaw !== "true")
                return; // feature off
            const text = q.getText(sel.index, sel.length);
            if (!text.trim())
                return;
            e.preventDefault();
            // Through the shared context menu, NOT a hand-rolled div. This
            // fires on the single most likely right-click in a draft —
            // selected text — and as a private one-item popup it offered
            // Proofread and nothing else: no Cut, no Copy, no Paste, no
            // formatting. Same defect the spell menus had (2026-08-07) and
            // the same fix: a menu that replaces the native one carries the
            // standard editing block. Passing editEngine hands the clipboard
            // items Quill's own selection.
            const { showContextMenu } = await import("../components/context-menu.js");
            const { standardEditItems } = await import("./edit-commands.js");
            showContextMenu(e.clientX, e.clientY, [
                { label: "Proofread selection", action: async () => {
                        try {
                            const { aiTransform } = await import("../lib/api-client.js");
                            const r = await aiTransform({ action: "proofread", text });
                            if (r?.text && r.text !== text) {
                                q.deleteText(sel.index, sel.length);
                                q.insertText(sel.index, r.text);
                                q.setSelection(sel.index, r.text.length);
                            }
                            else if (r?.reason) {
                                alert(`Proofread: ${r.reason}`);
                            }
                        }
                        catch (err) {
                            alert(`Proofread failed: ${err?.message || err}`);
                        }
                    } },
                ...standardEditItems(() => q),
            ]);
        }
        catch { /* fall through to native menu */ }
    });
    // IMPORTANT: register on the capture phase AND use stopImmediatePropagation
    // when we handle the paste ourselves. Quill 2.x's clipboard module attaches
    // its own listener on the same root element; preventDefault only stops the
    // browser's default contenteditable insertion, NOT Quill's parallel listener.
    // Without stopImmediatePropagation the two handlers fire independently and
    // both insert — user sees the URL twice. Capture phase guarantees we run
    // before Quill so stopImmediatePropagation actually blocks it.
    q.root.addEventListener("paste", (e) => {
        const cb = e.clipboardData;
        if (!cb)
            return;
        // Helper: call when we've handled the paste ourselves. Stops Quill's
        // own listener from also processing the same event.
        const consume = () => {
            e.preventDefault();
            e.stopImmediatePropagation();
        };
        // Q3: image-on-clipboard → inline as data: URL.
        for (const item of Array.from(cb.items)) {
            if (item.kind === "file" && item.type.startsWith("image/")) {
                const file = item.getAsFile();
                if (!file)
                    continue;
                consume();
                const reader = new FileReader();
                reader.onload = () => {
                    const dataUrl = String(reader.result || "");
                    const range = q.getSelection(true) || { index: q.getLength(), length: 0 };
                    q.insertEmbed(range.index, "image", dataUrl);
                    q.setSelection(range.index + 1, 0);
                };
                reader.readAsDataURL(file);
                return;
            }
        }
        // Text-family FILE on the clipboard (a .md/.html/.txt copied in
        // Explorer and pasted here) → read it and insert its content, with
        // the same conversions the equivalent text paste would get: .md →
        // email HTML, .html → sanitized markup, .txt → sniffed then plain
        // (Bob 2026-07-30 "if I paste a .md file will it be converted?").
        for (const item of Array.from(cb.items)) {
            if (item.kind !== "file")
                continue;
            const file = item.getAsFile();
            if (!file)
                continue;
            const name = (file.name || "").toLowerCase();
            const isMd = item.type === "text/markdown" || name.endsWith(".md") || name.endsWith(".markdown");
            const isHtmlFile = item.type === "text/html" || name.endsWith(".html") || name.endsWith(".htm");
            const isTxt = item.type === "text/plain" || name.endsWith(".txt");
            if (!isMd && !isHtmlFile && !isTxt)
                continue; // other kinds: not ours
            consume();
            file.text().then((text) => {
                const html = isMd ? markdownToEmailHtml(text)
                    : isHtmlFile ? sanitizeHtmlSource(text)
                        : looksLikeHtml(text) ? sanitizeHtmlSource(text)
                            : looksLikeMarkdown(text) ? markdownToEmailHtml(text)
                                : null;
                const range = q.getSelection(true) || { index: q.getLength(), length: 0 };
                if (range.length > 0)
                    q.deleteText(range.index, range.length);
                if (html !== null)
                    q.clipboard.dangerouslyPasteHTML(range.index, html);
                else
                    q.insertText(range.index, text);
            }).catch(() => { });
            return;
        }
        const html = cb.getData("text/html");
        const plain = cb.getData("text/plain");
        if (html) {
            // Detect "single anchor" clipboard — copy from a browser usually
            // produces something like:
            //   <meta charset='utf-8'><a href="https://example.com">click here</a>
            // or wrapped in <html><body>. Parse and check.
            try {
                const tmp = document.createElement("div");
                tmp.innerHTML = html;
                // Strip script/style, then unwrap <html>/<body> noise.
                const root = tmp.querySelector("body") || tmp;
                // Walk for the only meaningful element
                const meaningful = Array.from(root.childNodes).filter(n => {
                    if (n.nodeType === Node.TEXT_NODE)
                        return (n.textContent || "").trim().length > 0;
                    if (n.nodeType === Node.ELEMENT_NODE) {
                        const tag = n.tagName.toLowerCase();
                        return tag !== "meta" && tag !== "style" && tag !== "script";
                    }
                    return false;
                });
                // A "single anchor" clipboard is one link carrying all the
                // meaningful text, at ANY wrapper depth — browsers ship a bare
                // <a>, but Word/Docs wrap it in <p>/<span> layers (plus
                // StartFragment comments), which the old top-level-only check
                // missed, so those pastes fell through to Quill's own matcher
                // and could still double-insert.
                const anchors = root.querySelectorAll("a[href]");
                const rootText = (root.textContent || "").replace(/\s+/g, " ").trim();
                if (meaningful.length >= 1 && anchors.length === 1 &&
                    rootText === (anchors[0].textContent || "").replace(/\s+/g, " ").trim()) {
                    const a = anchors[0];
                    const href = a.getAttribute("href") || "";
                    const text = (a.textContent || "").trim();
                    if (href && text) {
                        consume();
                        const range = q.getSelection(true);
                        if (!range)
                            return;
                        if (range.length > 0) {
                            // Selected text exists — replace with the linked anchor text
                            q.deleteText(range.index, range.length);
                        }
                        q.insertText(range.index, text, { link: href });
                        q.setSelection(range.index + text.length, 0);
                        return;
                    }
                }
                // Single text-node wrapping the URL — common when copying from
                // browser address bar (Chrome ships text/html as
                // `<meta><span>URL</span>` alongside text/plain). Fall through
                // to the plain-URL path below instead of letting Quill insert
                // the bare URL text AND our handler insert it linked — which
                // is exactly the double-paste the user reported.
                const textOnly = (root.textContent || "").trim();
                if (textOnly && looksLikeUrl(textOnly) && !root.querySelector("a")) {
                    consume();
                    const range = q.getSelection(true);
                    if (!range)
                        return;
                    const url = normalizeUrl(textOnly);
                    if (range.length > 0) {
                        q.formatText(range.index, range.length, "link", url);
                        q.setSelection(range.index + range.length, 0);
                    }
                    else {
                        q.insertText(range.index, textOnly, { link: url });
                        q.setSelection(range.index + textOnly.length, 0);
                    }
                    return;
                }
            }
            catch { /* fall through to Quill default */ }
            return; // Quill handles richer HTML clipboard (no consume → Quill runs)
        }
        if (plain && looksLikeUrl(plain)) {
            consume();
            const range = q.getSelection(true);
            if (!range)
                return;
            const url = normalizeUrl(plain);
            if (range.length > 0) {
                // Preserve existing selection text, just format it as a link
                q.formatText(range.index, range.length, "link", url);
                q.setSelection(range.index + range.length, 0);
            }
            else {
                q.insertText(range.index, plain.trim(), { link: url });
                q.setSelection(range.index + plain.trim().length, 0);
            }
            return;
        }
        // Raw HTML source pasted as plain text → render it as markup
        // (sanitized), never as visible tag soup or a pre/code blob (Bob
        // 2026-07-30). Note Quill normalizes what it can't model — lists,
        // quotes, headings, links survive; arbitrary div layouts flatten.
        // TinyMCE (wireMarkdownPaste path) keeps them fully.
        if (plain && looksLikeHtml(plain)) {
            consume();
            const range = q.getSelection(true) || { index: q.getLength(), length: 0 };
            if (range.length > 0)
                q.deleteText(range.index, range.length);
            q.clipboard.dangerouslyPasteHTML(range.index, sanitizeHtmlSource(plain));
            return;
        }
        // Markdown-looking plain text → formatted HTML (headings, lists,
        // blockquotes, code, tables). Only plain-text clipboards reach here
        // (the html branch returned above), and the detector is conservative
        // — ordinary prose pastes flow through to Quill untouched.
        if (plain && looksLikeMarkdown(plain)) {
            consume();
            const range = q.getSelection(true) || { index: q.getLength(), length: 0 };
            if (range.length > 0)
                q.deleteText(range.index, range.length);
            q.clipboard.dangerouslyPasteHTML(range.index, markdownToEmailHtml(plain));
        }
    }, true); // capture=true — run before Quill's own paste listener
    // Autolink TYPED URLs: when the user types whitespace/Enter after a URL,
    // convert the word just finished into a live link (TinyMCE does this via
    // its autolink plugin; Quill has nothing built in, so a typed URL stayed
    // plain text — only pasted URLs got linked). Deliberately stricter than
    // the paste path: requires an explicit http(s):// or www. prefix, because
    // bare-domain matching ("example.com") also swallows typed abbreviations
    // like "U.S." — a paste is a deliberate URL, mid-sentence typing isn't.
    q.on("text-change", (delta, _old, source) => {
        if (source !== "user")
            return;
        const inserted = (delta.ops || []).reduce((s, op) => typeof op.insert === "string" ? s + op.insert : s, "");
        if (!inserted || !/\s$/.test(inserted))
            return;
        const sel = q.getSelection();
        if (!sel)
            return;
        const boundary = sel.index - 1; // the whitespace just typed
        const lookback = Math.min(boundary, 512);
        const before = q.getText(boundary - lookback, lookback);
        const m = before.match(/(?:https?:\/\/|www\.)\S+$/i);
        if (!m)
            return;
        const word = m[0].replace(/[),.;:!?\]]+$/, ""); // drop trailing punctuation
        if (!looksLikeUrl(word))
            return;
        const start = boundary - m[0].length;
        if (q.getFormat(start, word.length).link)
            return; // already a link
        // Mutating inside the text-change handler confuses Quill's delta
        // bookkeeping — defer one microtask.
        queueMicrotask(() => {
            q.formatText(start, word.length, "link", normalizeUrl(word), "api");
        });
    });
    // Hover preview: show the target URL in a floating tooltip when the
    // pointer is over a link. Built on top of native mouseover/mouseout
    // rather than Quill's ql-tooltip (which is keyboard-triggered).
    let hoverTip = null;
    q.root.addEventListener("mouseover", (e) => {
        const a = e.target.closest("a[href]");
        if (!a)
            return;
        if (hoverTip)
            hoverTip.remove();
        hoverTip = document.createElement("div");
        hoverTip.className = "mailx-link-hover";
        hoverTip.textContent = a.getAttribute("href") || "";
        document.body.appendChild(hoverTip);
        const rect = a.getBoundingClientRect();
        hoverTip.style.left = `${Math.max(8, rect.left)}px`;
        hoverTip.style.top = `${rect.bottom + 4}px`;
    });
    q.root.addEventListener("mouseout", (e) => {
        const to = e.relatedTarget;
        if (to && to.closest("a[href]"))
            return;
        if (hoverTip) {
            hoverTip.remove();
            hoverTip = null;
        }
    });
    return {
        // Raw engine handle — same contract as rmf-tiny's nativeEditor. Used
        // by compose's native-context-menu command handler to run formatting.
        nativeEditor: q,
        setHtml(html) {
            q.clipboard.dangerouslyPasteHTML(html);
        },
        getHtml() {
            return q.root.innerHTML;
        },
        getText() {
            return q.getText();
        },
        focus() {
            q.focus();
        },
        setCursor(pos) {
            q.setSelection(pos, 0);
        },
        root: q.root,
        getScrollContainer() {
            return q.root;
        },
        onContentChange(handler) {
            q.on("text-change", handler);
        },
        onKeyDown(handler) {
            q.root.addEventListener("keydown", handler);
        },
        insertTextAtCursor(text) {
            const sel = q.getSelection();
            if (sel)
                q.insertText(sel.index, text);
        }
    };
}
// ── tiptap ──
async function createTiptapEditor(container) {
    // tiptap loaded via CDN — use global UMD bundles. The UMD wrapper names
    // each global after the PACKAGE ("@tiptap/core" etc.), NOT a camelCased
    // alias — `window.tiptapCore` was always undefined, so every tiptap open
    // threw "Cannot destructure property 'Editor' of 'window.tiptapCore'" and
    // silently fell back to Quill (Bob 2026-06-22: "test tiptap" was secretly
    // Quill). Resolve by the real global keys, accepting either a named or a
    // default export shape since tiptap packages vary on that.
    const w = window;
    const core = w["@tiptap/core"];
    const starter = w["@tiptap/starter-kit"];
    const link = w["@tiptap/extension-link"];
    const image = w["@tiptap/extension-image"];
    const underline = w["@tiptap/extension-underline"];
    const placeholder = w["@tiptap/extension-placeholder"];
    const Editor = core?.Editor;
    const StarterKit = starter?.StarterKit ?? starter?.default;
    const Link = link?.Link ?? link?.default;
    const Image = image?.Image ?? image?.default;
    const Underline = underline?.Underline ?? underline?.default;
    const Placeholder = placeholder?.Placeholder ?? placeholder?.default;
    if (!Editor || !StarterKit) {
        throw new Error("tiptap UMD globals not found (expected window['@tiptap/core'].Editor)");
    }
    // Build toolbar
    const toolbar = document.createElement("div");
    toolbar.className = "tt-toolbar";
    toolbar.innerHTML = `
        <select class="tt-heading" tabindex="-1">
            <option value="p">Normal</option>
            <option value="1">Heading 1</option>
            <option value="2">Heading 2</option>
            <option value="3">Heading 3</option>
        </select>
        <button class="tt-btn" data-cmd="bold" title="Bold" tabindex="-1"><b>B</b></button>
        <button class="tt-btn" data-cmd="italic" title="Italic" tabindex="-1"><i>I</i></button>
        <button class="tt-btn" data-cmd="underline" title="Underline" tabindex="-1"><u>U</u></button>
        <button class="tt-btn" data-cmd="strike" title="Strikethrough" tabindex="-1"><s>S</s></button>
        <button class="tt-btn" data-cmd="bulletList" title="Bullet list" tabindex="-1">&#8226;</button>
        <button class="tt-btn" data-cmd="orderedList" title="Ordered list" tabindex="-1">1.</button>
        <button class="tt-btn" data-cmd="blockquote" title="Blockquote" tabindex="-1">&ldquo;</button>
        <button class="tt-btn" data-cmd="link" title="Link" tabindex="-1">&#128279;</button>
        <button class="tt-btn" data-cmd="clearFormat" title="Clear formatting" tabindex="-1">&#8999;</button>
    `;
    // Content area
    const content = document.createElement("div");
    content.className = "tt-content";
    container.appendChild(toolbar);
    container.appendChild(content);
    const ed = new Editor({
        element: content,
        extensions: [
            StarterKit,
            Link.configure({ openOnClick: false }),
            Image,
            Underline,
            Placeholder.configure({ placeholder: "Write your message..." }),
        ],
        content: "",
    });
    // Markdown / HTML-source / text-family-file paste — same conversions as
    // the Quill and TinyMCE branches. Without this the tiptap editor was the
    // odd one out: markdown text pasted flat, .md files inserted nothing.
    wireMarkdownPaste(content, html => ed.chain().focus().insertContent(html).run());
    // Wire toolbar buttons
    toolbar.querySelectorAll(".tt-btn").forEach((btn) => {
        btn.addEventListener("mousedown", (e) => {
            e.preventDefault();
            const cmd = btn.dataset.cmd;
            switch (cmd) {
                case "bold":
                    ed.chain().focus().toggleBold().run();
                    break;
                case "italic":
                    ed.chain().focus().toggleItalic().run();
                    break;
                case "underline":
                    ed.chain().focus().toggleUnderline().run();
                    break;
                case "strike":
                    ed.chain().focus().toggleStrike().run();
                    break;
                case "bulletList":
                    ed.chain().focus().toggleBulletList().run();
                    break;
                case "orderedList":
                    ed.chain().focus().toggleOrderedList().run();
                    break;
                case "blockquote":
                    ed.chain().focus().toggleBlockquote().run();
                    break;
                case "link": {
                    const url = prompt("URL:");
                    if (url)
                        ed.chain().focus().setLink({ href: url }).run();
                    break;
                }
                case "clearFormat":
                    ed.chain().focus().clearNodes().unsetAllMarks().run();
                    break;
            }
        });
    });
    // Wire heading select
    const headingSelect = toolbar.querySelector(".tt-heading");
    headingSelect?.addEventListener("change", () => {
        const val = headingSelect.value;
        if (val === "p")
            ed.chain().focus().setParagraph().run();
        else
            ed.chain().focus().toggleHeading({ level: parseInt(val) }).run();
    });
    // Keyboard shortcuts — Quill wires these via Modules; tiptap needs them
    // hooked manually. Match the Quill bindings (Ctrl+Shift+7/8 for lists,
    // Ctrl+K for link, Ctrl+Shift+K to unlink, Ctrl+Shift+X strike, Ctrl+\
    // clear). Listen on the editor's content element so we don't intercept
    // typing in the To/Cc/Bcc fields.
    content.addEventListener("keydown", (e) => {
        const mod = e.ctrlKey || e.metaKey;
        if (!mod)
            return;
        const k = e.key.toLowerCase();
        if (e.shiftKey && k === "7") {
            e.preventDefault();
            ed.chain().focus().toggleOrderedList().run();
            return;
        }
        if (e.shiftKey && k === "8") {
            e.preventDefault();
            ed.chain().focus().toggleBulletList().run();
            return;
        }
        if (e.shiftKey && k === "x") {
            e.preventDefault();
            ed.chain().focus().toggleStrike().run();
            return;
        }
        if (e.shiftKey && k === "k") {
            e.preventDefault();
            ed.chain().focus().unsetLink().run();
            return;
        }
        if (!e.shiftKey && k === "k") {
            e.preventDefault();
            const url = prompt("URL:");
            if (url)
                ed.chain().focus().setLink({ href: url }).run();
            return;
        }
        if (k === "\\") {
            e.preventDefault();
            ed.chain().focus().clearNodes().unsetAllMarks().run();
            return;
        }
    });
    const editorEl = content.querySelector(".tiptap") || content;
    return {
        setHtml(html) {
            ed.commands.setContent(html);
        },
        getHtml() {
            return ed.getHTML();
        },
        getText() {
            return ed.getText();
        },
        focus() {
            ed.commands.focus("start");
        },
        setCursor(pos) {
            ed.commands.focus("start");
        },
        root: editorEl,
        getScrollContainer() {
            return editorEl;
        },
        onContentChange(handler) {
            ed.on("update", handler);
        },
        onKeyDown(handler) {
            editorEl.addEventListener("keydown", handler);
        },
        insertTextAtCursor(text) {
            ed.commands.insertContent(text);
        }
    };
}
export async function createEditor(container, type, opts = {}) {
    if (type === "tiptap") {
        return createTiptapEditor(container);
    }
    if (type === "tinymce") {
        return createTinyMceEditor(container, opts);
    }
    return createQuillEditor(container);
}
/** Default jsDelivr URL — works without an API key. Users can override
 *  in Settings if they want Tiny Cloud or a self-hosted bundle. */
// Local bundled TinyMCE — copied from node_modules/tinymce/ into
// client/lib/tinymce/ by build-tinymce.js. Loaded with no internet
// dependency at runtime. Relative to compose.html (`client/compose/`)
// so it works under both msger custom protocol and Android file:// .
// Users wanting Tiny Cloud premium override this via the
// `mailx-tinymce-cdn` localStorage entry surfaced in Settings.
const DEFAULT_TINYMCE_CDN = "../lib/tinymce/tinymce.min.js";
/** TinyMCE branch — dynamic-imports the rmf-tiny adapter so a missing
 *  module only affects this branch. Throws on failure so the outer
 *  compose.ts fallback can take over (and properly load Quill's
 *  assets first). Don't fall back to Quill inline here — Quill's
 *  global isn't loaded when type === "tinymce" was requested, so
 *  `new Quill()` throws "Quill is not defined" and masks the real
 *  rmf-tiny error in the log. */
async function createTinyMceEditor(container, opts = {}) {
    let cdnUrl = DEFAULT_TINYMCE_CDN;
    let apiKey;
    try {
        cdnUrl = localStorage.getItem("mailx-tinymce-cdn") || DEFAULT_TINYMCE_CDN;
        apiKey = localStorage.getItem("mailx-tinymce-apikey") || undefined;
    }
    catch { /* private mode — use defaults */ }
    // Build step copies node_modules/@bobfrankston/rmf-tiny/src/adapter.js
    // → client/lib/rmf-tiny.js. Bare-name `@bobfrankston/rmf-tiny` resolution
    // would target `node_modules/`, which msger's custom protocol can't reach
    // (outside contentDir=client/). The relative path stays inside the
    // served root.
    const m = (await import("../lib/rmf-tiny.js"));
    // Unified editing font size — compose.ts sets --compose-font-size on
    // :root before creating the editor; TinyMCE's iframe can't see page CSS
    // vars, so hand it the resolved px via the adapter option.
    const cssPx = parseFloat(getComputedStyle(document.documentElement).getPropertyValue("--compose-font-size"));
    const ed = await m.createTinyMceEditor(container, { cdnUrl, apiKey, onFiles: opts.onFiles, fontSizePx: Number.isFinite(cssPx) ? cssPx : undefined });
    // Markdown-aware paste — same behavior as the Quill branch. TinyMCE's
    // editable body lives inside its own iframe, so the listener attaches
    // to getBody() (capture beats TinyMCE's own paste pipeline). Best
    // effort: if the facade doesn't expose the native editor, normal paste
    // behavior is unchanged.
    try {
        const native = ed.nativeEditor;
        const body = native?.getBody?.();
        if (native && body) {
            wireMarkdownPaste(body, html => native.insertContent(html));
        }
    }
    catch { /* markdown paste is an enhancement, never a blocker */ }
    return ed;
}
//# sourceMappingURL=editor.js.map