Skip to content

Commit

Permalink
Fix Live Markdown Input undo/redo history on web (#342)
Browse files Browse the repository at this point in the history
  • Loading branch information
Skalakid authored May 16, 2024
1 parent 4889c53 commit 3f077ae
Show file tree
Hide file tree
Showing 3 changed files with 159 additions and 107 deletions.
41 changes: 23 additions & 18 deletions src/MarkdownTextInput.web.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -174,7 +178,7 @@ const MarkdownTextInput = React.forwardRef<TextInput, MarkdownTextInputProps>(
const dimensions = React.useRef<Dimensions | null>(null);

if (!history.current) {
history.current = new InputHistory(100);
history.current = new InputHistory(100, 150, value || '');
}

const flattenedStyle = useMemo(() => StyleSheet.flatten(style), [style]);
Expand Down Expand Up @@ -203,7 +207,8 @@ const MarkdownTextInput = React.forwardRef<TextInput, MarkdownTextInputProps>(
}
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;
Expand Down Expand Up @@ -236,7 +241,8 @@ const MarkdownTextInput = React.forwardRef<TextInput, MarkdownTextInputProps>(
(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],
);
Expand All @@ -245,7 +251,8 @@ const MarkdownTextInput = React.forwardRef<TextInput, MarkdownTextInputProps>(
(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],
);
Expand Down Expand Up @@ -328,9 +335,10 @@ const MarkdownTextInput = React.forwardRef<TextInput, MarkdownTextInputProps>(
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;
}
Expand All @@ -344,14 +352,22 @@ const MarkdownTextInput = React.forwardRef<TextInput, MarkdownTextInputProps>(
case 'historyRedo':
text = redo(divRef.current);
break;
case 'insertFromPaste':
// if there is no newline at the end of the copied text, contentEditable adds invisible <br> 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<any>;
Expand Down Expand Up @@ -587,17 +603,6 @@ const MarkdownTextInput = React.forwardRef<TextInput, MarkdownTextInputProps>(
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
<div
Expand Down
134 changes: 81 additions & 53 deletions src/__tests__/webInputHistory.test.tsx
Original file line number Diff line number Diff line change
@@ -1,103 +1,73 @@
import {expect} from '@jest/globals';
import InputHistory from '../web/InputHistory';

const defaultItemText = '';
const defaultItem = {text: defaultItemText, cursorPosition: defaultItemText.length};

const testingHistory = [
{text: 'Hello world!', cursorPosition: 12},
{text: 'Hello *world*!', cursorPosition: 14},
{text: 'Hello _*world*_!', cursorPosition: 16},
];
const depth = testingHistory.length;
const debounceTime = 150;

test('history default item', () => {
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);
testingHistory.forEach((item) => {
history.add(item.text, item.cursorPosition);
});

expect(history.history).toEqual(testingHistory);
expect(history.items).toEqual(testingHistory);
expect(history.getCurrentItem()).toEqual(testingHistory[testingHistory.length - 1]);
});

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', () => {
Expand All @@ -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]);
});
});
Loading

0 comments on commit 3f077ae

Please sign in to comment.