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>
This commit is contained in:
ZDDC 2026-05-10 19:02:32 -05:00
parent d77981407c
commit 0b382716e3
4 changed files with 396 additions and 54 deletions

View file

@ -342,3 +342,137 @@ html, body {
.status-bar.is-error { color: var(--danger); } .status-bar.is-error { color: var(--danger); }
.status-bar.is-info { color: var(--text); } .status-bar.is-info { color: var(--text); }
/* ── Markdown plugin (right-pane internals when a .md is selected) ──────── */
/* Editor toolbar (above the editor+TOC split): Save + dirty marker +
status + source hint. Sticks to the top of the pane body. */
.md-toolbar {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.35rem 0.75rem;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
font-size: 0.85rem;
}
.md-toolbar__dirty {
color: var(--text-muted);
font-size: 0.85rem;
min-width: 6rem;
}
.md-toolbar__status {
flex: 1;
text-align: right;
color: var(--text-muted);
font-size: 0.85rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.md-toolbar__source {
color: var(--text-muted);
font-size: 0.75rem;
font-style: italic;
margin-left: 0.5rem;
padding: 0.15rem 0.4rem;
border-radius: var(--radius);
background: var(--bg);
border: 1px solid var(--border);
}
/* Editor + TOC two-pane split inside the preview body. */
.md-split {
flex: 1;
display: flex;
flex-direction: row;
min-height: 0;
overflow: hidden;
}
.md-editor-host {
flex: 1;
min-width: 0;
min-height: 0;
overflow: hidden;
}
/* TOC pane sits on the right. Fixed width by default; the user can't
resize it (yet) kept simple in v1. */
.md-toc-pane {
width: 220px;
flex-shrink: 0;
border-left: 1px solid var(--border);
background: var(--bg);
display: flex;
flex-direction: column;
overflow: hidden;
}
.md-toc-pane__header {
padding: 0.35rem 0.75rem;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border);
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-muted);
flex-shrink: 0;
}
.md-toc-pane__body {
flex: 1;
overflow-y: auto;
padding: 0.4rem 0;
font-size: 0.85rem;
line-height: 1.4;
}
.toc-empty {
color: var(--text-muted);
font-style: italic;
padding: 0.5rem 0.75rem;
margin: 0;
font-size: 0.85rem;
}
.toc-list {
list-style: none;
margin: 0;
padding: 0;
}
.toc-item {
margin: 0;
}
.toc-item a {
display: block;
padding: 0.2rem 0.75rem;
text-decoration: none;
color: var(--text);
border-left: 2px solid transparent;
transition: background 0.1s, border-color 0.1s;
}
.toc-item a:hover {
background: var(--bg-hover);
border-left-color: var(--primary);
}
.toc-item a:focus-visible {
outline: 2px solid var(--primary);
outline-offset: -2px;
}
.toc-level-1 a { padding-left: 0.75rem; font-weight: 600; }
.toc-level-2 a { padding-left: 1.4rem; }
.toc-level-3 a { padding-left: 2.05rem; }
.toc-level-4 a { padding-left: 2.7rem; color: var(--text-muted); }
.toc-level-5 a { padding-left: 3.35rem; color: var(--text-muted); font-size: 0.8rem; }
.toc-level-6 a { padding-left: 4rem; color: var(--text-muted); font-size: 0.8rem; }

View file

