feat(browse): two-pane shell + markdown plugin + grid mode (Phases A/B/C/D)

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>
This commit is contained in:
ZDDC 2026-05-10 15:46:51 -05:00
parent 875870501e
commit 7d4d2dc9a2
16 changed files with 957 additions and 373 deletions

View file

@ -17,12 +17,15 @@ js_temp=$(mktemp)
cleanup() { rm -f "$css_temp" "$js_raw" "$js_temp"; }
trap cleanup EXIT
# CSS files: shared base first, then browse-specific.
# CSS files: shared base first, then browse-specific. Toast UI's CSS
# is bundled because the markdown plugin uses Toast UI inside the
# preview pane (.md files render as a full editor).
concat_files \
"../shared/base.css" \
"../shared/toast.css" \
"../shared/nav.css" \
"../shared/logo.css" \
"../shared/vendor/toastui-editor.min.css" \
"css/base.css" \
"css/tree.css" \
> "$css_temp"
@ -35,6 +38,7 @@ concat_files \
concat_files \
"../shared/vendor/jszip.min.js" \
"../shared/vendor/utif.min.js" \
"../shared/vendor/toastui-editor-all.min.js" \
"../shared/zddc.js" \
"../shared/zddc-filter.js" \
"../shared/theme.js" \
@ -47,6 +51,8 @@ concat_files \
"js/loader.js" \
"js/tree.js" \
"js/preview.js" \
"js/preview-markdown.js" \
"js/grid.js" \
"js/events.js" \
"js/app.js" \
> "$js_raw"

View file

