feat: terminal tabs - several machines at once, up to four terminals split in a tab by drag and drop, resizable, renamable; the top bar keeps its status in sight
This commit is contained in:
1 parent
e7b5d28470
commit
67222e2e05
4 files changed
+769
-210
No files matched your search
@@ -9,12 +9,103 @@
|
||||
<style>
|
||||
#status.st-ok { color:#37c871; }
|
||||
#status.st-err { color:#ff5c5c; }
|
||||
/* The page is the terminal: bar, tabs, then the terminals to the bottom. */
|
||||
body { height:100vh; display:flex; flex-direction:column; overflow:hidden; }
|
||||
header.topbar { flex:none; }
|
||||
/* Tabs: folded away while there is one, sliding in with the second. */
|
||||
.tabs { flex:none; display:flex; align-items:flex-end; gap:4px; padding:0 16px; max-height:0; opacity:0; overflow:hidden;
|
||||
transition:max-height .28s ease, opacity .22s ease, padding .28s ease; }
|
||||
body.multi .tabs, body.multi-hint .tabs { max-height:48px; opacity:1; padding-top:10px; }
|
||||
.tablist { position:relative; display:flex; gap:4px; min-width:0; overflow-x:auto; scrollbar-width:none; }
|
||||
.tablist::-webkit-scrollbar { display:none; }
|
||||
.tab { display:flex; align-items:center; gap:8px; flex:0 1 auto; min-width:0; max-width:220px; height:34px; padding:0 6px 0 12px;
|
||||
border:1px solid var(--border); border-bottom:0; border-radius:9px 9px 0 0; background:#11151b; color:var(--muted);
|
||||
font-size:13px; user-select:none; cursor:default; overflow:hidden; white-space:nowrap;
|
||||
transition:background .18s, color .18s, max-width .22s ease, opacity .2s, padding .22s ease; }
|
||||
.tab:hover { color:var(--text); background:#151a21; }
|
||||
.tab.on { background:var(--panel); color:var(--text); }
|
||||
.tab.enter, .tab.leave { max-width:0; opacity:0; padding:0; }
|
||||
.tab.dragging { opacity:.3; }
|
||||
.tab-title { overflow:hidden; text-overflow:ellipsis; min-width:0; }
|
||||
.tab-edit { width:auto; min-width:60px; max-width:150px; height:24px; padding:2px 6px; font-size:13px; }
|
||||
.tab-x, .pane-x { flex:none; width:20px; height:20px; padding:0; display:inline-flex; align-items:center; justify-content:center;
|
||||
background:transparent; border:0; border-radius:5px; color:var(--muted); opacity:.6; transition:opacity .15s, background .15s, color .15s; }
|
||||
.tab-x:hover, .pane-x:hover { opacity:1; background:#3a1d1d; color:var(--danger); }
|
||||
.tab-add { flex:none; width:34px; height:34px; padding:0; display:inline-flex; align-items:center; justify-content:center;
|
||||
border:1px solid var(--border); border-bottom:0; border-radius:9px 9px 0 0; background:transparent; color:var(--muted);
|
||||
font-size:18px; line-height:1; transition:background .18s, color .18s; }
|
||||
.tab-add:hover { background:#151a21; color:var(--text); }
|
||||
.tabmark { position:absolute; top:6px; bottom:0; width:3px; margin-left:-1px; border-radius:2px; background:var(--accent);
|
||||
pointer-events:none; transition:left .12s ease; }
|
||||
.tabmark[hidden] { display:none; }
|
||||
.dot { width:7px; height:7px; border-radius:50%; background:#555d6a; flex:none; transition:background .3s; }
|
||||
.dot.ok { background:var(--ok); } .dot.err { background:var(--danger); } .dot.wait { background:#e6a23c; }
|
||||
/* The terminals' area. */
|
||||
.area { position:relative; flex:1; min-height:0; margin:12px 16px 16px; border:1px solid var(--border); border-radius:10px; background:#0c0f13; overflow:hidden; }
|
||||
body.multi .area { margin-top:0; border-top-left-radius:0; }
|
||||
.float-add { position:absolute; top:8px; right:10px; z-index:6; width:28px; height:28px; padding:0; display:inline-flex;
|
||||
align-items:center; justify-content:center; font-size:17px; line-height:1; color:var(--text);
|
||||
background:transparent; border:1px solid var(--border); opacity:.28; transition:opacity .25s ease, background .25s ease; }
|
||||
.float-add:hover, .float-add:focus-visible { opacity:1; background:var(--panel); }
|
||||
body.multi .float-add { opacity:0; pointer-events:none; }
|
||||
.tabview { position:absolute; inset:8px; opacity:0; visibility:hidden; transform:translateY(4px) scale(.995);
|
||||
transition:opacity .22s ease, transform .22s ease, visibility 0s .22s; }
|
||||
.tabview.on { opacity:1; visibility:visible; transform:none; transition:opacity .22s ease, transform .22s ease, visibility 0s; }
|
||||
.pane { position:absolute; display:flex; flex-direction:column; border-radius:7px; overflow:hidden;
|
||||
transition:left .26s ease, top .26s ease, width .26s ease, height .26s ease, border-color .2s; }
|
||||
.tabview.instant .pane, .pane.instant { transition:none; }
|
||||
.pane-head { display:none; }
|
||||
.tabview.split .pane { border:1px solid var(--border); }
|
||||
.tabview.split .pane.active { border-color:rgba(79,157,255,.7); }
|
||||
.tabview.split .pane-head { display:flex; align-items:center; gap:7px; flex:none; height:26px; padding:0 4px 0 9px;
|
||||
background:#11151b; border-bottom:1px solid var(--border); font-size:12px; color:var(--muted); cursor:grab; user-select:none; }
|
||||
.tabview.split .pane.active .pane-head { color:var(--text); }
|
||||
.pane-title { flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||
.pane-body { flex:1; min-height:0; padding:4px 0 0 4px; }
|
||||
.pane.dragging { opacity:.45; }
|
||||
.gutter { position:absolute; z-index:3; }
|
||||
.gutter.c { cursor:col-resize; } .gutter.r { cursor:row-resize; }
|
||||
.gutter::after { content:""; position:absolute; inset:0; border-radius:3px; background:var(--accent); opacity:0; transition:opacity .15s; }
|
||||
.gutter:hover::after, .gutter.drag::after { opacity:.55; }
|
||||
.hint { position:absolute; z-index:5; display:flex; align-items:center; justify-content:center; text-align:center; padding:8px;
|
||||
border:2px solid var(--accent); border-radius:9px; background:rgba(79,157,255,.14); color:var(--text); font-size:13px;
|
||||
pointer-events:none; transition:left .14s ease, top .14s ease, width .14s ease, height .14s ease; }
|
||||
.hint.bad { border-color:var(--danger); background:rgba(255,92,92,.12); }
|
||||
.hint[hidden] { display:none; }
|
||||
.drag-ghost { position:fixed; left:0; top:0; z-index:60; pointer-events:none; padding:7px 12px; border:1px solid var(--accent);
|
||||
border-radius:8px; background:var(--panel); color:var(--text); font-size:13px; white-space:nowrap;
|
||||
box-shadow:0 12px 30px rgba(0,0,0,.55); transition:transform .04s linear; }
|
||||
.drag-ghost[data-hint]:not([data-hint=""])::after { content:attr(data-hint); display:block; margin-top:3px; font-size:11px; color:var(--muted); }
|
||||
body.dnd, body.dnd * { cursor:grabbing !important; }
|
||||
.empty { position:absolute; inset:0; display:flex; flex-direction:column; align-items:center; justify-content:center; gap:12px; color:var(--muted); }
|
||||
.empty[hidden] { display:none; }
|
||||
/* The "+" menu: a terminal here, or on another machine. */
|
||||
.addmenu { position:fixed; z-index:50; min-width:270px; max-width:340px; max-height:70vh; overflow:auto; padding:6px;
|
||||
background:var(--panel); border:1px solid var(--border); border-radius:10px; box-shadow:0 14px 36px rgba(0,0,0,.55);
|
||||
animation:menu-in .14s ease; }
|
||||
.addmenu[hidden] { display:none; }
|
||||
@keyframes menu-in { from { opacity:0; transform:translateY(-4px); } }
|
||||
.addmenu .item { display:flex; align-items:center; gap:10px; width:100%; padding:8px 10px; border-radius:7px; text-align:left;
|
||||
background:transparent; color:var(--text); font-weight:400; }
|
||||
.addmenu .item:hover:not(:disabled) { background:#12202f; }
|
||||
.addmenu .item:disabled { opacity:.45; cursor:not-allowed; }
|
||||
.addmenu .item b { display:block; font-weight:500; font-size:14px; }
|
||||
.addmenu .item small { display:block; color:var(--muted); font-size:12px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; max-width:240px; }
|
||||
.addmenu .ico { width:22px; text-align:center; color:var(--accent); font-size:16px; flex:none; }
|
||||
.addmenu .chev { margin-left:auto; color:var(--muted); }
|
||||
.addmenu .mlist { border-top:1px solid var(--border); margin-top:4px; padding-top:4px; }
|
||||
/* Touch screens: the page is as tall as what the on-screen keyboard leaves,
|
||||
with a row of extra keys at the bottom, the way Termux does it. */
|
||||
with a row of extra keys at the bottom, the way Termux does it. Tabs are
|
||||
smaller, and there is no splitting. */
|
||||
.keys { display:none; }
|
||||
body.touch { position:fixed; left:0; right:0; top:0; height:100%; display:flex; flex-direction:column; overflow:hidden; }
|
||||
body.touch .wrap { flex:1; min-height:0; display:flex; width:100%; margin:0; padding:6px; }
|
||||
body.touch #terminal { flex:1; min-width:0; height:auto; }
|
||||
body.touch { position:fixed; left:0; right:0; top:0; height:100%; }
|
||||
body.touch .area { margin:6px; }
|
||||
body.touch.multi .area { margin-top:0; }
|
||||
body.touch .tabs { padding-left:6px; padding-right:6px; }
|
||||
body.touch.multi .tabs { padding-top:6px; max-height:38px; }
|
||||
body.touch .tab { height:28px; max-width:140px; font-size:12px; padding:0 2px 0 8px; gap:6px; }
|
||||
body.touch .tab-add { width:30px; height:28px; }
|
||||
body.touch .tabview { inset:4px; }
|
||||
body.touch .keys {
|
||||
display:grid; grid-template-columns:repeat(7, 1fr); gap:4px; flex:none;
|
||||
padding:4px 4px calc(4px + env(safe-area-inset-bottom)); background:var(--panel); border-top:1px solid var(--border);
|
||||
@@ -33,7 +124,7 @@
|
||||
.selmenu[hidden] { display:none; }
|
||||
.selmenu button { width:42px; height:42px; padding:0; display:inline-flex; align-items:center; justify-content:center;
|
||||
touch-action:manipulation; -webkit-tap-highlight-color:transparent; }
|
||||
body.touch #terminal { -webkit-user-select:none; user-select:none; -webkit-touch-callout:none; }
|
||||
body.touch .area { -webkit-user-select:none; user-select:none; -webkit-touch-callout:none; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -45,12 +136,19 @@
|
||||
<a href="/agents/{{.Agent.ID}}/forwards">Порты</a>
|
||||
<a href="/agents/{{.Agent.ID}}/status">Статус</a>
|
||||
<a href="/agents/{{.Agent.ID}}/edit">Изменить</a>
|
||||
<span id="status" class="muted" style="margin-left:28px">подключение…</span>
|
||||
<span id="status" class="muted" style="margin-left:28px"></span>
|
||||
</nav>
|
||||
</header>
|
||||
<div class="wrap" style="max-width:none">
|
||||
<div id="terminal"></div>
|
||||
<div class="tabs" id="tabs">
|
||||
<div class="tablist" id="tablist"><div class="tabmark" id="tabmark" hidden></div></div>
|
||||
<button type="button" class="tab-add" id="tabadd" title="Новый терминал" aria-label="Новый терминал">+</button>
|
||||
</div>
|
||||
<div class="area" id="area">
|
||||
<button type="button" class="float-add" id="floatadd" title="Новый терминал" aria-label="Новый терминал">+</button>
|
||||
<div class="empty" id="empty" hidden>Нет открытых терминалов<button type="button" id="emptyadd">Новый терминал</button></div>
|
||||
<div class="hint" id="hint" hidden></div>
|
||||
</div>
|
||||
<div class="addmenu" id="addmenu" hidden></div>
|
||||
<div class="selmenu" id="selmenu" hidden>
|
||||
<button type="button" data-a="copy" title="Копировать" aria-label="Копировать">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="12" height="12" rx="2"/><path d="M5 15H4a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v1"/></svg>
|
||||
@@ -85,144 +183,12 @@
|
||||
<script src="{{asset "xterm.js"}}"></script>
|
||||
<script src="{{asset "addon-fit.js"}}"></script>
|
||||
<script>
|
||||
(function () {
|
||||
const agentID = {{.Agent.ID}};
|
||||
const statusEl = document.getElementById('status');
|
||||
window.TERM_PAGE = { agent: { id: {{.Agent.ID}}, name: {{.Agent.Name}}, host: {{.Agent.Hostname}}, user: {{.Agent.TerminalUser}} } };
|
||||
// Touch helpers, for the terminal being typed in: the extra keys with their
|
||||
// CTRL / ALT, and selecting and copying with a long press.
|
||||
window.TermKeys = (function () {
|
||||
const touch = window.matchMedia('(pointer: coarse)').matches;
|
||||
if (touch) document.body.classList.add('touch');
|
||||
function setStatus(t, cls) { statusEl.textContent = t; statusEl.className = cls || ''; }
|
||||
|
||||
const term = new Terminal({
|
||||
cursorBlink: true,
|
||||
fontSize: touch ? 13 : 14,
|
||||
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
|
||||
theme: { background: '#0c0f13' }
|
||||
});
|
||||
const fit = new FitAddon.FitAddon();
|
||||
term.loadAddon(fit);
|
||||
term.open(document.getElementById('terminal'));
|
||||
fit.fit();
|
||||
|
||||
function bytesToB64(bytes) {
|
||||
let bin = '';
|
||||
for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
|
||||
return btoa(bin);
|
||||
}
|
||||
function b64ToBytes(b64) {
|
||||
const bin = atob(b64);
|
||||
const u = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++) u[i] = bin.charCodeAt(i);
|
||||
return u;
|
||||
}
|
||||
|
||||
const base = '/api/panel/term/';
|
||||
let sid = null;
|
||||
let closed = false;
|
||||
let connected = false;
|
||||
|
||||
function post(path, body) {
|
||||
return fetch(path, {
|
||||
method: 'POST',
|
||||
headers: body ? { 'Content-Type': 'application/json' } : {},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
keepalive: true
|
||||
});
|
||||
}
|
||||
|
||||
async function open() {
|
||||
try {
|
||||
const r = await post(base + encodeURIComponent(agentID) + '/open');
|
||||
if (!r.ok) {
|
||||
const body = await r.text().catch(() => '');
|
||||
setStatus('ошибка открытия (' + r.status + ')', 'st-err');
|
||||
term.write('\r\n\x1b[31m[не удалось открыть сессию: HTTP ' + r.status + ']\x1b[0m\r\n');
|
||||
if (body) term.write(body.slice(0, 300).replace(/\n/g, '\r\n') + '\r\n');
|
||||
if (r.status === 404) term.write('\x1b[33mМашина не найдена на сервере - возможно, устаревший бинарник сервера без маршрута /open.\x1b[0m\r\n');
|
||||
return;
|
||||
}
|
||||
const j = await r.json();
|
||||
sid = j.session_id;
|
||||
setStatus('ожидание агента…');
|
||||
sendResize();
|
||||
pollLoop();
|
||||
} catch (e) {
|
||||
setStatus('ошибка сети', 'st-err');
|
||||
term.write('\r\n\x1b[31m[ошибка сети: ' + (e && e.message ? e.message : e) + ']\x1b[0m\r\n');
|
||||
}
|
||||
}
|
||||
|
||||
async function pollLoop() {
|
||||
while (!closed && sid) {
|
||||
let r;
|
||||
try {
|
||||
r = await fetch(base + sid + '/out', { cache: 'no-store' });
|
||||
} catch (e) {
|
||||
// transient network hiccup; brief pause then retry
|
||||
await new Promise(res => setTimeout(res, 1000));
|
||||
continue;
|
||||
}
|
||||
if (!r.ok) { finish('[соединение закрыто]'); return; }
|
||||
const j = await r.json();
|
||||
if (j.closed) { finish('[сессия завершена]'); return; }
|
||||
if (j.frames) {
|
||||
for (const f of j.frames) {
|
||||
if (f.exit) { finish('[сессия завершена]'); return; }
|
||||
if (f.data) {
|
||||
if (!connected) { connected = true; setStatus('подключено', 'st-ok'); }
|
||||
term.write(b64ToBytes(f.data));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function finish(msg) {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
setStatus('соединение закрыто', 'muted');
|
||||
term.write('\r\n\x1b[33m' + msg + '\x1b[0m\r\n');
|
||||
}
|
||||
|
||||
// Input goes out one request at a time, so on a slow link a keystroke can
|
||||
// never overtake the one before it; what is typed meanwhile is sent together
|
||||
// next. A request that fails goes again under the same number, and the
|
||||
// server drops one it already has.
|
||||
let inSeq = 0, inBusy = false;
|
||||
const inQueue = [];
|
||||
function queueIn(m) {
|
||||
if (closed || !sid) return;
|
||||
const last = inQueue[inQueue.length - 1];
|
||||
if (last && last.t === 'i' && m.t === 'i') last.text += m.text;
|
||||
else if (last && last.t === 'r' && m.t === 'r') inQueue[inQueue.length - 1] = m;
|
||||
else inQueue.push(m);
|
||||
pumpIn();
|
||||
}
|
||||
async function pumpIn() {
|
||||
if (inBusy || !inQueue.length) return;
|
||||
inBusy = true;
|
||||
while (inQueue.length && !closed) {
|
||||
const m = inQueue.shift();
|
||||
const body = m.t === 'i'
|
||||
? { t: 'i', d: bytesToB64(new TextEncoder().encode(m.text)), seq: ++inSeq }
|
||||
: { t: 'r', cols: m.cols, rows: m.rows, seq: ++inSeq };
|
||||
for (let wait = 300; !closed; wait = Math.min(wait * 2, 5000)) {
|
||||
let r = null;
|
||||
try {
|
||||
r = await fetch(base + sid + '/in', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body)
|
||||
});
|
||||
} catch (e) { /* no answer: send it again */ }
|
||||
if (r && r.status < 500) break; // taken, or refused for good
|
||||
await new Promise(res => setTimeout(res, wait));
|
||||
}
|
||||
}
|
||||
inBusy = false;
|
||||
}
|
||||
function send(data) { queueIn({ t: 'i', text: data }); }
|
||||
// What the keyboard types gets the CTRL / ALT of the extra keys, if armed.
|
||||
term.onData(function (data) { send(withMods(data)); });
|
||||
|
||||
// --- Extra keys (touch screens) ---
|
||||
const cur = () => window.TermPane && window.TermPane.active();
|
||||
// CTRL and ALT: a tap arms the key for the next key press, a long press
|
||||
// keeps it on until tapped again.
|
||||
const mods = { ctrl: 0, alt: 0 }; // 0 off, 1 next key, 2 locked
|
||||
@@ -255,7 +221,7 @@
|
||||
// xterm's modifier parameter: 1 + shift(1) + alt(2) + ctrl(4).
|
||||
const CSI = { up: 'A', down: 'B', right: 'C', left: 'D', home: 'H', end: 'F' };
|
||||
const TILDE = { pgup: 5, pgdn: 6 };
|
||||
function keySeq(k) {
|
||||
function keySeq(k, term) {
|
||||
const m = useMods(), p = 1 + (m.alt ? 2 : 0) + (m.ctrl ? 4 : 0);
|
||||
if (CSI[k]) {
|
||||
if (p > 1) return '\x1b[1;' + p + CSI[k];
|
||||
@@ -286,27 +252,42 @@
|
||||
}
|
||||
return;
|
||||
}
|
||||
send(keySeq(b.dataset.k));
|
||||
const s = cur();
|
||||
if (s) s.send(keySeq(b.dataset.k, s.term));
|
||||
});
|
||||
b.addEventListener('pointercancel', function () { if (press) { clearTimeout(press.timer); press = null; } });
|
||||
b.addEventListener('contextmenu', function (e) { e.preventDefault(); });
|
||||
});
|
||||
|
||||
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.
|
||||
const menu = document.getElementById('selmenu');
|
||||
let selecting = false;
|
||||
function showMenu() {
|
||||
const s = cur();
|
||||
const keys = document.getElementById('keys').getBoundingClientRect();
|
||||
menu.style.top = '';
|
||||
menu.style.bottom = (window.innerHeight - keys.top + 14) + 'px';
|
||||
menu.querySelector('[data-a="copy"]').disabled = !(s && s.term.hasSelection());
|
||||
menu.hidden = false;
|
||||
}
|
||||
function selectionChanged(s) {
|
||||
if (!touch || s !== cur()) return;
|
||||
if (s.term.hasSelection()) showMenu();
|
||||
else if (!selecting) menu.hidden = true;
|
||||
}
|
||||
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 box = document.getElementById('area');
|
||||
let timer = null, start = null, anchor = null;
|
||||
function cellAt(term, el, x, y) {
|
||||
const r = el.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) {
|
||||
function selectWord(term, c) {
|
||||
const line = term.buffer.active.getLine(c.row);
|
||||
const text = line ? line.translateToString(false) : '';
|
||||
const word = ch => ch && !/\s/.test(ch);
|
||||
@@ -318,40 +299,33 @@
|
||||
term.select(a, c.row, b - a + 1);
|
||||
anchor = { from: c.row * term.cols + a, to: c.row * term.cols + b };
|
||||
}
|
||||
function stretch(c) {
|
||||
function stretch(term, 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);
|
||||
}
|
||||
// The menu shows as soon as there is a selection, however it came, and
|
||||
// sits a little above the extra keys.
|
||||
function showMenu() {
|
||||
const keys = document.getElementById('keys').getBoundingClientRect();
|
||||
menu.style.top = '';
|
||||
menu.style.bottom = (window.innerHeight - keys.top + 14) + 'px';
|
||||
menu.querySelector('[data-a="copy"]').disabled = !term.hasSelection();
|
||||
menu.hidden = false;
|
||||
}
|
||||
function hideMenu() { menu.hidden = true; term.clearSelection(); anchor = null; }
|
||||
function hideMenu() { menu.hidden = true; const s = cur(); if (s) s.term.clearSelection(); anchor = null; }
|
||||
box.addEventListener('touchstart', function (e) {
|
||||
if (e.touches.length !== 1) { clearTimeout(timer); return; }
|
||||
if (e.touches.length !== 1 || !e.target.closest('.pane-body')) { clearTimeout(timer); return; }
|
||||
const t = e.touches[0];
|
||||
start = { x: t.clientX, y: t.clientY };
|
||||
if (!menu.hidden) hideMenu();
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(function () {
|
||||
const s = cur();
|
||||
if (!s) return;
|
||||
selecting = true;
|
||||
selectWord(cellAt(start.x, start.y));
|
||||
selectWord(s.term, cellAt(s.term, s.el, start.x, start.y));
|
||||
showMenu();
|
||||
if (navigator.vibrate) navigator.vibrate(15);
|
||||
}, 450);
|
||||
}, { passive: true, capture: true });
|
||||
box.addEventListener('touchmove', function (e) {
|
||||
const t = e.touches[0];
|
||||
if (selecting) {
|
||||
const t = e.touches[0], s = cur();
|
||||
if (selecting && s) {
|
||||
e.preventDefault(); // no scrolling while the selection stretches
|
||||
e.stopPropagation();
|
||||
stretch(cellAt(t.clientX, t.clientY));
|
||||
stretch(s.term, cellAt(s.term, s.el, 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
|
||||
}
|
||||
@@ -365,11 +339,12 @@
|
||||
showMenu();
|
||||
}, { passive: false, capture: true });
|
||||
box.addEventListener('contextmenu', function (e) { e.preventDefault(); });
|
||||
term.onSelectionChange(function () {
|
||||
if (term.hasSelection()) showMenu();
|
||||
else if (!selecting) menu.hidden = true;
|
||||
});
|
||||
|
||||
const statusEl = document.getElementById('status');
|
||||
function flash(t, cls) {
|
||||
statusEl.textContent = t; statusEl.className = cls;
|
||||
setTimeout(function () { const s = cur(); if (s) s.setState(s.state, s.text); }, 1500);
|
||||
}
|
||||
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.
|
||||
@@ -384,52 +359,28 @@
|
||||
}
|
||||
menu.addEventListener('pointerdown', function (e) { e.preventDefault(); });
|
||||
menu.addEventListener('click', function (e) {
|
||||
const b = e.target.closest('button');
|
||||
if (!b) return;
|
||||
const b = e.target.closest('button'), s = cur();
|
||||
if (!b || !s) return;
|
||||
const a = b.dataset.a;
|
||||
if (a === 'copy') {
|
||||
const text = term.getSelection();
|
||||
const text = s.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'); });
|
||||
copyText(text).then(function () { flash('скопировано', 'st-ok'); }, function (err) { flash('не скопировалось: ' + 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'); });
|
||||
if (!navigator.clipboard || !navigator.clipboard.readText) { flash('вставка: браузер не даёт буфер', 'st-err'); return; }
|
||||
navigator.clipboard.readText().then(function (t) { if (t) s.term.paste(t); }, function () { flash('вставка: нет доступа к буферу', 'st-err'); });
|
||||
} else if (a === 'all') {
|
||||
term.selectAll();
|
||||
s.term.selectAll();
|
||||
showMenu();
|
||||
} else {
|
||||
hideMenu();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function refit() {
|
||||
if (touch && window.visualViewport) {
|
||||
// Follow the visible area, so the extra keys sit right above the
|
||||
// on-screen keyboard.
|
||||
const vv = window.visualViewport;
|
||||
document.body.style.top = vv.offsetTop + 'px';
|
||||
document.body.style.height = vv.height + 'px';
|
||||
}
|
||||
fit.fit();
|
||||
}
|
||||
window.addEventListener('resize', refit);
|
||||
if (touch && window.visualViewport) {
|
||||
window.visualViewport.addEventListener('resize', refit);
|
||||
window.visualViewport.addEventListener('scroll', refit);
|
||||
}
|
||||
refit();
|
||||
term.onResize(sendResize);
|
||||
|
||||
window.addEventListener('pagehide', function () {
|
||||
if (sid) navigator.sendBeacon(base + sid + '/close');
|
||||
});
|
||||
|
||||
term.focus();
|
||||
open();
|
||||
return { withMods: withMods, selectionChanged: selectionChanged };
|
||||
})();
|
||||
</script>
|
||||
<script src="{{asset "terminal.js"}}"></script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in new issue
Block a user