Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(web): handle text input metrics correctly on history undo events #434

Draft
wants to merge 9 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from 7 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
9 changes: 8 additions & 1 deletion ios/RCTBaseTextInputView+Markdown.mm
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,14 @@ - (BOOL)markdown_textOf:(NSAttributedString *)newText equals:(NSAttributedString
{
RCTMarkdownUtils *markdownUtils = [self getMarkdownUtils];
if (markdownUtils != nil) {
return [newText isEqualToAttributedString:oldText];
// Emoji characters are automatically assigned an AppleColorEmoji NSFont and the original font is moved to NSOriginalFont
// We need to remove these attributes before comparison
NSMutableAttributedString *newTextCopy = [newText mutableCopy];
NSMutableAttributedString *oldTextCopy = [oldText mutableCopy];
[newTextCopy removeAttribute:@"NSFont" range:NSMakeRange(0, newTextCopy.length)];
[oldTextCopy removeAttribute:@"NSFont" range:NSMakeRange(0, oldTextCopy.length)];
[oldTextCopy removeAttribute:@"NSOriginalFont" range:NSMakeRange(0, oldTextCopy.length)];
return [newTextCopy isEqualToAttributedString:oldTextCopy];
}

return [self markdown_textOf:newText equals:oldText];
Expand Down
9 changes: 8 additions & 1 deletion ios/RCTTextInputComponentView+Markdown.mm
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,14 @@ - (BOOL)markdown__textOf:(NSAttributedString *)newText equals:(NSAttributedStrin
{
RCTMarkdownUtils *markdownUtils = [self getMarkdownUtils];
if (markdownUtils != nil) {
return [newText isEqualToAttributedString:oldText];
// Emoji characters are automatically assigned an AppleColorEmoji NSFont and the original font is moved to NSOriginalFont
// We need to remove these attributes before comparison
NSMutableAttributedString *newTextCopy = [newText mutableCopy];
NSMutableAttributedString *oldTextCopy = [oldText mutableCopy];
[newTextCopy removeAttribute:@"NSFont" range:NSMakeRange(0, newTextCopy.length)];
[oldTextCopy removeAttribute:@"NSFont" range:NSMakeRange(0, oldTextCopy.length)];
[oldTextCopy removeAttribute:@"NSOriginalFont" range:NSMakeRange(0, oldTextCopy.length)];
return [newTextCopy isEqualToAttributedString:oldTextCopy];
}

return [self markdown__textOf:newText equals:oldText];
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@expensify/react-native-live-markdown",
"version": "0.1.105",
"version": "0.1.107",
"description": "Drop-in replacement for React Native's TextInput component with Markdown formatting.",
"main": "lib/commonjs/index",
"module": "lib/module/index",
Expand Down
34 changes: 5 additions & 29 deletions src/MarkdownTextInput.web.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,11 +65,6 @@ interface MarkdownNativeEvent extends Event {
inputType: string;
}

type Selection = {
hannojg marked this conversation as resolved.
Show resolved Hide resolved
start: number;
end: number;
};

type Dimensions = {
width: number;
height: number;
Expand Down Expand Up @@ -179,7 +174,7 @@ const MarkdownTextInput = React.forwardRef<TextInput, MarkdownTextInputProps>(
const pasteRef = useRef<boolean>(false);
const divRef = useRef<HTMLDivElement | null>(null);
const currentlyFocusedField = useRef<HTMLDivElement | null>(null);
const contentSelection = useRef<Selection | null>(null);
const contentSelection = useRef<CursorUtils.Selection | null>(null);
const className = `react-native-live-markdown-input-${multiline ? 'multiline' : 'singleline'}`;
const history = useRef<InputHistory>();
const dimensions = React.useRef<Dimensions | null>(null);
Expand Down Expand Up @@ -303,15 +298,15 @@ const MarkdownTextInput = React.forwardRef<TextInput, MarkdownTextInputProps>(
[onSelectionChange, setEventProps],
);

const updateRefSelectionVariables = useCallback((newSelection: Selection) => {
const updateRefSelectionVariables = useCallback((newSelection: CursorUtils.Selection) => {
const {start, end} = newSelection;
const markdownHTMLInput = divRef.current as HTMLInputElement;
markdownHTMLInput.selectionStart = start;
markdownHTMLInput.selectionEnd = end;
}, []);

const updateSelection = useCallback(
(e: SyntheticEvent<HTMLDivElement> | null = null, predefinedSelection: Selection | null = null) => {
(e: SyntheticEvent<HTMLDivElement> | null = null, predefinedSelection: CursorUtils.Selection | null = null) => {
if (!divRef.current) {
return;
}
Expand Down Expand Up @@ -400,26 +395,7 @@ const MarkdownTextInput = React.forwardRef<TextInput, MarkdownTextInputProps>(
}>;
setEventProps(event);

// The new text is between the prev start selection and the new end selection, can be empty
const addedText = normalizedText.slice(prevSelection.start, cursorPosition ?? 0);
// The length of the text that replaced the before text
const count = addedText.length;
// The start index of the replacement operation
let start = prevSelection.start;

const prevSelectionRange = prevSelection.end - prevSelection.start;
// The length the deleted text had before
let before = prevSelectionRange;
if (prevSelectionRange === 0 && (inputType === 'deleteContentBackward' || inputType === 'deleteContentForward')) {
// its possible the user pressed a delete key without a selection range, so we need to adjust the before value to have the length of the deleted text
before = prevTextLength - normalizedText.length;
}

if (inputType === 'deleteContentBackward') {
// When the user does a backspace delete he expects the content before the cursor to be removed.
// For this the start value needs to be adjusted (its as if the selection was before the text that we want to delete)
start -= before;
}
const {start, before, count} = ParseUtils.calculateInputMetrics(inputType, prevSelection, prevTextLength, normalizedText, cursorPosition);

event.nativeEvent.count = count;
event.nativeEvent.before = before;
Expand Down Expand Up @@ -660,7 +636,7 @@ const MarkdownTextInput = React.forwardRef<TextInput, MarkdownTextInputProps>(
return;
}

const newSelection: Selection = {start: selection.start, end: selection.end ?? selection.start};
const newSelection: CursorUtils.Selection = {start: selection.start, end: selection.end ?? selection.start};
contentSelection.current = newSelection;
updateRefSelectionVariables(newSelection);
CursorUtils.setCursorPosition(divRef.current, newSelection.start, newSelection.end);
Expand Down
6 changes: 6 additions & 0 deletions src/web/cursorUtils.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import * as BrowserUtils from './browserUtils';

type Selection = {
start: number;
end: number;
};

let prevTextLength: number | undefined;

function getPrevTextLength() {
Expand Down Expand Up @@ -162,4 +167,5 @@ function scrollCursorIntoView(target: HTMLInputElement) {
}
}

export type {Selection};
export {getCurrentCursorPosition, moveCursorToEnd, setCursorPosition, setPrevText, removeSelection, scrollCursorIntoView, getPrevTextLength};
54 changes: 53 additions & 1 deletion src/web/parserUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,21 @@ type NestedNode = {
endIndex: number;
};

type TextChangeMetrics = {
/**
* The start index in the provided string where the repalcement started from.
*/
start: number;
/**
* The amount of characters that have been added.
*/
count: number;
/**
* The amount of characters replaced.
*/
before: number;
};

function addStyling(targetElement: HTMLElement, type: MarkdownType, markdownStyle: PartialMarkdownStyle) {
const node = targetElement;
switch (type) {
Expand Down Expand Up @@ -223,6 +238,43 @@ function parseText(target: HTMLElement, text: string, cursorPositionIndex: numbe
return {text: target.innerText, cursorPosition: cursorPosition || 0};
}

export {parseText, parseRangesToHTMLNodes};
/**
* Calculates start, count and before values. Whenever the text is being changed you can think of it as a replacement operation,
* where parts of the string get replaced with new content.
*
* This is to align the onChange event with the native counter part:
* - https://github.com/facebook/react-native/pull/45248
*/
function calculateInputMetrics(inputType: string, prevSelection: CursorUtils.Selection, prevTextLength: number, normalizedText: string, cursorPosition: number | null): TextChangeMetrics {
// The new text is between the prev start selection and the new end selection, can be empty
const addedText = normalizedText.slice(prevSelection.start, cursorPosition ?? 0);
// The length of the text that replaced the "before" text
const count = addedText.length;
// The start index of the replacement operation
let start = prevSelection.start;
// Before is by default the length of the previous selection
let before = prevSelection.end - prevSelection.start;

// For some events start and before need to be adjusted
if (inputType === 'historyUndo') {
start = cursorPosition ?? 0;
before = prevTextLength - normalizedText.length;
} else if (inputType === 'deleteContentBackward' || inputType === 'deleteContentForward') {
if (before === 0) {
// Its possible the user pressed a delete key without a selection range (before = 0),
// so we need to adjust the before value to have the length of the deleted text
before = prevTextLength - normalizedText.length;
}
if (inputType === 'deleteContentBackward') {
// When the user does a backspace delete he expects the content before the cursor to be removed.
// For this the start value needs to be adjusted (its as if the selection was before the text that we want to delete)
start = Math.max(start - before, 0);
}
}

return {start, before, count};
}

export {parseText, parseRangesToHTMLNodes, calculateInputMetrics};

export type {MarkdownRange, MarkdownType};
Loading