diff --git a/apps/web/src/lib/selectionActions.browser.test.ts b/apps/web/src/lib/selectionActions.browser.test.ts new file mode 100644 index 0000000000000..553a97f0e5f51 --- /dev/null +++ b/apps/web/src/lib/selectionActions.browser.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it, vi } from "vite-plus/test"; +import { fakeTimers } from "../../test/render"; +import { observeSelectionActions } from "./selectionActions"; + +describe("observeSelectionActions", () => { + it("reports a touch long-press selection that cancels the pointer", async () => { + const element = document.createElement("div"); + element.textContent = "Select this text on a phone"; + document.body.append(element); + const onSelection = vi.fn(); + const actions = observeSelectionActions({ element, onSelection, onDismiss: vi.fn() }); + document.addEventListener("selectionchange", actions.selectionChanged); + using _cleanup = { + [Symbol.dispose]: () => { + document.removeEventListener("selectionchange", actions.selectionChanged); + actions.dispose(); + element.remove(); + }, + }; + + const touch = { bubbles: true, isPrimary: true, pointerType: "touch", button: 0 }; + element.dispatchEvent(new PointerEvent("pointerdown", touch)); + element.dispatchEvent(new MouseEvent("contextmenu", { bubbles: true })); + window.dispatchEvent(new PointerEvent("pointercancel", touch)); + window.getSelection()!.selectAllChildren(element); + + await vi.waitFor(() => expect(onSelection).toHaveBeenCalledWith(null)); + }); + + it("keeps a dismissed touch selection hidden when the viewport scrolls", () => { + const element = document.createElement("div"); + element.textContent = "Select this text on a phone"; + document.body.append(element); + const onSelection = vi.fn(); + const actions = observeSelectionActions({ + element, + restoreAfterScroll: true, + onSelection, + onDismiss: vi.fn(), + }); + using _timers = fakeTimers(); + using _cleanup = { + [Symbol.dispose]: () => { + actions.dispose(); + element.remove(); + }, + }; + + const touch = { bubbles: true, isPrimary: true, pointerType: "touch", button: 0 }; + element.dispatchEvent(new PointerEvent("pointerdown", touch)); + window.dispatchEvent(new PointerEvent("pointercancel", touch)); + actions.selectionChanged(); + vi.advanceTimersByTime(1_000); + expect(onSelection).toHaveBeenCalledTimes(1); + + document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" })); + element.dispatchEvent(new Event("scroll")); + actions.selectionChanged(); + vi.advanceTimersByTime(1_000); + expect(onSelection).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/web/src/lib/selectionActions.ts b/apps/web/src/lib/selectionActions.ts index 9303602097543..2c07a3fbea37e 100644 --- a/apps/web/src/lib/selectionActions.ts +++ b/apps/web/src/lib/selectionActions.ts @@ -62,6 +62,7 @@ export function observeSelectionActions({ let gestureActive = false; let dismissed = false; let restorable = false; + let touch = false; let pointer: SelectionActionPoint | null = null; let timer: number | null = null; let frame: number | null = null; @@ -76,7 +77,7 @@ export function observeSelectionActions({ }; const cancel = () => { cancelPending(); - pointerDown = gestureActive = false; + pointerDown = gestureActive = touch = false; pointer = null; dismissed = true; restorable = false; @@ -86,6 +87,8 @@ export function observeSelectionActions({ onDismiss("cancel"); }; const onContextMenu = () => { + // A touch long-press is how text gets selected, not a context menu. + if (touch) return; cancel(); onDismiss("interaction"); }; @@ -102,6 +105,7 @@ export function observeSelectionActions({ }; const onPointerDown = (event: PointerEvent) => { if (!event.isPrimary) return; + touch = event.pointerType === "touch"; if (isActionTarget(event.target)) { cancel(); return; @@ -123,6 +127,11 @@ export function observeSelectionActions({ gestureActive = event.button === 0 && !event.defaultPrevented; dismissed = !gestureActive; }; + const onPointerCancel = (event: PointerEvent) => { + // Touch long-press selection and panning cancel the pointer; selectionchange and scroll own those. + if (event.pointerType !== "touch") return dismiss(); + pointerDown = gestureActive = false; + }; const onPointerUp = (event: PointerEvent) => { // Preventing pointerdown (for example on a toolbar) can suppress mouseup. if (event.isPrimary && event.button === 0) pointerDown = false; @@ -155,7 +164,8 @@ export function observeSelectionActions({ // Autoscroll during a drag must not cancel that gesture's eventual release. if (!pointerDown) { pointer = null; - if (restoreAfterScroll && !dismissed && restorable) schedule(SELECTION_SCROLL_SETTLE_MS); + if (restoreAfterScroll && (touch || (!dismissed && restorable))) + schedule(SELECTION_SCROLL_SETTLE_MS); else dismissed = true; } onDismiss("cancel"); @@ -165,7 +175,7 @@ export function observeSelectionActions({ element.addEventListener("pointerdown", onSelectionStart); view.addEventListener("pointerup", onPointerUp); view.addEventListener("mouseup", onMouseUp); - view.addEventListener("pointercancel", dismiss); + view.addEventListener("pointercancel", onPointerCancel); document.addEventListener("keydown", onKeyDown); document.addEventListener("focusin", onFocusIn); element.addEventListener("contextmenu", onContextMenu, true); @@ -180,7 +190,11 @@ export function observeSelectionActions({ cancel, selectionChanged() { if (isActionTarget(document.activeElement)) return; - if (pointerDown || gestureActive) { + // Touch selection handles dispatch no pointer events, so wait for the selection to settle. + if (touch) { + onDismiss("cancel"); + schedule(SELECTION_SCROLL_SETTLE_MS); + } else if (pointerDown || gestureActive) { onDismiss("cancel"); } else if (!dismissed && timer === null && frame === null) { schedule(0); @@ -192,7 +206,7 @@ export function observeSelectionActions({ element.removeEventListener("pointerdown", onSelectionStart); view.removeEventListener("pointerup", onPointerUp); view.removeEventListener("mouseup", onMouseUp); - view.removeEventListener("pointercancel", dismiss); + view.removeEventListener("pointercancel", onPointerCancel); document.removeEventListener("keydown", onKeyDown); document.removeEventListener("focusin", onFocusIn); element.removeEventListener("contextmenu", onContextMenu, true); diff --git a/docs/adr/0026-show-cite-for-touch-selections.md b/docs/adr/0026-show-cite-for-touch-selections.md new file mode 100644 index 0000000000000..a0ab19500f257 --- /dev/null +++ b/docs/adr/0026-show-cite-for-touch-selections.md @@ -0,0 +1,21 @@ +# ADR 0026: Show Cite for touch text selections + +- Status: accepted +- Date: 2026-10-26 +- Compared with upstream: `pingdotgg/t3code` at `6ffde5ba3` (2026-10-26) + +Selecting assistant text in a mobile browser (seen on Chrome for Android) never +showed the **Cite** button. A touch selection starts with a long-press. That +long-press fires `contextmenu` and `pointercancel`, and both dismissed the +selection action. The handles that adjust the selection afterwards fire no +pointer events, so the `mouseup` that shows the action on desktop never came. + +[`observeSelectionActions`](../../apps/web/src/lib/selectionActions.ts) now +remembers whether the last primary pointer was touch. For touch it ignores +`contextmenu`, does not dismiss on `pointercancel`, and shows the action once +`selectionchange` or scrolling has settled for 150 ms. An explicit dismissal, +such as Escape, still holds until the next press. Mouse and pen behaviour is +unchanged. + +When updating from upstream, keep the touch path if upstream's observer still +waits for `mouseup`. Remove this if upstream handles touch selections itself.