Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -79,3 +79,6 @@ lib/

# react-native-live-markdown
.build_complete

# jest
coverage/
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 @@ -526,6 +528,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);
});
});
40 changes: 40 additions & 0 deletions src/web/utils/firefoxUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
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 graphemes = Array.from(segmenter.segment(text));

if (direction === 'right') {
const cursorPos = currentSelection.end;
const nextGrapheme = graphemes.find(({index, segment}) => {
const segmentEnd = index + segment.length;
return cursorPos < segmentEnd;
});

const newCursorPos = nextGrapheme ? nextGrapheme.index + nextGrapheme.segment.length : text.length;
setCursorPosition(target, isSelectionEvent ? currentSelection.start : newCursorPos, newCursorPos);
} else {
const cursorPos = currentSelection.start;
const prevGrapheme = graphemes.findLast(({index, segment}) => {
const segmentEnd = index + segment.length;
return segmentEnd < cursorPos;
});

const newCursorPos = prevGrapheme ? prevGrapheme.index + prevGrapheme.segment.length : 0;
setCursorPosition(target, newCursorPos, isSelectionEvent ? currentSelection.end : newCursorPos);
}
}

// eslint-disable-next-line import/prefer-default-export
export {handleFirefoxArrowKeyNavigation};