Skip to content
Open
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
11 changes: 11 additions & 0 deletions src/MarkdownTextInput.web.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -529,6 +531,15 @@ const MarkdownTextInput = React.forwardRef<MarkdownTextInput, MarkdownTextInputP
onKeyPress(event);
}

// Handle Arrow keys for consistent navigation across grapheme clusters (like emojis) on Firefox
if (BrowserUtils.isFirefox && ['ArrowRight', 'ArrowLeft'].includes(e.key) && !nativeEvent.altKey) {
e.preventDefault();
if (!divRef.current) {
return;
}
handleFirefoxArrowKeyNavigation(divRef.current, nativeEvent?.shiftKey, e.key === 'ArrowRight' ? 'right' : 'left');
}

if (
e.key === 'Enter' &&
// Do not call submit if composition is occuring.
Expand Down
92 changes: 92 additions & 0 deletions src/web/utils/__tests__/firefoxUtils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import * as CursorUtils from '../cursorUtils';
import type {MarkdownTextInputElement} from '../../../MarkdownTextInput.web';
import {handleFirefoxArrowKeyNavigation} from '../firefoxUtils';

const createMockTarget = (value: string): MarkdownTextInputElement => {
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);
});
});
48 changes: 48 additions & 0 deletions src/web/utils/firefoxUtils.ts
Original file line number Diff line number Diff line change
@@ -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};