diff --git a/src/MarkdownTextInput.web.tsx b/src/MarkdownTextInput.web.tsx index 4ded68c5..322282ef 100644 --- a/src/MarkdownTextInput.web.tsx +++ b/src/MarkdownTextInput.web.tsx @@ -80,6 +80,10 @@ let focusTimeout: NodeJS.Timeout | null = null; function normalizeValue(value: string) { return value.replace(/\n$/, ''); } +// Adds one '\n' at the end of the string if it's missing +function denormalizeValue(value: string) { + return value.endsWith('\n') ? `${value}\n` : value; +} // If an Input Method Editor is processing key input, the 'keyCode' is 229. // https://www.w3.org/TR/uievents/#determine-keydown-keyup-keyCode @@ -174,7 +178,7 @@ const MarkdownTextInput = React.forwardRef( const dimensions = React.useRef(null); if (!history.current) { - history.current = new InputHistory(100); + history.current = new InputHistory(100, 150, value || ''); } const flattenedStyle = useMemo(() => StyleSheet.flatten(style), [style]); @@ -203,7 +207,8 @@ const MarkdownTextInput = React.forwardRef( } const parsedText = ParseUtils.parseText(target, text, cursorPosition, customMarkdownStyles, !multiline); if (history.current && shouldAddToHistory) { - history.current.debouncedAdd(parsedText.text, parsedText.cursorPosition); + // We need to normalize the value before saving it to the history to prevent situations when additional new lines break the cursor position calculation logic + history.current.throttledAdd(normalizeValue(parsedText.text), parsedText.cursorPosition); } return parsedText; @@ -236,7 +241,8 @@ const MarkdownTextInput = React.forwardRef( (target: HTMLDivElement) => { if (!history.current) return ''; const item = history.current.undo(); - return parseText(target, item ? item.text : null, processedMarkdownStyle, item ? item.cursorPosition : null, false).text; + const undoValue = item ? denormalizeValue(item.text) : null; + return parseText(target, undoValue, processedMarkdownStyle, item ? item.cursorPosition : null, false).text; }, [parseText, processedMarkdownStyle], ); @@ -245,7 +251,8 @@ const MarkdownTextInput = React.forwardRef( (target: HTMLDivElement) => { if (!history.current) return ''; const item = history.current.redo(); - return parseText(target, item ? item.text : null, processedMarkdownStyle, item ? item.cursorPosition : null, false).text; + const redoValue = item ? denormalizeValue(item.text) : null; + return parseText(target, redoValue, processedMarkdownStyle, item ? item.cursorPosition : null, false).text; }, [parseText, processedMarkdownStyle], ); @@ -328,9 +335,10 @@ const MarkdownTextInput = React.forwardRef( if (!divRef.current || !(e.target instanceof HTMLElement)) { return; } + const changedText = e.target.innerText; if (compositionRef.current) { - updateTextColor(divRef.current, e.target.innerText); + updateTextColor(divRef.current, changedText); compositionRef.current = false; return; } @@ -344,14 +352,22 @@ const MarkdownTextInput = React.forwardRef( case 'historyRedo': text = 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; + break; + } + text = parseText(divRef.current, changedText, processedMarkdownStyle).text; + break; default: - text = parseText(divRef.current, e.target.innerText, processedMarkdownStyle).text; + text = parseText(divRef.current, changedText, processedMarkdownStyle).text; } if (pasteRef?.current) { pasteRef.current = false; updateSelection(e); } - updateTextColor(divRef.current, e.target.innerText); + updateTextColor(divRef.current, text); if (onChange) { const event = e as unknown as NativeSyntheticEvent; @@ -587,17 +603,6 @@ const MarkdownTextInput = React.forwardRef( CursorUtils.setCursorPosition(divRef.current, newSelection.start, newSelection.end); }, [selection, updateRefSelectionVariables]); - useEffect(() => { - if (history.current?.history.length !== 0) { - return; - } - const currentValue = value ?? ''; - history.current.add(currentValue, currentValue.length); - - handleContentSizeChange(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - return ( // eslint-disable-next-line jsx-a11y/no-static-element-interactions
{ + const history = new InputHistory(depth, debounceTime, defaultItemText); + expect(history.getCurrentItem()).toEqual(defaultItem); + expect(history.items).toEqual([defaultItem]); +}); test('add history action', () => { const history = new InputHistory(depth); @@ -14,7 +24,7 @@ test('add history action', () => { history.add(item.text, item.cursorPosition); }); - expect(history.history).toEqual(testingHistory); + expect(history.items).toEqual(testingHistory); expect(history.getCurrentItem()).toEqual(testingHistory[testingHistory.length - 1]); }); @@ -22,82 +32,42 @@ test('history depth', () => { const history = new InputHistory(depth); const text = '> Hello _*world*_!'; - history.setHistory(testingHistory); + const nextHistoryIndexes = [1, 2, 2]; + testingHistory.forEach((item, index) => { + history.add(item.text, item.cursorPosition); + expect(history.historyIndex).toEqual(nextHistoryIndexes[index]); + }); + history.add(text, text.length); const newItem = {text, cursorPosition: text.length}; const currentHistory = [...testingHistory.slice(1), newItem]; - expect(history.history).toEqual(currentHistory); + expect(history.items).toEqual(currentHistory); expect(history.getCurrentItem()).toEqual(newItem); }); -describe('debounce add history action', () => { - const text = 'Hello world!'; - const newItem = {text, cursorPosition: text.length}; - const text2 = 'Hello world 2!'; - const newItem2 = {text: text2, cursorPosition: text2.length}; - - beforeEach(() => { - jest.useFakeTimers(); - }); - - afterEach(() => { - jest.runOnlyPendingTimers(); - jest.useRealTimers(); - }); - - test('should debounce', () => { - const history = new InputHistory(depth, 300); - history.debouncedAdd(newItem.text, newItem.cursorPosition); - expect(history.history).toEqual([]); - jest.advanceTimersByTime(300); - expect(history.history).toEqual([newItem]); - }); - - test('should cancel previous invocation', () => { - const history = new InputHistory(depth, 300); - history.debouncedAdd(newItem.text, newItem.cursorPosition); - jest.advanceTimersByTime(100); - history.debouncedAdd(newItem2.text, newItem2.cursorPosition); - jest.advanceTimersByTime(300); - expect(history.history).toEqual([newItem2]); - }); - - test('undo before debounce invokes the function', () => { - const history = new InputHistory(depth, 300); - history.debouncedAdd(newItem.text, newItem.cursorPosition); - expect(history.undo()).toEqual(null); - jest.advanceTimersByTime(300); - expect(history.history).toEqual([]); - }); - - test('redo before debounce invokes the function', () => { - const history = new InputHistory(depth, 300); - history.debouncedAdd(newItem.text, newItem.cursorPosition); - expect(history.redo()).toEqual(null); - jest.advanceTimersByTime(300); - expect(history.history).toEqual([]); - }); -}); - test('undo history action', () => { const history = new InputHistory(depth); history.setHistory(testingHistory); expect(history.undo()).toEqual(testingHistory[1]); + expect(history.getCurrentItem()).toEqual(testingHistory[1]); history.setHistoryIndex(0); expect(history.undo()).toEqual(null); + expect(history.getCurrentItem()).toEqual(testingHistory[0]); }); test('redo history action', () => { const history = new InputHistory(depth); history.setHistory(testingHistory); expect(history.redo()).toEqual(null); + expect(history.getCurrentItem()).toEqual(testingHistory[testingHistory.length - 1]); history.setHistoryIndex(1); expect(history.redo()).toEqual(testingHistory[2]); + expect(history.getCurrentItem()).toEqual(testingHistory[2]); }); test('clearing history after adding new text after undo', () => { @@ -110,6 +80,64 @@ test('clearing history after adding new text after undo', () => { history.add(newItem.text, newItem.cursorPosition); - expect(history.history).toEqual([testingHistory[0], newItem]); + expect(history.items).toEqual([testingHistory[0], newItem]); expect(history.getCurrentItem()).toEqual(newItem); }); + +describe('debounce add history action', () => { + const text = 'Hello world!'; + const newItem = {text, cursorPosition: text.length}; + const text2 = 'Hello world 2!'; + const newItem2 = {text: text2, cursorPosition: text2.length}; + + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.runOnlyPendingTimers(); + jest.useRealTimers(); + }); + + test('should debounce', () => { + const history = new InputHistory(depth, debounceTime, defaultItemText); + history.throttledAdd(newItem.text, newItem.cursorPosition); + expect(history.items).toEqual([defaultItem, newItem]); + history.throttledAdd(newItem2.text, newItem2.cursorPosition); + expect(history.items).toEqual([defaultItem, newItem2]); + + jest.advanceTimersByTime(debounceTime); + history.throttledAdd(newItem.text, newItem.cursorPosition); + expect(history.items).toEqual([defaultItem, newItem2, newItem]); + }); + + test('should cancel previous invocation', () => { + const history = new InputHistory(depth, debounceTime); + history.throttledAdd(newItem.text, newItem.cursorPosition); + jest.advanceTimersByTime(debounceTime / 2); + history.throttledAdd(newItem2.text, newItem2.cursorPosition); + jest.advanceTimersByTime(debounceTime); + expect(history.items).toEqual([defaultItem, newItem2]); + }); + + test('undo before debounce ends', () => { + const history = new InputHistory(depth, debounceTime); + history.throttledAdd(newItem.text, newItem.cursorPosition); + expect(history.undo()).toEqual(defaultItem); + expect(history.getCurrentItem()).toEqual(defaultItem); + history.throttledAdd(newItem2.text, newItem2.cursorPosition); + expect(history.items).toEqual([defaultItem, newItem2]); + expect(history.getCurrentItem()).toEqual(newItem2); + }); + + test('redo before debounce ends', () => { + const history = new InputHistory(depth, debounceTime); + history.setHistory(testingHistory); + history.setHistoryIndex(1); + + history.throttledAdd(newItem2.text, newItem2.cursorPosition); + expect(history.redo()).toEqual(null); + expect(history.getCurrentItem()).toEqual(newItem2); + expect(history.items).toEqual([testingHistory[0], testingHistory[1], newItem2]); + }); +}); diff --git a/src/web/InputHistory.ts b/src/web/InputHistory.ts index 86713abf..fd8b9770 100644 --- a/src/web/InputHistory.ts +++ b/src/web/InputHistory.ts @@ -6,7 +6,7 @@ type HistoryItem = { export default class InputHistory { depth: number; - history: HistoryItem[]; + items: HistoryItem[]; historyIndex: number; @@ -16,19 +16,19 @@ export default class InputHistory { debounceTime: number; - constructor(depth: number, debounceTime = 150) { + constructor(depth: number, debounceTime = 150, startingText = '') { this.depth = depth; - this.history = []; + this.items = [{text: startingText, cursorPosition: startingText.length}]; this.historyIndex = 0; this.debounceTime = debounceTime; } getCurrentItem(): HistoryItem | null { - return this.history[this.historyIndex] || null; + return this.items[this.historyIndex] || null; } setHistory(newHistory: HistoryItem[]): void { - this.history = newHistory.slice(newHistory.length - this.depth); + this.items = newHistory.slice(newHistory.length - this.depth); this.historyIndex = newHistory.length - 1; } @@ -37,80 +37,99 @@ export default class InputHistory { } clear(): void { - this.history = []; + this.items = []; this.historyIndex = 0; } - debouncedAdd(text: string, cursorPosition: number): void { - this.currentText = text; - + throttledAdd(text: string, cursorPosition: number): void { if (this.timeout) { clearTimeout(this.timeout); } + if (this.currentText === null) { + this.timeout = null; + this.add(text, cursorPosition); + } else { + this.items[this.historyIndex] = {text, cursorPosition}; + } + this.currentText = text; + this.timeout = setTimeout(() => { - if (this.currentText == null) { - return; - } - this.add(this.currentText, cursorPosition); this.currentText = null; }, this.debounceTime); } + stopTimeout(): void { + if (this.timeout) { + clearTimeout(this.timeout); + this.timeout = null; + } + this.currentText = null; + } + add(text: string, cursorPosition: number): void { - if (this.history.length > 0) { - const lastItem = this.history[this.history.length - 1]; - if (lastItem && text === lastItem.text) { - this.historyIndex = this.history.length - 1; + if (this.items.length > 0) { + const currentItem = this.items[this.historyIndex]; + if (currentItem && text === currentItem.text) { return; } } - if (this.historyIndex < this.history.length - 1) { - this.history.splice(this.historyIndex + 1); + if (this.historyIndex < this.items.length - 1) { + this.items.splice(this.historyIndex + 1); + this.historyIndex = this.items.length - 1; } - this.history.push({text, cursorPosition}); - if (this.history.length > this.depth) { - this.history.shift(); + this.items.push({text, cursorPosition}); + if (this.items.length > this.depth) { + this.items.shift(); + } else { + this.historyIndex += 1; } - - this.historyIndex = this.history.length - 1; } undo(): HistoryItem | null { - if (this.currentText !== null && this.timeout) { - clearTimeout(this.timeout); - this.timeout = null; - return this.history[this.historyIndex] || null; - } + this.stopTimeout(); - if (this.history.length === 0 || this.historyIndex - 1 < 0) { + if (this.items.length === 0 || this.historyIndex - 1 < 0) { return null; } + const currentHistoryItem = this.items[this.historyIndex]; + const previousHistoryItem = this.items[this.historyIndex - 1]; + + const undoItem = previousHistoryItem + ? { + text: previousHistoryItem.text, + cursorPosition: Math.min( + (currentHistoryItem?.cursorPosition ?? 0) - ((currentHistoryItem?.text ?? '').length - (previousHistoryItem?.text ?? '').length), + (previousHistoryItem?.text ?? '').length, + ), + } + : null; + if (this.historyIndex > 0) { this.historyIndex -= 1; } - return this.history[this.historyIndex] || null; + + return undoItem; } redo(): HistoryItem | null { if (this.currentText !== null && this.timeout) { - clearTimeout(this.timeout); - return this.history[this.history.length - 1] || null; + this.stopTimeout(); } - if (this.history.length === 0 || this.historyIndex + 1 > this.history.length) { + if (this.items.length === 0 || this.historyIndex + 1 > this.items.length) { return null; } - if (this.historyIndex < this.history.length - 1) { + if (this.historyIndex < this.items.length - 1) { this.historyIndex += 1; } else { return null; } - return this.history[this.historyIndex] || null; + return this.items[this.historyIndex] || null; } }