/**
 * SailWP Co-pilot — frontend inline editor (Sortable.js edition).
 *
 * After three rounds of custom drag-and-drop bugs, we threw out the
 * hand-rolled implementation and use Sortable.js — the same library
 * that powers Notion, Trello, Vue.Draggable, and React-Sortable-HOC.
 * It handles overflow:hidden, nested grids, touch, animations, and all
 * the browser quirks we kept tripping over.
 *
 * UX model:
 *
 *   1. Every recognised "block" (section, figure, planet card, list item,
 *      or a div that's a grid/flex item) becomes a Sortable item.
 *      Drag from anywhere on it to reorder among its siblings.
 *
 *   2. On hover, a tiny floating toolbar appears in the LEFT GUTTER
 *      next to the block: ⋮⋮ drag affordance + ↑/↓ arrows that move the
 *      block up/down one position. The arrows are foolproof for users
 *      who can't or don't want to drag.
 *
 *   3. Click any text leaf (heading, paragraph, button label, planet
 *      name) to switch it into a contenteditable. Blur or Enter saves.
 *
 * All edits debounce-save to REST /update-design, which wraps the design
 * HTML in <!-- wp:html --> and persists via wp_update_post (a revision
 * per save, so every change is one click away from undo).
 */
(function () {
    'use strict';

    var config = window.SailWPInlineEdit;
    if (!config || !config.postId || !config.restUrl) return;

    var Sortable = window.Sortable;
    if (!Sortable) {
        // Library failed to load — log and bail. Without Sortable we
        // could fall back to a custom drag, but that's exactly what we
        // just removed, so a clean failure is preferable to silently
        // shipping the broken path again.
        if (window.console) console.error('SailWP inline-edit: Sortable.js not loaded');
        return;
    }

    var DEBOUNCE_MS = 800;
    var saveTimer = null;
    var hasUnsavedChanges = false;
    var savingNow = false;
    var editEnabled = true;

    // ------------------------------------------------------------------
    // Classification helpers
    // ------------------------------------------------------------------

    function isPartOfChrome(el) {
        if (!el || !el.closest) return false;
        return !!el.closest(
            '#wpadminbar, .swcp-fullpage-header, .sw-rail-root, ' +
            '.swcp-edit-status, .swcp-edit-toolbar, .swcp-edit-format-popup, ' +
            '.swcp-edit-bg-popup, ' +
            '.swcp-preview-banner, #swcp-preview-overlay'
        );
    }

    /**
     * Return only canonical design-root wrappers — strictly the
     * `<div class="swcp-design-root">` element(s) we wrap the design
     * in via the fullpage template.
     *
     * Earlier versions used an exclusion list ("everything in body
     * minus chrome") which silently swept in third-party footer
     * widgets — Astra's scroll-to-top button, Spectra's
     * #zip-ai-sidebar, the Co-pilot rail itself (whose class is
     * `sw-rail`, not the `sw-rail-root` the filter checked for).
     * Saving then wrote all of those into post_content. The next
     * render put one copy in the body AND wp_footer added another;
     * after ten edits the page had ten stacked rails plus chevrons
     * spilling down the left edge (Joost: "rare witte berg met
     * irritante peiltjes").
     *
     * A whitelist is the safe shape here. The fullpage template owns
     * the wrapper; everything else is plug-in / theme chrome and
     * never belongs in post_content.
     */
    function getDesignRoots() {
        return Array.prototype.slice.call(
            document.querySelectorAll('.swcp-design-root')
        );
    }

    function isBlockElement(el) {
        if (!el || el.nodeType !== 1) return false;
        try {
            var disp = window.getComputedStyle(el).display;
            return disp === 'block' || disp === 'flex' || disp === 'grid' || disp === 'list-item';
        } catch (_) {
            return false;
        }
    }

    // Tag list of "definitely a text leaf — switch into contenteditable on
    // click". Extended on 2026-05-16 with DIV: Joost flagged that titles
    // like "Visual journey" weren't editable because Opus rendered them
    // inside a <div class="kicker"> instead of an <h*>/p tag. The
    // no-block-child guard below keeps generic wrapper DIVs out (a DIV
    // with an H2 inside still doesn't qualify).
    var TEXT_LEAF_TAGS = ['H1','H2','H3','H4','H5','H6','P','LI','BLOCKQUOTE','SMALL','FIGCAPTION','SPAN','A','BUTTON','LABEL','EM','STRONG','TIME','SUMMARY','DIV'];
    function isTextLeaf(el) {
        if (!el || el.nodeType !== 1) return false;
        if (TEXT_LEAF_TAGS.indexOf(el.tagName) < 0) return false;
        if (!el.textContent || !el.textContent.trim()) return false;
        for (var i = 0; i < el.children.length; i++) {
            if (isBlockElement(el.children[i])) return false;
        }
        // If this element is also a sortable drag item, don't claim it as a
        // text leaf — the drag interaction wins. Without this, hovering a
        // sort-item DIV that happens to be a text leaf produces both a
        // cursor:grab and a cursor:text, and the click is ambiguous.
        if (el.classList && el.classList.contains('swcp-edit-sort-item')) return false;
        return true;
    }

    function findTextLeaf(target) {
        while (target && target !== document.body) {
            if (isTextLeaf(target)) return target;
            target = target.parentElement;
        }
        return null;
    }

    // ------------------------------------------------------------------
    // Identify sortable containers
    //
    // A "container" is a parent whose children are draggable items.
    // We look for:
    //   - any element with 2+ children that are SECTION/ARTICLE/FIGURE/
    //     ASIDE/LI/HEADER/FOOTER/NAV/MAIN
    //   - any flex/grid layout container with 2+ DIV/A children
    //
    // The same parent may host items of different tags — that's fine,
    // Sortable doesn't care about tag uniformity.
    // ------------------------------------------------------------------

    // Anything in this set is recognised as a draggable item when it's
    // a direct child of a container. Text-level tags (h1-h6, p, blockquote)
    // are included so individual headings + paragraphs can be reordered
    // alongside section-level units — the cosmos hero, for example, has
    // an eyebrow <p>, an <h2>, and a lede <p> all directly inside the
    // .hero-inner wrapper and the user wants to shuffle them independently.
    var ITEM_TAGS = {
        SECTION:1, ARTICLE:1, FIGURE:1, ASIDE:1, LI:1, HEADER:1, FOOTER:1, NAV:1, MAIN:1,
        H1:1, H2:1, H3:1, H4:1, H5:1, H6:1, P:1, BLOCKQUOTE:1, UL:1, OL:1, IMG:1, PICTURE:1, FIGCAPTION:1
    };

    function findSortableContainers(root) {
        var containers = [];
        var seen = new Set();
        var walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT, null);
        var node = walker.currentNode;
        while (node) {
            if (qualifiesAsContainer(node) && !seen.has(node)) {
                containers.push(node);
                seen.add(node);
            }
            node = walker.nextNode();
        }
        return containers;
    }

    // Strip editor-added classes + state classes (.flip etc.) when
    // computing a child's "signature". Two articles "planet" and
    // "planet flip" should match — the flip variant is just a
    // layout-flip of the same kind of card.
    function childSignature(el) {
        var classes = (el.className || '').toString().split(/\s+/);
        var primary = '';
        for (var i = 0; i < classes.length; i++) {
            var c = classes[i];
            if (!c) continue;
            if (c.indexOf('swcp-') === 0) continue;
            if (c === 'flip' || c === 'reverse' || c === 'reversed' ||
                c === 'is-reversed' || c === 'swap' ||
                c === 'active' || c === 'selected' || c === 'current' ||
                c === 'open' || c === 'collapsed' || c === 'expanded' ||
                c === 'first' || c === 'last' || c === 'even' || c === 'odd' ||
                c === 'floats' || c === 'floats2') continue;
            primary = c;
            break;
        }
        return el.tagName + '.' + primary;
    }

    function qualifiesAsContainer(el) {
        if (!el || el.nodeType !== 1) return false;
        if (isPartOfChrome(el)) return false;
        var children = el.children;
        if (children.length < 2) return false;
        // The design-root itself is ALWAYS a sortable container — that's
        // the canonical place to reorder top-level sections, regardless
        // of whether the user just inserted a pattern with a different
        // wrapper class. Without this carve-out, inserting a <section>
        // with class "swcp-pattern-*" into a design-root full of
        // <section class="swcp-design-section"> elements breaks the
        // signature-match rule and the new section becomes un-draggable.
        // Joost 2026-05-17.
        if (el.classList && el.classList.contains('swcp-design-root')) {
            return true;
        }

        // Collect non-chrome / non-script children + their signatures.
        var sigs = [], tags = [];
        for (var i = 0; i < children.length; i++) {
            var c = children[i];
            if (isPartOfChrome(c)) continue;
            if (c.tagName === 'SCRIPT' || c.tagName === 'STYLE') continue;
            sigs.push(childSignature(c));
            tags.push(c.tagName);
        }
        if (sigs.length < 2) return false;
        // Pass 1: strict signature match (same tag + same primary class).
        // Catches the clean "grid of 4 identical .feature-card" case.
        var firstSig = sigs[0];
        var allSig = true;
        for (var j = 1; j < sigs.length; j++) {
            if (sigs[j] !== firstSig) { allSig = false; break; }
        }
        if (allSig) return true;
        // Pass 2 (v0.23.5, 2026-05-17): tag-only fallback for the common
        // "8 ARTICLE.planet-mercury / .planet-venus / …" case where each
        // card carries a unique modifier class. Strict signature rejected
        // these (different primary class per child); tag-only catches
        // them so the planet cards become individually draggable instead
        // of stuck-together. Joost asked for nested sortable.
        var firstTag = tags[0];
        for (var k = 1; k < tags.length; k++) {
            if (tags[k] !== firstTag) return false;
        }
        return true;
    }

    function isSortableItem(el, container) {
        if (!el || el.nodeType !== 1) return false;
        if (el.parentElement !== container) return false;
        if (isPartOfChrome(el)) return false;
        if (el.tagName === 'SCRIPT' || el.tagName === 'STYLE') return false;
        if (ITEM_TAGS[el.tagName]) return true;
        try {
            var disp = window.getComputedStyle(container).display;
            var isFG = disp === 'flex' || disp === 'inline-flex' || disp === 'grid' || disp === 'inline-grid';
            if (isFG && (el.tagName === 'DIV' || el.tagName === 'A')) return true;
        } catch (_) {}
        return false;
    }

    // ------------------------------------------------------------------
    // Sortable wiring
    // ------------------------------------------------------------------

    var sortableInstances = [];

    function destroySortables() {
        sortableInstances.forEach(function (s) { try { s.destroy(); } catch (_) {} });
        sortableInstances = [];
    }

    /**
     * Sweep up anything that looks like an orphan from a previous drag
     * or a previous plugin version still cached in the page. Joost saw
     * "rare witte berg met irritante peiltjes" — most likely leftover
     * Sortable.js fallback clones or stale toolbar copies that never
     * got cleaned up. Running this on every attachSortables call keeps
     * the page free of those droppings.
     */
    function sweepOrphans() {
        var selectors = [
            '.sortable-fallback',
            '.sortable-ghost',
            '.sortable-chosen',
            '.sortable-drag',
            '.swcp-edit-ghost',
            '.swcp-edit-dragging',
            '.swcp-edit-chosen'
        ];
        selectors.forEach(function (sel) {
            document.querySelectorAll(sel).forEach(function (el) {
                // Pure fallback / ghost clones are top-level body children
                // that don't belong in any container — yank them. The
                // class-only matches inside a real sortable container
                // are just stuck state; strip the class instead of the
                // element.
                if (el.parentElement === document.body && sel.indexOf('ghost') < 0 && sel.indexOf('chosen') < 0) {
                    el.parentNode.removeChild(el);
                } else {
                    el.classList.remove(sel.slice(1));
                }
            });
        });
        // Multiple status chips or toolbars from older plugin versions
        // baking themselves into the page. Keep only the first.
        ['.swcp-edit-toolbar', '.swcp-edit-status'].forEach(function (sel) {
            var nodes = document.querySelectorAll(sel);
            for (var i = 1; i < nodes.length; i++) {
                nodes[i].parentNode && nodes[i].parentNode.removeChild(nodes[i]);
            }
        });
    }

    function attachSortables() {
        destroySortables();
        sweepOrphans();
        getDesignRoots().forEach(function (root) {
            findSortableContainers(root).forEach(function (container) {
                container.classList.add('swcp-edit-sort-container');
                // Tag children that count as items, so Sortable's
                // `draggable` selector works (it CSS-matches each child).
                Array.from(container.children).forEach(function (child) {
                    if (isSortableItem(child, container)) {
                        child.classList.add('swcp-edit-sort-item');
                    }
                });
                var instance = Sortable.create(container, {
                    draggable: '.swcp-edit-sort-item',
                    animation: 160,
                    ghostClass: 'swcp-edit-ghost',
                    chosenClass: 'swcp-edit-chosen',
                    dragClass:   'swcp-edit-dragging',
                    forceFallback: false,
                    fallbackOnBody: true,
                    delay: 120,
                    delayOnTouchOnly: false,
                    touchStartThreshold: 4,
                    filter: '[contenteditable="true"], input, textarea, select',
                    preventOnFilter: false,
                    onStart: function () {
                        document.body.classList.add('swcp-edit-dragging-active');
                        hideToolbar();
                        // Drop any sticky text edits / browser text-selections
                        // before the drag starts — otherwise Mercury keeps a
                        // coral outline + a highlighted word after landing,
                        // and "permanent geselecteerd" was Joost's exact
                        // complaint on the May 13 20:30 test.
                        commitActiveTextEdit();
                        clearTextSelection();
                        pushUndo(); // tentative; popUndoIfMatches in onEnd if no move
                    },
                    onEnd: function (evt) {
                        document.body.classList.remove('swcp-edit-dragging-active');
                        clearTextSelection();
                        if (evt.oldIndex !== evt.newIndex) {
                            markChanged();
                            flashJustMoved(evt.item);
                        } else {
                            popUndoIfMatches();
                        }
                    }
                });
                sortableInstances.push(instance);
            });
        });
        // After re-wiring sortables, the gaps between top-level children
        // may have moved (drag-end, insert, delete). Refresh the zones
        // so each new gap is immediately clickable.
        if (typeof scheduleInsertZonesRefresh === 'function') scheduleInsertZonesRefresh();
    }

    function flashJustMoved(el) {
        if (!el) return;
        el.classList.add('swcp-edit-just-dropped');
        setTimeout(function () { el.classList.remove('swcp-edit-just-dropped'); }, 700);
    }

    // ------------------------------------------------------------------
    // Undo stack — captures a pre-mutation snapshot of every design
    // root before each edit (text commit / drag / arrow / delete) and
    // pops the most recent one when the user hits Cmd+Z or the ↺ button.
    //
    // Snapshots are plain HTML strings stored in-memory; capped at 25
    // entries so a long editing session can't blow up memory. Reload =
    // history clears (the canonical state lives in WP post_content).
    // ------------------------------------------------------------------

    var undoStack = [];
    var UNDO_CAP = 25;
    var undoBtnEl = null;

    function snapshotDesign() {
        return getDesignRoots().map(function (r) {
            var clone = r.cloneNode(true);
            stripEditClasses(clone);
            return clone.outerHTML;
        }).join('\n');
    }

    function pushUndo() {
        var snap = snapshotDesign();
        // Skip identical consecutive snapshots — happens when text edit
        // committed without changes, or drag start was cancelled.
        if (undoStack.length && undoStack[undoStack.length - 1] === snap) return;
        undoStack.push(snap);
        if (undoStack.length > UNDO_CAP) undoStack.shift();
        refreshUndoButton();
    }

    function popUndoIfMatches() {
        // After a tentative push (drag start / text-edit start), if the
        // resulting state is identical to the snapshot just pushed, pop
        // it to keep the stack clean.
        if (!undoStack.length) return;
        if (undoStack[undoStack.length - 1] === snapshotDesign()) {
            undoStack.pop();
            refreshUndoButton();
        }
    }

    function performUndo() {
        if (!undoStack.length) return;
        var snap = undoStack.pop();
        var roots = getDesignRoots();
        if (!roots.length) return;
        // Re-parse the snapshot and swap it in place. We replace the
        // first root's content + remove the rest, then insert any extra
        // roots from the snapshot. In practice there's only one root
        // (.swcp-design-root) so this is straightforward.
        var anchor = roots[0];
        var parent = anchor.parentNode;
        if (!parent) return;
        var nextSibling = roots[roots.length - 1].nextSibling;
        roots.forEach(function (r) { r.remove(); });
        var tmp = document.createElement('div');
        tmp.innerHTML = snap;
        var newRoots = Array.prototype.slice.call(tmp.children);
        newRoots.forEach(function (nr) {
            parent.insertBefore(nr, nextSibling);
        });
        attachSortables();
        hideToolbar();
        commitActiveTextEdit();
        clearTextSelection();
        hasUnsavedChanges = true;
        scheduleSave();
        showStatus('Undone · saving…', 1500);
        refreshUndoButton();
    }

    function refreshUndoButton() {
        if (!undoBtnEl) return;
        undoBtnEl.disabled = undoStack.length === 0;
        undoBtnEl.classList.toggle('is-disabled', undoStack.length === 0);
    }

    function commitActiveTextEdit() {
        // If a contenteditable is currently focused, blur it so its
        // change handler commits the edit and removes the outline class.
        // Otherwise the source element travels through the drag still
        // marked .swcp-edit-active-text and the user sees a permanent
        // coral box on the moved card.
        var actives = document.querySelectorAll('.swcp-edit-active-text, [contenteditable="true"]');
        actives.forEach(function (el) {
            if (typeof el.blur === 'function') el.blur();
            el.contentEditable = 'false';
            el.classList.remove('swcp-edit-active-text');
        });
    }

    function clearTextSelection() {
        try {
            var sel = window.getSelection();
            if (sel && sel.removeAllRanges) sel.removeAllRanges();
        } catch (_) { /* selection APIs vary by browser */ }
    }

    // ------------------------------------------------------------------
    // Hover toolbar — ⋮⋮ + ↑ + ↓ in the left gutter of any sortable item
    //
    // Arrows are the safety net: if the user can't grab the drag handle
    // or the design has tricky pointer-events / overflow rules, they can
    // always nudge a block up or down one position by clicking.
    // ------------------------------------------------------------------

    var toolbar = null;
    var toolbarTarget = null;

    function ensureToolbar() {
        if (toolbar) return toolbar;
        // Defensive cleanup — strip any stale toolbar from a previous load.
        document.querySelectorAll('.swcp-edit-toolbar').forEach(function (el) {
            el.parentNode && el.parentNode.removeChild(el);
        });
        toolbar = document.createElement('div');
        toolbar.className = 'swcp-edit-toolbar';
        // Toolbar v0.23.5 (2026-05-17): Joost found the red × too
        // aggressive, asked for up/down arrows + a duplicate button.
        // Order: [Replace (img only)] [↑] [↓] [⧉ Duplicate] [×].
        // The × is back to subtle white with a tooltip; the destructive
        // affordance lives in the icon (×) plus the "Delete section"
        // tooltip, not in a permanent red wash.
        toolbar.innerHTML = (
            '<button type="button" class="swcp-edit-tb-replace" data-tip="Replace image" aria-label="Replace image" style="display:none;">' +
                '<svg width="14" height="14" viewBox="0 0 14 14" fill="none">' +
                    '<rect x="1.5" y="2.5" width="11" height="9" rx="1.3" stroke="currentColor" stroke-width="1.4"/>' +
                    '<circle cx="5" cy="6" r="1.1" fill="currentColor"/>' +
                    '<path d="M2 10 L5.5 7 L8 9 L10.5 6.5 L12 8" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round" fill="none"/>' +
                '</svg>' +
            '</button>' +
            '<button type="button" class="swcp-edit-tb-up" data-tip="Move up" aria-label="Move section up">' +
                '<svg width="12" height="12" viewBox="0 0 12 12" fill="none">' +
                    '<path d="M3 7 L6 4 L9 7" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"/>' +
                '</svg>' +
            '</button>' +
            '<button type="button" class="swcp-edit-tb-down" data-tip="Move down" aria-label="Move section down">' +
                '<svg width="12" height="12" viewBox="0 0 12 12" fill="none">' +
                    '<path d="M3 5 L6 8 L9 5" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"/>' +
                '</svg>' +
            '</button>' +
            '<button type="button" class="swcp-edit-tb-duplicate" data-tip="Duplicate section" aria-label="Duplicate section">' +
                '<svg width="12" height="12" viewBox="0 0 12 12" fill="none">' +
                    '<rect x="1.5" y="3.5" width="6" height="7" rx="1" stroke="currentColor" stroke-width="1.4"/>' +
                    '<rect x="4.5" y="1.5" width="6" height="7" rx="1" stroke="currentColor" stroke-width="1.4"/>' +
                '</svg>' +
            '</button>' +
            '<button type="button" class="swcp-edit-tb-delete" data-tip="Delete section" aria-label="Delete section">' +
                '<svg width="11" height="11" viewBox="0 0 11 11" fill="none">' +
                    '<path d="M2 2 L9 9 M9 2 L2 9" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/>' +
                '</svg>' +
            '</button>'
        );
        toolbar.style.display = 'none';
        document.body.appendChild(toolbar);

        toolbar.querySelector('.swcp-edit-tb-delete').addEventListener('click', function (e) {
            e.preventDefault();
            e.stopPropagation();
            if (!toolbarTarget) return;
            var target = toolbarTarget;
            pushUndo();
            hideToolbar();
            target.classList.add('swcp-edit-removing');
            setTimeout(function () {
                if (target.parentNode) target.parentNode.removeChild(target);
                markChanged();
                if (typeof attachSortables === 'function') attachSortables();
            }, 220);
        });

        toolbar.querySelector('.swcp-edit-tb-replace').addEventListener('click', function (e) {
            e.preventDefault();
            e.stopPropagation();
            if (!toolbarTarget) return;
            triggerImageReplace(toolbarTarget);
        });

        toolbar.querySelector('.swcp-edit-tb-up').addEventListener('click', function (e) {
            e.preventDefault();
            e.stopPropagation();
            moveToolbarTarget(-1);
        });
        toolbar.querySelector('.swcp-edit-tb-down').addEventListener('click', function (e) {
            e.preventDefault();
            e.stopPropagation();
            moveToolbarTarget(1);
        });
        toolbar.querySelector('.swcp-edit-tb-duplicate').addEventListener('click', function (e) {
            e.preventDefault();
            e.stopPropagation();
            duplicateToolbarTarget();
        });

        return toolbar;
    }

    /**
     * Move the currently-toolbared section one slot up (delta=-1) or
     * down (delta=+1) within its parent. Both arrow buttons in the
     * toolbar route through here. Foolproof alternative to drag for
     * users who don't want to grab and drop. Joost 2026-05-17.
     */
    function moveToolbarTarget(delta) {
        if (!toolbarTarget) return;
        var parent = toolbarTarget.parentNode;
        if (!parent) return;
        var sibling = (delta < 0) ? toolbarTarget.previousElementSibling : toolbarTarget.nextElementSibling;
        while (sibling && !(sibling.classList && sibling.classList.contains('swcp-edit-sort-item'))) {
            sibling = (delta < 0) ? sibling.previousElementSibling : sibling.nextElementSibling;
        }
        if (!sibling) {
            // Edge case — already at the top or bottom. Joost 2026-05-17:
            // silent no-op felt broken ("move-up nee niks"). Show a chip
            // so the user knows the click landed AND why nothing moved.
            showStatus && showStatus(delta < 0 ? 'Already at the top' : 'Already at the bottom', 1600);
            return;
        }
        pushUndo();
        if (delta < 0) {
            parent.insertBefore(toolbarTarget, sibling);
        } else {
            parent.insertBefore(toolbarTarget, sibling.nextSibling);
        }
        flashJustMoved(toolbarTarget);
        markChanged();
        positionToolbarOn(toolbarTarget);
        scheduleInsertZonesRefresh();
    }

    /**
     * Clone the currently-toolbared section and insert the copy just
     * after the original. Joost 2026-05-17: "we willen hier ook een
     * duplicate knop hebben" — common for repeating-card layouts.
     */
    function duplicateToolbarTarget() {
        if (!toolbarTarget) return;
        var parent = toolbarTarget.parentNode;
        if (!parent) return;
        pushUndo();
        var clone = toolbarTarget.cloneNode(true);
        // Strip transient editor classes so the clone starts fresh.
        clone.classList.remove('swcp-edit-just-inserted');
        clone.classList.remove('swcp-edit-just-dropped');
        clone.classList.remove('swcp-edit-removing');
        parent.insertBefore(clone, toolbarTarget.nextSibling);
        flashJustMoved(clone);
        markChanged();
        if (typeof attachSortables === 'function') attachSortables();
        scheduleInsertZonesRefresh();
    }

    /**
     * Replace the image inside `target` (which is either an <img> or a
     * container that contains an <img>) by prompting for a file upload.
     * Uses the existing /sailwp-copilot/v1/upload-image REST endpoint
     * (same one the chat-rail uses for paperclip attachments) so we
     * don't need wp.media on the frontend. The new image keeps the
     * original aspect-ratio slot via CSS: we never resize the container,
     * only swap src.
     */
    function triggerImageReplace(target) {
        var img = (target.tagName === 'IMG') ? target : target.querySelector('img');
        if (!img) return;
        var picker = document.createElement('input');
        picker.type = 'file';
        picker.accept = 'image/*';
        picker.style.display = 'none';
        document.body.appendChild(picker);

        var prevOpacity = img.style.opacity;
        var inFlight = false;
        function restoreOpacity() {
            if (!inFlight) return;
            img.style.opacity = prevOpacity || '';
            inFlight = false;
        }

        picker.addEventListener('change', function () {
            var file = picker.files && picker.files[0];
            picker.parentNode && picker.parentNode.removeChild(picker);
            if (!file) return;
            if (file.size > 25 * 1024 * 1024) {
                window.alert('That image is over 25 MB. Try a smaller one.');
                return;
            }
            img.style.opacity = '0.4';
            inFlight = true;
            // Visible progress chip — Joost 2026-05-17 v0.24.0 specifically
            // asked for "een laadindicator" while uploads run. Previously
            // the only signal was the image fading to 0.4 opacity, which
            // is too subtle on a busy page.
            showStatus && showStatus('Resizing image…', 0);
            resizeImageFile(file).then(function (resized) {
                showStatus && showStatus('Uploading ' + Math.round(resized.size / 1024) + ' KB…', 0);
                var cfg = window.SailWPCopilot || {};
                return fetch(cfg.restUrl + 'upload-image', {
                    method: 'POST',
                    headers: { 'Content-Type': 'application/json', 'X-WP-Nonce': cfg.restNonce },
                    credentials: 'same-origin',
                    body: JSON.stringify({ name: resized.name, dataUrl: resized.dataUrl })
                });
            }).then(function (r) { return r.json(); }).then(function (data) {
                if (!data || !data.ok || !data.url) {
                    var msg = (data && data.message) || 'Could not upload that image. Try again.';
                    throw new Error(msg);
                }
                pushUndo();
                img.removeAttribute('srcset');
                img.removeAttribute('sizes');
                var pict = img.closest('picture');
                if (pict) {
                    pict.querySelectorAll('source').forEach(function (s) { s.remove(); });
                }
                img.src = data.url;
                showStatus && showStatus('Image replaced ✓', 1800);
                markChanged();
            }).catch(function (err) {
                showStatus && showStatus('Upload failed', 2400);
                window.alert('Upload failed: ' + (err && err.message ? err.message : 'unknown error'));
            }).then(restoreOpacity, restoreOpacity);
        });
        picker.click();
    }

    /**
     * Resize an image File client-side before upload. Big files (Joost
     * 2026-05-17: 4.7 MB iPhone screenshot) frozen the renderer thread
     * because we were base64-encoding the raw bytes into a 6+ MB JSON
     * body. Down-scale to MAX_DIM longest edge + JPEG quality 0.85 turns
     * a 4.7 MB PNG into ~300-500 KB before it ever hits the wire.
     *
     * Returns Promise<{name, dataUrl, size}>. Falls back to the original
     * file if anything fails (corrupted image, canvas refused, etc.).
     */
    function resizeImageFile(file) {
        var MAX_DIM = 1800;
        var QUALITY = 0.85;
        return new Promise(function (resolve, reject) {
            var reader = new FileReader();
            reader.onload = function () {
                var img = new Image();
                img.onload = function () {
                    var w = img.naturalWidth, h = img.naturalHeight;
                    if (w <= MAX_DIM && h <= MAX_DIM) {
                        // Already small — pass through as-is.
                        resolve({ name: file.name, dataUrl: reader.result, size: file.size });
                        return;
                    }
                    var scale = Math.min(MAX_DIM / w, MAX_DIM / h);
                    var tw = Math.round(w * scale), th = Math.round(h * scale);
                    try {
                        var canvas = document.createElement('canvas');
                        canvas.width = tw;
                        canvas.height = th;
                        var ctx = canvas.getContext('2d');
                        ctx.drawImage(img, 0, 0, tw, th);
                        // PNG → keep as PNG to preserve transparency.
                        // Everything else (jpeg/webp) → JPEG-encode at
                        // 0.85 quality for the dramatic file-size win.
                        var isTransparent = /image\/(png|gif|webp)/i.test(file.type);
                        var outMime = isTransparent ? 'image/png' : 'image/jpeg';
                        var dataUrl = canvas.toDataURL(outMime, QUALITY);
                        // Estimate size from base64 length.
                        var approxSize = Math.round((dataUrl.length - dataUrl.indexOf(',') - 1) * 0.75);
                        // Rename to the right extension so WP accepts it.
                        var newName = file.name.replace(/\.[^.]+$/, '') + (isTransparent ? '.png' : '.jpg');
                        resolve({ name: newName, dataUrl: dataUrl, size: approxSize });
                    } catch (e) {
                        // Canvas failed (CORS? memory?) — fall back to original.
                        resolve({ name: file.name, dataUrl: reader.result, size: file.size });
                    }
                };
                img.onerror = function () {
                    resolve({ name: file.name, dataUrl: reader.result, size: file.size });
                };
                img.src = reader.result;
            };
            reader.onerror = function () { reject(new Error('Could not read that image file.')); };
            reader.readAsDataURL(file);
        });
    }

    /**
     * Strip leftover opacity:0.4 from any IMG inside the design root —
     * residue from a previous v0.23.4/5 image-replace that aborted before
     * restoring opacity. Visual-only cleanup; we don't markChanged so a
     * cold page-load doesn't trigger an unintended save. The next real
     * edit-save will write the cleaned HTML to the DB.
     */
    function cleanupStuckImageOpacity() {
        getDesignRoots().forEach(function (root) {
            root.querySelectorAll('img').forEach(function (img) {
                if (img.style.opacity === '0.4') img.style.opacity = '';
            });
        });
    }

    function hideToolbar() {
        if (toolbar) toolbar.style.display = 'none';
        toolbarTarget = null;
    }

    function positionToolbarOn(item) {
        ensureToolbar();
        toolbarTarget = item;
        var rect = item.getBoundingClientRect();
        // Toolbar lives in the block's TOP-RIGHT corner (v0.21.1 — was
        // top-left). Joost on 2026-05-16: "een klein kruisje wat hier
        // dan verschijnt", pointing top-right. Right-corner also keeps
        // the × out of the way of where the text usually starts (left).
        // Show a Replace-image button alongside the × when the block
        // is — or contains — an <img>.
        var hasImg = (item.tagName === 'IMG') || !!item.querySelector('img');
        var replaceBtn = toolbar.querySelector('.swcp-edit-tb-replace');
        if (replaceBtn) replaceBtn.style.display = hasImg ? 'inline-flex' : 'none';

        // v0.23.5: toolbar now has [Replace?] [↑] [↓] [⧉] [×] = 4 or 5
        // 32px buttons + 2px gaps + 6px padding total.
        var TB_W = hasImg ? 180 : 144;
        var TB_H = 32;
        var INSET = 8;
        var railZone = railLeftEdge();

        // Hide if the block is essentially off-screen or fully behind
        // the rail. We require at least 36px of visible height — below
        // that the toolbar dangles in empty space.
        var visTop    = Math.max(rect.top, 0);
        var visBottom = Math.min(rect.bottom, window.innerHeight);
        if (visBottom - visTop < 36) { hideToolbar(); return; }
        if (rect.right < 30 || rect.left > railZone - 8) { hideToolbar(); return; }

        // Anchor inside the block's top-right, with a small inset.
        var top  = rect.top  + INSET;
        var left = rect.right - TB_W - INSET;

        // Block top is above viewport — pin to visible top within the
        // block's horizontal span.
        if (top < INSET) top = INSET;
        // Clamp to viewport bottom too.
        if (top + TB_H > window.innerHeight - 4) top = window.innerHeight - TB_H - 4;
        // For narrow blocks the right-inset push may take us off-screen
        // to the left of the block — fall back to the block's left edge.
        if (left < rect.left + 4) left = rect.left + 4;
        // Never overlap the rail.
        if (left + TB_W > railZone - 6) left = Math.max(4, railZone - TB_W - 6);
        // Never drift off-screen.
        if (left < 4) left = 4;

        toolbar.style.left = left + 'px';
        toolbar.style.top  = top + 'px';
        toolbar.style.display = 'inline-flex';
    }

    function railLeftEdge() {
        // Approximate the left edge of the Co-pilot rail so the toolbar
        // never crashes into it. The rail's wrapper is .sw-rail-root.
        var rail = document.querySelector('.sw-rail-root:not(.is-collapsed)');
        if (rail) {
            var r = rail.getBoundingClientRect();
            if (r.width > 0) return r.left;
        }
        return window.innerWidth;
    }

    function friendlyLabel(el) {
        if (!el) return '';
        var text = (el.textContent || '').trim().replace(/\s+/g, ' ');
        if (text.length > 80) text = text.slice(0, 77) + '…';
        return text ? '"' + text + '"' : '';
    }

    // ------------------------------------------------------------------
    // Insert-zones: persistent thin overlay-strips between every pair of
    // top-level sections, plus one above the first section and one below
    // the last. Each is the click-target for the pattern gallery. v0.23.3
    // (2026-05-17): replaced the cursor-proximity rail because Joost
    // couldn't reach gaps that weren't directly adjacent to a hovered
    // section (e.g. drop a hero ABOVE the existing first section).
    // ------------------------------------------------------------------

    var insertZones = [];   // [{el, target, position}]
    var insertZoneRaf = 0;  // requestAnimationFrame handle for scroll/resize

    function refreshInsertZones() {
        if (!editEnabled || patternGalleryOpen) {
            insertZones.forEach(function (z) { if (z.el) z.el.style.display = 'none'; });
            return;
        }
        // Clear old zones (DOM + array). Cheap; we recompute often.
        insertZones.forEach(function (z) {
            if (z.el && z.el.parentNode) z.el.parentNode.removeChild(z.el);
        });
        insertZones = [];

        var roots = getDesignRoots();
        var railZone = railLeftEdge();
        roots.forEach(function (root) {
            var rootRect = root.getBoundingClientRect();
            // Top-of-page zone — insert BEFORE the first child.
            if (root.firstElementChild) {
                addInsertZone(root.firstElementChild, 'before', root.firstElementChild.getBoundingClientRect().top, railZone);
            }
            // Between-children zones — insert AFTER each child (except last
            // is handled separately as the "end" zone below).
            var children = Array.prototype.slice.call(root.children).filter(function (c) {
                if (isPartOfChrome(c)) return false;
                if (c.tagName === 'SCRIPT' || c.tagName === 'STYLE') return false;
                return true;
            });
            children.forEach(function (child, i) {
                if (i === children.length - 1) return; // last child = bottom zone below
                var rect = child.getBoundingClientRect();
                addInsertZone(child, 'after', rect.bottom, railZone);
            });
            // Bottom-of-page zone — insert AFTER the last child.
            if (children.length > 0) {
                var last = children[children.length - 1];
                addInsertZone(last, 'after', last.getBoundingClientRect().bottom, railZone);
            }
        });
    }

    function addInsertZone(targetEl, position, y, railZone) {
        var rect = targetEl.getBoundingClientRect();
        // Skip zones fully off-screen above or below the viewport so we
        // don't render hundreds when scrolled in a long document.
        if (y < -40 || y > window.innerHeight + 40) return;
        var left  = Math.max(0, rect.left);
        var width = Math.min(rect.right, railZone) - left;
        if (width < 40) return;
        var zone = document.createElement('div');
        zone.className = 'swcp-edit-insert-zone';
        zone.style.left  = left + 'px';
        zone.style.top   = (y - 10) + 'px'; // 20px-tall hit area, centred on the gap
        zone.style.width = width + 'px';
        zone.innerHTML = (
            '<div class="swcp-edit-insert-line"></div>' +
            '<button type="button" class="swcp-edit-insert-plus" title="Add a section here" aria-label="Add a section here">' +
                '<svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true">' +
                    '<path d="M6 1.5 V10.5 M1.5 6 H10.5" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>' +
                '</svg>' +
            '</button>'
        );
        document.body.appendChild(zone);
        var plus = zone.querySelector('.swcp-edit-insert-plus');
        plus.addEventListener('click', function (e) {
            e.preventDefault();
            e.stopPropagation();
            openPatternGallery(targetEl, position, e.clientX, e.clientY);
        });
        insertZones.push({ el: zone, target: targetEl, position: position });
    }

    function scheduleInsertZonesRefresh() {
        if (insertZoneRaf) return;
        insertZoneRaf = requestAnimationFrame(function () {
            insertZoneRaf = 0;
            refreshInsertZones();
        });
    }

    // ------------------------------------------------------------------
    // Pattern gallery — popup with categories + scaled-down previews.
    // Click a thumbnail → POST /patterns/insert, DOM-inject the result,
    // smooth-scroll to it, and play a coral halo so the user sees where
    // it landed. 3 starter patterns ship in v0.23.0; expand to 15 once
    // the surface feels right.
    // ------------------------------------------------------------------

    var patternGallery = null;
    var patternGalleryOpen = false;
    var patternsCache = null;
    var galleryAnchorTarget = null;
    var galleryAnchorPosition = null;

    function fetchPatterns() {
        if (patternsCache) return Promise.resolve(patternsCache);
        var cfg = window.SailWPCopilot || {};
        if (!cfg.restUrl) return Promise.resolve([]);
        return fetch(cfg.restUrl + 'patterns', {
            headers: { 'X-WP-Nonce': cfg.restNonce },
            credentials: 'same-origin'
        }).then(function (r) { return r.json(); }).then(function (data) {
            patternsCache = (data && data.patterns) || [];
            return patternsCache;
        }).catch(function () { return []; });
    }

    function ensurePatternGallery() {
        if (patternGallery) return patternGallery;
        patternGallery = document.createElement('div');
        patternGallery.className = 'swcp-edit-pattern-gallery';
        patternGallery.innerHTML = (
            '<div class="swcp-edit-pg-header">' +
                '<div class="swcp-edit-pg-title">Add a section</div>' +
                '<button type="button" class="swcp-edit-pg-close" aria-label="Close">×</button>' +
            '</div>' +
            '<div class="swcp-edit-pg-body">' +
                '<div class="swcp-edit-pg-loading">Loading patterns…</div>' +
            '</div>'
        );
        patternGallery.style.display = 'none';
        document.body.appendChild(patternGallery);
        patternGallery.querySelector('.swcp-edit-pg-close').addEventListener('click', function (e) {
            e.preventDefault(); e.stopPropagation();
            closePatternGallery();
        });
        // Click outside the gallery → close.
        document.addEventListener('mousedown', function (e) {
            if (!patternGalleryOpen) return;
            if (e.target.closest && e.target.closest('.swcp-edit-pattern-gallery')) return;
            if (e.target.closest && e.target.closest('.swcp-edit-insert-plus')) return;
            closePatternGallery();
        }, true);
        // Esc closes.
        document.addEventListener('keydown', function (e) {
            if (patternGalleryOpen && e.key === 'Escape') closePatternGallery();
        });
        return patternGallery;
    }

    function openPatternGallery(targetEl, position, x, y) {
        ensurePatternGallery();
        patternGalleryOpen = true;
        galleryAnchorTarget = targetEl;
        galleryAnchorPosition = position;
        // Position popup. Centred horizontally below the click; clamped
        // to viewport + the rail. Width is fixed at 560px for a tidy grid.
        var W = 560, H = 480;
        var railZone = railLeftEdge();
        var left = Math.max(16, Math.min(railZone - W - 16, x - W / 2));
        var top  = Math.max(16, Math.min(window.innerHeight - H - 16, y + 16));
        patternGallery.style.left  = left + 'px';
        patternGallery.style.top   = top + 'px';
        patternGallery.style.width = W + 'px';
        patternGallery.style.maxHeight = H + 'px';
        patternGallery.style.display = 'flex';
        // Hide insert-zones while the gallery is open (they refresh
        // automatically and return early when patternGalleryOpen=true).
        // The old hideInsertRail() helper was removed when we switched
        // to persistent zones in v0.23.3; calling it here threw a
        // ReferenceError that aborted openPatternGallery halfway, leaving
        // "Loading patterns…" forever. Joost 2026-05-17 v0.23.6.
        if (typeof scheduleInsertZonesRefresh === 'function') scheduleInsertZonesRefresh();

        var body = patternGallery.querySelector('.swcp-edit-pg-body');
        body.innerHTML = '<div class="swcp-edit-pg-loading">Loading patterns…</div>';
        fetchPatterns().then(function (patterns) {
            if (!patternGalleryOpen) return;
            renderPatternGallery(patterns, body);
        }).catch(function (err) {
            // Belt + braces: surface fetch errors instead of swallowing
            // them into an infinite loading state.
            if (!patternGalleryOpen) return;
            body.innerHTML = '<div class="swcp-edit-pg-empty">Could not load patterns: ' +
                escapeHtmlMini((err && err.message) || 'unknown error') +
                '. Refresh the page and try again.</div>';
        });
    }

    function closePatternGallery() {
        patternGalleryOpen = false;
        if (patternGallery) patternGallery.style.display = 'none';
        galleryAnchorTarget = null;
        galleryAnchorPosition = null;
    }

    function renderPatternGallery(patterns, body) {
        if (!patterns || !patterns.length) {
            body.innerHTML = '<div class="swcp-edit-pg-empty">No patterns yet — ask the Co-pilot to build one for you instead.</div>';
            return;
        }
        // Group by category, preserve definition order within.
        var groups = {};
        var order  = [];
        patterns.forEach(function (p) {
            if (!groups[p.category]) { groups[p.category] = []; order.push(p.category); }
            groups[p.category].push(p);
        });
        var html = '';
        order.forEach(function (cat) {
            html += '<div class="swcp-edit-pg-cat">' +
                '<div class="swcp-edit-pg-cat-label">' + escapeHtmlMini(cat) + '</div>' +
                '<div class="swcp-edit-pg-grid">';
            groups[cat].forEach(function (p) {
                // Card is a DIV (with role="button" + tabindex) rather than
                // a real <button>. Browsers move <section>/<h1>/<figure>
                // and other sectioning/flow content OUT of a <button> at
                // parse time (button only accepts phrasing content), so the
                // pattern's section wrapper would render OUTSIDE the card
                // and the preview would look empty. Joost 2026-05-17.
                html += (
                    '<div role="button" tabindex="0" class="swcp-edit-pg-card" data-pattern-id="' + escapeHtmlMini(p.id) + '" title="' + escapeHtmlMini(p.description || p.name) + '">' +
                        '<div class="swcp-edit-pg-card-preview"><div class="swcp-edit-pg-card-preview-inner">' + p.html + '</div></div>' +
                        '<div class="swcp-edit-pg-card-name">' + escapeHtmlMini(p.name) + '</div>' +
                    '</div>'
                );
            });
            html += '</div></div>';
        });
        body.innerHTML = html;
        // Wire card clicks.
        body.querySelectorAll('.swcp-edit-pg-card').forEach(function (card) {
            card.addEventListener('click', function (e) {
                e.preventDefault(); e.stopPropagation();
                insertPattern(card.getAttribute('data-pattern-id'));
            });
            // role="button" needs explicit keyboard activation.
            card.addEventListener('keydown', function (e) {
                if (e.key === 'Enter' || e.key === ' ') {
                    e.preventDefault();
                    insertPattern(card.getAttribute('data-pattern-id'));
                }
            });
        });
    }

    function escapeHtmlMini(s) {
        return String(s == null ? '' : s)
            .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
            .replace(/"/g, '&quot;').replace(/'/g, '&#39;');
    }

    function insertPattern(patternId) {
        if (!patternId || !galleryAnchorTarget) return;
        var cfg = window.SailWPCopilot || {};
        var postId = (config && config.postId) || 0;
        if (!postId) {
            window.alert('I lost track of which page this is. Refresh and try again.');
            return;
        }
        // Resolve the anchor's top-level block index by counting prior
        // siblings inside its design-root container.
        var root = galleryAnchorTarget.closest('.swcp-design-root');
        var anchorIndex = 0;
        if (root) {
            var siblings = Array.prototype.slice.call(root.children);
            anchorIndex = siblings.indexOf(galleryAnchorTarget);
            if (anchorIndex < 0) anchorIndex = siblings.length;
        }
        var position = galleryAnchorPosition || 'before';

        // Optimistic UX: show a "saving" overlay on the gallery.
        var body = patternGallery && patternGallery.querySelector('.swcp-edit-pg-body');
        if (body) body.innerHTML = '<div class="swcp-edit-pg-loading">Adding your section…</div>';

        fetch(cfg.restUrl + 'patterns/insert', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json', 'X-WP-Nonce': cfg.restNonce },
            credentials: 'same-origin',
            body: JSON.stringify({
                post_id: postId,
                pattern_id: patternId,
                position: position,
                anchor_block_index: anchorIndex
            })
        }).then(function (r) { return r.json(); }).then(function (data) {
            if (!data || !data.ok) {
                window.alert((data && data.message) || 'Could not add that section. Try again.');
                closePatternGallery();
                return;
            }
            domInsertPattern(data.html);
            closePatternGallery();
        }).catch(function () {
            window.alert('Could not reach the server. Refresh and try again.');
            closePatternGallery();
        });
    }

    /**
     * Inject the new pattern's HTML into the live page at the right
     * position, scroll to it, and flash a coral halo so the user sees
     * where it landed. The server already saved the change; DOM-inject
     * avoids a full page reload.
     */
    function domInsertPattern(html) {
        if (!html || !galleryAnchorTarget) return;
        var wrapper = document.createElement('div');
        wrapper.innerHTML = html;
        // The pattern is wrapped in a <section>; if it parses to a single
        // child use that, otherwise insert the wrapper itself.
        var node = (wrapper.children.length === 1) ? wrapper.firstElementChild : wrapper;
        if (galleryAnchorPosition === 'after') {
            galleryAnchorTarget.parentNode.insertBefore(node, galleryAnchorTarget.nextSibling);
        } else {
            galleryAnchorTarget.parentNode.insertBefore(node, galleryAnchorTarget);
        }
        // Smooth scroll into view + halo flash so the user notices.
        node.scrollIntoView({ behavior: 'smooth', block: 'center' });
        node.classList.add('swcp-edit-just-inserted');
        setTimeout(function () { node.classList.remove('swcp-edit-just-inserted'); }, 1500);
        // Reattach sortable so the new section is draggable + editable,
        // and refresh insert-zones so the gap above/below the new section
        // is immediately clickable.
        if (typeof attachSortables === 'function') attachSortables();
        scheduleInsertZonesRefresh();
    }

    function onMouseMove(e) {
        if (!editEnabled) return hideToolbar();
        if (document.body.classList.contains('swcp-edit-dragging-active')) return;
        // Suppress the section-toolbar while text-editing — buttons get
        // in the way of the cursor when typing.
        if (document.documentElement.classList.contains('swcp-edit-text-active')) {
            hideToolbar();
            return;
        }
        if (isPartOfChrome(e.target)) return; // keep toolbar pinned to last target
        // Find nearest sortable item under cursor.
        var item = findNearestSortableItem(e.target);
        if (item) {
            if (item !== toolbarTarget) positionToolbarOn(item);
        } else {
            hideToolbar();
        }
    }

    // Double-click → open swatch popup at cursor. Tries painted-ancestor
    // first (so swatch click hits the section that actually shows the
    // colour); falls back to the nearest section-like wrapper if no
    // paint found, so the gesture always gets a response.
    function onBgDoubleClick(e) {
        if (!editEnabled) return;
        if (isPartOfChrome(e.target)) return;
        // Image direct-replace: dblclick straight on an <img> triggers
        // the file-picker. Joost 2026-05-17: "ik wil gewoon deze foto
        // klikken en hem vervangen" — going via the toolbar Replace
        // button felt indirect. Bypasses the bg-popup path entirely.
        var img = e.target.closest && e.target.closest('img');
        if (img && !isPartOfChrome(img)) {
            e.preventDefault();
            e.stopPropagation();
            triggerImageReplace(img);
            return;
        }
        // Skip if a real text selection happened (dblclick = select word
        // inside text). Empty-area dblclicks have no selection.
        var sel = window.getSelection && window.getSelection();
        var hasSel = sel && sel.toString && sel.toString().trim().length > 0;
        var leaf = findTextLeaf(e.target);
        if (leaf && hasSel) return;
        var bgEl = findBgEditable(e.target);
        if (!bgEl) {
            var node = e.target;
            while (node && node !== document.documentElement) {
                if (isPartOfChrome(node)) { node = null; break; }
                var tag = node.tagName;
                if (tag === 'SECTION' || tag === 'ARTICLE' || tag === 'MAIN' || tag === 'HEADER' || tag === 'FOOTER' || tag === 'ASIDE' || tag === 'DIV') {
                    bgEl = node; break;
                }
                node = node.parentElement;
            }
        }
        if (!bgEl) return;
        e.preventDefault();
        e.stopPropagation();
        bgTarget = bgEl;
        openBgPopupAt(e.clientX, e.clientY);
    }

    function findNearestSortableItem(target) {
        while (target && target !== document.body) {
            if (target.classList && target.classList.contains('swcp-edit-sort-item')) return target;
            target = target.parentElement;
        }
        return null;
    }

    // ------------------------------------------------------------------
    // Background-colour editor — hover any element inside the design root
    // that paints its own bg, see a small 🎨 pill in the corner. Click
    // the pill to reveal 8 brand swatches + a "custom" colour picker.
    // Click a swatch to apply the new bg colour inline; inline-edit's
    // debounce save persists it like any other edit.
    // ------------------------------------------------------------------

    var bgTarget = null;
    var bgPopup = null;
    var bgPopupOpen = false;

    function hasOwnBackgroundLocal(el) {
        if (!el || el.nodeType !== 1) return false;
        var cs;
        try { cs = window.getComputedStyle(el); } catch (_) { return false; }
        if (!cs) return false;
        var bg = (cs.backgroundColor || '').trim();
        var img = (cs.backgroundImage || '').trim();
        var transparent = (
            !bg || bg === 'transparent' ||
            bg === 'rgba(0, 0, 0, 0)' || bg === 'rgba(0,0,0,0)'
        );
        return !transparent || (img && img !== 'none');
    }

    function isInsideDesignRoot(el) {
        return !!(el && el.closest && el.closest('.swcp-design-root'));
    }

    /**
     * Walk up from the (double-)clicked element to find the nearest
     * ancestor with its own painted background. Skip chrome (admin bar,
     * rail, our own popups) and text leaves. Joost 2026-05-17 v0.22.7:
     * the previous version required the bg-element to live inside
     * .swcp-design-root, but on Joost's test page the painted gradient
     * sits on a wrapper one level above design-root — so a click on the
     * empty bg never returned a match while a click on an IMG inside
     * design-root walked up and happened to find paint. Dropping the
     * design-root guard fixes the common case. The save side still scopes
     * to design-root: changes outside that boundary apply visually but
     * won't persist on refresh — acceptable v0 trade-off vs. doing
     * nothing.
     */
    function findBgEditable(target) {
        if (!target || target.nodeType !== 1) return null;
        if (isPartOfChrome(target)) return null;
        var node = target;
        var hops = 0;
        while (node && node !== document.documentElement && hops < 15) {
            if (isPartOfChrome(node)) return null;
            // Don't claim a pure text leaf — contenteditable owns those.
            var tag = node.tagName;
            var isLeaf = /^(H[1-6]|P|LI|BLOCKQUOTE|A|BUTTON|LABEL|SPAN|EM|STRONG|SMALL|FIGCAPTION)$/.test(tag);
            if (!isLeaf && hasOwnBackgroundLocal(node)) return node;
            node = node.parentElement;
            hops++;
        }
        return null;
    }

    function ensureBgPopup() {
        if (bgPopup) return bgPopup;
        var swatches = [
            { hex: '#e8845c', label: 'Coral' },
            { hex: '#1a1a2e', label: 'Dark navy' },
            { hex: '#e8b87a', label: 'Warm cream' },
            { hex: '#ffffff', label: 'White' },
            { hex: '#1e3a5f', label: 'Deep blue' },
            { hex: '#2d4a3e', label: 'Forest green' },
            { hex: '#f5ecde', label: 'Beige' },
            { hex: '#3a3a3a', label: 'Charcoal' }
        ];
        bgPopup = document.createElement('div');
        bgPopup.className = 'swcp-edit-bg-popup';
        var dots = swatches.map(function (s) {
            return '<button type="button" class="swcp-edit-bg-swatch" ' +
                'data-hex="' + s.hex + '" ' +
                'title="' + s.label + ' (' + s.hex + ')" ' +
                'aria-label="' + s.label + '" ' +
                'style="background:' + s.hex + ';"></button>';
        }).join('');
        bgPopup.innerHTML = (
            dots +
            '<button type="button" class="swcp-edit-bg-swatch swcp-edit-bg-swatch-custom" ' +
                'title="Pick a custom colour" aria-label="Pick a custom colour">+</button>'
        );
        bgPopup.style.display = 'none';
        document.body.appendChild(bgPopup);

        bgPopup.addEventListener('click', function (e) {
            var btn = e.target.closest && e.target.closest('button.swcp-edit-bg-swatch');
            if (!btn) return;
            e.preventDefault();
            e.stopPropagation();
            if (btn.classList.contains('swcp-edit-bg-swatch-custom')) {
                openCustomColourPicker();
                return;
            }
            applyBgColour(btn.getAttribute('data-hex'));
        });
        // Click outside the popup → close it.
        document.addEventListener('mousedown', function (e) {
            if (!bgPopupOpen) return;
            if (e.target.closest && (e.target.closest('.swcp-edit-bg-popup') || e.target.closest('.swcp-edit-bg-pill'))) return;
            closeBgPopup();
        }, true);
        return bgPopup;
    }

    /**
     * Open the swatch popup anchored to a viewport-coord point (the
     * cursor position from the dblclick). Sits just below-and-right of
     * the click, clamped to viewport edges + the rail's left edge.
     */
    function openBgPopupAt(x, y) {
        if (!bgTarget) return;
        ensureBgPopup();
        bgPopupOpen = true;
        var POPUP_W = 280, POPUP_H = 50;
        var rail = document.querySelector('.sw-rail-root:not(.is-collapsed)');
        var railLeft = (rail && rail.getBoundingClientRect().left) || window.innerWidth;
        var left = x + 8;
        var top  = y + 8;
        if (left + POPUP_W > railLeft - 6) left = Math.max(8, railLeft - POPUP_W - 6);
        if (left < 8) left = 8;
        if (top + POPUP_H > window.innerHeight - 4) top = Math.max(8, y - POPUP_H - 8);
        if (top < 8) top = 8;
        bgPopup.style.left = left + 'px';
        bgPopup.style.top  = top + 'px';
        bgPopup.style.display = 'flex';
    }

    function closeBgPopup() {
        bgPopupOpen = false;
        if (bgPopup) bgPopup.style.display = 'none';
        bgTarget = null;
    }

    function applyBgColour(hex) {
        if (!bgTarget || !hex) return;
        pushUndo();
        bgTarget.style.backgroundColor = hex;
        // If the original background was an image/gradient, the inline
        // color alone won't show through — also strip the image overlay.
        try {
            var cs = window.getComputedStyle(bgTarget);
            if (cs && cs.backgroundImage && cs.backgroundImage !== 'none') {
                bgTarget.style.backgroundImage = 'none';
            }
        } catch (_) {}
        markChanged();
        showStatus && showStatus('Saving…', 0);
        closeBgPopup();
    }

    function openCustomColourPicker() {
        if (!bgTarget) return;
        var input = document.createElement('input');
        input.type = 'color';
        try {
            var cs = window.getComputedStyle(bgTarget);
            var rgb = cs && cs.backgroundColor;
            // Seed with current bg if convertible. <input type="color">
            // wants #rrggbb so we have to parse rgb(r,g,b) values.
            var m = rgb && rgb.match(/^rgba?\((\d+),\s*(\d+),\s*(\d+)/);
            if (m) {
                var hex = '#' + [m[1], m[2], m[3]].map(function (n) {
                    var h = parseInt(n, 10).toString(16);
                    return h.length === 1 ? '0' + h : h;
                }).join('');
                input.value = hex;
            }
        } catch (_) {}
        input.style.position = 'fixed';
        input.style.left = '-9999px';
        document.body.appendChild(input);
        input.addEventListener('change', function () {
            var val = input.value;
            try { document.body.removeChild(input); } catch (_) {}
            if (val) applyBgColour(val);
        });
        input.click();
    }

    // ------------------------------------------------------------------
    // Text formatting popup — appears just above the selection when the
    // user highlights some text inside an active contenteditable. Bold,
    // italic, and link buttons; uses document.execCommand for the actual
    // mutation (deprecated but still works in every browser today and is
    // dramatically simpler than rebuilding the Selection API equivalent).
    // ------------------------------------------------------------------

    var formatPopup = null;

    function ensureFormatPopup() {
        if (formatPopup) return formatPopup;
        formatPopup = document.createElement('div');
        formatPopup.className = 'swcp-edit-format-popup';
        formatPopup.innerHTML = (
            '<button type="button" class="swcp-edit-fp-bold"   title="Bold (⌘B)"   aria-label="Bold"><b>B</b></button>' +
            '<button type="button" class="swcp-edit-fp-italic" title="Italic (⌘I)" aria-label="Italic"><span class="swcp-edit-fp-i-glyph">I</span></button>' +
            '<button type="button" class="swcp-edit-fp-link"   title="Add link"    aria-label="Add link">' +
                '<svg width="14" height="14" viewBox="0 0 14 14" fill="none">' +
                    '<path d="M5 9 L9 5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>' +
                    '<path d="M6 4 H4 A2.5 2.5 0 0 0 4 9 H5.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" fill="none"/>' +
                    '<path d="M8 5 H10 A2.5 2.5 0 0 1 10 10 H8.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" fill="none"/>' +
                '</svg>' +
            '</button>'
        );
        formatPopup.style.display = 'none';
        document.body.appendChild(formatPopup);

        // mousedown + preventDefault so the contenteditable doesn't lose
        // focus when the user clicks a format button — otherwise the
        // selection is gone before execCommand can act on it.
        formatPopup.querySelector('.swcp-edit-fp-bold').addEventListener('mousedown', function (e) {
            e.preventDefault();
            applyFormat('bold');
        });
        formatPopup.querySelector('.swcp-edit-fp-italic').addEventListener('mousedown', function (e) {
            e.preventDefault();
            applyFormat('italic');
        });
        formatPopup.querySelector('.swcp-edit-fp-link').addEventListener('mousedown', function (e) {
            e.preventDefault();
            var url = window.prompt('Link URL:', 'https://');
            if (!url || url === 'https://' || url === 'http://') return;
            // Normalise — accept "example.com", "/about", "#section", "mailto:…"
            if (!/^(https?:|mailto:|tel:|#|\/)/i.test(url)) url = 'https://' + url;
            applyFormat('createLink', url);
        });
        return formatPopup;
    }

    function applyFormat(command, value) {
        try {
            document.execCommand(command, false, value);
        } catch (_) { /* execCommand is best-effort */ }
        // Mark dirty so save kicks in after the edit. We don't pushUndo
        // here because the active contenteditable's blur handler will
        // do that with the full pre-edit snapshot when focus is lost.
        hasUnsavedChanges = true;
        scheduleSave();
        showStatus('Saving…', 0);
        // Re-position popup over the new selection range.
        positionFormatPopup();
    }

    function positionFormatPopup() {
        var sel = window.getSelection();
        if (!sel || sel.isCollapsed || sel.rangeCount === 0) {
            hideFormatPopup();
            return;
        }
        var anchor = sel.anchorNode && (sel.anchorNode.nodeType === 1 ? sel.anchorNode : sel.anchorNode.parentElement);
        if (!anchor || !anchor.closest || !anchor.closest('[contenteditable="true"]')) {
            hideFormatPopup();
            return;
        }
        var range = sel.getRangeAt(0);
        var rect  = range.getBoundingClientRect();
        if (!rect || (rect.width === 0 && rect.height === 0)) {
            hideFormatPopup();
            return;
        }
        ensureFormatPopup();
        var POPUP_W = 112;
        var POPUP_H = 34;
        var left = rect.left + rect.width / 2 - POPUP_W / 2;
        var top  = rect.top - POPUP_H - 8;
        if (top < 8) top = rect.bottom + 8; // not enough room above → below
        if (left < 4) left = 4;
        if (left + POPUP_W > window.innerWidth - 4) left = window.innerWidth - POPUP_W - 4;
        formatPopup.style.left = left + 'px';
        formatPopup.style.top  = top + 'px';
        formatPopup.style.display = 'inline-flex';
    }

    function hideFormatPopup() {
        if (formatPopup) formatPopup.style.display = 'none';
    }

    function onSelectionChange() {
        if (!editEnabled) return hideFormatPopup();
        // Defer one tick so the selection is finalised before we measure
        // — Chrome occasionally fires selectionchange mid-range-update.
        setTimeout(positionFormatPopup, 0);
    }

    // ------------------------------------------------------------------
    // Text edit (unchanged — click any text leaf to edit in place)
    // ------------------------------------------------------------------

    function onKeydown(e) {
        // Cmd+Z / Ctrl+Z = undo last change. We intentionally don't
        // intercept the keystroke while the user is typing inside a
        // contenteditable (the browser's own per-character undo is
        // more useful at that level), only when editing is "ambient".
        if (!editEnabled) return;
        var isUndo = (e.key === 'z' || e.key === 'Z') && (e.metaKey || e.ctrlKey) && !e.shiftKey;
        if (!isUndo) return;
        var active = document.activeElement;
        if (active && active.isContentEditable) return;
        if (active && (active.tagName === 'INPUT' || active.tagName === 'TEXTAREA')) return;
        e.preventDefault();
        performUndo();
    }

    function onClick(e) {
        if (!editEnabled) return;
        if (isPartOfChrome(e.target)) return;
        var leaf = findTextLeaf(e.target);
        if (!leaf || leaf.isContentEditable) return;
        if (leaf.tagName === 'A') {
            var href = leaf.getAttribute('href') || '';
            if (href && href !== '#' && href.charAt(0) !== '#') return;
        }
        e.preventDefault();
        e.stopPropagation();
        startTextEdit(leaf, e);
    }

    function startTextEdit(el, evt) {
        el.contentEditable = 'true';
        el.classList.add('swcp-edit-active-text');
        el.spellcheck = true;
        // Hide the section-toolbar while text-editing — Joost 2026-05-17
        // v0.24.0: "het zit een beetje in de weg ik wil gewoon eventjes
        // dit kunnen aanpassen". The format-popup (bold/italic/link)
        // handles editing actions; the section-level × / ↑ / ↓ / ⧉ /
        // Replace are not relevant while typing text.
        hideToolbar();
        document.documentElement.classList.add('swcp-edit-text-active');
        el.focus();
        try {
            var sel = window.getSelection();
            var range = null;
            if (document.caretPositionFromPoint) {
                var cp = document.caretPositionFromPoint(evt.clientX, evt.clientY);
                if (cp) {
                    range = document.createRange();
                    range.setStart(cp.offsetNode, cp.offset);
                    range.collapse(true);
                }
            } else if (document.caretRangeFromPoint) {
                range = document.caretRangeFromPoint(evt.clientX, evt.clientY);
            }
            if (range) {
                sel.removeAllRanges();
                sel.addRange(range);
            }
        } catch (_) {}

        var savedHTML = el.innerHTML;

        function onBlur() {
            // Hold off a tick — clicking a button in the format popup
            // briefly steals focus and would otherwise close the edit
            // before bold/italic/link can apply.
            setTimeout(function () {
                if (document.activeElement === el) return; // re-focused, keep editing
                el.contentEditable = 'false';
                el.classList.remove('swcp-edit-active-text');
                document.documentElement.classList.remove('swcp-edit-text-active');
                el.removeEventListener('blur', onBlur);
                el.removeEventListener('keydown', onKey);
                hideFormatPopup();
                if (el.innerHTML !== savedHTML) {
                    // Restore saved HTML temporarily, snapshot the pre-edit
                    // state for undo, then re-apply the new value. Keeps
                    // the undo stack semantically correct ("undo returns
                    // the page to BEFORE this edit").
                    var newHTML = el.innerHTML;
                    el.innerHTML = savedHTML;
                    pushUndo();
                    el.innerHTML = newHTML;
                    markChanged();
                }
            }, 60);
        }
        function onKey(ke) {
            if (ke.key === 'Escape') {
                ke.preventDefault();
                el.innerHTML = savedHTML;
                el.blur();
            } else if (ke.key === 'Enter' && !ke.shiftKey) {
                ke.preventDefault();
                el.blur();
            }
        }
        el.addEventListener('blur', onBlur);
        el.addEventListener('keydown', onKey);
    }

    // ------------------------------------------------------------------
    // Save loop
    // ------------------------------------------------------------------

    function markChanged() {
        hasUnsavedChanges = true;
        scheduleSave();
        showStatus('Saving…', 0);
    }

    function scheduleSave() {
        if (saveTimer) clearTimeout(saveTimer);
        saveTimer = setTimeout(saveNow, DEBOUNCE_MS);
    }

    function saveNow() {
        if (savingNow) return;
        if (!hasUnsavedChanges) return;
        savingNow = true;
        var html = serializeDesign();
        fetch(config.restUrl + 'update-design', {
            method: 'POST',
            headers: {
                'Content-Type': 'application/json',
                'X-WP-Nonce': config.restNonce
            },
            body: JSON.stringify({ post_id: config.postId, html: html })
        }).then(function (r) {
            return r.text().then(function (t) {
                var d = null; try { d = JSON.parse(t); } catch (_) {}
                return { ok: r.ok, status: r.status, data: d || {} };
            });
        }).then(function (wrapped) {
            savingNow = false;
            if (wrapped.ok && wrapped.data && wrapped.data.ok) {
                hasUnsavedChanges = false;
                showStatus('Saved ✓', 1500);
                // Tell the iframe-bridge a save landed — it forwards to
                // the dashboard parent so the activity feed can refresh.
                try {
                    window.dispatchEvent(new CustomEvent('swcp:design-saved', {
                        detail: { postId: config.postId }
                    }));
                } catch (_) {}
            } else {
                showStatus('Save failed · will retry', 3000);
                scheduleSave();
            }
        }).catch(function () {
            savingNow = false;
            showStatus('Offline · will retry when reconnected', 3000);
            scheduleSave();
        });
    }

    function serializeDesign() {
        // Strip the inline-edit-only classes so the saved HTML matches
        // what visitors would receive — Sortable also adds transient
        // classes during a drag, but those are cleaned up onEnd.
        return getDesignRoots().map(function (root) {
            var clone = root.cloneNode(true);
            stripEditClasses(clone);
            return clone.outerHTML;
        }).join('\n');
    }

    function stripEditClasses(root) {
        var toStrip = [
            'swcp-edit-sort-container',
            'swcp-edit-sort-item',
            'swcp-edit-hover-text',
            'swcp-edit-active-text',
            'swcp-edit-just-dropped',
            'swcp-edit-chosen',
            'swcp-edit-ghost',
            'swcp-edit-dragging'
        ];
        root.querySelectorAll('.' + toStrip.join(', .')).forEach(function (el) {
            toStrip.forEach(function (c) { el.classList.remove(c); });
            if (!el.classList.length) el.removeAttribute('class');
        });
        toStrip.forEach(function (c) { root.classList && root.classList.remove(c); });
        // Also clear stale contenteditable attributes the previous build
        // accidentally persisted into the saved HTML.
        root.querySelectorAll('[contenteditable]').forEach(function (el) {
            el.removeAttribute('contenteditable');
            el.removeAttribute('spellcheck');
        });
    }

    // ------------------------------------------------------------------
    // Status chip
    // ------------------------------------------------------------------

    var statusEl = null;
    var statusTimer = null;
    var DEFAULT_STATUS = 'Click text to edit · drag any block · double-click a background for colour';

    function injectStatusChip() {
        statusEl = document.createElement('div');
        statusEl.className = 'swcp-edit-status';
        statusEl.innerHTML = (
            '<span class="swcp-edit-status-text">' + DEFAULT_STATUS + '</span>' +
            '<button type="button" class="swcp-edit-status-undo is-disabled" title="Undo (⌘Z)" aria-label="Undo last change" disabled>' +
                '<svg width="13" height="13" viewBox="0 0 13 13" fill="none">' +
                    '<path d="M3 6 L6 3 L6 5 C9 5 11 7 11 10 L9.5 10 C9.5 8 8 6.5 6 6.5 L6 8.5 Z" fill="currentColor"/>' +
                '</svg>' +
            '</button>' +
            '<button type="button" class="swcp-edit-status-toggle" aria-label="Pause editing">Pause</button>'
        );
        document.body.appendChild(statusEl);
        statusEl.querySelector('.swcp-edit-status-toggle').addEventListener('click', toggleEditing);
        undoBtnEl = statusEl.querySelector('.swcp-edit-status-undo');
        undoBtnEl.addEventListener('click', function (e) {
            e.preventDefault();
            performUndo();
        });
    }

    function toggleEditing() {
        editEnabled = !editEnabled;
        if (!editEnabled) {
            hideToolbar();
            destroySortables();
            document.body.classList.remove('swcp-edit-dragging-active');
        } else {
            attachSortables();
        }
        document.body.classList.toggle('swcp-edit-disabled', !editEnabled);
        var btn = statusEl.querySelector('.swcp-edit-status-toggle');
        btn.textContent = editEnabled ? 'Pause' : 'Resume';
        btn.setAttribute('aria-label', editEnabled ? 'Pause editing' : 'Resume editing');
        var textEl = statusEl.querySelector('.swcp-edit-status-text');
        textEl.textContent = editEnabled ? DEFAULT_STATUS : 'Editing paused';
    }

    function showStatus(text, autoFadeMs) {
        if (!statusEl) return;
        var textEl = statusEl.querySelector('.swcp-edit-status-text');
        if (!textEl) return;
        textEl.textContent = text;
        statusEl.classList.add('swcp-edit-status--active');
        if (statusTimer) clearTimeout(statusTimer);
        if (autoFadeMs) {
            statusTimer = setTimeout(function () {
                if (!hasUnsavedChanges && !savingNow) {
                    textEl.textContent = editEnabled ? DEFAULT_STATUS : 'Editing paused';
                    statusEl.classList.remove('swcp-edit-status--active');
                }
            }, autoFadeMs);
        }
    }

    // ------------------------------------------------------------------
    // Boot
    // ------------------------------------------------------------------

    function init() {
        // No design content on this page? Don't activate at all — the
        // "Click text to edit" chip is meaningless when there's nothing
        // to edit, and Joost flagged it as confusing on the empty
        // post-reset state (May 13 22:32 screenshot, "hier moet ik dus
        // niet zitten?"). We re-check on a short interval in case the
        // design is being generated right now and gets injected later
        // — once it lands, the chip + sortables come online.
        if (!getDesignRoots().length) {
            setTimeout(init, 2500);
            return;
        }
        document.body.classList.add('swcp-edit-enabled');
        // Strip stale intrinsic width/height attributes from images
        // inside design roots. A pre-v0.20.46 Replace would stamp the
        // attachment's natural dimensions onto the <img>, which then
        // overrode the design's CSS (aspect-ratio + object-fit) and
        // rendered a portrait photo as a stretched banana inside a
        // square thumb. Clean those up on every editor init so
        // historical content fixes itself without the user having to
        // re-Replace each image.
        getDesignRoots().forEach(function (root) {
            root.querySelectorAll('img[width], img[height]').forEach(function (img) {
                img.removeAttribute('width');
                img.removeAttribute('height');
            });
        });
        injectStatusChip();
        ensureToolbar();
        cleanupStuckImageOpacity();
        attachSortables();
        refreshInsertZones();
        document.addEventListener('mousemove', onMouseMove, true);
        document.addEventListener('click', onClick, true);
        document.addEventListener('dblclick', onBgDoubleClick, true);
        document.addEventListener('keydown', onKeydown, true);
        document.addEventListener('selectionchange', onSelectionChange);
        window.addEventListener('scroll', function () {
            if (toolbarTarget) positionToolbarOn(toolbarTarget);
            scheduleInsertZonesRefresh();
        }, true);
        window.addEventListener('resize', scheduleInsertZonesRefresh);
        window.addEventListener('beforeunload', function (e) {
            if (hasUnsavedChanges || savingNow) {
                try {
                    var blob = new Blob([
                        JSON.stringify({
                            post_id: config.postId,
                            html: serializeDesign(),
                            _wpnonce: config.restNonce
                        })
                    ], { type: 'application/json' });
                    navigator.sendBeacon && navigator.sendBeacon(
                        config.restUrl + 'update-design',
                        blob
                    );
                } catch (_) {}
                e.preventDefault();
                e.returnValue = '';
            }
        });
    }

    // (v0.20.51 — image Replace, shape Colour, and background palette
    // were all removed here. Joost's 2026-05-15 14:30 direction:
    // inline editor stays minimal — text edit + bold/italic/link
    // via the format popup, delete via the toolbar ×, drag to
    // reorder. Anything bigger goes through the AI Co-pilot chat.)

    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', init);
    } else {
        init();
    }
})();
