Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
128 changes: 103 additions & 25 deletions client/src/components/Chat/Input/QuoteButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand Down Expand Up @@ -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();
Comment on lines +189 to +190

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Account for selections that leave the starting scroller

When a selection begins inside a code block or table scroller and extends into later prose in the same .message-render, readSelection collects clipping ancestors only from measured.startContainer, but this loop applies all of those rectangles to the bounding box of the entire range. This discards the visible selected prose outside the starting scroller and pins the popup to the code/table instead of the overall visible selection. Clip each range fragment only by ancestors that contain it, or avoid applying the starting node's clippers to a cross-container range.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 8b1fde7. The popup now groups selected text by clipping context, intersects each group with its own scroll ancestors, and merges only visible bounds. Added red-before-green coverage for selections crossing a code scroller and prose in both directions, including scrolling the code fully out of view while retaining the complete quoted text. QuoteButton tests: 8/8; staged ESLint, Prettier, and import-order checks: passed.

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<HTMLElement, HTMLElement[]>();
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
Expand Down Expand Up @@ -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;
}
Expand All @@ -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 });
}
};

Expand Down Expand Up @@ -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;
}
Expand Down
190 changes: 190 additions & 0 deletions client/src/components/Chat/Input/__tests__/QuoteButton.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<RecoilRoot>
<div className="message-render" style={{ overflow: 'auto' }}>
{codeFirst ? (
<>
<pre style={{ overflowX: 'auto' }}>
<code>{codeText}</code>
</pre>
<p>{proseText}</p>
</>
) : (
<>
<p>{proseText}</p>
<pre style={{ overflowX: 'auto' }}>
<code>{codeText}</code>
</pre>
</>
)}
</div>
<textarea id={mainTextareaId} />
<Quotes />
<QuoteButton conversationId={CONVO_ID} />
</RecoilRoot>,
);
const code = document.querySelector('.message-render code')?.firstChild;
const prose = screen.getByText(proseText).firstChild;
if (!code || !prose) {
throw new Error('Selection text nodes were not rendered');
}
const range = document.createRange();
const first = codeFirst ? code : prose;
const last = codeFirst ? prose : code;
range.setStart(first, 0);
range.setEnd(last, last.textContent?.length ?? 0);
Object.defineProperty(Range.prototype, 'getBoundingClientRect', {
configurable: true,
value: function (this: Range) {
if (this.startContainer === code && this.endContainer === code) {
return rect({
top: codeFirst ? 100 : 200,
bottom: codeFirst ? 120 : 220,
left: codeLeft,
right: codeLeft + 100,
});
}
if (this.startContainer === prose && this.endContainer === prose) {
return rect({
top: codeFirst ? 200 : 100,
bottom: codeFirst ? 220 : 120,
left: 460,
right: 480,
});
}
return rect({ top: 100, bottom: 220, left: codeLeft, right: 480 });
},
});
window.getSelection()?.removeAllRanges();
window.getSelection()?.addRange(range);
const quote = window.getSelection()?.toString().trim();
expect(quote).toContain(codeText.trim());
expect(quote).toContain(proseText);

fireEvent.mouseUp(document);
const button = screen.getByTestId('add-to-chat-button');
expect(button).toHaveStyle({ left: '260px', top: '62px' });

codeLeft = -400;
const scroller = code.parentElement?.closest('pre');
if (!scroller) {
throw new Error('Code scroller was not rendered');
}
fireEvent.scroll(scroller);
act(() => jest.advanceTimersByTime(20));
expect(button).toHaveStyle({ left: '420px', top: codeFirst ? '162px' : '62px' });
fireEvent.click(button);
expect(JSON.parse(screen.getByTestId('quotes').textContent ?? '[]')).toEqual([quote]);
} finally {
jest.useRealTimers();
elementRect.mockRestore();
if (originalRangeRect) {
Object.defineProperty(Range.prototype, 'getBoundingClientRect', {
configurable: true,
value: originalRangeRect,
});
} else {
delete (Range.prototype as Partial<Range>).getBoundingClientRect;
}
}
},
);

it('anchors a long code selection to its visible portion inside nested scrollers', () => {
jest.useFakeTimers();
const codeLine = 'set -e; git fetch '.repeat(40);
let rangeRect = rect({ top: 100, bottom: 120, left: 140, right: 2200 });
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: 95, bottom: 180, left: 100, right: 400 });
}
return rect({ top: 80, bottom: 500, left: 90, right: 500 });
});
Object.defineProperty(Range.prototype, 'getBoundingClientRect', {
configurable: true,
value: () => rangeRect,
});

try {
render(
<RecoilRoot>
<div className="message-render" style={{ overflow: 'auto' }}>
<pre style={{ overflowX: 'auto' }}>
<code>{codeLine}</code>
</pre>
</div>
<textarea id={mainTextareaId} />
<Quotes />
<QuoteButton conversationId={CONVO_ID} />
</RecoilRoot>,
);
const code = document.querySelector('.message-render code');
if (!code) {
throw new Error('Code selection target was not rendered');
}
const range = document.createRange();
range.selectNodeContents(code);
window.getSelection()?.removeAllRanges();
window.getSelection()?.addRange(range);

fireEvent.mouseUp(document);
const button = screen.getByTestId('add-to-chat-button');
expect(button).toHaveStyle({ top: '62px', left: '220px' });

rangeRect = rect({ top: 100, bottom: 120, left: -200, right: 1860 });
fireEvent.scroll(code.closest('pre')!);
act(() => jest.advanceTimersByTime(20));
expect(button).toHaveStyle({ left: '200px' });

rangeRect = rect({ top: 100, bottom: 120, left: -2200, right: -140 });
fireEvent.scroll(code.closest('pre')!);
act(() => jest.advanceTimersByTime(20));
expect(button).not.toBeVisible();

rangeRect = rect({ top: 140, bottom: 240, left: 310, right: 2200 });
fireEvent.mouseUp(document);
expect(button).toHaveStyle({ top: '102px', left: '305px' });
fireEvent.click(button);
expect(JSON.parse(screen.getByTestId('quotes').textContent ?? '[]')).toEqual([
codeLine.trim(),
]);
} finally {
jest.useRealTimers();
elementRect.mockRestore();
if (originalRangeRect) {
Object.defineProperty(Range.prototype, 'getBoundingClientRect', {
configurable: true,
value: originalRangeRect,
});
} else {
delete (Range.prototype as Partial<Range>).getBoundingClientRect;
}
}
});
});

describe('QuoteButton lifecycle', () => {
Expand Down
Loading