| ... | @@ -395,6 +395,15 @@ export function isTerminalPasteShortcut( | ... | @@ -395,6 +395,15 @@ export function isTerminalPasteShortcut( |
| 395 | return isMacPlatform(platform) ? event.metaKey : event.ctrlKey && event.shiftKey; | 395 | return isMacPlatform(platform) ? event.metaKey : event.ctrlKey && event.shiftKey; |
| 396 | } | 396 | } |
| 397 | | 397 | |
| | 398 | /** |
| | 399 | * Middle-click paste is an X11/Wayland convention. macOS and Windows have no |
| | 400 | * primary selection and use the button for autoscroll, so only desktops that |
| | 401 | * expect the gesture get it. |
| | 402 | */ |
| | 403 | function isMiddleClickPastePlatform(): boolean { |
| | 404 | return /linux|bsd/i.test(navigator.platform); |
| | 405 | } |
| | 406 | |
| 398 | export function isTerminalCompositionCommitInput(event: Pick<InputEvent, "inputType">): boolean { | 407 | export function isTerminalCompositionCommitInput(event: Pick<InputEvent, "inputType">): boolean { |
| 399 | return ( | 408 | return ( |
| 400 | event.inputType === "" || | 409 | event.inputType === "" || |
| ... | @@ -938,6 +947,20 @@ export class GhosttyTerminalSurface { | ... | @@ -938,6 +947,20 @@ export class GhosttyTerminalSurface { |
| 938 | if (encoded.length > 0) this.options.onData(encoded); | 947 | if (encoded.length > 0) this.options.onData(encoded); |
| 939 | } | 948 | } |
| 940 | | 949 | |
| | 950 | /** |
| | 951 | * Middle-click pastes the terminal's own selection, which is the only |
| | 952 | * primary-selection-like buffer a browser can read. It goes through |
| | 953 | * pasteFromClipboard so it joins the same paste race as every other path. |
| | 954 | * With nothing selected here there is no buffer to paste, and CLIPBOARD is |
| | 955 | * deliberately not substituted: middle-click must never emit text the user |
| | 956 | * only ever copied. |
| | 957 | */ |
| | 958 | private pasteTerminalSelection(): void { |
| | 959 | const selection = this.getSelection(); |
| | 960 | if (selection.length === 0) return; |
| | 961 | void this.pasteFromClipboard(() => Promise.resolve(selection)); |
| | 962 | } |
| | 963 | |
| 941 | hasSelection(): boolean { | 964 | hasSelection(): boolean { |
| 942 | return this.core.selectionText().length > 0; | 965 | return this.core.selectionText().length > 0; |
| 943 | } | 966 | } |
| ... | @@ -1272,6 +1295,12 @@ export class GhosttyTerminalSurface { | ... | @@ -1272,6 +1295,12 @@ export class GhosttyTerminalSurface { |
| 1272 | this.canvas.setPointerCapture(event.pointerId); | 1295 | this.canvas.setPointerCapture(event.pointerId); |
| 1273 | return; | 1296 | return; |
| 1274 | } | 1297 | } |
| | 1298 | if (event.button === 1 && isMiddleClickPastePlatform()) { |
| | 1299 | // Left uncancelled on purpose: cancelling pointerdown drops the |
| | 1300 | // compatibility mousedown, which is what activates a split pane. |
| | 1301 | this.pasteTerminalSelection(); |
| | 1302 | return; |
| | 1303 | } |
| 1275 | if (event.button !== 0) return; | 1304 | if (event.button !== 0) return; |
| 1276 | const clickCount = this.recordSelectionClick(event); | 1305 | const clickCount = this.recordSelectionClick(event); |
| 1277 | const link = this.linkAt(event.clientX, event.clientY); | 1306 | const link = this.linkAt(event.clientX, event.clientY); |
| ... | @@ -1515,6 +1544,10 @@ export class GhosttyTerminalSurface { | ... | @@ -1515,6 +1544,10 @@ export class GhosttyTerminalSurface { |
| 1515 | if (this.canvas.hasPointerCapture(event.pointerId)) { | 1544 | if (this.canvas.hasPointerCapture(event.pointerId)) { |
| 1516 | this.canvas.releasePointerCapture(event.pointerId); | 1545 | this.canvas.releasePointerCapture(event.pointerId); |
| 1517 | } | 1546 | } |
| | 1547 | if (event.button === 1 && isMiddleClickPastePlatform()) { |
| | 1548 | event.preventDefault(); |
| | 1549 | return; |
| | 1550 | } |
| 1518 | if (event.button !== 0) return; | 1551 | if (event.button !== 0) return; |
| 1519 | if (!this.selectionMoved && this.selectionMode === "cell") { | 1552 | if (!this.selectionMoved && this.selectionMode === "cell") { |
| 1520 | this.clearSelection(); | 1553 | this.clearSelection(); |
| ... | @@ -1551,10 +1584,23 @@ export class GhosttyTerminalSurface { | ... | @@ -1551,10 +1584,23 @@ export class GhosttyTerminalSurface { |
| 1551 | }; | 1584 | }; |
| 1552 | | 1585 | |
| 1553 | private readonly onMouseDown = (event: MouseEvent) => { | 1586 | private readonly onMouseDown = (event: MouseEvent) => { |
| 1554 | if (event.button === 0) event.preventDefault(); | 1587 | // Cancelling the middle button here stops autoscroll while still letting |
| | 1588 | // the event bubble to the drawer handler that activates a split pane. |
| | 1589 | if (event.button === 0 || (event.button === 1 && isMiddleClickPastePlatform())) { |
| | 1590 | event.preventDefault(); |
| | 1591 | } |
| 1555 | this.focus(); | 1592 | this.focus(); |
| 1556 | }; | 1593 | }; |
| 1557 | | 1594 | |
| | 1595 | /** |
| | 1596 | * Chromium pastes PRIMARY into the focused editable on a middle mouseup, and |
| | 1597 | * the hidden textarea is focused, so leaving the default alive would deliver |
| | 1598 | * a second paste through onPaste on top of the one onPointerDown sent. |
| | 1599 | */ |
| | 1600 | private readonly onMouseUp = (event: MouseEvent) => { |
| | 1601 | if (event.button === 1 && isMiddleClickPastePlatform()) event.preventDefault(); |
| | 1602 | }; |
| | 1603 | |
| 1558 | private readonly onContextMenu = (event: MouseEvent) => { | 1604 | private readonly onContextMenu = (event: MouseEvent) => { |
| 1559 | if (shouldReportTerminalMouse(this.core.isMouseTracking(), event)) { | 1605 | if (shouldReportTerminalMouse(this.core.isMouseTracking(), event)) { |
| 1560 | event.preventDefault(); | 1606 | event.preventDefault(); |
| ... | @@ -1644,6 +1690,7 @@ export class GhosttyTerminalSurface { | ... | @@ -1644,6 +1690,7 @@ export class GhosttyTerminalSurface { |
| 1644 | this.canvas.addEventListener("pointercancel", this.onPointerUp); | 1690 | this.canvas.addEventListener("pointercancel", this.onPointerUp); |
| 1645 | this.canvas.addEventListener("wheel", this.onWheel, { passive: false }); | 1691 | this.canvas.addEventListener("wheel", this.onWheel, { passive: false }); |
| 1646 | this.canvas.addEventListener("mousedown", this.onMouseDown); | 1692 | this.canvas.addEventListener("mousedown", this.onMouseDown); |
| | 1693 | this.canvas.addEventListener("mouseup", this.onMouseUp); |
| 1647 | this.canvas.addEventListener("contextmenu", this.onContextMenu); | 1694 | this.canvas.addEventListener("contextmenu", this.onContextMenu); |
| 1648 | this.scrollbar.addEventListener("pointerdown", this.onScrollbarPointerDown); | 1695 | this.scrollbar.addEventListener("pointerdown", this.onScrollbarPointerDown); |
| 1649 | this.scrollbar.addEventListener("pointermove", this.onScrollbarPointerMove); | 1696 | this.scrollbar.addEventListener("pointermove", this.onScrollbarPointerMove); |
| ... | @@ -1669,6 +1716,7 @@ export class GhosttyTerminalSurface { | ... | @@ -1669,6 +1716,7 @@ export class GhosttyTerminalSurface { |
| 1669 | this.canvas.removeEventListener("pointercancel", this.onPointerUp); | 1716 | this.canvas.removeEventListener("pointercancel", this.onPointerUp); |
| 1670 | this.canvas.removeEventListener("wheel", this.onWheel); | 1717 | this.canvas.removeEventListener("wheel", this.onWheel); |
| 1671 | this.canvas.removeEventListener("mousedown", this.onMouseDown); | 1718 | this.canvas.removeEventListener("mousedown", this.onMouseDown); |
| | 1719 | this.canvas.removeEventListener("mouseup", this.onMouseUp); |
| 1672 | this.canvas.removeEventListener("contextmenu", this.onContextMenu); | 1720 | this.canvas.removeEventListener("contextmenu", this.onContextMenu); |
| 1673 | this.scrollbar.removeEventListener("pointerdown", this.onScrollbarPointerDown); | 1721 | this.scrollbar.removeEventListener("pointerdown", this.onScrollbarPointerDown); |
| 1674 | this.scrollbar.removeEventListener("pointermove", this.onScrollbarPointerMove); | 1722 | this.scrollbar.removeEventListener("pointermove", this.onScrollbarPointerMove); |