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
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
Loading