Completes the markdown plugin's deferred v2 items:
1. TOC pane
A third pane to the right of the Toast UI editor lists every heading
in the current document, hierarchically indented by level. Click an
item → editor scrolls to that heading (markdown-mode uses
setSelection + preview scroll; WYSIWYG mode uses DOM text matching;
the target heading flashes briefly via primary-light background).
The TOC re-renders on every editor change (debounced 250ms) so it
stays in sync with edits.
Heading parser supports ATX-style `^#{1,6}\s+` lines, strips inline
markdown emphasis/code/links/strike from the displayed label.
Empty file → "Empty file." Headingless file → "No headings."
2. FS-API writes
Saves now route to whichever source the file came from:
- node.handle + createWritable available → FileSystemWritableFileStream
(local folder picker). The user's chosen file gets overwritten
via the browser's File System Access API.
- node.url + server source → PUT to the server URL (as before).
- zip-virtual file → save disabled (no writable stream from JSZip).
- Anything else → save disabled with a tooltip.
Save status surfaces via the existing toolbar (`Saved 10:42:18`) AND
a shared toast notification ("Saved readme.md" / "Save failed: …")
so the success/failure is visible regardless of whether the user is
looking at the toolbar.
Source-hint chip on the toolbar shows "local" / "server" /
"read-only (inside zip)" so the user knows which write path is
active before they make changes.
CSS additions in browse/css/tree.css for .md-toolbar, .md-split,
.md-editor-host, .md-toc-pane, .toc-list, and the .toc-level-1..6
indentation rules.
A new Playwright test exercises the markdown plugin end-to-end:
mounts the editor on a .md click, asserts the three DOM regions are
visible, verifies the TOC contains the three expected headings from
the test fixture's markdown content, and confirms the source hint
reads "local" for FS-API mode.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
362 lines
14 KiB
JavaScript
362 lines
14 KiB
JavaScript
// preview-markdown.js — markdown plugin for the browse preview pane.
|
|
// Click a .md / .markdown file in the tree → instantiate Toast UI
|
|
// editor inside the right pane, alongside a TOC pane on the right.
|
|
// Save (Ctrl+S) writes back via:
|
|
// - PUT to the file's server URL when in server mode, or
|
|
// - FileSystemWritableFileStream when in FS-API mode (local folder
|
|
// picker). Both paths set dirty=false + a status timestamp on
|
|
// success.
|
|
// zip-virtual files are read-only — the save button stays disabled.
|
|
//
|
|
// Toast UI Editor is bundled (shared/vendor/toastui-editor-all.min.js)
|
|
// and is available synchronously as window.toastui by the time this
|
|
// module runs.
|
|
(function () {
|
|
'use strict';
|
|
|
|
if (!window.app || !window.app.modules) return;
|
|
|
|
function escapeHtml(s) {
|
|
return String(s).replace(/&/g, '&').replace(/</g, '<')
|
|
.replace(/>/g, '>').replace(/"/g, '"');
|
|
}
|
|
|
|
var currentInstance = null; // { editor, container, dirty, node, hash, tocEl }
|
|
|
|
// Compute SHA-256 hex of a string for a "is this content different
|
|
// from what we loaded?" check. Used to enable/disable Save.
|
|
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;
|
|
}
|
|
|
|
// ── TOC (table of contents) ─────────────────────────────────────────────
|
|
//
|
|
// Ported from mdedit/js/toc.js, condensed: parse markdown for ATX-style
|
|
// headings, build a flat hierarchical list, click jumps the editor to
|
|
// the heading's line. We track WYSIWYG vs markdown mode and route the
|
|
// scroll behaviour to whichever pane is visible.
|
|
|
|
function parseHeadings(content) {
|
|
var headings = [];
|
|
var lines = content.split('\n');
|
|
for (var i = 0; i < lines.length; i++) {
|
|
var m = lines[i].match(/^(#{1,6})\s+(.+)$/);
|
|
if (!m) continue;
|
|
var text = m[2].trim()
|
|
.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 top = hs[i].getBoundingClientRect().top - ww.getBoundingClientRect().top;
|
|
ww.scrollTop = top - 10;
|
|
flashHeading(hs[i]);
|
|
return;
|
|
}
|
|
}
|
|
} else {
|
|
var line = heading.lineIndex + 1;
|
|
try { editor.setSelection([line, 1], [line, 1]); } catch (_) { /* ignore */ }
|
|
var preview = els.mdPreview;
|
|
if (!preview) return;
|
|
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 ptop = phs[j].getBoundingClientRect().top - preview.getBoundingClientRect().top;
|
|
preview.scrollTop = ptop - 10;
|
|
flashHeading(phs[j]);
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
} catch (e) { /* swallow; click was best-effort */ }
|
|
}
|
|
|
|
function flashHeading(el) {
|
|
if (!el) return;
|
|
el.style.transition = 'background-color 0.3s ease';
|
|
el.style.backgroundColor = 'var(--primary-light)';
|
|
setTimeout(function () {
|
|
el.style.backgroundColor = '';
|
|
setTimeout(function () { el.style.transition = ''; }, 300);
|
|
}, 1200);
|
|
}
|
|
|
|
function renderToc(tocEl, content, editor) {
|
|
if (!tocEl) return;
|
|
var headings = parseHeadings(content);
|
|
if (!content.trim()) {
|
|
tocEl.innerHTML = '<p class="toc-empty">Empty file.</p>';
|
|
return;
|
|
}
|
|
if (headings.length === 0) {
|
|
tocEl.innerHTML = '<p class="toc-empty">No headings.</p>';
|
|
return;
|
|
}
|
|
// Build a flat ordered list; CSS handles the visual indent.
|
|
var html = '<ul class="toc-list">';
|
|
for (var i = 0; i < headings.length; i++) {
|
|
var h = headings[i];
|
|
html += '<li class="toc-item toc-level-' + h.level + '" data-line="' + h.lineIndex + '" data-text="' + escapeHtml(h.text) + '">'
|
|
+ '<a href="#" tabindex="0">' + escapeHtml(h.text) + '</a></li>';
|
|
}
|
|
html += '</ul>';
|
|
tocEl.innerHTML = html;
|
|
|
|
// One delegated click handler.
|
|
tocEl.querySelectorAll('.toc-item').forEach(function (li) {
|
|
li.addEventListener('click', function (e) {
|
|
e.preventDefault();
|
|
var idx = parseInt(li.dataset.line, 10);
|
|
var text = li.dataset.text;
|
|
scrollEditorToHeading(editor, { text: text, lineIndex: idx });
|
|
});
|
|
});
|
|
}
|
|
|
|
// Light debounce so TOC doesn't rebuild on every keystroke.
|
|
function debounce(fn, ms) {
|
|
var t;
|
|
return function () {
|
|
clearTimeout(t);
|
|
var args = arguments, self = this;
|
|
t = setTimeout(function () { fn.apply(self, args); }, ms);
|
|
};
|
|
}
|
|
|
|
// ── Save (server + FS-API) ──────────────────────────────────────────────
|
|
|
|
async function saveContent(node, content) {
|
|
// FS-API mode: write via the local file handle.
|
|
if (node.handle && typeof node.handle.createWritable === 'function') {
|
|
var writable = await node.handle.createWritable();
|
|
await writable.write(content);
|
|
await writable.close();
|
|
return;
|
|
}
|
|
// Server mode: PUT the new bytes.
|
|
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;
|
|
}
|
|
|
|
// Build the markdown plugin's DOM:
|
|
// ┌──────────────────────────────────────────────────┐
|
|
// │ toolbar (Save, ● modified, status, source hint) │
|
|
// ├──────────────────────────────────┬───────────────┤
|
|
// │ editor (Toast UI) │ TOC pane │
|
|
// └──────────────────────────────────┴───────────────┘
|
|
container.innerHTML = '';
|
|
container.style.display = 'flex';
|
|
container.style.flexDirection = 'column';
|
|
|
|
var toolbar = document.createElement('div');
|
|
toolbar.className = 'md-toolbar';
|
|
|
|
var saveBtn = document.createElement('button');
|
|
saveBtn.className = 'btn btn-sm btn-primary';
|
|
saveBtn.type = 'button';
|
|
saveBtn.textContent = 'Save';
|
|
saveBtn.disabled = true;
|
|
|
|
var dirty = document.createElement('span');
|
|
dirty.className = 'md-toolbar__dirty';
|
|
|
|
var status = document.createElement('span');
|
|
status.className = 'md-toolbar__status';
|
|
|
|
var sourceHint = document.createElement('span');
|
|
sourceHint.className = 'md-toolbar__source';
|
|
if (node.zipParentId != null) {
|
|
sourceHint.textContent = 'read-only (inside zip)';
|
|
} else if (node.handle) {
|
|
sourceHint.textContent = 'local';
|
|
} else if (node.url) {
|
|
sourceHint.textContent = 'server';
|
|
}
|
|
|
|
toolbar.appendChild(saveBtn);
|
|
toolbar.appendChild(dirty);
|
|
toolbar.appendChild(status);
|
|
toolbar.appendChild(sourceHint);
|
|
container.appendChild(toolbar);
|
|
|
|
var split = document.createElement('div');
|
|
split.className = 'md-split';
|
|
container.appendChild(split);
|
|
|
|
var editorHost = document.createElement('div');
|
|
editorHost.className = 'md-editor-host';
|
|
split.appendChild(editorHost);
|
|
|
|
var tocPane = document.createElement('div');
|
|
tocPane.className = 'md-toc-pane';
|
|
var tocHeader = document.createElement('div');
|
|
tocHeader.className = 'md-toc-pane__header';
|
|
tocHeader.textContent = 'Outline';
|
|
var tocBody = document.createElement('div');
|
|
tocBody.className = 'md-toc-pane__body';
|
|
tocBody.innerHTML = '<p class="toc-empty">Loading…</p>';
|
|
tocPane.appendChild(tocHeader);
|
|
tocPane.appendChild(tocBody);
|
|
split.appendChild(tocPane);
|
|
|
|
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
|
|
};
|
|
|
|
var writable = canSave(node);
|
|
if (!writable) {
|
|
saveBtn.disabled = true;
|
|
saveBtn.title = 'Save not available — read-only source.';
|
|
}
|
|
|
|
renderToc(tocBody, text, editor);
|
|
|
|
function markDirty(isDirty) {
|
|
currentInstance.dirty = isDirty;
|
|
saveBtn.disabled = !isDirty || !writable;
|
|
dirty.textContent = isDirty ? '● modified' : '';
|
|
}
|
|
|
|
var updateOnChange = debounce(async function () {
|
|
var current = editor.getMarkdown();
|
|
var h = await hashContent(current);
|
|
markDirty(h !== currentInstance.hash);
|
|
renderToc(tocBody, current, editor);
|
|
}, 250);
|
|
|
|
editor.on('change', updateOnChange);
|
|
|
|
async function save() {
|
|
if (!currentInstance.dirty || !writable) return;
|
|
var content = editor.getMarkdown();
|
|
try {
|
|
status.textContent = 'Saving…';
|
|
await saveContent(node, content);
|
|
currentInstance.hash = await hashContent(content);
|
|
markDirty(false);
|
|
var now = new Date();
|
|
status.textContent = 'Saved ' + now.toLocaleTimeString();
|
|
if (window.zddc && window.zddc.toast) {
|
|
window.zddc.toast('Saved ' + node.name, 'success');
|
|
}
|
|
} catch (e) {
|
|
status.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);
|
|
|
|
// Ctrl+S / Cmd+S inside the editor → 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
|
|
};
|
|
})();
|