// preview-markdown.js — markdown plugin for the browse preview pane.
//
// Layout (CSS Grid):
//   ┌─────────────────────────────────────────────────────────────────┐
//   │  info: name | dirty | status | source | DOCX HTML PDF | Save    │
//   ├────────────────────────┬────────────────────────────────────────┤
//   │  YAML front matter     │                                        │
//   │  ┌──────────────────┐  │                                        │
//   │  │ title: Foo       │  │   Toast UI Editor                      │
//   │  │ revision: A      │  │   (md / wysiwyg / preview)             │
//   │  └──────────────────┘  │                                        │
//   ├────────────────────────┤                                        │
//   │  Outline               │                                        │
//   │  • Heading 1           │                                        │
//   │    • Subheading        │                                        │
//   │  • Heading 2           │                                        │
//   └────────────────────────┴────────────────────────────────────────┘
// Grid keeps every cell's size definite, which is what Toast UI needs
// to compute its inner scroll regions correctly. The previous nested-
// flexbox layout produced indeterminate heights and a fragile TOC
// pane width — grid fixes both.
//
// Front matter is edited in a dedicated <textarea> in the sidebar
// (always present — typing into the placeholder grows the envelope on
// save). On load the `---\n…\n---\n` envelope is stripped from the
// bytes fed to Toast UI; on save the textarea content is re-stitched
// on top of the editor body. Keeps YAML out of the rich editor where
// users can't reliably edit it.
//
// Save (Ctrl+S) writes back via PUT (server mode) or
// FileSystemWritableFileStream (FS-API). Zip-virtual files are
// read-only — Save stays disabled. Toast UI is vendored
// (shared/vendor/toastui-editor-all.min.js); window.toastui is
// available synchronously before this module runs.
(function () {
    'use strict';

    if (!window.app || !window.app.modules) return;

    var SIDEBAR_MIN_WIDTH = 180;
    var SIDEBAR_MAX_WIDTH = 480;
    var SIDEBAR_DEFAULT_WIDTH = 280;
    var FM_DEFAULT_HEIGHT = 180; // px — front-matter pane height inside sidebar

    function escapeHtml(s) {
        return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;')
            .replace(/>/g, '&gt;').replace(/"/g, '&quot;');
    }

    var currentInstance = null; // { editor, container, dirty, node, hash, tocEl, fmEl }
    var lastSidebarWidth = SIDEBAR_DEFAULT_WIDTH;  // remember across mounts
    var lastFmHeight = FM_DEFAULT_HEIGHT;

    async function hashContent(text) {
        if (!window.crypto || !window.crypto.subtle) return null;
        var enc = new TextEncoder().encode(text);
        var buf = await window.crypto.subtle.digest('SHA-256', enc);
        var bytes = new Uint8Array(buf);
        var hex = '';
        for (var i = 0; i < bytes.length; i++) {
            hex += bytes[i].toString(16).padStart(2, '0');
        }
        return hex;
    }

    function dispose() {
        if (currentInstance && currentInstance.editor) {
            try { currentInstance.editor.destroy(); } catch (_) { /* ignore */ }
        }
        currentInstance = null;
    }

    // ── Front matter ────────────────────────────────────────────────────────
    // Lightweight YAML front-matter parser. Same envelope as mdedit's:
    // `---\n…\n---\n`, key:value lines, simple `[a, b, c]` arrays.

    function parseFrontMatter(content) {
        if (!content || content.indexOf('---\n') !== 0) {
            return { data: {}, body: content || '' };
        }
        var endIdx = content.indexOf('\n---\n', 4);
        if (endIdx === -1) return { data: {}, body: content };
        var fmText = content.substring(4, endIdx);
        var body = content.substring(endIdx + 5);
        var data = {};
        var lines = fmText.split('\n');
        for (var i = 0; i < lines.length; i++) {
            var line = lines[i].trim();
            if (!line || line.charAt(0) === '#') continue;
            var colon = line.indexOf(':');
            if (colon <= 0) continue;
            var key = line.substring(0, colon).trim();
            var val = line.substring(colon + 1).trim();
            val = val.replace(/^["']|["']$/g, '');
            if (val.startsWith('[') && val.endsWith(']')) {
                val = val.slice(1, -1).split(',').map(function (s) {
                    return s.trim().replace(/^["']|["']$/g, '');
                });
            }
            data[key] = val;
        }
        return { data: data, body: body };
    }

    // Inverse of parseFrontMatter — turn a {key: value | array} object back
    // into newline-separated YAML lines suitable for the textarea. Arrays
    // are quoted to match what the parser will round-trip through. Returns
    // "" when there are no keys (so the textarea shows its placeholder).
    function stringifyFrontMatter(data) {
        if (!data) return '';
        var keys = Object.keys(data);
        if (keys.length === 0) return '';
        var out = [];
        for (var i = 0; i < keys.length; i++) {
            var k = keys[i];
            var v = data[k];
            if (Array.isArray(v)) {
                out.push(k + ': [' + v.map(function (x) {
                    return '"' + String(x).replace(/"/g, '\\"') + '"';
                }).join(', ') + ']');
            } else {
                out.push(k + ': ' + String(v));
            }
        }
        return out.join('\n');
    }

    // Stitch the textarea's YAML lines and the editor's body back together
    // into the on-disk envelope. Empty textarea → return body unchanged
    // (no envelope written). Trailing whitespace in the textarea is
    // tolerated.
    function assembleContent(fmText, body) {
        var fm = (fmText || '').replace(/\s+$/, '');
        if (!fm) return body || '';
        return '---\n' + fm + '\n---\n' + (body || '');
    }

    // ── TOC (table of contents) ────────────────────────────────────────────
    // ATX headings only; the body markdown drives the outline. Clicking
    // a heading routes to whichever Toast UI pane is currently active
    // (WYSIWYG or markdown preview).

    function parseHeadings(content) {
        var headings = [];
        // Strip front matter so headings inside the envelope (e.g. comments)
        // don't appear in the outline.
        var parsed = parseFrontMatter(content);
        var body = parsed.body;
        var lines = body.split('\n');
        var inFence = false;
        for (var i = 0; i < lines.length; i++) {
            var line = lines[i];
            // Skip fenced code blocks — headings inside them aren't real.
            if (/^\s*```/.test(line)) { inFence = !inFence; continue; }
            if (inFence) continue;
            var m = line.match(/^(#{1,6})\s+(.+?)\s*#*\s*$/);
            if (!m) continue;
            var text = m[2]
                .replace(/\\(.)/g, '$1')
                .replace(/\*\*(.*?)\*\*/g, '$1')
                .replace(/\*(.*?)\*/g, '$1')
                .replace(/`(.*?)`/g, '$1')
                .replace(/\[(.*?)\]\(.*?\)/g, '$1')
                .replace(/~~(.*?)~~/g, '$1')
                .trim();
            headings.push({ level: m[1].length, text: text, lineIndex: i });
        }
        return headings;
    }

    function scrollEditorToHeading(editor, heading) {
        try {
            var els = editor.getEditorElements();
            if (editor.isWysiwygMode && editor.isWysiwygMode()) {
                var ww = els.wwEditor;
                if (!ww) return;
                var hs = ww.querySelectorAll('h1, h2, h3, h4, h5, h6');
                for (var i = 0; i < hs.length; i++) {
                    if (hs[i].textContent.trim() === heading.text) {
                        var scroller = findScrollParent(hs[i]) || ww;
                        scroller.scrollTo({
                            top: hs[i].offsetTop - 12,
                            behavior: 'smooth'
                        });
                        flashHeading(hs[i]);
                        return;
                    }
                }
            } else {
                var line = heading.lineIndex + 1;
                try { editor.setSelection([line, 1], [line, 1]); } catch (_) { /* ignore */ }
                // Find the matching heading in the live markdown preview
                // (right column of split view). If preview is collapsed
                // (markdown-only) this is a no-op.
                var preview = els.mdPreview;
                if (preview) {
                    var phs = preview.querySelectorAll('h1, h2, h3, h4, h5, h6');
                    for (var j = 0; j < phs.length; j++) {
                        if (phs[j].textContent.trim() === heading.text) {
                            var pscroller = findScrollParent(phs[j]) || preview;
                            pscroller.scrollTo({
                                top: phs[j].offsetTop - 12,
                                behavior: 'smooth'
                            });
                            flashHeading(phs[j]);
                            return;
                        }
                    }
                }
            }
        } catch (_e) { /* swallow; click was best-effort */ }
    }

    function findScrollParent(el) {
        var cur = el.parentElement;
        while (cur) {
            var s = getComputedStyle(cur);
            if (/(auto|scroll)/.test(s.overflowY)) return cur;
            cur = cur.parentElement;
        }
        return null;
    }

    function flashHeading(el) {
        if (!el) return;
        el.classList.add('md-toc__flash');
        setTimeout(function () { el.classList.remove('md-toc__flash'); }, 900);
    }

    function renderToc(tocEl, content, editor) {
        if (!tocEl) return;
        if (!content || !content.trim()) {
            tocEl.innerHTML = '<p class="md-toc__empty">Empty file.</p>';
            return;
        }
        var headings = parseHeadings(content);
        if (headings.length === 0) {
            tocEl.innerHTML = '<p class="md-toc__empty">No headings yet.</p>';
            return;
        }
        // Build a flat list; CSS handles indentation. Using a flat list
        // (rather than nested <ul>s) keeps the click target a clean,
        // full-width row regardless of heading depth.
        var html = '<ul class="md-toc__list">';
        for (var i = 0; i < headings.length; i++) {
            var h = headings[i];
            html += '<li class="md-toc__item md-toc__item--l' + h.level + '"'
                + ' data-line="' + h.lineIndex + '"'
                + ' data-text="' + escapeHtml(h.text) + '"'
                + ' title="' + escapeHtml(h.text) + '">'
                + escapeHtml(h.text)
                + '</li>';
        }
        html += '</ul>';
        tocEl.innerHTML = html;
        tocEl.querySelectorAll('.md-toc__item').forEach(function (li) {
            li.addEventListener('click', function () {
                var idx = parseInt(li.dataset.line, 10);
                var text = li.dataset.text;
                scrollEditorToHeading(editor, { text: text, lineIndex: idx });
            });
        });
    }

    function debounce(fn, ms) {
        var t;
        return function () {
            clearTimeout(t);
            var args = arguments, self = this;
            t = setTimeout(function () { fn.apply(self, args); }, ms);
        };
    }

    // ── Save ────────────────────────────────────────────────────────────────

    async function saveContent(node, content) {
        if (node.handle && typeof node.handle.createWritable === 'function') {
            var writable = await node.handle.createWritable();
            await writable.write(content);
            await writable.close();
            return;
        }
        if (node.url && window.app.state.source === 'server') {
            var resp = await fetch(node.url, {
                method: 'PUT',
                headers: { 'Content-Type': 'text/markdown; charset=utf-8' },
                body: content,
                credentials: 'same-origin'
            });
            if (!resp.ok) throw new Error('HTTP ' + resp.status);
            return;
        }
        throw new Error('No write target for this file (read-only source).');
    }

    // A markdown file living inside a .zip is read-only: a ZipFileHandle
    // refuses createWritable (offline / nested), and zddc-server refuses
    // writes to a "<…>.zip/<member>" URL (405).
    function isZipMemberNode(node) {
        if (node.handle && node.handle.isZipEntry) return true;
        if (node.url && window.app.state.source === 'server' && /\.zip\//i.test(node.url)) return true;
        return false;
    }

    function canSave(node) {
        if (isZipMemberNode(node)) return false;
        // Server-computed authority gate. The listing's `writable`
        // bit reflects what a PUT would do — false here means the
        // file API would 403 the save, so we mount in read-only
        // mode rather than letting the user type and lose changes.
        if (node.url && window.app.state.source === 'server' && !node.writable) return false;
        if (node.handle && typeof node.handle.createWritable === 'function') return true;
        if (node.url && window.app.state.source === 'server') return true;
        return false;
    }

    // ── Mount ───────────────────────────────────────────────────────────────

    async function render(node, container, ctx) {
        if (typeof window.toastui === 'undefined') {
            container.innerHTML =
                '<div class="preview-empty" style="color:var(--danger)">'
                + 'Toast UI Editor isn\'t bundled in this build.</div>';
            return;
        }
        dispose();

        // Read content.
        var text;
        try {
            var buf = await ctx.getArrayBuffer(node);
            text = new TextDecoder('utf-8', { fatal: false }).decode(buf);
        } catch (e) {
            container.innerHTML =
                '<div class="preview-empty" style="color:var(--danger)">'
                + 'Could not read ' + escapeHtml(node.name) + ': '
                + escapeHtml(e.message || String(e)) + '</div>';
            return;
        }

        // Wipe the container and install a single shell child. The
        // shell mirrors mdedit's layout: sidebar on the LEFT (front
        // matter top, TOC bottom), content on the RIGHT (informational
        // header above the Toast UI editor). CSS Grid keeps every
        // cell sized definitely so Toast UI's scroll regions resolve
        // correctly.
        container.innerHTML = '';
        var shell = document.createElement('div');
        shell.className = 'md-shell';
        shell.style.gridTemplateColumns = lastSidebarWidth + 'px 1fr';
        container.appendChild(shell);

        // ── Sidebar (col 1): front matter (top) + TOC (bottom) ──────────────
        // Sidebar is a flex column: FM section (fixed height, set
        // inline below) + horizontal resizer + TOC section (1fr).
        var sidebar = document.createElement('div');
        sidebar.className = 'md-shell__sidebar';
        shell.appendChild(sidebar);

        var fmSection = document.createElement('section');
        fmSection.className = 'md-side md-side--fm';
        // Front-matter height is driven inline (persisted across
        // remounts via lastFmHeight) so the resizer's drag-handler
        // mutates a single source of truth.
        fmSection.style.height = lastFmHeight + 'px';
        var fmHeader = document.createElement('div');
        fmHeader.className = 'md-side__header';
        fmHeader.textContent = 'YAML front matter';
        var fmBody = document.createElement('div');
        fmBody.className = 'md-side__body md-fm__body';
        var fmTextarea = document.createElement('textarea');
        fmTextarea.className = 'md-fm__textarea';
        fmTextarea.spellcheck = false;
        fmTextarea.autocapitalize = 'off';
        fmTextarea.autocomplete = 'off';
        // No placeholder text — files with no YAML front matter render
        // as a genuinely empty pane. Showing a synthetic example would
        // make the file look like it had data when it doesn't.
        fmTextarea.placeholder = '';
        fmBody.appendChild(fmTextarea);
        fmSection.appendChild(fmHeader);
        fmSection.appendChild(fmBody);
        sidebar.appendChild(fmSection);

        // Horizontal resizer between front-matter and TOC.
        var fmResizer = document.createElement('div');
        fmResizer.className = 'md-shell__fmresizer';
        fmResizer.setAttribute('role', 'separator');
        fmResizer.setAttribute('aria-orientation', 'horizontal');
        fmResizer.setAttribute('aria-label', 'Resize front-matter pane');
        fmResizer.tabIndex = 0;
        sidebar.appendChild(fmResizer);

        var tocSection = document.createElement('section');
        tocSection.className = 'md-side md-side--toc';
        var tocHeader = document.createElement('div');
        tocHeader.className = 'md-side__header';
        tocHeader.textContent = 'Outline';
        var tocBody = document.createElement('div');
        tocBody.className = 'md-side__body md-toc__body';
        tocSection.appendChild(tocHeader);
        tocSection.appendChild(tocBody);
        sidebar.appendChild(tocSection);

        // Vertical resizer between sidebar and content.
        var resizer = document.createElement('div');
        resizer.className = 'md-shell__resizer';
        resizer.setAttribute('role', 'separator');
        resizer.setAttribute('aria-orientation', 'vertical');
        resizer.setAttribute('aria-label', 'Resize sidebar');
        resizer.tabIndex = 0;
        shell.appendChild(resizer);

        // ── Content (col 2): informational header + editor ──────────────────
        var content = document.createElement('div');
        content.className = 'md-shell__content';
        shell.appendChild(content);

        // Informational header above the editor: file name + save +
        // dirty indicator + status + source hint. Renamed from
        // "toolbar" to read as a header, since it titles the content.
        var infohdr = document.createElement('div');
        infohdr.className = 'md-shell__infohdr';

        var titleEl = document.createElement('span');
        titleEl.className = 'md-shell__title';
        titleEl.textContent = node.name;
        titleEl.title = node.name;

        var saveBtn = document.createElement('button');
        saveBtn.className = 'btn btn-sm btn-primary md-shell__save';
        saveBtn.type = 'button';
        saveBtn.textContent = 'Save';
        saveBtn.disabled = true;

        var dirtyEl = document.createElement('span');
        dirtyEl.className = 'md-shell__dirty';

        var statusEl = document.createElement('span');
        statusEl.className = 'md-shell__status';

        var sourceEl = document.createElement('span');
        sourceEl.className = 'md-shell__source';
        if (isZipMemberNode(node)) {
            sourceEl.textContent = 'read-only (zip)';
        } else if (node.handle) {
            sourceEl.textContent = 'local';
        } else if (node.url) {
            sourceEl.textContent = 'server';
        }

        // Download-as-{docx,html,pdf} affordances. Server-mode + .md
        // only: the server endpoint runs pandoc/chromium in a
        // container and returns the converted bytes.
        //
        // These are real <a> elements with href + download attributes,
        // styled like buttons. That means right-click → "Copy link
        // address" / "Open in new tab" / "Save link as" all work
        // natively — users can share the conversion URL or download
        // through their preferred path. Click is intercepted only
        // when the buffer is dirty (auto-save first, then re-fire
        // the click so the browser fetches the saved bytes).
        var serverModeMd = window.app && window.app.state &&
            window.app.state.source === 'server' &&
            node.url && /\.md$/i.test(node.name);
        var convertBtns = [];
        if (serverModeMd) {
            // Virtual-extension URLs: <file>.md → <file>.docx etc.
            // The dispatcher recognises the sibling-extension pattern
            // and routes through ServeConverted. Cleaner than the
            // old `?convert=` query form — right-clicking the link
            // gives a sensible "Save as <file>.docx" prompt.
            var mdUrlBase = node.url.replace(/\.md$/i, '');
            ['docx', 'html', 'pdf'].forEach(function (fmt) {
                var a = document.createElement('a');
                a.className = 'btn btn-sm btn-secondary md-shell__download';
                a.href = mdUrlBase + '.' + fmt;
                // target=_blank: clicks open in a new tab. The server
                // sends Content-Disposition: inline, so the new tab
                // either renders (HTML → web page; PDF → browser's
                // PDF viewer) or auto-downloads (DOCX, since browsers
                // can't render Office Open XML). Right-click "Save
                // Link As" still gives a download-to-disk path for
                // any format. Errors from the server (422, 503, …)
                // appear as a plain-text page in the new tab, which
                // is more diagnostic than a transient toast.
                a.target = '_blank';
                a.rel = 'noopener';
                a.textContent = fmt.toUpperCase();
                a.title = 'Open ' + fmt.toUpperCase()
                    + ' in a new tab  (right-click for Save Link As / Copy Link)';
                a.dataset.fmt = fmt;
                convertBtns.push(a);
            });
        }

        infohdr.appendChild(titleEl);
        infohdr.appendChild(dirtyEl);
        infohdr.appendChild(statusEl);
        infohdr.appendChild(sourceEl);
        for (var ci = 0; ci < convertBtns.length; ci++) {
            infohdr.appendChild(convertBtns[ci]);
        }
        infohdr.appendChild(saveBtn);
        content.appendChild(infohdr);

        // Editor host.
        var editorHost = document.createElement('div');
        editorHost.className = 'md-shell__editor';
        content.appendChild(editorHost);

        // Split the loaded bytes into FM (textarea) + body (editor). The
        // hash that gates dirty-state is taken over the reassembled
        // bytes so that round-tripping a clean file shows "not dirty"
        // even if we tweak whitespace in the YAML lines.
        var initialParsed = parseFrontMatter(text);
        fmTextarea.value = stringifyFrontMatter(initialParsed.data);
        var bodyText = initialParsed.body;

        var initialHash = await hashContent(assembleContent(fmTextarea.value, bodyText));
        var writableMode = canSave(node);
        // autofocus:false keeps the keyboard caret in the tree pane —
        // arrow-key nav can continue through markdown files without
        // diverting into the editor. The user clicks into the editor
        // (or tabs to it) when they actually want to type.
        var editorOpts = {
            el: editorHost,
            height: '100%',
            usageStatistics: false,
            autofocus: false,
            initialValue: bodyText,
        };
        var editor;
        if (!writableMode) {
            // Read-only mount uses Toast UI's Viewer (rendered markdown,
            // no edit toolbar, no caret). The disabled Save button +
            // its tooltip carry the read-only signal — no banner here
            // since the Viewer's lack of edit chrome is already a
            // clear visual cue.
            editor = window.toastui.Editor.factory(Object.assign({}, editorOpts, {
                viewer: true,
            }));
        } else {
            editor = new window.toastui.Editor(Object.assign({}, editorOpts, {
                // WYSIWYG by default — most users want the rendered view
                // out of the gate; the markdown/WYSIWYG toggle in the
                // Toast UI toolbar still flips to source mode in one click.
                initialEditType: 'wysiwyg',
                previewStyle: 'vertical',
                toolbarItems: [
                    ['heading', 'bold', 'italic', 'strike'],
                    ['hr', 'quote'],
                    ['ul', 'ol', 'task', 'indent', 'outdent'],
                    ['table', 'image', 'link'],
                    ['code', 'codeblock']
                ]
            }));
        }

        currentInstance = {
            editor: editor,
            container: container,
            dirty: false,
            node: node,
            hash: initialHash,
            tocEl: tocBody,
            fmEl: fmTextarea
        };

        if (!writableMode) {
            saveBtn.disabled = true;
            saveBtn.title = 'Save not available — read-only source.';
            fmTextarea.readOnly = true;
        }

        renderToc(tocBody, bodyText, editor);

        // ── Sidebar/content resizer ─────────────────────────────────────────
        // Sidebar is on the LEFT now. Dragging right grows the
        // sidebar; left shrinks it.
        (function () {
            var dragging = false;
            var startX = 0;
            var startW = 0;
            function onMove(e) {
                if (!dragging) return;
                var dx = e.clientX - startX;
                var w = startW + dx;
                w = Math.max(SIDEBAR_MIN_WIDTH, Math.min(SIDEBAR_MAX_WIDTH, w));
                lastSidebarWidth = w;
                shell.style.gridTemplateColumns = w + 'px 1fr';
                e.preventDefault();
            }
            function onUp() {
                dragging = false;
                resizer.classList.remove('is-dragging');
                document.removeEventListener('mousemove', onMove);
                document.removeEventListener('mouseup', onUp);
            }
            resizer.addEventListener('mousedown', function (e) {
                dragging = true;
                resizer.classList.add('is-dragging');
                startX = e.clientX;
                startW = sidebar.getBoundingClientRect().width;
                document.addEventListener('mousemove', onMove);
                document.addEventListener('mouseup', onUp);
                e.preventDefault();
            });
            resizer.addEventListener('keydown', function (e) {
                if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return;
                e.preventDefault();
                var step = e.key === 'ArrowLeft' ? -24 : 24;
                var w = Math.max(SIDEBAR_MIN_WIDTH,
                    Math.min(SIDEBAR_MAX_WIDTH, lastSidebarWidth + step));
                lastSidebarWidth = w;
                shell.style.gridTemplateColumns = w + 'px 1fr';
            });
        })();

        // ── Front-matter / TOC vertical resizer ─────────────────────────────
        (function () {
            var FM_MIN = 60;
            var dragging = false;
            var startY = 0;
            var startH = 0;
            function maxFmHeight() {
                var sidebarRect = sidebar.getBoundingClientRect();
                // Leave at least 120 px for the TOC body + headers.
                return Math.max(FM_MIN, sidebarRect.height - 160);
            }
            function onMove(e) {
                if (!dragging) return;
                var dy = e.clientY - startY;
                var h = Math.max(FM_MIN, Math.min(maxFmHeight(), startH + dy));
                lastFmHeight = h;
                fmSection.style.height = h + 'px';
                e.preventDefault();
            }
            function onUp() {
                dragging = false;
                fmResizer.classList.remove('is-dragging');
                document.removeEventListener('mousemove', onMove);
                document.removeEventListener('mouseup', onUp);
            }
            fmResizer.addEventListener('mousedown', function (e) {
                dragging = true;
                fmResizer.classList.add('is-dragging');
                startY = e.clientY;
                startH = fmSection.getBoundingClientRect().height;
                document.addEventListener('mousemove', onMove);
                document.addEventListener('mouseup', onUp);
                e.preventDefault();
            });
            fmResizer.addEventListener('keydown', function (e) {
                if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return;
                e.preventDefault();
                var step = e.key === 'ArrowUp' ? -24 : 24;
                var h = Math.max(FM_MIN, Math.min(maxFmHeight(), lastFmHeight + step));
                lastFmHeight = h;
                fmSection.style.height = h + 'px';
            });
        })();

        // ── Change tracking + auto-rerender ────────────────────────────────
        function markDirty(isDirty) {
            currentInstance.dirty = isDirty;
            // Re-read canSave at every transition, not via a closure-captured
            // value, so the gate reflects current write authority — see the
            // matching pattern in preview-yaml.js.
            saveBtn.disabled = !isDirty || !canSave(node);
            dirtyEl.textContent = isDirty ? '● modified' : '';
        }

        var onChange = debounce(async function () {
            var body = editor.getMarkdown();
            var h = await hashContent(assembleContent(fmTextarea.value, body));
            markDirty(h !== currentInstance.hash);
            renderToc(tocBody, body, editor);
        }, 250);
        editor.on('change', onChange);

        var onFmChange = debounce(async function () {
            var body = editor.getMarkdown();
            var h = await hashContent(assembleContent(fmTextarea.value, body));
            markDirty(h !== currentInstance.hash);
        }, 250);
        fmTextarea.addEventListener('input', onFmChange);

        // ── Save ───────────────────────────────────────────────────────────
        async function save() {
            if (!currentInstance.dirty || !canSave(node)) return;
            var content = assembleContent(fmTextarea.value, editor.getMarkdown());
            try {
                statusEl.textContent = 'Saving…';
                await saveContent(node, content);
                currentInstance.hash = await hashContent(content);
                markDirty(false);
                statusEl.textContent = 'Saved ' + new Date().toLocaleTimeString();
                if (window.zddc && window.zddc.toast) {
                    window.zddc.toast('Saved ' + node.name, 'success');
                }
            } catch (e) {
                statusEl.textContent = 'Save failed: ' + (e.message || e);
                if (window.zddc && window.zddc.toast) {
                    window.zddc.toast('Save failed: ' + (e.message || e), 'error');
                }
            }
        }
        saveBtn.addEventListener('click', save);
        container.addEventListener('keydown', function (e) {
            if ((e.ctrlKey || e.metaKey) && (e.key === 's' || e.key === 'S')) {
                e.preventDefault();
                save();
            }
        });

        // Download-as-* click handlers. The anchors are real <a href>
        // links so right-click / middle-click / Copy Link Address all
        // work natively. The JS handler only steps in when the buffer
        // is dirty — auto-save first, then re-fire the click so the
        // browser fetches the just-saved bytes. After the click is
        // re-fired, currentInstance.dirty is false so the handler
        // is a no-op on the second pass and the native navigation
        // proceeds.
        convertBtns.forEach(function (a) {
            a.addEventListener('click', async function (e) {
                var fmt = a.dataset.fmt;
                if (!currentInstance.dirty) {
                    // Clean — let the browser handle the click. The
                    // server's response (DOCX/HTML/PDF bytes, 422,
                    // 503, etc.) lands in whatever target the user
                    // picked (current tab, new tab, save-as).
                    return;
                }
                // Dirty: intercept, save, retry.
                e.preventDefault();
                if (!canSave(node)) {
                    if (window.zddc && window.zddc.toast) {
                        window.zddc.toast(
                            'This source is read-only — save a copy elsewhere first.',
                            'error');
                    }
                    return;
                }
                statusEl.textContent = 'Saving before download…';
                try { await save(); } catch (_) { /* save() surfaces its own error */ }
                if (currentInstance.dirty) return; // save failed; toast already shown
                statusEl.textContent = 'Downloading ' + fmt.toUpperCase() + '…';
                // Re-trigger the click. dirty=false now so the handler
                // exits early on the second pass and the browser
                // processes the native navigation.
                a.click();
            });
        });
    }

    window.app.modules.markdown = {
        render: render,
        dispose: dispose
    };
})();
