Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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 {handleFirefoxRightArrowKeyNavigation} 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 ArrowRight for consistent navigation across grapheme clusters (like emojis) on firefox
if (e.key === 'ArrowRight' && BrowserUtils.isFirefox && !nativeEvent.altKey) {
e.preventDefault();
if (!divRef.current) {
return;
}
handleFirefoxRightArrowKeyNavigation(divRef.current, nativeEvent?.shiftKey);
}

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

handleFirefoxRightArrowKeyNavigation(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});

handleFirefoxRightArrowKeyNavigation(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});

handleFirefoxRightArrowKeyNavigation(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});
handleFirefoxRightArrowKeyNavigation(target);
expect(CursorUtils.setCursorPosition).toHaveBeenCalledWith(target, 4, 4);
});

it('should handle multiple emojis correctly', () => {
const target = createMockTarget('😀😀text');
(CursorUtils.getCurrentCursorPosition as jest.Mock).mockReturnValueOnce({start: 0, end: 0}).mockReturnValueOnce({start: 2, end: 2});

handleFirefoxRightArrowKeyNavigation(target);
expect(CursorUtils.setCursorPosition).toHaveBeenCalledWith(target, 2, 2);
handleFirefoxRightArrowKeyNavigation(target);
expect(CursorUtils.setCursorPosition).toHaveBeenCalledWith(target, 4, 4);
});

it('should handle emoji selection', () => {
const target = createMockTarget('😀😀text');
(CursorUtils.getCurrentCursorPosition as jest.Mock).mockReturnValueOnce({start: 0, end: 0}).mockReturnValueOnce({start: 2, end: 2});

handleFirefoxRightArrowKeyNavigation(target, true);
expect(CursorUtils.setCursorPosition).toHaveBeenCalledWith(target, 0, 2);
handleFirefoxRightArrowKeyNavigation(target);
expect(CursorUtils.setCursorPosition).toHaveBeenCalledWith(target, 4, 4);
});
});
26 changes: 26 additions & 0 deletions src/web/utils/firefoxUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import type {MarkdownTextInputElement} from '../../MarkdownTextInput.web';
import {getCurrentCursorPosition, setCursorPosition} from './cursorUtils';

function handleFirefoxRightArrowKeyNavigation(target: MarkdownTextInputElement, isSelectionEvent = false): void {
const currentSelection = getCurrentCursorPosition(target);
if (!currentSelection) {
return;
}

const text = target.value;
const cursorPos = currentSelection.end;

const segmenter = new Intl.Segmenter('en', {granularity: 'grapheme'});
const graphemes = Array.from(segmenter.segment(text));

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);
}
// eslint-disable-next-line import/prefer-default-export
export {handleFirefoxRightArrowKeyNavigation};