feat(browse): markdown editor — editable YAML front matter + DOCX/HTML/PDF download buttons

Two improvements to browse's preview-markdown plugin so it can replace
the standalone mdedit tool:

1. **YAML front-matter editing.** The FM pane above the outline used to
   render a read-only <dl> of parsed keys — sparse and unusable when
   the file had no envelope yet. It's now a dedicated <textarea> that's
   always present. On load, parseFrontMatter() splits the `---\n…\n---`
   envelope off the body: the body feeds Toast UI Editor, the envelope
   feeds the textarea. On save, assembleContent() recombines them.
   Dirty tracking covers both halves via a SHA-256 of the assembled
   bytes. The shell mirrors mdedit's old layout (FM textarea top,
   outline below) but the FM pane is now always functional, eliminating
   the "empty pane over the TOC" problem.

2. **Download as DOCX / HTML / PDF.** When the file handle is HTTP-
   backed (server mode) and the file is a .md, three buttons appear in
   the info header next to Save. Clicking one fetches the server's
   ?convert=<fmt> endpoint and triggers a browser download with a
   clean filename (foo.md → foo.docx). Auto-saves the buffer first if
   dirty so the converted bytes reflect what's on screen.

Helper at window.zddc.source.downloadConverted (shared/zddc-source.js)
so other tools — archive, transmittal — can reuse the same flow later.
Friendly error messages map HTTP 503 / 422 / 504 to actionable toasts.
This commit is contained in:
ZDDC 2026-05-13 10:32:38 -05:00
parent f5cf79dc1c
commit b34edcecac
3 changed files with 213 additions and 63 deletions

View file

