diff --git a/webapp/channels/src/utils/utils.test.tsx b/webapp/channels/src/utils/utils.test.tsx index 2cea531f17d..bbc7518c085 100644 --- a/webapp/channels/src/utils/utils.test.tsx +++ b/webapp/channels/src/utils/utils.test.tsx @@ -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>()) { + 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, + }); + }); +}); diff --git a/webapp/channels/src/utils/utils.tsx b/webapp/channels/src/utils/utils.tsx index bcd0a5ea7ca..6603adf4c09 100644 --- a/webapp/channels/src/utils/utils.tsx +++ b/webapp/channels/src/utils/utils.tsx @@ -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; }