From 68ede6ab25a02b36bf3e61961ca3fddf43830f3f Mon Sep 17 00:00:00 2001 From: iRedTea Date: Wed, 14 Oct 2026 16:09:02 +0300 Subject: [PATCH] feat: previews of PDF, Word, spreadsheets, CSV and HTML in the file manager; the find bar's buttons back in one row --- internal/server/fsstream.go | 7 +- internal/server/web/static/docview.js | 391 +++++++++++++++++++++++ internal/server/web/templates/files.html | 135 ++++++-- 3 files changed, 514 insertions(+), 19 deletions(-) create mode 100644 internal/server/web/static/docview.js diff --git a/internal/server/fsstream.go b/internal/server/fsstream.go index 319b5ac..0ef0a96 100644 --- a/internal/server/fsstream.go +++ b/internal/server/fsstream.go @@ -199,10 +199,13 @@ var inlineTypes = map[string]string{ ".mp4": "video/mp4", ".m4v": "video/mp4", ".webm": "video/webm", ".ogv": "video/ogg", ".mov": "video/quicktime", ".mp3": "audio/mpeg", ".wav": "audio/wav", ".ogg": "audio/ogg", ".oga": "audio/ogg", ".m4a": "audio/mp4", ".aac": "audio/aac", ".flac": "audio/flac", ".opus": "audio/ogg", + ".pdf": "application/pdf", } // fileHeaders sets what the browser needs to download or show the file. A // file shown in place runs nothing: no scripts in an SVG, whatever it holds. +// A PDF is the exception, as the browser's viewer will not open in a sandbox; +// it runs in that viewer, not in the panel's page. func fileHeaders(w http.ResponseWriter, p string, inline bool) { name := path.Base(p) ctype := "application/octet-stream" @@ -216,7 +219,9 @@ func fileHeaders(w http.ResponseWriter, p string, inline bool) { w.Header().Set("Content-Type", ctype) w.Header().Set("Content-Disposition", disp+"; filename=\""+strings.ReplaceAll(name, "\"", "")+"\"") w.Header().Set("X-Content-Type-Options", "nosniff") - w.Header().Set("Content-Security-Policy", "sandbox; default-src 'none'; img-src data:; style-src 'unsafe-inline'") + if ctype != "application/pdf" { + w.Header().Set("Content-Security-Policy", "sandbox; default-src 'none'; img-src data:; style-src 'unsafe-inline'") + } w.Header().Set("Cache-Control", "no-store") } diff --git a/internal/server/web/static/docview.js b/internal/server/web/static/docview.js new file mode 100644 index 0000000..a6631ff --- /dev/null +++ b/internal/server/web/static/docview.js @@ -0,0 +1,391 @@ +// Light previews of office files for the file manager, with no libraries: +// docx, xlsx and ods are zip archives of XML, opened with the browser's own +// DecompressionStream; csv and tsv are parsed here; an old binary .doc only +// gives up its text. Each turns into plain HTML the page styles itself. +(function () { + 'use strict'; + + const esc = s => String(s).replace(/[&<>"]/g, c => ({'&': '&', '<': '<', '>': '>', '"': '"'}[c])); + + // ---------- zip ---------- + // Reads the central directory; a file is inflated only when asked for. + function openZip(buf) { + const b = buf instanceof Uint8Array ? buf : new Uint8Array(buf); + const dv = new DataView(b.buffer, b.byteOffset, b.byteLength); + let eocd = -1; + for (let i = b.length - 22; i >= Math.max(0, b.length - 65557); i--) { + if (dv.getUint32(i, true) === 0x06054b50) { eocd = i; break; } + } + if (eocd < 0) throw new Error('не zip-архив'); + const count = dv.getUint16(eocd + 10, true); + let p = dv.getUint32(eocd + 16, true); + const files = new Map(); + const dec = new TextDecoder(); + for (let n = 0; n < count && dv.getUint32(p, true) === 0x02014b50; n++) { + const method = dv.getUint16(p + 10, true), size = dv.getUint32(p + 20, true); + const nameLen = dv.getUint16(p + 28, true), extra = dv.getUint16(p + 30, true), comment = dv.getUint16(p + 32, true); + const local = dv.getUint32(p + 42, true); + files.set(dec.decode(b.subarray(p + 46, p + 46 + nameLen)), {method, size, local}); + p += 46 + nameLen + extra + comment; + } + async function bytes(name) { + const f = files.get(name); + if (!f) return null; + const start = f.local + 30 + dv.getUint16(f.local + 26, true) + dv.getUint16(f.local + 28, true); + const raw = b.subarray(start, start + f.size); + if (f.method === 0) return raw; + if (f.method !== 8) throw new Error('сжатие zip, которое браузер не знает'); + const out = new Blob([raw]).stream().pipeThrough(new DecompressionStream('deflate-raw')); + return new Uint8Array(await new Response(out).arrayBuffer()); + } + async function xml(name) { + const raw = await bytes(name); + return raw ? new DOMParser().parseFromString(new TextDecoder().decode(raw), 'application/xml') : null; + } + return {files, bytes, xml}; + } + + // Children of an element by local name, whatever the namespace prefix. + const kids = (el, name) => el ? Array.from(el.children).filter(c => c.localName === name) : []; + const kid = (el, name) => kids(el, name)[0] || null; + const attr = (el, name) => { + if (!el) return null; + for (const a of el.attributes) if (a.localName === name) return a.value; + return null; + }; + + // ---------- docx ---------- + async function docx(buf) { + const z = openZip(buf); + const doc = await z.xml('word/document.xml'); + if (!doc) throw new Error('в архиве нет word/document.xml'); + // Style ids to names, to know headings whatever the language of Word. + const styles = {}; + const st = await z.xml('word/styles.xml'); + if (st) for (const s of st.getElementsByTagNameNS('*', 'style')) { + styles[attr(s, 'styleId')] = (attr(kid(s, 'name'), 'val') || '').toLowerCase(); + } + const body = doc.getElementsByTagNameNS('*', 'body')[0]; + return '
' + blocks(body, styles) + '
'; + } + function blocks(el, styles) { + let html = '', list = false; + for (const c of el ? el.children : []) { + if (c.localName === 'p') { + const pPr = kid(c, 'pPr'); + const isList = !!kid(pPr, 'numPr'); + if (isList && !list) { html += ''; list = false; } + html += para(c, pPr, styles, isList); + } else { + if (list) { html += ''; list = false; } + if (c.localName === 'tbl') html += table(c, styles); + else if (c.localName === 'sdt') html += blocks(kid(c, 'sdtContent'), styles); + } + } + return html + (list ? '' : ''); + } + function para(p, pPr, styles, isList) { + const name = styles[attr(kid(pPr, 'pStyle'), 'val')] || ''; + const h = /^heading (\d)/.exec(name); + const tag = isList ? 'li' : name === 'title' ? 'h1' : h ? 'h' + Math.min(6, +h[1] + 1) : 'p'; + const jc = attr(kid(pPr, 'jc'), 'val'); + const align = jc === 'center' ? 'center' : (jc === 'right' || jc === 'end') ? 'right' : (jc === 'both' ? 'justify' : ''); + const inner = runs(p); + return '<' + tag + (align ? ' style="text-align:' + align + '"' : '') + '>' + (inner || ' ') + ''; + } + function runs(el) { + let html = ''; + for (const c of el.children) { + if (c.localName === 'r') html += run(c); + else if (c.localName === 'hyperlink' || c.localName === 'smartTag' || c.localName === 'ins' || c.localName === 'sdt' || c.localName === 'sdtContent' || c.localName === 'fldSimple') { + const inner = runs(c); + html += c.localName === 'hyperlink' ? '' + inner + '' : inner; + } + } + return html; + } + function run(r) { + const pr = kid(r, 'rPr'); + const on = n => { const e = kid(pr, n); return !!e && attr(e, 'val') !== '0' && attr(e, 'val') !== 'false'; }; + let t = ''; + for (const c of r.children) { + if (c.localName === 't') t += esc(c.textContent); + else if (c.localName === 'tab') t += ' '; + else if (c.localName === 'br' || c.localName === 'cr') t += '
'; + } + if (!t) return ''; + if (on('b')) t = '' + t + ''; + if (on('i')) t = '' + t + ''; + if (kid(pr, 'u') && attr(kid(pr, 'u'), 'val') !== 'none') t = '' + t + ''; + if (on('strike')) t = '' + t + ''; + // A size set on the run itself, as many documents do instead of styles. + const sz = +attr(kid(pr, 'sz'), 'val'); + if (sz && sz !== 24) t = '' + t + ''; + const va = attr(kid(pr, 'vertAlign'), 'val'); + if (va === 'superscript') t = '' + t + ''; + else if (va === 'subscript') t = '' + t + ''; + return t; + } + function table(tbl, styles) { + let html = '
'; + for (const tr of kids(tbl, 'tr')) { + html += ''; + for (const tc of kids(tr, 'tc')) { + const span = attr(kid(kid(tc, 'tcPr'), 'gridSpan'), 'val'); + html += ' 1 ? ' colspan="' + span + '"' : '') + '>' + blocks(tc, styles) + ''; + } + html += ''; + } + return html + '
'; + } + + // ---------- tables: one grid per sheet ---------- + const MAX_ROWS = 2000, MAX_COLS = 60; + function colName(i) { let s = ''; for (i++; i > 0; i = Math.floor((i - 1) / 26)) s = String.fromCharCode(65 + (i - 1) % 26) + s; return s; } + function grid(rows) { + let cols = 0; + for (const r of rows) cols = Math.max(cols, r.length); + cols = Math.min(cols, MAX_COLS); + const cut = rows.length > MAX_ROWS || rows.some(r => r.length > MAX_COLS); + let html = '
'; + for (let c = 0; c < cols; c++) html += ''; + html += ''; + rows.slice(0, MAX_ROWS).forEach((r, i) => { + html += ''; + for (let c = 0; c < cols; c++) { + const v = r[c] == null ? '' : r[c]; + html += '' + esc(v) + ''; + } + html += ''; + }); + html += '
' + colName(c) + '
' + (i + 1) + '
'; + return {html, cut}; + } + // Several sheets: tabs over the grid. + function sheets(list) { + if (!list.length) return '

