feat(webssh): let the browser select and copy terminal text
- stop a mouse-down from stealing focus into the hidden input, which collapsed the selection a drag was building; focus comes back on a plain click, and after a real selection it is left where the copy shortcuts need it - preserve a selection across repaints: note it as a row/character offset and put it back after a row is rewritten, so output no longer wipes it, and hold a frame while a drag is in progress - let Ctrl/Cmd+C copy a selection instead of collapsing it first; it still interrupts when nothing is selected - make the mount non-focusable, tint the selection, and document the behaviour
This commit is contained in:
1 parent
d8d70e2bb6
commit
769eeb0f2c
3 files changed
+100
-18
No files matched your search
+12
-6
@@ -52,13 +52,19 @@ Insert/Delete, Tab, Escape and the function keys all send what a terminal sends,
|
||||
and the arrows follow the mode a full-screen program asks for.
|
||||
|
||||
A small bar above the terminal carries the few keys a browser keeps for itself
|
||||
(Ctrl+C, Ctrl+Z, Ctrl+D, Escape, Tab and the arrows), so they are never out of
|
||||
reach.
|
||||
(Ctrl+C, Ctrl+Z, Ctrl+D, Escape, Tab), so they are never out of reach.
|
||||
|
||||
Copy and paste use the platform's own shortcuts — Cmd+C/Cmd+V, Ctrl+V,
|
||||
Shift+Insert — and the browser's right-click menu. Pasting a block of several
|
||||
lines is handed to the shell exactly as the shell asked for it: a shell that
|
||||
enables bracketed paste shows the text in its edit buffer first.
|
||||
Selecting text is left to the browser, so it works like text anywhere: drag
|
||||
across the terminal to select, double-click a word, and the selection survives
|
||||
output arriving underneath it — a repaint puts the highlight back. **Ctrl+C**
|
||||
(or **Cmd+C**) copies a selection, and interrupts the running command when
|
||||
there is none, which is the rule a desktop terminal follows; the right-click
|
||||
menu offers Copy too.
|
||||
|
||||
Pasting uses the platform's shortcuts — Cmd+V/Ctrl+V, Shift+Insert — and a
|
||||
block of several lines is handed to the shell exactly as the shell asked for
|
||||
it: a shell with bracketed paste enabled shows the text in its edit buffer
|
||||
first.
|
||||
|
||||
## Turning it off
|
||||
|
||||
|
||||
@@ -95,6 +95,7 @@
|
||||
this.decoder = new TextDecoder("utf-8");
|
||||
this.disposed = false;
|
||||
this.exited = false;
|
||||
this._selecting = false;
|
||||
this.screen = [];
|
||||
for (var i = 0; i < this.rows; i++) this.screen.push(blankLine(this.cols));
|
||||
this._buildDom();
|
||||
@@ -147,20 +148,35 @@
|
||||
window.addEventListener("keydown", function (e) {
|
||||
if (self.disposed || self.exited) return;
|
||||
if (self.mount.contains(document.activeElement)) return; // already ours
|
||||
// With text selected, focus is on the page rather than the hidden input,
|
||||
// and that is exactly what lets the browser's own copy shortcut work.
|
||||
// Focusing the input here would collapse the selection before the
|
||||
// default copy had a chance to run.
|
||||
if ((e.ctrlKey || e.metaKey) && !e.altKey && !e.shiftKey &&
|
||||
(e.key === "c" || e.key === "C") && String(window.getSelection())) return;
|
||||
self.input.focus();
|
||||
self._onKey(e);
|
||||
});
|
||||
this.mount.addEventListener("paste", function (e) { self._onPaste(e); });
|
||||
this.mount.addEventListener("wheel", function (e) { self._onWheel(e); }, { passive: false });
|
||||
// A click must leave focus on the hidden input, never on the mount itself:
|
||||
// the mount is focusable so it can host the cursor, and the browser's own
|
||||
// focus handling runs after mousedown, hence the deferred call.
|
||||
this.mount.addEventListener("mousedown", function () {
|
||||
setTimeout(function () { self.input.focus(); }, 0);
|
||||
});
|
||||
this.mount.addEventListener("focus", function () { self.input.focus(); });
|
||||
this.mount.addEventListener("focus", function () { self.needFull = true; self._scheduleRender(); });
|
||||
this.mount.addEventListener("blur", function () { self.needFull = true; self._scheduleRender(); });
|
||||
// Selecting text is the browser's own job, so a mouse-down must not move
|
||||
// focus: refocusing the hidden input mid-drag is what collapsed the
|
||||
// selection the drag was building. Leave the gesture alone and pick focus
|
||||
// back up on mouse-up, but only when nothing was selected — after a real
|
||||
// selection focus has to stay away from the input, otherwise the copy
|
||||
// shortcuts would find an empty one.
|
||||
this.mount.addEventListener("mousedown", function () { self._selecting = true; });
|
||||
var endSelect = function () {
|
||||
if (!self._selecting) return;
|
||||
self._selecting = false;
|
||||
self.needFull = true;
|
||||
self._scheduleRender();
|
||||
if (!String(window.getSelection())) self.input.focus();
|
||||
};
|
||||
document.addEventListener("mouseup", endSelect);
|
||||
window.addEventListener("blur", function () { self._selecting = false; });
|
||||
this.input.addEventListener("focus", function () { self.needFull = true; self._scheduleRender(); });
|
||||
this.input.addEventListener("blur", function () { self.needFull = true; self._scheduleRender(); });
|
||||
this._ro = new ResizeObserver(function () { self._onResize(); });
|
||||
this._ro.observe(this.mount);
|
||||
};
|
||||
@@ -518,6 +534,14 @@
|
||||
|
||||
Term.prototype._render = function () {
|
||||
if (this.disposed) return;
|
||||
// A mouse drag is choosing text right now: repainting the rows would
|
||||
// replace the nodes under the pointer and drop the selection being built.
|
||||
// The mouse-up handler reschedules this once the drag is over.
|
||||
if (this._selecting) return;
|
||||
// Output can arrive while text is selected, and rewriting a row's markup is
|
||||
// what destroys the browser's selection. Note where it was — as a row and a
|
||||
// character offset — and put it back after the repaint.
|
||||
var sel = this._captureSelection();
|
||||
var total = this.sb.length + this.screen.length;
|
||||
var maxVy = Math.max(0, total - this.rows);
|
||||
if (this.vy > maxVy) this.vy = maxVy;
|
||||
@@ -540,6 +564,7 @@
|
||||
this.dirty.clear();
|
||||
this.needFull = false;
|
||||
this.lastCursorRow = cursorRow;
|
||||
if (sel) this._restoreSelection(sel);
|
||||
};
|
||||
|
||||
Term.prototype._rowHtml = function (line, live, focused) {
|
||||
@@ -565,6 +590,57 @@
|
||||
return html;
|
||||
};
|
||||
|
||||
// Native selection is what makes the terminal copyable, but a repaint
|
||||
// replaces a row's markup and takes the selection with it. These helpers turn
|
||||
// a live selection into a row/offset pair and back, so it survives a frame of
|
||||
// output. Only selections inside the rows matter; the hidden input's own
|
||||
// selection (IME) is left alone.
|
||||
Term.prototype._captureSelection = function () {
|
||||
var s = window.getSelection();
|
||||
if (!s || s.rangeCount === 0 || s.isCollapsed) return null;
|
||||
var r = s.getRangeAt(0);
|
||||
var a = this._rowOffset(r.startContainer, r.startOffset);
|
||||
if (!a) return null;
|
||||
return { a: a, b: this._rowOffset(r.endContainer, r.endOffset) || a };
|
||||
};
|
||||
|
||||
Term.prototype._rowOffset = function (node, offset) {
|
||||
var el = node.nodeType === 1 ? node : node.parentNode;
|
||||
while (el && el !== this.rowsEl && !(el.classList && el.classList.contains("term-row"))) el = el.parentNode;
|
||||
if (!el || el === this.rowsEl) return null;
|
||||
var row = this.rowEls.indexOf(el);
|
||||
if (row < 0) return null;
|
||||
// Measuring from the top of the row gives the character offset directly,
|
||||
// independent of how the row is split into styled spans.
|
||||
var pre = document.createRange();
|
||||
pre.selectNodeContents(el);
|
||||
try { pre.setEnd(node, offset); } catch (e) { return null; }
|
||||
return { row: row, off: pre.toString().length };
|
||||
};
|
||||
|
||||
Term.prototype._pointAt = function (el, off) {
|
||||
var walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT, null);
|
||||
var n, acc = 0;
|
||||
while ((n = walker.nextNode())) {
|
||||
var len = n.nodeValue.length;
|
||||
if (acc + len >= off) return { node: n, offset: off - acc };
|
||||
acc += len;
|
||||
}
|
||||
return { node: el, offset: el.childNodes.length };
|
||||
};
|
||||
|
||||
Term.prototype._restoreSelection = function (sel) {
|
||||
var elA = this.rowEls[sel.a.row], elB = this.rowEls[sel.b.row];
|
||||
if (!elA || !elB) return;
|
||||
var a = this._pointAt(elA, sel.a.off), b = this._pointAt(elB, sel.b.off);
|
||||
var r = document.createRange();
|
||||
try { r.setStart(a.node, a.offset); r.setEnd(b.node, b.offset); } catch (e) { return; }
|
||||
var s = window.getSelection();
|
||||
if (!s) return;
|
||||
s.removeAllRanges();
|
||||
s.addRange(r);
|
||||
};
|
||||
|
||||
// ---- input ------------------------------------------------------------
|
||||
|
||||
Term.prototype._send = function (s) { if (this.opts.onInput) this.opts.onInput(s); };
|
||||
|
||||
@@ -38,7 +38,8 @@ main>div{height:100%}
|
||||
.term{height:100%;outline:none;padding:.5rem .6rem;background:#0f172a;overflow:hidden;cursor:text;
|
||||
font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,"DejaVu Sans Mono",monospace;
|
||||
font-size:13px;line-height:1.35;color:#e2e8f0}
|
||||
.term-rows{white-space:pre;user-select:text}
|
||||
.term-rows{white-space:pre;-webkit-user-select:text;user-select:text}
|
||||
.term-rows::selection,.term-rows ::selection{background:#2563eb;color:#fff}
|
||||
.term-row{white-space:pre;height:1.35em;overflow:hidden}
|
||||
.term-probe{position:absolute;visibility:hidden;white-space:pre;line-height:1.35}
|
||||
.term-input{position:absolute;top:0;left:0;width:1px;height:1px;padding:0;border:0;opacity:0;
|
||||
@@ -79,7 +80,7 @@ main>div{height:100%}
|
||||
<button type="button" data-ctl="z">^Z</button>
|
||||
<button type="button" data-ctl="d">^D</button>
|
||||
</div>
|
||||
<main><div id="termMount" tabindex="0"></div></main>
|
||||
<main><div id="termMount"></div></main>
|
||||
<div class="ask" id="ask" hidden>
|
||||
<div class="ask-box">
|
||||
<p id="askText"></p>
|
||||
@@ -192,7 +193,6 @@ main>div{height:100%}
|
||||
});
|
||||
|
||||
start();
|
||||
mount.focus();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
Reference in new issue
Block a user