diff --git a/src/MarkdownTextInput.web.tsx b/src/MarkdownTextInput.web.tsx index a73a1287..d1349601 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: CursorUtils.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: CursorUtils.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 = Math.max(start - before, 0); - } + 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: 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..153934fe 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,5 @@ function scrollCursorIntoView(target: HTMLInputElement) { } } +export type {Selection}; export {getCurrentCursorPosition, moveCursorToEnd, setCursorPosition, setPrevText, removeSelection, scrollCursorIntoView, getPrevTextLength}; diff --git a/src/web/parserUtils.ts b/src/web/parserUtils.ts index 49583b9f..582ac380 100644 --- a/src/web/parserUtils.ts +++ b/src/web/parserUtils.ts @@ -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) { @@ -223,6 +238,59 @@ 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') { + // 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), + // 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};