// preview-markdown.js — markdown plugin for the browse preview pane. // // Layout (CSS Grid): // ┌─────────────────────────────────────────────────────────────────┐ // │ toolbar: Save | ● modified | status | source │ // ├────────────────────────────────────────┬────────────────────────┤ // │ │ Outline │ // │ │ • Heading 1 │ // │ Toast UI Editor │ • Subheading │ // │ (md / wysiwyg / preview) │ • Heading 2 │ // │ ├────────────────────────┤ // │ │ Front matter │ // │ │ title: Foo │ // │ │ revision: A │ // └────────────────────────────────────────┴────────────────────────┘ // 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. // // 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 TOC_MIN_WIDTH = 180; var TOC_MAX_WIDTH = 480; var TOC_DEFAULT_WIDTH = 260; function escapeHtml(s) { return String(s).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); } var currentInstance = null; // { editor, container, dirty, node, hash, tocEl, fmEl } var lastTocWidth = TOC_DEFAULT_WIDTH; // remember across mounts 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 }; } 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) ──────────────────────────────────────────── // 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 = '

Empty file.

'; return; } var headings = parseHeadings(content); if (headings.length === 0) { tocEl.innerHTML = '

No headings yet.

'; return; } // Build a flat list; CSS handles indentation. Using a flat list // (rather than nested