From 7d4d2dc9a20ecadeb51cdabb417bdb2a8c019f69 Mon Sep 17 00:00:00 2001 From: ZDDC Date: Sun, 10 May 2026 15:46:51 -0500 Subject: [PATCH] feat(browse): two-pane shell + markdown plugin + grid mode (Phases A/B/C/D) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 , TIFF, ZIP listing, text
). 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: + → 
) M browse/js/preview.js (renderInline / renderInPopup split) M browse/js/events.js (toggle wiring, resizer, click handlers adapted from to
) 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) --- browse/build.sh | 8 +- browse/css/tree.css | 404 +++++++++++------- browse/js/events.js | 126 ++++-- browse/js/grid.js | 104 +++++ browse/js/init.js | 7 + browse/js/preview-markdown.js | 185 ++++++++ browse/js/preview.js | 284 ++++++++---- browse/js/tree.js | 66 +-- browse/template.html | 93 ++-- classifier/template.html | 6 + mdedit/build.sh | 4 +- mdedit/template.html | 10 +- .../vendor/toastui-editor-all.min.js | 0 .../vendor/toastui-editor.min.css | 0 tests/browse.spec.js | 31 +- zddc/internal/handler/tables.html | 2 +- 16 files changed, 957 insertions(+), 373 deletions(-) create mode 100644 browse/js/grid.js create mode 100644 browse/js/preview-markdown.js rename {mdedit => shared}/vendor/toastui-editor-all.min.js (100%) rename {mdedit => shared}/vendor/toastui-editor.min.css (100%) diff --git a/browse/build.sh b/browse/build.sh index 9069990..feed024 100755 --- a/browse/build.sh +++ b/browse/build.sh @@ -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" diff --git a/browse/css/tree.css b/browse/css/tree.css index 0b681fc..8f2381c 100644 --- a/browse/css/tree.css +++ b/browse/css/tree.css @@ -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); } diff --git a/browse/js/events.js b/browse/js/events.js index d0dd5fb..7f8abaa 100644 --- a/browse/js/events.js +++ b/browse/js/events.js @@ -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); - }); - })(ths[i]); + // 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'); + }); } - // 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 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 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); diff --git a/browse/js/grid.js b/browse/js/grid.js new file mode 100644 index 0000000..20dfed9 --- /dev/null +++ b/browse/js/grid.js @@ -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: /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, '&').replace(//g, '>').replace(/"/g, '"'); + } + + 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 = + '
' + + '

Grid mode

' + + '

The classifier (bulk ZDDC rename) workflow runs as an embedded' + + ' iframe and requires the page be served by zddc-server.

' + + '

If you opened this file directly (file://), open the standalone' + + ' classifier.html tool instead — it provides the same' + + ' workflow against a local folder you pick from the file system.

' + + '
'; + return; + } + + if (!classifierAvailableHere()) { + host.innerHTML = + '
' + + '

Grid mode

' + + '

The classifier (bulk ZDDC rename) workflow auto-serves at' + + ' working/, staging/, and' + + ' incoming/ URLs. The current page' + + ' (' + escapeHtml(window.location.pathname) + ') isn\'t' + + ' inside any of those, so classifier isn\'t available here.

' + + '

Navigate browse into a working/ or staging/ folder, then' + + ' switch to Grid.

' + + '
'; + return; + } + + // Compute the iframe src: current page's directory + classifier.html. + var pathname = window.location.pathname || '/'; + if (!pathname.endsWith('/')) { + // Strip trailing /.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(); + } + }; +})(); diff --git a/browse/js/init.js b/browse/js/init.js index 705b41a..e58b4b8 100644 --- a/browse/js/init.js +++ b/browse/js/init.js @@ -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, diff --git a/browse/js/preview-markdown.js b/browse/js/preview-markdown.js new file mode 100644 index 0000000..34cafd9 --- /dev/null +++ b/browse/js/preview-markdown.js @@ -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, '&').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 = + '
' + + 'Toast UI Editor isn\'t bundled in this build.
'; + 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 = + '
' + + 'Could not read ' + escapeHtml(node.name) + ': ' + + escapeHtml(e.message || String(e)) + '
'; + 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 + }; +})(); diff --git a/browse/js/preview.js b/browse/js/preview.js index 51585ef..d35e0cf 100644 --- a/browse/js/preview.js +++ b/browse/js/preview.js @@ -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;
 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 
; 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 = '
' + escapeHtml(msg) + '
'; + } + + function renderError(container, msg) { + container.innerHTML = '
' + + escapeHtml(msg) + '
'; + } + + 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 = ''; + } catch (e) { + renderError(container, e.message || String(e)); + } + return; + } + + // Plain images (jpg/png/gif/webp/svg) → . TIFF goes through preview-lib. + if (preview && preview.isImage(ext) && !preview.isTiff(ext)) { + try { + var imgInfo = await getBlobUrl(node); + container.innerHTML = '' + escapeHtml(node.name)
+                    + ''; + } 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 = + '
'; + } 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 = ''; @@ -128,64 +249,47 @@ + ''; } - 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); - 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 (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)'; + var pre = win.document.createElement('pre'); + pre.className = 'preview-text'; + pre.textContent = text; + c.innerHTML = ''; + c.appendChild(pre); + } else { + c.innerHTML = '
No inline preview for .' + + escapeHtml(ext) + ' — click Download.
'; } - var pre = win.document.createElement('pre'); - pre.className = 'preview-text'; - pre.textContent = text; - c.innerHTML = ''; - c.appendChild(pre); } catch (e) { c.innerHTML = '
Error: ' + escapeHtml(e.message || e) + '
'; } } - 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 = '
Error rendering TIFF: ' - + escapeHtml(e.message || e) + '
'; - } - } - - 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 = '
Error reading ZIP: ' - + escapeHtml(e.message || e) + '
'; - } - } - - 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; // 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 = '
No inline preview for .' - + escapeHtml(ext) + ' — click Download.
'; - } - } + 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 + }; })(); diff --git a/browse/js/tree.js b/browse/js/tree.js index 8a207b6..4d1ae69 100644 --- a/browse/js/tree.js +++ b/browse/js/tree.js @@ -146,57 +146,43 @@ .replace(/>/g, '>').replace(/"/g, '"'); } + // Render a single tree row as a flat
. 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 = '' - + escapeHtml(node.name) + ''; - } 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 = '' + escapeHtml(node.name) + ''; - } + var selected = state.selectedId === node.id ? ' is-selected' : ''; return '' - + '
' - + '' - + '' - + '' - + '' - + ''; + + '" data-iszip="' + node.isZip + '"' + + ' style="padding-left:' + indent + 'rem"' + + ' role="treeitem" tabindex="-1">' + + '' + + '' + iconChar + '' + + '' + + escapeHtml(node.name) + '' + + ''; } 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: diff --git a/browse/template.html b/browse/template.html index 2f3883d..de8e651 100644 --- a/browse/template.html +++ b/browse/template.html @@ -34,40 +34,56 @@
-
-
+
+

