From ea2db30c36b078e181ea13cce567f4fa95c5e707 Mon Sep 17 00:00:00 2001 From: Fernando Rojo Date: Wed, 11 Dec 2024 16:06:04 -0500 Subject: [PATCH 1/3] zero-config web support --- src/MarkdownTextInput.native.tsx | 133 +++++ src/MarkdownTextInput.tsx | 861 +++++++++++++++++++++++---- src/MarkdownTextInput.web.tsx | 784 ------------------------ src/font-family-monospace.ios.tsx | 1 + src/font-family-monospace.tsx | 3 + src/index.tsx | 3 +- src/is-web.native.tsx | 3 + src/is-web.tsx | 3 + src/parseExpensiMark.ts | 10 +- src/styleUtils.ts | 7 +- src/web/inputElements/inlineImage.ts | 2 +- src/web/utils/animationUtils.ts | 2 +- src/web/utils/blockUtils.ts | 2 +- src/web/utils/cursorUtils.ts | 2 +- src/web/utils/inputUtils.ts | 2 +- src/web/utils/parserUtils.ts | 2 +- src/web/utils/treeUtils.ts | 2 +- 17 files changed, 913 insertions(+), 909 deletions(-) create mode 100644 src/MarkdownTextInput.native.tsx delete mode 100644 src/MarkdownTextInput.web.tsx create mode 100644 src/font-family-monospace.ios.tsx create mode 100644 src/font-family-monospace.tsx create mode 100644 src/is-web.native.tsx create mode 100644 src/is-web.tsx diff --git a/src/MarkdownTextInput.native.tsx b/src/MarkdownTextInput.native.tsx new file mode 100644 index 000000000..b744a9bd3 --- /dev/null +++ b/src/MarkdownTextInput.native.tsx @@ -0,0 +1,133 @@ +import {StyleSheet, TextInput, processColor} from 'react-native'; +import React from 'react'; +import type {TextInputProps} from 'react-native'; +import {createWorkletRuntime, makeShareableCloneRecursive} from 'react-native-reanimated'; +import type {WorkletRuntime} from 'react-native-reanimated'; +import type {ShareableRef, WorkletFunction} from 'react-native-reanimated/lib/typescript/commonTypes'; + +import MarkdownTextInputDecoratorViewNativeComponent from './MarkdownTextInputDecoratorViewNativeComponent'; +import type {MarkdownStyle} from './MarkdownTextInputDecoratorViewNativeComponent'; +import NativeLiveMarkdownModule from './NativeLiveMarkdownModule'; +import {mergeMarkdownStyleWithDefault} from './styleUtils'; +import type {PartialMarkdownStyle} from './styleUtils'; +import type {InlineImagesInputProps, MarkdownRange} from './commonTypes'; + +declare global { + // eslint-disable-next-line no-var + var jsi_setMarkdownRuntime: (runtime: WorkletRuntime) => void; + // eslint-disable-next-line no-var + var jsi_registerMarkdownWorklet: (shareableWorklet: ShareableRef>) => number; + // eslint-disable-next-line no-var + var jsi_unregisterMarkdownWorklet: (parserId: number) => void; +} + +let initialized = false; +let workletRuntime: WorkletRuntime | undefined; + +function initializeLiveMarkdownIfNeeded() { + if (initialized) { + return; + } + if (NativeLiveMarkdownModule) { + NativeLiveMarkdownModule.install(); + } + if (!global.jsi_setMarkdownRuntime) { + throw new Error('[react-native-live-markdown] global.jsi_setMarkdownRuntime is not available'); + } + workletRuntime = createWorkletRuntime('LiveMarkdownRuntime'); + global.jsi_setMarkdownRuntime(workletRuntime); + initialized = true; +} + +function registerParser(parser: (input: string) => MarkdownRange[]): number { + initializeLiveMarkdownIfNeeded(); + const shareableWorklet = makeShareableCloneRecursive(parser) as ShareableRef>; + const parserId = global.jsi_registerMarkdownWorklet(shareableWorklet); + return parserId; +} + +function unregisterParser(parserId: number) { + global.jsi_unregisterMarkdownWorklet(parserId); +} + +interface MarkdownTextInputProps extends TextInputProps, InlineImagesInputProps { + markdownStyle?: PartialMarkdownStyle; + parser: (value: string) => MarkdownRange[]; +} + +type MarkdownTextInput = TextInput & React.Component; + +function processColorsInMarkdownStyle(input: MarkdownStyle): MarkdownStyle { + const output = JSON.parse(JSON.stringify(input)); + + Object.keys(output).forEach((key) => { + const obj = output[key]; + Object.keys(obj).forEach((prop) => { + // TODO: use ReactNativeStyleAttributes from 'react-native/Libraries/Components/View/ReactNativeStyleAttributes' + if (!(prop === 'color' || prop.endsWith('Color'))) { + return; + } + obj[prop] = processColor(obj[prop]); + }); + }); + + return output as MarkdownStyle; +} + +function processMarkdownStyle(input: PartialMarkdownStyle | undefined): MarkdownStyle { + return processColorsInMarkdownStyle(mergeMarkdownStyleWithDefault(input)); +} + +const MarkdownTextInput = React.forwardRef((props, ref) => { + const IS_FABRIC = 'nativeFabricUIManager' in global; + + const markdownStyle = React.useMemo(() => processMarkdownStyle(props.markdownStyle), [props.markdownStyle]); + + if (props.parser === undefined) { + throw new Error('[react-native-live-markdown] `parser` is undefined'); + } + + // eslint-disable-next-line no-underscore-dangle + const workletHash = (props.parser as {__workletHash?: number}).__workletHash; + if (workletHash === undefined) { + throw new Error('[react-native-live-markdown] `parser` is not a worklet'); + } + + const parserId = React.useMemo(() => { + return registerParser(props.parser); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [workletHash]); + + React.useEffect(() => { + return () => unregisterParser(parserId); + }, [parserId]); + + return ( + <> + + + + ); +}); + +const styles = StyleSheet.create({ + displayNone: { + display: 'none', + }, + farAway: { + position: 'absolute', + top: 1e8, + left: 1e8, + }, +}); + +export type {PartialMarkdownStyle as MarkdownStyle, MarkdownTextInputProps}; + +export default MarkdownTextInput; diff --git a/src/MarkdownTextInput.tsx b/src/MarkdownTextInput.tsx index b744a9bd3..b96013785 100644 --- a/src/MarkdownTextInput.tsx +++ b/src/MarkdownTextInput.tsx @@ -1,133 +1,784 @@ -import {StyleSheet, TextInput, processColor} from 'react-native'; -import React from 'react'; -import type {TextInputProps} from 'react-native'; -import {createWorkletRuntime, makeShareableCloneRecursive} from 'react-native-reanimated'; -import type {WorkletRuntime} from 'react-native-reanimated'; -import type {ShareableRef, WorkletFunction} from 'react-native-reanimated/lib/typescript/commonTypes'; - -import MarkdownTextInputDecoratorViewNativeComponent from './MarkdownTextInputDecoratorViewNativeComponent'; -import type {MarkdownStyle} from './MarkdownTextInputDecoratorViewNativeComponent'; -import NativeLiveMarkdownModule from './NativeLiveMarkdownModule'; -import {mergeMarkdownStyleWithDefault} from './styleUtils'; -import type {PartialMarkdownStyle} from './styleUtils'; -import type {InlineImagesInputProps, MarkdownRange} from './commonTypes'; - -declare global { - // eslint-disable-next-line no-var - var jsi_setMarkdownRuntime: (runtime: WorkletRuntime) => void; - // eslint-disable-next-line no-var - var jsi_registerMarkdownWorklet: (shareableWorklet: ShareableRef>) => number; - // eslint-disable-next-line no-var - var jsi_unregisterMarkdownWorklet: (parserId: number) => void; -} - -let initialized = false; -let workletRuntime: WorkletRuntime | undefined; - -function initializeLiveMarkdownIfNeeded() { - if (initialized) { - return; - } - if (NativeLiveMarkdownModule) { - NativeLiveMarkdownModule.install(); - } - if (!global.jsi_setMarkdownRuntime) { - throw new Error('[react-native-live-markdown] global.jsi_setMarkdownRuntime is not available'); - } - workletRuntime = createWorkletRuntime('LiveMarkdownRuntime'); - global.jsi_setMarkdownRuntime(workletRuntime); - initialized = true; -} +/* eslint-disable @typescript-eslint/no-explicit-any */ +import type { + TextInput, + TextInputSubmitEditingEventData, + TextStyle, + NativeSyntheticEvent, + TextInputSelectionChangeEventData, + TextInputProps, + TextInputKeyPressEventData, + TextInputFocusEventData, + TextInputContentSizeChangeEventData, + GestureResponderEvent, +} from 'react-native'; +import React, {useEffect, useRef, useCallback, useMemo, useLayoutEffect} from 'react'; +import type {CSSProperties, MutableRefObject, ReactEventHandler, FocusEventHandler, MouseEvent, KeyboardEvent, SyntheticEvent, ClipboardEventHandler, TouchEvent} from 'react'; +import {StyleSheet} from 'react-native'; +import {updateInputStructure} from './web/utils/parserUtils'; +import InputHistory from './web/InputHistory'; +import type {TreeNode} from './web/utils/treeUtils'; +import {getCurrentCursorPosition, removeSelection, setCursorPosition} from './web/utils/cursorUtils'; +import './web/MarkdownTextInput.css'; +import type {MarkdownStyle} from './MarkdownTextInput.native'; +import {getElementHeight, getPlaceholderValue, isEventComposing, normalizeValue, parseInnerHTMLToText} from './web/utils/inputUtils'; +import {parseToReactDOMStyle, processMarkdownStyle} from './web/utils/webStyleUtils'; +import {forceRefreshAllImages} from './web/inputElements/inlineImage'; +import type {MarkdownRange, InlineImagesInputProps} from './commonTypes'; -function registerParser(parser: (input: string) => MarkdownRange[]): number { - initializeLiveMarkdownIfNeeded(); - const shareableWorklet = makeShareableCloneRecursive(parser) as ShareableRef>; - const parserId = global.jsi_registerMarkdownWorklet(shareableWorklet); - return parserId; -} +const useClientEffect = typeof window === 'undefined' ? useEffect : useLayoutEffect; -function unregisterParser(parserId: number) { - global.jsi_unregisterMarkdownWorklet(parserId); +interface MarkdownTextInputProps extends TextInputProps, InlineImagesInputProps { + markdownStyle?: MarkdownStyle; + parser: (text: string) => MarkdownRange[]; + onClick?: (e: MouseEvent) => void; + dir?: string; + disabled?: boolean; } -interface MarkdownTextInputProps extends TextInputProps, InlineImagesInputProps { - markdownStyle?: PartialMarkdownStyle; - parser: (value: string) => MarkdownRange[]; +interface MarkdownNativeEvent extends Event { + inputType?: string; } type MarkdownTextInput = TextInput & React.Component; -function processColorsInMarkdownStyle(input: MarkdownStyle): MarkdownStyle { - const output = JSON.parse(JSON.stringify(input)); +type Selection = { + start: number; + end: number; +}; + +type Dimensions = { + width: number; + height: number; +}; + +type ParseTextResult = { + text: string; + cursorPosition: number | null; +}; + +let focusTimeout: NodeJS.Timeout | null = null; + +type MarkdownTextInputElement = HTMLDivElement & + HTMLInputElement & { + tree: TreeNode; + selection: Selection; + imageElements: HTMLImageElement[]; + }; + +type HTMLMarkdownElement = HTMLElement & { + value: string; +}; + +const MarkdownTextInput = React.forwardRef( + ( + { + accessibilityLabel, + accessibilityLabelledBy, + accessibilityRole, + autoCapitalize = 'sentences', + autoCorrect = true, + blurOnSubmit = false, + clearTextOnFocus, + dir = 'auto', + disabled = false, + numberOfLines, + multiline = false, + markdownStyle, + parser, + onBlur, + onChange, + onChangeText, + onClick, + onFocus, + onKeyPress, + onSelectionChange, + onSubmitEditing, + placeholder, + placeholderTextColor = `rgba(0,0,0,0.2)`, + selectTextOnFocus, + spellCheck, + selection, + style = {}, + value, + autoFocus = false, + onContentSizeChange, + id, + inputMode, + onTouchStart, + maxLength, + addAuthTokenToImageURLCallback, + imagePreviewAuthRequiredURLs, + }, + ref, + ) => { + if (parser === undefined) { + throw new Error('[react-native-live-markdown] `parser` is undefined'); + } + if (typeof parser !== 'function') { + throw new Error('[react-native-live-markdown] `parser` is not a function'); + } + + const compositionRef = useRef(false); + const divRef = useRef(null); + const currentlyFocusedField = useRef(null); + const contentSelection = useRef(null); + const className = `react-native-live-markdown-input-${multiline ? 'multiline' : 'singleline'}`; + const history = useRef(); + const dimensions = useRef(null); + const pasteContent = useRef(null); + const hasJustBeenFocused = useRef(false); + + if (!history.current) { + history.current = new InputHistory(100, 150, value || ''); + } + + const flattenedStyle = useMemo(() => StyleSheet.flatten(style), [style]); + + // Empty placeholder would collapse the div, so we need to use zero-width space to prevent it + const heightSafePlaceholder = useMemo(() => getPlaceholderValue(placeholder), [placeholder]); + + const setEventProps = useCallback((e: NativeSyntheticEvent) => { + if (divRef.current) { + const text = divRef.current.value; + if (e.target) { + // TODO: change the logic here so every event have value property + (e.target as unknown as HTMLInputElement).value = text; + } + if (e.nativeEvent && e.nativeEvent.text) { + e.nativeEvent.text = text; + } + } + return e; + }, []); + + const parseText = useCallback( + ( + parserFunction: (input: string) => MarkdownRange[], + target: MarkdownTextInputElement, + text: string | null, + customMarkdownStyles: MarkdownStyle, + cursorPosition: number | null = null, + shouldAddToHistory = true, + shouldForceDOMUpdate = false, + shouldScrollIntoView = false, + ): ParseTextResult => { + if (!divRef.current) { + return {text: text || '', cursorPosition: null}; + } + + if (text === null) { + return {text: divRef.current.value, cursorPosition: null}; + } + const parsedText = updateInputStructure(parserFunction, target, text, cursorPosition, multiline, customMarkdownStyles, false, shouldForceDOMUpdate, shouldScrollIntoView, { + addAuthTokenToImageURLCallback, + imagePreviewAuthRequiredURLs, + }); + divRef.current.value = parsedText.text; + + if (history.current && shouldAddToHistory) { + history.current.throttledAdd(parsedText.text, parsedText.cursorPosition); + } + + return parsedText; + }, + [addAuthTokenToImageURLCallback, imagePreviewAuthRequiredURLs, multiline], + ); + + const processedMarkdownStyle = useMemo(() => { + const newMarkdownStyle = processMarkdownStyle(markdownStyle); + if (divRef.current) { + parseText(parser, divRef.current, divRef.current.value, newMarkdownStyle, null, false, false); + } + return newMarkdownStyle; + }, [parser, markdownStyle, parseText]); + + const inputStyles = useMemo( + () => + StyleSheet.flatten([ + styles.defaultInputStyles, + flattenedStyle && { + caretColor: (flattenedStyle as TextStyle).color || 'black', + }, + {whiteSpace: multiline ? 'pre-wrap' : 'nowrap'}, + disabled && styles.disabledInputStyles, + parseToReactDOMStyle(flattenedStyle), + ]) as CSSProperties, + [flattenedStyle, multiline, disabled], + ); + + const undo = useCallback( + (target: MarkdownTextInputElement): ParseTextResult => { + if (!history.current) { + return { + text: '', + cursorPosition: 0, + }; + } + const item = history.current.undo(); + const undoValue = item ? item.text : null; + return parseText(parser, target, undoValue, processedMarkdownStyle, item ? item.cursorPosition : null, false); + }, + [parser, parseText, processedMarkdownStyle], + ); - Object.keys(output).forEach((key) => { - const obj = output[key]; - Object.keys(obj).forEach((prop) => { - // TODO: use ReactNativeStyleAttributes from 'react-native/Libraries/Components/View/ReactNativeStyleAttributes' - if (!(prop === 'color' || prop.endsWith('Color'))) { + const redo = useCallback( + (target: MarkdownTextInputElement): ParseTextResult => { + if (!history.current) { + return { + text: '', + cursorPosition: 0, + }; + } + const item = history.current.redo(); + const redoValue = item ? item.text : null; + return parseText(parser, target, redoValue, processedMarkdownStyle, item ? item.cursorPosition : null, false); + }, + [parser, parseText, processedMarkdownStyle], + ); + + // Placeholder text color logic + const updateTextColor = useCallback( + (node: HTMLDivElement, text: string) => { + // eslint-disable-next-line no-param-reassign -- we need to change the style of the node, so we need to modify it + node.style.color = String(placeholder && (text === '' || text === '\n') ? placeholderTextColor : flattenedStyle.color || 'black'); + }, + [flattenedStyle.color, placeholder, placeholderTextColor], + ); + + const handleSelectionChange: ReactEventHandler = useCallback( + (event) => { + const e = event as unknown as NativeSyntheticEvent; + setEventProps(e); + if (onSelectionChange && contentSelection.current) { + e.nativeEvent.selection = contentSelection.current; + onSelectionChange(e); + } + }, + [onSelectionChange, setEventProps], + ); + + const updateRefSelectionVariables = useCallback((newSelection: Selection) => { + if (!divRef.current) { return; } - obj[prop] = processColor(obj[prop]); - }); - }); + const {start, end} = newSelection; + divRef.current.selection = {start, end}; + }, []); - return output as MarkdownStyle; -} + const updateSelection = useCallback( + (e: SyntheticEvent, predefinedSelection: Selection | null = null) => { + if (!divRef.current) { + return; + } + const newSelection = predefinedSelection || getCurrentCursorPosition(divRef.current); -function processMarkdownStyle(input: PartialMarkdownStyle | undefined): MarkdownStyle { - return processColorsInMarkdownStyle(mergeMarkdownStyleWithDefault(input)); -} + if (newSelection && (!contentSelection.current || contentSelection.current.start !== newSelection.start || contentSelection.current.end !== newSelection.end)) { + updateRefSelectionVariables(newSelection); + contentSelection.current = newSelection; -const MarkdownTextInput = React.forwardRef((props, ref) => { - const IS_FABRIC = 'nativeFabricUIManager' in global; + handleSelectionChange(e); + } + }, + [handleSelectionChange, updateRefSelectionVariables], + ); - const markdownStyle = React.useMemo(() => processMarkdownStyle(props.markdownStyle), [props.markdownStyle]); + const handleOnSelect = useCallback( + (e: React.SyntheticEvent) => { + updateSelection(e); - if (props.parser === undefined) { - throw new Error('[react-native-live-markdown] `parser` is undefined'); - } + // If the input has just been focused, we need to scroll the cursor into view + if (divRef.current && contentSelection.current && hasJustBeenFocused.current) { + setCursorPosition(divRef.current, contentSelection.current?.start, contentSelection.current?.end, true); + hasJustBeenFocused.current = false; + } + }, + [updateSelection], + ); - // eslint-disable-next-line no-underscore-dangle - const workletHash = (props.parser as {__workletHash?: number}).__workletHash; - if (workletHash === undefined) { - throw new Error('[react-native-live-markdown] `parser` is not a worklet'); - } + const handleContentSizeChange = useCallback(() => { + if (!divRef.current || !multiline || !onContentSizeChange) { + return; + } - const parserId = React.useMemo(() => { - return registerParser(props.parser); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [workletHash]); + const {offsetWidth: newWidth, offsetHeight: newHeight} = divRef.current; - React.useEffect(() => { - return () => unregisterParser(parserId); - }, [parserId]); + if (newHeight !== dimensions.current?.height || newWidth !== dimensions.current.width) { + dimensions.current = {height: newHeight, width: newWidth}; - return ( - <> - - ); + } + }, [multiline, onContentSizeChange]); + + const handleOnChangeText = useCallback( + (e: SyntheticEvent) => { + if (!divRef.current || !(e.target instanceof HTMLElement) || !contentSelection.current) { + return; + } + const nativeEvent = e.nativeEvent as MarkdownNativeEvent; + const inputType = nativeEvent.inputType; + + updateTextColor(divRef.current, e.target.textContent ?? ''); + const previousText = divRef.current.value; + let parsedText = normalizeValue( + inputType === 'pasteText' ? pasteContent.current || '' : parseInnerHTMLToText(e.target as MarkdownTextInputElement, contentSelection.current.start, inputType), + ); + + if (pasteContent.current) { + pasteContent.current = null; + } + + if (maxLength !== undefined && parsedText.length > maxLength) { + parsedText = previousText; + } + + const prevSelection = contentSelection.current ?? {start: 0, end: 0}; + const newCursorPosition = + inputType === 'deleteContentForward' && contentSelection.current.start === contentSelection.current.end + ? Math.max(contentSelection.current.start, 0) // Don't move the caret when deleting forward with no characters selected + : Math.max(Math.max(contentSelection.current.end, 0) + (parsedText.length - previousText.length), 0); + + if (compositionRef.current) { + updateTextColor(divRef.current, parsedText); + updateSelection(e, { + start: newCursorPosition, + end: newCursorPosition, + }); + divRef.current.value = parsedText; + if (onChangeText) { + onChangeText(parsedText); + } + return; + } + let newInputUpdate: ParseTextResult; + switch (inputType) { + case 'historyUndo': + newInputUpdate = undo(divRef.current); + break; + case 'historyRedo': + newInputUpdate = redo(divRef.current); + break; + default: + newInputUpdate = parseText(parser, divRef.current, parsedText, processedMarkdownStyle, newCursorPosition, true, !inputType, inputType === 'pasteText'); + } + const {text, cursorPosition} = newInputUpdate; + updateTextColor(divRef.current, text); + updateSelection(e, { + start: cursorPosition ?? 0, + end: cursorPosition ?? 0, + }); + + if (onChange) { + 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 = text.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 = previousText.length - text.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); + } + + 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) { + onChangeText(text); + } + + handleContentSizeChange(); + }, + [parser, updateTextColor, updateSelection, onChange, onChangeText, handleContentSizeChange, undo, redo, parseText, processedMarkdownStyle, setEventProps, maxLength], + ); + + const insertText = useCallback( + (e: SyntheticEvent, text: string) => { + if (!contentSelection.current || !divRef.current) { + return; + } + + const previousText = divRef.current.value; + let insertedText = text; + let availableLength = text.length; + const prefix = divRef.current.value.substring(0, contentSelection.current.start); + const suffix = divRef.current.value.substring(contentSelection.current.end); + if (maxLength !== undefined) { + availableLength = maxLength - prefix.length - suffix.length; + insertedText = text.slice(0, Math.max(availableLength, 0)); + } + const newText = `${prefix}${insertedText}${suffix}`; + if (previousText === newText) { + document.execCommand('delete'); + } + + pasteContent.current = availableLength > 0 ? newText : previousText; + (e.nativeEvent as MarkdownNativeEvent).inputType = 'pasteText'; + + handleOnChangeText(e); + }, + [handleOnChangeText, maxLength], + ); + + const handleKeyPress = useCallback( + (e: KeyboardEvent) => { + if (!divRef.current) { + return; + } + + const hostNode = e.target; + e.stopPropagation(); + + if (e.key === 'z' && (e.ctrlKey || e.metaKey)) { + e.preventDefault(); + const nativeEvent = e.nativeEvent as unknown as MarkdownNativeEvent; + if (e.shiftKey) { + nativeEvent.inputType = 'historyRedo'; + } else { + nativeEvent.inputType = 'historyUndo'; + } + + handleOnChangeText(e); + return; + } + + const blurOnSubmitDefault = !multiline; + const shouldBlurOnSubmit = blurOnSubmit === null ? blurOnSubmitDefault : blurOnSubmit; + + const nativeEvent = e.nativeEvent; + const isComposing = isEventComposing(nativeEvent); + + const event = e as unknown as NativeSyntheticEvent; + setEventProps(event); + if (onKeyPress) { + onKeyPress(event); + } + + if ( + e.key === 'Enter' && + // Do not call submit if composition is occuring. + !isComposing && + !e.isDefaultPrevented() + ) { + // prevent "Enter" from inserting a newline or submitting a form + e.preventDefault(); + if (!e.shiftKey && (blurOnSubmit || !multiline) && onSubmitEditing) { + onSubmitEditing(event as unknown as NativeSyntheticEvent); + } else if (multiline) { + // We need to change normal behavior of "Enter" key to insert a line breaks, to prevent wrapping contentEditable text in
tags. + // Thanks to that in every situation we have proper amount of new lines in our parsed text. Without it pressing enter in empty lines will add 2 more new lines. + insertText(e, '\n'); + } + if (!e.shiftKey && ((shouldBlurOnSubmit && hostNode !== null) || !multiline)) { + setTimeout(() => divRef.current && divRef.current.blur(), 0); + } + } + }, + [multiline, blurOnSubmit, setEventProps, onKeyPress, handleOnChangeText, onSubmitEditing, insertText], + ); + + const handleFocus: FocusEventHandler = useCallback( + (event) => { + hasJustBeenFocused.current = true; + const e = event as unknown as NativeSyntheticEvent; + const hostNode = e.target as unknown as HTMLDivElement; + currentlyFocusedField.current = hostNode; + setEventProps(e); + if (divRef.current) { + if (contentSelection.current) { + setCursorPosition(divRef.current, contentSelection.current.start, contentSelection.current.end); + } else { + const valueLength = value ? value.length : (divRef.current.value || '').length; + setCursorPosition(divRef.current, valueLength, null); + } + } + + if (divRef.current) { + divRef.current.scrollIntoView({ + block: 'nearest', + }); + } + + if (onFocus) { + setEventProps(e); + onFocus(e); + } + + if (hostNode !== null) { + if (clearTextOnFocus && divRef.current) { + divRef.current.textContent = ''; + } + if (selectTextOnFocus) { + // Safari requires selection to occur in a setTimeout + if (focusTimeout !== null) { + clearTimeout(focusTimeout); + } + focusTimeout = setTimeout(() => { + if (hostNode === null) { + return; + } + document.execCommand('selectAll', false, ''); + }, 0); + } + } + }, + [clearTextOnFocus, onFocus, selectTextOnFocus, setEventProps, value], + ); + + const handleBlur: FocusEventHandler = useCallback( + (event) => { + const e = event as unknown as NativeSyntheticEvent; + removeSelection(); + currentlyFocusedField.current = null; + if (onBlur) { + setEventProps(e); + onBlur(e); + } + }, + [onBlur, setEventProps], + ); + + const handleClick = useCallback( + (e: MouseEvent) => { + if (!onClick || !divRef.current) { + return; + } + (e.target as HTMLInputElement).value = divRef.current.value; + onClick(e); + }, + [onClick], + ); + + const handleCopy: ClipboardEventHandler = useCallback((e) => { + if (!divRef.current || !contentSelection.current) { + return; + } + e.preventDefault(); + const text = divRef.current?.value.substring(contentSelection.current.start, contentSelection.current.end); + e.clipboardData.setData('text/plain', text ?? ''); + }, []); + + const handleCut = useCallback( + (e: React.ClipboardEvent) => { + if (!divRef.current || !contentSelection.current) { + return; + } + handleCopy(e); + if (contentSelection.current.start !== contentSelection.current.end) { + document.execCommand('delete'); + } + }, + [handleCopy], + ); + + const handlePaste = useCallback( + (e: React.ClipboardEvent) => { + if (e.isDefaultPrevented() || !divRef.current || !contentSelection.current) { + return; + } + e.preventDefault(); + const clipboardData = e.clipboardData; + const text = clipboardData.getData('text/plain'); + insertText(e, text); + }, + [insertText], + ); + + const startComposition = useCallback(() => { + compositionRef.current = true; + }, []); + + const endComposition = useCallback( + (e: React.CompositionEvent) => { + compositionRef.current = false; + handleOnChangeText(e); + }, + [handleOnChangeText], + ); + + const setRef = (currentRef: HTMLDivElement | null) => { + const r = currentRef; + if (r) { + (r as unknown as TextInput).isFocused = () => document.activeElement === r; + (r as unknown as TextInput).clear = () => { + r.textContent = ''; + updateTextColor(r, ''); + }; + + if (value === '' || value === undefined) { + // update to placeholder color when value is empty + updateTextColor(r, r.textContent ?? ''); + } + } + + if (ref) { + if (typeof ref === 'object') { + // eslint-disable-next-line no-param-reassign + (ref as MutableRefObject).current = r; + } else if (typeof ref === 'function') { + (ref as (elementRef: HTMLDivElement | null) => void)(r); + } + } + divRef.current = r as MarkdownTextInputElement; + }; + + const handleTouchStart = (event: TouchEvent) => { + if (!onTouchStart) { + return; + } + const e = event as unknown as GestureResponderEvent; + onTouchStart(e); + }; + + useClientEffect( + function parseAndStyleValue() { + if (!divRef.current || value === divRef.current.value) { + return; + } + + if (value === undefined) { + parseText(parser, divRef.current, divRef.current.value, processedMarkdownStyle); + return; + } + const normalizedValue = normalizeValue(value); + + divRef.current.value = normalizedValue; + parseText(parser, divRef.current, normalizedValue, processedMarkdownStyle, null, true, false, true); + + updateTextColor(divRef.current, value); + }, + [parser, multiline, processedMarkdownStyle, value, maxLength], + ); + + useClientEffect( + function adjustHeight() { + if (!divRef.current || !multiline) { + return; + } + const elementHeight = getElementHeight(divRef.current, inputStyles, numberOfLines); + divRef.current.style.height = elementHeight; + divRef.current.style.maxHeight = elementHeight; + }, + [numberOfLines], + ); + + useEffect(() => { + if (!divRef.current) { + return; + } + // focus the input on mount if autoFocus is set + if (autoFocus) { + divRef.current.focus(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + // update content size when the input styles change + handleContentSizeChange(); + }, [handleContentSizeChange, inputStyles]); + + useEffect(() => { + if (!divRef.current || !selection || (contentSelection.current && selection.start === contentSelection.current.start && selection.end === contentSelection.current.end)) { + return; + } + const newSelection: Selection = {start: selection.start, end: selection.end ?? selection.start}; + contentSelection.current = newSelection; + updateRefSelectionVariables(newSelection); + setCursorPosition(divRef.current, newSelection.start, newSelection.end); + }, [selection, updateRefSelectionVariables]); + + useEffect(() => { + const handleReconnect = () => { + forceRefreshAllImages(divRef.current as MarkdownTextInputElement, processedMarkdownStyle); + }; + + window.addEventListener('online', handleReconnect); + return () => { + window.removeEventListener('online', handleReconnect); + }; + }, [processedMarkdownStyle]); + + return ( + // eslint-disable-next-line jsx-a11y/no-static-element-interactions +
- - ); -}); + ); + }, +); const styles = StyleSheet.create({ - displayNone: { - display: 'none', + defaultInputStyles: { + borderColor: 'black', + borderWidth: 1, + borderStyle: 'solid', + fontFamily: 'sans-serif', + // @ts-expect-error it works on web + boxSizing: 'border-box', + overflowY: 'auto', + overflowX: 'auto', + overflowWrap: 'break-word', }, - farAway: { - position: 'absolute', - top: 1e8, - left: 1e8, + disabledInputStyles: { + opacity: 0.75, + cursor: 'auto', }, }); -export type {PartialMarkdownStyle as MarkdownStyle, MarkdownTextInputProps}; - export default MarkdownTextInput; + +export type {MarkdownTextInputProps, MarkdownTextInputElement, HTMLMarkdownElement}; diff --git a/src/MarkdownTextInput.web.tsx b/src/MarkdownTextInput.web.tsx deleted file mode 100644 index b69028012..000000000 --- a/src/MarkdownTextInput.web.tsx +++ /dev/null @@ -1,784 +0,0 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -import type { - TextInput, - TextInputSubmitEditingEventData, - TextStyle, - NativeSyntheticEvent, - TextInputSelectionChangeEventData, - TextInputProps, - TextInputKeyPressEventData, - TextInputFocusEventData, - TextInputContentSizeChangeEventData, - GestureResponderEvent, -} from 'react-native'; -import React, {useEffect, useRef, useCallback, useMemo, useLayoutEffect} from 'react'; -import type {CSSProperties, MutableRefObject, ReactEventHandler, FocusEventHandler, MouseEvent, KeyboardEvent, SyntheticEvent, ClipboardEventHandler, TouchEvent} from 'react'; -import {StyleSheet} from 'react-native'; -import {updateInputStructure} from './web/utils/parserUtils'; -import InputHistory from './web/InputHistory'; -import type {TreeNode} from './web/utils/treeUtils'; -import {getCurrentCursorPosition, removeSelection, setCursorPosition} from './web/utils/cursorUtils'; -import './web/MarkdownTextInput.css'; -import type {MarkdownStyle} from './MarkdownTextInputDecoratorViewNativeComponent'; -import {getElementHeight, getPlaceholderValue, isEventComposing, normalizeValue, parseInnerHTMLToText} from './web/utils/inputUtils'; -import {parseToReactDOMStyle, processMarkdownStyle} from './web/utils/webStyleUtils'; -import {forceRefreshAllImages} from './web/inputElements/inlineImage'; -import type {MarkdownRange, InlineImagesInputProps} from './commonTypes'; - -const useClientEffect = typeof window === 'undefined' ? useEffect : useLayoutEffect; - -interface MarkdownTextInputProps extends TextInputProps, InlineImagesInputProps { - markdownStyle?: MarkdownStyle; - parser: (text: string) => MarkdownRange[]; - onClick?: (e: MouseEvent) => void; - dir?: string; - disabled?: boolean; -} - -interface MarkdownNativeEvent extends Event { - inputType?: string; -} - -type MarkdownTextInput = TextInput & React.Component; - -type Selection = { - start: number; - end: number; -}; - -type Dimensions = { - width: number; - height: number; -}; - -type ParseTextResult = { - text: string; - cursorPosition: number | null; -}; - -let focusTimeout: NodeJS.Timeout | null = null; - -type MarkdownTextInputElement = HTMLDivElement & - HTMLInputElement & { - tree: TreeNode; - selection: Selection; - imageElements: HTMLImageElement[]; - }; - -type HTMLMarkdownElement = HTMLElement & { - value: string; -}; - -const MarkdownTextInput = React.forwardRef( - ( - { - accessibilityLabel, - accessibilityLabelledBy, - accessibilityRole, - autoCapitalize = 'sentences', - autoCorrect = true, - blurOnSubmit = false, - clearTextOnFocus, - dir = 'auto', - disabled = false, - numberOfLines, - multiline = false, - markdownStyle, - parser, - onBlur, - onChange, - onChangeText, - onClick, - onFocus, - onKeyPress, - onSelectionChange, - onSubmitEditing, - placeholder, - placeholderTextColor = `rgba(0,0,0,0.2)`, - selectTextOnFocus, - spellCheck, - selection, - style = {}, - value, - autoFocus = false, - onContentSizeChange, - id, - inputMode, - onTouchStart, - maxLength, - addAuthTokenToImageURLCallback, - imagePreviewAuthRequiredURLs, - }, - ref, - ) => { - if (parser === undefined) { - throw new Error('[react-native-live-markdown] `parser` is undefined'); - } - if (typeof parser !== 'function') { - throw new Error('[react-native-live-markdown] `parser` is not a function'); - } - - const compositionRef = useRef(false); - const divRef = useRef(null); - const currentlyFocusedField = useRef(null); - const contentSelection = useRef(null); - const className = `react-native-live-markdown-input-${multiline ? 'multiline' : 'singleline'}`; - const history = useRef(); - const dimensions = useRef(null); - const pasteContent = useRef(null); - const hasJustBeenFocused = useRef(false); - - if (!history.current) { - history.current = new InputHistory(100, 150, value || ''); - } - - const flattenedStyle = useMemo(() => StyleSheet.flatten(style), [style]); - - // Empty placeholder would collapse the div, so we need to use zero-width space to prevent it - const heightSafePlaceholder = useMemo(() => getPlaceholderValue(placeholder), [placeholder]); - - const setEventProps = useCallback((e: NativeSyntheticEvent) => { - if (divRef.current) { - const text = divRef.current.value; - if (e.target) { - // TODO: change the logic here so every event have value property - (e.target as unknown as HTMLInputElement).value = text; - } - if (e.nativeEvent && e.nativeEvent.text) { - e.nativeEvent.text = text; - } - } - return e; - }, []); - - const parseText = useCallback( - ( - parserFunction: (input: string) => MarkdownRange[], - target: MarkdownTextInputElement, - text: string | null, - customMarkdownStyles: MarkdownStyle, - cursorPosition: number | null = null, - shouldAddToHistory = true, - shouldForceDOMUpdate = false, - shouldScrollIntoView = false, - ): ParseTextResult => { - if (!divRef.current) { - return {text: text || '', cursorPosition: null}; - } - - if (text === null) { - return {text: divRef.current.value, cursorPosition: null}; - } - const parsedText = updateInputStructure(parserFunction, target, text, cursorPosition, multiline, customMarkdownStyles, false, shouldForceDOMUpdate, shouldScrollIntoView, { - addAuthTokenToImageURLCallback, - imagePreviewAuthRequiredURLs, - }); - divRef.current.value = parsedText.text; - - if (history.current && shouldAddToHistory) { - history.current.throttledAdd(parsedText.text, parsedText.cursorPosition); - } - - return parsedText; - }, - [addAuthTokenToImageURLCallback, imagePreviewAuthRequiredURLs, multiline], - ); - - const processedMarkdownStyle = useMemo(() => { - const newMarkdownStyle = processMarkdownStyle(markdownStyle); - if (divRef.current) { - parseText(parser, divRef.current, divRef.current.value, newMarkdownStyle, null, false, false); - } - return newMarkdownStyle; - }, [parser, markdownStyle, parseText]); - - const inputStyles = useMemo( - () => - StyleSheet.flatten([ - styles.defaultInputStyles, - flattenedStyle && { - caretColor: (flattenedStyle as TextStyle).color || 'black', - }, - {whiteSpace: multiline ? 'pre-wrap' : 'nowrap'}, - disabled && styles.disabledInputStyles, - parseToReactDOMStyle(flattenedStyle), - ]) as CSSProperties, - [flattenedStyle, multiline, disabled], - ); - - const undo = useCallback( - (target: MarkdownTextInputElement): ParseTextResult => { - if (!history.current) { - return { - text: '', - cursorPosition: 0, - }; - } - const item = history.current.undo(); - const undoValue = item ? item.text : null; - return parseText(parser, target, undoValue, processedMarkdownStyle, item ? item.cursorPosition : null, false); - }, - [parser, parseText, processedMarkdownStyle], - ); - - const redo = useCallback( - (target: MarkdownTextInputElement): ParseTextResult => { - if (!history.current) { - return { - text: '', - cursorPosition: 0, - }; - } - const item = history.current.redo(); - const redoValue = item ? item.text : null; - return parseText(parser, target, redoValue, processedMarkdownStyle, item ? item.cursorPosition : null, false); - }, - [parser, parseText, processedMarkdownStyle], - ); - - // Placeholder text color logic - const updateTextColor = useCallback( - (node: HTMLDivElement, text: string) => { - // eslint-disable-next-line no-param-reassign -- we need to change the style of the node, so we need to modify it - node.style.color = String(placeholder && (text === '' || text === '\n') ? placeholderTextColor : flattenedStyle.color || 'black'); - }, - [flattenedStyle.color, placeholder, placeholderTextColor], - ); - - const handleSelectionChange: ReactEventHandler = useCallback( - (event) => { - const e = event as unknown as NativeSyntheticEvent; - setEventProps(e); - if (onSelectionChange && contentSelection.current) { - e.nativeEvent.selection = contentSelection.current; - onSelectionChange(e); - } - }, - [onSelectionChange, setEventProps], - ); - - const updateRefSelectionVariables = useCallback((newSelection: Selection) => { - if (!divRef.current) { - return; - } - const {start, end} = newSelection; - divRef.current.selection = {start, end}; - }, []); - - const updateSelection = useCallback( - (e: SyntheticEvent, predefinedSelection: Selection | null = null) => { - if (!divRef.current) { - return; - } - const newSelection = predefinedSelection || getCurrentCursorPosition(divRef.current); - - if (newSelection && (!contentSelection.current || contentSelection.current.start !== newSelection.start || contentSelection.current.end !== newSelection.end)) { - updateRefSelectionVariables(newSelection); - contentSelection.current = newSelection; - - handleSelectionChange(e); - } - }, - [handleSelectionChange, updateRefSelectionVariables], - ); - - const handleOnSelect = useCallback( - (e: React.SyntheticEvent) => { - updateSelection(e); - - // If the input has just been focused, we need to scroll the cursor into view - if (divRef.current && contentSelection.current && hasJustBeenFocused.current) { - setCursorPosition(divRef.current, contentSelection.current?.start, contentSelection.current?.end, true); - hasJustBeenFocused.current = false; - } - }, - [updateSelection], - ); - - const handleContentSizeChange = useCallback(() => { - if (!divRef.current || !multiline || !onContentSizeChange) { - return; - } - - const {offsetWidth: newWidth, offsetHeight: newHeight} = divRef.current; - - if (newHeight !== dimensions.current?.height || newWidth !== dimensions.current.width) { - dimensions.current = {height: newHeight, width: newWidth}; - - onContentSizeChange({ - nativeEvent: { - contentSize: dimensions.current, - }, - } as NativeSyntheticEvent); - } - }, [multiline, onContentSizeChange]); - - const handleOnChangeText = useCallback( - (e: SyntheticEvent) => { - if (!divRef.current || !(e.target instanceof HTMLElement) || !contentSelection.current) { - return; - } - const nativeEvent = e.nativeEvent as MarkdownNativeEvent; - const inputType = nativeEvent.inputType; - - updateTextColor(divRef.current, e.target.textContent ?? ''); - const previousText = divRef.current.value; - let parsedText = normalizeValue( - inputType === 'pasteText' ? pasteContent.current || '' : parseInnerHTMLToText(e.target as MarkdownTextInputElement, contentSelection.current.start, inputType), - ); - - if (pasteContent.current) { - pasteContent.current = null; - } - - if (maxLength !== undefined && parsedText.length > maxLength) { - parsedText = previousText; - } - - const prevSelection = contentSelection.current ?? {start: 0, end: 0}; - const newCursorPosition = - inputType === 'deleteContentForward' && contentSelection.current.start === contentSelection.current.end - ? Math.max(contentSelection.current.start, 0) // Don't move the caret when deleting forward with no characters selected - : Math.max(Math.max(contentSelection.current.end, 0) + (parsedText.length - previousText.length), 0); - - if (compositionRef.current) { - updateTextColor(divRef.current, parsedText); - updateSelection(e, { - start: newCursorPosition, - end: newCursorPosition, - }); - divRef.current.value = parsedText; - if (onChangeText) { - onChangeText(parsedText); - } - return; - } - let newInputUpdate: ParseTextResult; - switch (inputType) { - case 'historyUndo': - newInputUpdate = undo(divRef.current); - break; - case 'historyRedo': - newInputUpdate = redo(divRef.current); - break; - default: - newInputUpdate = parseText(parser, divRef.current, parsedText, processedMarkdownStyle, newCursorPosition, true, !inputType, inputType === 'pasteText'); - } - const {text, cursorPosition} = newInputUpdate; - updateTextColor(divRef.current, text); - updateSelection(e, { - start: cursorPosition ?? 0, - end: cursorPosition ?? 0, - }); - - if (onChange) { - 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 = text.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 = previousText.length - text.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); - } - - 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) { - onChangeText(text); - } - - handleContentSizeChange(); - }, - [parser, updateTextColor, updateSelection, onChange, onChangeText, handleContentSizeChange, undo, redo, parseText, processedMarkdownStyle, setEventProps, maxLength], - ); - - const insertText = useCallback( - (e: SyntheticEvent, text: string) => { - if (!contentSelection.current || !divRef.current) { - return; - } - - const previousText = divRef.current.value; - let insertedText = text; - let availableLength = text.length; - const prefix = divRef.current.value.substring(0, contentSelection.current.start); - const suffix = divRef.current.value.substring(contentSelection.current.end); - if (maxLength !== undefined) { - availableLength = maxLength - prefix.length - suffix.length; - insertedText = text.slice(0, Math.max(availableLength, 0)); - } - const newText = `${prefix}${insertedText}${suffix}`; - if (previousText === newText) { - document.execCommand('delete'); - } - - pasteContent.current = availableLength > 0 ? newText : previousText; - (e.nativeEvent as MarkdownNativeEvent).inputType = 'pasteText'; - - handleOnChangeText(e); - }, - [handleOnChangeText, maxLength], - ); - - const handleKeyPress = useCallback( - (e: KeyboardEvent) => { - if (!divRef.current) { - return; - } - - const hostNode = e.target; - e.stopPropagation(); - - if (e.key === 'z' && (e.ctrlKey || e.metaKey)) { - e.preventDefault(); - const nativeEvent = e.nativeEvent as unknown as MarkdownNativeEvent; - if (e.shiftKey) { - nativeEvent.inputType = 'historyRedo'; - } else { - nativeEvent.inputType = 'historyUndo'; - } - - handleOnChangeText(e); - return; - } - - const blurOnSubmitDefault = !multiline; - const shouldBlurOnSubmit = blurOnSubmit === null ? blurOnSubmitDefault : blurOnSubmit; - - const nativeEvent = e.nativeEvent; - const isComposing = isEventComposing(nativeEvent); - - const event = e as unknown as NativeSyntheticEvent; - setEventProps(event); - if (onKeyPress) { - onKeyPress(event); - } - - if ( - e.key === 'Enter' && - // Do not call submit if composition is occuring. - !isComposing && - !e.isDefaultPrevented() - ) { - // prevent "Enter" from inserting a newline or submitting a form - e.preventDefault(); - if (!e.shiftKey && (blurOnSubmit || !multiline) && onSubmitEditing) { - onSubmitEditing(event as unknown as NativeSyntheticEvent); - } else if (multiline) { - // We need to change normal behavior of "Enter" key to insert a line breaks, to prevent wrapping contentEditable text in
tags. - // Thanks to that in every situation we have proper amount of new lines in our parsed text. Without it pressing enter in empty lines will add 2 more new lines. - insertText(e, '\n'); - } - if (!e.shiftKey && ((shouldBlurOnSubmit && hostNode !== null) || !multiline)) { - setTimeout(() => divRef.current && divRef.current.blur(), 0); - } - } - }, - [multiline, blurOnSubmit, setEventProps, onKeyPress, handleOnChangeText, onSubmitEditing, insertText], - ); - - const handleFocus: FocusEventHandler = useCallback( - (event) => { - hasJustBeenFocused.current = true; - const e = event as unknown as NativeSyntheticEvent; - const hostNode = e.target as unknown as HTMLDivElement; - currentlyFocusedField.current = hostNode; - setEventProps(e); - if (divRef.current) { - if (contentSelection.current) { - setCursorPosition(divRef.current, contentSelection.current.start, contentSelection.current.end); - } else { - const valueLength = value ? value.length : (divRef.current.value || '').length; - setCursorPosition(divRef.current, valueLength, null); - } - } - - if (divRef.current) { - divRef.current.scrollIntoView({ - block: 'nearest', - }); - } - - if (onFocus) { - setEventProps(e); - onFocus(e); - } - - if (hostNode !== null) { - if (clearTextOnFocus && divRef.current) { - divRef.current.textContent = ''; - } - if (selectTextOnFocus) { - // Safari requires selection to occur in a setTimeout - if (focusTimeout !== null) { - clearTimeout(focusTimeout); - } - focusTimeout = setTimeout(() => { - if (hostNode === null) { - return; - } - document.execCommand('selectAll', false, ''); - }, 0); - } - } - }, - [clearTextOnFocus, onFocus, selectTextOnFocus, setEventProps, value], - ); - - const handleBlur: FocusEventHandler = useCallback( - (event) => { - const e = event as unknown as NativeSyntheticEvent; - removeSelection(); - currentlyFocusedField.current = null; - if (onBlur) { - setEventProps(e); - onBlur(e); - } - }, - [onBlur, setEventProps], - ); - - const handleClick = useCallback( - (e: MouseEvent) => { - if (!onClick || !divRef.current) { - return; - } - (e.target as HTMLInputElement).value = divRef.current.value; - onClick(e); - }, - [onClick], - ); - - const handleCopy: ClipboardEventHandler = useCallback((e) => { - if (!divRef.current || !contentSelection.current) { - return; - } - e.preventDefault(); - const text = divRef.current?.value.substring(contentSelection.current.start, contentSelection.current.end); - e.clipboardData.setData('text/plain', text ?? ''); - }, []); - - const handleCut = useCallback( - (e: React.ClipboardEvent) => { - if (!divRef.current || !contentSelection.current) { - return; - } - handleCopy(e); - if (contentSelection.current.start !== contentSelection.current.end) { - document.execCommand('delete'); - } - }, - [handleCopy], - ); - - const handlePaste = useCallback( - (e: React.ClipboardEvent) => { - if (e.isDefaultPrevented() || !divRef.current || !contentSelection.current) { - return; - } - e.preventDefault(); - const clipboardData = e.clipboardData; - const text = clipboardData.getData('text/plain'); - insertText(e, text); - }, - [insertText], - ); - - const startComposition = useCallback(() => { - compositionRef.current = true; - }, []); - - const endComposition = useCallback( - (e: React.CompositionEvent) => { - compositionRef.current = false; - handleOnChangeText(e); - }, - [handleOnChangeText], - ); - - const setRef = (currentRef: HTMLDivElement | null) => { - const r = currentRef; - if (r) { - (r as unknown as TextInput).isFocused = () => document.activeElement === r; - (r as unknown as TextInput).clear = () => { - r.textContent = ''; - updateTextColor(r, ''); - }; - - if (value === '' || value === undefined) { - // update to placeholder color when value is empty - updateTextColor(r, r.textContent ?? ''); - } - } - - if (ref) { - if (typeof ref === 'object') { - // eslint-disable-next-line no-param-reassign - (ref as MutableRefObject).current = r; - } else if (typeof ref === 'function') { - (ref as (elementRef: HTMLDivElement | null) => void)(r); - } - } - divRef.current = r as MarkdownTextInputElement; - }; - - const handleTouchStart = (event: TouchEvent) => { - if (!onTouchStart) { - return; - } - const e = event as unknown as GestureResponderEvent; - onTouchStart(e); - }; - - useClientEffect( - function parseAndStyleValue() { - if (!divRef.current || value === divRef.current.value) { - return; - } - - if (value === undefined) { - parseText(parser, divRef.current, divRef.current.value, processedMarkdownStyle); - return; - } - const normalizedValue = normalizeValue(value); - - divRef.current.value = normalizedValue; - parseText(parser, divRef.current, normalizedValue, processedMarkdownStyle, null, true, false, true); - - updateTextColor(divRef.current, value); - }, - [parser, multiline, processedMarkdownStyle, value, maxLength], - ); - - useClientEffect( - function adjustHeight() { - if (!divRef.current || !multiline) { - return; - } - const elementHeight = getElementHeight(divRef.current, inputStyles, numberOfLines); - divRef.current.style.height = elementHeight; - divRef.current.style.maxHeight = elementHeight; - }, - [numberOfLines], - ); - - useEffect(() => { - if (!divRef.current) { - return; - } - // focus the input on mount if autoFocus is set - if (autoFocus) { - divRef.current.focus(); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - useEffect(() => { - // update content size when the input styles change - handleContentSizeChange(); - }, [handleContentSizeChange, inputStyles]); - - useEffect(() => { - if (!divRef.current || !selection || (contentSelection.current && selection.start === contentSelection.current.start && selection.end === contentSelection.current.end)) { - return; - } - const newSelection: Selection = {start: selection.start, end: selection.end ?? selection.start}; - contentSelection.current = newSelection; - updateRefSelectionVariables(newSelection); - setCursorPosition(divRef.current, newSelection.start, newSelection.end); - }, [selection, updateRefSelectionVariables]); - - useEffect(() => { - const handleReconnect = () => { - forceRefreshAllImages(divRef.current as MarkdownTextInputElement, processedMarkdownStyle); - }; - - window.addEventListener('online', handleReconnect); - return () => { - window.removeEventListener('online', handleReconnect); - }; - }, [processedMarkdownStyle]); - - return ( - // eslint-disable-next-line jsx-a11y/no-static-element-interactions -
- ); - }, -); - -const styles = StyleSheet.create({ - defaultInputStyles: { - borderColor: 'black', - borderWidth: 1, - borderStyle: 'solid', - fontFamily: 'sans-serif', - // @ts-expect-error it works on web - boxSizing: 'border-box', - overflowY: 'auto', - overflowX: 'auto', - overflowWrap: 'break-word', - }, - disabledInputStyles: { - opacity: 0.75, - cursor: 'auto', - }, -}); - -export default MarkdownTextInput; - -export type {MarkdownTextInputProps, MarkdownTextInputElement, HTMLMarkdownElement}; diff --git a/src/font-family-monospace.ios.tsx b/src/font-family-monospace.ios.tsx new file mode 100644 index 000000000..43905a3e6 --- /dev/null +++ b/src/font-family-monospace.ios.tsx @@ -0,0 +1 @@ +export default 'Courier'; diff --git a/src/font-family-monospace.tsx b/src/font-family-monospace.tsx new file mode 100644 index 000000000..39e65926b --- /dev/null +++ b/src/font-family-monospace.tsx @@ -0,0 +1,3 @@ +const FONT_FAMILY_MONOSPACE = 'monospace'; + +export default FONT_FAMILY_MONOSPACE; diff --git a/src/index.tsx b/src/index.tsx index 4f3b1d607..1857df47e 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,4 +1,5 @@ export {default as MarkdownTextInput} from './MarkdownTextInput'; -export type {MarkdownTextInputProps, MarkdownStyle} from './MarkdownTextInput'; +export type {MarkdownTextInputProps} from './MarkdownTextInput'; +export type {MarkdownStyle} from './MarkdownTextInput.native'; // .native is fine if we use "export type" export type {MarkdownType, MarkdownRange} from './commonTypes'; export {default as parseExpensiMark} from './parseExpensiMark'; diff --git a/src/is-web.native.tsx b/src/is-web.native.tsx new file mode 100644 index 000000000..761bd58b2 --- /dev/null +++ b/src/is-web.native.tsx @@ -0,0 +1,3 @@ +const IS_WEB = false; + +export default IS_WEB; diff --git a/src/is-web.tsx b/src/is-web.tsx new file mode 100644 index 000000000..2fdd8431e --- /dev/null +++ b/src/is-web.tsx @@ -0,0 +1,3 @@ +const IS_WEB = true; + +export default IS_WEB; diff --git a/src/parseExpensiMark.ts b/src/parseExpensiMark.ts index 673734af7..44ef2eef4 100644 --- a/src/parseExpensiMark.ts +++ b/src/parseExpensiMark.ts @@ -1,22 +1,20 @@ 'worklet'; -import {Platform} from 'react-native'; import {ExpensiMark} from 'expensify-common'; import {unescapeText} from 'expensify-common/dist/utils'; import {decode} from 'html-entities'; import type {WorkletFunction} from 'react-native-reanimated/lib/typescript/commonTypes'; import type {MarkdownType, MarkdownRange} from './commonTypes'; - -function isWeb() { - return Platform.OS === 'web'; -} +import IS_WEB from './is-web'; function isJest() { return !!global.process.env.JEST_WORKER_ID; } +const IS_DEV = typeof __DEV__ === 'boolean' ? __DEV__ : process.env.NODE_ENV === 'development'; + // eslint-disable-next-line no-underscore-dangle -if (__DEV__ && !isWeb() && !isJest() && (decode as WorkletFunction).__workletHash === undefined) { +if (IS_DEV && !IS_WEB && !isJest() && (decode as WorkletFunction).__workletHash === undefined) { throw new Error( "[react-native-live-markdown] `parseExpensiMark` requires `html-entities` package to be workletized. Please add `'worklet';` directive at the top of `node_modules/html-entities/lib/index.js` using patch-package.", ); diff --git a/src/styleUtils.ts b/src/styleUtils.ts index 47cfa5295..4cb9778b9 100644 --- a/src/styleUtils.ts +++ b/src/styleUtils.ts @@ -1,15 +1,10 @@ -import {Platform} from 'react-native'; import type {MarkdownStyle} from './MarkdownTextInputDecoratorViewNativeComponent'; +import FONT_FAMILY_MONOSPACE from './font-family-monospace'; type PartialMarkdownStyle = Partial<{ [K in keyof MarkdownStyle]: Partial; }>; -const FONT_FAMILY_MONOSPACE = Platform.select({ - ios: 'Courier', - default: 'monospace', -}); - function makeDefaultMarkdownStyle(): MarkdownStyle { return { syntax: { diff --git a/src/web/inputElements/inlineImage.ts b/src/web/inputElements/inlineImage.ts index 9648d9c19..92e75432f 100644 --- a/src/web/inputElements/inlineImage.ts +++ b/src/web/inputElements/inlineImage.ts @@ -1,4 +1,4 @@ -import type {HTMLMarkdownElement, MarkdownTextInputElement} from '../../MarkdownTextInput.web'; +import type {HTMLMarkdownElement, MarkdownTextInputElement} from '../../MarkdownTextInput'; import type {InlineImagesInputProps, MarkdownRange} from '../../commonTypes'; import {parseStringWithUnitToNumber} from '../../styleUtils'; import type {PartialMarkdownStyle} from '../../styleUtils'; diff --git a/src/web/utils/animationUtils.ts b/src/web/utils/animationUtils.ts index 7fc9cede8..657b54925 100644 --- a/src/web/utils/animationUtils.ts +++ b/src/web/utils/animationUtils.ts @@ -1,4 +1,4 @@ -import type {HTMLMarkdownElement, MarkdownTextInputElement} from '../../MarkdownTextInput.web'; +import type {HTMLMarkdownElement, MarkdownTextInputElement} from '../../MarkdownTextInput'; const ANIMATED_ELEMENT_TYPES = ['spinner'] as const; diff --git a/src/web/utils/blockUtils.ts b/src/web/utils/blockUtils.ts index e9837569b..2b86f6379 100644 --- a/src/web/utils/blockUtils.ts +++ b/src/web/utils/blockUtils.ts @@ -1,4 +1,4 @@ -import type {MarkdownTextInputElement} from '../../MarkdownTextInput.web'; +import type {MarkdownTextInputElement} from '../../MarkdownTextInput'; import type {InlineImagesInputProps, MarkdownRange} from '../../commonTypes'; import type {PartialMarkdownStyle} from '../../styleUtils'; import {addInlineImagePreview} from '../inputElements/inlineImage'; diff --git a/src/web/utils/cursorUtils.ts b/src/web/utils/cursorUtils.ts index adc0a1b97..7d830ae9a 100644 --- a/src/web/utils/cursorUtils.ts +++ b/src/web/utils/cursorUtils.ts @@ -1,4 +1,4 @@ -import type {MarkdownTextInputElement} from '../../MarkdownTextInput.web'; +import type {MarkdownTextInputElement} from '../../MarkdownTextInput'; import {findHTMLElementInTree, getTreeNodeByIndex} from './treeUtils'; import type {TreeNode} from './treeUtils'; diff --git a/src/web/utils/inputUtils.ts b/src/web/utils/inputUtils.ts index d52dc6d86..7ca6a08bd 100644 --- a/src/web/utils/inputUtils.ts +++ b/src/web/utils/inputUtils.ts @@ -1,5 +1,5 @@ import type {CSSProperties} from 'react'; -import type {MarkdownTextInputElement} from '../../MarkdownTextInput.web'; +import type {MarkdownTextInputElement} from '../../MarkdownTextInput'; const ZERO_WIDTH_SPACE = '\u200B'; diff --git a/src/web/utils/parserUtils.ts b/src/web/utils/parserUtils.ts index 419ed780e..374b42987 100644 --- a/src/web/utils/parserUtils.ts +++ b/src/web/utils/parserUtils.ts @@ -1,4 +1,4 @@ -import type {HTMLMarkdownElement, MarkdownTextInputElement} from '../../MarkdownTextInput.web'; +import type {HTMLMarkdownElement, MarkdownTextInputElement} from '../../MarkdownTextInput'; import {addNodeToTree, createRootTreeNode, updateTreeElementRefs} from './treeUtils'; import type {NodeType, TreeNode} from './treeUtils'; import type {PartialMarkdownStyle} from '../../styleUtils'; diff --git a/src/web/utils/treeUtils.ts b/src/web/utils/treeUtils.ts index c4cbd8663..4740f1af6 100644 --- a/src/web/utils/treeUtils.ts +++ b/src/web/utils/treeUtils.ts @@ -1,4 +1,4 @@ -import type {HTMLMarkdownElement} from '../../MarkdownTextInput.web'; +import type {HTMLMarkdownElement} from '../../MarkdownTextInput'; import type {MarkdownRange, MarkdownType} from '../../commonTypes'; type NodeType = MarkdownType | 'line' | 'text' | 'br' | 'block' | 'root'; From 806d9945aa44766ce15b8e77abaf188cd90b96dc Mon Sep 17 00:00:00 2001 From: Fernando Rojo Date: Wed, 11 Dec 2024 16:07:22 -0500 Subject: [PATCH 2/3] types --- src/web/utils/webStyleUtils.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/web/utils/webStyleUtils.ts b/src/web/utils/webStyleUtils.ts index e0f845102..1714536bd 100644 --- a/src/web/utils/webStyleUtils.ts +++ b/src/web/utils/webStyleUtils.ts @@ -2,6 +2,7 @@ import type {TextStyle} from 'react-native'; import type {MarkdownStyle} from '../../MarkdownTextInputDecoratorViewNativeComponent'; import {mergeMarkdownStyleWithDefault} from '../../styleUtils'; +import type {PartialMarkdownStyle} from '../../styleUtils'; let createReactDOMStyle: (style: any) => any; try { @@ -43,7 +44,7 @@ function processUnitsInMarkdownStyle(input: MarkdownStyle): MarkdownStyle { return output as MarkdownStyle; } -function processMarkdownStyle(input: MarkdownStyle | undefined): MarkdownStyle { +function processMarkdownStyle(input: PartialMarkdownStyle | undefined): MarkdownStyle { return processUnitsInMarkdownStyle(mergeMarkdownStyleWithDefault(input)); } From 45460d5b8ec9b3e7b3e59c614b68cb4613e96a0b Mon Sep 17 00:00:00 2001 From: Fernando Rojo Date: Wed, 11 Dec 2024 16:35:44 -0500 Subject: [PATCH 3/3] remove stylesheet.flatten --- src/MarkdownTextInput.tsx | 36 ++++++++++++++++++---------------- src/web/utils/webStyleUtils.ts | 6 ++++-- 2 files changed, 23 insertions(+), 19 deletions(-) diff --git a/src/MarkdownTextInput.tsx b/src/MarkdownTextInput.tsx index b96013785..d7c85e8b6 100644 --- a/src/MarkdownTextInput.tsx +++ b/src/MarkdownTextInput.tsx @@ -2,7 +2,6 @@ import type { TextInput, TextInputSubmitEditingEventData, - TextStyle, NativeSyntheticEvent, TextInputSelectionChangeEventData, TextInputProps, @@ -13,7 +12,6 @@ import type { } from 'react-native'; import React, {useEffect, useRef, useCallback, useMemo, useLayoutEffect} from 'react'; import type {CSSProperties, MutableRefObject, ReactEventHandler, FocusEventHandler, MouseEvent, KeyboardEvent, SyntheticEvent, ClipboardEventHandler, TouchEvent} from 'react'; -import {StyleSheet} from 'react-native'; import {updateInputStructure} from './web/utils/parserUtils'; import InputHistory from './web/InputHistory'; import type {TreeNode} from './web/utils/treeUtils'; @@ -69,6 +67,14 @@ type HTMLMarkdownElement = HTMLElement & { value: string; }; +function flattenStyle(style: React.CSSProperties | React.CSSProperties[] | undefined, finalStyle: CSSProperties = {}) { + if (Array.isArray(style)) { + style.forEach((s) => flattenStyle(s, finalStyle)); + return finalStyle; + } + return Object.assign(finalStyle, style); +} + const MarkdownTextInput = React.forwardRef( ( { @@ -98,7 +104,7 @@ const MarkdownTextInput = React.forwardRef StyleSheet.flatten(style), [style]); + const flattenedStyle = useMemo(() => flattenStyle(style as React.CSSProperties), [style]); // Empty placeholder would collapse the div, so we need to use zero-width space to prevent it const heightSafePlaceholder = useMemo(() => getPlaceholderValue(placeholder), [placeholder]); @@ -193,16 +199,12 @@ const MarkdownTextInput = React.forwardRef - StyleSheet.flatten([ - styles.defaultInputStyles, - flattenedStyle && { - caretColor: (flattenedStyle as TextStyle).color || 'black', - }, - {whiteSpace: multiline ? 'pre-wrap' : 'nowrap'}, - disabled && styles.disabledInputStyles, - parseToReactDOMStyle(flattenedStyle), - ]) as CSSProperties, + () => ({ + ...styles.defaultInputStyles, + ...{whiteSpace: multiline ? 'pre-wrap' : 'nowrap'}, + ...(disabled && styles.disabledInputStyles), + ...parseToReactDOMStyle(flattenedStyle), + }), [flattenedStyle, multiline, disabled], ); @@ -761,23 +763,23 @@ const MarkdownTextInput = React.forwardRef; export default MarkdownTextInput; diff --git a/src/web/utils/webStyleUtils.ts b/src/web/utils/webStyleUtils.ts index 1714536bd..dff3bf2be 100644 --- a/src/web/utils/webStyleUtils.ts +++ b/src/web/utils/webStyleUtils.ts @@ -1,5 +1,4 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import type {TextStyle} from 'react-native'; import type {MarkdownStyle} from '../../MarkdownTextInputDecoratorViewNativeComponent'; import {mergeMarkdownStyleWithDefault} from '../../styleUtils'; import type {PartialMarkdownStyle} from '../../styleUtils'; @@ -48,7 +47,10 @@ function processMarkdownStyle(input: PartialMarkdownStyle | undefined): Markdown return processUnitsInMarkdownStyle(mergeMarkdownStyleWithDefault(input)); } -function parseToReactDOMStyle(style: TextStyle): any { +/** + * TODO nando DEPRECATE? + */ +function parseToReactDOMStyle(style: React.CSSProperties): any { return createReactDOMStyle(preprocessStyle(style)); }