feat: previews of PDF, Word, spreadsheets, CSV and HTML in the file manager; the find bar's buttons back in one row

This commit is contained in:
redtea committed 2026-10-14 16:09:02 +03:00
1 parent d448c1e3a4
commit 68ede6ab25
3 files changed
+514 -19

No files matched your search

+6 -1
View File
@@ -199,10 +199,13 @@ var inlineTypes = map[string]string{
".mp4": "video/mp4", ".m4v": "video/mp4", ".webm": "video/webm", ".ogv": "video/ogg", ".mov": "video/quicktime", ".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", ".mp3": "audio/mpeg", ".wav": "audio/wav", ".ogg": "audio/ogg", ".oga": "audio/ogg", ".m4a": "audio/mp4",
".aac": "audio/aac", ".flac": "audio/flac", ".opus": "audio/ogg", ".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 // 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. // 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) { func fileHeaders(w http.ResponseWriter, p string, inline bool) {
name := path.Base(p) name := path.Base(p)
ctype := "application/octet-stream" 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-Type", ctype)
w.Header().Set("Content-Disposition", disp+"; filename=\""+strings.ReplaceAll(name, "\"", "")+"\"") w.Header().Set("Content-Disposition", disp+"; filename=\""+strings.ReplaceAll(name, "\"", "")+"\"")
w.Header().Set("X-Content-Type-Options", "nosniff") 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") w.Header().Set("Cache-Control", "no-store")
} }
+391
View File
@@ -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 => ({'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;'}[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 '<div class="dv-doc">' + blocks(body, styles) + '</div>';
}
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 += '<ul>'; list = true; }
if (!isList && list) { html += '</ul>'; list = false; }
html += para(c, pPr, styles, isList);
} else {
if (list) { html += '</ul>'; list = false; }
if (c.localName === 'tbl') html += table(c, styles);
else if (c.localName === 'sdt') html += blocks(kid(c, 'sdtContent'), styles);
}
}
return html + (list ? '</ul>' : '');
}
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 || '&nbsp;') + '</' + tag + '>';
}
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' ? '<u class="dv-link">' + inner + '</u>' : 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 += '&emsp;';
else if (c.localName === 'br' || c.localName === 'cr') t += '<br>';
}
if (!t) return '';
if (on('b')) t = '<b>' + t + '</b>';
if (on('i')) t = '<i>' + t + '</i>';
if (kid(pr, 'u') && attr(kid(pr, 'u'), 'val') !== 'none') t = '<u>' + t + '</u>';
if (on('strike')) t = '<s>' + t + '</s>';
// 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 = '<span style="font-size:' + Math.max(9, Math.min(36, sz / 2)) + 'pt">' + t + '</span>';
const va = attr(kid(pr, 'vertAlign'), 'val');
if (va === 'superscript') t = '<sup>' + t + '</sup>';
else if (va === 'subscript') t = '<sub>' + t + '</sub>';
return t;
}
function table(tbl, styles) {
let html = '<div class="dv-scroll"><table class="dv-table">';
for (const tr of kids(tbl, 'tr')) {
html += '<tr>';
for (const tc of kids(tr, 'tc')) {
const span = attr(kid(kid(tc, 'tcPr'), 'gridSpan'), 'val');
html += '<td' + (span > 1 ? ' colspan="' + span + '"' : '') + '>' + blocks(tc, styles) + '</td>';
}
html += '</tr>';
}
return html + '</table></div>';
}
// ---------- 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 = '<div class="dv-scroll dv-grid"><table class="dv-sheet"><thead><tr><th></th>';
for (let c = 0; c < cols; c++) html += '<th>' + colName(c) + '</th>';
html += '</tr></thead><tbody>';
rows.slice(0, MAX_ROWS).forEach((r, i) => {
html += '<tr><th>' + (i + 1) + '</th>';
for (let c = 0; c < cols; c++) {
const v = r[c] == null ? '' : r[c];
html += '<td' + (typeof v === 'number' ? ' class="n"' : '') + '>' + esc(v) + '</td>';
}
html += '</tr>';
});
html += '</tbody></table></div>';
return {html, cut};
}
// Several sheets: tabs over the grid.
function sheets(list) {
if (!list.length) return '<p class="dv-note">в книге нет листов</p>';
let html = '';
if (list.length > 1) {
html += '<div class="dv-tabs">' + list.map((s, i) => '<button type="button" data-sheet="' + i + '"' + (i ? '' : ' class="on"') + '>' + esc(s.name) + '</button>').join('') + '</div>';
}
list.forEach((s, i) => {
const g = grid(s.rows);
html += '<div class="dv-pane" data-pane="' + i + '"' + (i ? ' hidden' : '') + '>' + g.html +
(g.cut ? '<p class="dv-note">показаны первые ' + MAX_ROWS + ' строк и ' + MAX_COLS + ' столбцов</p>' : '') + '</div>';
});
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 += '&emsp;';
else if (n.localName === 'line-break') html += '<br>';
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 += '<h' + l + '>' + inline(c) + '</h' + l + '>'; }
else if (c.localName === 'p') html += '<p>' + (inline(c) || '&nbsp;') + '</p>';
else if (c.localName === 'list') html += '<ul>' + kids(c, 'list-item').map(li => '<li>' + walk(li) + '</li>').join('') + '</ul>';
else if (c.localName === 'table') html += '<div class="dv-scroll"><table class="dv-table">' + Array.from(c.getElementsByTagNameNS('*', 'table-row')).map(r =>
'<tr>' + kids(r, 'table-cell').map(td => '<td>' + walk(td) + '</td>').join('') + '</tr>').join('') + '</table></div>';
else if (c.localName === 'section') html += walk(c);
}
return html;
};
return '<div class="dv-doc">' + walk(body) + '</div>';
}
// ---------- 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 '<p class="dv-note">старый формат .doc: показан только текст, без оформления</p><div class="dv-doc">' +
text.split(/\r\n|\r|\n|\x07/).filter(l => l.trim()).map(l => '<p>' + esc(l) + '</p>').join('') + '</div>';
}
window.DocView = {docx, xlsx, ods, odt, csv, doc, bindTabs};
})();
+117 -18
View File
@@ -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; } 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 img, .vmedia video { max-width:100%; max-height:calc(75vh - 32px); object-fit:contain; }
.vmedia audio { width:min(100%, 520px); } .vmedia audio { width:min(100%, 520px); }
/* Find and replace, over the editor: both fields the same width, their /* Find and replace, over the editor: both fields the same width, the
buttons in a column of their own, the close button beside both rows. */ buttons of each row after its field. */
.find { display:grid; grid-template-columns:minmax(0, 1fr) auto auto; gap:6px 8px; align-items:center; .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); } padding:8px 12px; border-bottom:1px solid var(--border); background:var(--panel); }
.find .fin { position:relative; grid-column:1; } .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; } .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 input.bad { border-color:var(--danger); }
.find .cnt { position:absolute; right:10px; top:0; bottom:0; display:flex; align-items:center; pointer-events:none; .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; } 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 .fbtns { grid-column:2; justify-self:end; display:flex; gap:6px; }
.find button { height:32px; min-width:32px; padding:0 10px; font-size:13px; } .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 { 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 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; .find .fx { color:var(--danger); }
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 #frow2, .find #frow2b { grid-row:2; } .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. */ /* 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; .hexed { position:relative; height:70vh; overflow:auto; outline:none; background:#0c0f13;
font:13px/20px ui-monospace,SFMono-Regular,Menlo,monospace; } font:13px/20px ui-monospace,SFMono-Regular,Menlo,monospace; }
@@ -204,12 +228,16 @@
<div class="fbtns"> <div class="fbtns">
<button type="button" class="tg" id="fcase" title="Учитывать регистр">Aa</button> <button type="button" class="tg" id="fcase" title="Учитывать регистр">Aa</button>
<button type="button" class="tg" id="fre" title="Регулярное выражение">.*</button> <button type="button" class="tg" id="fre" title="Регулярное выражение">.*</button>
<button type="button" class="btn ghost" id="fprev" title="Предыдущее (Shift+Enter)">↑</button> <button type="button" class="btn ghost" id="fprev" title="Предыдущее (Shift+Enter)" aria-label="Предыдущее">
<button type="button" class="btn ghost" id="fnext" title="Следующее (Enter)">↓</button> <svg width="12" height="12" viewBox="0 0 12 12"><path d="M6 2.5 11 9.5H1z" fill="currentColor"/></svg>
</button>
<button type="button" class="btn ghost" id="fnext" title="Следующее (Enter)" aria-label="Следующее">
<svg width="12" height="12" viewBox="0 0 12 12"><path d="M6 9.5 1 2.5h10z" fill="currentColor"/></svg>
</button>
<button type="button" class="btn ghost fx" id="fclose" title="Закрыть поиск (Esc)" aria-label="Закрыть поиск">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><path d="M18 6 6 18M6 6l12 12"/></svg>
</button>
</div> </div>
<button type="button" class="fx" id="fclose" title="Закрыть поиск (Esc)" aria-label="Закрыть поиск">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><path d="M18 6 6 18M6 6l12 12"/></svg>
</button>
<div class="fin" id="frow2"><input type="text" id="frep" placeholder="Заменить на" spellcheck="false" autocomplete="off"></div> <div class="fin" id="frow2"><input type="text" id="frep" placeholder="Заменить на" spellcheck="false" autocomplete="off"></div>
<div class="fbtns" id="frow2b"> <div class="fbtns" id="frow2b">
<button type="button" class="btn ghost" id="frepone">Заменить</button> <button type="button" class="btn ghost" id="frepone">Заменить</button>
@@ -218,6 +246,7 @@
</div> </div>
<div id="vcode" hidden></div> <div id="vcode" hidden></div>
<div class="vmedia" id="vmedia" hidden></div> <div class="vmedia" id="vmedia" hidden></div>
<div class="vdoc" id="vdoc" hidden></div>
<div class="hexed" id="vhexed" tabindex="0" hidden><div id="hxsp"></div><div class="hx-rows" id="hxrows"></div></div> <div class="hexed" id="vhexed" tabindex="0" hidden><div id="hxsp"></div><div class="hx-rows" id="hxrows"></div></div>
</div> </div>
</dialog> </dialog>
@@ -233,6 +262,7 @@
<div class="toast" id="toast"></div> <div class="toast" id="toast"></div>
<script src="/static/codemirror/codemirror.js"></script> <script src="/static/codemirror/codemirror.js"></script>
<script src="/static/docview.js"></script>
<script> <script>
(function () { (function () {
const agentID = {{.Agent.ID}}; const agentID = {{.Agent.ID}};
@@ -331,6 +361,8 @@
const IMG = /\.(png|jpe?g|gif|webp|bmp|ico|avif|svg)$/i, VID = /\.(mp4|m4v|webm|ogv|mov)$/i, AUD = /\.(mp3|wav|ogg|oga|m4a|aac|flac|opus)$/i; const IMG = /\.(png|jpe?g|gif|webp|bmp|ico|avif|svg)$/i, VID = /\.(mp4|m4v|webm|ogv|mov)$/i, AUD = /\.(mp3|wav|ogg|oga|m4a|aac|flac|opus)$/i;
const HEX_CAP = 64 * 1024 * 1024; // what the hex editor takes in memory const HEX_CAP = 64 * 1024 * 1024; // what the hex editor takes in memory
const DOC = /\.(pdf|docx?|odt|xlsx|ods|csv|tsv|html?)$/i;
const DOC_CAP = 32 * 1024 * 1024; // the biggest document drawn here
// Highlighting by the file's name, then its extension, then its #! line. // Highlighting by the file's name, then its extension, then its #! line.
const BY_EXT = { const BY_EXT = {
@@ -382,7 +414,8 @@
// What the viewer has open now. // What the viewer has open now.
const V = { path: '', kind: '', dirty: false, clean: 0 }; const V = { path: '', kind: '', dirty: false, clean: 0 };
function showPane(which) { function showPane(which) {
for (const [id, k] of [['vcode', 'code'], ['vmedia', 'media'], ['vhexed', 'hex']]) $(id).hidden = which !== k; for (const [id, k] of [['vcode', 'code'], ['vmedia', 'media'], ['vhexed', 'hex'], ['vdoc', 'doc']]) $(id).hidden = which !== k;
if (which !== 'doc') $('vdoc').innerHTML = '';
if (which !== 'code') closeFind(); if (which !== 'code') closeFind();
} }
function setButtons(o) { function setButtons(o) {
@@ -398,6 +431,7 @@
async function view(e) { async function view(e) {
const path = join(cwd, e.name); const path = join(cwd, e.name);
if (IMG.test(e.name) || VID.test(e.name) || AUD.test(e.name)) return viewMedia(path, e.size); if (IMG.test(e.name) || VID.test(e.name) || AUD.test(e.name)) return viewMedia(path, e.size);
if (DOC.test(e.name)) return viewDoc(path, e.size);
const r = await api('read', {path}); const r = await api('read', {path});
if (!r.ok) { toast(r.error || 'ошибка', true); return; } if (!r.ok) { toast(r.error || 'ошибка', true); return; }
if (r.data.binary) return viewHex(path, r.data.size); if (r.data.binary) return viewHex(path, r.data.size);
@@ -421,7 +455,6 @@
V.dirty = false; V.dirty = false;
setButtons({save: editable}); setButtons({save: editable});
$('frow2').hidden = $('frow2b').hidden = !editable; $('frow2').hidden = $('frow2b').hidden = !editable;
$('find').classList.toggle('ro', !editable);
$('vmsg').textContent = fmtSize(d.size) + ' · ' + modeName(mode) + $('vmsg').textContent = fmtSize(d.size) + ' · ' + modeName(mode) +
(d.truncated ? ' · показаны первые ' + fmtSize(EDIT_LIMIT) : '') + (editable ? '' : ' · только чтение (больше ' + fmtSize(EDIT_LIMIT) + ')') + (d.truncated ? ' · показаны первые ' + fmtSize(EDIT_LIMIT) : '') + (editable ? '' : ' · только чтение (больше ' + fmtSize(EDIT_LIMIT) + ')') +
' · Ctrl/Cmd+F - поиск и замена'; ' · Ctrl/Cmd+F - поиск и замена';
@@ -444,7 +477,9 @@
el.onerror = () => { $('vmsg').textContent = fmtSize(size) + ' · браузер не смог это показать'; }; el.onerror = () => { $('vmsg').textContent = fmtSize(size) + ' · браузер не смог это показать'; };
el.src = fsURL('download', path) + '&inline=1'; el.src = fsURL('download', path) + '&inline=1';
box.appendChild(el); box.appendChild(el);
setButtons({text: /\.svg$/i.test(path), hex: true}); // SVG is text: edited as such, not byte by byte.
const svg = /\.svg$/i.test(path);
setButtons({text: svg, hex: !svg});
$('vmsg').textContent = fmtSize(size); $('vmsg').textContent = fmtSize(size);
$('vtext').onclick = async () => { $('vtext').onclick = async () => {
const r = await api('read', {path}); const r = await api('read', {path});
@@ -454,6 +489,70 @@
}; };
$('vhex').onclick = () => { stopMedia(); viewHex(path, size); }; $('vhex').onclick = () => { stopMedia(); viewHex(path, size); };
} }
// PDF goes to the browser's own viewer; the rest is drawn by docview.js.
async function viewDoc(path, size) {
openViewer(path);
V.kind = 'doc';
showPane('doc');
const ext = path.slice(path.lastIndexOf('.') + 1).toLowerCase();
// Text with a preview gets the text editor; the rest, the hex one.
const isText = ext === 'csv' || ext === 'tsv' || ext === 'html' || ext === 'htm';
setButtons({text: isText, hex: !isText});
$('vtext').onclick = async () => {
const r = await api('read', {path});
if (!r.ok) { toast(r.error || 'ошибка', true); return; }
viewText(path, r.data);
};
$('vhex').onclick = () => viewHex(path, size);
const box = $('vdoc');
$('vmsg').textContent = fmtSize(size);
if (ext === 'pdf') {
const src = fsURL('download', path) + '&inline=1';
box.innerHTML = '<iframe class="dv-pdf" title="PDF"></iframe>';
box.firstChild.src = src;
const a = document.createElement('a');
a.href = src; a.target = '_blank'; a.rel = 'noopener'; a.textContent = 'открыть в новой вкладке';
$('vmsg').append(' · ', a);
return;
}
if (ext === 'html' || ext === 'htm') {
// The page as it looks, in a frame that runs none of its scripts.
const r = await api('read', {path});
if (V.path !== path || V.kind !== 'doc') return;
if (!r.ok) { box.innerHTML = '<p class="dv-note">не удалось показать: ' + esc(r.error || 'ошибка') + '</p>'; return; }
const f = document.createElement('iframe');
f.className = 'dv-html';
f.setAttribute('sandbox', '');
f.title = 'HTML';
f.srcdoc = new TextDecoder().decode(b64ToBytes(r.data.content));
box.innerHTML = '';
box.appendChild(f);
if (r.data.truncated) $('vmsg').textContent += ' · показаны первые ' + fmtSize(EDIT_LIMIT);
return;
}
if (size > DOC_CAP) { box.innerHTML = '<p class="dv-note">файл больше ' + fmtSize(DOC_CAP) + ' - для просмотра скачайте его</p>'; return; }
box.innerHTML = '<p class="dv-note">загрузка…</p>';
let html;
try {
setBusy(true);
const r = await fetch(fsURL('download', path));
if (!r.ok) throw new Error((await r.text()).trim() || ('HTTP ' + r.status));
const buf = await r.arrayBuffer();
if (V.path !== path || V.kind !== 'doc') return;
html = ext === 'docx' ? await DocView.docx(buf)
: ext === 'odt' ? await DocView.odt(buf)
: ext === 'xlsx' ? await DocView.xlsx(buf)
: ext === 'ods' ? await DocView.ods(buf)
: ext === 'doc' ? DocView.doc(buf)
: DocView.csv(new TextDecoder().decode(buf), ext === 'tsv');
} catch (err) {
html = '<p class="dv-note">не удалось показать: ' + esc(err.message) + '</p>';
} finally { setBusy(false); }
if (V.path !== path || V.kind !== 'doc') return;
box.innerHTML = html;
DocView.bindTabs(box);
}
function stopMedia() { function stopMedia() {
const el = $('vmedia').firstChild; const el = $('vmedia').firstChild;
if (el && el.pause) el.pause(); if (el && el.pause) el.pause();