/**
 * @bobfrankston/rmf-tiny — TinyMCE adapter for rmfmail.
 *
 * MIT-licensed adapter glue. Imports TinyMCE at runtime; ships no
 * TinyMCE bytes. The user is responsible for making TinyMCE reachable
 * to their environment:
 *
 *   - Desktop / Node-side install:  `npm install tinymce`
 *     (rmfmail's createEditor("tinymce") branch dynamically imports
 *     this adapter, which dynamically imports tinymce from
 *     node_modules.)
 *
 *   - Android / browser-only WebView: pass `cdnUrl` in the options to
 *     have the adapter inject a <script src=…> tag. The bytes are
 *     fetched directly from Tiny's CDN (or whatever URL the user
 *     configured); the host APK / page never carries them.
 *
 * The adapter implements the same `MailxEditor` shape as the Quill /
 * tiptap factories in rmfmail's editor.ts, so the host code path is
 * identical regardless of which editor is in use.
 */
/** Load tinymce. Tries native module import first (desktop / npm-installed);
 *  falls back to script-tag injection from cdnUrl. Resolves to whatever's
 *  on `window.tinymce`. */
async function loadTinymce(opts) {
    // 1. Try the npm-installed path. Wrapped in try because in browser-only
    //    environments the module specifier won't resolve and we want to
    //    fall through to CDN injection.
    try {
        const mod = await import(/* @vite-ignore */ "tinymce");
        const tinymce = mod.default || mod;
        // Pull in the plugins TinyMCE's paste-from-Word handler needs. These
        // are side-effect imports — they register themselves on the global.
        await Promise.all([
            import("tinymce/themes/silver").catch(() => { }),
            import("tinymce/icons/default").catch(() => { }),
            import("tinymce/models/dom").catch(() => { }),
            import("tinymce/plugins/lists").catch(() => { }),
            import("tinymce/plugins/link").catch(() => { }),
            import("tinymce/plugins/table").catch(() => { }),
            import("tinymce/plugins/code").catch(() => { }),
            import("tinymce/plugins/image").catch(() => { }),
        ]);
        return tinymce;
    }
    catch {
        // Fall through.
    }
    // 2. Already loaded by a prior call.
    const w = window;
    if (w.tinymce)
        return w.tinymce;
    // 3. Inject from CDN if provided.
    if (!opts.cdnUrl) {
        throw new Error("rmf-tiny: tinymce not installed (npm install tinymce) and no cdnUrl supplied. See README for Android setup.");
    }
    const url = opts.apiKey && !opts.cdnUrl.includes("api-key")
        ? `${opts.cdnUrl}${opts.cdnUrl.includes("?") ? "&" : "?"}apiKey=${encodeURIComponent(opts.apiKey)}`
        : opts.cdnUrl;
    await new Promise((resolve, reject) => {
        const s = document.createElement("script");
        s.src = url;
        s.referrerPolicy = "origin";
        s.onload = () => resolve();
        s.onerror = () => reject(new Error(`rmf-tiny: failed to load TinyMCE from ${url}`));
        document.head.appendChild(s);
    });
    if (!w.tinymce)
        throw new Error("rmf-tiny: TinyMCE script loaded but window.tinymce is missing");
    return w.tinymce;
}
/** Create a TinyMCE-backed MailxEditor in `container`. */
export async function createTinyMceEditor(container, opts = {}) {
    const tinymce = await loadTinymce(opts);
    // TinyMCE attaches to a target element by id selector; create one
    // inside the host container so multiple editors on a page don't
    // collide and so we don't pollute the caller's element with TinyMCE
    // attributes directly.
    const target = document.createElement("div");
    target.id = `rmf-tiny-${Math.random().toString(36).slice(2, 10)}`;
    target.style.cssText = "width:100%;height:100%;";
    container.appendChild(target);
    // Code-block languages — shared by the stock codesample dialog
    // (codesample_languages) AND our custom "Insert code" dialog below, so a
    // language added here shows up in both. "Text" first = default (no
    // highlighting, which would mangle plain pasted snippets — Bob 2026-05-24).
    // PowerShell added 2026-05-31 (Bob: ".ps1 is another file type").
    const CODE_LANGS = [
        { text: "Text", value: "text" },
        { text: "HTML/XML", value: "markup" },
        { text: "JavaScript", value: "javascript" },
        { text: "TypeScript", value: "typescript" },
        { text: "CSS", value: "css" },
        { text: "JSON", value: "json" },
        { text: "Python", value: "python" },
        { text: "Java", value: "java" },
        { text: "C", value: "c" },
        { text: "C++", value: "cpp" },
        { text: "C#", value: "csharp" },
        { text: "Go", value: "go" },
        { text: "Rust", value: "rust" },
        { text: "Ruby", value: "ruby" },
        { text: "PHP", value: "php" },
        { text: "Shell", value: "bash" },
        { text: "PowerShell", value: "powershell" },
        { text: "SQL", value: "sql" },
    ];
    const editor = await new Promise((resolve) => {
        tinymce.init({
            target,
            // Word-paste fidelity is the entire point of using TinyMCE here.
            // `paste_as_text: false` keeps formatting; powerpaste isn't in
            // OSS but the standard paste plugin handles Word reasonably well.
            // All free / OSS plugins bundled in the jsDelivr tinymce@6 script.
            // No premium plugins listed — listing one without an API key
            // triggers TinyMCE's upsell dialog on every load.
            // NOTE: no "paste" plugin — it was REMOVED in TinyMCE 6 (paste,
            // including Word-paste fidelity, is now built into core). We're on
            // v8; listing "paste" makes TinyMCE's loader fetch the nonexistent
            // plugins/paste/plugin.min.js over msger's custom protocol, which
            // returns an error body the WebView tries to eval → "Uncaught
            // SyntaxError: Unexpected identifier 'Not'" → init fails → compose
            // falls back to a stub editor and Reply/Reply-All break (Bob
            // 2026-06-01). The paste_* OPTIONS below remain valid (core).
            plugins: "lists advlist link table code codesample image searchreplace autolink wordcount emoticons charmap insertdatetime quickbars nonbreaking directionality help",
            toolbar: [
                "undo redo | bold italic underline strikethrough | forecolor backcolor",
                "bullist numlist outdent indent | link table image code rmfcode | emoticons charmap | help",
            ].join(" | "),
            // Include "tools" so wordcount and searchreplace are reachable.
            menubar: "file edit view insert format tools",
            // View menu — append zoom commands. fullscreen omitted: this editor
            // lives inside a compose iframe overlay that already fills its host
            // window; TinyMCE's fullscreen plugin re-positions the container in
            // ways that produce a blank body, so it's not in the plugin list.
            menu: {
                view: { title: "View", items: "rmfsource | visualaid visualchars visualblocks | preview | zoomIn zoomOut zoomReset" },
            },
            // Disable the "Get all features" upsell badge that TinyMCE 6+
            // injects automatically when no premium plugins are listed.
            promotion: false,
            // Floating toolbar that appears on text selection — keeps the
            // main toolbar uncluttered while exposing common formatters
            // where the cursor already is. quickbars_insert_toolbar:false
            // suppresses the second (insert-on-blank-line) popup which
            // clutters more than it helps in an email composer.
            quickbars_selection_toolbar: "bold italic underline | forecolor | quicklink blockquote",
            quickbars_insert_toolbar: false,
            // Word-style layout buttons (registered in setup below): in-line
            // with text, image-left/text-wraps-right, image-right/text-wraps-
            // left. Replaced the generic alignleft/center/right icons, which
            // don't communicate wrapping (Bob 2026-07-18: "the align is
            // confusing, better to use the Word icons for the three cases").
            quickbars_image_toolbar: "rmfwrapinline rmfwrapleft rmfwrapright",
            // Code-sample dropdown languages. TinyMCE's default list omits
            // "Text" / plain — every option triggers syntax highlighting
            // which mangles unrelated paste content (Bob 2026-05-24).
            // Adding Text first so it's the default; rest are the modern
            // languages we actually paste.
            codesample_languages: CODE_LANGS,
            // WebView's native spell-check (red underlines, right-click
            // "Add to dictionary"). Free; same UX as Quill's spellcheck=true.
            // Premium tinymcespellchecker plugin would replace this with a
            // custom backend via spellchecker_rpc_url, but requires a
            // Tiny Cloud subscription.
            browser_spellcheck: true,
            // Insert/Edit Image dialog: add a BROWSE button to the Source field
            // so a local file can be picked instead of only pasting a URL (Bob
            // 2026-06-17 "insert image should use the browse API"). Uses the same
            // OS file-picker the compose Attach button uses (<input type=file>,
            // which works in the msger WebView), then inlines the chosen image as
            // a base64 data URL — matching the drag-drop-an-image behavior.
            file_picker_types: "image",
            file_picker_callback: (cb, _value, meta) => {
                if (meta && meta.filetype && meta.filetype !== "image")
                    return;
                const input = document.createElement("input");
                input.type = "file";
                input.accept = "image/*";
                // MUST be connected to the document before .click() — WebView2
                // (Chromium) silently refuses to open the OS file dialog for a
                // DETACHED file input, so the Browse button in the Insert Image
                // dialog did nothing while drag-drop (no input) and URL-source
                // insert both worked (Bob 2026-07-06 "insert image is failing …
                // but drag drop does work"). The compose Attach button never hit
                // this because it uses a permanent #compose-file input. Hidden +
                // removed after change so it leaves no DOM residue.
                input.style.display = "none";
                const cleanup = () => { input.remove(); };
                input.onchange = () => {
                    const file = input.files && input.files[0];
                    if (!file) {
                        cleanup();
                        return;
                    }
                    const reader = new FileReader();
                    reader.onload = () => { cb(reader.result, { alt: file.name, title: file.name }); cleanup(); };
                    reader.onerror = () => { cb(""); cleanup(); };
                    reader.readAsDataURL(file);
                };
                document.body.appendChild(input);
                input.click();
            },
            // Right-click context menu DISABLED so WebView2's native menu
            // fires instead. We need the native menu because that's where
            // the browser shows spelling suggestions (TinyMCE's contextmenu
            // intercepts the right-click and replaces the menu — red
            // underlines appear but suggestions are unreachable). Trade-off:
            // lose TinyMCE's link / image / table quick actions. Standard
            // text formatting is still on the toolbar.
            contextmenu: false,
            statusbar: false,
            branding: false,
            license_key: "gpl",
            // Keep URLs verbatim. TinyMCE defaults to convert_urls:true +
            // relative_urls:true, which rewrites an absolute href
            // (https://github.com/…) to be RELATIVE to the editor's base URL
            // (the msger custom-protocol page). A link that worked in the
            // original message arrives in the reply quote rewritten/dead
            // (Bob 2026-05-31: "URL works in the original but not in the
            // reply"). false on both = hrefs travel into the sent message
            // exactly as the sender wrote them.
            convert_urls: false,
            relative_urls: false,
            remove_script_host: false,
            paste_data_images: true,
            // NOTE: paste_word_valid_elements and paste_retain_style_properties
            // were REMOVED in TinyMCE 8 (core paste keeps formatting by
            // default now) — passing them only triggered the deprecation
            // console warning on every compose load. Removed 2026-07-18; if
            // TinyMCE is ever pinned back to ≤7, restore them from git.
            // Auto-link bare URLs in pasted content. TinyMCE's `autolink`
            // plugin only fires on TYPED space/enter; URLs that arrive via
            // clipboard (browser address bar, terminal copy) come in as
            // plain text and stay un-linked. paste_preprocess runs on the
            // HTML the paste plugin produced.
            //
            // CRITICAL: skip auto-link when the content already contains
            // anchors. Naive regex over the whole content would wrap
            // `<a href="X">X</a>` in ANOTHER anchor (nested anchors are
            // invalid HTML and browsers split them, producing visible
            // junk). For HTML pastes that already have linked URLs (the
            // common case from a browser address bar or another mail
            // client), TinyMCE preserves them — no auto-link pass needed.
            // For plain-text pastes (no anchors present), wrap any bare
            // http(s)://… runs. Trailing sentence punctuation is excluded
            // from the URL.
            paste_preprocess: (_plugin, args) => {
                if (/<a[\s>]/i.test(args.content))
                    return;
                args.content = args.content.replace(/(^|[\s(\[])((?:https?|ftp):\/\/[^\s<>"']+[^\s<>"'.,;:!?)\]])/gi, (_m, lead, url) => `${lead}<a href="${url}">${url}</a>`);
            },
            // Body font + quoted-reply styling. Without explicit rules for
            // <blockquote> and div.reply the editor iframe renders the
            // quoted block with no visual distinction from the user's own
            // text — pasted reply quotes vanish into a wall of unformatted
            // paragraphs (Bob 2026-05-12: "the body losing all formatting").
            // The styles below match Thunderbird/Outlook conventions: a
            // muted left-bar + indent on blockquotes, slight color shift on
            // the "On … wrote:" attribution, untouched typography for
            // everything else so genuine HTML formatting (bold / italic /
            // tables / inline color) still comes through verbatim.
            content_style: [
                // Dark-blue native caret bar. caret-shape:block produced a
                // column wider than the gap between letters AND caused the
                // caret to "scoot back to its old position" after an arrow
                // press (Chromium block-caret quirk with the arrow-key
                // selection adjustment, Bob 2026-05-24). Plain bar is
                // browser-default and behaves correctly with arrow keys;
                // just darken the color so it stays visible.
                // Explicit line-height (was implicit/skin-default). WebView2's
                // native spell-check red squiggle was rendering ABOVE the word
                // instead of under it (Bob 2026-06-17, on reply-attribution
                // lines). The marker is positioned from the line-box metrics, and
                // an undefined/oversized line-height plus per-line font-size
                // changes threw it off. A definite, tight line-height gives the
                // marker a stable baseline to sit under.
                `body { font-family: system-ui, sans-serif; font-size: ${opts.fontSizePx ?? 14}px; line-height: 1.3; caret-color: #0a2647; }`,
                "blockquote { border-left: 3px solid #c0c8d0; margin: 0 0 0 4px; padding: 2px 0 2px 10px; color: #555; }",
                // Drop the 0.5em margin-gap above the reply block and the per-line
                // font-size shrink on the attribution line — both contributed to
                // the squiggle landing in the gap above the words. Keep it the
                // same size + line-height as body so the marker aligns.
                "div.reply { margin-top: 0; }",
                "div.reply > p:first-child { color: #666; line-height: 1.3; margin: 0 0 4px 0; }",
                "pre, code { font-family: ui-monospace, Consolas, Menlo, monospace; }",
            ].join(" "),
            init_instance_callback: (ed) => resolve(ed),
            setup: (ed) => {
                if (opts.initialHtml)
                    ed.on("init", () => ed.setContent(opts.initialHtml));
                // ── Image layout buttons (Word "Layout Options" style) ──
                // Icons mimic Word's glyphs: an arch (the image) among text
                // lines, so each button SHOWS its result instead of a generic
                // align arrow. Three cases: in line with text, image left
                // (text wraps right), image right (text wraps left).
                const ln = (x1, x2, y) => `<rect x="${x1}" y="${y}" width="${x2 - x1}" height="1.5" rx="0.75" fill="currentColor"/>`;
                const arch = (cx, base) => `<path d="M ${cx - 4.5} ${base} a 4.5 4.5 0 0 1 9 0 z" fill="currentColor"/>`;
                const svg = (body) => `<svg width="24" height="24" viewBox="0 0 24 24">${body}</svg>`;
                ed.ui.registry.addIcon("rmf-wrap-inline", svg(ln(3, 21, 4.5) + ln(3, 6.5, 14.5) + arch(12, 16) + ln(17.5, 21, 14.5) + ln(3, 21, 19.5)));
                ed.ui.registry.addIcon("rmf-wrap-left", svg(ln(3, 21, 4.5) + arch(7.5, 16) + ln(14, 21, 9.5) + ln(14, 21, 14.5) + ln(3, 21, 19.5)));
                ed.ui.registry.addIcon("rmf-wrap-right", svg(ln(3, 21, 4.5) + ln(3, 10, 9.5) + ln(3, 10, 14.5) + arch(16.5, 16) + ln(3, 21, 19.5)));
                const applyWrap = (mode) => {
                    const node = ed.selection.getNode();
                    if (!node || node.nodeName !== "IMG")
                        return;
                    ed.undoManager.transact(() => {
                        ed.dom.setStyles(node, {
                            float: mode === "inline" ? "" : mode,
                            margin: mode === "inline" ? "" :
                                mode === "left" ? "0 12px 8px 0" : "0 0 8px 12px",
                        });
                    });
                    ed.nodeChanged();
                };
                const wrapButton = (name, icon, tip, mode) => {
                    ed.ui.registry.addToggleButton(name, {
                        icon, tooltip: tip,
                        onAction: () => applyWrap(mode),
                        onSetup: (api) => {
                            const update = () => {
                                const node = ed.selection.getNode();
                                const f = node?.nodeName === "IMG" ? (node.style.float || "inline") : "";
                                api.setActive(f === mode || (mode === "inline" && f === "none"));
                            };
                            ed.on("NodeChange", update);
                            return () => ed.off("NodeChange", update);
                        },
                    });
                };
                wrapButton("rmfwrapinline", "rmf-wrap-inline", "In line with text", "inline");
                wrapButton("rmfwrapleft", "rmf-wrap-left", "Image left — text wraps around the right", "left");
                wrapButton("rmfwrapright", "rmf-wrap-right", "Image right — text wraps around the left", "right");
                // Bitmap-first paste. An image copied from a web app (Gemini,
                // browser "Copy image") puts BOTH a bitmap file item and an
                // html flavor on the clipboard — and the html flavor is a
                // hotlink-protected remote URL or a foreign blob: URI that
                // renders broken. TinyMCE's core paste prefers the html
                // flavor, so the paste appeared to do nothing (Bob
                // 2026-07-18: "image from Gemini ... I don't get that option
                // in your editor"). When the clipboard carries an image file
                // and no real text, insert the bitmap as a data: URI and
                // consume the event. Mirrors mailx's Quill Q3 handler.
                ed.on("paste", (e) => {
                    const cb = e.clipboardData;
                    if (!cb)
                        return;
                    const imgItem = Array.from(cb.items || []).find((i) => i.kind === "file" && i.type.startsWith("image/"));
                    if (!imgItem)
                        return;
                    // Real text alongside the image (e.g. copying a document
                    // region) → let TinyMCE's normal paste keep the layout.
                    if ((cb.getData("text/plain") || "").trim())
                        return;
                    const file = imgItem.getAsFile();
                    if (!file)
                        return;
                    e.preventDefault();
                    const reader = new FileReader();
                    reader.onload = () => {
                        try {
                            ed.insertContent(`<img src="${String(reader.result || "")}" alt="${file.name || "pasted image"}">`);
                        }
                        catch { /* editor tearing down mid-paste */ }
                    };
                    reader.readAsDataURL(file);
                });
                // Never emit session-scoped blob: URIs from getContent. TinyMCE
                // converts inserted/pasted data: images into blob-cache entries
                // and renders `src="blob:..."` in the editor (and re-blobs the
                // body on every getContent scan, so rewriting the DOM doesn't
                // stick). Plain getContent() restores data: — but the
                // Source-Code dialog uses source_view getContent, which SKIPS
                // the restore, so View→Source showed `src="blob:...:0"` plus
                // the filename alt and read as a dead image (Bob 2026-07-12
                // "insert image does not work ... I see the name for the
                // image"). Restore blob→data on EVERY GetContent, source view
                // included; a source edit saved back re-enters as data: and
                // round-trips cleanly.
                ed.on("GetContent", (e) => {
                    if (!e?.content || typeof e.content !== "string" || !e.content.includes("blob:"))
                        return;
                    e.content = e.content.replace(/src="(blob:[^"]+)"/g, (m, uri) => {
                        const info = ed.editorUpload?.blobCache?.getByUri?.(uri);
                        return info ? `src="data:${info.blob().type};base64,${info.base64()}"` : m;
                    });
                });
                // Source-Code dialog opens with the caret at the END of the
                // HTML (TinyMCE focuses + selects-to-end), so a long message
                // greets the user scrolled to the BOTTOM (Bob 2026-07-12).
                // Put the caret at the top instead. requestAnimationFrame ×2:
                // the dialog's own focus handler runs after OpenWindow fires.
                ed.on("OpenWindow", () => {
                    requestAnimationFrame(() => requestAnimationFrame(() => {
                        const ta = document.querySelector(".tox-dialog textarea");
                        if (ta && ta.value) {
                            try {
                                ta.setSelectionRange(0, 0);
                                ta.scrollTop = 0;
                            }
                            catch { /* not the code dialog */ }
                        }
                    }));
                });
                // Zoom — content font-size scales between ZOOM_MIN and ZOOM_MAX.
                // Reachable via View → Zoom in/out/reset, Ctrl+wheel inside the
                // editor iframe, and Ctrl+= / Ctrl+- / Ctrl+0 shortcuts.
                // Persisted in localStorage so re-opening compose lands at the
                // size the user picked. Per-host scope (one key, not per-doc)
                // matches how every other rich-text editor's zoom works.
                const ZOOM_DEFAULT = opts.fontSizePx ?? 14;
                const ZOOM_STEP = 2;
                const ZOOM_MIN = 8;
                const ZOOM_MAX = 32;
                const ZOOM_STORAGE_KEY = "rmf-tiny:zoom-px";
                let zoomPx = ZOOM_DEFAULT;
                try {
                    const stored = Number(localStorage.getItem(ZOOM_STORAGE_KEY));
                    if (Number.isFinite(stored) && stored >= ZOOM_MIN && stored <= ZOOM_MAX) {
                        zoomPx = stored;
                    }
                }
                catch { /* localStorage unavailable — use default */ }
                const saveZoom = () => {
                    try {
                        localStorage.setItem(ZOOM_STORAGE_KEY, String(zoomPx));
                    }
                    catch { /* quota / disabled — non-fatal */ }
                };
                const applyZoom = () => {
                    try {
                        const body = ed.getBody();
                        if (!body)
                            return;
                        body.style.fontSize = `${zoomPx}px`;
                        // WebView2's native spell-check markers are laid out ONCE
                        // (at the initial 14px) and do NOT reposition when the
                        // font-size changes via inline style — they stay stranded
                        // at the old, smaller baseline, so the red squiggle draws
                        // ABOVE the now-larger words instead of under them (Bob
                        // 2026-06-17 "red squiggle on the row above the text").
                        // Toggling the spellcheck attribute off→on forces the
                        // engine to re-run + re-place the markers at the current
                        // font metrics. rAF lets the font-size reflow land first.
                        const win = (ed.getWin && ed.getWin()) || window;
                        const raf = win.requestAnimationFrame || ((f) => setTimeout(f, 16));
                        body.setAttribute("spellcheck", "false");
                        raf(() => { try {
                            body.setAttribute("spellcheck", "true");
                        }
                        catch { /* */ } });
                    }
                    catch { /* */ }
                };
                const bumpZoom = (delta) => {
                    zoomPx = Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, zoomPx + delta));
                    applyZoom();
                    saveZoom();
                };
                // Don't spell-check URLs or email addresses (Bob 2026-07-06).
                // WebView2's native spell-check underlines every token, so
                // "gmail", "iecc", "https", path segments etc. all draw red
                // squiggles inside links. The browser skips any subtree whose
                // ancestor carries spellcheck="false", and autolink already
                // wraps bare URLs + emails in <a> (as does mailto: / http
                // linking), so tagging every link suppresses exactly the
                // URL/email case while leaving prose checked. Idempotent —
                // only touches links missing the attribute — so running it on
                // every NodeChange is cheap. Also covers late-linked tokens the
                // moment autolink converts them.
                const suppressLinkSpellcheck = () => {
                    try {
                        const body = ed.getBody();
                        if (!body)
                            return;
                        const links = body.querySelectorAll("a:not([spellcheck])");
                        for (const a of links)
                            a.setAttribute("spellcheck", "false");
                    }
                    catch { /* editor torn down mid-call — non-fatal */ }
                };
                ed.on("init", suppressLinkSpellcheck);
                ed.on("SetContent", suppressLinkSpellcheck);
                ed.on("NodeChange", suppressLinkSpellcheck);
                // Custom "Insert code" — wraps the stock codesample plugin
                // (keeps its Prism highlighting + edit-in-place) but adds a
                // "Box it in" border checkbox so a snippet is visually
                // demarcated from surrounding prose. The border is applied as
                // an INLINE style on the <pre>, NOT a content_style class, so
                // it survives into the sent email where the recipient has none
                // of our editor CSS. Bob 2026-05-31.
                const BORDER_STYLES = { "border": "1px solid #888", "border-radius": "4px", "padding": "10px" };
                const openCodeDialog = () => {
                    const preEl = ed.dom.getParent(ed.selection.getNode(), "pre");
                    let curLang = "text";
                    let curBorder = false;
                    let curCode = "";
                    if (preEl) {
                        const m = (preEl.className || "").match(/language-([\w-]+)/);
                        if (m)
                            curLang = m[1];
                        curBorder = !!(preEl.style && preEl.style.border && preEl.style.border !== "none");
                        // Read existing code straight off the DOM — don't depend on
                        // the codesample plugin's internal getCurrentCode (its API
                        // shape isn't stable across TinyMCE versions).
                        curCode = preEl.textContent || "";
                    }
                    ed.windowManager.open({
                        title: "Insert code",
                        size: "large",
                        body: {
                            type: "panel",
                            items: [
                                { type: "listbox", name: "language", label: "Language", items: CODE_LANGS },
                                { type: "textarea", name: "code", label: "Code" },
                                { type: "checkbox", name: "border", label: "Box it in (border around the code)" },
                            ],
                        },
                        initialData: { language: curLang, code: curCode, border: curBorder },
                        buttons: [
                            { type: "cancel", text: "Cancel" },
                            { type: "submit", text: "Save", primary: true },
                        ],
                        onSubmit: (api) => {
                            const data = api.getData();
                            const lang = data.language || "text";
                            const esc = (s) => String(s)
                                .replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
                            const borderStyle = data.border
                                ? ` style="${Object.entries(BORDER_STYLES).map(([k, v]) => `${k}:${v}`).join(";")}"`
                                : "";
                            // Build the block and insert it directly. The previous
                            // path called the codesample plugin's internal
                            // `insertCodeSample`, which silently inserted NOTHING on
                            // the current TinyMCE (Bob 2026-06-06 "nothing got
                            // inserted"). Direct insertContent / setOuterHTML is
                            // version-independent and can't silently no-op.
                            const html = `<pre class="language-${lang}"${borderStyle}><code>${esc(data.code || "")}</code></pre>`;
                            if (preEl && preEl.parentNode) {
                                ed.dom.setOuterHTML(preEl, html); // editing an existing block
                            }
                            else {
                                ed.insertContent(html); // fresh insert
                            }
                            ed.undoManager.add(); // snapshot + fire AddUndo → draft save
                            api.close();
                        },
                    });
                };
                ed.ui.registry.addButton("rmfcode", {
                    icon: "code-sample",
                    tooltip: "Insert/edit code block",
                    onAction: openCodeDialog,
                });
                ed.ui.registry.addMenuItem("rmfcode", {
                    icon: "code-sample",
                    text: "Code block…",
                    onAction: openCodeDialog,
                });
                // View HTML source, caret-positioned (Bob 2026-07-18).
                // Registered as a menu item (View menu) AND reachable from the
                // compose NATIVE right-click menu via editor.showSource() —
                // TinyMCE's own contextmenu is off (native menu carries
                // spellcheck suggestions), so the right-click path is wired in
                // mailx compose.ts, not here. openSourceDialog is module-scope.
                ed.ui.registry.addMenuItem("rmfsource", {
                    icon: "sourcecode",
                    text: "HTML source…",
                    onAction: () => openSourceDialog(ed),
                });
                ed.ui.registry.addMenuItem("zoomIn", {
                    text: "Zoom in", shortcut: "Ctrl+=",
                    onAction: () => bumpZoom(ZOOM_STEP),
                });
                ed.ui.registry.addMenuItem("zoomOut", {
                    text: "Zoom out", shortcut: "Ctrl+-",
                    onAction: () => bumpZoom(-ZOOM_STEP),
                });
                ed.ui.registry.addMenuItem("zoomReset", {
                    text: "Reset zoom", shortcut: "Ctrl+0",
                    onAction: () => { zoomPx = ZOOM_DEFAULT; applyZoom(); saveZoom(); },
                });
                // Keyboard shortcuts go on the iframe doc directly. TinyMCE's
                // `addShortcut` is too late — WebView2 intercepts Ctrl+= and
                // Ctrl+- for its built-in page zoom, so we have to call
                // preventDefault in the capture phase to win the race.
                // Bound below in the "init" handler once the iframe doc exists.
                // Typing right after a link must not extend the link. When
                // any text insertion is about to happen with the caret
                // collapsed at the very end of an <a>, hop the caret to just
                // after the <a> first — otherwise contenteditable keeps
                // appending into the link and the whole sentence turns into
                // link text (Bob 2026-05-18: "I typed a URL but it then
                // included everything else I typed in the URL text"; Bob
                // 2026-05-25 reconfirmed). Covers autolink, pasted URLs,
                // hand-made links, IME composition, and clipboard paste.
                //
                // Uses `beforeinput` (modern, fires for ALL text insertion
                // kinds — keypress is deprecated and silently skips IME /
                // paste / autocomplete on Chromium). Done as a raw DOM
                // listener so it runs BEFORE TinyMCE's own beforeinput
                // handling, which is what reaches into the <a> and extends
                // it. Re-bound on every `init` (the iframe doc is recreated
                // on setContent, fullscreen, etc.).
                const installLinkEscape = () => {
                    const doc = ed.getDoc();
                    if (!doc || doc.__rmfLinkEscapeInstalled)
                        return;
                    doc.__rmfLinkEscapeInstalled = true;
                    doc.addEventListener("beforeinput", (e) => {
                        // Only intercept text-insertion kinds. Deletes,
                        // formatting, history nav don't extend links.
                        const kind = e.inputType || "";
                        const isInsert = kind === "insertText"
                            || kind === "insertCompositionText"
                            || kind === "insertFromPaste"
                            || kind === "insertFromDrop"
                            || kind === "insertFromComposition";
                        if (!isInsert)
                            return;
                        const sel = doc.getSelection();
                        if (!sel || sel.rangeCount === 0)
                            return;
                        const rng = sel.getRangeAt(0);
                        if (!rng.collapsed)
                            return;
                        const node = rng.startContainer;
                        const a = (node.nodeType === Node.ELEMENT_NODE ? node : node.parentNode);
                        if (!a)
                            return;
                        const link = a.closest?.("a");
                        if (!link)
                            return;
                        // Range from caret to end-of-link: empty ⇒ caret is
                        // at the link's trailing edge. Anything else means
                        // mid-link edit, which we want to leave alone.
                        let tail;
                        try {
                            tail = rng.cloneRange();
                            tail.setEndAfter(link);
                        }
                        catch {
                            return;
                        }
                        if (tail.toString().length > 0)
                            return;
                        // Move the caret to just after the link. Use TinyMCE's
                        // own selection API rather than a raw
                        // removeAllRanges/addRange: the raw mutation ran ahead
                        // of TinyMCE's beforeinput handling and desynced its
                        // UndoManager, leaving Ctrl+Z erratic (Bob 2026-05-31:
                        // "^z is very broken in TinyMCE"). Going through
                        // ed.selection keeps TinyMCE's selection + undo state
                        // consistent. Raw range as a fallback if it throws.
                        const parent = link.parentNode;
                        const idx = parent ? Array.prototype.indexOf.call(parent.childNodes, link) + 1 : -1;
                        if (parent && idx >= 0) {
                            try {
                                ed.selection.setCursorLocation(parent, idx);
                            }
                            catch {
                                const after = doc.createRange();
                                after.setStartAfter(link);
                                after.collapse(true);
                                sel.removeAllRanges();
                                sel.addRange(after);
                            }
                        }
                    }, true);
                };
                ed.on("init", installLinkEscape);
                ed.on("SetContent", installLinkEscape);
                // File drop: match every other mail client — an image embeds
                // INLINE where you drop it; any other file is handed to the host
                // to attach. We take the drop over entirely (preventDefault) so
                // TinyMCE never shows its "Dropped file type is not supported"
                // toast (Bob 2026-06-13). Image-only drops without an onFiles
                // host still embed inline; a non-image with no host falls
                // through to TinyMCE's default.
                ed.on("drop", (e) => {
                    const dt = e.dataTransfer;
                    const files = dt && dt.files ? Array.from(dt.files) : [];
                    if (files.length === 0)
                        return; // text/html drop — leave to TinyMCE
                    const images = files.filter(f => (f.type || "").startsWith("image/"));
                    const others = files.filter(f => !(f.type || "").startsWith("image/"));
                    // If there's nothing we can do (only non-images and no host
                    // to attach them), let TinyMCE handle it as before.
                    if (images.length === 0 && !opts.onFiles)
                        return;
                    e.preventDefault();
                    e.stopPropagation();
                    // Drop the caret at the drop point so inline images land
                    // where the user aimed (not at the last cursor position).
                    try {
                        const rng = ed.getDoc().caretRangeFromPoint?.(e.clientX, e.clientY)
                            || ed.getDoc().caretPositionFromPoint?.(e.clientX, e.clientY);
                        if (rng && rng.startContainer)
                            ed.selection.setRng(rng);
                    }
                    catch { /* insert at current cursor */ }
                    for (const img of images) {
                        const reader = new FileReader();
                        reader.onload = () => {
                            const url = String(reader.result || "");
                            if (url)
                                ed.insertContent(`<img src="${url}" alt="${(img.name || "image").replace(/"/g, "")}">`);
                        };
                        reader.readAsDataURL(img);
                    }
                    if (others.length > 0 && opts.onFiles) {
                        try {
                            opts.onFiles(others);
                        }
                        catch (err) {
                            console.error("rmf-tiny onFiles handler threw:", err);
                        }
                    }
                });
                // Quoted material (the original you're replying to) must NOT be
                // spell-checked. Two reasons, one fix: (1) it isn't the user's
                // prose — you don't proofread the message you're answering; and
                // (2) WebView2's native red squiggle renders MISPLACED (high/low,
                // in the gap above the words) specifically on the attribution and
                // quote lines — every misplaced-squiggle shot Bob has sent
                // (2026-06-17 → 06-22) is on quoted text, never on what he typed.
                // Marking the quote subtree spellcheck="false" removes the wrong
                // markers AND is semantically right; the reply the user types
                // (outside the quote) keeps live spellcheck. Beats chasing the
                // native marker math, which several line-height/reflow attempts
                // never nailed. Re-applied on every SetContent (reply injected
                // after init, draft reload, paste-as-quote).
                const QUOTE_SELECTOR = "blockquote, div.reply, .gmail_quote, .moz-cite-prefix, pre.moz-quote-pre, [type=cite]";
                const markQuotedNoSpellcheck = (body) => {
                    if (!body?.querySelectorAll)
                        return;
                    body.querySelectorAll(QUOTE_SELECTOR).forEach((el) => {
                        el.setAttribute("spellcheck", "false");
                    });
                };
                ed.on("SetContent", () => { try {
                    markQuotedNoSpellcheck(ed.getBody());
                }
                catch { /* */ } });
                ed.on("init", () => {
                    // Engage WebView2's native spellcheck. TinyMCE's own
                    // `browser_spellcheck: true` is a no-op — its code path
                    // only DISABLES spellcheck on false; on true it leaves
                    // the iframe body without an explicit attribute, and
                    // WebView2 doesn't engage red underlines on a fresh
                    // contenteditable iframe without `spellcheck="true"` set.
                    try {
                        const body = ed.getBody();
                        if (body) {
                            body.setAttribute("spellcheck", "true");
                            body.setAttribute("lang", "en");
                            markQuotedNoSpellcheck(body);
                        }
                        const doc = ed.getDoc();
                        if (doc?.documentElement)
                            doc.documentElement.setAttribute("lang", "en");
                    }
                    catch { /* */ }
                    // Apply zoom AND force a spellcheck-marker reflow on every
                    // open — not only when zoom differs from default. WebView2
                    // places the red underlines once, against whatever font
                    // metrics existed at the instant spellcheck engaged; if the
                    // content/layout settles a frame later (initial setContent,
                    // skin CSS applying), the markers strand ABOVE the words
                    // (Bob 2026-06-21, still misplaced at default size). applyZoom
                    // re-asserts the font-size (a no-op at default) and toggles
                    // spellcheck off→on, which re-runs marker placement at the
                    // final metrics. Defer to the next frame so the initial
                    // content render lands first.
                    const reflowRaf = ((ed.getWin && ed.getWin()) || window).requestAnimationFrame || ((f) => setTimeout(f, 16));
                    reflowRaf(() => applyZoom());
                    // Ctrl+wheel zoom inside the editor iframe.
                    try {
                        const doc = ed.getDoc();
                        doc.addEventListener("wheel", (e) => {
                            if (!e.ctrlKey)
                                return;
                            e.preventDefault();
                            bumpZoom(e.deltaY < 0 ? ZOOM_STEP : -ZOOM_STEP);
                        }, { passive: false });
                        // Capture-phase keydown so we beat WebView2's built-in
                        // page-zoom binding. `e.key` is "=" (or "+" with shift),
                        // "-", and "0". MetaKey covers Mac Cmd; ctrlKey covers
                        // Windows/Linux Ctrl. Numpad +/- arrive as "+" / "-"
                        // already so a single check covers both.
                        doc.addEventListener("keydown", (e) => {
                            if (!(e.ctrlKey || e.metaKey))
                                return;
                            if (e.key === "=" || e.key === "+") {
                                e.preventDefault();
                                e.stopPropagation();
                                bumpZoom(ZOOM_STEP);
                            }
                            else if (e.key === "-") {
                                e.preventDefault();
                                e.stopPropagation();
                                bumpZoom(-ZOOM_STEP);
                            }
                            else if (e.key === "0") {
                                e.preventDefault();
                                e.stopPropagation();
                                zoomPx = ZOOM_DEFAULT;
                                applyZoom();
                            }
                        }, true);
                    }
                    catch { /* */ }
                });
            },
        });
    });
    return {
        setHtml(html) { editor.setContent(html); },
        getHtml() { return editor.getContent(); },
        getText() { return editor.getContent({ format: "text" }); },
        /** Subscribe to content changes. compose.ts calls this to drive draft
         *  auto-save; it was MISSING from the facade, so the call threw
         *  "editor.onContentChange is not a function" during compose init —
         *  aborting the rest of init and leaving change-tracking unwired, so
         *  edits made via dialogs (Insert code, Source code) were never
         *  captured into the draft and looked "ignored" (Bob 2026-06-06).
         *  `SetContent`/`ExecCommand` are the load-bearing events here: they're
         *  what the codesample + code (source) dialogs fire on apply. */
        onContentChange(handler) {
            editor.on("input change undo redo keyup SetContent ExecCommand AddUndo", () => handler());
        },
        focus() { editor.focus(); },
        setCursor(pos) {
            // TinyMCE works in ranges, not character indices. Map the
            // common rmfmail usages: pos === 0 → cursor at TOP of body
            // (reply / forward — user types above the quoted block);
            // anything else → cursor at END (legacy "put cursor at end"
            // semantics).
            const place = () => {
                const body = editor.getBody();
                if (pos === 0) {
                    // Put the caret INSIDE the first block element. Collapsing
                    // to raw body-start lands it outside any block (before /
                    // between bare nodes) where contentEditable insertion is
                    // unpredictable — Bob 2026-05-21: "typing goes in the
                    // wrong place until you try again". rmfmail's reply body
                    // now leads with a real <p>; drop the caret into it.
                    const first = body.firstChild;
                    if (first && first.nodeType === 1 /* element */) {
                        editor.selection.setCursorLocation(first, 0);
                    }
                    else {
                        editor.selection.select(body, true);
                        editor.selection.collapse(true);
                    }
                    editor.focus();
                    // Viewport to the top so the user looks at the empty
                    // reply line, not scrolled down into the quote.
                    editor.getWin()?.scrollTo(0, 0);
                }
                else {
                    editor.selection.select(body, true);
                    editor.selection.collapse(false);
                    editor.focus();
                    editor.selection.scrollIntoView();
                }
            };
            try {
                place();
                // Re-apply on the next frame. Cross-iframe focus (compose is
                // an iframe; TinyMCE's edit area is a nested iframe) lets the
                // first selection set get clobbered by a late layout / focus
                // event — the "have to click in and try again" symptom. A
                // second pass after the frame settles makes it stick.
                editor.getWin()?.requestAnimationFrame?.(() => {
                    try {
                        place();
                    }
                    catch { /* */ }
                });
            }
            catch { /* */ }
        },
        get root() { return editor.getContainer(); },
        on(event, handler) { editor.on(event, handler); },
        off(event, handler) { editor.off(event, handler); },
        showSource() { openSourceDialog(editor); },
        nativeEditor: editor,
    };
}
/** View/edit the full HTML source with the textarea caret placed at the
 *  same spot as the current DOM caret (Bob 2026-07-18: "show source that
 *  positions me at the current position in the source"). Drops a private-use
 *  sentinel char at the caret, serializes, finds the sentinel's byte offset,
 *  strips it from both the DOM (pristine again) and the displayed source,
 *  then opens a textarea dialog scrolled + selected to that offset. Save
 *  round-trips the edited source back through setContent. */