@ -1,12 +1,16 @@
// preview-markdown.js — markdown plugin for the browse preview pane. // preview-markdown.js — markdown plugin for the browse preview pane.
// Click a .md / .markdown file in the tree → instantiate Toast UI // Click a .md / .markdown file in the tree → instantiate Toast UI
// editor inside the right pane. Save (Ctrl+S) writes back via PUT // editor inside the right pane, alongside a TOC pane on the right.
// when the file came from a server URL; FS-API and zip-virtual files // Save (Ctrl+S) writes back via:
// are read-only for now (toolbar shows a hint). // - 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 loaded from mdedit's bundled vendor file in the // Toast UI Editor is bundled (shared/vendor/toastui-editor-all.min.js)
// browse build (see browse/build.sh). window.toastui is available // and is available synchronously as window.toastui by the time this
// synchronously when this module runs. // module runs.
(function () { (function () {
'use strict'; 'use strict';
@ -17,11 +21,10 @@
.replace(/>/g, '&gt;').replace(/"/g, '&quot;'); .replace(/>/g, '&gt;').replace(/"/g, '&quot;');
} }
var currentInstance = null; // { editor, container, dirty, node, hash } var currentInstance = null; // { editor, container, dirty, node, hash, tocEl }
// Compute SHA-256 hex of a string for a quick "is this content // Compute SHA-256 hex of a string for a "is this content different
// different from what was loaded?" check. Used to decide whether // from what we loaded?" check. Used to enable/disable Save.
// the save button should be active. Not used for integrity.
async function hashContent(text) { async function hashContent(text) {
if (!window.crypto || !window.crypto.subtle) return null; if (!window.crypto || !window.crypto.subtle) return null;
var enc = new TextEncoder().encode(text); var enc = new TextEncoder().encode(text);
@ -41,6 +44,152 @@
currentInstance = null; 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) { async function render(node, container, ctx) {
if (typeof window.toastui === 'undefined') { if (typeof window.toastui === 'undefined') {
container.innerHTML = container.innerHTML =
@ -49,10 +198,9 @@
return; return;
} }
// Tear down any previous markdown instance (single-file model).
dispose(); dispose();
// Read the file content. // Read content.
var text; var text;
try { try {
var buf = await ctx.getArrayBuffer(node); var buf = await ctx.getArrayBuffer(node);
@ -66,45 +214,65 @@
} }
// Build the markdown plugin's DOM: // Build the markdown plugin's DOM:
// ┌──────────────────────────────────┐ // ┌──────────────────────────────────────────────────┐
// │ toolbar (Save, dirty marker) │ // │ toolbar (Save, ● modified, status, source hint) │
// ├──────────────────────────────────┤ // ├──────────────────────────────────┬───────────────┤
// │ Toast UI editor │ // │ editor (Toast UI) │ TOC pane │
// └──────────────────────────────────┘ // └──────────────────────────────────┴───────────────┘
//
// TOC pane is deferred — a near-term iteration can split this
// into editor | toc once the simpler form is exercised.
container.innerHTML = ''; container.innerHTML = '';
container.style.display = 'flex'; container.style.display = 'flex';
container.style.flexDirection = 'column'; container.style.flexDirection = 'column';
var toolbar = document.createElement('div'); var toolbar = document.createElement('div');
toolbar.className = 'md-toolbar'; toolbar.className = 'md-toolbar';
toolbar.style.cssText = 'display:flex;align-items:center;gap:0.5rem;'
+ 'padding:0.35rem 0.75rem;background:var(--bg-secondary);'
+ 'border-bottom:1px solid var(--border);flex-shrink:0;';
var saveBtn = document.createElement('button'); var saveBtn = document.createElement('button');
saveBtn.className = 'btn btn-sm btn-primary'; saveBtn.className = 'btn btn-sm btn-primary';
saveBtn.type = 'button'; saveBtn.type = 'button';
saveBtn.textContent = 'Save'; saveBtn.textContent = 'Save';
saveBtn.disabled = true; // enabled when content changes saveBtn.disabled = true;
var dirty = document.createElement('span'); var dirty = document.createElement('span');
dirty.style.cssText = 'color:var(--text-muted);font-size:0.85rem;'; dirty.className = 'md-toolbar__dirty';
dirty.textContent = '';
var status = document.createElement('span'); var status = document.createElement('span');
status.style.cssText = 'flex:1;text-align:right;color:var(--text-muted);font-size:0.85rem;'; 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(saveBtn);
toolbar.appendChild(dirty); toolbar.appendChild(dirty);
toolbar.appendChild(status); toolbar.appendChild(status);
toolbar.appendChild(sourceHint);
container.appendChild(toolbar); container.appendChild(toolbar);
var split = document.createElement('div');
split.className = 'md-split';
container.appendChild(split);
var editorHost = document.createElement('div'); var editorHost = document.createElement('div');
editorHost.style.cssText = 'flex:1;min-height:0;overflow:hidden;'; editorHost.className = 'md-editor-host';
container.appendChild(editorHost); 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 initialHash = await hashContent(text);
var editor = new window.toastui.Editor({ var editor = new window.toastui.Editor({
@ -123,47 +291,56 @@
] ]
}); });
currentInstance = { editor: editor, container: container, dirty: false, node: node, hash: initialHash }; 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) { function markDirty(isDirty) {
currentInstance.dirty = isDirty; currentInstance.dirty = isDirty;
saveBtn.disabled = !isDirty; saveBtn.disabled = !isDirty || !writable;
dirty.textContent = isDirty ? '● modified' : ''; dirty.textContent = isDirty ? '● modified' : '';
} }
editor.on('change', async function () { var updateOnChange = debounce(async function () {
var current = editor.getMarkdown(); var current = editor.getMarkdown();
var h = await hashContent(current); var h = await hashContent(current);
markDirty(h !== currentInstance.hash); markDirty(h !== currentInstance.hash);
}); renderToc(tocBody, current, editor);
}, 250);
editor.on('change', updateOnChange);
async function save() { async function save() {
if (!currentInstance.dirty) return; if (!currentInstance.dirty || !writable) return;
var content = editor.getMarkdown(); var content = editor.getMarkdown();
// Read-only sources: zip-virtual, FS-API without write
// permission. For now we only attempt PUT against server URLs;
// FS-API saves can be wired in a later iteration via the
// existing zddc-source polyfill.
if (!node.url || window.app.state.source !== 'server') {
status.textContent = 'Save not yet supported for this source.';
return;
}
try { try {
status.textContent = 'Saving…'; status.textContent = 'Saving…';
var resp = await fetch(node.url, { await saveContent(node, content);
method: 'PUT',
headers: { 'Content-Type': 'text/markdown; charset=utf-8' },
body: content,
credentials: 'same-origin'
});
if (!resp.ok) {
throw new Error('HTTP ' + resp.status);
}
currentInstance.hash = await hashContent(content); currentInstance.hash = await hashContent(content);
markDirty(false); markDirty(false);
status.textContent = 'Saved ' + new Date().toLocaleTimeString(); var now = new Date();
status.textContent = 'Saved ' + now.toLocaleTimeString();
if (window.zddc && window.zddc.toast) {
window.zddc.toast('Saved ' + node.name, 'success');
}
} catch (e) { } catch (e) {
status.textContent = 'Save failed: ' + (e.message || e); status.textContent = 'Save failed: ' + (e.message || e);
if (window.zddc && window.zddc.toast) {
window.zddc.toast('Save failed: ' + (e.message || e), 'error');
}
} }
} }
@ -171,7 +348,7 @@
// Ctrl+S / Cmd+S inside the editor → save. // Ctrl+S / Cmd+S inside the editor → save.
container.addEventListener('keydown', function (e) { container.addEventListener('keydown', function (e) {
if ((e.ctrlKey || e.metaKey) && e.key === 's') { if ((e.ctrlKey || e.metaKey) && (e.key === 's' || e.key === 'S')) {
e.preventDefault(); e.preventDefault();
save(); save();
} }

View file

@ -72,4 +72,35 @@ test.describe('Browse', () => {
await expect(page.locator('#previewTitle')).toHaveText(/notes\.txt/); await expect(page.locator('#previewTitle')).toHaveText(/notes\.txt/);
await expect(page.locator('#previewPopout')).toBeVisible(); await expect(page.locator('#previewPopout')).toBeVisible();
}); });
test('clicking a .md file mounts the markdown editor with a TOC', async ({ page }) => {
await page.goto(`file://${HTML_PATH}`, { waitUntil: 'load' });
await page.waitForSelector('#addDirectoryBtn', { timeout: 15000 });
await page.evaluate(() => {
window.__setMockDirectory('notes', [
{
name: 'readme.md',
content: '# Title\n\nIntro.\n\n## Section One\n\nText.\n\n### Subsection\n\nDeeper.',
size: 100,
},
]);
});
await page.locator('#addDirectoryBtn').click();
await page.waitForSelector('#treeBody .tree-row[data-isdir="false"]', { timeout: 10000 });
await page.locator('#treeBody .tree-row[data-isdir="false"]').first().click();
// Markdown plugin DOM mounts: toolbar, editor host, TOC pane.
await expect(page.locator('.md-toolbar')).toBeVisible({ timeout: 15000 });
await expect(page.locator('.md-editor-host')).toBeVisible();
await expect(page.locator('.md-toc-pane')).toBeVisible();
// TOC enumerates the three headings.
await page.waitForSelector('.toc-list li', { timeout: 10000 });
const tocItems = await page.locator('.toc-list li a').allTextContents();
expect(tocItems).toEqual(['Title', 'Section One', 'Subsection']);
// Source hint reflects local FS-API mode.
await expect(page.locator('.md-toolbar__source')).toHaveText(/local/i);
});
}); });

View file

@ -1155,7 +1155,7 @@ body.help-open .app-header {
</svg> </svg>
<div class="header-title-group"> <div class="header-title-group">
<span class="app-header__title" id="table-title">ZDDC Table</span> <span class="app-header__title" id="table-title">ZDDC Table</span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-beta · 2026-05-10 · diamond-flame-kettle</span></span> <span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-alpha · 2026-05-11 00:02:27 · d779814-dirty</span></span>
</div> </div>
</div> </div>
<div class="header-right"> <div class="header-right">