From 0bea831a77db2c7a3f34c207a85aee074f2bb565 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hanno=20J=2E=20G=C3=B6decke?= Date: Tue, 2 Jul 2024 18:02:45 +0200 Subject: [PATCH 1/3] feat: add count/before/start info to `onChange` event To align with react native, see: https://github.com/facebook/react-native/pull/45248 --- src/MarkdownTextInput.web.tsx | 40 +++++++++++++++++++++++++++++++++-- src/web/cursorUtils.ts | 6 +++++- 2 files changed, 43 insertions(+), 3 deletions(-) diff --git a/src/MarkdownTextInput.web.tsx b/src/MarkdownTextInput.web.tsx index 801eb0cd..308f1806 100644 --- a/src/MarkdownTextInput.web.tsx +++ b/src/MarkdownTextInput.web.tsx @@ -341,6 +341,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); @@ -369,6 +371,8 @@ const MarkdownTextInput = React.forwardRef( text = parseText(divRef.current, changedText, processedMarkdownStyle).text; } + const normalizedText = normalizeValue(text); + if (pasteRef?.current) { pasteRef.current = false; updateSelection(e); @@ -376,13 +380,45 @@ 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); + + const newSelection = CursorUtils.getCurrentCursorPosition(divRef.current) ?? {start: 0, end: 0}; + + // The new text is between the prev start selection and the new end selection + const maybeAddedtext = normalizedText.slice(prevSelection.start, newSelection.end); + // The length of the text that replaced the before text + const count = maybeAddedtext.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 && (nativeEvent.inputType === 'deleteContentBackward' || nativeEvent.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 (nativeEvent.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}; From 62b92e7323c1a6f0092da1dc00db0347363b0fac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hanno=20J=2E=20G=C3=B6decke?= Date: Fri, 5 Jul 2024 12:52:26 +0200 Subject: [PATCH 2/3] apply code review --- src/MarkdownTextInput.web.tsx | 51 +++++++++++++++++++++-------------- 1 file changed, 31 insertions(+), 20 deletions(-) diff --git a/src/MarkdownTextInput.web.tsx b/src/MarkdownTextInput.web.tsx index 308f1806..912e8a80 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,13 +245,16 @@ 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], ); @@ -254,11 +262,14 @@ const MarkdownTextInput = React.forwardRef( const redo = useCallback( (target: HTMLDivElement) => { 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], ); @@ -350,27 +361,29 @@ 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) { @@ -387,24 +400,22 @@ const MarkdownTextInput = React.forwardRef( }>; setEventProps(event); - const newSelection = CursorUtils.getCurrentCursorPosition(divRef.current) ?? {start: 0, end: 0}; - - // The new text is between the prev start selection and the new end selection - const maybeAddedtext = normalizedText.slice(prevSelection.start, newSelection.end); + // 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 = maybeAddedtext.length; + 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 && (nativeEvent.inputType === 'deleteContentBackward' || nativeEvent.inputType === 'deleteContentForward')) { + 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 (nativeEvent.inputType === 'deleteContentBackward') { + 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; From 4a323cf629578574ad5df2223f17b699ba353a6c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hanno=20J=2E=20G=C3=B6decke?= Date: Fri, 5 Jul 2024 13:10:39 +0200 Subject: [PATCH 3/3] add return type --- src/MarkdownTextInput.web.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/MarkdownTextInput.web.tsx b/src/MarkdownTextInput.web.tsx index 912e8a80..da75daf3 100644 --- a/src/MarkdownTextInput.web.tsx +++ b/src/MarkdownTextInput.web.tsx @@ -260,7 +260,7 @@ const MarkdownTextInput = React.forwardRef( ); const redo = useCallback( - (target: HTMLDivElement) => { + (target: HTMLDivElement): ParseTextResult => { if (!history.current) { return { text: '',