function openSourceDialog(editor) {
    const SENTINEL = ""; // private-use area — never in real mail text
    let offset = -1;
    try {
        // Insert the sentinel at the caret without polluting undo history.
        const insert = () => editor.selection.setContent(SENTINEL);
        if (typeof editor.undoManager?.ignore === "function")
            editor.undoManager.ignore(insert);
        else
            insert();
        const marked = editor.getContent();
        offset = marked.indexOf(SENTINEL);
        // Remove the sentinel char from the DOM directly — a targeted
        // nodeValue edit keeps the rest of the document (and the user's real
        // caret context) untouched, unlike a full setContent.
        try {
            const body = editor.getBody();
            const doc = editor.getDoc();
            const walk = doc.createTreeWalker(body, NodeFilter.SHOW_TEXT, null);
            let n;
            while ((n = walk.nextNode())) {
                if (n.nodeValue && n.nodeValue.indexOf(SENTINEL) >= 0) {
                    n.nodeValue = n.nodeValue.replace(SENTINEL, "");
                    break;
                }
            }
        }
        catch { /* sentinel cleanup is best-effort */ }
    }
    catch { /* fall through to a plain source view at offset 0 */ }
    // Display the pristine source (sentinel already stripped from the DOM);
    // `offset` is where the sentinel sat, i.e. the caret position.
    const source = editor.getContent();
    const caretAt = offset >= 0 ? Math.min(offset, source.length) : source.length;
    editor.windowManager.open({
        title: "HTML source",
        size: "large",
        body: {
            type: "panel",
            items: [{ type: "textarea", name: "src", label: "", maximized: true }],
        },
        initialData: { src: source },
        buttons: [
            { type: "cancel", text: "Cancel" },
            { type: "submit", text: "Save", primary: true },
        ],
        onSubmit: (api) => {
            const data = api.getData();
            editor.undoManager.transact(() => editor.setContent(data.src || ""));
            editor.nodeChanged();
            api.close();
        },
    });
    // Place the textarea caret at the DOM-caret offset. The dialog's textarea
    // isn't exposed through the dialog API, so reach it via the DOM once the
    // dialog has painted (rAF ×2 — TinyMCE focuses it after OpenWindow).
    const win = (editor.getWin && editor.getWin()) || window;
    const raf = win.requestAnimationFrame || ((f) => setTimeout(f, 16));
    raf(() => raf(() => {
        const tas = Array.from(document.querySelectorAll(".tox-dialog textarea"));
        const ta = tas[tas.length - 1]; // the dialog just opened is the last
        if (!ta)
            return;
        try {
            ta.focus();
            ta.setSelectionRange(caretAt, caretAt);
            // Scroll the caret line into view: approximate by line count.
            const before = ta.value.slice(0, caretAt);
            const line = before.split("\n").length - 1;
            const lineH = parseFloat(getComputedStyle(ta).lineHeight) || 18;
            ta.scrollTop = Math.max(0, (line * lineH) - ta.clientHeight / 2);
        }
        catch { /* not the source textarea */ }
    }));
}
//# sourceMappingURL=adapter.js.map