diff --git a/client/src/components/Chat/Input/QuoteButton.tsx b/client/src/components/Chat/Input/QuoteButton.tsx index f5d50978063..9a10501a842 100644 --- a/client/src/components/Chat/Input/QuoteButton.tsx +++ b/client/src/components/Chat/Input/QuoteButton.tsx @@ -79,7 +79,7 @@ const CLIPPING_OVERFLOWS = new Set(['auto', 'scroll', 'hidden']); */ const findClippingAncestors = (element: HTMLElement): HTMLElement[] => { const clippers: HTMLElement[] = []; - let current = element.parentElement; + let current: HTMLElement | null = element; while (current && current !== document.body) { const { overflowX, overflowY } = getComputedStyle(current); if (CLIPPING_OVERFLOWS.has(overflowX) || CLIPPING_OVERFLOWS.has(overflowY)) { @@ -179,31 +179,107 @@ const readSelection = (): Reading | null => { }; }; -/** - * 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(); +/** Use only the visible part of a selection to place the popup. A long code + * line can extend far beyond its scroll container even while partly selected. */ +const clipAnchor = (anchor: Anchor, clippers: HTMLElement[]): Anchor | null => { + let top = Math.max(0, anchor.top); + let bottom = Math.min(window.innerHeight, anchor.bottom); + let left = Math.max(0, anchor.left); + let right = Math.min(window.innerWidth, anchor.right); + for (const clipper of clippers) { + const bounds = clipper.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; + if (top >= bottom || left >= right) { + return null; } } - return ( - anchor.bottom >= top && anchor.top <= bottom && anchor.right >= left && anchor.left <= right - ); + return top < bottom && left < right ? { top, bottom, left, right } : null; +}; + +/** Selected text can cross from a clipped code block into unclipped prose. Clip + * each consecutive text fragment to its own ancestors before merging the visible bounds. */ +const visibleAnchor = (range: Range, anchor: Anchor, clippers: HTMLElement[]): Anchor | null => { + if ( + range.startContainer === range.endContainer && + range.startContainer.nodeType === Node.TEXT_NODE + ) { + return clipAnchor(anchor, clippers); + } + + const ancestors = new Map(); + const clippersFor = (element: HTMLElement): HTMLElement[] => { + const cached = ancestors.get(element); + if (cached) { + return cached; + } + const parent = element.parentElement; + const outer = parent && parent !== document.body ? clippersFor(parent) : []; + const { overflowX, overflowY } = getComputedStyle(element); + const found = + CLIPPING_OVERFLOWS.has(overflowX) || CLIPPING_OVERFLOWS.has(overflowY) + ? [element, ...outer] + : outer; + ancestors.set(element, found); + return found; + }; + + let first: Text | null = null; + let last: Text | null = null; + let groupClippers: HTMLElement[] = []; + let visible: Anchor | null = null; + const measureGroup = () => { + if (!first || !last) { + return; + } + const fragment = document.createRange(); + fragment.setStart(first, first === range.startContainer ? range.startOffset : 0); + fragment.setEnd(last, last === range.endContainer ? range.endOffset : last.length); + const bounds = anchorFromRect(fragment.getBoundingClientRect()); + const clipped = bounds && clipAnchor(bounds, groupClippers); + if (!clipped) { + return; + } + visible = visible + ? { + top: Math.min(visible.top, clipped.top), + bottom: Math.max(visible.bottom, clipped.bottom), + left: Math.min(visible.left, clipped.left), + right: Math.max(visible.right, clipped.right), + } + : clipped; + }; + + const walker = document.createTreeWalker(range.commonAncestorContainer, NodeFilter.SHOW_TEXT); + if (range.startContainer.nodeType === Node.TEXT_NODE) { + walker.currentNode = range.startContainer; + } + for ( + let node = + range.startContainer.nodeType === Node.TEXT_NODE ? walker.currentNode : walker.nextNode(); + node; + node = walker.nextNode() + ) { + if (range.comparePoint(node, 0) > 0) { + break; + } + if (!node.textContent || !range.intersectsNode(node) || !node.parentElement) { + continue; + } + const next = node as Text; + const nextClippers = clippersFor(node.parentElement as HTMLElement); + if (first && groupClippers !== nextClippers) { + measureGroup(); + first = null; + } + first ??= next; + last = next; + groupClippers = nextClippers; + } + measureGroup(); + return visible; }; /** Place the popup on the preferred side, falling back to the other side and @@ -321,7 +397,8 @@ function QuoteButton({ conversationId }: { conversationId: string }) { * 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)) { + const anchor = reading && visibleAnchor(reading.range, reading.anchor, reading.clippers); + if (!reading || !anchor) { hide(); return; } @@ -333,9 +410,9 @@ function QuoteButton({ conversationId }: { conversationId: string }) { !previous || previous.text !== reading.text || previous.viaTouch !== touch || - !sameAnchor(previous.anchor, reading.anchor) + !sameAnchor(previous.anchor, anchor) ) { - presentSelection({ text: reading.text, anchor: reading.anchor, viaTouch: touch }); + presentSelection({ text: reading.text, anchor, viaTouch: touch }); } }; @@ -403,8 +480,9 @@ function QuoteButton({ conversationId }: { conversationId: string }) { if (!range) { return; } - const anchor = anchorFromRect(range.getBoundingClientRect()); - if (!anchor || !isAnchorVisible(anchor, clippersRef.current)) { + const bounds = anchorFromRect(range.getBoundingClientRect()); + const anchor = bounds && visibleAnchor(range, bounds, clippersRef.current); + if (!anchor) { hide(); return; } diff --git a/client/src/components/Chat/Input/__tests__/QuoteButton.test.tsx b/client/src/components/Chat/Input/__tests__/QuoteButton.test.tsx index 3eabf6fcbd4..1fd528b9c02 100644 --- a/client/src/components/Chat/Input/__tests__/QuoteButton.test.tsx +++ b/client/src/components/Chat/Input/__tests__/QuoteButton.test.tsx @@ -150,6 +150,196 @@ describe('QuoteButton', () => { } } }); + + it.each([true, false])( + 'anchors a selection crossing code and prose when code comes first: %s', + (codeFirst) => { + jest.useFakeTimers(); + const codeText = 'wide code '.repeat(30); + const proseText = 'Selected prose remains visible'; + let codeLeft = 140; + const originalRangeRect = Range.prototype.getBoundingClientRect; + const elementRect = jest + .spyOn(HTMLElement.prototype, 'getBoundingClientRect') + .mockImplementation(function (this: HTMLElement) { + if (this instanceof HTMLButtonElement) { + return rect({ top: 0, bottom: 30, left: 0, right: 100 }); + } + if (this.tagName === 'PRE') { + return rect({ top: codeFirst ? 95 : 195, bottom: 260, left: 100, right: 400 }); + } + return rect({ top: 80, bottom: 500, left: 90, right: 650 }); + }); + + try { + render( + +
+ {codeFirst ? ( + <> +
+                    {codeText}
+                  
+

{proseText}

+ + ) : ( + <> +

{proseText}

+
+                    {codeText}
+                  
+ + )} +
+