From 155f71f81a7e92abc4142ded5c2d94bf456c0ee4 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Thu, 13 Aug 2026 00:36:45 -0400 Subject: [PATCH] =?UTF-8?q?=F0=9F=93=B1=20fix:=20Show=20Quote=20Popup=20fo?= =?UTF-8?q?r=20Block=20Selections=20and=20on=20Touch=20Devices=20(#14777)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ๐Ÿ“ฑ fix: Show Quote Popup for Block Selections and on Touch Devices The "Add to chat" popup never appeared for two whole classes of selection. Block-granularity gestures (triple-click, double-click then word-drag) park the selection's far boundary at the start of the next block. For a message's closing block that boundary sits outside `.message-render` โ€” on the composer wrapper or the following message row โ€” while selecting no text there, so the anchor/focus equality check suppressed the popup. Triple-clicking any earlier paragraph worked, which is what made this look like an edge case. The range is now clamped to the message before the check, and selections that really do carry visible text from another message are still refused. Touch platforms could not reach the feature at all. A long-press, and every drag of the native selection handles, emits no mouse event whatsoever โ€” only `selectionchange` โ€” while the popup was shown exclusively from mouseup, dblclick and keyup. Showing now also hangs off a settle-debounced `selectionchange`, gated so an in-progress mouse drag still cannot flicker it. Accepting was broken independently: the tap is also the gesture that dismisses the selection, unmounting the button before `click` could land, so touch commits on `pointerdown` instead. The desktop mousedown path is deliberately unchanged, since preventDefault on `pointerdown` can suppress the compatibility mousedown that click depends on. Two UX consequences of the same code: scrolling re-anchors the popup rather than dismissing it on the first event (the chat auto-scrolls constantly while streaming, and a mobile URL bar collapsing fires resize), and touch selections place the button below the text, clear of the OS Copy/Share callout, with a 44px tap target. Covered by six e2e tests โ€” three desktop, three on an emulated Pixel 5 with a real touchscreen โ€” each verified to fail against the pre-fix build. * ๐Ÿฉน fix: Address Review Findings and Repair the Scroll Specs The two failing e2e shards were a defect in the specs, not the component. `scrollMessages` reached for `.scrollbar-gutter-stable` with a document-wide query, but the nav and side panels carry that class too, so it could grab a sidebar list that never scrolls โ€” 0px moved, and only in CI, where the nav renders differently. The scroller is now reached from the message itself, the way `MessageNav` does it. The specs also centre the selection first and nudge by a quarter of the visible height, so the gesture cannot scroll the selection clean out of view and then blame the popup for going with it. Review findings, all in `QuoteButton`: Visibility was tested against the window, but the list scrolls inside a bounded container, so text can sit clipped under the header or the composer while its un-clipped rect is still inside the window โ€” leaving the popup floating over unrelated UI. It is now clipped to the nearest scrollable ancestor. Touch committed on the press, so starting a scroll on the button, or touching it and thinking better of it, still added the quote. The excerpt is captured on the press and committed on the release, and only when that release lands on the button, restoring the cancellation every button is expected to have. Commit on press existed because the tap dismisses the selection before `click` fires; capturing the text up front keeps that safe, and an in-flight press is no longer allowed to unmount its own target. A visible popup also described the previous selection for up to the settle window, so a tap while dragging a native selection handle queued the stale excerpt. It is dropped as soon as a differing selection starts settling. Finally, `viaTouch` survived from the last press into keyboard-driven selections on hybrid devices, which could flip the popup into the touch layout; keydown clears it. The cancel path is covered by a new touch spec, verified to fail against a commit-on-press build. * ๐Ÿงต fix: Reconcile Cancelled Presses, Widen Clipping, Steady the Scroll Specs Second review round, with one finding taken on trust and flagged rather than claimed as proven. A cancelled touch press could leave the popup backed by a selection that no longer existed. A press deliberately keeps the button alive through a collapsing selection so the release has a target to be judged against, but a cancel then dropped the press without ever honouring the collapse it had masked, so a later tap could add a dead excerpt. Ending a press without committing now rechecks the live selection and dismisses if it went away. Visibility now intersects every clipping ancestor of the message rather than stopping at the nearest. This one is precautionary, not a proven fix: the review that prompted it describes scroll containers *inside* a message (a wide table, a code block) shadowing the outer chat scroller, but the walk starts from the message element, so those are descendants and were never in the chain. Behaviour is unchanged in the current layout โ€” a spec covering a table-cell selection passes identically with and without it โ€” and it is kept only because intersecting the whole chain stays correct if the list is ever nested inside a further-clipped panel. The comment says exactly this. The scroll specs were the real instability. They now move the selection between two positions that are both on screen instead of nudging by a pixel count: blind nudges kept pushing it under the composer, where the popup correctly hides, and the chat's own auto-scroll made the landing spot unpredictable. They also target the opening paragraph, since the closing one is the last content in the conversation and cannot be carried upward from a list already at maximum scroll. The reply fixture gained a table so a selection inside a nested scroll container is exercised, and a spec covers the cancelled press. 15/15 pass locally. * ๐ŸชŸ fix: Judge Quote-Popup Visibility From the Selection, on Both Axes Third review round. All three findings held up, and each now has a spec that fails without its fix. Clipping is now measured from the selection rather than from the message, and on both axes. A wide table or a long code line scrolls inside its own container โ€” and `overflow-x: auto` makes the computed `overflow-y` auto, so it clips vertically too โ€” which means scrolling it sideways carries the selected text out of view while the message never moves. Walking up from the message could not see those containers at all, and a vertical-only test could not see that motion. This supersedes the previous round's precautionary widening, which was kept without evidence; the evidence is now a spec that scrolls a table past its own selection. Publishing a settled selection also checks visibility. Nothing is tracked during the 300ms settle interval, so a scroll inside that window never reached the re-anchoring path, and the reading was published off-screen and then clamped into view โ€” stranding the popup over unrelated UI. The cancelled-press spec now reproduces the ordering it describes. Collapsing the selection and cancelling in one synchronous block let the asynchronous `selectionchange` arrive after the press had ended, which is the ordinary path and passes either way; it now waits for delivery in between, so the collapse lands while the press is still masking it. Two other specs needed the same scrutiny: `toBeHidden` is satisfied by an element that does not exist yet, so the settle spec sits out the interval before asserting, and it scrolls just past the container edge rather than to the end of the conversation, because a violent scroll re-renders the messages and drops the selection for unrelated reasons. The reply fixture's table is now wide enough to overflow sideways. 17/17 pass, and each new spec was re-run against a build with its own fix reverted to confirm it fails there. --- .../src/components/Chat/Input/QuoteButton.tsx | 498 ++++++++++++-- e2e/setup/fake-model.js | 41 ++ e2e/specs/mock/quotes.spec.ts | 625 ++++++++++++++++-- 3 files changed, 1064 insertions(+), 100 deletions(-) diff --git a/client/src/components/Chat/Input/QuoteButton.tsx b/client/src/components/Chat/Input/QuoteButton.tsx index 01a391e192..c841afa361 100644 --- a/client/src/components/Chat/Input/QuoteButton.tsx +++ b/client/src/components/Chat/Input/QuoteButton.tsx @@ -4,6 +4,7 @@ import { TextQuote } from 'lucide-react'; import { useSetRecoilState } from 'recoil'; import { mainTextareaId } from '~/common'; import { useLocalize } from '~/hooks'; +import { cn } from '~/utils'; import store from '~/store'; /** Only selections fully inside a rendered chat message get the popup. */ @@ -17,13 +18,35 @@ const MAX_QUOTE_COUNT = 10; const POPUP_OFFSET = 8; /** Keep the popup this far (px) from the viewport edges. */ const EDGE_MARGIN = 16; +/** Quiet period before a mouse-less selection (touch long-press, native handle + * drag, keyboard extend) is treated as final. Every change restarts it, so the + * popup lands once the selection stops moving instead of chasing a handle. */ +const SELECTION_SETTLE_MS = 300; + +type Anchor = { + /** Viewport-relative bounds of the selection (used to place the button, and + * to tell whether it is still visible โ€” hence both axes, since a selection + * can also be scrolled sideways out of a wide table or code block). */ + top: number; + bottom: number; + left: number; + right: number; +}; type SelectionState = { text: string; - /** Viewport-relative anchor of the selection (used to place the button). */ - top: number; - bottom: number; - centerX: number; + anchor: Anchor; + /** Touch selections carry an OS callout above them, so the popup goes below. */ + viaTouch: boolean; +}; + +type Reading = { + text: string; + anchor: Anchor; + /** Retained so scrolling can re-measure without re-walking the selection. */ + range: Range; + /** Everything that clips the selection while the page scrolls. */ + clippers: HTMLElement[]; }; const resolveMessageElement = (node: Node | null): HTMLElement | null => { @@ -31,18 +54,108 @@ const resolveMessageElement = (node: Node | null): HTMLElement | null => { return (element?.closest(MESSAGE_SELECTOR) as HTMLElement | null) ?? null; }; -const readSelection = (): SelectionState | null => { +const anchorFromRect = (rect: DOMRect): Anchor | null => { + if (rect.width === 0 && rect.height === 0) { + return null; + } + return { top: rect.top, bottom: rect.bottom, left: rect.left, right: rect.right }; +}; + +const anchorCenterX = (anchor: Anchor): number => (anchor.left + anchor.right) / 2; + +const sameAnchor = (a: Anchor, b: Anchor): boolean => + a.top === b.top && a.bottom === b.bottom && a.left === b.left && a.right === b.right; + +const stripWhitespace = (value: string): string => value.replace(/\s+/g, ''); + +const CLIPPING_OVERFLOWS = new Set(['auto', 'scroll', 'hidden']); + +/** + * Every ancestor that clips the element, innermost first, so visibility can be + * judged against all of them at once. + * + * Walking from the *selection* rather than from the message matters: a wide + * table or a long code line scrolls inside its own container (and `overflow-x: + * auto` makes the computed `overflow-y` auto too, so it is a clipper on both + * axes), and scrolling that container sideways carries the selected text out of + * view while the message itself has not moved at all. + */ +const findClippingAncestors = (element: HTMLElement): HTMLElement[] => { + const clippers: HTMLElement[] = []; + let current = element.parentElement; + while (current && current !== document.body) { + const { overflowX, overflowY } = getComputedStyle(current); + if (CLIPPING_OVERFLOWS.has(overflowX) || CLIPPING_OVERFLOWS.has(overflowY)) { + clippers.push(current); + } + current = current.parentElement; + } + return clippers; +}; + +const sameRange = (a: Range, b: Range): boolean => { + try { + return ( + a.compareBoundaryPoints(Range.START_TO_START, b) === 0 && + a.compareBoundaryPoints(Range.END_TO_END, b) === 0 + ); + } catch { + /** Detached or cross-document ranges cannot be compared; treat as changed. */ + return false; + } +}; + +/** + * Block-granularity gestures (triple-click, double-click then drag) park the + * selection's far boundary at the start of the *next* block. For the last block + * of a message that boundary sits outside `.message-render` โ€” on the composer + * wrapper or the following message โ€” even though no text out there is selected, + * which used to suppress the popup for any triple-clicked closing paragraph. + * + * Clamping the range to the message keeps those gestures eligible; comparing + * visible text (whitespace-insensitive, since the overhang contributes only + * collapsed whitespace) still rejects selections that truly span messages. + */ +const clampToMessage = (range: Range, message: HTMLElement): Range | null => { + const bounds = document.createRange(); + bounds.selectNodeContents(message); + + const clamped = range.cloneRange(); + if (clamped.compareBoundaryPoints(Range.START_TO_START, bounds) < 0) { + clamped.setStart(bounds.startContainer, bounds.startOffset); + } + if (clamped.compareBoundaryPoints(Range.END_TO_END, bounds) > 0) { + clamped.setEnd(bounds.endContainer, bounds.endOffset); + } + + return stripWhitespace(clamped.toString()) === stripWhitespace(range.toString()) ? clamped : null; +}; + +const readSelection = (): Reading | null => { const selection = window.getSelection(); if (!selection || selection.rangeCount === 0 || selection.isCollapsed) { return null; } - const anchorMessage = resolveMessageElement(selection.anchorNode); - const focusMessage = resolveMessageElement(selection.focusNode); - if (!anchorMessage || anchorMessage !== focusMessage) { + const range = selection.getRangeAt(0); + const startMessage = resolveMessageElement(range.startContainer); + const endMessage = resolveMessageElement(range.endContainer); + const message = startMessage ?? endMessage; + if (!message) { return null; } + let measured = range; + if (startMessage !== endMessage) { + const clamped = clampToMessage(range, message); + if (!clamped) { + return null; + } + measured = clamped; + } + + /** `Selection.toString()` reflects rendered text, so it stays the quote source + * even when the measured range was clamped to the message. */ const text = selection .toString() .replace(/\u00a0/g, ' ') @@ -51,73 +164,268 @@ const readSelection = (): SelectionState | null => { return null; } - const rect = selection.getRangeAt(0).getBoundingClientRect(); - if (rect.width === 0 && rect.height === 0) { + const anchor = anchorFromRect(measured.getBoundingClientRect()); + if (!anchor) { return null; } + const selectionElement = + measured.startContainer instanceof Element + ? (measured.startContainer as HTMLElement) + : (measured.startContainer.parentElement ?? message); + return { text: text.slice(0, MAX_QUOTE_LENGTH), - top: rect.top, - bottom: rect.bottom, - centerX: rect.left + rect.width / 2, + anchor, + range: measured, + clippers: findClippingAncestors(selectionElement), }; }; +/** + * Whether the selection is still on screen, judged against the window + * intersected with every ancestor that clips it, on both axes. Text slipping + * under the chat header or sideways out of a table is invisible even though its + * un-clipped rect is still inside the window, and checking the window alone + * would leave the popup floating over unrelated UI. + */ +const isAnchorVisible = (anchor: Anchor, clippers: HTMLElement[]): boolean => { + let top = 0; + let bottom = window.innerHeight; + let left = 0; + let right = window.innerWidth; + for (let index = 0; index < clippers.length; index++) { + const bounds = clippers[index].getBoundingClientRect(); + top = Math.max(top, bounds.top); + bottom = Math.min(bottom, bounds.bottom); + left = Math.max(left, bounds.left); + right = Math.min(right, bounds.right); + if (top > bottom || left > right) { + return false; + } + } + return ( + anchor.bottom >= top && anchor.top <= bottom && anchor.right >= left && anchor.left <= right + ); +}; + +/** Place the popup on the preferred side, falling back to the other side and + * finally clamping into the viewport. */ +const resolveTop = (anchor: Anchor, height: number, preferBelow: boolean): number => { + const above = anchor.top - POPUP_OFFSET - height; + const below = anchor.bottom + POPUP_OFFSET; + const maxTop = Math.max(EDGE_MARGIN, window.innerHeight - height - EDGE_MARGIN); + const fits = (value: number) => value >= EDGE_MARGIN && value <= maxTop; + + const [preferred, fallback] = preferBelow ? [below, above] : [above, below]; + if (fits(preferred)) { + return preferred; + } + if (fits(fallback)) { + return fallback; + } + return Math.min(Math.max(preferred, EDGE_MARGIN), maxTop); +}; + /** * ChatGPT-style floating "Add to chat" button. Watches for text selections * inside chat messages and, on click, appends the selected excerpt to the * conversation's pending-quotes queue so it shows as a removable chip above * the composer and rides along with the next submission. * + * Pointer and touch platforms surface selections through different events: + * a mouse drag ends in `mouseup`, but a long-press or a drag of the native + * selection handles emits no mouse event at all, only `selectionchange`. Both + * are handled โ€” mouse paths show immediately, mouse-less ones after the + * selection settles โ€” so the popup is reachable on phones as well as desktops. + * * Rendered through a portal so the `fixed` positioning stays viewport-relative * regardless of any transformed ancestor in the composer tree. The on-screen * position is computed from the button's measured size (no CSS transform), so it - * is clamped accurately to the viewport โ€” flipping below the selection when - * there is no room above and keeping its full width within the side margins. + * is clamped accurately to the viewport, and it tracks the selection while the + * page scrolls rather than dismissing on the first scroll event. */ function QuoteButton({ conversationId }: { conversationId: string }) { const localize = useLocalize(); const [selection, setSelection] = useState(null); const [pos, setPos] = useState<{ top: number; left: number } | null>(null); const buttonRef = useRef(null); + const rangeRef = useRef(null); + const clippersRef = useRef([]); + /** Excerpt captured when a touch press begins, committed only if that press + * completes on the button. */ + const pressedTextRef = useRef(null); + /** Set by the listener effect so the press handlers can dismiss the popup. */ + const hideRef = useRef<() => void>(() => undefined); const setQuotes = useSetRecoilState(store.pendingQuotesByConvoId(conversationId)); useEffect(() => { - const updateSelection = () => { - setSelection(readSelection()); - /** Recompute placement from scratch for the new selection. */ - setPos(null); - }; - const clearSelection = () => setSelection(null); - /** Hide the popup the instant the selection collapses or empties, including - * paths that fire no mouse/key event โ€” e.g. a streaming markdown re-render - * replacing the selected text node, which would otherwise leave the button - * stranded over a now-collapsed caret. Only hides here; showing stays gated - * on mouseup/dblclick/keyup so an in-progress drag never flickers it. */ - const handleSelectionChange = () => { - const sel = window.getSelection(); - if (!sel || sel.rangeCount === 0 || sel.isCollapsed) { - setSelection(null); + let settleTimer: ReturnType | undefined; + let frame = 0; + /** Suppresses showing mid-drag, which would flicker the popup across the + * growing selection; the closing `mouseup` shows it. */ + let mouseDragging = false; + let viaTouch = false; + + const clearSettleTimer = () => { + if (settleTimer !== undefined) { + clearTimeout(settleTimer); + settleTimer = undefined; } }; - document.addEventListener('mouseup', updateSelection); + /** Drop what is on screen, keeping any pending settle intact. */ + const dropVisible = () => { + /** Never yank the button out from under an in-flight touch press โ€” its + * release still has to land on a live target to count. */ + if (pressedTextRef.current !== null) { + return; + } + rangeRef.current = null; + clippersRef.current = []; + setSelection(null); + setPos(null); + }; + + const hide = () => { + clearSettleTimer(); + dropVisible(); + }; + hideRef.current = hide; + + const show = (touch = viaTouch) => { + clearSettleTimer(); + const reading = readSelection(); + /** Also gate on visibility here, not just while re-anchoring: nothing is + * tracked during the settle window, so a selection scrolled out of the + * chat in those 300ms would otherwise be published off-screen and + * clamped into view, stranding the popup over unrelated UI. */ + if (!reading || !isAnchorVisible(reading.anchor, reading.clippers)) { + hide(); + return; + } + rangeRef.current = reading.range; + clippersRef.current = reading.clippers; + /** Reuse the previous state object when nothing moved so a redundant + * settle pass costs no render. */ + setSelection((prev) => + prev && + prev.text === reading.text && + prev.viaTouch === touch && + sameAnchor(prev.anchor, reading.anchor) + ? prev + : { text: reading.text, anchor: reading.anchor, viaTouch: touch }, + ); + }; + + const handlePointerDown = (event: PointerEvent) => { + viaTouch = event.pointerType !== 'mouse'; + mouseDragging = !viaTouch; + }; + + const endPointer = (event: PointerEvent) => { + if (event.pointerType === 'mouse') { + mouseDragging = false; + } + }; + + const handleMouseUp = () => { + mouseDragging = false; + show(); + }; + /** Chromium commits a double-click word selection on `dblclick`, after * `mouseup` has already read a still-collapsed range, so listen here too. */ - document.addEventListener('dblclick', updateSelection); - document.addEventListener('keyup', updateSelection); + const handleDoubleClick = () => show(); + /** Keyboard selections carry no OS callout, so they never prefer below. + * Clearing the flag on the way down also stops a settle pass scheduled by + * the resulting `selectionchange` from reviving the touch layout on a + * hybrid device whose last press happened to be a finger. */ + const handleKeyDown = () => { + viaTouch = false; + }; + const handleKeyUp = () => show(false); + + /** + * The only signal touch platforms give: long-press selection and native + * handle drags fire no mouse events. Also hides the instant a selection + * collapses or empties through paths that fire no input event โ€” e.g. a + * streaming markdown re-render replacing the selected text node, which + * would otherwise strand the button over a now-collapsed caret. + */ + const handleSelectionChange = () => { + const current = window.getSelection(); + if (!current || current.rangeCount === 0 || current.isCollapsed) { + hide(); + return; + } + if (mouseDragging) { + return; + } + /** The visible popup still describes the previous selection. Drop it now, + * or a tap landing during the settle window โ€” easy to do while dragging a + * native selection handle โ€” would queue the stale excerpt. */ + if (rangeRef.current && !sameRange(rangeRef.current, current.getRangeAt(0))) { + dropVisible(); + } + clearSettleTimer(); + const touch = viaTouch; + settleTimer = setTimeout(() => show(touch), SELECTION_SETTLE_MS); + }; + + /** Follow the selection instead of dismissing on the first scroll: chat + * auto-scrolls while streaming, and on mobile the URL bar collapsing fires + * resize, both of which used to drop a selection the user just made. */ + const reanchor = () => { + frame = 0; + const range = rangeRef.current; + if (!range) { + return; + } + const anchor = anchorFromRect(range.getBoundingClientRect()); + if (!anchor || !isAnchorVisible(anchor, clippersRef.current)) { + hide(); + return; + } + setSelection((prev) => + prev && !sameAnchor(prev.anchor, anchor) ? { ...prev, anchor } : prev, + ); + }; + + const scheduleReanchor = () => { + if (rangeRef.current === null || frame !== 0) { + return; + } + frame = requestAnimationFrame(reanchor); + }; + + document.addEventListener('pointerdown', handlePointerDown, true); + document.addEventListener('pointerup', endPointer, true); + document.addEventListener('pointercancel', endPointer, true); + document.addEventListener('mouseup', handleMouseUp); + document.addEventListener('dblclick', handleDoubleClick); + document.addEventListener('keydown', handleKeyDown, true); + document.addEventListener('keyup', handleKeyUp); document.addEventListener('selectionchange', handleSelectionChange); - document.addEventListener('scroll', clearSelection, true); - window.addEventListener('resize', clearSelection); + document.addEventListener('scroll', scheduleReanchor, true); + window.addEventListener('resize', scheduleReanchor); return () => { - document.removeEventListener('mouseup', updateSelection); - document.removeEventListener('dblclick', updateSelection); - document.removeEventListener('keyup', updateSelection); + clearSettleTimer(); + if (frame !== 0) { + cancelAnimationFrame(frame); + } + rangeRef.current = null; + document.removeEventListener('pointerdown', handlePointerDown, true); + document.removeEventListener('pointerup', endPointer, true); + document.removeEventListener('pointercancel', endPointer, true); + document.removeEventListener('mouseup', handleMouseUp); + document.removeEventListener('dblclick', handleDoubleClick); + document.removeEventListener('keydown', handleKeyDown, true); + document.removeEventListener('keyup', handleKeyUp); document.removeEventListener('selectionchange', handleSelectionChange); - document.removeEventListener('scroll', clearSelection, true); - window.removeEventListener('resize', clearSelection); + document.removeEventListener('scroll', scheduleReanchor, true); + window.removeEventListener('resize', scheduleReanchor); }; }, []); @@ -129,30 +437,51 @@ function QuoteButton({ conversationId }: { conversationId: string }) { } const { width, height } = buttonRef.current.getBoundingClientRect(); const maxLeft = Math.max(EDGE_MARGIN, window.innerWidth - width - EDGE_MARGIN); - const left = Math.min(Math.max(selection.centerX - width / 2, EDGE_MARGIN), maxLeft); + const left = Math.min( + Math.max(anchorCenterX(selection.anchor) - width / 2, EDGE_MARGIN), + maxLeft, + ); + const top = resolveTop(selection.anchor, height, selection.viaTouch); - const aboveTop = selection.top - POPUP_OFFSET - height; - const belowTop = selection.bottom + POPUP_OFFSET; - const maxTop = Math.max(EDGE_MARGIN, window.innerHeight - height - EDGE_MARGIN); - const top = aboveTop >= EDGE_MARGIN ? aboveTop : Math.min(belowTop, maxTop); - - setPos({ top: Math.max(top, EDGE_MARGIN), left }); + setPos((prev) => (prev && prev.top === top && prev.left === left ? prev : { top, left })); }, [selection]); + const commitQuote = useCallback( + (text: string) => { + setQuotes((prev) => + prev.includes(text) || prev.length >= MAX_QUOTE_COUNT ? prev : [...prev, text], + ); + rangeRef.current = null; + clippersRef.current = []; + pressedTextRef.current = null; + setSelection(null); + setPos(null); + window.getSelection()?.removeAllRanges(); + document.getElementById(mainTextareaId)?.focus(); + }, + [setQuotes], + ); + const addQuote = useCallback(() => { - if (!selection) { - return; + if (selection) { + commitQuote(selection.text); } - setQuotes((prev) => - prev.includes(selection.text) || prev.length >= MAX_QUOTE_COUNT - ? prev - : [...prev, selection.text], - ); - setSelection(null); - setPos(null); - window.getSelection()?.removeAllRanges(); - document.getElementById(mainTextareaId)?.focus(); - }, [selection, setQuotes]); + }, [selection, commitQuote]); + + /** + * End a touch press that did not commit. While a press is in flight the + * listener effect deliberately ignores a collapsing selection so the button + * survives to judge the release โ€” which means a cancel leaves the popup + * backed by a range that may no longer be selected. Re-check once the press + * is over, and dismiss if the selection went away with it. + */ + const abandonPress = useCallback(() => { + pressedTextRef.current = null; + const live = window.getSelection(); + if (!live || live.rangeCount === 0 || live.isCollapsed) { + hideRef.current(); + } + }, []); if (!selection) { return null; @@ -162,8 +491,49 @@ function QuoteButton({ conversationId }: { conversationId: string }) {