Four user-reported items:
1. landing: remove the standalone-tool strip from the site picker.
Per user, it was awkward — links pointing at zddc.varasys.io
releases from inside a deployment is a layering confusion. The
nav.tool-strip block in landing/template.html and its CSS are
gone.
2. zddc-server: route /Project/archive/<party>/mdl[/] to the tables
app for the virtual-MDL case where the on-disk folder doesn't
exist yet. Previously fell through to 404 because the dispatcher
only routed virtual mdl/ via the IsDir branch — the IsNotExist
branch was missing the equivalent check. Now both shapes (with
and without trailing slash) hit RecognizeTableRequest's default-
MDL fallback and ServeTable serves the embedded tables.html.
3. browse: re-layout the markdown editor to mirror mdedit's layout.
Was: sidebar on right with TOC top + front-matter bottom.
Now: sidebar on LEFT with YAML front matter top + Outline bottom,
content on RIGHT with an informational header (file title +
save controls + status + source) above the Toast UI editor.
New horizontal resizer between the front-matter and outline
sections inside the sidebar (drag the row boundary; arrow keys
step by 24 px). Browse test selectors updated.
4. zddc-server reviewing aggregator: extend to depth ≥ 2 so the
user can preview files inside virtual reviewing/<tracking>/
received/ and staged/ folders. IsReviewingPath now returns a
sidePath ("received[/rest]" or "staged[/rest]"); ServeReviewing's
depth-2 branch proxies the underlying real folder's listing,
emitting folder entries with virtual reviewing/ URLs (so
navigation stays in the aggregator) and file entries with
canonical archive/ or staging/ URLs (so byte fetches resolve
directly). ACL is enforced against the real path; depth-1
received/ + staged/ URLs are now virtual too (was canonical),
so the user smoothly descends into the depth-2 listing.
Tests updated for the new IsReviewingPath signature and the depth-1
URL shape.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
587 lines
25 KiB
JavaScript
587 lines
25 KiB
JavaScript
// preview-markdown.js — markdown plugin for the browse preview pane.
|
|
//
|
|
// 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 │
|
|
// └────────────────────────────────────────┴────────────────────────┘
|
|
// 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.
|
|
//
|
|
// 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, '&').replace(/</g, '<')
|
|
.replace(/>/g, '>').replace(/"/g, '"');
|
|
}
|
|
|
|
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 };
|
|
}
|
|
|
|
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">';
|
|
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>';
|
|
}
|
|
html += '</dl>';
|
|
fmEl.innerHTML = html;
|
|
}
|
|
|
|
// ── 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).');
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
// 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) ──────────────
|
|
var sidebar = document.createElement('div');
|
|
sidebar.className = 'md-shell__sidebar';
|
|
sidebar.style.gridTemplateRows = lastFmHeight + 'px 1fr';
|
|
shell.appendChild(sidebar);
|
|
|
|
var fmSection = document.createElement('section');
|
|
fmSection.className = 'md-side md-side--fm';
|
|
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';
|
|
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 (node.zipParentId != null) {
|
|
sourceEl.textContent = 'read-only (zip)';
|
|
} else if (node.handle) {
|
|
sourceEl.textContent = 'local';
|
|
} else if (node.url) {
|
|
sourceEl.textContent = 'server';
|
|
}
|
|
|
|
infohdr.appendChild(titleEl);
|
|
infohdr.appendChild(dirtyEl);
|
|
infohdr.appendChild(statusEl);
|
|
infohdr.appendChild(sourceEl);
|
|
infohdr.appendChild(saveBtn);
|
|
content.appendChild(infohdr);
|
|
|
|
// Editor host.
|
|
var editorHost = document.createElement('div');
|
|
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);
|
|
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,
|
|
fmEl: fmBody
|
|
};
|
|
|
|
var writable = canSave(node);
|
|
if (!writable) {
|
|
saveBtn.disabled = true;
|
|
saveBtn.title = 'Save not available — read-only source.';
|
|
}
|
|
|
|
renderToc(tocBody, text, editor);
|
|
renderFrontMatter(fmBody, text);
|
|
|
|
// ── 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;
|
|
sidebar.style.gridTemplateRows = h + 'px 1fr';
|
|
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;
|
|
sidebar.style.gridTemplateRows = h + 'px 1fr';
|
|
});
|
|
})();
|
|
|
|
// ── Change tracking + auto-rerender ────────────────────────────────
|
|
function markDirty(isDirty) {
|
|
currentInstance.dirty = isDirty;
|
|
saveBtn.disabled = !isDirty || !writable;
|
|
dirtyEl.textContent = isDirty ? '● modified' : '';
|
|
}
|
|
|
|
var onChange = debounce(async function () {
|
|
var current = editor.getMarkdown();
|
|
var h = await hashContent(current);
|
|
markDirty(h !== currentInstance.hash);
|
|
renderToc(tocBody, current, editor);
|
|
renderFrontMatter(fmBody, current);
|
|
}, 250);
|
|
editor.on('change', onChange);
|
|
|
|
// ── Save ───────────────────────────────────────────────────────────
|
|
async function save() {
|
|
if (!currentInstance.dirty || !writable) return;
|
|
var content = 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();
|
|
}
|
|
});
|
|
}
|
|
|
|
window.app.modules.markdown = {
|
|
render: render,
|
|
dispose: dispose
|
|
};
|
|
})();
|