/**
 * The Cut / Copy / Paste / Select-all block, for every menu in the app.
 *
 * Why this exists: mailx replaces the WebView's native context menu almost
 * everywhere (app.ts kills the default one, and each surface builds its own),
 * and a menu that takes over from the native one inherits the native one's
 * JOB — including the clipboard. Surfaces that forgot produced a right-click
 * with no Cut/Copy/Paste at all, which is what a user hits first and what
 * Bob's wife reported (2026-08-09, "some menus don't show copy/cut/paste").
 *
 * Before this file there were three separate clipboard implementations —
 * a rich-editor one in compose/edit-commands.ts, a hand-rolled one for the
 * recipient <input> in compose.ts, and nothing anywhere else. They differed
 * in what they put on the clipboard and in whether a refusal was reported or
 * swallowed. Now every menu builds its clipboard block from `editMenuItems()`
 * and every clipboard action runs through `runClipboard()`.
 *
 * Two execution paths, ONE entry point:
 *   - `engine` set (TinyMCE Editor or Quill) → the rich path, which preserves
 *     text/html so formatting survives a copy between messages.
 *   - otherwise → the DOM path, which handles <input>/<textarea> through
 *     value + selectionStart/End, and contenteditable/plain page text through
 *     the Selection API.
 *
 * NOT document.execCommand("copy"/"paste"): those need transient user
 * activation, which a context-menu callback (and especially a native-menu
 * callback dispatched from Rust via ExecuteScript) does not carry. The async
 * Clipboard API works from a focused document; clipboard READ is
 * permission-gated and can legitimately be refused — so these functions
 * REJECT with a real message instead of returning quietly, and every caller
 * surfaces it. A menu item that silently does nothing is worse than no menu
 * item.
 */
/** Input types whose value is text the user can sensibly cut/copy/paste.
 *  Deliberately excludes the pickers (date, color, file, checkbox…), where a
 *  clipboard item would do nothing. */
const TEXTUAL_INPUT_TYPES = new Set([
    "text", "search", "email", "url", "tel", "number", "password", "",
]);
function isTinyEngine(ne) {
    return !!ne && typeof ne.execCommand === "function" && !!ne.selection;
}
/** Classify `target` for the purposes of a clipboard menu. Never throws —
 *  a menu must open even if it can't offer anything. */
