diff --git a/src/MarkdownTextInput.web.tsx b/src/MarkdownTextInput.web.tsx index 801eb0cd..da75daf3 100644 --- a/src/MarkdownTextInput.web.tsx +++ b/src/MarkdownTextInput.web.tsx @@ -75,6 +75,11 @@ type Dimensions = { height: number; }; +type ParseTextResult = { + text: string; + cursorPosition: number | null; +}; + let focusTimeout: NodeJS.Timeout | null = null; // Removes one '\n' from the end of the string that were added by contentEditable div @@ -203,7 +208,7 @@ const MarkdownTextInput = React.forwardRef( }, []); const parseText = useCallback( - (target: HTMLDivElement, text: string | null, customMarkdownStyles: MarkdownStyle, cursorPosition: number | null = null, shouldAddToHistory = true) => { + (target: HTMLDivElement, text: string | null, customMarkdownStyles: MarkdownStyle, cursorPosition: number | null = null, shouldAddToHistory = true): ParseTextResult => { if (text === null) { return {text: target.innerText, cursorPosition: null}; } @@ -240,25 +245,31 @@ const MarkdownTextInput = React.forwardRef( ); const undo = useCallback( - (target: HTMLDivElement) => { + (target: HTMLDivElement): ParseTextResult => { if (!history.current) { - return ''; + return { + text: '', + cursorPosition: 0, + }; } const item = history.current.undo(); const undoValue = item ? denormalizeValue(item.text) : null; - return parseText(target, undoValue, processedMarkdownStyle, item ? item.cursorPosition : null, false).text; + return parseText(target, undoValue, processedMarkdownStyle, item ? item.cursorPosition : null, false); }, [parseText, processedMarkdownStyle], ); const redo = useCallback( - (target: HTMLDivElement) => { + (target: HTMLDivElement): ParseTextResult => { if (!history.current) { - return ''; + return { + text: '', + cursorPosition: 0, + }; } const item = history.current.redo(); const redoValue = item ? denormalizeValue(item.text) : null; - return parseText(target, redoValue, processedMarkdownStyle, item ? item.cursorPosition : null, false).text; + return parseText(target, redoValue, processedMarkdownStyle, item ? item.cursorPosition : null, false); }, [parseText, processedMarkdownStyle], ); @@ -341,6 +352,8 @@ const MarkdownTextInput = React.forwardRef( if (!divRef.current || !(e.target instanceof HTMLElement)) { return; } + const prevSelection = contentSelection.current ?? {start: 0, end: 0}; + const prevTextLength = CursorUtils.getPrevTextLength() ?? 0; const changedText = e.target.innerText; if (compositionRef.current && !BrowserUtils.isMobile) { updateTextColor(divRef.current, changedText); @@ -348,27 +361,31 @@ const MarkdownTextInput = React.forwardRef( return; } - let text = ''; + let newInputUpdate: ParseTextResult; const nativeEvent = e.nativeEvent as MarkdownNativeEvent; - switch (nativeEvent.inputType) { + const inputType = nativeEvent.inputType; + switch (inputType) { case 'historyUndo': - text = undo(divRef.current); + newInputUpdate = undo(divRef.current); break; case 'historyRedo': - text = redo(divRef.current); + newInputUpdate = redo(divRef.current); break; case 'insertFromPaste': // if there is no newline at the end of the copied text, contentEditable adds invisible
tag at the end of the text, so we need to normalize it if (changedText.length > 2 && changedText[changedText.length - 2] !== '\n' && changedText[changedText.length - 1] === '\n') { - text = parseText(divRef.current, normalizeValue(changedText), processedMarkdownStyle).text; + newInputUpdate = parseText(divRef.current, normalizeValue(changedText), processedMarkdownStyle); break; } - text = parseText(divRef.current, changedText, processedMarkdownStyle).text; + newInputUpdate = parseText(divRef.current, changedText, processedMarkdownStyle); break; default: - text = parseText(divRef.current, changedText, processedMarkdownStyle).text; + newInputUpdate = parseText(divRef.current, changedText, processedMarkdownStyle); } + const {text, cursorPosition} = newInputUpdate; + const normalizedText = normalizeValue(text); + if (pasteRef?.current) { pasteRef.current = false; updateSelection(e); @@ -376,13 +393,43 @@ const MarkdownTextInput = React.forwardRef( updateTextColor(divRef.current, text); if (onChange) { - const event = e as unknown as NativeSyntheticEvent; + const event = e as unknown as NativeSyntheticEvent<{ + count: number; + before: number; + start: number; + }>; 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; + } + + event.nativeEvent.count = count; + event.nativeEvent.before = before; + event.nativeEvent.start = start; + + // @ts-expect-error TODO: Remove once react native PR merged https://github.com/facebook/react-native/pull/45248 onChange(event); } if (onChangeText) { - const normalizedText = normalizeValue(text); onChangeText(normalizedText); } diff --git a/src/web/cursorUtils.ts b/src/web/cursorUtils.ts index ba5c3c3e..9d2ca9ae 100644 --- a/src/web/cursorUtils.ts +++ b/src/web/cursorUtils.ts @@ -2,6 +2,10 @@ import * as BrowserUtils from './browserUtils'; let prevTextLength: number | undefined; +function getPrevTextLength() { + return prevTextLength; +} + function findTextNodes(textNodes: Text[], node: ChildNode) { if (node.nodeType === Node.TEXT_NODE) { textNodes.push(node as Text); @@ -158,4 +162,4 @@ function scrollCursorIntoView(target: HTMLInputElement) { } } -export {getCurrentCursorPosition, moveCursorToEnd, setCursorPosition, setPrevText, removeSelection, scrollCursorIntoView}; +export {getCurrentCursorPosition, moveCursorToEnd, setCursorPosition, setPrevText, removeSelection, scrollCursorIntoView, getPrevTextLength};