Skip to content
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 = {
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};