diff --git a/src/MarkdownTextInput.web.tsx b/src/MarkdownTextInput.web.tsx index e6cdce6a..31bda716 100644 --- a/src/MarkdownTextInput.web.tsx +++ b/src/MarkdownTextInput.web.tsx @@ -17,6 +17,7 @@ import * as CursorUtils from './web/cursorUtils'; import * as StyleUtils from './styleUtils'; import type * as MarkdownTextInputDecoratorViewNativeComponent from './MarkdownTextInputDecoratorViewNativeComponent'; import './web/MarkdownTextInput.css'; +import MarkdownInputHistory from './web/historyClass'; // TODO: remove require require('../parser/react-native-live-markdown-parser.js'); @@ -38,6 +39,10 @@ interface MarkdownTextInputProps extends TextInputProps { dir?: string; } +interface MarkdownNativeEvent extends Event { + inputType: string; +} + type Selection = { start: number; end: number; @@ -56,7 +61,6 @@ function isEventComposing(nativeEvent: globalThis.KeyboardEvent) { return nativeEvent.isComposing || nativeEvent.keyCode === 229; } -const parseText = ParseUtils.parseText; const getCurrentCursorPosition = CursorUtils.getCurrentCursorPosition; const setCursorPosition = CursorUtils.setCursorPosition; @@ -95,6 +99,32 @@ const MarkdownTextInput = React.forwardRef( const contentSelection = useRef(null); const className = `react-native-live-markdown-input-${multiline ? 'multiline' : 'single-line'}`; + const history = useRef(new MarkdownInputHistory(50)); + + function parseText(target: HTMLDivElement, text: string, markdownStyles: MarkdownStyle, singleLine: boolean) { + const parsedText = ParseUtils.parseText(target, text, markdownStyles, singleLine); + history.current.debouncedAdd(parsedText); + return parsedText; + } + + const undo = (target: HTMLDivElement, markdownStyles: MarkdownStyle, singleLine: boolean) => { + const text = history.current.undo(); + if (text === null) { + return target.innerText; + } + ParseUtils.parseText(target, text, markdownStyles, singleLine, true); + return text; + }; + + const redo = (target: HTMLDivElement, markdownStyles: MarkdownStyle, singleLine: boolean) => { + const text = history.current.redo(); + if (text === null) { + return target.innerText; + } + ParseUtils.parseText(target, text, markdownStyles, singleLine, true); + return text; + }; + const processedMarkdownStyle = React.useMemo(() => { const newMarkdownStyle = StyleUtils.mergeMarkdownStyleWithDefault(markdownStyle, true); @@ -130,11 +160,60 @@ const MarkdownTextInput = React.forwardRef( node.style.color = String(placeholder && (text === '' || text === '\n') ? placeholderTextColor : (style as TextStyle).color || 'black'); }, []); + const handleOnChangeText = useCallback( + (e: FormEvent) => { + if (!divRef.current || !(e.target instanceof HTMLElement)) { + return; + } + + let text = ''; + const nativeEvent = e.nativeEvent as MarkdownNativeEvent; + if (nativeEvent.inputType === 'historyUndo') { + text = undo(divRef.current, processedMarkdownStyle, !multiline); + } else if (nativeEvent.inputType === 'historyRedo') { + text = redo(divRef.current, processedMarkdownStyle, !multiline); + } else { + text = parseText(divRef.current, e.target.innerText, processedMarkdownStyle, !multiline); + } + + updateTextColor(divRef.current, e.target.innerText); + + if (onChange) { + const event = e as unknown as NativeSyntheticEvent; + setEventProps(event); + onChange(event); + } + + if (onChangeText) { + const normalizedText = normalizeValue(text); + onChangeText(normalizedText); + } + }, + [multiline, onChange, onChangeText, setEventProps], + ); + const handleKeyPress = useCallback( (e: KeyboardEvent) => { + if (!divRef.current) { + return; + } + const hostNode = e.target; e.stopPropagation(); + if (e.key === 'z' && 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; @@ -173,29 +252,6 @@ const MarkdownTextInput = React.forwardRef( [onKeyPress], ); - const handleOnChangeText = useCallback( - (e: FormEvent) => { - if (!divRef.current || !(e.target instanceof HTMLElement)) { - return; - } - - updateTextColor(divRef.current, e.target.innerText); - - const text = parseText(divRef.current, e.target.innerText, processedMarkdownStyle, !multiline); - if (onChange) { - const event = e as unknown as NativeSyntheticEvent; - setEventProps(event); - onChange(event); - } - - if (onChangeText) { - const normalizedText = normalizeValue(text); - onChangeText(normalizedText); - } - }, - [multiline, onChange, onChangeText, setEventProps], - ); - const handleSelectionChange: ReactEventHandler = useCallback( (event) => { const e = event as unknown as NativeSyntheticEvent; diff --git a/src/web/historyClass.ts b/src/web/historyClass.ts new file mode 100644 index 00000000..1c75c706 --- /dev/null +++ b/src/web/historyClass.ts @@ -0,0 +1,79 @@ +export default class MarkdownInputHistory { + depth: number; + + history: string[]; + + pointerIndex: number; + + currentlyWritten: string | null = null; + + timeout: NodeJS.Timeout | null = null; + + debounceTime: number; + + constructor(historyDepth: number, debounceTime = 200) { + this.depth = historyDepth; + this.history = []; + this.pointerIndex = 0; + this.debounceTime = debounceTime; + } + + debouncedAdd(text: string): void { + this.currentlyWritten = text; + + if (this.timeout) { + clearTimeout(this.timeout); + } + + this.timeout = setTimeout(() => { + if (this.currentlyWritten == null) { + return; + } + this.add(this.currentlyWritten); + this.currentlyWritten = null; + }, this.debounceTime); + } + + add(text: string): void { + if (this.pointerIndex < this.history.length - 1) { + this.history.splice(this.pointerIndex + 1); + } + + this.history.push(text); + if (this.history.length > this.depth) { + this.history.shift(); + } + + this.pointerIndex = this.history.length - 1; + } + + undo(): string | null { + if (this.currentlyWritten !== null && this.timeout) { + clearTimeout(this.timeout); + return this.history[this.history.length - 1] || this.currentlyWritten || null; + } + + if (this.history.length === 0) { + return null; + } + + if (this.pointerIndex >= 0) { + this.pointerIndex -= 1; + } + return this.history[this.pointerIndex] || null; + } + + redo(): string | null { + if (this.currentlyWritten !== null && this.timeout) { + return this.currentlyWritten; + } + if (this.history.length === 0) { + return null; + } + + if (this.pointerIndex < this.history.length - 1) { + this.pointerIndex += 1; + } + return this.history[this.pointerIndex] || null; + } +} diff --git a/src/web/parserUtils.ts b/src/web/parserUtils.ts index fc588a04..b3ee62b9 100644 --- a/src/web/parserUtils.ts +++ b/src/web/parserUtils.ts @@ -167,7 +167,7 @@ function parseRangesToHTMLNodes(text: string, ranges: MarkdownRange[], markdownS return root; } -function parseText(target: HTMLElement, text: string, markdownStyle: PartialMarkdownStyle = {}, disableNewLinesInCursorPositioning = false) { +function parseText(target: HTMLElement, text: string, markdownStyle: PartialMarkdownStyle = {}, disableNewLinesInCursorPositioning = false, alwaysMoveToTheEnd = false) { const targetElement = target; let cursorPosition: number | null = null; @@ -194,7 +194,9 @@ function parseText(target: HTMLElement, text: string, markdownStyle: PartialMark target.appendChild(dom); } - if (isFocused && cursorPosition !== null) { + if (alwaysMoveToTheEnd) { + CursorUtils.moveCursorToEnd(target); + } else if (isFocused && cursorPosition !== null) { CursorUtils.setCursorPosition(target, cursorPosition, disableNewLinesInCursorPositioning); }