// 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
};
})();