mirror of
https://github.com/mattermost/mattermost.git
synced 2026-09-19 10:12:47 +08:00
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:
co-authored by
Nick Misasi
Cursor Agent
parent
40fe8782ef
commit
4a1fa5e2af
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user