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:
1 parent
d448c1e3a4
commit
68ede6ab25
3 files changed
+514
-19
No files matched your search
@@ -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 @@
|
||||
<div class="fbtns">
|
||||
<button type="button" class="tg" id="fcase" title="Учитывать регистр">Aa</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="fnext" title="Следующее (Enter)">↓</button>
|
||||
<button type="button" class="btn ghost" id="fprev" title="Предыдущее (Shift+Enter)" aria-label="Предыдущее">
|
||||
<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>
|
||||
<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="fbtns" id="frow2b">
|
||||
<button type="button" class="btn ghost" id="frepone">Заменить</button>
|
||||
@@ -218,6 +246,7 @@
|
||||
</div>
|
||||
<div id="vcode" 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>
|
||||
</dialog>
|
||||
@@ -233,6 +262,7 @@
|
||||
<div class="toast" id="toast"></div>
|
||||
|
||||
<script src="/static/codemirror/codemirror.js"></script>
|
||||
<script src="/static/docview.js"></script>
|
||||
<script>
|
||||
(function () {
|
||||
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 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.
|
||||
const BY_EXT = {
|
||||
@@ -382,7 +414,8 @@
|
||||
// What the viewer has open now.
|
||||
const V = { path: '', kind: '', dirty: false, clean: 0 };
|
||||
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();
|
||||
}
|
||||
function setButtons(o) {
|
||||
@@ -398,6 +431,7 @@
|
||||
async function view(e) {
|
||||
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 (DOC.test(e.name)) return viewDoc(path, e.size);
|
||||
const r = await api('read', {path});
|
||||
if (!r.ok) { toast(r.error || 'ошибка', true); return; }
|
||||
if (r.data.binary) return viewHex(path, r.data.size);
|
||||
@@ -421,7 +455,6 @@
|
||||
V.dirty = false;
|
||||
setButtons({save: editable});
|
||||
$('frow2').hidden = $('frow2b').hidden = !editable;
|
||||
$('find').classList.toggle('ro', !editable);
|
||||
$('vmsg').textContent = fmtSize(d.size) + ' · ' + modeName(mode) +
|
||||
(d.truncated ? ' · показаны первые ' + fmtSize(EDIT_LIMIT) : '') + (editable ? '' : ' · только чтение (больше ' + fmtSize(EDIT_LIMIT) + ')') +
|
||||
' · Ctrl/Cmd+F - поиск и замена';
|
||||
@@ -444,7 +477,9 @@
|
||||
el.onerror = () => { $('vmsg').textContent = fmtSize(size) + ' · браузер не смог это показать'; };
|
||||
el.src = fsURL('download', path) + '&inline=1';
|
||||
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);
|
||||
$('vtext').onclick = async () => {
|
||||
const r = await api('read', {path});
|
||||
@@ -454,6 +489,70 @@
|
||||
};
|
||||
$('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() {
|
||||
const el = $('vmedia').firstChild;
|
||||
if (el && el.pause) el.pause();
|
||||
|
||||
Reference in new issue
Block a user