// preview-markdown.js — markdown plugin for the browse preview pane. // Click a .md / .markdown file in the tree → instantiate Toast UI // editor inside the right pane, alongside a TOC pane on the right. // Save (Ctrl+S) writes back via: // - PUT to the file's server URL when in server mode, or // - FileSystemWritableFileStream when in FS-API mode (local folder // picker). Both paths set dirty=false + a status timestamp on // success. // zip-virtual files are read-only — the save button stays disabled. // // Toast UI Editor is bundled (shared/vendor/toastui-editor-all.min.js) // and is available synchronously as window.toastui by the time this // module runs. (function () { 'use strict'; if (!window.app || !window.app.modules) return; function escapeHtml(s) { return String(s).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); } var currentInstance = null; // { editor, container, dirty, node, hash, tocEl } // Compute SHA-256 hex of a string for a "is this content different // from what we loaded?" check. Used to enable/disable Save. 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; } // ── TOC (table of contents) ───────────────────────────────────────────── // // Ported from mdedit/js/toc.js, condensed: parse markdown for ATX-style // headings, build a flat hierarchical list, click jumps the editor to // the heading's line. We track WYSIWYG vs markdown mode and route the // scroll behaviour to whichever pane is visible. function parseHeadings(content) { var headings = []; var lines = content.split('\n'); for (var i = 0; i < lines.length; i++) { var m = lines[i].match(/^(#{1,6})\s+(.+)$/); if (!m) continue; var text = m[2].trim() .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 top = hs[i].getBoundingClientRect().top - ww.getBoundingClientRect().top; ww.scrollTop = top - 10; flashHeading(hs[i]); return; } } } else { var line = heading.lineIndex + 1; try { editor.setSelection([line, 1], [line, 1]); } catch (_) { /* ignore */ } var preview = els.mdPreview; if (!preview) return; 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 ptop = phs[j].getBoundingClientRect().top - preview.getBoundingClientRect().top; preview.scrollTop = ptop - 10; flashHeading(phs[j]); return; } } } } catch (e) { /* swallow; click was best-effort */ } } function flashHeading(el) { if (!el) return; el.style.transition = 'background-color 0.3s ease'; el.style.backgroundColor = 'var(--primary-light)'; setTimeout(function () { el.style.backgroundColor = ''; setTimeout(function () { el.style.transition = ''; }, 300); }, 1200); } function renderToc(tocEl, content, editor) { if (!tocEl) return; var headings = parseHeadings(content); if (!content.trim()) { tocEl.innerHTML = '
Empty file.
'; return; } if (headings.length === 0) { tocEl.innerHTML = 'No headings.
'; return; } // Build a flat ordered list; CSS handles the visual indent. var html = 'Loading…
'; tocPane.appendChild(tocHeader); tocPane.appendChild(tocBody); split.appendChild(tocPane); var initialHash = await hashContent(text); var editor = new window.toastui.Editor({ el: editorHost, height: '100%', initialEditType: 'markdown', previewStyle: 'vertical', initialValue: text, usageStatistics: false, 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 }; var writable = canSave(node); if (!writable) { saveBtn.disabled = true; saveBtn.title = 'Save not available — read-only source.'; } renderToc(tocBody, text, editor); function markDirty(isDirty) { currentInstance.dirty = isDirty; saveBtn.disabled = !isDirty || !writable; dirty.textContent = isDirty ? '● modified' : ''; } var updateOnChange = debounce(async function () { var current = editor.getMarkdown(); var h = await hashContent(current); markDirty(h !== currentInstance.hash); renderToc(tocBody, current, editor); }, 250); editor.on('change', updateOnChange); async function save() { if (!currentInstance.dirty || !writable) return; var content = editor.getMarkdown(); try { status.textContent = 'Saving…'; await saveContent(node, content); currentInstance.hash = await hashContent(content); markDirty(false); var now = new Date(); status.textContent = 'Saved ' + now.toLocaleTimeString(); if (window.zddc && window.zddc.toast) { window.zddc.toast('Saved ' + node.name, 'success'); } } catch (e) { status.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); // Ctrl+S / Cmd+S inside the editor → save. container.addEventListener('keydown', function (e) { if ((e.ctrlKey || e.metaKey) && (e.key === 's' || e.key === 'S')) { e.preventDefault(); save(); } }); } window.app.modules.markdown = { render: render, dispose: dispose }; })();