ZDDC/browse/js/preview-markdown.js
ZDDC 63fc4338b6 fix(browse): trim markdown read-only banner + drop YAML front-matter placeholder
- Read-only markdown files mount as Toast UI Viewer, which already
  has no edit toolbar / no caret — the absence is itself the cue.
  Drop the explicit red banner; keep the disabled-Save tooltip.
- YAML front-matter textarea no longer shows a placeholder example
  (title/date/tags). A file without front matter renders as a
  genuinely empty pane instead of looking like it has content.
- YAML editor's banner stays — CodeMirror readOnly has no
  built-in visual signal beyond the disabled caret.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 09:47:21 -05:00

758 lines
34 KiB
JavaScript

// preview-markdown.js — markdown plugin for the browse preview pane.
//
// Layout (CSS Grid):
// ┌─────────────────────────────────────────────────────────────────┐
// │ 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
// (shared/vendor/toastui-editor-all.min.js); window.toastui is
// available synchronously before this module runs.
(function () {
'use strict';
if (!window.app || !window.app.modules) return;
var SIDEBAR_MIN_WIDTH = 180;
var SIDEBAR_MAX_WIDTH = 480;
var SIDEBAR_DEFAULT_WIDTH = 280;
var FM_DEFAULT_HEIGHT = 180; // px — front-matter pane height inside sidebar
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, fmEl }
var lastSidebarWidth = SIDEBAR_DEFAULT_WIDTH; // remember across mounts
var lastFmHeight = FM_DEFAULT_HEIGHT;
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 envelope as mdedit's:
// `---\n…\n---\n`, key:value lines, simple `[a, b, c]` arrays.
function parseFrontMatter(content) {
if (!content || content.indexOf('---\n') !== 0) {
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 };
}
// 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 = 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));
}
}
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) ────────────────────────────────────────────
// ATX headings only; the body markdown drives the outline. Clicking
// a heading routes to whichever Toast UI pane is currently active
// (WYSIWYG or markdown preview).
function parseHeadings(content) {
var headings = [];
// Strip front matter so headings inside the envelope (e.g. comments)
// don't appear in the outline.
var parsed = parseFrontMatter(content);
var body = parsed.body;
var lines = body.split('\n');
var inFence = false;
for (var i = 0; i < lines.length; i++) {
var line = lines[i];
// Skip fenced code blocks — headings inside them aren't real.
if (/^\s*```/.test(line)) { inFence = !inFence; continue; }
if (inFence) continue;
var m = line.match(/^(#{1,6})\s+(.+?)\s*#*\s*$/);
if (!m) continue;
var text = m[2]
.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 scroller = findScrollParent(hs[i]) || ww;
scroller.scrollTo({
top: hs[i].offsetTop - 12,
behavior: 'smooth'
});
flashHeading(hs[i]);
return;
}
}
} else {
var line = heading.lineIndex + 1;
try { editor.setSelection([line, 1], [line, 1]); } catch (_) { /* ignore */ }
// Find the matching heading in the live markdown preview
// (right column of split view). If preview is collapsed
// (markdown-only) this is a no-op.
var preview = els.mdPreview;
if (preview) {
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 pscroller = findScrollParent(phs[j]) || preview;
pscroller.scrollTo({
top: phs[j].offsetTop - 12,
behavior: 'smooth'
});
flashHeading(phs[j]);
return;
}
}
}
}
} catch (_e) { /* swallow; click was best-effort */ }
}
function findScrollParent(el) {
var cur = el.parentElement;
while (cur) {
var s = getComputedStyle(cur);
if (/(auto|scroll)/.test(s.overflowY)) return cur;
cur = cur.parentElement;
}
return null;
}
function flashHeading(el) {
if (!el) return;
el.classList.add('md-toc__flash');
setTimeout(function () { el.classList.remove('md-toc__flash'); }, 900);
}
function renderToc(tocEl, content, editor) {
if (!tocEl) return;
if (!content || !content.trim()) {
tocEl.innerHTML = '<p class="md-toc__empty">Empty file.</p>';
return;
}
var headings = parseHeadings(content);
if (headings.length === 0) {
tocEl.innerHTML = '<p class="md-toc__empty">No headings yet.</p>';
return;
}
// Build a flat list; CSS handles indentation. Using a flat list
// (rather than nested <ul>s) keeps the click target a clean,
// full-width row regardless of heading depth.
var html = '<ul class="md-toc__list">';
for (var i = 0; i < headings.length; i++) {
var h = headings[i];
html += '<li class="md-toc__item md-toc__item--l' + h.level + '"'
+ ' data-line="' + h.lineIndex + '"'
+ ' data-text="' + escapeHtml(h.text) + '"'
+ ' title="' + escapeHtml(h.text) + '">'
+ escapeHtml(h.text)
+ '</li>';
}
html += '</ul>';
tocEl.innerHTML = html;
tocEl.querySelectorAll('.md-toc__item').forEach(function (li) {
li.addEventListener('click', function () {
var idx = parseInt(li.dataset.line, 10);
var text = li.dataset.text;
scrollEditorToHeading(editor, { text: text, lineIndex: idx });
});
});
}
function debounce(fn, ms) {
var t;
return function () {
clearTimeout(t);
var args = arguments, self = this;
t = setTimeout(function () { fn.apply(self, args); }, ms);
};
}
// ── Save ────────────────────────────────────────────────────────────────
async function saveContent(node, content) {
if (node.handle && typeof node.handle.createWritable === 'function') {
var writable = await node.handle.createWritable();
await writable.write(content);
await writable.close();
return;
}
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).');
}
// A markdown file living inside a .zip is read-only: a ZipFileHandle
// refuses createWritable (offline / nested), and zddc-server refuses
// writes to a "<…>.zip/<member>" URL (405).
function isZipMemberNode(node) {
if (node.handle && node.handle.isZipEntry) return true;
if (node.url && window.app.state.source === 'server' && /\.zip\//i.test(node.url)) return true;
return false;
}
function canSave(node) {
if (isZipMemberNode(node)) return false;
// Server-computed authority gate. The listing's `writable`
// bit reflects what a PUT would do — false here means the
// file API would 403 the save, so we mount in read-only
// mode rather than letting the user type and lose changes.
if (node.url && window.app.state.source === 'server' && !node.writable) 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;
}
// Wipe the container and install a single shell child. The
// shell mirrors mdedit's layout: sidebar on the LEFT (front
// matter top, TOC bottom), content on the RIGHT (informational
// header above the Toast UI editor). CSS Grid keeps every
// cell sized definitely so Toast UI's scroll regions resolve
// correctly.
container.innerHTML = '';
var shell = document.createElement('div');
shell.className = 'md-shell';
shell.style.gridTemplateColumns = lastSidebarWidth + 'px 1fr';
container.appendChild(shell);
// ── Sidebar (col 1): front matter (top) + TOC (bottom) ──────────────
// Sidebar is a flex column: FM section (fixed height, set
// inline below) + horizontal resizer + TOC section (1fr).
var sidebar = document.createElement('div');
sidebar.className = 'md-shell__sidebar';
shell.appendChild(sidebar);
var fmSection = document.createElement('section');
fmSection.className = 'md-side md-side--fm';
// Front-matter height is driven inline (persisted across
// remounts via lastFmHeight) so the resizer's drag-handler
// mutates a single source of truth.
fmSection.style.height = lastFmHeight + 'px';
var fmHeader = document.createElement('div');
fmHeader.className = 'md-side__header';
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';
// No placeholder text — files with no YAML front matter render
// as a genuinely empty pane. Showing a synthetic example would
// make the file look like it had data when it doesn't.
fmTextarea.placeholder = '';
fmBody.appendChild(fmTextarea);
fmSection.appendChild(fmHeader);
fmSection.appendChild(fmBody);
sidebar.appendChild(fmSection);
// Horizontal resizer between front-matter and TOC.
var fmResizer = document.createElement('div');
fmResizer.className = 'md-shell__fmresizer';
fmResizer.setAttribute('role', 'separator');
fmResizer.setAttribute('aria-orientation', 'horizontal');
fmResizer.setAttribute('aria-label', 'Resize front-matter pane');
fmResizer.tabIndex = 0;
sidebar.appendChild(fmResizer);
var tocSection = document.createElement('section');
tocSection.className = 'md-side md-side--toc';
var tocHeader = document.createElement('div');
tocHeader.className = 'md-side__header';
tocHeader.textContent = 'Outline';
var tocBody = document.createElement('div');
tocBody.className = 'md-side__body md-toc__body';
tocSection.appendChild(tocHeader);
tocSection.appendChild(tocBody);
sidebar.appendChild(tocSection);
// Vertical resizer between sidebar and content.
var resizer = document.createElement('div');
resizer.className = 'md-shell__resizer';
resizer.setAttribute('role', 'separator');
resizer.setAttribute('aria-orientation', 'vertical');
resizer.setAttribute('aria-label', 'Resize sidebar');
resizer.tabIndex = 0;
shell.appendChild(resizer);
// ── Content (col 2): informational header + editor ──────────────────
var content = document.createElement('div');
content.className = 'md-shell__content';
shell.appendChild(content);
// Informational header above the editor: file name + save +
// dirty indicator + status + source hint. Renamed from
// "toolbar" to read as a header, since it titles the content.
var infohdr = document.createElement('div');
infohdr.className = 'md-shell__infohdr';
var titleEl = document.createElement('span');
titleEl.className = 'md-shell__title';
titleEl.textContent = node.name;
titleEl.title = node.name;
var saveBtn = document.createElement('button');
saveBtn.className = 'btn btn-sm btn-primary md-shell__save';
saveBtn.type = 'button';
saveBtn.textContent = 'Save';
saveBtn.disabled = true;
var dirtyEl = document.createElement('span');
dirtyEl.className = 'md-shell__dirty';
var statusEl = document.createElement('span');
statusEl.className = 'md-shell__status';
var sourceEl = document.createElement('span');
sourceEl.className = 'md-shell__source';
if (isZipMemberNode(node)) {
sourceEl.textContent = 'read-only (zip)';
} else if (node.handle) {
sourceEl.textContent = 'local';
} else if (node.url) {
sourceEl.textContent = 'server';
}
// Download-as-{docx,html,pdf} affordances. Server-mode + .md
// only: the server endpoint runs pandoc/chromium in a
// container and returns the converted bytes.
//
// These are real <a> elements with href + download attributes,
// styled like buttons. That means right-click → "Copy link
// address" / "Open in new tab" / "Save link as" all work
// natively — users can share the conversion URL or download
// through their preferred path. Click is intercepted only
// when the buffer is dirty (auto-save first, then re-fire
// the click so the browser fetches the saved bytes).
var serverModeMd = window.app && window.app.state &&
window.app.state.source === 'server' &&
node.url && /\.md$/i.test(node.name);
var convertBtns = [];
if (serverModeMd) {
// Virtual-extension URLs: <file>.md → <file>.docx etc.
// The dispatcher recognises the sibling-extension pattern
// and routes through ServeConverted. Cleaner than the
// old `?convert=` query form — right-clicking the link
// gives a sensible "Save as <file>.docx" prompt.
var mdUrlBase = node.url.replace(/\.md$/i, '');
['docx', 'html', 'pdf'].forEach(function (fmt) {
var a = document.createElement('a');
a.className = 'btn btn-sm btn-secondary md-shell__download';
a.href = mdUrlBase + '.' + fmt;
// target=_blank: clicks open in a new tab. The server
// sends Content-Disposition: inline, so the new tab
// either renders (HTML → web page; PDF → browser's
// PDF viewer) or auto-downloads (DOCX, since browsers
// can't render Office Open XML). Right-click "Save
// Link As" still gives a download-to-disk path for
// any format. Errors from the server (422, 503, …)
// appear as a plain-text page in the new tab, which
// is more diagnostic than a transient toast.
a.target = '_blank';
a.rel = 'noopener';
a.textContent = fmt.toUpperCase();
a.title = 'Open ' + fmt.toUpperCase()
+ ' in a new tab (right-click for Save Link As / Copy Link)';
a.dataset.fmt = fmt;
convertBtns.push(a);
});
}
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);
// Editor host.
var editorHost = document.createElement('div');
editorHost.className = 'md-shell__editor';
content.appendChild(editorHost);
// 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 writableMode = canSave(node);
// autofocus:false keeps the keyboard caret in the tree pane —
// arrow-key nav can continue through markdown files without
// diverting into the editor. The user clicks into the editor
// (or tabs to it) when they actually want to type.
var editorOpts = {
el: editorHost,
height: '100%',
usageStatistics: false,
autofocus: false,
initialValue: bodyText,
};
var editor;
if (!writableMode) {
// Read-only mount uses Toast UI's Viewer (rendered markdown,
// no edit toolbar, no caret). The disabled Save button +
// its tooltip carry the read-only signal — no banner here
// since the Viewer's lack of edit chrome is already a
// clear visual cue.
editor = window.toastui.Editor.factory(Object.assign({}, editorOpts, {
viewer: true,
}));
} else {
editor = new window.toastui.Editor(Object.assign({}, editorOpts, {
// WYSIWYG by default — most users want the rendered view
// out of the gate; the markdown/WYSIWYG toggle in the
// Toast UI toolbar still flips to source mode in one click.
initialEditType: 'wysiwyg',
previewStyle: 'vertical',
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,
fmEl: fmTextarea
};
if (!writableMode) {
saveBtn.disabled = true;
saveBtn.title = 'Save not available — read-only source.';
fmTextarea.readOnly = true;
}
renderToc(tocBody, bodyText, editor);
// ── Sidebar/content resizer ─────────────────────────────────────────
// Sidebar is on the LEFT now. Dragging right grows the
// sidebar; left shrinks it.
(function () {
var dragging = false;
var startX = 0;
var startW = 0;
function onMove(e) {
if (!dragging) return;
var dx = e.clientX - startX;
var w = startW + dx;
w = Math.max(SIDEBAR_MIN_WIDTH, Math.min(SIDEBAR_MAX_WIDTH, w));
lastSidebarWidth = w;
shell.style.gridTemplateColumns = w + 'px 1fr';
e.preventDefault();
}
function onUp() {
dragging = false;
resizer.classList.remove('is-dragging');
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
}
resizer.addEventListener('mousedown', function (e) {
dragging = true;
resizer.classList.add('is-dragging');
startX = e.clientX;
startW = sidebar.getBoundingClientRect().width;
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
e.preventDefault();
});
resizer.addEventListener('keydown', function (e) {
if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return;
e.preventDefault();
var step = e.key === 'ArrowLeft' ? -24 : 24;
var w = Math.max(SIDEBAR_MIN_WIDTH,
Math.min(SIDEBAR_MAX_WIDTH, lastSidebarWidth + step));
lastSidebarWidth = w;
shell.style.gridTemplateColumns = w + 'px 1fr';
});
})();
// ── Front-matter / TOC vertical resizer ─────────────────────────────
(function () {
var FM_MIN = 60;
var dragging = false;
var startY = 0;
var startH = 0;
function maxFmHeight() {
var sidebarRect = sidebar.getBoundingClientRect();
// Leave at least 120 px for the TOC body + headers.
return Math.max(FM_MIN, sidebarRect.height - 160);
}
function onMove(e) {
if (!dragging) return;
var dy = e.clientY - startY;
var h = Math.max(FM_MIN, Math.min(maxFmHeight(), startH + dy));
lastFmHeight = h;
fmSection.style.height = h + 'px';
e.preventDefault();
}
function onUp() {
dragging = false;
fmResizer.classList.remove('is-dragging');
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
}
fmResizer.addEventListener('mousedown', function (e) {
dragging = true;
fmResizer.classList.add('is-dragging');
startY = e.clientY;
startH = fmSection.getBoundingClientRect().height;
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
e.preventDefault();
});
fmResizer.addEventListener('keydown', function (e) {
if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return;
e.preventDefault();
var step = e.key === 'ArrowUp' ? -24 : 24;
var h = Math.max(FM_MIN, Math.min(maxFmHeight(), lastFmHeight + step));
lastFmHeight = h;
fmSection.style.height = h + 'px';
});
})();
// ── Change tracking + auto-rerender ────────────────────────────────
function markDirty(isDirty) {
currentInstance.dirty = isDirty;
saveBtn.disabled = !isDirty || !writable;
dirtyEl.textContent = isDirty ? '● modified' : '';
}
var onChange = debounce(async function () {
var body = editor.getMarkdown();
var h = await hashContent(assembleContent(fmTextarea.value, body));
markDirty(h !== currentInstance.hash);
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 = assembleContent(fmTextarea.value, editor.getMarkdown());
try {
statusEl.textContent = 'Saving…';
await saveContent(node, content);
currentInstance.hash = await hashContent(content);
markDirty(false);
statusEl.textContent = 'Saved ' + new Date().toLocaleTimeString();
if (window.zddc && window.zddc.toast) {
window.zddc.toast('Saved ' + node.name, 'success');
}
} catch (e) {
statusEl.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);
container.addEventListener('keydown', function (e) {
if ((e.ctrlKey || e.metaKey) && (e.key === 's' || e.key === 'S')) {
e.preventDefault();
save();
}
});
// Download-as-* click handlers. The anchors are real <a href>
// links so right-click / middle-click / Copy Link Address all
// work natively. The JS handler only steps in when the buffer
// is dirty — auto-save first, then re-fire the click so the
// browser fetches the just-saved bytes. After the click is
// re-fired, currentInstance.dirty is false so the handler
// is a no-op on the second pass and the native navigation
// proceeds.
convertBtns.forEach(function (a) {
a.addEventListener('click', async function (e) {
var fmt = a.dataset.fmt;
if (!currentInstance.dirty) {
// Clean — let the browser handle the click. The
// server's response (DOCX/HTML/PDF bytes, 422,
// 503, etc.) lands in whatever target the user
// picked (current tab, new tab, save-as).
return;
}
// Dirty: intercept, save, retry.
e.preventDefault();
if (!writable) {
if (window.zddc && window.zddc.toast) {
window.zddc.toast(
'This source is read-only — save a copy elsewhere first.',
'error');
}
return;
}
statusEl.textContent = 'Saving before download…';
try { await save(); } catch (_) { /* save() surfaces its own error */ }
if (currentInstance.dirty) return; // save failed; toast already shown
statusEl.textContent = 'Downloading ' + fmt.toUpperCase() + '…';
// Re-trigger the click. dirty=false now so the handler
// exits early on the second pass and the browser
// processes the native navigation.
a.click();
});
});
}
window.app.modules.markdown = {
render: render,
dispose: dispose
};
})();