в книге нет листов

'; + let html = ''; + if (list.length > 1) { + html += '
' + list.map((s, i) => '').join('') + '
'; + } + list.forEach((s, i) => { + const g = grid(s.rows); + html += '
' + g.html + + (g.cut ? '

показаны первые ' + MAX_ROWS + ' строк и ' + MAX_COLS + ' столбцов

' : '') + '
'; + }); + return html; + } + function bindTabs(root) { + root.querySelectorAll('[data-sheet]').forEach(b => b.onclick = () => { + root.querySelectorAll('[data-sheet]').forEach(x => x.classList.toggle('on', x === b)); + root.querySelectorAll('[data-pane]').forEach(p => p.hidden = p.dataset.pane !== b.dataset.sheet); + }); + } + + // ---------- xlsx ---------- + async function xlsx(buf) { + const z = openZip(buf); + const wb = await z.xml('xl/workbook.xml'); + if (!wb) throw new Error('в архиве нет xl/workbook.xml'); + const rels = {}; + const rx = await z.xml('xl/_rels/workbook.xml.rels'); + if (rx) for (const r of rx.getElementsByTagNameNS('*', 'Relationship')) rels[r.getAttribute('Id')] = r.getAttribute('Target'); + const shared = []; + const ss = await z.xml('xl/sharedStrings.xml'); + if (ss) for (const si of ss.getElementsByTagNameNS('*', 'si')) { + shared.push(Array.from(si.getElementsByTagNameNS('*', 't')).filter(t => t.parentElement.localName !== 'rPh').map(t => t.textContent).join('')); + } + const list = []; + for (const s of wb.getElementsByTagNameNS('*', 'sheet')) { + let target = rels[attr(s, 'id')] || ''; + target = target.startsWith('/') ? target.slice(1) : 'xl/' + target.replace(/^\.\//, ''); + const doc = await z.xml(target); + list.push({name: s.getAttribute('name') || '?', rows: doc ? xlsxRows(doc, shared) : []}); + } + return sheets(list); + } + function xlsxRows(doc, shared) { + const rows = []; + for (const row of doc.getElementsByTagNameNS('*', 'row')) { + const ri = (+row.getAttribute('r') || rows.length + 1) - 1; + if (ri >= MAX_ROWS) break; + const out = rows[ri] = rows[ri] || []; + let next = 0; + for (const c of kids(row, 'c')) { + const ref = /^([A-Z]+)/.exec(c.getAttribute('r') || ''); + let ci = next; + if (ref) { ci = 0; for (const ch of ref[1]) ci = ci * 26 + ch.charCodeAt(0) - 64; ci--; } + next = ci + 1; + if (ci >= MAX_COLS + 1) continue; + const t = c.getAttribute('t'), v = kid(c, 'v'); + let val = v ? v.textContent : ''; + if (t === 's') val = shared[+val] || ''; + else if (t === 'inlineStr') val = Array.from(c.getElementsByTagNameNS('*', 't')).map(x => x.textContent).join(''); + else if (t === 'b') val = val === '1' ? 'TRUE' : 'FALSE'; + else if (t !== 'str' && t !== 'e' && val !== '' && !isNaN(val)) val = +val; + out[ci] = val; + } + } + for (let i = 0; i < rows.length; i++) rows[i] = rows[i] || []; + return rows; + } + + // ---------- ods / odt ---------- + async function ods(buf) { + const z = openZip(buf); + const doc = await z.xml('content.xml'); + if (!doc) throw new Error('в архиве нет content.xml'); + const list = []; + for (const t of doc.getElementsByTagNameNS('*', 'table')) { + if (t.parentElement && t.parentElement.localName === 'table-cell') continue; + const rows = []; + for (const r of t.getElementsByTagNameNS('*', 'table-row')) { + const cells = []; + for (const c of r.children) { + if (c.localName !== 'table-cell' && c.localName !== 'covered-table-cell') continue; + const rep = Math.min(+attr(c, 'number-columns-repeated') || 1, MAX_COLS + 1 - cells.length); + const num = attr(c, 'value'); + const text = Array.from(c.getElementsByTagNameNS('*', 'p')).map(p => p.textContent).join('\n'); + const val = attr(c, 'value-type') === 'float' && num != null ? +num : text; + for (let i = 0; i < rep; i++) cells.push(val); + if (cells.length > MAX_COLS) break; + } + while (cells.length && cells[cells.length - 1] === '') cells.pop(); + const rep = Math.min(+attr(r, 'number-rows-repeated') || 1, MAX_ROWS + 1 - rows.length); + for (let i = 0; i < rep; i++) rows.push(cells); + if (rows.length > MAX_ROWS) break; + } + while (rows.length && !rows[rows.length - 1].length) rows.pop(); + list.push({name: attr(t, 'name') || '?', rows}); + } + return sheets(list); + } + async function odt(buf) { + const z = openZip(buf); + const doc = await z.xml('content.xml'); + const body = doc && doc.getElementsByTagNameNS('*', 'text')[0]; + if (!body) throw new Error('в архиве нет текста'); + // Runs of spaces, tabs and breaks are elements of their own in ODF. + const inline = el => { + let html = ''; + for (const n of el.childNodes) { + if (n.nodeType === 3) html += esc(n.nodeValue); + else if (n.localName === 's') html += ' '.repeat(+attr(n, 'c') || 1); + else if (n.localName === 'tab') html += ' '; + else if (n.localName === 'line-break') html += '
'; + else if (n.nodeType === 1) html += inline(n); + } + return html; + }; + const walk = el => { + let html = ''; + for (const c of el.children) { + if (c.localName === 'h') { const l = Math.min(6, (+attr(c, 'outline-level') || 1) + 1); html += '' + inline(c) + ''; } + else if (c.localName === 'p') html += '

