DOCX/XLSX preview: add renderDocx (docx-preview) and renderXlsx (SheetJS) to shared/preview-lib.js — the natural home alongside renderTiff/ renderZipListing, reusable by every tool. browse dispatches Office files to them in both the inline pane and the pop-out window via the existing preview.isOffice() check, and browse/build.sh now bundles the docx-preview + xlsx vendors. Renderers degrade to a friendly message if a tool doesn't bundle the vendor. Overflow fix: .md-shell used `grid-template-columns: 280px 1fr`. A bare `1fr` is `minmax(auto, 1fr)`, whose `auto` floor is the editor's min-content width (Toast UI's toolbar) — so the content track refused to shrink and the whole shell overflowed #previewBody as the window narrowed instead of the editor reflowing smaller. Switch both tracks to minmax(0, …) in the CSS and in the three JS spots that rewrite the columns on sidebar-drag, and give .md-shell__sidebar min-width: 0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
768 lines
35 KiB
JavaScript
768 lines
35 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, '&').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 };
|
|
}
|
|
|
|
// 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') {
|
|
// Local folders are picked read-only; escalate to readwrite on
|
|
// first save (one FS-Access prompt, then granted for the session).
|
|
var up = window.app.modules.upload;
|
|
if (up && up.ensureWritable) await up.ensureWritable();
|
|
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 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.
|
|
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 = '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';
|
|
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 = '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);
|
|
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 = '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);
|
|
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;
|
|
// 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' : '';
|
|
}
|
|
|
|
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 || !canSave(node)) 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 (!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.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
|
|
};
|
|
})();
|