From 3e8737b7c93017c1444f58059f3b78e551d55e0b Mon Sep 17 00:00:00 2001 From: ZDDC Date: Wed, 3 Jun 2026 08:55:14 -0500 Subject: [PATCH] feat(browse): preview .docx/.xlsx + fix markdown-editor horizontal overflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DOCX/XLSX preview: add renderDocx (docx-preview) and renderXlsx (SheetJS) to shared/preview-lib.js — the natural home alongside renderTiff/ renderZipListing, reusable by every tool. browse dispatches Office files to them in both the inline pane and the pop-out window via the existing preview.isOffice() check, and browse/build.sh now bundles the docx-preview + xlsx vendors. Renderers degrade to a friendly message if a tool doesn't bundle the vendor. Overflow fix: .md-shell used `grid-template-columns: 280px 1fr`. A bare `1fr` is `minmax(auto, 1fr)`, whose `auto` floor is the editor's min-content width (Toast UI's toolbar) — so the content track refused to shrink and the whole shell overflowed #previewBody as the window narrowed instead of the editor reflowing smaller. Switch both tracks to minmax(0, …) in the CSS and in the three JS spots that rewrite the columns on sidebar-drag, and give .md-shell__sidebar min-width: 0. Co-Authored-By: Claude Opus 4.8 (1M context) --- browse/build.sh | 2 + browse/css/tree.css | 10 ++- browse/js/preview-markdown.js | 6 +- browse/js/preview.js | 25 ++++++++ shared/preview-lib.js | 115 ++++++++++++++++++++++++++++++++++ 5 files changed, 154 insertions(+), 4 deletions(-) diff --git a/browse/build.sh b/browse/build.sh index b0a5a56..3f69eba 100755 --- a/browse/build.sh +++ b/browse/build.sh @@ -42,6 +42,8 @@ concat_files \ # without an external HTTP dependency. concat_files \ "../shared/vendor/jszip.min.js" \ + "../shared/vendor/docx-preview.min.js" \ + "../shared/vendor/xlsx.full.min.js" \ "../shared/vendor/utif.min.js" \ "../shared/vendor/js-yaml.min.js" \ "../shared/vendor/codemirror-yaml.min.js" \ diff --git a/browse/css/tree.css b/browse/css/tree.css index ff2a29d..9feafea 100644 --- a/browse/css/tree.css +++ b/browse/css/tree.css @@ -589,7 +589,14 @@ body { .md-shell { display: grid; grid-template-rows: 1fr; - grid-template-columns: 280px 1fr; /* JS overrides on resize */ + /* minmax(0, …) on BOTH tracks is load-bearing: a bare `1fr` is + `minmax(auto, 1fr)`, whose `auto` floor is the editor's min-content + width (Toast UI's toolbar). That floor stops the content track from + shrinking, so the whole shell overflows #previewBody as the window + narrows instead of the editor getting narrower. minmax(0, 1fr) drops + the floor so the editor reflows down to nothing. JS overrides the + column widths on drag — it preserves the minmax(0, …) form. */ + grid-template-columns: minmax(0, 280px) minmax(0, 1fr); grid-template-areas: "sidebar content"; height: 100%; min-height: 0; @@ -606,6 +613,7 @@ body { grid-area: sidebar; display: flex; flex-direction: column; + min-width: 0; min-height: 0; overflow: hidden; border-right: 1px solid var(--border); diff --git a/browse/js/preview-markdown.js b/browse/js/preview-markdown.js index b9854b0..83b3ef8 100644 --- a/browse/js/preview-markdown.js +++ b/browse/js/preview-markdown.js @@ -354,7 +354,7 @@ container.innerHTML = ''; var shell = document.createElement('div'); shell.className = 'md-shell'; - shell.style.gridTemplateColumns = lastSidebarWidth + 'px 1fr'; + shell.style.gridTemplateColumns = 'minmax(0, ' + lastSidebarWidth + 'px) minmax(0, 1fr)'; container.appendChild(shell); // ── Sidebar (col 1): front matter (top) + TOC (bottom) ────────────── @@ -595,7 +595,7 @@ var w = startW + dx; w = Math.max(SIDEBAR_MIN_WIDTH, Math.min(SIDEBAR_MAX_WIDTH, w)); lastSidebarWidth = w; - shell.style.gridTemplateColumns = w + 'px 1fr'; + shell.style.gridTemplateColumns = 'minmax(0, ' + w + 'px) minmax(0, 1fr)'; e.preventDefault(); } function onUp() { @@ -620,7 +620,7 @@ var w = Math.max(SIDEBAR_MIN_WIDTH, Math.min(SIDEBAR_MAX_WIDTH, lastSidebarWidth + step)); lastSidebarWidth = w; - shell.style.gridTemplateColumns = w + 'px 1fr'; + shell.style.gridTemplateColumns = 'minmax(0, ' + w + 'px) minmax(0, 1fr)'; }); })(); diff --git a/browse/js/preview.js b/browse/js/preview.js index f22ffa4..b841b15 100644 --- a/browse/js/preview.js +++ b/browse/js/preview.js @@ -176,6 +176,24 @@ return; } + // Office docs (.docx via docx-preview, .xlsx/.xls via SheetJS) → + // shared/preview-lib renderers. .doc/.ppt etc. fall through to the + // download fallback below. + if (preview && preview.isOffice(ext)) { + try { + var officeBuf = await getArrayBuffer(node); + container.innerHTML = ''; + if (ext === 'docx') { + await preview.renderDocx(document, container, officeBuf, { fileName: node.name }); + } else { + await preview.renderXlsx(document, container, officeBuf, { fileName: node.name }); + } + } catch (e) { + renderError(container, 'Failed to render ' + ext.toUpperCase() + ': ' + (e.message || e)); + } + return; + } + if (preview && preview.isText(ext)) { try { var txtBuf = await getArrayBuffer(node); @@ -273,6 +291,13 @@ } 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.isOffice(ext)) { + var ob = await getArrayBuffer(node); + if (ext === 'docx') { + await preview.renderDocx(win.document, c, ob, { fileName: node.name }); + } else { + await preview.renderXlsx(win.document, c, ob, { fileName: node.name }); + } } else if (preview && preview.isText(ext)) { var txb = await getArrayBuffer(node); var text = new TextDecoder('utf-8', { fatal: false }).decode(txb); diff --git a/shared/preview-lib.js b/shared/preview-lib.js index afb8837..5a50df9 100644 --- a/shared/preview-lib.js +++ b/shared/preview-lib.js @@ -4,15 +4,23 @@ * Cross-tool helpers for previewing file types that need a decoder: * - TIFF (UTIF.js) — multi-page, browser-PDF-viewer-style toolbar * - ZIP listing (JSZip) — sortable file-list view + * - DOCX (docx-preview) — Word-styled pages in a scroll container + * - XLSX/XLS (SheetJS) — sheet-to-HTML table with a sheet tab bar * * Renderers operate on any document (parent window or popup window), so the * same code works for tools whose preview opens in a popup (classifier, * archive, transmittal) and tools that render inline (browse). * + * The DOCX/XLSX renderers expect their vendor lib bundled by the calling + * tool's build.sh (docx-preview.min.js → window.docx, xlsx.full.min.js → + * window.XLSX); they degrade to a friendly message if it isn't present. + * * Public API on window.zddc.preview: * loadLibrary(url) → Promise * renderTiff(doc, container, arrayBuffer, opts) → Promise * renderZipListing(doc, container, arrayBuffer, opts) → Promise + * renderDocx(doc, container, arrayBuffer, opts) → Promise + * renderXlsx(doc, container, arrayBuffer, opts) → Promise * TIFF_EXTENSIONS, IMAGE_EXTENSIONS, TEXT_EXTENSIONS, OFFICE_EXTENSIONS * isTiff(ext), isImage(ext), isText(ext), isZip(ext), isOffice(ext) * @@ -525,6 +533,111 @@ }); } + // ── DOCX (docx-preview) ──────────────────────────────────────────────── + // + // docx-preview renders Word-styled pages with an intrinsic page width, so + // we wrap it in a scroll container: a wide document scrolls WITHIN the + // preview pane rather than pushing the page wider. docx-preview is bundled + // by the tools that opt in (each build.sh concatenates + // shared/vendor/docx-preview.min.js → window.docx). + + var DOCX_CSS = + '.zddc-docx{height:100%;min-width:0;overflow:auto;' + + 'background:var(--bg-secondary,#eee);padding:1rem;box-sizing:border-box;}' + + '.zddc-docx .docx-wrapper{background:transparent;padding:0;}'; + + function renderDocx(doc, container, arrayBuffer, opts) { + opts = opts || {}; + injectStyles(doc, 'zddc-docx-styles', DOCX_CSS); + if (!window.docx || typeof window.docx.renderAsync !== 'function') { + container.innerHTML = '
DOCX preview unavailable ' + + '(renderer not bundled in this tool).
'; + return Promise.resolve(); + } + container.innerHTML = ''; + var scroll = doc.createElement('div'); + scroll.className = 'zddc-docx'; + container.appendChild(scroll); + return Promise.resolve( + window.docx.renderAsync(arrayBuffer, scroll, null, { inWrapper: true }) + ).catch(function (err) { + container.innerHTML = '
' + + 'Failed to render DOCX: ' + escapeHtml(err.message || err) + '
'; + }); + } + + // ── XLSX / XLS (SheetJS) ─────────────────────────────────────────────── + // + // Reads the workbook and renders the active sheet to an HTML table; a tab + // bar switches sheets when there's more than one. SheetJS is bundled by + // the tools that opt in (shared/vendor/xlsx.full.min.js → window.XLSX). + + var XLSX_CSS = + '.zddc-xlsx{display:flex;flex-direction:column;height:100%;min-width:0;' + + 'min-height:0;overflow:hidden;}' + + '.zddc-xlsx__tabs{display:flex;flex-wrap:wrap;gap:0.25rem;padding:0.4rem;' + + 'border-bottom:1px solid var(--border,#ccc);flex:0 0 auto;}' + + '.zddc-xlsx__tab{padding:0.2rem 0.6rem;border:1px solid var(--border,#ccc);' + + 'border-radius:4px;background:var(--bg,#fff);color:var(--text,#222);' + + 'cursor:pointer;font-size:0.85rem;}' + + '.zddc-xlsx__tab.is-active{background:var(--primary,#2563eb);color:#fff;' + + 'border-color:var(--primary,#2563eb);}' + + '.zddc-xlsx__body{flex:1 1 auto;min-width:0;min-height:0;overflow:auto;}' + + '.zddc-xlsx__body table{border-collapse:collapse;font-size:0.85rem;' + + 'color:var(--text,#222);}' + + '.zddc-xlsx__body td,.zddc-xlsx__body th{border:1px solid var(--border,#ddd);' + + 'padding:0.2rem 0.45rem;white-space:nowrap;}'; + + function renderXlsx(doc, container, arrayBuffer, opts) { + opts = opts || {}; + injectStyles(doc, 'zddc-xlsx-styles', XLSX_CSS); + if (!window.XLSX || typeof window.XLSX.read !== 'function') { + container.innerHTML = '
Spreadsheet preview unavailable ' + + '(renderer not bundled in this tool).
'; + return Promise.resolve(); + } + try { + var wb = window.XLSX.read(arrayBuffer, { type: 'array' }); + container.innerHTML = ''; + var rootEl = doc.createElement('div'); + rootEl.className = 'zddc-xlsx'; + var body = doc.createElement('div'); + body.className = 'zddc-xlsx__body'; + + function showSheet(name) { + var sheet = wb.Sheets[name]; + body.innerHTML = sheet + ? window.XLSX.utils.sheet_to_html(sheet, { editable: false }) + : ''; + } + + if (wb.SheetNames.length > 1) { + var tabs = doc.createElement('div'); + tabs.className = 'zddc-xlsx__tabs'; + wb.SheetNames.forEach(function (name, i) { + var btn = doc.createElement('button'); + btn.className = 'zddc-xlsx__tab' + (i === 0 ? ' is-active' : ''); + btn.textContent = name; + btn.addEventListener('click', function () { + var all = tabs.querySelectorAll('.zddc-xlsx__tab'); + for (var j = 0; j < all.length; j++) all[j].classList.remove('is-active'); + btn.classList.add('is-active'); + showSheet(name); + }); + tabs.appendChild(btn); + }); + rootEl.appendChild(tabs); + } + rootEl.appendChild(body); + container.appendChild(rootEl); + showSheet(wb.SheetNames[0]); + } catch (err) { + container.innerHTML = '
' + + 'Failed to render spreadsheet: ' + escapeHtml(err.message || err) + '
'; + } + return Promise.resolve(); + } + // ── Public API ─────────────────────────────────────────────────────────── if (!root.zddc) root.zddc = {}; @@ -541,6 +654,8 @@ loadLibrary: loadLibrary, renderTiff: renderTiff, renderZipListing: renderZipListing, + renderDocx: renderDocx, + renderXlsx: renderXlsx, formatSize: formatSize, formatDate: formatDate };