Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix Live Markdown Input undo/redo history on web #342

Merged
merged 15 commits into from
May 16, 2024
Merged
30 changes: 14 additions & 16 deletions src/MarkdownTextInput.web.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,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 +203,7 @@ 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);
history.current.throttledAdd(parsedText.text, parsedText.cursorPosition);
}

return parsedText;
Expand Down Expand Up @@ -328,9 +328,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 +345,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, changedText);

if (onChange) {
const event = e as unknown as NativeSyntheticEvent<any>;
Expand Down Expand Up @@ -587,17 +596,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
127 changes: 75 additions & 52 deletions src/__tests__/webInputHistory.test.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,30 @@
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]);
});

Expand All @@ -28,76 +38,31 @@ test('history depth', () => {
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 +75,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
Loading