ZDDC Browse

-

A simple directory listing for ZDDC archives — and any directory. - Pick how you want to browse:

-
    +

    A two-pane file browser for ZDDC archives — and any directory.

    +
    • Online — when this page is served by zddc-server, the listing for the current directory loads automatically.
    • Local — click Add Local Directory to pick any folder on your computer (Chromium-based browsers).
    -

    Once loaded: click a folder to expand it, shift-click - to expand its entire subtree (or collapse it again), - click column headers to sort. Click any file to open it.

    +

    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 Grid mode to bulk-rename ZDDC files + spreadsheet-style.

' - + '' - + '' - + '' - + '' + iconChar + '' - + nameInner - + '' - + '' + (node.isDir ? '' : fmtSize(node.size)) + '' + (node.isDir ? '' : escapeHtml(node.ext)) + '' + fmtDate(node.modTime) + '
- - - - - - - - - -
Name Size Type Modified
+ + +
+
+
+
+ +
+
+ No file selected + + +
+
+
Click a file in the tree to preview it.
+
+
+
+ + +
@@ -82,35 +98,32 @@

What is Browse?

-

Browse is a directory listing for ZDDC archives — and any directory. It works in two modes:

+

Browse is the ZDDC file experience. Two top-level modes:

-
Online
-
When the page is served by zddc-server, the listing for the current - URL directory loads automatically. Breadcrumbs link to ancestor folders.
-
Local
-
Click Add Local Directory to pick any folder on your - computer. Local mode requires a Chromium-based browser (File System - Access API).
+
Browse mode
+
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.
+
Grid mode
+
Spreadsheet view of the current subtree's files for bulk + ZDDC renaming. Edit cells directly, copy/paste with Excel, + save back to disk.
-

Tree navigation

+

Tree navigation (Browse mode)

Click a folder
-
Toggle expand/collapse on that folder.
-
Double-click a folder
-
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.
+
Expand or collapse it inline.
Shift-click a folder
-
Recursive expand or collapse — applies to the whole subtree.
+
Recursive expand or collapse — the whole subtree.
Click a file
-
Open in the preview popup. Modifier-click (Ctrl/Cmd) or middle-click - opens in a new tab.
+
Preview it in the right pane.
+
⤴ Pop out
+
Open the current preview in a separate window — useful for a second + monitor.
ZIP files
Behave as folders — click to inspect contents inline. JSZip is bundled, so this works offline.
-
Column headers
-
Click to sort; click again to reverse.
Refresh
Re-fetches the current directory listing — works for both local (re-enumerates the FS handle) and online (re-fetches the JSON).
diff --git a/classifier/template.html b/classifier/template.html index 85c1c18..c9387f8 100644 --- a/classifier/template.html +++ b/classifier/template.html @@ -132,6 +132,12 @@

ZDDC Classifier

+

+ This standalone tool is being absorbed into the Browse app. + Browse's Grid view-mode now provides the same spreadsheet + workflow alongside file navigation. This standalone build remains + available for offline use and air-gapped environments. +

Rename a folder of files to ZDDC format using a spreadsheet interface.

Open a directory, fill in tracking number, revision, status, and title for each file, then save — the files are renamed on disk.

diff --git a/mdedit/build.sh b/mdedit/build.sh index ee61d7a..d0184e1 100644 --- a/mdedit/build.sh +++ b/mdedit/build.sh @@ -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" diff --git a/mdedit/template.html b/mdedit/template.html index 484eb65..2de928d 100644 --- a/mdedit/template.html +++ b/mdedit/template.html @@ -58,7 +58,15 @@
-