diff --git a/packages/editor/src/Editor.tsx b/packages/editor/src/Editor.tsx index 8c92ff9..ff4caae 100644 --- a/packages/editor/src/Editor.tsx +++ b/packages/editor/src/Editor.tsx @@ -9,6 +9,7 @@ import { applyShiftTab, applyTab, } from './keymap'; +import { useUndoHistory } from './useUndoHistory'; // useLayoutEffect is synchronous and prevents cursor flicker, but SSR will warn. // Fall back to useEffect during server rendering. @@ -32,6 +33,9 @@ export const Editor = forwardRef(function Edit ) { const internalRef = useRef(null); const pendingSelection = useRef<{ start: number; end: number } | null>(null); + const history = useUndoHistory(value); + const pasteFlag = useRef(false); + const shortcutInputFlag = useRef(false); // Merge forwarded ref with internal ref const setRef = useCallback( @@ -59,11 +63,26 @@ export const Editor = forwardRef(function Edit (result: EditResult | null, e: React.KeyboardEvent): boolean => { if (!result) return false; e.preventDefault(); + const el = internalRef.current; + if (el) { + // Commit the pre-shortcut state so it can be undone to + history.commit({ + value: el.value, + selectionStart: el.selectionStart, + selectionEnd: el.selectionEnd, + }); + // Immediately commit the post-shortcut state as its own undo group + history.commit({ + value: result.value, + selectionStart: result.selectionStart, + selectionEnd: result.selectionEnd, + }); + } pendingSelection.current = { start: result.selectionStart, end: result.selectionEnd }; onChange(result.value); return true; }, - [onChange], + [onChange, history], ); const handleKeyDown = useCallback( @@ -78,14 +97,41 @@ export const Editor = forwardRef(function Edit return; } - const isMac = typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.platform); - const mod = isMac ? e.metaKey : e.ctrlKey; + const mod = e.metaKey || e.ctrlKey; const state = { value: el.value, selectionStart: el.selectionStart, selectionEnd: el.selectionEnd, }; + if (mod && !e.altKey) { + if (!e.shiftKey && e.key.toLowerCase() === 'z') { + e.preventDefault(); + const current = { + value: el.value, + selectionStart: el.selectionStart, + selectionEnd: el.selectionEnd, + }; + const prev = history.undo(current); + if (prev) { + pendingSelection.current = { start: prev.selectionStart, end: prev.selectionEnd }; + onChange(prev.value); + } + userOnKeyDown?.(e); + return; + } + if (e.shiftKey && e.key.toLowerCase() === 'z') { + e.preventDefault(); + const next = history.redo(); + if (next) { + pendingSelection.current = { start: next.selectionStart, end: next.selectionEnd }; + onChange(next.value); + } + userOnKeyDown?.(e); + return; + } + } + if (mod && !e.shiftKey && !e.altKey) { const key = e.key.toLowerCase(); if (key === 'b') apply(applyBold(state), e); @@ -101,7 +147,41 @@ export const Editor = forwardRef(function Edit // The event will have `defaultPrevented === true` when Ash acted on it. userOnKeyDown?.(e); }, - [apply, tabSize, userOnKeyDown, readOnly, disabled], + [apply, history, onChange, tabSize, userOnKeyDown, readOnly, disabled], + ); + + const handleChange = useCallback( + (e: React.ChangeEvent) => { + const newValue = e.target.value; + onChange(newValue); + if (pasteFlag.current) { + pasteFlag.current = false; + // Commit the post-paste state immediately as its own undo group + const el = internalRef.current; + history.commit({ + value: newValue, + selectionStart: el?.selectionStart ?? 0, + selectionEnd: el?.selectionEnd ?? 0, + }); + } else if (shortcutInputFlag.current) { + shortcutInputFlag.current = false; + // Commit the post-shortcut state immediately as its own undo group + const el = internalRef.current; + history.commit({ + value: newValue, + selectionStart: el?.selectionStart ?? 0, + selectionEnd: el?.selectionEnd ?? 0, + }); + } else { + // Debounce regular typing into history groups + history.scheduleCommit(() => ({ + value: internalRef.current?.value ?? newValue, + selectionStart: internalRef.current?.selectionStart ?? 0, + selectionEnd: internalRef.current?.selectionEnd ?? 0, + })); + } + }, + [onChange, history], ); const handlePaste = useCallback( @@ -112,7 +192,7 @@ export const Editor = forwardRef(function Edit return; } - const pastedText = e.clipboardData.getData('text'); + const pastedText = e.clipboardData?.getData('text') ?? ''; const state = { value: el.value, selectionStart: el.selectionStart, @@ -121,19 +201,75 @@ export const Editor = forwardRef(function Edit const result = applyLinkPaste(state, pastedText); if (result) { e.preventDefault(); + // Commit pre- and post-paste states as a discrete undo group + history.commit({ + value: el.value, + selectionStart: el.selectionStart, + selectionEnd: el.selectionEnd, + }); + history.commit({ + value: result.value, + selectionStart: result.selectionStart, + selectionEnd: result.selectionEnd, + }); pendingSelection.current = { start: result.selectionStart, end: result.selectionEnd }; onChange(result.value); + } else { + // Commit the pre-paste state immediately so paste becomes its own undo group + history.commit({ + value: el.value, + selectionStart: el.selectionStart, + selectionEnd: el.selectionEnd, + }); + pasteFlag.current = true; } userOnPaste?.(e); }, - [onChange, readOnly, disabled, userOnPaste], + [onChange, history, readOnly, disabled, userOnPaste], ); - const handleChange = useCallback( - (e: React.ChangeEvent) => { - onChange(e.target.value); + const handleCut = useCallback( + (_e: React.ClipboardEvent) => { + const el = internalRef.current; + if (!el) return; + // Commit the pre-cut state immediately so cut becomes its own undo group + history.commit({ + value: el.value, + selectionStart: el.selectionStart, + selectionEnd: el.selectionEnd, + }); + pasteFlag.current = true; + }, + [history], + ); + + // Detect OS/browser editing shortcuts (e.g. Ctrl+Backspace for word deletion) + // that bypass Ash's onKeyDown handlers. Commit the pre-change state immediately + // so each such action forms its own discrete undo group. + const handleBeforeInput = useCallback( + (e: React.FormEvent) => { + const inputType = (e.nativeEvent as InputEvent).inputType; + const isShortcutEdit = + inputType === 'deleteWordBackward' || + inputType === 'deleteWordForward' || + inputType === 'deleteSoftLineBackward' || + inputType === 'deleteSoftLineForward' || + inputType === 'deleteHardLineBackward' || + inputType === 'deleteHardLineForward' || + inputType === 'deleteByDrag'; + if (isShortcutEdit) { + const el = internalRef.current; + if (el) { + history.commit({ + value: el.value, + selectionStart: el.selectionStart, + selectionEnd: el.selectionEnd, + }); + shortcutInputFlag.current = true; + } + } }, - [onChange], + [history], ); return ( @@ -142,8 +278,10 @@ export const Editor = forwardRef(function Edit ref={setRef} value={value} onChange={handleChange} + onBeforeInput={handleBeforeInput} onKeyDown={handleKeyDown} onPaste={handlePaste} + onCut={handleCut} placeholder={placeholder} readOnly={readOnly} disabled={disabled} diff --git a/packages/editor/src/__tests__/Editor.undo.test.tsx b/packages/editor/src/__tests__/Editor.undo.test.tsx new file mode 100644 index 0000000..4ef789e --- /dev/null +++ b/packages/editor/src/__tests__/Editor.undo.test.tsx @@ -0,0 +1,435 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, act, fireEvent } from '@testing-library/react'; +import { useState } from 'react'; +import { Editor } from '../Editor'; + +// Wrapper component that manages controlled state +function ControlledEditor(props: { initialValue?: string }) { + const [value, setValue] = useState(props.initialValue ?? ''); + return ( + + ); +} + +function getTextarea() { + return screen.getByRole('textbox') as HTMLTextAreaElement; +} + +// Simulate typing into the controlled textarea (triggers React's onChange) +function type(el: HTMLTextAreaElement, value: string) { + fireEvent.change(el, { target: { value } }); +} + +// Fire a keydown event on the element +function keyDown( + el: HTMLElement, + key: string, + modifiers: { ctrlKey?: boolean; metaKey?: boolean; shiftKey?: boolean } = {}, +) { + fireEvent.keyDown(el, { key, bubbles: true, ...modifiers }); +} + +describe('undo (Ctrl+Z)', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it('undoes a committed chunk of typing', () => { + render(); + const el = getTextarea(); + + act(() => { + type(el, 'hello world'); + vi.advanceTimersByTime(600); + }); + + expect(el.value).toBe('hello world'); + + act(() => { + keyDown(el, 'z', { ctrlKey: true }); + }); + + expect(el.value).toBe(''); + }); + + it('undoes back through multiple committed groups', () => { + render(); + const el = getTextarea(); + + act(() => { + type(el, 'hello'); + vi.advanceTimersByTime(600); + }); + act(() => { + type(el, 'hello world'); + vi.advanceTimersByTime(600); + }); + + expect(el.value).toBe('hello world'); + + act(() => { + keyDown(el, 'z', { ctrlKey: true }); + }); + expect(el.value).toBe('hello'); + + act(() => { + keyDown(el, 'z', { ctrlKey: true }); + }); + expect(el.value).toBe(''); + }); + + it('commits uncommitted typing before undoing', () => { + render(); + const el = getTextarea(); + + act(() => { + type(el, 'hello'); + vi.advanceTimersByTime(600); + }); + + // Type more without letting the debounce fire + act(() => { + type(el, 'hello world'); + // intentionally NOT advancing timers + }); + + expect(el.value).toBe('hello world'); + + // Undo should save "hello world" as a checkpoint then restore "hello" + act(() => { + keyDown(el, 'z', { ctrlKey: true }); + }); + expect(el.value).toBe('hello'); + }); + + it('does nothing when already at the beginning of history', () => { + render(); + const el = getTextarea(); + + act(() => { + keyDown(el, 'z', { ctrlKey: true }); + }); + expect(el.value).toBe('initial'); + }); +}); + +describe('redo (Ctrl+Shift+Z)', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it('redoes the last undone change', () => { + render(); + const el = getTextarea(); + + act(() => { + type(el, 'hello'); + vi.advanceTimersByTime(600); + }); + + act(() => { + keyDown(el, 'z', { ctrlKey: true }); + }); + expect(el.value).toBe(''); + + act(() => { + keyDown(el, 'z', { ctrlKey: true, shiftKey: true }); + }); + expect(el.value).toBe('hello'); + }); + + it('clears the redo stack when a new edit is made after undo', () => { + render(); + const el = getTextarea(); + + act(() => { + type(el, 'hello'); + vi.advanceTimersByTime(600); + }); + + act(() => { + keyDown(el, 'z', { ctrlKey: true }); + }); + expect(el.value).toBe(''); + + // New edit clears redo stack + act(() => { + type(el, 'world'); + vi.advanceTimersByTime(600); + }); + + // Redo should be a no-op now + act(() => { + keyDown(el, 'z', { ctrlKey: true, shiftKey: true }); + }); + expect(el.value).toBe('world'); + }); +}); + +describe('undo with Ctrl+B shortcut', () => { + it('undoes bold formatting applied via Ctrl+B', () => { + render(); + const el = getTextarea(); + + act(() => { + el.setSelectionRange(6, 11); + keyDown(el, 'b', { ctrlKey: true }); + }); + + expect(el.value).toBe('hello **world**'); + + act(() => { + keyDown(el, 'z', { ctrlKey: true }); + }); + + expect(el.value).toBe('hello world'); + }); + + it('restores original selection after undoing Ctrl+B', () => { + render(); + const el = getTextarea(); + + act(() => { + el.setSelectionRange(6, 11); + keyDown(el, 'b', { ctrlKey: true }); + }); + + expect(el.value).toBe('hello **world**'); + + act(() => { + keyDown(el, 'z', { ctrlKey: true }); + }); + + expect(el.value).toBe('hello world'); + expect(el.selectionStart).toBe(6); + expect(el.selectionEnd).toBe(11); + }); +}); + +describe('macOS metaKey bindings', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it('undoes with Cmd+Z (metaKey)', () => { + render(); + const el = getTextarea(); + + act(() => { + type(el, 'hello'); + vi.advanceTimersByTime(600); + }); + + act(() => { + keyDown(el, 'z', { metaKey: true }); + }); + + expect(el.value).toBe(''); + }); + + it('redoes with Cmd+Shift+Z (metaKey + shiftKey)', () => { + render(); + const el = getTextarea(); + + act(() => { + type(el, 'hello'); + vi.advanceTimersByTime(600); + }); + + act(() => { + keyDown(el, 'z', { metaKey: true }); + }); + expect(el.value).toBe(''); + + act(() => { + keyDown(el, 'z', { metaKey: true, shiftKey: true }); + }); + expect(el.value).toBe('hello'); + }); +}); + +describe('uppercase Z key events', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it('redoes with uppercase Z key (Ctrl+Shift+Z emitting key: "Z")', () => { + render(); + const el = getTextarea(); + + act(() => { + type(el, 'hello'); + vi.advanceTimersByTime(600); + }); + + act(() => { + keyDown(el, 'z', { ctrlKey: true }); + }); + expect(el.value).toBe(''); + + act(() => { + keyDown(el, 'Z', { ctrlKey: true, shiftKey: true }); + }); + expect(el.value).toBe('hello'); + }); +}); + +describe('redo cleared immediately after undo + new edit', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it('disables redo immediately when typing after undo, without waiting for debounce', () => { + render(); + const el = getTextarea(); + + act(() => { + type(el, 'hello'); + vi.advanceTimersByTime(600); + }); + + act(() => { + keyDown(el, 'z', { ctrlKey: true }); + }); + expect(el.value).toBe(''); + + // Type a new edit — do NOT advance timers (debounce hasn't fired) + act(() => { + type(el, 'world'); + }); + + // Redo should be a no-op immediately, before the debounce fires + act(() => { + keyDown(el, 'z', { ctrlKey: true, shiftKey: true }); + }); + expect(el.value).toBe('world'); + }); +}); + +describe('undo with paste', () => { + it('undoes pasted content', () => { + render(); + const el = getTextarea(); + + act(() => { + // Fire paste event — commits pre-paste state + fireEvent.paste(el); + // Then onChange fires from the paste result + type(el, 'hello world'); + }); + + expect(el.value).toBe('hello world'); + + act(() => { + keyDown(el, 'z', { ctrlKey: true }); + }); + + expect(el.value).toBe('hello '); + }); +}); + +describe('undo with keyboard-shortcut-driven word deletion', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it('treats deleteWordBackward (Ctrl+Backspace) as a discrete undo group', () => { + render(); + const el = getTextarea(); + + act(() => { + // Simulate Ctrl+Backspace — browser fires beforeinput with deleteWordBackward + fireEvent( + el, + new InputEvent('beforeinput', { bubbles: true, cancelable: true, inputType: 'deleteWordBackward' }), + ); + // Then the browser mutates the value and React fires onChange + type(el, 'hello '); + }); + + expect(el.value).toBe('hello '); + + act(() => { + keyDown(el, 'z', { ctrlKey: true }); + }); + + expect(el.value).toBe('hello world'); + }); + + it('treats deleteWordForward (Ctrl+Delete) as a discrete undo group', () => { + render(); + const el = getTextarea(); + + act(() => { + fireEvent( + el, + new InputEvent('beforeinput', { bubbles: true, cancelable: true, inputType: 'deleteWordForward' }), + ); + type(el, ' world'); + }); + + expect(el.value).toBe(' world'); + + act(() => { + keyDown(el, 'z', { ctrlKey: true }); + }); + + expect(el.value).toBe('hello world'); + }); + + it('does not merge a word-delete shortcut with prior typing in the same undo group', () => { + render(); + const el = getTextarea(); + + // Type some text and let the debounce commit it + act(() => { + type(el, 'hello world'); + vi.advanceTimersByTime(600); + }); + + act(() => { + // Word deletion is a discrete group — commits pre-delete state immediately + fireEvent( + el, + new InputEvent('beforeinput', { bubbles: true, cancelable: true, inputType: 'deleteWordBackward' }), + ); + type(el, 'hello '); + }); + + expect(el.value).toBe('hello '); + + // First undo removes the word deletion + act(() => { + keyDown(el, 'z', { ctrlKey: true }); + }); + expect(el.value).toBe('hello world'); + + // Second undo removes the initial typing + act(() => { + keyDown(el, 'z', { ctrlKey: true }); + }); + expect(el.value).toBe(''); + }); +}); diff --git a/packages/editor/src/useUndoHistory.ts b/packages/editor/src/useUndoHistory.ts new file mode 100644 index 0000000..c87d148 --- /dev/null +++ b/packages/editor/src/useUndoHistory.ts @@ -0,0 +1,81 @@ +import { useRef, useCallback } from 'react'; + +interface HistoryEntry { + value: string; + selectionStart: number; + selectionEnd: number; +} + +export function useUndoHistory(initialValue: string) { + const stack = useRef([ + { value: initialValue, selectionStart: 0, selectionEnd: 0 }, + ]); + const index = useRef(0); + const debounceTimer = useRef | null>(null); + + const cancelDebounce = useCallback(() => { + if (debounceTimer.current !== null) { + clearTimeout(debounceTimer.current); + debounceTimer.current = null; + } + }, []); + + const commit = useCallback( + (entry: HistoryEntry) => { + cancelDebounce(); + // Drop any forward history (new edit clears redo stack) + stack.current = stack.current.slice(0, index.current + 1); + // Skip duplicate entries (same value and selection) + const last = stack.current[index.current]; + if ( + last && + last.value === entry.value && + last.selectionStart === entry.selectionStart && + last.selectionEnd === entry.selectionEnd + ) + return; + stack.current.push(entry); + index.current++; + }, + [cancelDebounce], + ); + + const scheduleCommit = useCallback( + (getEntry: () => HistoryEntry, delay = 500) => { + cancelDebounce(); + // Clear redo stack immediately so any new edit invalidates redo right away, + // even before the debounce fires and commit() is called. + stack.current = stack.current.slice(0, index.current + 1); + debounceTimer.current = setTimeout(() => { + debounceTimer.current = null; + commit(getEntry()); + }, delay); + }, + [cancelDebounce, commit], + ); + + const undo = useCallback( + (currentEntry: HistoryEntry): HistoryEntry | null => { + // If there are uncommitted changes, commit them first so they can be redone + const last = stack.current[index.current]; + if (last.value !== currentEntry.value) { + commit(currentEntry); + } else { + cancelDebounce(); + } + if (index.current <= 0) return null; + index.current--; + return stack.current[index.current]; + }, + [commit, cancelDebounce], + ); + + const redo = useCallback((): HistoryEntry | null => { + cancelDebounce(); + if (index.current >= stack.current.length - 1) return null; + index.current++; + return stack.current[index.current]; + }, [cancelDebounce]); + + return { commit, scheduleCommit, undo, redo, cancelDebounce }; +}