| ... | ... | @@ -395,6 +395,15 @@ export function isTerminalPasteShortcut( |
| 395 | 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 | 407 | export function isTerminalCompositionCommitInput(event: Pick<InputEvent, "inputType">): boolean { |
| 399 | 408 | return ( |
| 400 | 409 | event.inputType === "" || |
| ... | ... | @@ -938,6 +947,20 @@ export class GhosttyTerminalSurface { |
| 938 | 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 | 964 | hasSelection(): boolean { |
| 942 | 965 | return this.core.selectionText().length > 0; |
| 943 | 966 | } |
| ... | ... | @@ -1272,6 +1295,12 @@ export class GhosttyTerminalSurface { |
| 1272 | 1295 | this.canvas.setPointerCapture(event.pointerId); |
| 1273 | 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 | 1304 | if (event.button !== 0) return; |
| 1276 | 1305 | const clickCount = this.recordSelectionClick(event); |
| 1277 | 1306 | const link = this.linkAt(event.clientX, event.clientY); |
| ... | ... | @@ -1515,6 +1544,10 @@ export class GhosttyTerminalSurface { |
| 1515 | 1544 | if (this.canvas.hasPointerCapture(event.pointerId)) { |
| 1516 | 1545 | this.canvas.releasePointerCapture(event.pointerId); |
| 1517 | 1546 | } |
| 1547 | if (event.button === 1 && isMiddleClickPastePlatform()) { |
| 1548 | event.preventDefault(); |
| 1549 | return; |
| 1550 | } |
| 1518 | 1551 | if (event.button !== 0) return; |
| 1519 | 1552 | if (!this.selectionMoved && this.selectionMode === "cell") { |
| 1520 | 1553 | this.clearSelection(); |
| ... | ... | @@ -1551,10 +1584,23 @@ export class GhosttyTerminalSurface { |
| 1551 | 1584 | }; |
| 1552 | 1585 | |
| 1553 | 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 | 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 | 1604 | private readonly onContextMenu = (event: MouseEvent) => { |
| 1559 | 1605 | if (shouldReportTerminalMouse(this.core.isMouseTracking(), event)) { |
| 1560 | 1606 | event.preventDefault(); |
| ... | ... | @@ -1644,6 +1690,7 @@ export class GhosttyTerminalSurface { |
| 1644 | 1690 | this.canvas.addEventListener("pointercancel", this.onPointerUp); |
| 1645 | 1691 | this.canvas.addEventListener("wheel", this.onWheel, { passive: false }); |
| 1646 | 1692 | this.canvas.addEventListener("mousedown", this.onMouseDown); |
| 1693 | this.canvas.addEventListener("mouseup", this.onMouseUp); |
| 1647 | 1694 | this.canvas.addEventListener("contextmenu", this.onContextMenu); |
| 1648 | 1695 | this.scrollbar.addEventListener("pointerdown", this.onScrollbarPointerDown); |
| 1649 | 1696 | this.scrollbar.addEventListener("pointermove", this.onScrollbarPointerMove); |
| ... | ... | @@ -1669,6 +1716,7 @@ export class GhosttyTerminalSurface { |
| 1669 | 1716 | this.canvas.removeEventListener("pointercancel", this.onPointerUp); |
| 1670 | 1717 | this.canvas.removeEventListener("wheel", this.onWheel); |
| 1671 | 1718 | this.canvas.removeEventListener("mousedown", this.onMouseDown); |
| 1719 | this.canvas.removeEventListener("mouseup", this.onMouseUp); |
| 1672 | 1720 | this.canvas.removeEventListener("contextmenu", this.onContextMenu); |
| 1673 | 1721 | this.scrollbar.removeEventListener("pointerdown", this.onScrollbarPointerDown); |
| 1674 | 1722 | this.scrollbar.removeEventListener("pointermove", this.onScrollbarPointerMove); |