' + (inline(c) || ' ') + '

'; + else if (c.localName === 'list') html += ''; + else if (c.localName === 'table') html += '
' + Array.from(c.getElementsByTagNameNS('*', 'table-row')).map(r => + '' + kids(r, 'table-cell').map(td => '').join('') + '').join('') + '
' + walk(td) + '
'; + else if (c.localName === 'section') html += walk(c); + } + return html; + }; + return '
' + walk(body) + '
'; + } + + // ---------- csv / tsv ---------- + function csv(text, tab) { + const first = text.slice(0, text.indexOf('\n') >>> 0 || text.length); + const sep = tab ? '\t' : (first.split(';').length > first.split(',').length ? ';' : ','); + const rows = []; + let row = [], f = '', q = false; + for (let i = 0; i < text.length; i++) { + const c = text[i]; + if (q) { + if (c === '"') { if (text[i + 1] === '"') { f += '"'; i++; } else q = false; } + else f += c; + } else if (c === '"' && f === '') q = true; + else if (c === sep) { row.push(f); f = ''; } + else if (c === '\n' || c === '\r') { + if (c === '\r' && text[i + 1] === '\n') i++; + row.push(f); f = ''; rows.push(row); row = []; + if (rows.length > MAX_ROWS) break; + } else f += c; + } + if (f !== '' || row.length) { row.push(f); rows.push(row); } + return sheets([{name: '', rows: rows.map(r => r.map(v => v !== '' && !isNaN(v) && /^-?[\d.]+$/.test(v.trim()) ? +v : v))}]); + } + + // ---------- old .doc ---------- + // Word 97-2003 is a binary format; without a full parser all that can be + // shown is the text, picked out of the document's WordDocument stream as + // the runs that read as words. + function cfbStream(b, want) { + const dv = new DataView(b.buffer, b.byteOffset, b.byteLength); + if (dv.getUint32(0, true) !== 0xE011CFD0) return null; + const ss = 1 << dv.getUint16(0x1E, true); + const sector = n => 512 + n * ss; + const fat = []; + const addFat = n => { const o = sector(n); for (let i = 0; i < ss / 4; i++) fat.push(dv.getUint32(o + i * 4, true)); }; + for (let i = 0; i < 109; i++) { const n = dv.getUint32(0x4C + i * 4, true); if (n < 0xFFFFFFFA) addFat(n); } + for (let n = dv.getUint32(0x44, true), k = 0; n < 0xFFFFFFFA && k < 1000; k++) { + const o = sector(n); + for (let i = 0; i < ss / 4 - 1; i++) { const m = dv.getUint32(o + i * 4, true); if (m < 0xFFFFFFFA) addFat(m); } + n = dv.getUint32(o + ss - 4, true); + } + const chain = (start, size) => { + const out = new Uint8Array(size); + let n = start, at = 0; + for (let guard = 0; n < 0xFFFFFFFA && at < size && guard < 1e6; guard++) { + const o = sector(n), len = Math.min(ss, size - at); + if (o + len > b.length) break; + out.set(b.subarray(o, o + len), at); + at += len; + n = fat[n]; + } + return out.subarray(0, at); + }; + const dirStart = dv.getUint32(0x30, true); + const dir = chain(dirStart, ss * 64); + const dd = new DataView(dir.buffer, dir.byteOffset, dir.byteLength); + for (let o = 0; o + 128 <= dir.length; o += 128) { + const nameLen = dd.getUint16(o + 0x40, true); + let name = ''; + for (let i = 0; i + 2 < nameLen; i += 2) name += String.fromCharCode(dd.getUint16(o + i, true)); + if (name === want && dd.getUint8(o + 0x42) === 2) return chain(dd.getUint32(o + 0x74, true), dd.getUint32(o + 0x78, true)); + } + return null; + } + function doc(buf) { + const all = new Uint8Array(buf); + const b = cfbStream(all, 'WordDocument') || all; + const ok = c => c === 9 || c === 10 || c === 13 || (c >= 32 && c < 127) || (c >= 0xA0 && c <= 0x24F) || (c >= 0x400 && c <= 0x4FF) || (c >= 0x2010 && c <= 0x2026) || c === 0x2116; + const pieces = []; + let cur = ''; + const flush = min => { + const letters = (cur.match(/[A-Za-zА-Яа-яЁё0-9]/g) || []).length; + if (cur.trim().length >= min && letters >= cur.trim().length * 0.5) pieces.push(cur); + cur = ''; + }; + for (let i = 0; i + 1 < b.length; i += 2) { + const c = b[i] | (b[i + 1] << 8); + if (ok(c)) cur += String.fromCharCode(c); else flush(3); + } + flush(4); + let text = pieces.join('\n'); + if (text.length < 40) { // a document that keeps its text 8-bit + const p8 = []; + for (let i = 0; i < b.length; i++) { + const c = b[i]; + if (c === 9 || c === 10 || c === 13 || (c >= 32 && c < 127)) cur += String.fromCharCode(c); + else { if (cur.trim().length >= 8 && (cur.match(/[A-Za-z]/g) || []).length >= cur.length * 0.5) p8.push(cur); cur = ''; } + } + text = p8.join('\n'); + } + if (!text.trim()) throw new Error('текст не нашёлся'); + return '

