diff --git a/src/MarkdownTextInput.web.tsx b/src/MarkdownTextInput.web.tsx index 864c6d90..2044fc35 100644 --- a/src/MarkdownTextInput.web.tsx +++ b/src/MarkdownTextInput.web.tsx @@ -24,6 +24,8 @@ import {getElementHeight, getPlaceholderValue, isEventComposing, normalizeValue, import {idGenerator, parseToReactDOMStyle, processMarkdownStyle} from './web/utils/webStyleUtils'; import {forceRefreshAllImages} from './web/inputElements/inlineImage'; import type {MarkdownRange, InlineImagesInputProps} from './commonTypes'; +import BrowserUtils from './web/utils/browserUtils'; +import {handleFirefoxArrowKeyNavigation} from './web/utils/firefoxUtils'; const useClientEffect = typeof window === 'undefined' ? useEffect : useLayoutEffect; @@ -529,6 +531,15 @@ const MarkdownTextInput = React.forwardRef { + const div = document.createElement('div') as unknown as MarkdownTextInputElement; + div.value = value; + return div; +}; + +jest.mock('../cursorUtils', () => ({ + ...jest.requireActual('../cursorUtils'), + getCurrentCursorPosition: jest.fn(), + setCursorPosition: jest.fn(), +})); + +describe('handleFirefoxArrowKeyNavigation', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should do nothing if no cursor in target', () => { + const target = createMockTarget('test'); + (CursorUtils.getCurrentCursorPosition as jest.Mock).mockReturnValue(null); + + handleFirefoxArrowKeyNavigation(target); + expect(CursorUtils.setCursorPosition).not.toHaveBeenCalled(); + }); + + it('should move cursor to next grapheme boundary with regular text', () => { + const target = createMockTarget('hello world'); + (CursorUtils.getCurrentCursorPosition as jest.Mock).mockReturnValue({start: 5, end: 5}); + + handleFirefoxArrowKeyNavigation(target); + expect(CursorUtils.setCursorPosition).toHaveBeenCalledWith(target, 6, 6); + }); + + it('should move cursor correctly when inside emoji', () => { + const target = createMockTarget('๐Ÿ˜€text'); + (CursorUtils.getCurrentCursorPosition as jest.Mock).mockReturnValue({start: 1, end: 1}); + + handleFirefoxArrowKeyNavigation(target); + expect(CursorUtils.setCursorPosition).toHaveBeenCalledWith(target, 2, 2); + }); + + it('should not move cursor beyond text length', () => { + const target = createMockTarget('test'); + + (CursorUtils.getCurrentCursorPosition as jest.Mock).mockReturnValue({start: 4, end: 4}); + handleFirefoxArrowKeyNavigation(target); + expect(CursorUtils.setCursorPosition).toHaveBeenCalledWith(target, 4, 4); + }); + + it('should handle multiple emojis', () => { + const target = createMockTarget('๐Ÿ˜€๐Ÿ˜€text'); + (CursorUtils.getCurrentCursorPosition as jest.Mock).mockReturnValueOnce({start: 0, end: 0}).mockReturnValueOnce({start: 2, end: 2}); + + handleFirefoxArrowKeyNavigation(target); + expect(CursorUtils.setCursorPosition).toHaveBeenCalledWith(target, 2, 2); + handleFirefoxArrowKeyNavigation(target); + expect(CursorUtils.setCursorPosition).toHaveBeenCalledWith(target, 4, 4); + }); + + it('should handle multiple emojis backward navigation', () => { + const target = createMockTarget('๐Ÿ˜€๐Ÿ˜€text'); + (CursorUtils.getCurrentCursorPosition as jest.Mock).mockReturnValueOnce({start: 4, end: 4}).mockReturnValueOnce({start: 2, end: 2}); + + handleFirefoxArrowKeyNavigation(target, false, 'left'); + expect(CursorUtils.setCursorPosition).toHaveBeenCalledWith(target, 2, 2); + handleFirefoxArrowKeyNavigation(target, false, 'left'); + expect(CursorUtils.setCursorPosition).toHaveBeenCalledWith(target, 2, 2); + }); + + it('should handle emoji selection', () => { + const target = createMockTarget('๐Ÿ˜€๐Ÿ˜€text'); + (CursorUtils.getCurrentCursorPosition as jest.Mock).mockReturnValueOnce({start: 0, end: 0}).mockReturnValueOnce({start: 2, end: 2}); + + handleFirefoxArrowKeyNavigation(target, true); + expect(CursorUtils.setCursorPosition).toHaveBeenCalledWith(target, 0, 2); + handleFirefoxArrowKeyNavigation(target); + expect(CursorUtils.setCursorPosition).toHaveBeenCalledWith(target, 4, 4); + }); + it('should handle emoji selection backwards', () => { + const target = createMockTarget('๐Ÿ˜€๐Ÿ˜€text'); + (CursorUtils.getCurrentCursorPosition as jest.Mock).mockReturnValueOnce({start: 4, end: 4}).mockReturnValueOnce({start: 2, end: 4}); + + handleFirefoxArrowKeyNavigation(target, true, 'left'); + expect(CursorUtils.setCursorPosition).toHaveBeenCalledWith(target, 2, 4); + handleFirefoxArrowKeyNavigation(target, true, 'left'); + expect(CursorUtils.setCursorPosition).toHaveBeenCalledWith(target, 0, 4); + }); +}); diff --git a/src/web/utils/firefoxUtils.ts b/src/web/utils/firefoxUtils.ts new file mode 100644 index 00000000..9ae63ce9 --- /dev/null +++ b/src/web/utils/firefoxUtils.ts @@ -0,0 +1,48 @@ +import type {MarkdownTextInputElement} from '../../MarkdownTextInput.web'; +import {getCurrentCursorPosition, setCursorPosition} from './cursorUtils'; + +/** + * Ensures consistent arrow navigation across grapheme clusters (like emojis) + */ +function handleFirefoxArrowKeyNavigation(target: MarkdownTextInputElement, isSelectionEvent = false, direction: 'right' | 'left' = 'right'): void { + const currentSelection = getCurrentCursorPosition(target); + if (!currentSelection) { + return; + } + + const text = target.value; + + const segmenter = new Intl.Segmenter('en', {granularity: 'grapheme'}); + const segments = segmenter.segment(text); + + if (direction === 'right') { + const cursorPos = currentSelection.end; + let newCursorPos = text.length; + + // eslint-disable-next-line no-restricted-syntax + for (const {index, segment} of segments) { + const segmentEnd = index + segment.length; + if (cursorPos < segmentEnd) { + newCursorPos = segmentEnd; + break; + } + } + setCursorPosition(target, isSelectionEvent ? currentSelection.start : newCursorPos, newCursorPos); + return; + } + const cursorPos = currentSelection.start; + let newCursorPos = 0; + + // eslint-disable-next-line no-restricted-syntax + for (const {index, segment} of segments) { + const segmentEnd = index + segment.length; + if (segmentEnd < cursorPos) { + newCursorPos = segmentEnd; + } + } + + setCursorPosition(target, newCursorPos, isSelectionEvent ? currentSelection.end : newCursorPos); +} + +// eslint-disable-next-line import/prefer-default-export +export {handleFirefoxArrowKeyNavigation};