@ -538,6 +538,16 @@ html, body {
background: var(--bg); background: var(--bg);
border: 1px solid var(--border); border: 1px solid var(--border);
} }
.md-shell__download {
/* Slightly tighter than the Save button so a row of three doesn't
crowd the title. The base .btn styles still drive padding/color. */
font-variant-numeric: tabular-nums;
letter-spacing: 0.02em;
}
.md-shell__download[disabled] {
opacity: 0.55;
cursor: progress;
}
/* Editor host: a single grid cell with overflow:hidden so Toast UI's /* Editor host: a single grid cell with overflow:hidden so Toast UI's
internal scrollers handle the content. */ internal scrollers handle the content. */
@ -623,34 +633,41 @@ html, body {
transition: background-color 0.3s ease; transition: background-color 0.3s ease;
} }
/* ── Front matter list ──────────────────────────────────────────────────── */ /* ── Front matter editor ────────────────────────────────────────────────── */
.md-fm__empty { .md-fm__body {
/* Body cell owns the textarea; sized by the sidebar's grid row. */
padding: 0;
display: block;
overflow: hidden;
}
.md-fm__textarea {
width: 100%;
height: 100%;
box-sizing: border-box;
margin: 0;
padding: 0.4rem 0.6rem;
border: 0;
background: transparent;
color: var(--text);
font-family: var(--font-mono, ui-monospace, SFMono-Regular, Consolas, monospace);
font-size: 0.8rem;
line-height: 1.45;
resize: none;
outline: none;
white-space: pre;
overflow: auto;
tab-size: 2;
}
.md-fm__textarea::placeholder {
color: var(--text-muted); color: var(--text-muted);
font-style: italic; font-style: italic;
font-size: 0.82rem;
margin: 0;
padding: 0.5rem 0.75rem;
} }
.md-fm__list { .md-fm__textarea:focus {
margin: 0; background: var(--surface-2, rgba(0, 0, 0, 0.025));
padding: 0.3rem 0.75rem;
display: grid;
grid-template-columns: minmax(4.5rem, max-content) 1fr;
gap: 0.2rem 0.6rem;
font-size: 0.8rem;
} }
.md-fm__list dt { .md-fm__textarea[readonly] {
font-weight: 600;
color: var(--text-muted); color: var(--text-muted);
text-transform: lowercase; cursor: not-allowed;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.md-fm__list dd {
margin: 0;
color: var(--text);
overflow-wrap: anywhere;
} }
/* ── Sort control ────────────────────────────────────────────────────────── */ /* ── Sort control ────────────────────────────────────────────────────────── */

View file

@ -2,22 +2,31 @@
// //
// Layout (CSS Grid): // Layout (CSS Grid):
// ┌─────────────────────────────────────────────────────────────────┐ // ┌─────────────────────────────────────────────────────────────────┐
// │ toolbar: Save | ● modified | status | source │ // │ info: name | dirty | status | source | DOCX HTML PDF | Save │
// ├────────────────────────────────────────┬────────────────────────┤ // ├────────────────────────┬────────────────────────────────────────┤
// │ │ Outline │ // │ YAML front matter │ │
// │ │ • Heading 1 │ // │ ┌──────────────────┐ │ │
// │ Toast UI Editor │ • Subheading │ // │ │ title: Foo │ │ Toast UI Editor │
// │ (md / wysiwyg / preview) │ • Heading 2 │ // │ │ revision: A │ │ (md / wysiwyg / preview) │
// │ ├────────────────────────┤ // │ └──────────────────┘ │ │
// │ │ Front matter │ // ├────────────────────────┤ │
// │ │ title: Foo │ // │ Outline │ │
// │ │ revision: A │ // │ • Heading 1 │ │
// └────────────────────────────────────────┴────────────────────────┘ // │ • Subheading │ │
// │ • Heading 2 │ │
// └────────────────────────┴────────────────────────────────────────┘
// Grid keeps every cell's size definite, which is what Toast UI needs // Grid keeps every cell's size definite, which is what Toast UI needs
// to compute its inner scroll regions correctly. The previous nested- // to compute its inner scroll regions correctly. The previous nested-
// flexbox layout produced indeterminate heights and a fragile TOC // flexbox layout produced indeterminate heights and a fragile TOC
// pane width — grid fixes both. // pane width — grid fixes both.
// //
// Front matter is edited in a dedicated <textarea> in the sidebar
// (always present — typing into the placeholder grows the envelope on
// save). On load the `---\n…\n---\n` envelope is stripped from the
// bytes fed to Toast UI; on save the textarea content is re-stitched
// on top of the editor body. Keeps YAML out of the rich editor where
// users can't reliably edit it.
//
// Save (Ctrl+S) writes back via PUT (server mode) or // Save (Ctrl+S) writes back via PUT (server mode) or
// FileSystemWritableFileStream (FS-API). Zip-virtual files are // FileSystemWritableFileStream (FS-API). Zip-virtual files are
// read-only — Save stays disabled. Toast UI is vendored // read-only — Save stays disabled. Toast UI is vendored
@ -93,25 +102,37 @@
return { data: data, body: body }; return { data: data, body: body };
} }
function renderFrontMatter(fmEl, content) { // Inverse of parseFrontMatter — turn a {key: value | array} object back
if (!fmEl) return; // into newline-separated YAML lines suitable for the textarea. Arrays
var parsed = parseFrontMatter(content); // are quoted to match what the parser will round-trip through. Returns
var keys = Object.keys(parsed.data); // "" when there are no keys (so the textarea shows its placeholder).
if (keys.length === 0) { function stringifyFrontMatter(data) {
fmEl.innerHTML = '<p class="md-fm__empty">No front matter.</p>'; if (!data) return '';
return; var keys = Object.keys(data);
} if (keys.length === 0) return '';
var html = '<dl class="md-fm__list">'; var out = [];
for (var i = 0; i < keys.length; i++) { for (var i = 0; i < keys.length; i++) {
var k = keys[i]; var k = keys[i];
var v = parsed.data[k]; var v = data[k];
var displayV = Array.isArray(v) if (Array.isArray(v)) {
? v.map(escapeHtml).join(', ') out.push(k + ': [' + v.map(function (x) {
: escapeHtml(String(v)); return '"' + String(x).replace(/"/g, '\\"') + '"';
html += '<dt>' + escapeHtml(k) + '</dt><dd>' + displayV + '</dd>'; }).join(', ') + ']');
} else {
out.push(k + ': ' + String(v));
}
} }
html += '</dl>'; return out.join('\n');
fmEl.innerHTML = html; }
// Stitch the textarea's YAML lines and the editor's body back together
// into the on-disk envelope. Empty textarea → return body unchanged
// (no envelope written). Trailing whitespace in the textarea is
// tolerated.
function assembleContent(fmText, body) {
var fm = (fmText || '').replace(/\s+$/, '');
if (!fm) return body || '';
return '---\n' + fm + '\n---\n' + (body || '');
} }
// ── TOC (table of contents) ──────────────────────────────────────────── // ── TOC (table of contents) ────────────────────────────────────────────
@ -337,6 +358,13 @@
fmHeader.textContent = 'YAML front matter'; fmHeader.textContent = 'YAML front matter';
var fmBody = document.createElement('div'); var fmBody = document.createElement('div');
fmBody.className = 'md-side__body md-fm__body'; fmBody.className = 'md-side__body md-fm__body';
var fmTextarea = document.createElement('textarea');
fmTextarea.className = 'md-fm__textarea';
fmTextarea.spellcheck = false;
fmTextarea.autocapitalize = 'off';
fmTextarea.autocomplete = 'off';
fmTextarea.placeholder = 'title: Document Title\ndate: 2026-05-13\ntags: [example]';
fmBody.appendChild(fmTextarea);
fmSection.appendChild(fmHeader); fmSection.appendChild(fmHeader);
fmSection.appendChild(fmBody); fmSection.appendChild(fmBody);
sidebar.appendChild(fmSection); sidebar.appendChild(fmSection);
@ -408,10 +436,35 @@
sourceEl.textContent = 'server'; sourceEl.textContent = 'server';
} }
// Download-as-{docx,html,pdf} buttons. Server-mode + .md only:
// the server endpoint runs pandoc/chromium in a container and
// returns the converted bytes. Click handlers wire up below
// (after save() is defined) because they auto-save first when
// the buffer is dirty.
var serverModeMd = window.app && window.app.state &&
window.app.state.source === 'server' &&
node.url && /\.md$/i.test(node.name);
var convertBtns = [];
if (serverModeMd && window.zddc && window.zddc.source &&
typeof window.zddc.source.downloadConverted === 'function') {
['docx', 'html', 'pdf'].forEach(function (fmt) {
var btn = document.createElement('button');
btn.className = 'btn btn-sm btn-secondary md-shell__download';
btn.type = 'button';
btn.textContent = fmt.toUpperCase();
btn.title = 'Download as ' + fmt.toUpperCase();
btn.dataset.fmt = fmt;
convertBtns.push(btn);
});
}
infohdr.appendChild(titleEl); infohdr.appendChild(titleEl);
infohdr.appendChild(dirtyEl); infohdr.appendChild(dirtyEl);
infohdr.appendChild(statusEl); infohdr.appendChild(statusEl);
infohdr.appendChild(sourceEl); infohdr.appendChild(sourceEl);
for (var ci = 0; ci < convertBtns.length; ci++) {
infohdr.appendChild(convertBtns[ci]);
}
infohdr.appendChild(saveBtn); infohdr.appendChild(saveBtn);
content.appendChild(infohdr); content.appendChild(infohdr);
@ -420,15 +473,21 @@
editorHost.className = 'md-shell__editor'; editorHost.className = 'md-shell__editor';
content.appendChild(editorHost); content.appendChild(editorHost);
// Construct the editor. height: 100% works because editorHost // Split the loaded bytes into FM (textarea) + body (editor). The
// is a grid cell with a definite size. // hash that gates dirty-state is taken over the reassembled
var initialHash = await hashContent(text); // bytes so that round-tripping a clean file shows "not dirty"
// even if we tweak whitespace in the YAML lines.
var initialParsed = parseFrontMatter(text);
fmTextarea.value = stringifyFrontMatter(initialParsed.data);
var bodyText = initialParsed.body;
var initialHash = await hashContent(assembleContent(fmTextarea.value, bodyText));
var editor = new window.toastui.Editor({ var editor = new window.toastui.Editor({
el: editorHost, el: editorHost,
height: '100%', height: '100%',
initialEditType: 'markdown', initialEditType: 'markdown',
previewStyle: 'vertical', previewStyle: 'vertical',
initialValue: text, initialValue: bodyText,
usageStatistics: false, usageStatistics: false,
toolbarItems: [ toolbarItems: [
['heading', 'bold', 'italic', 'strike'], ['heading', 'bold', 'italic', 'strike'],
@ -446,17 +505,17 @@
node: node, node: node,
hash: initialHash, hash: initialHash,
tocEl: tocBody, tocEl: tocBody,
fmEl: fmBody fmEl: fmTextarea
}; };
var writable = canSave(node); var writable = canSave(node);
if (!writable) { if (!writable) {
saveBtn.disabled = true; saveBtn.disabled = true;
saveBtn.title = 'Save not available — read-only source.'; saveBtn.title = 'Save not available — read-only source.';
fmTextarea.readOnly = true;
} }
renderToc(tocBody, text, editor); renderToc(tocBody, bodyText, editor);
renderFrontMatter(fmBody, text);
// ── Sidebar/content resizer ───────────────────────────────────────── // ── Sidebar/content resizer ─────────────────────────────────────────
// Sidebar is on the LEFT now. Dragging right grows the // Sidebar is on the LEFT now. Dragging right grows the
@ -552,18 +611,24 @@
} }
var onChange = debounce(async function () { var onChange = debounce(async function () {
var current = editor.getMarkdown(); var body = editor.getMarkdown();
var h = await hashContent(current); var h = await hashContent(assembleContent(fmTextarea.value, body));
markDirty(h !== currentInstance.hash); markDirty(h !== currentInstance.hash);
renderToc(tocBody, current, editor); renderToc(tocBody, body, editor);
renderFrontMatter(fmBody, current);
}, 250); }, 250);
editor.on('change', onChange); editor.on('change', onChange);
var onFmChange = debounce(async function () {
var body = editor.getMarkdown();
var h = await hashContent(assembleContent(fmTextarea.value, body));
markDirty(h !== currentInstance.hash);
}, 250);
fmTextarea.addEventListener('input', onFmChange);
// ── Save ─────────────────────────────────────────────────────────── // ── Save ───────────────────────────────────────────────────────────
async function save() { async function save() {
if (!currentInstance.dirty || !writable) return; if (!currentInstance.dirty || !writable) return;
var content = editor.getMarkdown(); var content = assembleContent(fmTextarea.value, editor.getMarkdown());
try { try {
statusEl.textContent = 'Saving…'; statusEl.textContent = 'Saving…';
await saveContent(node, content); await saveContent(node, content);
@ -587,6 +652,42 @@
save(); save();
} }
}); });
// Download-as-* click handlers. Auto-save when the buffer is
// dirty so the converted file reflects what's on screen. If
// the save fails the existing toast/status surfaces it; we
// bail without firing the conversion.
convertBtns.forEach(function (btn) {
btn.addEventListener('click', async function () {
var fmt = btn.dataset.fmt;
if (currentInstance.dirty) {
if (!writable) {
if (window.zddc && window.zddc.toast) {
window.zddc.toast(
'This source is read-only — save a copy elsewhere first.',
'error');
}
return;
}
btn.disabled = true;
try { await save(); } finally { btn.disabled = false; }
if (currentInstance.dirty) return; // save failed
}
btn.disabled = true;
try {
statusEl.textContent = 'Converting to ' + fmt.toUpperCase() + '…';
await window.zddc.source.downloadConverted(node.url, node.name, fmt);
statusEl.textContent = 'Downloaded ' + fmt.toUpperCase();
} catch (e) {
statusEl.textContent = (e && e.message) || String(e);
if (window.zddc && window.zddc.toast) {
window.zddc.toast((e && e.message) || String(e), 'error');
}
} finally {
btn.disabled = false;
}
});
});
} }
window.app.modules.markdown = { window.app.modules.markdown = {

View file

@ -1,5 +1,5 @@
// shared/zddc-source.js — source abstraction for tools that handle // shared/zddc-source.js — source abstraction for tools that handle
// directory trees (classifier, mdedit, transmittal, browse, archive). // directory trees (classifier, transmittal, browse, archive).
// //
// Two backends: // Two backends:
// //
@ -370,12 +370,44 @@
return !!(handle && handle.isHttp === true); return !!(handle && handle.isHttp === true);
} }
// downloadConverted fetches a server-side MD→{docx,html,pdf}
// conversion and triggers a browser download with a clean filename.
// srcUrl points at the .md source on the server. fmt is one of
// "docx" | "html" | "pdf". The server response status maps to a
// friendly error message for the caller to surface (toast / status).
async function downloadConverted(srcUrl, fileName, fmt) {
var resp = await fetch(srcUrl + '?convert=' + encodeURIComponent(fmt),
{ credentials: 'same-origin' });
if (!resp.ok) {
var msg;
if (resp.status === 503) msg = 'Conversion service unavailable on this server.';
else if (resp.status === 422) msg = 'Conversion failed — the source may be malformed.';
else if (resp.status === 504) msg = 'Conversion timed out.';
else msg = 'Conversion failed (HTTP ' + resp.status + ').';
// Append server-supplied body text if it adds detail.
try {
var detail = await resp.text();
if (detail && detail.length < 400) msg += ' ' + detail.trim();
} catch (_) { /* ignore */ }
throw new Error(msg);
}
var blob = await resp.blob();
var a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = fileName.replace(/\.md$/i, '') + '.' + fmt;
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(function () { URL.revokeObjectURL(a.href); }, 1000);
}
window.zddc.source = { window.zddc.source = {
HttpDirectoryHandle: HttpDirectoryHandle, HttpDirectoryHandle: HttpDirectoryHandle,
HttpFileHandle: HttpFileHandle, HttpFileHandle: HttpFileHandle,
detectServerRoot: detectServerRoot, detectServerRoot: detectServerRoot,
moveFile: moveFile, moveFile: moveFile,
isHttpHandle: isHttpHandle, isHttpHandle: isHttpHandle,
downloadConverted: downloadConverted,
// Lower-level helpers exposed for tools that want to call the // Lower-level helpers exposed for tools that want to call the
// server directly without going through the polyfill. // server directly without going through the polyfill.
httpListing: httpListing, httpListing: httpListing,