// 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; } // ── Front matter ─────────────────────────────────────────────────────── // // Lightweight YAML-front-matter parser. Same shape as mdedit's // parseFrontMatter — handles `---\n…\n---\n` envelope, key: value // lines, simple `[a, b, c]` arrays. Comments (#) skipped. Returns // { data, body }; body is the markdown content with the front-matter // envelope stripped. function parseFrontMatter(content) { if (!content || !content.startsWith('---\n')) { 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 }; } function renderFrontMatter(fmEl, content) { if (!fmEl) return; var parsed = parseFrontMatter(content); var keys = Object.keys(parsed.data); if (keys.length === 0) { fmEl.innerHTML = '

No front matter.

'; return; } var html = '
'; for (var i = 0; i < keys.length; i++) { var k = keys[i]; var v = parsed.data[k]; var displayV = Array.isArray(v) ? v.map(escapeHtml).join(', ') : escapeHtml(String(v)); html += '
' + escapeHtml(k) + '
' + displayV + '
'; } html += '
'; fmEl.innerHTML = html; } // ── 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 = ''; tocEl.innerHTML = html; // One delegated click handler. tocEl.querySelectorAll('.toc-item').forEach(function (li) { li.addEventListener('click', function (e) { e.preventDefault(); var idx = parseInt(li.dataset.line, 10); var text = li.dataset.text; scrollEditorToHeading(editor, { text: text, lineIndex: idx }); }); }); } // Light debounce so TOC doesn't rebuild on every keystroke. function debounce(fn, ms) { var t; return function () { clearTimeout(t); var args = arguments, self = this; t = setTimeout(function () { fn.apply(self, args); }, ms); }; } // ── Save (server + FS-API) ────────────────────────────────────────────── async function saveContent(node, content) { // FS-API mode: write via the local file handle. if (node.handle && typeof node.handle.createWritable === 'function') { var writable = await node.handle.createWritable(); await writable.write(content); await writable.close(); return; } // Server mode: PUT the new bytes. 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).'); } function canSave(node) { if (node.zipParentId != null) 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 = '
' + 'Toast UI Editor isn\'t bundled in this build.
'; 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 = '
' + 'Could not read ' + escapeHtml(node.name) + ': ' + escapeHtml(e.message || String(e)) + '
'; return; } // Build the markdown plugin's DOM: // ┌──────────────────────────────────────────────────┐ // │ toolbar (Save, ● modified, status, source hint) │ // ├──────────────────────────────────┬───────────────┤ // │ editor (Toast UI) │ TOC pane │ // └──────────────────────────────────┴───────────────┘ container.innerHTML = ''; container.style.display = 'flex'; container.style.flexDirection = 'column'; var toolbar = document.createElement('div'); toolbar.className = 'md-toolbar'; var saveBtn = document.createElement('button'); saveBtn.className = 'btn btn-sm btn-primary'; saveBtn.type = 'button'; saveBtn.textContent = 'Save'; saveBtn.disabled = true; var dirty = document.createElement('span'); dirty.className = 'md-toolbar__dirty'; var status = document.createElement('span'); status.className = 'md-toolbar__status'; var sourceHint = document.createElement('span'); sourceHint.className = 'md-toolbar__source'; if (node.zipParentId != null) { sourceHint.textContent = 'read-only (inside zip)'; } else if (node.handle) { sourceHint.textContent = 'local'; } else if (node.url) { sourceHint.textContent = 'server'; } toolbar.appendChild(saveBtn); toolbar.appendChild(dirty); toolbar.appendChild(status); toolbar.appendChild(sourceHint); container.appendChild(toolbar); var split = document.createElement('div'); split.className = 'md-split'; container.appendChild(split); var editorHost = document.createElement('div'); editorHost.className = 'md-editor-host'; split.appendChild(editorHost); var tocResizer = document.createElement('div'); tocResizer.className = 'pane-resizer md-toc-resizer'; tocResizer.setAttribute('aria-hidden', 'true'); split.appendChild(tocResizer); var tocPane = document.createElement('div'); tocPane.className = 'md-toc-pane'; // Front-matter section above TOC, read-only display. var fmSection = document.createElement('div'); fmSection.className = 'md-fm-section'; var fmHeader = document.createElement('div'); fmHeader.className = 'md-toc-pane__header'; fmHeader.textContent = 'Front matter'; var fmBody = document.createElement('div'); fmBody.className = 'md-fm-body'; fmSection.appendChild(fmHeader); fmSection.appendChild(fmBody); tocPane.appendChild(fmSection); var tocHeader = document.createElement('div'); tocHeader.className = 'md-toc-pane__header'; tocHeader.textContent = 'Outline'; var tocBody = document.createElement('div'); tocBody.className = 'md-toc-pane__body'; tocBody.innerHTML = '

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); renderFrontMatter(fmBody, text); // TOC pane resizer — drag horizontally. Stays in-memory only; // refresh resets to the default 220px. (function () { var dragging = false; var startX = 0; var startWidth = 0; tocResizer.addEventListener('mousedown', function (e) { dragging = true; tocResizer.classList.add('is-dragging'); startX = e.clientX; startWidth = tocPane.getBoundingClientRect().width; e.preventDefault(); }); document.addEventListener('mousemove', function (e) { if (!dragging) return; // Drag left to grow the TOC, right to shrink it. var dx = e.clientX - startX; var w = Math.max(150, Math.min(window.innerWidth * 0.4, startWidth - dx)); tocPane.style.width = w + 'px'; }); document.addEventListener('mouseup', function () { if (!dragging) return; dragging = false; tocResizer.classList.remove('is-dragging'); }); })(); 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); renderFrontMatter(fmBody, current); }, 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 }; })();