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:
parent
f5cf79dc1c
commit
b34edcecac
3 changed files with 213 additions and 63 deletions
|
|
@ -538,6 +538,16 @@ html, body {
|
|||
background: var(--bg);
|
||||
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
|
||||
internal scrollers handle the content. */
|
||||
|
|
@ -623,34 +633,41 @@ html, body {
|
|||
transition: background-color 0.3s ease;
|
||||
}
|
||||
|
||||
/* ── Front matter list ──────────────────────────────────────────────────── */
|
||||
.md-fm__empty {
|
||||
/* ── Front matter editor ────────────────────────────────────────────────── */
|
||||
.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);
|
||||
font-style: italic;
|
||||
font-size: 0.82rem;
|
||||
margin: 0;
|
||||
padding: 0.5rem 0.75rem;
|
||||
}
|
||||
.md-fm__list {
|
||||
margin: 0;
|
||||
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__textarea:focus {
|
||||
background: var(--surface-2, rgba(0, 0, 0, 0.025));
|
||||
}
|
||||
.md-fm__list dt {
|
||||
font-weight: 600;
|
||||
.md-fm__textarea[readonly] {
|
||||
color: var(--text-muted);
|
||||
text-transform: lowercase;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.md-fm__list dd {
|
||||
margin: 0;
|
||||
color: var(--text);
|
||||
overflow-wrap: anywhere;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ── Sort control ────────────────────────────────────────────────────────── */
|
||||
|
|
|
|||
|
|
@ -2,22 +2,31 @@
|
|||
//
|
||||
// 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 │
|
||||
// └────────────────────────────────────────┴────────────────────────┘
|
||||
// │ info: name | dirty | status | source | DOCX HTML PDF | Save │
|
||||
// ├────────────────────────┬────────────────────────────────────────┤
|
||||
// │ YAML front matter │ │
|
||||
// │ ┌──────────────────┐ │ │
|
||||
// │ │ title: Foo │ │ Toast UI Editor │
|
||||
// │ │ revision: A │ │ (md / wysiwyg / preview) │
|
||||
// │ └──────────────────┘ │ │
|
||||
// ├────────────────────────┤ │
|
||||
// │ Outline │ │
|
||||
// │ • Heading 1 │ │
|
||||
// │ • Subheading │ │
|
||||
// │ • Heading 2 │ │
|
||||
// └────────────────────────┴────────────────────────────────────────┘
|
||||
// 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.
|
||||
//
|
||||
// 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
|
||||
// FileSystemWritableFileStream (FS-API). Zip-virtual files are
|
||||
// read-only — Save stays disabled. Toast UI is vendored
|
||||
|
|
@ -93,25 +102,37 @@
|
|||
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 = '<p class="md-fm__empty">No front matter.</p>';
|
||||
return;
|
||||
}
|
||||
var html = '<dl class="md-fm__list">';
|
||||
// Inverse of parseFrontMatter — turn a {key: value | array} object back
|
||||
// into newline-separated YAML lines suitable for the textarea. Arrays
|
||||
// are quoted to match what the parser will round-trip through. Returns
|
||||
// "" when there are no keys (so the textarea shows its placeholder).
|
||||
function stringifyFrontMatter(data) {
|
||||
if (!data) return '';
|
||||
var keys = Object.keys(data);
|
||||
if (keys.length === 0) return '';
|
||||
var out = [];
|
||||
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 += '<dt>' + escapeHtml(k) + '</dt><dd>' + displayV + '</dd>';
|
||||
var v = data[k];
|
||||
if (Array.isArray(v)) {
|
||||
out.push(k + ': [' + v.map(function (x) {
|
||||
return '"' + String(x).replace(/"/g, '\\"') + '"';
|
||||
}).join(', ') + ']');
|
||||
} else {
|
||||
out.push(k + ': ' + String(v));
|
||||
}
|
||||
html += '</dl>';
|
||||
fmEl.innerHTML = html;
|
||||
}
|
||||
return out.join('\n');
|
||||
}
|
||||
|
||||
// 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) ────────────────────────────────────────────
|
||||
|
|
@ -337,6 +358,13 @@
|
|||
fmHeader.textContent = 'YAML front matter';
|
||||
var fmBody = document.createElement('div');
|
||||
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(fmBody);
|
||||
sidebar.appendChild(fmSection);
|
||||
|
|
@ -408,10 +436,35 @@
|
|||
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(dirtyEl);
|
||||
infohdr.appendChild(statusEl);
|
||||
infohdr.appendChild(sourceEl);
|
||||
for (var ci = 0; ci < convertBtns.length; ci++) {
|
||||
infohdr.appendChild(convertBtns[ci]);
|
||||
}
|
||||
infohdr.appendChild(saveBtn);
|
||||
content.appendChild(infohdr);
|
||||
|
||||
|
|
@ -420,15 +473,21 @@
|
|||
editorHost.className = 'md-shell__editor';
|
||||
content.appendChild(editorHost);
|
||||
|
||||
// Construct the editor. height: 100% works because editorHost
|
||||
// is a grid cell with a definite size.
|
||||
var initialHash = await hashContent(text);
|
||||
// Split the loaded bytes into FM (textarea) + body (editor). The
|
||||
// hash that gates dirty-state is taken over the reassembled
|
||||
// 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({
|
||||
el: editorHost,
|
||||
height: '100%',
|
||||
initialEditType: 'markdown',
|
||||
previewStyle: 'vertical',
|
||||
initialValue: text,
|
||||
initialValue: bodyText,
|
||||
usageStatistics: false,
|
||||
toolbarItems: [
|
||||
['heading', 'bold', 'italic', 'strike'],
|
||||
|
|
@ -446,17 +505,17 @@
|
|||
node: node,
|
||||
hash: initialHash,
|
||||
tocEl: tocBody,
|
||||
fmEl: fmBody
|
||||
fmEl: fmTextarea
|
||||
};
|
||||
|
||||
var writable = canSave(node);
|
||||
if (!writable) {
|
||||
saveBtn.disabled = true;
|
||||
saveBtn.title = 'Save not available — read-only source.';
|
||||
fmTextarea.readOnly = true;
|
||||
}
|
||||
|
||||
renderToc(tocBody, text, editor);
|
||||
renderFrontMatter(fmBody, text);
|
||||
renderToc(tocBody, bodyText, editor);
|
||||
|
||||
// ── Sidebar/content resizer ─────────────────────────────────────────
|
||||
// Sidebar is on the LEFT now. Dragging right grows the
|
||||
|
|
@ -552,18 +611,24 @@
|
|||
}
|
||||
|
||||
var onChange = debounce(async function () {
|
||||
var current = editor.getMarkdown();
|
||||
var h = await hashContent(current);
|
||||
var body = editor.getMarkdown();
|
||||
var h = await hashContent(assembleContent(fmTextarea.value, body));
|
||||
markDirty(h !== currentInstance.hash);
|
||||
renderToc(tocBody, current, editor);
|
||||
renderFrontMatter(fmBody, current);
|
||||
renderToc(tocBody, body, editor);
|
||||
}, 250);
|
||||
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 ───────────────────────────────────────────────────────────
|
||||
async function save() {
|
||||
if (!currentInstance.dirty || !writable) return;
|
||||
var content = editor.getMarkdown();
|
||||
var content = assembleContent(fmTextarea.value, editor.getMarkdown());
|
||||
try {
|
||||
statusEl.textContent = 'Saving…';
|
||||
await saveContent(node, content);
|
||||
|
|
@ -587,6 +652,42 @@
|
|||
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 = {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
// 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:
|
||||
//
|
||||
|
|
@ -370,12 +370,44 @@
|
|||
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 = {
|
||||
HttpDirectoryHandle: HttpDirectoryHandle,
|
||||
HttpFileHandle: HttpFileHandle,
|
||||
detectServerRoot: detectServerRoot,
|
||||
moveFile: moveFile,
|
||||
isHttpHandle: isHttpHandle,
|
||||
downloadConverted: downloadConverted,
|
||||
// Lower-level helpers exposed for tools that want to call the
|
||||
// server directly without going through the polyfill.
|
||||
httpListing: httpListing,
|
||||
|
|
|
|||
Loading…
Reference in a new issue