ZDDC/browse/js/preview-markdown.js
ZDDC 0b382716e3 feat(browse): TOC pane + FS-API saves in the markdown plugin
Completes the markdown plugin's deferred v2 items:

1. TOC pane

A third pane to the right of the Toast UI editor lists every heading
in the current document, hierarchically indented by level. Click an
item → editor scrolls to that heading (markdown-mode uses
setSelection + preview scroll; WYSIWYG mode uses DOM text matching;
the target heading flashes briefly via primary-light background).
The TOC re-renders on every editor change (debounced 250ms) so it
stays in sync with edits.

Heading parser supports ATX-style `^#{1,6}\s+` lines, strips inline
markdown emphasis/code/links/strike from the displayed label.
Empty file → "Empty file." Headingless file → "No headings."

2. FS-API writes

Saves now route to whichever source the file came from:

  - node.handle + createWritable available → FileSystemWritableFileStream
    (local folder picker). The user's chosen file gets overwritten
    via the browser's File System Access API.
  - node.url + server source → PUT to the server URL (as before).
  - zip-virtual file → save disabled (no writable stream from JSZip).
  - Anything else → save disabled with a tooltip.

Save status surfaces via the existing toolbar (`Saved 10:42:18`) AND
a shared toast notification ("Saved readme.md" / "Save failed: …")
so the success/failure is visible regardless of whether the user is
looking at the toolbar.

Source-hint chip on the toolbar shows "local" / "server" /
"read-only (inside zip)" so the user knows which write path is
active before they make changes.

CSS additions in browse/css/tree.css for .md-toolbar, .md-split,
.md-editor-host, .md-toc-pane, .toc-list, and the .toc-level-1..6
indentation rules.

A new Playwright test exercises the markdown plugin end-to-end:
mounts the editor on a .md click, asserts the three DOM regions are
visible, verifies the TOC contains the three expected headings from
the test fixture's markdown content, and confirms the source hint
reads "local" for FS-API mode.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 19:02:32 -05:00

362 lines
14 KiB
JavaScript

// 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, '&amp;').replace(/</g, '&lt;')
.replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
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 = '<p class="toc-empty">Empty file.</p>';
return;
}
if (headings.length === 0) {
tocEl.innerHTML = '<p class="toc-empty">No headings.</p>';
return;
}
// Build a flat ordered list; CSS handles the visual indent.
var html = '<ul class="toc-list">';
for (var i = 0; i < headings.length; i++) {
var h = headings[i];
html += '<li class="toc-item toc-level-' + h.level + '" data-line="' + h.lineIndex + '" data-text="' + escapeHtml(h.text) + '">'
+ '<a href="#" tabindex="0">' + escapeHtml(h.text) + '</a></li>';
}
html += '</ul>';
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 =
'<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;
}
// 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 tocPane = document.createElement('div');
tocPane.className = 'md-toc-pane';
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 = '<p class="toc-empty">Loading…</p>';
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
};
})();