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 };