Reshape browse from "tree-as-table with popup preview" into a unified
file-experience tool with three layered behaviors:
Phase A — Two-pane shell
Phase B — Markdown plugin (Toast UI inline)
Phase C — Grid mode (classifier workflow)
Phase D — Deprecation banners on standalone classifier + mdedit
= Phase A: two-pane shell + lightweight preview plugins =
Browse's table view becomes a tree-pane on the left + preview-pane on
the right with a draggable resizer. Click a folder → expand inline.
Click a file → render in the right pane. The previous popup window
becomes an explicit "⤴ Pop out" button in the right-pane header for
users with a second monitor.
Preview rendering reuses shared/preview-lib.js (PDF iframe, image
<img>, TIFF, ZIP listing, text <pre>). Unknown types show a download
link. browse/js/preview.js refactored into renderInline (default) +
renderInPopup (Pop out button); both share the same plugin
dispatch logic.
Filter rows were already removed earlier this session. Sort columns
likewise — the tree is alphabetical by default; the underlying
setSort API still exists for future re-introduction.
= Phase B: markdown plugin =
New browse/js/preview-markdown.js: when a .md or .markdown file is
clicked, the right pane mounts a Toast UI editor (initial-value =
file contents) with a small toolbar containing Save + dirty indicator
+ status text. Save sends PUT through the file API for server-mode
files; non-server sources are read-only for now (deferred to a
follow-up that wires zddc-source.js writes too). Ctrl+S / Cmd+S
inside the editor saves.
Toast UI Editor (~700 KB JS + ~160 KB CSS) was previously bundled
only in mdedit/vendor/. Moved to shared/vendor/ so browse and mdedit
both pull from one location.
= Phase C: grid mode =
View-mode toggle [Browse | Grid] in the toolbar. Grid mode loads the
classifier tool as an iframe scoped to the current directory (server
mode at working/staging/incoming locations) — classifier's full
bulk-rename workflow without leaving browse. v1 implementation; a
future iteration could bundle classifier's modules directly into
browse for tighter integration. Hostile cases (file:// origin, paths
outside working/staging/incoming) show a friendly explanation
instead of a blank iframe.
new browse/js/grid.js handles the activation logic.
= Phase D: deprecation banners =
mdedit and classifier standalones gain a "this tool is being absorbed
into Browse" advisory banner. Both standalones remain fully
functional and continue to ship — they're useful for offline single-
file editing and air-gapped environments. The banner just points
users toward the unified browse experience.
= Files =
+ browse/js/preview-markdown.js (markdown plugin)
+ browse/js/grid.js (grid-mode plugin)
M browse/template.html (two-pane layout, view toggle, banners)
M browse/css/tree.css (two-pane CSS, replaces table styles)
M browse/js/init.js (state additions: selectedId, viewMode)
M browse/js/tree.js (rowHtml: <tr>+<td> → <div>)
M browse/js/preview.js (renderInline / renderInPopup split)
M browse/js/events.js (toggle wiring, resizer, click handlers
adapted from <table> to <div>)
M browse/build.sh (Toast UI vendor + new modules)
R mdedit/vendor/toastui-* → shared/vendor/ (one bundle, two tools)
M mdedit/build.sh (paths)
M mdedit/template.html (deprecation banner)
M classifier/template.html (deprecation banner)
M tests/browse.spec.js (selectors updated for new layout +
new "click file → preview" test)
Bundle sizes after this commit:
browse: ~1020 KB (was ~290 KB; added Toast UI ~700 KB)
classifier: ~1470 KB (unchanged from prior baseline)
mdedit: ~2140 KB (unchanged; vendor location moved but not added)
What's deferred:
- TOC + front-matter pane in browse's markdown plugin (mdedit has
these; browse v1 uses just the editor).
- FS-API writes from browse's markdown plugin (server PUT works).
- Classifier modules bundled directly into browse (v1 uses iframe).
- Sort UI in the new tree (model still supports it; no widget yet).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
185 lines
7.3 KiB
JavaScript
185 lines
7.3 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. Save (Ctrl+S) writes back via PUT
|
|
// when the file came from a server URL; FS-API and zip-virtual files
|
|
// are read-only for now (toolbar shows a hint).
|
|
//
|
|
// Toast UI Editor is loaded from mdedit's bundled vendor file in the
|
|
// browse build (see browse/build.sh). window.toastui is available
|
|
// synchronously when 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 }
|
|
|
|
// Compute SHA-256 hex of a string for a quick "is this content
|
|
// different from what was loaded?" check. Used to decide whether
|
|
// the save button should be active. Not used for integrity.
|
|
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;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
// Tear down any previous markdown instance (single-file model).
|
|
dispose();
|
|
|
|
// Read the file 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, dirty marker) │
|
|
// ├──────────────────────────────────┤
|
|
// │ Toast UI editor │
|
|
// └──────────────────────────────────┘
|
|
//
|
|
// TOC pane is deferred — a near-term iteration can split this
|
|
// into editor | toc once the simpler form is exercised.
|
|
container.innerHTML = '';
|
|
container.style.display = 'flex';
|
|
container.style.flexDirection = 'column';
|
|
|
|
var toolbar = document.createElement('div');
|
|
toolbar.className = 'md-toolbar';
|
|
toolbar.style.cssText = 'display:flex;align-items:center;gap:0.5rem;'
|
|
+ 'padding:0.35rem 0.75rem;background:var(--bg-secondary);'
|
|
+ 'border-bottom:1px solid var(--border);flex-shrink:0;';
|
|
|
|
var saveBtn = document.createElement('button');
|
|
saveBtn.className = 'btn btn-sm btn-primary';
|
|
saveBtn.type = 'button';
|
|
saveBtn.textContent = 'Save';
|
|
saveBtn.disabled = true; // enabled when content changes
|
|
|
|
var dirty = document.createElement('span');
|
|
dirty.style.cssText = 'color:var(--text-muted);font-size:0.85rem;';
|
|
dirty.textContent = '';
|
|
|
|
var status = document.createElement('span');
|
|
status.style.cssText = 'flex:1;text-align:right;color:var(--text-muted);font-size:0.85rem;';
|
|
|
|
toolbar.appendChild(saveBtn);
|
|
toolbar.appendChild(dirty);
|
|
toolbar.appendChild(status);
|
|
container.appendChild(toolbar);
|
|
|
|
var editorHost = document.createElement('div');
|
|
editorHost.style.cssText = 'flex:1;min-height:0;overflow:hidden;';
|
|
container.appendChild(editorHost);
|
|
|
|
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 };
|
|
|
|
function markDirty(isDirty) {
|
|
currentInstance.dirty = isDirty;
|
|
saveBtn.disabled = !isDirty;
|
|
dirty.textContent = isDirty ? '● modified' : '';
|
|
}
|
|
|
|
editor.on('change', async function () {
|
|
var current = editor.getMarkdown();
|
|
var h = await hashContent(current);
|
|
markDirty(h !== currentInstance.hash);
|
|
});
|
|
|
|
async function save() {
|
|
if (!currentInstance.dirty) return;
|
|
var content = editor.getMarkdown();
|
|
// Read-only sources: zip-virtual, FS-API without write
|
|
// permission. For now we only attempt PUT against server URLs;
|
|
// FS-API saves can be wired in a later iteration via the
|
|
// existing zddc-source polyfill.
|
|
if (!node.url || window.app.state.source !== 'server') {
|
|
status.textContent = 'Save not yet supported for this source.';
|
|
return;
|
|
}
|
|
try {
|
|
status.textContent = 'Saving…';
|
|
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);
|
|
}
|
|
currentInstance.hash = await hashContent(content);
|
|
markDirty(false);
|
|
status.textContent = 'Saved ' + new Date().toLocaleTimeString();
|
|
} catch (e) {
|
|
status.textContent = 'Save failed: ' + (e.message || e);
|
|
}
|
|
}
|
|
|
|
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.preventDefault();
|
|
save();
|
|
}
|
|
});
|
|
}
|
|
|
|
window.app.modules.markdown = {
|
|
render: render,
|
|
dispose: dispose
|
|
};
|
|
})();
|