Fix autocomplete clipping when RHS is open (#36287)

* Fix autocomplete clipping beside RHS

Co-authored-by: Nick Misasi <nick13misasi@gmail.com>

* Address Copilot autocomplete findings

Co-authored-by: Nick Misasi <nick13misasi@gmail.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Nick Misasi <nick13misasi@gmail.com>
This commit is contained in:
cursor[bot]
2026-05-08 13:54:14 -04:00
committed by GitHub
co-authored by Nick Misasi Cursor Agent
parent 40fe8782ef
commit 4a1fa5e2af
2 changed files with 318 additions and 4 deletions
+293 -1
View File
@@ -2,7 +2,7 @@
// See LICENSE.txt for license information.
import {FileTypes} from './constants';
import {getFileType} from './utils';
import {getFileType, getSuggestionBoxAlgn} from './utils';
describe('Utils.getFileType', () => {
test('should identify image files by extension', () => {
@@ -39,6 +39,7 @@ describe('Utils.getFileType', () => {
// These are not valid URLs but should still be processed correctly
expect(getFileType('path/to/image.jpg')).toBe(FileTypes.IMAGE);
expect(getFileType('image.png')).toBe(FileTypes.IMAGE);
expect(getFileType('PHOTO.PNG')).toBe(FileTypes.IMAGE);
});
test('should identify other file types correctly', () => {
@@ -50,6 +51,17 @@ describe('Utils.getFileType', () => {
expect(getFileType('txt')).toBe(FileTypes.TEXT);
});
test('should only treat proxy image URLs with a url parameter as images', () => {
expect(getFileType('/api/v4/image')).toBe(FileTypes.OTHER);
expect(getFileType('/api/v4/image?url=')).toBe(FileTypes.IMAGE);
expect(getFileType('/api/v4/image?param=value')).toBe(FileTypes.OTHER);
});
test('should not treat path-like filenames with query strings or hashes as direct extensions', () => {
expect(getFileType('path/to/image.jpg?x=1')).toBe(FileTypes.OTHER);
expect(getFileType('path/to/image.jpg#fragment')).toBe(FileTypes.OTHER);
});
test('should not classify PSD files as images (MM-67077)', () => {
// PSD preview support was removed due to memory vulnerability in oov/psd package
expect(getFileType('psd')).toBe(FileTypes.OTHER);
@@ -61,3 +73,283 @@ describe('Utils.getFileType', () => {
expect(getFileType('')).toBe(FileTypes.OTHER);
});
});
describe('Utils.getSuggestionBoxAlgn', () => {
const originalCreateRange = document.createRange;
const originalGetSelection = document.getSelection;
const originalGetComputedStyle = window.getComputedStyle;
const originalInnerWidth = window.innerWidth;
const originalInnerHeight = window.innerHeight;
beforeEach(() => {
document.body.innerHTML = '';
document.documentElement.scrollLeft = 0;
document.documentElement.scrollTop = 0;
document.createRange = jest.fn(() => ({
setStart: jest.fn(),
setEnd: jest.fn(),
getClientRects: jest.fn(() => [{left: 200, top: 40}]),
})) as unknown as typeof document.createRange;
document.getSelection = jest.fn(() => ({
removeAllRanges: jest.fn(),
addRange: jest.fn(),
})) as unknown as typeof document.getSelection;
});
afterEach(() => {
document.body.innerHTML = '';
document.createRange = originalCreateRange;
document.getSelection = originalGetSelection;
window.getComputedStyle = originalGetComputedStyle;
Object.defineProperty(window, 'innerWidth', {configurable: true, value: originalInnerWidth});
Object.defineProperty(window, 'innerHeight', {configurable: true, value: originalInnerHeight});
});
test('returns zero offsets for invalid input', () => {
expect(getSuggestionBoxAlgn(null as any)).toEqual({
pixelsToMoveX: 0,
pixelsToMoveY: 0,
});
});
function createTextArea(clippingAncestorRight?: number) {
const clippingAncestor = document.createElement('div');
clippingAncestor.style.overflow = 'hidden';
clippingAncestor.style.overflowX = 'hidden';
clippingAncestor.style.overflowY = 'hidden';
clippingAncestor.getBoundingClientRect = jest.fn(() => ({
width: 385,
right: clippingAncestorRight ?? 654,
})) as unknown as typeof clippingAncestor.getBoundingClientRect;
const container = document.createElement('div');
container.getBoundingClientRect = jest.fn(() => ({
width: 600,
right: 900,
})) as unknown as typeof container.getBoundingClientRect;
const textArea = document.createElement('textarea');
textArea.value = 'hello @';
textArea.selectionStart = textArea.value.length;
textArea.selectionEnd = textArea.value.length;
textArea.style.lineHeight = '20px';
textArea.getBoundingClientRect = jest.fn(() => ({
left: 295,
top: 100,
width: 333,
right: 628,
})) as unknown as typeof textArea.getBoundingClientRect;
Object.defineProperty(textArea, 'offsetWidth', {
configurable: true,
value: 333,
});
clippingAncestor.appendChild(container);
container.appendChild(textArea);
document.body.appendChild(clippingAncestor);
return {textArea, clippingAncestor};
}
function mockComputedStyle(overrides = new Map<Element, Partial<CSSStyleDeclaration>>()) {
window.getComputedStyle = jest.fn((element: Element) => ({
lineHeight: '20px',
overflow: 'visible',
overflowX: 'visible',
overflowY: 'visible',
...overrides.get(element),
} as CSSStyleDeclaration)) as typeof window.getComputedStyle;
}
test('keeps the suggestion list inside the nearest clipping container', () => {
Object.defineProperty(window, 'innerWidth', {configurable: true, value: 900});
Object.defineProperty(window, 'innerHeight', {configurable: true, value: 900});
const {textArea, clippingAncestor} = createTextArea(654);
mockComputedStyle(new Map([[clippingAncestor, {
overflow: 'hidden',
overflowX: 'hidden',
overflowY: 'hidden',
}]]));
expect(getSuggestionBoxAlgn(textArea)).toMatchObject({
pixelsToMoveX: 0,
pixelsToMoveY: 40,
});
});
test('treats an equal-width clipping ancestor as a valid horizontal boundary', () => {
Object.defineProperty(window, 'innerWidth', {configurable: true, value: 900});
Object.defineProperty(window, 'innerHeight', {configurable: true, value: 900});
const {textArea, clippingAncestor} = createTextArea(654);
clippingAncestor.getBoundingClientRect = jest.fn(() => ({
width: 333,
right: 654,
})) as unknown as typeof clippingAncestor.getBoundingClientRect;
mockComputedStyle(new Map([[clippingAncestor, {
overflow: 'hidden',
overflowX: 'hidden',
overflowY: 'hidden',
}]]));
expect(getSuggestionBoxAlgn(textArea)).toMatchObject({
pixelsToMoveX: 0,
pixelsToMoveY: 40,
});
});
test('skips narrow clipping ancestors and uses the next valid boundary', () => {
Object.defineProperty(window, 'innerWidth', {configurable: true, value: 900});
Object.defineProperty(window, 'innerHeight', {configurable: true, value: 900});
const outerAncestor = document.createElement('div');
outerAncestor.getBoundingClientRect = jest.fn(() => ({
width: 900,
right: 900,
})) as unknown as typeof outerAncestor.getBoundingClientRect;
const innerAncestor = document.createElement('div');
innerAncestor.getBoundingClientRect = jest.fn(() => ({
width: 320,
right: 620,
})) as unknown as typeof innerAncestor.getBoundingClientRect;
const textArea = document.createElement('textarea');
textArea.value = 'hello @';
textArea.selectionStart = textArea.value.length;
textArea.selectionEnd = textArea.value.length;
textArea.style.lineHeight = '20px';
textArea.getBoundingClientRect = jest.fn(() => ({
left: 295,
top: 100,
width: 333,
right: 628,
})) as unknown as typeof textArea.getBoundingClientRect;
Object.defineProperty(textArea, 'offsetWidth', {
configurable: true,
value: 333,
});
outerAncestor.appendChild(innerAncestor);
innerAncestor.appendChild(textArea);
document.body.appendChild(outerAncestor);
mockComputedStyle(new Map([
[innerAncestor, {
overflow: 'hidden',
overflowX: 'hidden',
overflowY: 'hidden',
}],
[outerAncestor, {
overflow: 'hidden',
overflowX: 'hidden',
overflowY: 'hidden',
}],
]));
expect(getSuggestionBoxAlgn(textArea)).toMatchObject({
pixelsToMoveX: 200,
pixelsToMoveY: 40,
});
});
test('treats overflowX clipping as a horizontal boundary even when overflow remains visible', () => {
Object.defineProperty(window, 'innerWidth', {configurable: true, value: 900});
Object.defineProperty(window, 'innerHeight', {configurable: true, value: 900});
const {textArea, clippingAncestor} = createTextArea(654);
mockComputedStyle(new Map([[clippingAncestor, {
overflow: 'visible',
overflowX: 'hidden',
overflowY: 'visible',
}]]));
expect(getSuggestionBoxAlgn(textArea)).toMatchObject({
pixelsToMoveX: 0,
pixelsToMoveY: 40,
});
});
test('uses the viewport width when no clipping ancestor constrains the textbox', () => {
Object.defineProperty(window, 'innerWidth', {configurable: true, value: 900});
Object.defineProperty(window, 'innerHeight', {configurable: true, value: 900});
const textArea = document.createElement('textarea');
textArea.value = 'hello @';
textArea.selectionStart = textArea.value.length;
textArea.selectionEnd = textArea.value.length;
textArea.style.lineHeight = '20px';
textArea.getBoundingClientRect = jest.fn(() => ({
left: 295,
top: 100,
width: 333,
right: 628,
})) as unknown as typeof textArea.getBoundingClientRect;
Object.defineProperty(textArea, 'offsetWidth', {
configurable: true,
value: 333,
});
document.body.appendChild(textArea);
mockComputedStyle();
expect(getSuggestionBoxAlgn(textArea)).toMatchObject({
pixelsToMoveX: 200,
pixelsToMoveY: 40,
});
});
test('aligns with the textbox when requested', () => {
Object.defineProperty(window, 'innerWidth', {configurable: true, value: 900});
Object.defineProperty(window, 'innerHeight', {configurable: true, value: 900});
const {textArea} = createTextArea(900);
mockComputedStyle();
expect(getSuggestionBoxAlgn(textArea, 39, true)).toMatchObject({
pixelsToMoveX: 0,
pixelsToMoveY: 40,
lineHeight: 20,
placementShift: false,
});
});
test('applies trigger offset and placement shift when the viewport is short', () => {
Object.defineProperty(window, 'innerWidth', {configurable: true, value: 900});
Object.defineProperty(window, 'innerHeight', {configurable: true, value: 120});
const textArea = document.createElement('textarea');
textArea.value = 'hello ~';
textArea.selectionStart = textArea.value.length;
textArea.selectionEnd = textArea.value.length;
textArea.style.lineHeight = '24px';
textArea.getBoundingClientRect = jest.fn(() => ({
left: 295,
top: 100,
width: 333,
right: 628,
})) as unknown as typeof textArea.getBoundingClientRect;
Object.defineProperty(textArea, 'offsetWidth', {
configurable: true,
value: 333,
});
document.body.appendChild(textArea);
mockComputedStyle(new Map([[textArea, {lineHeight: '24px'}]]));
expect(getSuggestionBoxAlgn(textArea, 39)).toMatchObject({
pixelsToMoveX: 161,
pixelsToMoveY: 40,
lineHeight: 24,
placementShift: true,
});
});
});
+25 -3
View File
@@ -777,6 +777,27 @@ export function offsetTopLeft(el: HTMLElement) {
return {top: rect.top + scrollTop, left: rect.left + scrollLeft};
}
function getSuggestionBoxHorizontalBoundary(textArea: HTMLElement) {
const {w: viewportWidth} = getViewportSize();
const scrollLeft = window.pageXOffset || document.documentElement.scrollLeft;
const textAreaRect = textArea.getBoundingClientRect();
let ancestor = textArea.parentElement;
while (ancestor) {
const ancestorRect = ancestor.getBoundingClientRect();
const ancestorStyle = getElementComputedStyle(ancestor);
const clipsHorizontalOverflow = ancestorStyle.overflow !== 'visible' || ancestorStyle.overflowX !== 'visible';
if (clipsHorizontalOverflow && ancestorRect.width >= textAreaRect.width) {
return ancestorRect.right + scrollLeft;
}
ancestor = ancestor.parentElement;
}
return viewportWidth + scrollLeft;
}
export function getSuggestionBoxAlgn(textArea: HTMLTextAreaElement, pxToSubstract = 0, alignWithTextBox = false) {
if (!textArea || !(textArea instanceof HTMLElement)) {
return {
@@ -786,8 +807,9 @@ export function getSuggestionBoxAlgn(textArea: HTMLTextAreaElement, pxToSubstrac
}
const {x: caretXCoordinateInTxtArea, y: caretYCoordinateInTxtArea} = getCaretXYCoordinate(textArea);
const {w: viewportWidth, h: viewportHeight} = getViewportSize();
const {h: viewportHeight} = getViewportSize();
const {offsetWidth: textAreaWidth} = textArea;
const horizontalBoundary = getSuggestionBoxHorizontalBoundary(textArea);
const suggestionBoxWidth = Math.min(textAreaWidth, Constants.SUGGESTION_LIST_MAXWIDTH);
@@ -803,8 +825,8 @@ export function getSuggestionBoxAlgn(textArea: HTMLTextAreaElement, pxToSubstrac
if (alignWithTextBox) {
// when the list should be aligned with the textbox just set this value to 0
pxToTheRight = 0;
} else if (xBoxRightCoordinate > viewportWidth) {
// if the right-border edge of the suggestion box will overflow the x-axis viewport
} else if (xBoxRightCoordinate > horizontalBoundary) {
// if the right-border edge of the suggestion box will overflow the visible text area container
// stick the suggestion list to the very right of the TextArea
pxToTheRight = textAreaWidth - suggestionBoxWidth;
}