diff --git a/docs/web-ssh.md b/docs/web-ssh.md
index 17ddd01..192e0b0 100644
--- a/docs/web-ssh.md
+++ b/docs/web-ssh.md
@@ -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
diff --git a/src/internal/panel/assets/term.js b/src/internal/panel/assets/term.js
index 6470d8d..3040e9e 100644
--- a/src/internal/panel/assets/term.js
+++ b/src/internal/panel/assets/term.js
@@ -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); };
diff --git a/src/internal/panel/templates/webssh.html b/src/internal/panel/templates/webssh.html
index f962aeb..8409bd9 100644
--- a/src/internal/panel/templates/webssh.html
+++ b/src/internal/panel/templates/webssh.html
@@ -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%}
-