старый формат .doc: показан только текст, без оформления

' + + text.split(/\r\n|\r|\n|\x07/).filter(l => l.trim()).map(l => '

' + esc(l) + '

').join('') + '
'; + } + + window.DocView = {docx, xlsx, ods, odt, csv, doc, bindTabs}; +})(); diff --git a/internal/server/web/templates/files.html b/internal/server/web/templates/files.html index 3c5b869..cad1729 100644 --- a/internal/server/web/templates/files.html +++ b/internal/server/web/templates/files.html @@ -75,9 +75,9 @@ background:#0c0f13 repeating-conic-gradient(#151a21 0 25%, #0c0f13 0 50%) 0 0/20px 20px; border-radius:0 0 12px 12px; } .vmedia img, .vmedia video { max-width:100%; max-height:calc(75vh - 32px); object-fit:contain; } .vmedia audio { width:min(100%, 520px); } - /* Find and replace, over the editor: both fields the same width, their - buttons in a column of their own, the close button beside both rows. */ - .find { display:grid; grid-template-columns:minmax(0, 1fr) auto auto; gap:6px 8px; align-items:center; + /* Find and replace, over the editor: both fields the same width, the + buttons of each row after its field. */ + .find { display:grid; grid-template-columns:minmax(0, 1fr) auto; gap:6px 8px; align-items:center; padding:8px 12px; border-bottom:1px solid var(--border); background:var(--panel); } .find .fin { position:relative; grid-column:1; } .find input { width:100%; height:32px; padding:4px 76px 4px 10px; font-size:13px; font-family:ui-monospace,Menlo,monospace; } @@ -85,16 +85,40 @@ .find input.bad { border-color:var(--danger); } .find .cnt { position:absolute; right:10px; top:0; bottom:0; display:flex; align-items:center; pointer-events:none; color:var(--muted); font-size:12px; font-variant-numeric:tabular-nums; } - .find .fbtns { grid-column:2; display:grid; grid-auto-flow:column; grid-auto-columns:1fr; gap:6px; } - .find button { height:32px; min-width:32px; padding:0 10px; font-size:13px; } + .find .fbtns { grid-column:2; justify-self:end; display:flex; gap:6px; } + .find button { height:32px; min-width:32px; padding:0 10px; font-size:13px; + display:inline-flex; align-items:center; justify-content:center; } .find button.tg { background:transparent; border:1px solid var(--border); color:var(--muted); font-family:ui-monospace,Menlo,monospace; } .find button.tg.on { border-color:var(--accent); color:var(--text); background:#12202f; } - .find .fx { grid-column:3; grid-row:1 / span 2; align-self:center; width:32px; height:32px; padding:0; - display:inline-flex; align-items:center; justify-content:center; - background:transparent; border:1px solid rgba(255,92,92,.55); color:var(--danger); } - .find .fx:hover { background:#3a1d1d; border-color:var(--danger); } - .find.ro .fx { grid-row:1; } + .find .fx { color:var(--danger); } .find #frow2, .find #frow2b { grid-row:2; } + /* Documents and tables, drawn in the panel's colours. */ + .vdoc { height:75vh; overflow:auto; background:#0c0f13; border-radius:0 0 12px 12px; } + .vdoc .dv-pdf { display:block; width:100%; height:100%; border:0; background:#525659; } + .vdoc .dv-html { display:block; width:100%; height:100%; border:0; background:#fff; } + .dv-doc { max-width:820px; margin:0 auto; padding:28px 32px 40px; font-size:15px; line-height:1.65; color:var(--text); overflow-wrap:anywhere; } + .dv-doc p { margin:0 0 .7em; } + .dv-doc h1, .dv-doc h2, .dv-doc h3, .dv-doc h4, .dv-doc h5, .dv-doc h6 { margin:1.2em 0 .5em; line-height:1.3; } + .dv-doc h1 { font-size:26px; } .dv-doc h2 { font-size:21px; } .dv-doc h3 { font-size:18px; } .dv-doc h4, .dv-doc h5, .dv-doc h6 { font-size:16px; } + .dv-doc ul { margin:0 0 .7em; padding-left:1.4em; } + .dv-doc li p { margin:0; } + .dv-doc .dv-link { color:var(--accent); } + .dv-table { border-collapse:collapse; margin:.4em 0 1em; width:auto; } + .dv-table td { border:1px solid var(--border); padding:6px 10px; vertical-align:top; font-size:14px; } + .dv-table td p { margin:0; } + .dv-scroll { overflow:auto; } + .dv-grid { max-height:100%; } + .dv-sheet { width:auto; border-collapse:separate; border-spacing:0; font-size:13px; font-variant-numeric:tabular-nums; } + .dv-sheet th, .dv-sheet td { border-right:1px solid var(--border); border-bottom:1px solid var(--border); padding:4px 10px; + white-space:nowrap; max-width:360px; overflow:hidden; text-overflow:ellipsis; text-align:left; } + .dv-sheet thead th { position:sticky; top:0; z-index:1; background:var(--panel); color:var(--muted); font-weight:500; text-align:center; } + .dv-sheet tbody th { position:sticky; left:0; background:var(--panel); color:var(--muted); font-weight:500; text-align:right; } + .dv-sheet thead th:first-child { left:0; z-index:2; } + .dv-sheet td.n { text-align:right; } + .dv-tabs { position:sticky; top:0; z-index:3; display:flex; gap:6px; flex-wrap:wrap; padding:8px 12px; background:var(--panel); border-bottom:1px solid var(--border); } + .dv-tabs button { height:28px; padding:0 12px; font-size:13px; background:transparent; border:1px solid var(--border); color:var(--muted); } + .dv-tabs button.on { border-color:var(--accent); color:var(--text); background:#12202f; } + .dv-note { color:var(--muted); font-size:13px; padding:12px 16px; margin:0; } /* Hex editor: offset, 16 bytes, the same as text. Only the rows in sight are drawn. */ .hexed { position:relative; height:70vh; overflow:auto; outline:none; background:#0c0f13; font:13px/20px ui-monospace,SFMono-Regular,Menlo,monospace; } @@ -204,12 +228,16 @@
- - + + +
-
@@ -218,6 +246,7 @@
+ @@ -233,6 +262,7 @@
+