Generalise the front-matter completion into a reusable, provider-based helper
(browse/js/yaml-complete.js) and wire BOTH YAML editors through it. Still fully
deterministic — every candidate and doc string comes from a schema, no AI.
- yaml-complete.js: shared CodeMirror plumbing (indent→key-path, sibling scan,
show-hint, debounced hover tooltip) + two providers:
· flatProvider — a fixed field list (front matter), with an exclude set.
· schemaProvider — a JSON Schema walker that resolves nested key-paths
through properties / additionalProperties / patternProperties and the
recursive $ref:"#" .zddc uses for paths:; keys from object properties,
values from enum / boolean, hover docs from `description`.
- .zddc editor (preview-yaml.js): fetch /.api/zddc-schema once and attach the
schemaProvider on .zddc files — nested-key completion at every level, enum
values (default_tool, dir_tool, views.*.tool), booleans, and hover docs.
Plain .yaml stays lint+highlight only.
- Front-matter editor (preview-markdown.js): refactored to delegate to the
shared helper via flatProvider (excluding the filename-driven identity keys);
the bespoke frontMatterHints is gone — one implementation now.
- Hover-doc tooltip styling.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1148 lines
55 KiB
JavaScript
1148 lines
55 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
|
|
|
|
var util = window.app.modules.util;
|
|
var escapeHtml = util.escapeHtml;
|
|
var hashContent = util.hashContent;
|
|
|
|
var currentInstance = null; // { editor, container, dirty, node, hash, tocEl, fmEl }
|
|
var lastSidebarWidth = SIDEBAR_DEFAULT_WIDTH; // remember across mounts
|
|
var lastFmHeight = FM_DEFAULT_HEIGHT;
|
|
|
|
function dispose() {
|
|
if (currentInstance) {
|
|
// Tear down the document-level resizer drag listeners (added
|
|
// lazily on mousedown). They're normally removed on mouseup,
|
|
// but a dispose mid-drag — or any switch away — would otherwise
|
|
// strand them pointing at the dead shell. The AbortController
|
|
// removes whatever is still attached in one call.
|
|
if (currentInstance.ac) {
|
|
try { currentInstance.ac.abort(); } catch (_) { /* ignore */ }
|
|
}
|
|
if (currentInstance.editor) {
|
|
try { currentInstance.editor.destroy(); } catch (_) { /* ignore */ }
|
|
}
|
|
}
|
|
currentInstance = null;
|
|
}
|
|
|
|
function isDirty() {
|
|
return !!(currentInstance && currentInstance.dirty);
|
|
}
|
|
|
|
function currentNode() {
|
|
return currentInstance ? currentInstance.node : null;
|
|
}
|
|
|
|
// ── Front matter ────────────────────────────────────────────────────────
|
|
// Cached recognised-front-matter placeholder, fetched once from the server
|
|
// (/.api/frontmatter — the single source of truth that mirrors the
|
|
// converter's RecognizedFrontMatter). null = not yet fetched; '' = fetched
|
|
// empty / unavailable. The promise dedupes concurrent fetches.
|
|
var fmPlaceholder = null;
|
|
var fmPlaceholderPromise = null;
|
|
// Recognised fields ([{name, hint, values}]) from the same /.api/frontmatter
|
|
// fetch — drives schema completion (keys + enum values). null = not loaded.
|
|
var fmFields = null;
|
|
|
|
// applyFrontMatterHint populates a greyed caption (+ tooltip) with the
|
|
// server's recognised front-matter fields, in server mode only. Async +
|
|
// best-effort: a failed fetch leaves the caption hidden, never an error.
|
|
// (Replaces the old textarea placeholder — CodeMirror 5 has no built-in
|
|
// placeholder without an unvendored add-on. Arbitrary keys stay free.)
|
|
function applyFrontMatterHint(el) {
|
|
var st = window.app && window.app.state;
|
|
if (!st || st.source !== 'server') return;
|
|
function paint() {
|
|
if (!el.isConnected) return; // user switched files before resolve
|
|
if (!fmPlaceholder) { el.style.display = 'none'; return; }
|
|
el.textContent = 'ⓘ Recognised front-matter keys (hover) — any other key is allowed';
|
|
el.title = fmPlaceholder;
|
|
el.style.display = '';
|
|
}
|
|
if (fmPlaceholder !== null) { paint(); return; }
|
|
if (!fmPlaceholderPromise) {
|
|
fmPlaceholderPromise = fetch('/.api/frontmatter', {
|
|
headers: { 'Accept': 'application/json' },
|
|
credentials: 'same-origin'
|
|
}).then(function (r) { return r.ok ? r.json() : null; })
|
|
.then(function (j) {
|
|
fmPlaceholder = (j && j.placeholder) || '';
|
|
fmFields = (j && j.fields) || [];
|
|
})
|
|
.catch(function () { fmPlaceholder = ''; fmFields = []; });
|
|
}
|
|
fmPlaceholderPromise.then(paint);
|
|
}
|
|
|
|
// 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 || '');
|
|
}
|
|
|
|
// ── ZDDC identity (filename is the single source of truth) ──────────────
|
|
// The four identity fields live in the filename. They're mirrored into the
|
|
// front matter so the converter (which reads FM) sees them, but the
|
|
// filename always wins: on open we sync FM ← filename, and editing one in
|
|
// the FM is treated as a cue to RENAME the file (see renderIdentityCue),
|
|
// never a silent value change. Maps the FM key ↔ the parseFilename field.
|
|
var IDENTITY_FIELDS = [
|
|
{ fm: 'title', fn: 'title', label: 'title' },
|
|
{ fm: 'tracking_number', fn: 'trackingNumber', label: 'tracking number' },
|
|
{ fm: 'revision', fn: 'revision', label: 'revision' },
|
|
{ fm: 'status', fn: 'status', label: 'status' }
|
|
];
|
|
|
|
// parseFilename → {title, tracking_number, revision, status} (non-empty
|
|
// fields only), or null when the name isn't a conventional ZDDC filename
|
|
// (no canonical identity — the editor stays fully usable on arbitrary
|
|
// directories, where FM is the sole source).
|
|
function filenameIdentity(filename) {
|
|
var z = window.zddc;
|
|
var fn = (z && z.parseFilename) ? z.parseFilename(filename) : null;
|
|
if (!fn || !fn.trackingNumber) return null;
|
|
var out = {};
|
|
IDENTITY_FIELDS.forEach(function (f) {
|
|
var v = fn[f.fn];
|
|
if (v != null && String(v).trim() !== '') out[f.fm] = v;
|
|
});
|
|
return out;
|
|
}
|
|
|
|
// ── 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 ────────────────────────────────────────────────────────────────
|
|
|
|
function saveContent(node, content, opts) {
|
|
return util.saveFile(node, content, 'text/markdown; charset=utf-8', opts);
|
|
}
|
|
|
|
var isZipMemberNode = util.isZipMemberNode;
|
|
var isEditableZipMember = util.isEditableZipMember;
|
|
|
|
function canSave(node) {
|
|
// A .zddc.zip bundle member is saveable iff editable (elevated admin) —
|
|
// the server's ServeZipWrite is the gate; other zip members read-only.
|
|
if (isZipMemberNode(node)) return isEditableZipMember(node);
|
|
// Server-computed authority gate. The listing's verbs string
|
|
// tells us whether a PUT to this entry would be allowed —
|
|
// false here means the file API would 403, so we mount in
|
|
// read-only mode rather than letting the user type and lose
|
|
// changes. cap.has() falls back to node.writable for 'w'
|
|
// when verbs is absent (offline FS-API listings).
|
|
if (node.url && window.app.state.source === 'server'
|
|
&& window.zddc.cap && !window.zddc.cap.has(node, 'w')) 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 + the server version token (etag/last-modified) so
|
|
// the save can send an If-Match precondition and detect a concurrent
|
|
// edit instead of clobbering it. Falls back to getArrayBuffer (and a
|
|
// null token → no precondition) for callers/sources without it.
|
|
var text, loadedEtag = null, loadedLastModified = null;
|
|
try {
|
|
if (ctx.getContentWithVersion) {
|
|
var loaded = await ctx.getContentWithVersion(node);
|
|
text = new TextDecoder('utf-8', { fatal: false }).decode(loaded.buf);
|
|
loadedEtag = loaded.etag;
|
|
loadedLastModified = loaded.lastModified;
|
|
} else {
|
|
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 = 'minmax(0, ' + lastSidebarWidth + 'px) minmax(0, 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';
|
|
// CodeMirror YAML editor host — mounted with the front-matter value
|
|
// once it's computed (sync-on-open) below. Same editor family as the
|
|
// .zddc previewer: syntax highlighting, line numbers, lint gutter.
|
|
var fmEditorHost = document.createElement('div');
|
|
fmEditorHost.className = 'md-fm__editor';
|
|
fmBody.appendChild(fmEditorHost);
|
|
// Recognised-keys hint (server mode): a greyed caption under the header
|
|
// whose tooltip carries the full "key: # hint" template from
|
|
// /.api/frontmatter. Replaces the old textarea placeholder.
|
|
var fmHint = document.createElement('div');
|
|
fmHint.className = 'md-fm__hint';
|
|
fmHint.style.display = 'none';
|
|
applyFrontMatterHint(fmHint);
|
|
// Rename cue: shown when the author edits an identity field
|
|
// (tracking_number / revision / status / title) away from the
|
|
// filename. The filename owns identity, so the cue offers an explicit
|
|
// "Rename file & reopen" button rather than silently keeping or
|
|
// discarding the value. Populated by renderIdentityCue().
|
|
var fmWarn = document.createElement('div');
|
|
fmWarn.className = 'md-fm__warn';
|
|
// Visibility is controlled via style.display (toggled in
|
|
// renderIdentityCue), NOT the `hidden` attribute: an inline
|
|
// display:flex outranks [hidden]{display:none}, which would leave an
|
|
// empty box on screen whenever the cue has nothing to say.
|
|
fmWarn.style.cssText = 'color:#92400e;background:#fffbeb;border:1px solid '
|
|
+ '#fcd34d;border-radius:4px;padding:6px 8px;margin:0 0 4px;font-size:'
|
|
+ '0.78rem;line-height:1.5;flex-wrap:wrap;align-items:center;gap:6px;'
|
|
+ 'display:none;';
|
|
fmSection.appendChild(fmHeader);
|
|
fmSection.appendChild(fmHint);
|
|
fmSection.appendChild(fmWarn);
|
|
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 = isEditableZipMember(node) ? 'config bundle' : '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.
|
|
//
|
|
// Format set + URL come from the download module's canonical
|
|
// conversion matrix (download.exportTargets / convertUrl) — the
|
|
// SAME source of truth the Export context-menu uses, so the
|
|
// editor's buttons and the menu never offer different formats.
|
|
var dl = window.app.modules.download;
|
|
var mdTargets = (dl && dl.exportTargets) ? dl.exportTargets('md') : ['docx', 'html', 'pdf'];
|
|
mdTargets.forEach(function (fmt) {
|
|
var a = document.createElement('a');
|
|
a.className = 'btn btn-sm btn-secondary md-shell__download';
|
|
a.href = (dl && dl.convertUrl) ? dl.convertUrl(node.url, fmt)
|
|
: node.url.replace(/\.md$/i, '') + '.' + 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);
|
|
var bodyText = initialParsed.body;
|
|
// On open, RECONCILE existing front-matter identity keys with the
|
|
// filename (the single source of truth) — but never ADD them. A blank
|
|
// or new file opens blank (we don't inject a title etc.); a file whose
|
|
// author already wrote a now-stale title/revision/… gets corrected.
|
|
// The converter derives identity from the filename regardless, so
|
|
// there's nothing to "bake in" for an empty front matter. The dirty
|
|
// baseline stays the ON-DISK state, so a correction opens the buffer
|
|
// dirty and a save persists it.
|
|
var onDiskFM = stringifyFrontMatter(initialParsed.data);
|
|
var fid = filenameIdentity(node.name);
|
|
if (fid) {
|
|
for (var ik in fid) {
|
|
if (Object.prototype.hasOwnProperty.call(fid, ik)
|
|
&& Object.prototype.hasOwnProperty.call(initialParsed.data, ik)) {
|
|
initialParsed.data[ik] = fid[ik];
|
|
}
|
|
}
|
|
}
|
|
var syncedFM = stringifyFrontMatter(initialParsed.data);
|
|
var initialHash = await hashContent(assembleContent(onDiskFM, bodyText));
|
|
var writableMode = canSave(node);
|
|
|
|
// Front-matter YAML editor — CodeMirror, the same editor family as the
|
|
// .zddc previewer (syntax highlighting, line numbers, shared js-yaml
|
|
// lint gutter). Replaces the old <textarea>.
|
|
var fmCM = window.CodeMirror(fmEditorHost, {
|
|
value: syncedFM,
|
|
mode: 'yaml',
|
|
lineNumbers: true,
|
|
tabSize: 2,
|
|
indentUnit: 2,
|
|
indentWithTabs: false,
|
|
lineWrapping: true,
|
|
gutters: ['CodeMirror-lint-markers', 'CodeMirror-linenumbers'],
|
|
lint: { hasGutters: true },
|
|
autofocus: false,
|
|
readOnly: !writableMode
|
|
});
|
|
// The yaml lint helper (registered by the .zddc previewer) checks this
|
|
// to decide the schema layer; a .md node → plain js-yaml parse lint.
|
|
fmCM._zddcNode = node;
|
|
// Schema completion + hover docs via the shared helper. The front
|
|
// matter is flat; the identity keys are excluded from suggestions
|
|
// (filename-driven — see renderIdentityCue).
|
|
var yc = window.app.modules.yamlComplete;
|
|
if (yc) {
|
|
yc.attach(fmCM, yc.flatProvider(function () { return fmFields; }, {
|
|
exclude: IDENTITY_FIELDS.map(function (f) { return f.fm; })
|
|
}), { readOnly: !writableMode });
|
|
}
|
|
// CodeMirror mis-measures when mounted before its pane is laid out;
|
|
// refresh on the next frame so the gutters + scroll size correctly.
|
|
requestAnimationFrame(function () { fmCM.refresh(); });
|
|
// 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']
|
|
]
|
|
}));
|
|
}
|
|
|
|
// One AbortController per mount — wired into the document-level
|
|
// resizer listeners below so dispose() can detach them all at once.
|
|
var ac = new AbortController();
|
|
var instance = {
|
|
editor: editor,
|
|
container: container,
|
|
dirty: false,
|
|
node: node,
|
|
hash: initialHash,
|
|
tocEl: tocBody,
|
|
fmEl: fmCM,
|
|
ac: ac,
|
|
// Server version token captured at load — sent as If-Match on
|
|
// save and refreshed from each successful PUT's response ETag.
|
|
etag: loadedEtag,
|
|
lastModified: loadedLastModified
|
|
};
|
|
currentInstance = instance;
|
|
|
|
if (!writableMode) {
|
|
saveBtn.disabled = true;
|
|
saveBtn.title = 'Save not available — read-only source.';
|
|
// fmCM was created with readOnly:!writableMode — nothing more here.
|
|
}
|
|
|
|
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 = 'minmax(0, ' + w + 'px) minmax(0, 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, { signal: ac.signal });
|
|
document.addEventListener('mouseup', onUp, { signal: ac.signal });
|
|
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 = 'minmax(0, ' + w + 'px) minmax(0, 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, { signal: ac.signal });
|
|
document.addEventListener('mouseup', onUp, { signal: ac.signal });
|
|
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) {
|
|
if (currentInstance !== instance) return; // editor replaced
|
|
instance.dirty = isDirty;
|
|
// Re-read canSave at every transition, not via a closure-captured
|
|
// value, so the gate reflects current write authority — see the
|
|
// matching pattern in preview-yaml.js.
|
|
saveBtn.disabled = !isDirty || !canSave(node);
|
|
dirtyEl.textContent = isDirty ? '● modified' : '';
|
|
}
|
|
|
|
// The debounced handlers can resolve AFTER this editor was disposed
|
|
// and a new file mounted (the timer + the await both outlive the
|
|
// switch). Bail when we're no longer the active instance so we never
|
|
// call into a destroyed Toast UI editor or write the wrong file's
|
|
// dirty/hash state.
|
|
var onChange = debounce(async function () {
|
|
if (currentInstance !== instance) return;
|
|
var body = editor.getMarkdown();
|
|
var h = await hashContent(assembleContent(fmCM.getValue(), body));
|
|
if (currentInstance !== instance) return;
|
|
markDirty(h !== instance.hash);
|
|
renderToc(tocBody, body, editor);
|
|
}, 250);
|
|
editor.on('change', onChange);
|
|
|
|
// Build the filename the current FM identity edits imply: the parsed
|
|
// filename parts, overlaid with any non-empty identity values the
|
|
// author typed in the FM. "" when the name isn't ZDDC-conventional or
|
|
// the result wouldn't be a valid ZDDC filename.
|
|
function renamedFilenameFromEdits(data) {
|
|
var z = window.zddc;
|
|
var fn = (z && z.parseFilename) ? z.parseFilename(node.name) : null;
|
|
if (!fn || !fn.valid || !z.formatFilename) return '';
|
|
var parts = {
|
|
trackingNumber: fn.trackingNumber,
|
|
revision: fn.revision,
|
|
status: fn.status,
|
|
title: fn.title,
|
|
extension: fn.extension
|
|
};
|
|
IDENTITY_FIELDS.forEach(function (f) {
|
|
if (!(f.fm in data)) return;
|
|
var v = String(data[f.fm] == null ? '' : data[f.fm]).trim();
|
|
if (v !== '') parts[f.fn] = v;
|
|
});
|
|
try { return z.formatFilename(parts); } catch (_e) { return ''; }
|
|
}
|
|
|
|
// The filename owns identity, so a manual edit to an identity field is
|
|
// a cue to RENAME the file, not a value to keep. When the FM identity
|
|
// differs from the filename, surface that + an explicit "Rename file &
|
|
// reopen" button. No-op for non-ZDDC names (no canonical identity).
|
|
function renderIdentityCue() {
|
|
while (fmWarn.firstChild) fmWarn.removeChild(fmWarn.firstChild);
|
|
var fid = filenameIdentity(node.name);
|
|
if (!fid || !canSave(node)) { fmWarn.style.display = 'none'; return; }
|
|
var data = parseFrontMatter('---\n' + fmCM.getValue() + '\n---\n').data || {};
|
|
var edits = [];
|
|
IDENTITY_FIELDS.forEach(function (f) {
|
|
if (!(f.fm in data)) return;
|
|
var got = String(data[f.fm] == null ? '' : data[f.fm]).trim();
|
|
var want = String(fid[f.fm] == null ? '' : fid[f.fm]).trim();
|
|
if (got !== '' && got !== want) edits.push(f.label + ' → “' + got + '”');
|
|
});
|
|
if (!edits.length) { fmWarn.style.display = 'none'; return; }
|
|
var msg = document.createElement('span');
|
|
msg.textContent = '✎ Identity comes from the filename. You changed '
|
|
+ edits.join(', ') + '. ';
|
|
fmWarn.appendChild(msg);
|
|
var newName = renamedFilenameFromEdits(data);
|
|
if (newName && newName !== node.name) {
|
|
var btn = document.createElement('button');
|
|
btn.type = 'button';
|
|
btn.className = 'btn btn-sm md-fm__rename';
|
|
btn.textContent = 'Rename file & reopen';
|
|
btn.title = 'Save, rename to “' + newName + '”, and reopen it for editing.';
|
|
btn.addEventListener('click', function () { renameToMatch(newName); });
|
|
fmWarn.appendChild(btn);
|
|
}
|
|
// Cancel: discard the identity edits, restoring the filename values.
|
|
var cancelBtn = document.createElement('button');
|
|
cancelBtn.type = 'button';
|
|
cancelBtn.className = 'btn btn-sm md-fm__revert';
|
|
cancelBtn.textContent = 'Cancel';
|
|
cancelBtn.title = 'Discard these identity edits and restore the filename values.';
|
|
cancelBtn.addEventListener('click', function () { revertIdentityEdits(); });
|
|
fmWarn.appendChild(cancelBtn);
|
|
fmWarn.style.display = 'flex';
|
|
}
|
|
|
|
// Revert the identity fields in the front matter to the filename-
|
|
// derived values (undo a manual identity edit), leaving the rest of the
|
|
// front matter + body untouched. Recomputes dirty + the cue after.
|
|
async function revertIdentityEdits() {
|
|
var fid = filenameIdentity(node.name);
|
|
if (!fid) return;
|
|
var data = parseFrontMatter('---\n' + fmCM.getValue() + '\n---\n').data || {};
|
|
for (var k in fid) {
|
|
if (Object.prototype.hasOwnProperty.call(fid, k)
|
|
&& Object.prototype.hasOwnProperty.call(data, k)) {
|
|
data[k] = fid[k];
|
|
}
|
|
}
|
|
fmCM.setValue(stringifyFrontMatter(data));
|
|
var body = editor.getMarkdown();
|
|
var h = await hashContent(assembleContent(fmCM.getValue(), body));
|
|
if (currentInstance !== instance) return;
|
|
markDirty(h !== instance.hash);
|
|
renderIdentityCue();
|
|
}
|
|
|
|
// Rename action: persist the current buffer (so body edits aren't
|
|
// lost), rename the file to match the edited identity, then reopen the
|
|
// new name fresh. Server mode reopens via the ?file deep-link (reuses
|
|
// the tested open-by-path walker); FS-Access mode reuses the moved
|
|
// handle in place.
|
|
async function renameToMatch(newName) {
|
|
var up = window.app.modules.upload;
|
|
if (!up || !up.renameNode || !newName) return;
|
|
// 1. Persist the current buffer first so body edits survive the
|
|
// rename. Force the write (no If-Match) — the user deliberately
|
|
// initiated this rename, so we commit their version rather than
|
|
// interrupting with the conflict-resolution modal (which save()
|
|
// raises on a 412). The identity edit that triggered the rename
|
|
// is consumed by the new filename, so there's nothing to merge.
|
|
if (instance.dirty && canSave(node)) {
|
|
try {
|
|
var content = assembleContent(fmCM.getValue(), editor.getMarkdown());
|
|
statusEl.textContent = 'Saving…';
|
|
var res = await saveContent(node, content, { force: true });
|
|
await markSaved(content, res);
|
|
if (currentInstance !== instance) return;
|
|
} catch (e) {
|
|
statusEl.textContent = '';
|
|
if (window.zddc && window.zddc.toast) {
|
|
window.zddc.toast('Save failed: ' + (e.message || e), 'error');
|
|
}
|
|
return;
|
|
}
|
|
}
|
|
// 2. Rename on disk.
|
|
try {
|
|
statusEl.textContent = 'Renaming…';
|
|
await up.renameNode(node, newName);
|
|
} catch (e) {
|
|
statusEl.textContent = '';
|
|
if (window.zddc && window.zddc.toast) {
|
|
window.zddc.toast('Rename failed: ' + (e.message || e), 'error');
|
|
}
|
|
return;
|
|
}
|
|
// Drop the dirty guard so the reopen/navigation isn't blocked.
|
|
markDirty(false); instance.dirty = false;
|
|
// 3. Reopen the renamed file.
|
|
if (window.app.state.source === 'server') {
|
|
var tree = window.app.modules.tree;
|
|
var scope = (window.app.state.currentPath || '/').replace(/\/$/, '') + '/';
|
|
var oldPath = tree ? tree.pathFor(node) : '';
|
|
var dir = oldPath.slice(0, oldPath.lastIndexOf('/') + 1);
|
|
var newPath = dir + newName;
|
|
var rel = newPath.indexOf(scope) === 0 ? newPath.slice(scope.length) : newName;
|
|
var params = new URLSearchParams();
|
|
params.set('file', rel);
|
|
if (window.app.state.showHidden) params.set('hidden', '1');
|
|
window.location.assign(scope + '?' + params.toString());
|
|
return;
|
|
}
|
|
// FS-Access: the handle was renamed in place. Update the node,
|
|
// refresh the tree, reopen the editor on it.
|
|
node.name = newName;
|
|
var ev = window.app.modules.events;
|
|
if (ev && ev.refreshListing) { try { await ev.refreshListing(); } catch (_e) { /* swallow */ } }
|
|
var prev = window.app.modules.preview;
|
|
if (prev && prev.showFilePreview) prev.showFilePreview(node);
|
|
}
|
|
|
|
var onFmChange = debounce(async function () {
|
|
if (currentInstance !== instance) return;
|
|
var body = editor.getMarkdown();
|
|
var h = await hashContent(assembleContent(fmCM.getValue(), body));
|
|
if (currentInstance !== instance) return;
|
|
markDirty(h !== instance.hash);
|
|
renderIdentityCue();
|
|
}, 250);
|
|
fmCM.on('change', onFmChange);
|
|
renderIdentityCue(); // initial state on load (clean after sync-on-open)
|
|
|
|
// If sync-on-open corrected the front matter, open the buffer dirty so
|
|
// a save bakes the filename-derived identity in — and say so, since the
|
|
// change is otherwise silent (the values just match the filename now).
|
|
if (writableMode && fmCM.getValue() !== onDiskFM) {
|
|
markDirty(true);
|
|
statusEl.textContent = 'Front matter synced to filename — review and save';
|
|
}
|
|
|
|
// ── Save ───────────────────────────────────────────────────────────
|
|
// Mark a successful write: adopt the new server ETag (so the next
|
|
// save's If-Match matches — no false conflict on save→edit→save),
|
|
// refresh the dirty baseline, clear dirty.
|
|
async function markSaved(content, res) {
|
|
if (currentInstance !== instance) return;
|
|
if (res && res.etag) instance.etag = res.etag;
|
|
instance.hash = await hashContent(content);
|
|
if (currentInstance !== instance) return;
|
|
markDirty(false);
|
|
statusEl.textContent = 'Saved ' + new Date().toLocaleTimeString();
|
|
if (window.zddc && window.zddc.toast) {
|
|
window.zddc.toast('Saved ' + node.name, 'success');
|
|
}
|
|
}
|
|
|
|
// 412 → the file changed on the server since we loaded it. Open the
|
|
// shared conflict dialog rather than clobbering. Dirty stays set
|
|
// until the user resolves.
|
|
async function resolveConflict(content) {
|
|
var conflict = window.app.modules.conflict;
|
|
var prev = window.app.modules.preview;
|
|
if (!conflict || !prev) return; // no UI available — leave dirty
|
|
await conflict.open({
|
|
filename: node.name,
|
|
mineText: content,
|
|
fetchTheirs: function () {
|
|
return prev.getContentWithVersion(node).then(function (r) {
|
|
return new TextDecoder('utf-8', { fatal: false }).decode(r.buf);
|
|
});
|
|
},
|
|
// Overwrite: re-fetch the CURRENT version and save against it
|
|
// (still 412s on a third concurrent writer rather than blind-
|
|
// forcing).
|
|
onOverwrite: function () {
|
|
return prev.getContentWithVersion(node).then(function (cur) {
|
|
return saveContent(node, content, { etag: cur.etag, lastModified: cur.lastModified });
|
|
}).then(function (res) { return markSaved(content, res); });
|
|
},
|
|
// Reload theirs: discard local edits. Clear dirty first so the
|
|
// renderInline dirty-guard skips its confirm; the fresh render
|
|
// re-captures content + a new ETag.
|
|
onReload: function () {
|
|
markDirty(false);
|
|
instance.dirty = false;
|
|
return prev.showFilePreview(node);
|
|
},
|
|
onSaveCopy: function () {
|
|
return util.saveCopy(node, content, 'text/markdown; charset=utf-8')
|
|
.then(function (name) {
|
|
if (window.zddc && window.zddc.toast) {
|
|
window.zddc.toast('Saved your version as ' + name, 'success');
|
|
}
|
|
});
|
|
}
|
|
});
|
|
if (currentInstance === instance) statusEl.textContent = '';
|
|
}
|
|
|
|
async function save() {
|
|
if (currentInstance !== instance) return;
|
|
if (!instance.dirty || !canSave(node)) return;
|
|
var content = assembleContent(fmCM.getValue(), editor.getMarkdown());
|
|
try {
|
|
statusEl.textContent = 'Saving…';
|
|
var res = await saveContent(node, content, {
|
|
etag: instance.etag, lastModified: instance.lastModified
|
|
});
|
|
await markSaved(content, res);
|
|
} catch (e) {
|
|
if (e && e.status === 412) {
|
|
if (currentInstance !== instance) return;
|
|
statusEl.textContent = 'Conflict — resolving…';
|
|
await resolveConflict(content);
|
|
return;
|
|
}
|
|
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 (!instance.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 (!canSave(node)) {
|
|
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 !== instance || instance.dirty) return; // save failed / switched away
|
|
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,
|
|
isDirty: isDirty,
|
|
currentNode: currentNode
|
|
};
|
|
})();
|