feat: files up to 512 MB as streams, a code editor with highlighting and find/replace, media preview, hex editing; touch selection in the terminal (0.0.7)

This commit is contained in:
redtea committed 2026-10-14 15:52:38 +03:00
1 parent 3e46116a9b
commit e1f5595afc
14 files changed
+1178 -94

No files matched your search

+117
View File
@@ -27,6 +27,12 @@
.keys button:active { background:var(--border); }
.keys button.on { background:var(--accent); border-color:var(--accent); color:#fff; }
.keys button.lock { background:var(--accent); border-color:#fff; color:#fff; }
/* Copy and paste on touch screens: a long press selects, this menu acts. */
.selmenu { position:fixed; left:50%; transform:translateX(-50%); z-index:20; display:flex; gap:4px; padding:4px;
background:var(--panel); border:1px solid var(--border); border-radius:8px; box-shadow:0 6px 24px rgba(0,0,0,.5); }
.selmenu[hidden] { display:none; }
.selmenu button { height:36px; padding:0 12px; font-size:14px; touch-action:manipulation; -webkit-tap-highlight-color:transparent; }
body.touch #terminal { -webkit-user-select:none; user-select:none; -webkit-touch-callout:none; }
</style>
</head>
<body>
@@ -44,6 +50,12 @@
<div class="wrap" style="max-width:none">
<div id="terminal"></div>
</div>
<div class="selmenu" id="selmenu" hidden>
<button type="button" data-a="copy">Копировать</button>
<button type="button" class="btn ghost" data-a="paste">Вставить</button>
<button type="button" class="btn ghost" data-a="all">Всё</button>
<button type="button" class="btn ghost" data-a="close" aria-label="Закрыть">×</button>
</div>
<div class="keys" id="keys">
<button type="button" data-k="esc">ESC</button>
<button type="button" data-k="/">/</button>
@@ -272,6 +284,111 @@
});
function sendResize() { queueIn({ t: 'r', cols: term.cols, rows: term.rows }); }
// --- Selecting and copying on touch screens ---
// xterm.js has no touch selection of its own. A long press selects the word
// under the finger and dragging on from there stretches the selection; a
// menu then copies it or pastes. A plain tap or swipe still types and scrolls.
if (touch) {
const box = document.getElementById('terminal'), menu = document.getElementById('selmenu');
let timer = null, start = null, anchor = null, selecting = false;
function cellAt(x, y) {
const r = box.querySelector('.xterm-screen').getBoundingClientRect();
const col = Math.max(0, Math.min(term.cols - 1, Math.floor((x - r.left) / (r.width / term.cols))));
const row = Math.max(0, Math.min(term.rows - 1, Math.floor((y - r.top) / (r.height / term.rows))));
return { col: col, row: row + term.buffer.active.viewportY };
}
function selectWord(c) {
const line = term.buffer.active.getLine(c.row);
const text = line ? line.translateToString(false) : '';
const word = ch => ch && !/\s/.test(ch);
let a = c.col, b = c.col;
if (word(text[c.col])) {
while (a > 0 && word(text[a - 1])) a--;
while (b < text.length - 1 && word(text[b + 1])) b++;
}
term.select(a, c.row, b - a + 1);
anchor = { from: c.row * term.cols + a, to: c.row * term.cols + b };
}
function stretch(c) {
const p = c.row * term.cols + c.col;
const from = Math.min(anchor.from, p), to = Math.max(anchor.to, p);
term.select(from % term.cols, Math.floor(from / term.cols), to - from + 1);
}
function showMenu() {
const keys = document.getElementById('keys').getBoundingClientRect();
menu.style.top = '';
menu.style.bottom = (window.innerHeight - keys.top + 8) + 'px';
menu.querySelector('[data-a="copy"]').disabled = !term.hasSelection();
menu.hidden = false;
}
function hideMenu() { menu.hidden = true; term.clearSelection(); anchor = null; }
box.addEventListener('touchstart', function (e) {
if (e.touches.length !== 1) { clearTimeout(timer); return; }
const t = e.touches[0];
start = { x: t.clientX, y: t.clientY };
if (!menu.hidden) hideMenu();
clearTimeout(timer);
timer = setTimeout(function () {
selecting = true;
selectWord(cellAt(start.x, start.y));
if (navigator.vibrate) navigator.vibrate(15);
}, 450);
}, { passive: true, capture: true });
box.addEventListener('touchmove', function (e) {
const t = e.touches[0];
if (selecting) {
e.preventDefault(); // no scrolling while the selection stretches
e.stopPropagation();
stretch(cellAt(t.clientX, t.clientY));
} else if (start && Math.hypot(t.clientX - start.x, t.clientY - start.y) > 8) {
clearTimeout(timer); // a swipe: let it scroll
}
}, { passive: false, capture: true });
box.addEventListener('touchend', function (e) {
clearTimeout(timer);
if (!selecting) return;
selecting = false;
e.preventDefault(); // no tap: the keyboard stays as it was
e.stopPropagation();
showMenu();
}, { passive: false, capture: true });
box.addEventListener('contextmenu', function (e) { e.preventDefault(); });
function copyText(text) {
if (navigator.clipboard && window.isSecureContext) return navigator.clipboard.writeText(text);
// Plain http: the old way, through a field that is there for a moment.
const ta = document.createElement('textarea');
ta.value = text;
ta.style.cssText = 'position:fixed;top:0;left:0;opacity:0';
document.body.appendChild(ta);
ta.select();
const ok = document.execCommand('copy');
ta.remove();
return ok ? Promise.resolve() : Promise.reject(new Error('нет доступа к буферу'));
}
menu.addEventListener('pointerdown', function (e) { e.preventDefault(); });
menu.addEventListener('click', function (e) {
const b = e.target.closest('button');
if (!b) return;
const a = b.dataset.a;
if (a === 'copy') {
const text = term.getSelection();
hideMenu();
copyText(text).then(function () { setStatus('скопировано', 'st-ok'); setTimeout(function () { setStatus(connected ? 'подключено' : '', connected ? 'st-ok' : ''); }, 1500); },
function (err) { setStatus('не скопировалось: ' + err.message, 'st-err'); });
} else if (a === 'paste') {
hideMenu();
if (!navigator.clipboard || !navigator.clipboard.readText) { setStatus('вставка: браузер не даёт буфер', 'st-err'); return; }
navigator.clipboard.readText().then(function (t) { if (t) term.paste(t); }, function () { setStatus('вставка: нет доступа к буферу', 'st-err'); });
} else if (a === 'all') {
term.selectAll();
showMenu();
} else {
hideMenu();
}
});
}
function refit() {
if (touch && window.visualViewport) {
// Follow the visible area, so the extra keys sit right above the