@ -1,87 +1,112 @@
/* Toolbar above the listing */
/* ── Layout ──────────────────────────────────────────────────────────────── */
.browse-root {
flex: 1;
html, body {
margin: 0;
padding: 0;
height: 100%;
overflow: hidden;
font-family: var(--font);
color: var(--text);
background-color: var(--bg);
}
#appMain {
position: relative;
height: calc(100vh - 2.65rem); /* clear .app-header */
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
}
.browse-table-wrap {
.browse-root {
display: flex;
flex-direction: column;
flex: 1;
overflow: auto;
min-height: 0;
height: 100%;
overflow: hidden;
background: var(--bg);
}
.toolbar {
/* ── Toolbar ─────────────────────────────────────────────────────────────── */
.browse-toolbar {
display: flex;
align-items: center;
gap: 1rem;
padding: 0.6rem 1rem;
gap: 0.75rem;
padding: 0.4rem 1rem;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
flex-wrap: wrap;
}
/* Breadcrumb path. The root node is a 🏠 link to "/" (online) or
the FS handle name (offline). Each segment is a clickable link in
server mode that re-navigates the browser; in FS-API mode they
render as plain spans because we don't keep ancestor handles. */
.view-mode-toggle {
display: inline-flex;
gap: 0;
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
}
.view-mode-toggle .btn {
border-radius: 0;
border: none;
border-right: 1px solid var(--border);
}
.view-mode-toggle .btn:last-child {
border-right: none;
}
.view-mode-toggle .btn[aria-selected="true"] {
background: var(--primary);
color: var(--text-light);
}
/* Breadcrumbs */
.breadcrumbs {
flex: 1;
min-width: 0;
overflow-x: auto;
overflow-y: hidden;
white-space: nowrap;
font-family: Consolas, Monaco, monospace;
font-size: 0.9rem;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.15rem 0.4rem;
font-size: 0.85rem;
color: var(--text-muted);
padding: 0.1rem 0;
/* Hide the scrollbar but keep horizontal scroll for very deep paths */
scrollbar-width: thin;
min-width: 0;
}
.breadcrumbs .bc-link {
color: var(--primary);
.breadcrumbs a,
.breadcrumbs button {
color: var(--text-muted);
background: none;
border: 0;
padding: 0.1rem 0.3rem;
border-radius: var(--radius);
cursor: pointer;
text-decoration: none;
padding: 0.1rem 0.25rem;
border-radius: 3px;
font: inherit;
}
.breadcrumbs .bc-link:hover {
background: var(--bg-hover, rgba(0,0,0,0.05));
text-decoration: underline;
}
.breadcrumbs .bc-link--current {
.breadcrumbs a:hover,
.breadcrumbs button:hover {
color: var(--text);
font-weight: 500;
cursor: default;
}
.breadcrumbs .bc-link--current:hover {
background: transparent;
text-decoration: none;
background: var(--bg-hover);
}
.breadcrumbs .bc-sep {
color: var(--text-muted);
margin: 0 0.05rem;
user-select: none;
}
.breadcrumbs .bc-root {
display: inline-flex;
align-items: center;
line-height: 1;
.breadcrumbs .bc-current {
color: var(--text);
font-weight: 600;
padding: 0.1rem 0.3rem;
}
.bc-home-icon {
width: 1rem;
height: 1rem;
display: block;
color: currentColor;
width: 1em;
height: 1em;
vertical-align: -0.15em;
}
.toolbar__count {
@ -90,143 +115,230 @@
white-space: nowrap;
}
/* Table — folders + files in a tree */
/* ── Two-pane browse view ────────────────────────────────────────────────── */
.browse-table {
width: 100%;
border-collapse: collapse;
font-size: 0.9rem;
.browse-view {
display: flex;
flex: 1;
overflow: hidden;
min-height: 0;
}
.pane {
overflow: hidden;
background: var(--bg);
/* No flex:1 tables don't reliably distribute extra height across
rows the way flex columns do. With few rows we'd get tall rows
that shrink as more children are loaded. The wrap div handles
scrolling instead. */
display: flex;
flex-direction: column;
}
.browse-table tbody tr {
/* Pin rows to a deterministic height so table layout never
redistributes vertical space across them. */
line-height: 1.4;
.tree-pane {
width: 360px;
min-width: 200px;
max-width: 60%;
border-right: 1px solid var(--border);
flex-shrink: 0;
}
.browse-table thead th {
position: sticky;
top: 0;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border);
text-align: left;
padding: 0.5rem 0.75rem;
font-weight: 600;
color: var(--text);
user-select: none;
.tree-pane__body {
flex: 1;
overflow: auto;
padding: 0.25rem 0;
font-size: 0.875rem;
}
/* Pane resizer — 4px grab handle between tree and preview */
.pane-resizer {
width: 4px;
background: transparent;
cursor: col-resize;
flex-shrink: 0;
position: relative;
z-index: 1;
}
.browse-table th.sortable {
cursor: pointer;
.pane-resizer:hover,
.pane-resizer.is-dragging {
background: var(--primary);
}
.browse-table th.sortable:hover {
background: var(--bg-hover, #e8e8e8);
}
.sort-arrow {
display: inline-block;
width: 0.7rem;
color: var(--text-muted);
font-size: 0.7rem;
margin-left: 0.2rem;
}
.browse-table th.sort-asc .sort-arrow::after { content: "▲"; color: var(--text); }
.browse-table th.sort-desc .sort-arrow::after { content: "▼"; color: var(--text); }
.browse-table tbody td {
padding: 0.3rem 0.75rem;
border-bottom: 1px solid var(--border);
vertical-align: middle;
}
.browse-table tbody tr:hover {
background: var(--bg-hover, #f6faff);
}
/* Tree-row — name cell with indent + chevron */
.tree-name {
display: flex;
align-items: center;
gap: 0.4rem;
.preview-pane {
flex: 1;
min-width: 0;
}
.tree-name__indent {
flex: 0 0 auto;
.preview-pane__header {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.4rem 0.75rem;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
min-height: 2.1rem;
}
.preview-pane__title {
flex: 1;
font-size: 0.9rem;
font-weight: 500;
color: var(--text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
}
.preview-pane__meta {
font-size: 0.8rem;
color: var(--text-muted);
white-space: nowrap;
}
.preview-pane__body {
flex: 1;
overflow: auto;
display: flex;
flex-direction: column;
background: var(--bg);
}
/* The body's children fill the available space. Plugins inject
different content here img, iframe, pre, custom markdown editor. */
.preview-pane__body > * {
flex: 1;
min-height: 0;
}
.preview-empty {
display: flex;
align-items: center;
justify-content: center;
color: var(--text-muted);
font-size: 0.95rem;
padding: 2rem;
text-align: center;
}
.preview-pane__body img.preview-image {
max-width: 100%;
max-height: 100%;
object-fit: contain;
margin: auto;
display: block;
flex: none; /* avoid flex sizing interfering with object-fit */
}
.preview-pane__body iframe.preview-iframe {
width: 100%;
height: 100%;
border: none;
}
.preview-pane__body pre.preview-text {
padding: 1rem;
font-family: var(--font-mono);
font-size: 0.85rem;
white-space: pre-wrap;
word-wrap: break-word;
margin: 0;
overflow: auto;
background: var(--bg);
color: var(--text);
}
/* ── Tree (vertical, file-explorer style) ───────────────────────────────── */
.tree-row {
display: flex;
align-items: center;
gap: 0.25rem;
padding: 0.15rem 0.5rem;
cursor: pointer;
user-select: none;
border-radius: 0;
color: var(--text);
}
.tree-row:hover {
background: var(--bg-hover);
}
.tree-row.is-selected {
background: var(--bg-selected);
color: var(--text);
}
.tree-row.is-selected .tree-name__label {
color: var(--text);
}
.tree-name__chevron {
display: inline-block;
width: 1rem;
text-align: center;
color: var(--text-muted);
cursor: pointer;
user-select: none;
flex: 0 0 1rem;
line-height: 1;
flex-shrink: 0;
font-family: monospace;
font-size: 0.65rem;
}
.tree-name__chevron--leaf { visibility: hidden; }
.tree-name__chevron::before { content: "▶"; font-size: 0.65rem; }
.tree-row.expanded > td .tree-name__chevron::before { content: "▼"; }
.tree-row[data-isdir="true"] .tree-name__chevron::before,
.tree-row[data-iszip="true"] .tree-name__chevron::before {
content: "▸";
}
.tree-row[data-isdir="true"].expanded .tree-name__chevron::before,
.tree-row[data-iszip="true"].expanded .tree-name__chevron::before {
content: "▾";
}
.tree-name__chevron--leaf::before {
content: "";
}
.tree-name__icon {
flex: 0 0 1.1rem;
text-align: center;
color: var(--text-muted);
font-size: 1rem;
line-height: 1;
flex-shrink: 0;
font-size: 0.95rem;
}
.tree-name__label {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--text);
}
.tree-name__label.is-folder {
.tree-row[data-isdir="true"] .tree-name__label,
.tree-row[data-iszip="true"] .tree-name__label {
font-weight: 500;
}
.tree-name__label.is-file {
cursor: pointer;
color: var(--primary);
text-decoration: none;
/* ── Grid view (Phase C) ─────────────────────────────────────────────────── */
.grid-view {
flex: 1;
overflow: auto;
background: var(--bg);
padding: 0;
}
.tree-name__label.is-file:hover {
text-decoration: underline;
}
/* Numeric columns right-aligned */
.col-size, .col-date {
text-align: right;
font-variant-numeric: tabular-nums;
white-space: nowrap;
.grid-empty {
padding: 3rem;
text-align: center;
color: var(--text-muted);
}
.col-ext {
/* ── Status bar ──────────────────────────────────────────────────────────── */
.status-bar {
padding: 0.4rem 1rem;
background: var(--bg-secondary);
border-top: 1px solid var(--border);
font-size: 0.8rem;
color: var(--text-muted);
font-family: Consolas, Monaco, monospace;
font-size: 0.85rem;
}
/* Loading row */
.tree-row--loading td {
color: var(--text-muted);
font-style: italic;
padding: 0.5rem 1rem 0.5rem calc(0.75rem + 2.4rem);
min-height: 1.6rem;
flex-shrink: 0;
}
.status-bar.is-error { color: var(--danger); }
.status-bar.is-info { color: var(--text); }

View file

@ -122,43 +122,70 @@
var refresh = document.getElementById('refreshHeaderBtn');
if (refresh) refresh.addEventListener('click', refreshListing);
// Sort headers
var ths = document.querySelectorAll('#browseTable thead th.sortable');
for (var i = 0; i < ths.length; i++) {
(function (th) {
th.addEventListener('click', function () {
tree.setSort(th.dataset.sort);
// View-mode toggle (Browse vs Grid)
var btnBrowse = document.getElementById('viewModeBrowse');
var btnGrid = document.getElementById('viewModeGrid');
if (btnBrowse) btnBrowse.addEventListener('click', function () { setViewMode('browse'); });
if (btnGrid) btnGrid.addEventListener('click', function () { setViewMode('grid'); });
// Pop-out preview button — opens the current preview in a separate window.
var popout = document.getElementById('previewPopout');
if (popout) popout.addEventListener('click', function () {
var p = previewMod();
if (p && state.lastPreviewedNodeId != null) {
var n = state.nodes.get(state.lastPreviewedNodeId);
if (n) p.showFilePreview(n, { popup: true });
}
});
// Pane resizer (tree pane width). Drag horizontally; clamps to
// [180, 60% of viewport]. State stays in-memory only — refresh
// resets to the default 360px.
var resizer = document.querySelector('.pane-resizer[data-resizer-for="tree-pane"]');
var treePane = document.getElementById('treePane');
if (resizer && treePane) {
var dragging = false;
var startX = 0;
var startWidth = 0;
resizer.addEventListener('mousedown', function (e) {
dragging = true;
resizer.classList.add('is-dragging');
startX = e.clientX;
startWidth = treePane.getBoundingClientRect().width;
e.preventDefault();
});
document.addEventListener('mousemove', function (e) {
if (!dragging) return;
var dx = e.clientX - startX;
var w = Math.max(180, Math.min(window.innerWidth * 0.6, startWidth + dx));
treePane.style.width = w + 'px';
});
document.addEventListener('mouseup', function () {
if (!dragging) return;
dragging = false;
resizer.classList.remove('is-dragging');
});
})(ths[i]);
}
// Tree-row clicks (event delegation on tbody).
// Tree-row clicks (event delegation on the tree body).
// Click semantics on a folder row:
// - plain click → toggle just this folder
// - shift-click → recursive expand/collapse of the whole
// subtree (matches common file-explorer
// convention; e.g. Finder, VSCode tree,
// Windows Explorer)
// - alt-click → ALSO recursive (alt is sometimes the
// expand-all key on Linux DEs; bind both
// so muscle memory works either way)
// File rows: let the <a> tag's natural target=_blank do its
// job — don't intercept.
var tbody = document.getElementById('browseTbody');
if (tbody) {
tbody.addEventListener('click', function (e) {
var row = e.target.closest('tr.tree-row');
// - plain click → toggle expand
// - shift-click → recursive expand/collapse of the subtree
// - alt-click → ALSO recursive
// File rows: plain click → preview in right pane; modifier-click
// and middle-click open in new tab.
var treeBody = document.getElementById('treeBody');
if (treeBody) {
treeBody.addEventListener('click', function (e) {
var row = e.target.closest('.tree-row');
if (!row) return;
var id = parseInt(row.dataset.id, 10);
var node = state.nodes.get(id);
if (!node) return;
var isExpandable = row.dataset.isdir === 'true' || row.dataset.iszip === 'true';
var clickedChevron = !!e.target.closest('.tree-name__chevron');
if (isExpandable) {
// For folders + zips: click anywhere on the row
// toggles. Modifier-click → recursive expand.
e.preventDefault();
if (e.shiftKey || e.altKey) {
if (node.expanded) tree.collapseSubtree(id);
@ -169,27 +196,22 @@
return;
}
// Plain file row.
// Modifier-click (ctrl/cmd) and middle-click → fall
// through to the <a> tag's natural target=_blank
// behavior (open in new tab). For server-backed
// files, that opens the real URL via zddc-server.
// File row: modifier-click → open URL in new tab if
// available (server mode preserves the original URL,
// useful for direct download / sharing).
if (e.ctrlKey || e.metaKey || e.shiftKey || e.button === 1) {
if (node.url) window.open(node.url, '_blank', 'noopener');
return;
}
// Plain click → preview popup. Intercept default nav.
// Plain click → preview in the right pane.
e.preventDefault();
state.selectedId = id;
state.lastPreviewedNodeId = id;
tree.render(); // refresh selection highlight
var p = previewMod();
if (p) p.showFilePreview(node);
});
// Middle-click (auxclick) — same fall-through logic.
tbody.addEventListener('auxclick', function (e) {
if (e.button !== 1) return; // middle only
// Browser handles target=_blank natively for middle
// click; don't preventDefault, just don't intercept.
});
// Double-click on a folder → "navigate into" it. Distinct
// from single-click (which expands inline) so users keep
// both UX models. Server mode jumps to the folder URL —
@ -203,8 +225,8 @@
// navigate-into doesn't apply.
// ZIPs: skipped too — they're inspected via inline
// expansion (JSZip), not navigated into.
tbody.addEventListener('dblclick', function (e) {
var row = e.target.closest('tr.tree-row');
treeBody.addEventListener('dblclick', function (e) {
var row = e.target.closest('.tree-row');
if (!row) return;
if (row.dataset.isdir !== 'true') return;
var id = parseInt(row.dataset.id, 10);
@ -216,6 +238,28 @@
}
}
function setViewMode(mode) {
state.viewMode = mode;
var browseView = document.getElementById('browseView');
var gridView = document.getElementById('gridView');
var btnBrowse = document.getElementById('viewModeBrowse');
var btnGrid = document.getElementById('viewModeGrid');
if (mode === 'grid') {
if (browseView) browseView.classList.add('hidden');
if (gridView) gridView.classList.remove('hidden');
if (btnBrowse) btnBrowse.setAttribute('aria-selected', 'false');
if (btnGrid) btnGrid.setAttribute('aria-selected', 'true');
// Lazily mount classifier on first activation.
var grid = window.app.modules.grid;
if (grid && grid.activate) grid.activate();
} else {
if (browseView) browseView.classList.remove('hidden');
if (gridView) gridView.classList.add('hidden');
if (btnBrowse) btnBrowse.setAttribute('aria-selected', 'true');
if (btnGrid) btnGrid.setAttribute('aria-selected', 'false');
}
}
async function navigateIntoFolder(node) {
if (state.source === 'server') {
var url = window.app.modules.tree.pathFor(node);

104
browse/js/grid.js Normal file
View file

@ -0,0 +1,104 @@
// grid.js — "Grid mode" plugin for browse. Activated by the
// view-mode toggle in the toolbar. Loads the standalone classifier
// tool as an iframe scoped to the current directory; the user gets
// classifier's full bulk-rename workflow without leaving browse.
//
// This is a v1 — a future iteration could bundle classifier's
// modules directly into browse for tighter integration (shared
// state, no iframe chrome). For now the iframe is a clean separation
// that preserves classifier's full feature set.
//
// Iframe src resolution:
// - server mode: <currentDirURL>/classifier.html. classifier is
// auto-served at any working/staging/incoming subtree per
// zddc-server's apps/availability.go. Outside those locations the
// iframe will 404 — we surface a friendly message instead of an
// opaque blank page.
// - file:// or unknown: show a "switch to server mode for grid"
// hint. classifier needs FS-API access; embedding it via file://
// iframe is blocked by browser security.
(function () {
'use strict';
var state = window.app.state;
var mounted = false;
function escapeHtml(s) {
return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;')
.replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
function classifierAvailableHere() {
// classifier auto-serves under any path containing a segment
// named working / staging / incoming (case-insensitive).
// browse mode-toggle should reflect that.
var path = (window.location && window.location.pathname) || '';
return /\/(working|staging|incoming)(\/|$)/i.test(path);
}
function activate() {
var host = document.getElementById('gridView');
if (!host) return;
if (mounted) return;
host.innerHTML = '';
if (state.source !== 'server') {
host.innerHTML =
'<div class="grid-empty">'
+ '<h3 style="margin-bottom:0.5rem">Grid mode</h3>'
+ '<p>The classifier (bulk ZDDC rename) workflow runs as an embedded'
+ ' iframe and requires the page be served by zddc-server.</p>'
+ '<p>If you opened this file directly (file://), open the standalone'
+ ' <code>classifier.html</code> tool instead — it provides the same'
+ ' workflow against a local folder you pick from the file system.</p>'
+ '</div>';
return;
}
if (!classifierAvailableHere()) {
host.innerHTML =
'<div class="grid-empty">'
+ '<h3 style="margin-bottom:0.5rem">Grid mode</h3>'
+ '<p>The classifier (bulk ZDDC rename) workflow auto-serves at'
+ ' <code>working/</code>, <code>staging/</code>, and'
+ ' <code>incoming/</code> URLs. The current page'
+ ' (<code>' + escapeHtml(window.location.pathname) + '</code>) isn\'t'
+ ' inside any of those, so classifier isn\'t available here.</p>'
+ '<p>Navigate browse into a working/ or staging/ folder, then'
+ ' switch to Grid.</p>'
+ '</div>';
return;
}
// Compute the iframe src: current page's directory + classifier.html.
var pathname = window.location.pathname || '/';
if (!pathname.endsWith('/')) {
// Strip trailing /<file>.html or similar — keep up to the last "/".
var lastSlash = pathname.lastIndexOf('/');
pathname = lastSlash >= 0 ? pathname.substring(0, lastSlash + 1) : '/';
}
var src = pathname + 'classifier.html';
host.innerHTML = '';
var frame = document.createElement('iframe');
frame.src = src;
frame.title = 'ZDDC Classifier (Grid mode)';
frame.style.cssText = 'width:100%;height:100%;border:0;display:block;'
+ 'background:var(--bg);';
host.appendChild(frame);
mounted = true;
}
window.app.modules.grid = {
activate: activate,
// Hook the toggle button visibility / hint to the activation
// predicate so users at non-classifier paths see the button
// in a disabled state with explanation. Callers run this
// after the initial directory is loaded.
availableHere: function () {
return state.source === 'server' && classifierAvailableHere();
}
};
})();

View file

@ -25,6 +25,13 @@
// Sort state. key: 'name' | 'size' | 'ext' | 'date'. dir: 1 or -1.
sort: { key: 'name', dir: 1 },
// Currently-selected tree node id (for highlight + pop-out).
selectedId: null,
lastPreviewedNodeId: null,
// View mode: 'browse' (tree + preview, default) | 'grid' (classifier).
viewMode: 'browse',
// The tree's in-memory representation. Each node:
// { id, name, isDir, size, modTime, ext, url, depth,
// parentId, expanded, loaded, childIds, isZip, zipFile,

View file

@ -0,0 +1,185 @@
// 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, '&amp;').replace(/</g, '&lt;')
.replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
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
};
})();

View file

@ -1,11 +1,14 @@
// preview.js — file preview popup. Reuses shared/preview-lib.js for
// TIFF, ZIP listing, and image-rendering helpers; native iframe for
// PDF and HTML; <pre> for text; download button for everything else.
// preview.js — file-preview rendering for the browse tool's right pane.
//
// Lifecycle: a single popup window is reused across multiple file
// clicks (state.previewWindow). Subsequent clicks rewrite its
// contents instead of spawning a new window — same UX as the archive
// tool.
// Default flow: showFilePreview(node) renders into the inline preview
// pane (#previewBody). Popup flow: showFilePreview(node, {popup:true})
// opens a separate window — kept for users who want previews on a
// second monitor.
//
// Rendering uses shared/preview-lib.js for content types it handles
// (TIFF, ZIP listing, image-mime detection). PDF / HTML go in iframes;
// text into a <pre>; markdown into the dedicated markdown plugin
// (preview-markdown.js); unknown extensions show a download button.
(function () {
'use strict';
@ -13,9 +16,7 @@
var loader = window.app.modules.loader;
var preview = window.zddc && window.zddc.preview;
if (!preview) {
// shared/preview-lib.js wasn't concatenated in. Bail loudly so
// the bug shows up in console rather than mysteriously failing.
console.error('[browse] zddc.preview not loaded — preview popup disabled.');
console.error('[browse] zddc.preview not loaded — preview disabled.');
}
function escapeHtml(s) {
@ -32,15 +33,22 @@
'zip': 'application/zip',
'txt': 'text/plain', 'md': 'text/markdown', 'json': 'application/json',
'xml': 'application/xml', 'csv': 'text/csv', 'log': 'text/plain',
'js': 'text/javascript', 'css': 'text/css'
'js': 'text/javascript', 'css': 'text/css',
'docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'xls': 'application/vnd.ms-excel'
};
// Pull bytes for a file node. Three sources:
// - server URL (zddc-server-backed file, including downloads
// of archived files served at real paths)
// - FS-API handle (local folder)
// - JSZip entry (file inside an expanded zip; reads from
// parent's cached JSZip instance)
function getMime(ext) { return MIME[ext] || 'application/octet-stream'; }
function fmtSize(bytes) {
if (bytes == null) return '';
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
if (bytes < 1024 * 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
return (bytes / (1024 * 1024 * 1024)).toFixed(2) + ' GB';
}
async function getArrayBuffer(node) {
if (node.zipParentId != null) {
var owner = state.nodes.get(node.zipParentId);
@ -61,13 +69,6 @@
throw new Error('no source for file');
}
function getMime(ext) {
return MIME[ext] || 'application/octet-stream';
}
// Build a blob URL for the file's bytes. For server-mode regular
// files (not in a zip), prefer the live URL — relative links and
// server-side interception (e.g. .archive resolution) work then.
async function getBlobUrl(node) {
if (state.source === 'server' && node.url && node.zipParentId == null) {
return { url: node.url, fromServer: true };
@ -77,14 +78,134 @@
return { url: URL.createObjectURL(blob), fromServer: false };
}
// ── Inline rendering ────────────────────────────────────────────────────
function renderEmpty(container, msg) {
container.innerHTML = '<div class="preview-empty">' + escapeHtml(msg) + '</div>';
}
function renderError(container, msg) {
container.innerHTML = '<div class="preview-empty" style="color:var(--danger)">'
+ escapeHtml(msg) + '</div>';
}
async function renderInline(node) {
var container = document.getElementById('previewBody');
var titleEl = document.getElementById('previewTitle');
var metaEl = document.getElementById('previewMeta');
var popoutBtn = document.getElementById('previewPopout');
if (!container) return;
if (titleEl) titleEl.textContent = node.name;
if (metaEl) {
var meta = [];
if (!node.isDir && !node.isZip) meta.push(fmtSize(node.size));
if (node.ext) meta.push(node.ext.toUpperCase());
metaEl.textContent = meta.join(' · ');
}
if (popoutBtn) popoutBtn.classList.remove('hidden');
var ext = (node.ext || '').toLowerCase();
// Markdown plugin (if loaded) takes over for .md / .markdown.
if ((ext === 'md' || ext === 'markdown') &&
window.app.modules.markdown &&
typeof window.app.modules.markdown.render === 'function') {
try {
await window.app.modules.markdown.render(node, container, { getArrayBuffer: getArrayBuffer });
} catch (e) {
renderError(container, 'Markdown render failed: ' + (e.message || e));
}
return;
}
// PDF / HTML → iframe.
if (ext === 'pdf' || ext === 'html' || ext === 'htm') {
try {
var info = await getBlobUrl(node);
var sandbox = (ext === 'pdf') ? '' : ' sandbox="allow-same-origin allow-popups allow-popups-to-escape-sandbox"';
container.innerHTML = '<iframe class="preview-iframe" src="' + escapeHtml(info.url) + '"' + sandbox + '></iframe>';
} catch (e) {
renderError(container, e.message || String(e));
}
return;
}
// Plain images (jpg/png/gif/webp/svg) → <img>. TIFF goes through preview-lib.
if (preview && preview.isImage(ext) && !preview.isTiff(ext)) {
try {
var imgInfo = await getBlobUrl(node);
container.innerHTML = '<img class="preview-image" alt="' + escapeHtml(node.name)
+ '" src="' + escapeHtml(imgInfo.url) + '">';
} catch (e) {
renderError(container, e.message || String(e));
}
return;
}
if (preview && preview.isTiff(ext)) {
try {
var tiffBuf = await getArrayBuffer(node);
container.innerHTML = '';
await preview.renderTiff(document, container, tiffBuf, { fileName: node.name });
} catch (e) {
renderError(container, 'Failed to render TIFF: ' + (e.message || e));
}
return;
}
if (preview && preview.isZip(ext)) {
try {
var zipBuf = await getArrayBuffer(node);
container.innerHTML = '';
await preview.renderZipListing(document, container, zipBuf, { fileName: node.name });
} catch (e) {
renderError(container, 'Failed to read ZIP: ' + (e.message || e));
}
return;
}
if (preview && preview.isText(ext)) {
try {
var txtBuf = await getArrayBuffer(node);
var text = new TextDecoder('utf-8', { fatal: false }).decode(txtBuf);
var MAX = 200000;
if (text.length > MAX) {
text = text.substring(0, MAX) + '\n\n... (truncated, '
+ (text.length - MAX) + ' more chars)';
}
container.innerHTML = '';
var pre = document.createElement('pre');
pre.className = 'preview-text';
pre.textContent = text;
container.appendChild(pre);
} catch (e) {
renderError(container, e.message || String(e));
}
return;
}
// Unknown type — offer a download link.
try {
var fallbackInfo = await getBlobUrl(node);
container.innerHTML =
'<div class="preview-empty">'
+ 'No inline preview for <code>.' + escapeHtml(ext) + '</code>. '
+ '<br><a class="btn btn-primary btn-sm" download="' + escapeHtml(node.name)
+ '" href="' + escapeHtml(fallbackInfo.url) + '" style="margin-top:1rem">'
+ 'Download ' + escapeHtml(node.name) + '</a>'
+ '</div>';
} catch (e) {
renderError(container, 'No source for ' + node.name);
}
}
// ── Popup window (kept for "Pop out" button) ────────────────────────────
function popupShell(node, primaryUrl) {
var safeName = escapeHtml(node.name);
var safeHref = escapeHtml(primaryUrl);
var ext = (node.ext || '').toLowerCase();
// Inline PDF and HTML previews load in iframes. HTML uses
// sandbox="allow-same-origin allow-popups
// allow-popups-to-escape-sandbox" — same posture as archive's
// preview: links navigate, scripts blocked, popups allowed.
var contentHtml;
if (ext === 'pdf') {
contentHtml = '<iframe src="' + safeHref + '"></iframe>';
@ -128,64 +249,47 @@
+ '</' + 'script></body></html>';
}
async function renderTextInWindow(node, win) {
async function renderInPopupWindow(node, win, info) {
var ext = (node.ext || '').toLowerCase();
if (ext === 'pdf' || ext === 'html' || ext === 'htm') return;
if (preview && preview.isImage(ext) && !preview.isTiff(ext)) return;
var c = win.document.getElementById('previewContent');
if (!c) return;
try {
var buf = await getArrayBuffer(node);
var text = new TextDecoder('utf-8', { fatal: false }).decode(buf);
if (preview && preview.isTiff(ext)) {
var tb = await getArrayBuffer(node);
await preview.renderTiff(win.document, c, tb, { fileName: node.name });
} else if (preview && preview.isZip(ext)) {
var zb = await getArrayBuffer(node);
await preview.renderZipListing(win.document, c, zb, { fileName: node.name });
} else if (preview && preview.isText(ext)) {
var txb = await getArrayBuffer(node);
var text = new TextDecoder('utf-8', { fatal: false }).decode(txb);
var MAX = 200000;
if (text.length > MAX) {
text = text.substring(0, MAX) + '\n\n... (truncated, '
+ (text.length - MAX) + ' more chars — Download for full file)';
}
if (text.length > MAX) text = text.substring(0, MAX) + '\n\n... (truncated)';
var pre = win.document.createElement('pre');
pre.className = 'preview-text';
pre.textContent = text;
c.innerHTML = '';
c.appendChild(pre);
} else {
c.innerHTML = '<div class="loading">No inline preview for .'
+ escapeHtml(ext) + ' — click Download.</div>';
}
} catch (e) {
c.innerHTML = '<div class="loading">Error: ' + escapeHtml(e.message || e) + '</div>';
}
}
async function renderTiffInWindow(node, win) {
var c = win.document.getElementById('previewContent');
if (!c || !preview) return;
try {
var buf = await getArrayBuffer(node);
await preview.renderTiff(win.document, c, buf, { fileName: node.name });
} catch (e) {
c.innerHTML = '<div class="loading">Error rendering TIFF: '
+ escapeHtml(e.message || e) + '</div>';
}
}
async function renderZipInWindow(node, win) {
var c = win.document.getElementById('previewContent');
if (!c || !preview) return;
try {
var buf = await getArrayBuffer(node);
await preview.renderZipListing(win.document, c, buf, { fileName: node.name });
} catch (e) {
c.innerHTML = '<div class="loading">Error reading ZIP: '
+ escapeHtml(e.message || e) + '</div>';
}
}
async function showFilePreview(node) {
if (node.isDir) return;
var ext = (node.ext || '').toLowerCase();
async function renderInPopup(node) {
var info;
try {
info = await getBlobUrl(node);
} catch (e) {
window.app.modules.events.statusError('Preview failed: ' + e.message);
window.app.modules.events.statusError('Pop-out failed: ' + e.message);
return;
}
var html = popupShell(node, info.url);
var win = state.previewWindow;
if (win && !win.closed) {
win.document.open();
@ -201,7 +305,6 @@
'width=' + w + ',height=' + h + ',left=' + left + ',top=' + top
+ ',resizable=yes,scrollbars=yes');
if (!win) {
// Popup blocked — fall back to opening the file directly.
window.open(info.url, '_blank', 'noopener');
return;
}
@ -210,30 +313,21 @@
win.focus();
state.previewWindow = win;
}
// Async content rendering for the non-iframe types.
if (ext === 'pdf' || ext === 'html' || ext === 'htm') {
return; // iframe wired in popupShell
}
if (preview && preview.isImage(ext) && !preview.isTiff(ext)) {
return; // <img> wired in popupShell
}
if (preview && preview.isTiff(ext)) {
await renderTiffInWindow(node, win);
} else if (preview && preview.isZip(ext)) {
await renderZipInWindow(node, win);
} else if (preview && preview.isText(ext)) {
await renderTextInWindow(node, win);
} else {
// Unknown type — show a friendly "no preview, click
// download" placeholder.
var c = win.document.getElementById('previewContent');
if (c) {
c.innerHTML = '<div class="loading">No inline preview for .'
+ escapeHtml(ext) + ' — click Download.</div>';
}
}
await renderInPopupWindow(node, win, info);
}
window.app.modules.preview = { showFilePreview: showFilePreview };
// ── Public entry ────────────────────────────────────────────────────────
async function showFilePreview(node, opts) {
if (node.isDir) return;
opts = opts || {};
if (opts.popup) return renderInPopup(node);
return renderInline(node);
}
window.app.modules.preview = {
showFilePreview: showFilePreview,
// Expose for the markdown plugin so it can read file bytes.
getArrayBuffer: getArrayBuffer
};
})();

View file

@ -146,57 +146,43 @@
.replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
// Render a single tree row as a flat <div>. Indentation via
// padding-left so the row's hover background spans the full
// pane width. Files are rendered as plain rows (no anchor) —
// the preview pane handles click navigation, and a Ctrl/Cmd-
// click can fall back to opening the file's url in a new tab
// via the events.js click handler (it sees the modifier key).
function rowHtml(node) {
var indent = node.depth * 1.2;
var indent = 0.4 + node.depth * 1.0;
var expandable = node.isDir || node.isZip;
var iconChar = node.isDir ? '📁' : (node.isZip ? '🗜️' : '📄');
var chevronClass = 'tree-name__chevron'
+ (expandable ? '' : ' tree-name__chevron--leaf');
var nameInner;
if (node.isDir) {
nameInner = '<span class="tree-name__label is-folder">'
+ escapeHtml(node.name) + '</span>';
} else {
// File / zip: clickable. Plain click → preview popup.
// Modifier-click (ctrl/cmd) and middle-click → open in
// new tab (browser default for the href). Server mode
// gets the real URL (so right-click → save-link-as also
// works); FS mode and zip-virtual children get '#'.
var href = node.url || '#';
nameInner = '<a class="tree-name__label is-file"'
+ ' href="' + escapeHtml(href) + '"'
+ ' target="_blank" rel="noopener">' + escapeHtml(node.name) + '</a>';
}
var selected = state.selectedId === node.id ? ' is-selected' : '';
return ''
+ '<tr class="tree-row ' + (node.expanded ? 'expanded' : '')
+ '<div class="tree-row ' + (node.expanded ? 'expanded' : '') + selected
+ '" data-id="' + node.id
+ '" data-isdir="' + node.isDir
+ '" data-iszip="' + node.isZip + '">'
+ '<td class="col-name">'
+ '<span class="tree-name">'
+ '<span class="tree-name__indent" style="width:' + indent + 'rem;"></span>'
+ '" data-iszip="' + node.isZip + '"'
+ ' style="padding-left:' + indent + 'rem"'
+ ' role="treeitem" tabindex="-1">'
+ '<span class="' + chevronClass + '"></span>'
+ '<span class="tree-name__icon">' + iconChar + '</span>'
+ nameInner
+ '</span>'
+ '</td>'
+ '<td class="col-size">' + (node.isDir ? '' : fmtSize(node.size)) + '</td>'
+ '<td class="col-ext">' + (node.isDir ? '' : escapeHtml(node.ext)) + '</td>'
+ '<td class="col-date">' + fmtDate(node.modTime) + '</td>'
+ '</tr>';
+ '<span class="tree-name__label" title="' + escapeHtml(node.name) + '">'
+ escapeHtml(node.name) + '</span>'
+ '</div>';
}
function render() {
var tbody = document.getElementById('browseTbody');
if (!tbody) return;
var body = document.getElementById('treeBody');
if (!body) return;
var ids = visibleIds();
var html = '';
for (var i = 0; i < ids.length; i++) {
html += rowHtml(state.nodes.get(ids[i]));
}
tbody.innerHTML = html;
body.innerHTML = html;
updateCount();
updateSortHeaders();
renderBreadcrumbs();
}
@ -276,15 +262,9 @@
el.innerHTML = html;
}
function updateSortHeaders() {
var ths = document.querySelectorAll('#browseTable thead th.sortable');
for (var i = 0; i < ths.length; i++) {
ths[i].classList.remove('sort-asc', 'sort-desc');
if (ths[i].dataset.sort === state.sort.key) {
ths[i].classList.add(state.sort.dir > 0 ? 'sort-asc' : 'sort-desc');
}
}
}
// Sort headers no longer exist in the DOM (the tree replaced the
// table); the tree.setSort() method still works but only via
// programmatic callers — there's no UI for changing sort yet.
// Load a folder's children (lazy; idempotent re-loads). Dispatches
// by node kind:

View file

@ -34,40 +34,56 @@
</header>
<main id="appMain">
<div id="emptyState" class="empty-state">
<div class="empty-state__inner">
<div id="emptyState" class="empty-state empty-state--overlay">
<div class="empty-state__inner empty-state__inner--centered">
<h2>ZDDC Browse</h2>
<p>A simple directory listing for ZDDC archives — and any directory.
Pick how you want to browse:</p>
<ul>
<p>A two-pane file browser for ZDDC archives — and any directory.</p>
<ul class="welcome-list">
<li><b>Online</b> — when this page is served by zddc-server, the
listing for the current directory loads automatically.</li>
<li><b>Local</b> — click <i>Add Local Directory</i> to pick any folder
on your computer (Chromium-based browsers).</li>
</ul>
<p>Once loaded: click a folder to expand it, <b>shift-click</b>
to expand its entire subtree (or collapse it again),
click column headers to sort. Click any file to open it.</p>
<p>Once loaded: click folders to expand, click files to preview them in
the right pane. Markdown files open in a full editor with TOC.
Switch to <b>Grid</b> mode to bulk-rename ZDDC files
spreadsheet-style.</p>
</div>
</div>
<div id="browseRoot" class="browse-root hidden">
<div class="toolbar">
<div class="browse-toolbar">
<div class="view-mode-toggle" role="tablist" aria-label="View mode">
<button id="viewModeBrowse" class="btn btn-sm" role="tab" aria-selected="true">Browse</button>
<button id="viewModeGrid" class="btn btn-sm" role="tab" aria-selected="false">Grid</button>
</div>
<nav class="breadcrumbs" id="breadcrumbs" aria-label="Path"></nav>
<span class="toolbar__count" id="entryCount"></span>
</div>
<div class="browse-table-wrap">
<table class="browse-table" id="browseTable">
<thead>
<tr class="header-row">
<th data-sort="name" class="col-name sortable">Name <span class="sort-arrow"></span></th>
<th data-sort="size" class="col-size sortable">Size <span class="sort-arrow"></span></th>
<th data-sort="ext" class="col-ext sortable">Type <span class="sort-arrow"></span></th>
<th data-sort="date" class="col-date sortable">Modified <span class="sort-arrow"></span></th>
</tr>
</thead>
<tbody id="browseTbody"></tbody>
</table>
<!-- Browse mode (default): two-pane tree + preview -->
<div id="browseView" class="browse-view">
<div class="pane tree-pane" id="treePane">
<div class="tree-pane__body" id="treeBody" role="tree" aria-label="Files"></div>
</div>
<div class="pane-resizer" data-resizer-for="tree-pane" aria-hidden="true"></div>
<div class="pane preview-pane" id="previewPane">
<div class="preview-pane__header">
<span class="preview-pane__title" id="previewTitle">No file selected</span>
<span class="preview-pane__meta" id="previewMeta"></span>
<button id="previewPopout" class="btn btn-sm btn-secondary hidden" title="Pop out into a separate window" aria-label="Pop out into a separate window">⤴ Pop out</button>
</div>
<div class="preview-pane__body" id="previewBody">
<div class="preview-empty">Click a file in the tree to preview it.</div>
</div>
</div>
</div>
<!-- Grid mode: classifier-style spreadsheet rooted at the current dir -->
<div id="gridView" class="grid-view hidden">
<div class="grid-empty">
Grid view is loading…
</div>
</div>
</div>
</main>
@ -82,35 +98,32 @@
</div>
<div class="help-panel__body">
<h3>What is Browse?</h3>
<p>Browse is a directory listing for ZDDC archives — and any directory. It works in two modes:</p>
<p>Browse is the ZDDC file experience. Two top-level modes:</p>
<dl>
<dt>Online</dt>
<dd>When the page is served by zddc-server, the listing for the current
URL directory loads automatically. Breadcrumbs link to ancestor folders.</dd>
<dt>Local</dt>
<dd>Click <strong>Add Local Directory</strong> to pick any folder on your
computer. Local mode requires a Chromium-based browser (File System
Access API).</dd>
<dt>Browse mode</dt>
<dd>File tree on the left, preview on the right. Click a folder to
expand, click a file to preview. Markdown files open in a full editor;
PDF, image, ZIP, XLSX, DOCX, TIFF all render inline.</dd>
<dt>Grid mode</dt>
<dd>Spreadsheet view of the current subtree's files for bulk
ZDDC renaming. Edit cells directly, copy/paste with Excel,
save back to disk.</dd>
</dl>
<h3>Tree navigation</h3>
<h3>Tree navigation (Browse mode)</h3>
<dl>
<dt>Click a folder</dt>
<dd>Toggle expand/collapse on that folder.</dd>
<dt>Double-click a folder</dt>
<dd>Navigate into the folder — it becomes the new root of the
view. Server mode loads the folder's URL; local mode re-roots
onto that folder's handle.</dd>
<dd>Expand or collapse it inline.</dd>
<dt>Shift-click a folder</dt>
<dd>Recursive expand or collapse — applies to the whole subtree.</dd>
<dd>Recursive expand or collapse — the whole subtree.</dd>
<dt>Click a file</dt>
<dd>Open in the preview popup. Modifier-click (Ctrl/Cmd) or middle-click
opens in a new tab.</dd>
<dd>Preview it in the right pane.</dd>
<dt>⤴ Pop out</dt>
<dd>Open the current preview in a separate window — useful for a second
monitor.</dd>
<dt>ZIP files</dt>
<dd>Behave as folders — click to inspect contents inline. JSZip is
bundled, so this works offline.</dd>
<dt>Column headers</dt>
<dd>Click to sort; click again to reverse.</dd>
<dt>Refresh</dt>
<dd>Re-fetches the current directory listing — works for both
local (re-enumerates the FS handle) and online (re-fetches the JSON).</dd>

View file

@ -132,6 +132,12 @@
<div id="welcomeScreen" class="empty-state empty-state--overlay">
<div class="empty-state__inner empty-state__inner--centered">
<h2>ZDDC Classifier</h2>
<p style="background:var(--bg-secondary);padding:0.75rem 1rem;border-left:3px solid var(--warning);text-align:left;font-size:0.9rem;color:var(--text-muted);margin-bottom:1rem;">
<strong>This standalone tool is being absorbed into the Browse app.</strong>
Browse's <em>Grid</em> view-mode now provides the same spreadsheet
workflow alongside file navigation. This standalone build remains
available for offline use and air-gapped environments.
</p>
<p>Rename a folder of files to ZDDC format using a spreadsheet interface.</p>
<p>Open a directory, fill in tracking number, revision, status, and title for each file, then save — the files are renamed on disk.</p>

View file

@ -11,8 +11,8 @@ output_html="$output_dir/mdedit.html"
# Vendor files (bundled dependencies — no CDN required at runtime)
# Note: Tailwind is NOT a vendor file — it's replaced by css/tailwind-utils.css,
# a hand-written subset of only the utility classes used in template.html.
toastui_js="$root_dir/vendor/toastui-editor-all.min.js"
toastui_css="$root_dir/vendor/toastui-editor.min.css"
toastui_js="$root_dir/../shared/vendor/toastui-editor-all.min.js"
toastui_css="$root_dir/../shared/vendor/toastui-editor.min.css"
mkdir -p "$output_dir"
ensure_exists "$src_html"

View file

@ -58,6 +58,14 @@
<div class="pane-resizer bg-gray-200 dark:bg-gray-700 transition-colors relative z-10 w-1 cursor-col-resize hover:bg-blue-500" data-resizer-for="file-nav"></div>
<div class="pane content-pane flex-1 relative flex flex-col bg-white dark:bg-gray-900 overflow-hidden" id="main-content">
<div id="welcome-banner" style="background:var(--bg-secondary);padding:0.75rem 1rem;border-left:3px solid var(--warning);text-align:left;font-size:0.9rem;color:var(--text-muted);margin:1rem;border-radius:var(--radius)">
<strong>The Browse app now opens markdown files in this same editor.</strong>
Browse provides a unified file tree + per-file-type preview where
<code>.md</code> files render in this Toast UI editor. The
standalone Markdown Editor remains available for offline single-file
editing and air-gapped environments.
</div>
<div id="welcome-screen" class="welcome-screen hidden flex-col items-center justify-center h-full text-gray-500 dark:text-gray-400 text-center p-6">
<p id="welcome-hint" class="text-sm">Click <strong>Scratchpad</strong> in the file list to start editing,<br>or <strong>Add Local Directory</strong> to work with files.</p>
<p id="welcome-firefox" class="text-sm text-amber-600 hidden mt-2">Your browser doesn't support the File System API.<br>Use <strong>Scratchpad</strong> to edit markdown and download as a file.</p>

View file

@ -37,14 +37,39 @@ test.describe('Browse', () => {
await page.locator('#addDirectoryBtn').click();
// Browse swaps from empty state to the populated table.
// Browse swaps from empty state to the two-pane layout. The
// tree pane is on the left; rows are <div class="tree-row">.
await page.waitForSelector('#browseRoot:not(.hidden)', { timeout: 10000 });
await page.waitForFunction(
() => document.querySelectorAll('#browseTable tbody tr.tree-row').length >= 3,
() => document.querySelectorAll('#treeBody .tree-row').length >= 3,
{ timeout: 10000 }
);
const rows = await page.locator('#browseTable tbody tr.tree-row').count();
const rows = await page.locator('#treeBody .tree-row').count();
expect(rows).toBeGreaterThanOrEqual(3);
// Right preview pane is present and starts in the empty state.
await expect(page.locator('#previewPane')).toBeVisible();
await expect(page.locator('#previewBody')).toContainText(/Click a file/);
});
test('clicking a file shows it in the preview pane', async ({ page }) => {
await page.goto(`file://${HTML_PATH}`, { waitUntil: 'load' });
await page.waitForSelector('#addDirectoryBtn', { timeout: 15000 });
await page.evaluate(() => {
window.__setMockDirectory('docs', [
{ name: 'notes.txt', content: 'hello world', size: 11 },
]);
});
await page.locator('#addDirectoryBtn').click();
await page.waitForSelector('#treeBody .tree-row', { timeout: 10000 });
// Click the file row.
await page.locator('#treeBody .tree-row[data-isdir="false"]').first().click();
// Preview title updates to the file name; pop-out button appears.
await expect(page.locator('#previewTitle')).toHaveText(/notes\.txt/);
await expect(page.locator('#previewPopout')).toBeVisible();
});
});

View file

@ -1155,7 +1155,7 @@ body.help-open .app-header {
</svg>
<div class="header-title-group">
<span class="app-header__title" id="table-title">ZDDC Table</span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-beta · 2026-05-10 · mint-pelican-badge</span></span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-alpha · 2026-05-10 20:45:05 · 8758705-dirty</span></span>
</div>
</div>
<div class="header-right">