From e5e51c6c73e25aeb927adb773531468e87200d64 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hanno=20J=2E=20G=C3=B6decke?= Date: Tue, 16 Jul 2024 11:43:20 +0000 Subject: [PATCH 1/8] fix(web): handle text input metrics correctly on history undo events MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Hanno J. Gödecke --- src/MarkdownTextInput.web.tsx | 34 +++------------- src/web/parserUtils.ts | 75 ++++++++++++++++++++++++++++++++++- 2 files changed, 78 insertions(+), 31 deletions(-) diff --git a/src/MarkdownTextInput.web.tsx b/src/MarkdownTextInput.web.tsx index 7581e9a6..9937cb63 100644 --- a/src/MarkdownTextInput.web.tsx +++ b/src/MarkdownTextInput.web.tsx @@ -65,11 +65,6 @@ interface MarkdownNativeEvent extends Event { inputType: string; } -type Selection = { - start: number; - end: number; -}; - type Dimensions = { width: number; height: number; @@ -179,7 +174,7 @@ const MarkdownTextInput = React.forwardRef( const pasteRef = useRef(false); const divRef = useRef(null); const currentlyFocusedField = useRef(null); - const contentSelection = useRef(null); + const contentSelection = useRef(null); const className = `react-native-live-markdown-input-${multiline ? 'multiline' : 'singleline'}`; const history = useRef(); const dimensions = React.useRef(null); @@ -303,7 +298,7 @@ const MarkdownTextInput = React.forwardRef( [onSelectionChange, setEventProps], ); - const updateRefSelectionVariables = useCallback((newSelection: Selection) => { + const updateRefSelectionVariables = useCallback((newSelection: ParseUtils.Selection) => { const {start, end} = newSelection; const markdownHTMLInput = divRef.current as HTMLInputElement; markdownHTMLInput.selectionStart = start; @@ -311,7 +306,7 @@ const MarkdownTextInput = React.forwardRef( }, []); const updateSelection = useCallback( - (e: SyntheticEvent | null = null, predefinedSelection: Selection | null = null) => { + (e: SyntheticEvent | null = null, predefinedSelection: ParseUtils.Selection | null = null) => { if (!divRef.current) { return; } @@ -400,26 +395,7 @@ const MarkdownTextInput = React.forwardRef( }>; 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; @@ -660,7 +636,7 @@ const MarkdownTextInput = React.forwardRef( return; } - const newSelection: Selection = {start: selection.start, end: selection.end ?? selection.start}; + const newSelection: ParseUtils.Selection = {start: selection.start, end: selection.end ?? selection.start}; contentSelection.current = newSelection; updateRefSelectionVariables(newSelection); CursorUtils.setCursorPosition(divRef.current, newSelection.start, newSelection.end); diff --git a/src/web/parserUtils.ts b/src/web/parserUtils.ts index 49583b9f..eec5034d 100644 --- a/src/web/parserUtils.ts +++ b/src/web/parserUtils.ts @@ -18,6 +18,26 @@ 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; +}; + +type Selection = { + start: number; + end: number; +}; + function addStyling(targetElement: HTMLElement, type: MarkdownType, markdownStyle: PartialMarkdownStyle) { const node = targetElement; switch (type) { @@ -223,6 +243,57 @@ 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. + * For example: + * + * 1. Text input is abc + * 2. User adds "d" + * 3. start: 3, before: 0, count: 1 + * + * We started to replace text starting from position 3, we replaced 0 characters and added 1 character. + * Another example: + * + * 1. Text input is abc + * 2. User selects "b" and adds "d" + * 3. start: 1, before: 1, count: 1 + * + * We started to replace text starting from position 1, we replaced 1 character and added 1 character. + * + * 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: 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}; +export type {MarkdownRange, MarkdownType, Selection}; From 21958f5bcb4160443bde0bd74197bf24e6891a13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hanno=20J=2E=20G=C3=B6decke?= Date: Wed, 17 Jul 2024 09:52:18 +0200 Subject: [PATCH 2/8] shorten explanation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Hanno J. Gödecke --- src/web/parserUtils.ts | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/src/web/parserUtils.ts b/src/web/parserUtils.ts index eec5034d..350a7c2a 100644 --- a/src/web/parserUtils.ts +++ b/src/web/parserUtils.ts @@ -246,20 +246,6 @@ function parseText(target: HTMLElement, text: string, cursorPositionIndex: numbe /** * 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. - * For example: - * - * 1. Text input is abc - * 2. User adds "d" - * 3. start: 3, before: 0, count: 1 - * - * We started to replace text starting from position 3, we replaced 0 characters and added 1 character. - * Another example: - * - * 1. Text input is abc - * 2. User selects "b" and adds "d" - * 3. start: 1, before: 1, count: 1 - * - * We started to replace text starting from position 1, we replaced 1 character and added 1 character. * * This is to align the onChange event with the native counter part: * - https://github.com/facebook/react-native/pull/45248 From 4f8e139567eb0638c153be512f14bef2ce37b296 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hanno=20J=2E=20G=C3=B6decke?= Date: Wed, 17 Jul 2024 07:55:55 +0000 Subject: [PATCH 3/8] moved selection to cursorUtils MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Hanno J. Gödecke --- src/MarkdownTextInput.web.tsx | 8 ++++---- src/web/cursorUtils.ts | 7 ++++++- src/web/parserUtils.ts | 9 ++------- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/MarkdownTextInput.web.tsx b/src/MarkdownTextInput.web.tsx index 9937cb63..d1349601 100644 --- a/src/MarkdownTextInput.web.tsx +++ b/src/MarkdownTextInput.web.tsx @@ -174,7 +174,7 @@ const MarkdownTextInput = React.forwardRef( const pasteRef = useRef(false); const divRef = useRef(null); const currentlyFocusedField = useRef(null); - const contentSelection = useRef(null); + const contentSelection = useRef(null); const className = `react-native-live-markdown-input-${multiline ? 'multiline' : 'singleline'}`; const history = useRef(); const dimensions = React.useRef(null); @@ -298,7 +298,7 @@ const MarkdownTextInput = React.forwardRef( [onSelectionChange, setEventProps], ); - const updateRefSelectionVariables = useCallback((newSelection: ParseUtils.Selection) => { + const updateRefSelectionVariables = useCallback((newSelection: CursorUtils.Selection) => { const {start, end} = newSelection; const markdownHTMLInput = divRef.current as HTMLInputElement; markdownHTMLInput.selectionStart = start; @@ -306,7 +306,7 @@ const MarkdownTextInput = React.forwardRef( }, []); const updateSelection = useCallback( - (e: SyntheticEvent | null = null, predefinedSelection: ParseUtils.Selection | null = null) => { + (e: SyntheticEvent | null = null, predefinedSelection: CursorUtils.Selection | null = null) => { if (!divRef.current) { return; } @@ -636,7 +636,7 @@ const MarkdownTextInput = React.forwardRef( return; } - const newSelection: ParseUtils.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); diff --git a/src/web/cursorUtils.ts b/src/web/cursorUtils.ts index 9d2ca9ae..b18ba466 100644 --- a/src/web/cursorUtils.ts +++ b/src/web/cursorUtils.ts @@ -1,5 +1,10 @@ import * as BrowserUtils from './browserUtils'; +type Selection = { + start: number; + end: number; +}; + let prevTextLength: number | undefined; function getPrevTextLength() { @@ -162,4 +167,4 @@ function scrollCursorIntoView(target: HTMLInputElement) { } } -export {getCurrentCursorPosition, moveCursorToEnd, setCursorPosition, setPrevText, removeSelection, scrollCursorIntoView, getPrevTextLength}; +export {getCurrentCursorPosition, moveCursorToEnd, setCursorPosition, setPrevText, removeSelection, scrollCursorIntoView, getPrevTextLength, Selection}; diff --git a/src/web/parserUtils.ts b/src/web/parserUtils.ts index 350a7c2a..9422033a 100644 --- a/src/web/parserUtils.ts +++ b/src/web/parserUtils.ts @@ -33,11 +33,6 @@ type TextChangeMetrics = { before: number; }; -type Selection = { - start: number; - end: number; -}; - function addStyling(targetElement: HTMLElement, type: MarkdownType, markdownStyle: PartialMarkdownStyle) { const node = targetElement; switch (type) { @@ -250,7 +245,7 @@ function parseText(target: HTMLElement, text: string, cursorPositionIndex: numbe * 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: Selection, prevTextLength: number, normalizedText: string, cursorPosition: number | null): TextChangeMetrics { +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 @@ -282,4 +277,4 @@ function calculateInputMetrics(inputType: string, prevSelection: Selection, prev export {parseText, parseRangesToHTMLNodes, calculateInputMetrics}; -export type {MarkdownRange, MarkdownType, Selection}; +export type {MarkdownRange, MarkdownType}; From 8c568840e22ae5ebf3387cb47f6bc99abb8c1ec8 Mon Sep 17 00:00:00 2001 From: OSBotify <76178356+OSBotify@users.noreply.github.com> Date: Wed, 17 Jul 2024 17:28:31 +1000 Subject: [PATCH 4/8] Update version to 0.1.106 (#435) --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 3d9fcd1a..07a2fc8c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@expensify/react-native-live-markdown", - "version": "0.1.105", + "version": "0.1.106", "description": "Drop-in replacement for React Native's TextInput component with Markdown formatting.", "main": "lib/commonjs/index", "module": "lib/module/index", From cca4d1847be96c54749756dc850611fb08680b97 Mon Sep 17 00:00:00 2001 From: QichenZhu <57348009+QichenZhu@users.noreply.github.com> Date: Wed, 17 Jul 2024 19:51:02 +1200 Subject: [PATCH 5/8] Fix selection issue for text containing emoji (#416) --- ios/RCTBaseTextInputView+Markdown.mm | 9 ++++++++- ios/RCTTextInputComponentView+Markdown.mm | 9 ++++++++- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/ios/RCTBaseTextInputView+Markdown.mm b/ios/RCTBaseTextInputView+Markdown.mm index 209dc6bb..7662d545 100644 --- a/ios/RCTBaseTextInputView+Markdown.mm +++ b/ios/RCTBaseTextInputView+Markdown.mm @@ -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]; diff --git a/ios/RCTTextInputComponentView+Markdown.mm b/ios/RCTTextInputComponentView+Markdown.mm index 1a4581f3..3161e00d 100644 --- a/ios/RCTTextInputComponentView+Markdown.mm +++ b/ios/RCTTextInputComponentView+Markdown.mm @@ -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]; From cd476c6f1dd22bc87f972d27229b5a2a284f54a0 Mon Sep 17 00:00:00 2001 From: OSBotify <76178356+OSBotify@users.noreply.github.com> Date: Wed, 17 Jul 2024 17:52:45 +1000 Subject: [PATCH 6/8] Update version to 0.1.107 (#436) --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 07a2fc8c..2da7f2a1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@expensify/react-native-live-markdown", - "version": "0.1.106", + "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", From 894790d4cea64a10dfa92ec8d4cd8dfb9a931455 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hanno=20J=2E=20G=C3=B6decke?= Date: Wed, 17 Jul 2024 08:04:09 +0000 Subject: [PATCH 7/8] fix tsc + prettier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Hanno J. Gödecke --- src/web/cursorUtils.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/web/cursorUtils.ts b/src/web/cursorUtils.ts index b18ba466..153934fe 100644 --- a/src/web/cursorUtils.ts +++ b/src/web/cursorUtils.ts @@ -167,4 +167,5 @@ function scrollCursorIntoView(target: HTMLInputElement) { } } -export {getCurrentCursorPosition, moveCursorToEnd, setCursorPosition, setPrevText, removeSelection, scrollCursorIntoView, getPrevTextLength, Selection}; +export type {Selection}; +export {getCurrentCursorPosition, moveCursorToEnd, setCursorPosition, setPrevText, removeSelection, scrollCursorIntoView, getPrevTextLength}; From 2d188ce485af39b45e25fc6aa8d375d62159e709 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hanno=20J=2E=20G=C3=B6decke?= Date: Wed, 17 Jul 2024 13:26:41 +0200 Subject: [PATCH 8/8] wip --- src/web/parserUtils.ts | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/src/web/parserUtils.ts b/src/web/parserUtils.ts index 9422033a..582ac380 100644 --- a/src/web/parserUtils.ts +++ b/src/web/parserUtils.ts @@ -257,8 +257,24 @@ function calculateInputMetrics(inputType: string, prevSelection: CursorUtils.Sel // For some events start and before need to be adjusted if (inputType === 'historyUndo') { - start = cursorPosition ?? 0; - before = prevTextLength - normalizedText.length; + // wip: not working yet + before = Math.abs(prevText.length - normalizedText.length); + + count = 0; + let startFound = false; + let charIndex = newCursorPosition - 1; + while (!startFound) { + const newChar = normalizedText[charIndex]; + const prevChar = prevText[charIndex]; + charIndex--; + + if (newChar !== prevChar) { + count++; + } else { + startFound = count > 0 || charIndex === 0; + } + } + start = newCursorPosition - count; } else if (inputType === 'deleteContentBackward' || inputType === 'deleteContentForward') { if (before === 0) { // Its possible the user pressed a delete key without a selection range (before = 0),