export function describeEditTarget(target) {
    const none = { el: null, editable: false, selection: "", canRead: true, isField: false };
    const node = target;
    if (!node)
        return { ...none, selection: pageSelection() };
    const start = (node.nodeType === Node.ELEMENT_NODE ? node : node.parentElement);
    if (!start)
        return { ...none, selection: pageSelection() };
    const field = start.closest("input, textarea");
    if (field) {
        const type = (field instanceof HTMLInputElement ? field.type : "text").toLowerCase();
        if (!TEXTUAL_INPUT_TYPES.has(type))
            return { ...none, selection: pageSelection() };
        const s = field.selectionStart ?? 0, e = field.selectionEnd ?? 0;
        return {
            el: field,
            editable: !field.readOnly && !field.disabled,
            selection: e > s ? field.value.slice(s, e) : "",
            canRead: type !== "password",
            isField: true,
        };
    }
    const ce = start.closest("[contenteditable]");
    const editable = !!ce && ce.isContentEditable;
    return { el: ce || start, editable, selection: pageSelection(), canRead: true, isField: false };
}
function pageSelection() {
    try {
        return window.getSelection()?.toString() || "";
    }
    catch {
        return "";
    }
}
function escapeHtml(s) {
    return s.replace(/[&<>]/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;" }[c]));
}
/** Read the clipboard, preferring text/html so formatting survives a paste
 *  exactly as Ctrl+V would. Returns both shapes; callers pick. */
async function readClipboard() {
    let html = "", text = "";
    if (navigator.clipboard?.read) {
        for (const item of await navigator.clipboard.read()) {
            if (item.types.includes("text/html"))
                html = await (await item.getType("text/html")).text();
            if (item.types.includes("text/plain"))
                text = await (await item.getType("text/plain")).text();
        }
        // Chromium resolves read() with an empty list rather than rejecting
        // when the page isn't allowed to read — don't let that look like an
        // empty clipboard.
        if (!html && !text)
            text = await navigator.clipboard.readText();
    }
    else {
        text = await navigator.clipboard.readText();
    }
    return { html, text };
}
/** Write `text` (and optionally `html`) to the clipboard in one item, so a
 *  paste into a rich target keeps formatting and a paste into a plain one
 *  still gets sensible text. */
async function writeClipboard(text, html) {
    if (html && typeof ClipboardItem === "function" && navigator.clipboard?.write) {
        await navigator.clipboard.write([new ClipboardItem({
                "text/html": new Blob([html], { type: "text/html" }),
                "text/plain": new Blob([text], { type: "text/plain" }),
            })]);
        return;
    }
    await navigator.clipboard.writeText(text);
}
/** Expand a collapsed caret to the word under it, so "right-click a word →
 *  Copy" acts on that word rather than on nothing. Rich-editor path only:
 *  in a plain field an empty selection means empty, and silently grabbing a
 *  word would be a surprise. */
function expandToWord(ne) {
    if (isTinyEngine(ne)) {
        try {
            if (ne.selection.isCollapsed() && typeof ne.selection.expand === "function") {
                ne.selection.expand({ type: "word" });
            }
        }
        catch { /* older TinyMCE — the command still applies at the caret */ }
        return;
    }
    if (ne && typeof ne.getSelection === "function" && typeof ne.setSelection === "function") {
        const sel = ne.getSelection();
        if (!sel || sel.length > 0)
            return;
        const text = ne.getText();
        let a = sel.index, b = sel.index;
        while (a > 0 && /\S/.test(text[a - 1]))
            a--;
        while (b < text.length && /\S/.test(text[b]))
            b++;
        if (b > a)
            ne.setSelection(a, b - a);
    }
}
/** Replace the selected range of an <input>/<textarea> with `text`, leaving
 *  the caret after it, and fire `input` so listeners (autocomplete, autosave,
 *  auto-grow) see the change — assigning .value alone fires nothing. */
function replaceFieldSelection(field, text) {
    const s = field.selectionStart ?? field.value.length;
    const e = field.selectionEnd ?? field.value.length;
    field.value = field.value.slice(0, s) + text + field.value.slice(e);
    field.selectionStart = field.selectionEnd = s + text.length;
    field.dispatchEvent(new Event("input", { bubbles: true }));
}
/**
 * Run a clipboard command for `ctx`. Rejects (never swallows) when the
 * clipboard refuses — callers report it.
 */
export async function runClipboard(ctx, id) {
    if (ctx.engine)
        return runEngineClipboard(ctx.engine, id);
    const info = describeEditTarget(ctx.target);
    if (id === "paste") {
        if (!info.editable || !info.el)
            throw new Error("nothing here accepts a paste");
        const { html, text } = await readClipboard();
        if (info.isField) {
            // A field holds text, so HTML on the clipboard is pasted as its
            // text — which is what every other app does here.
            if (!text && !html)
                return;
            replaceFieldSelection(info.el, text || stripHtml(html));
            return;
        }
        const content = html || (text ? escapeHtml(text).replace(/\r?\n/g, "<br>") : "");
        if (!content)
            return;
        insertHtmlAtSelection(info.el, content);
        return;
    }
    if (!info.canRead)
        throw new Error("this field's contents can't be copied");
    if (!info.selection)
        return; // nothing selected — no-op, not an error
    if (info.isField) {
        await writeClipboard(info.selection);
    }
    else {
        await writeClipboard(info.selection, selectionHtml());
    }
    if (id !== "cut")
        return;
    if (!info.editable)
        throw new Error("this text is read-only — copied instead of cut");
    if (info.isField) {
        replaceFieldSelection(info.el, "");
        return;
    }
    try {
        window.getSelection()?.deleteFromDocument();
    }
    catch { /* selection vanished */ }
}
/** Rich-editor clipboard (TinyMCE / Quill). Keeps text/html on copy so a
 *  formatted quote survives the round trip. */
async function runEngineClipboard(ne, id) {
    const isTiny = isTinyEngine(ne);
    if (id === "paste") {
        const { html, text } = await readClipboard();
        const content = html || (text ? escapeHtml(text).replace(/\r?\n/g, "<br>") : "");
        if (!content)
            return;
        if (isTiny) {
            ne.execCommand("mceInsertContent", false, content);
            return;
        }
        if (ne?.clipboard?.dangerouslyPasteHTML) {
            const sel = ne.getSelection(true);
            ne.clipboard.dangerouslyPasteHTML(sel?.index ?? 0, content);
            return;
        }
        throw new Error("editor doesn't support paste");
    }
    expandToWord(ne);
    let html = "", text = "";
    if (isTiny) {
        html = ne.selection.getContent({ format: "html" });
        text = ne.selection.getContent({ format: "text" });
    }
    else {
        text = pageSelection();
        html = selectionHtml();
    }
    if (!text && !html)
        return;
    await writeClipboard(text, html || undefined);
    if (id !== "cut")
        return;
    if (isTiny) {
        ne.execCommand("Delete");
        return;
    }
    if (typeof ne?.deleteText === "function") {
        const sel = ne.getSelection();
        if (sel?.length)
            ne.deleteText(sel.index, sel.length);
    }
}
/** Serialize the current DOM selection as HTML, for a formatting-preserving copy. */
function selectionHtml() {
    try {
        const sel = window.getSelection();
        if (!sel || sel.rangeCount === 0 || sel.isCollapsed)
            return "";
        const div = document.createElement("div");
        div.appendChild(sel.getRangeAt(0).cloneContents());
        return div.innerHTML;
    }
    catch {
        return "";
    }
}
function stripHtml(html) {
    const div = document.createElement("div");
    div.innerHTML = html;
    return div.textContent || "";
}
/** Insert `html` at the caret inside a contenteditable host. */
function insertHtmlAtSelection(host, html) {
    const sel = window.getSelection();
    const frag = document.createRange().createContextualFragment(html);
    if (!sel || sel.rangeCount === 0) {
        host?.appendChild(frag);
        return;
    }
    const range = sel.getRangeAt(0);
    range.deleteContents();
    const last = frag.lastChild;
    range.insertNode(frag);
    if (last) {
        range.setStartAfter(last);
        range.collapse(true);
        sel.removeAllRanges();
        sel.addRange(range);
    }
}
/** Select everything in the target — the field's value, or the page selection
 *  scoped to the element the menu was opened over. */
function selectAll(info) {
    if (info.isField && info.el) {
        info.el.select();
        return;
    }
    const host = info.el;
    if (!host)
        return;
    const range = document.createRange();
    range.selectNodeContents(host);
    const sel = window.getSelection();
    sel?.removeAllRanges();
    sel?.addRange(range);
}
/**
 * The clipboard block for a menu, ready to spread into an items array.
 *
 * Returns [] when the right-click landed somewhere none of it applies (a
 * message row with no selection, a folder name, an icon) — so callers can
 * spread it unconditionally without ending up with a menu full of dead
 * entries. Items that apply but aren't currently available (Copy with nothing
 * selected) are shown DISABLED rather than hidden, so the menu doesn't change
 * shape under the pointer.
 */
export function editMenuItems(ctx, opts = {}) {
    const report = opts.onError || ((m) => console.warn(`[edit-menu] ${m}`));
    const info = ctx.engine
        ? { el: null, editable: true, selection: pageSelection(), canRead: true, isField: false }
        : describeEditTarget(ctx.target);
    // Nothing to offer: not editable, and no text selected to copy.
    if (!ctx.engine && !info.editable && !info.selection)
        return [];
    const run = (id) => () => {
        void runClipboard(ctx, id).catch((e) => {
            const key = id === "cut" ? "Ctrl+X" : id === "copy" ? "Ctrl+C" : "Ctrl+V";
            const what = id[0].toUpperCase() + id.slice(1);
            report(`${what} failed: ${e?.message || e}. ${key} still works.`);
        });
    };
    const hasSel = !!ctx.engine || !!info.selection;
    const items = [
        { label: "Cut", action: run("cut"), disabled: !info.editable || !hasSel || !info.canRead },
        { label: "Copy", action: run("copy"), disabled: !hasSel || !info.canRead },
        { label: "Paste", action: run("paste"), disabled: !info.editable },
    ];
    if (opts.selectAll !== false && !ctx.engine) {
        items.push({ label: "Select all", action: () => selectAll(info) });
    }
    return items;
}
//# sourceMappingURL=edit-menu.js.map