From 449cc71046eb6db0aa574af4da0720cfb18d847d Mon Sep 17 00:00:00 2001 From: rocktree Date: Tue, 10 Mar 2026 06:34:10 -0400 Subject: [PATCH 1/4] feat: implement undo/redo functionality Add undo/redo support with Cmd+Z / Ctrl+Z (undo) and Cmd+Shift+Z / Ctrl+Shift+Z (redo). - useUndoHistory hook manages a history stack with debounced grouping (500ms) for regular typing - Shortcut actions (Cmd+B, Tab, Enter, etc.) are committed as discrete undo groups immediately - Paste operations are committed as their own undo group via onPaste interception - New edits after an undo clear the redo stack (standard behavior) - Uncommitted typing is flushed to history before undo so it can be redone Co-Authored-By: Claude Sonnet 4.6 --- packages/editor/src/Editor.tsx | 90 ++++++- .../editor/src/__tests__/Editor.undo.test.tsx | 219 ++++++++++++++++++ packages/editor/src/useUndoHistory.ts | 72 ++++++ 3 files changed, 377 insertions(+), 4 deletions(-) create mode 100644 packages/editor/src/__tests__/Editor.undo.test.tsx create mode 100644 packages/editor/src/useUndoHistory.ts diff --git a/packages/editor/src/Editor.tsx b/packages/editor/src/Editor.tsx index e8cdf81..cda95b3 100644 --- a/packages/editor/src/Editor.tsx +++ b/packages/editor/src/Editor.tsx @@ -1,6 +1,7 @@ import { forwardRef, useCallback, useEffect, useLayoutEffect, useRef } from 'react'; import type { EditResult, EditorProps } from './types'; import { applyBold, applyEnter, applyItalic, 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. @@ -14,6 +15,7 @@ export const Editor = forwardRef(function Edit tabSize = 2, placeholder = 'Start writing...', onKeyDown: userOnKeyDown, + onPaste: userOnPaste, readOnly, disabled, wrapperClassName, @@ -23,6 +25,8 @@ 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); // Merge forwarded ref with internal ref const setRef = useCallback( @@ -50,11 +54,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( @@ -77,6 +96,34 @@ export const Editor = forwardRef(function Edit selectionEnd: el.selectionEnd, }; + if (mod && !e.altKey) { + if (!e.shiftKey && e.key === '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 === '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) { if (e.key === 'b') apply(applyBold(state), e); else if (e.key === 'i') apply(applyItalic(state), e); @@ -90,14 +137,48 @@ 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) => { - onChange(e.target.value); + 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 { + // 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( + (e: React.ClipboardEvent) => { + const el = internalRef.current; + if (!el) return; + // 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], + [history, userOnPaste], ); return ( @@ -107,6 +188,7 @@ export const Editor = forwardRef(function Edit value={value} onChange={handleChange} onKeyDown={handleKeyDown} + onPaste={handlePaste} 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..f9b5b2b --- /dev/null +++ b/packages/editor/src/__tests__/Editor.undo.test.tsx @@ -0,0 +1,219 @@ +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; 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'); + }); +}); + +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 '); + }); +}); diff --git a/packages/editor/src/useUndoHistory.ts b/packages/editor/src/useUndoHistory.ts new file mode 100644 index 0000000..b24940d --- /dev/null +++ b/packages/editor/src/useUndoHistory.ts @@ -0,0 +1,72 @@ +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 + const last = stack.current[index.current]; + if (last && last.value === entry.value) return; + stack.current.push(entry); + index.current++; + }, + [cancelDebounce], + ); + + const scheduleCommit = useCallback( + (getEntry: () => HistoryEntry, delay = 500) => { + cancelDebounce(); + 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 }; +} From 372009e906fe0b7f29bbd19d7da114322c48a0db Mon Sep 17 00:00:00 2001 From: rocktree Date: Tue, 10 Mar 2026 06:39:10 -0400 Subject: [PATCH 2/4] fix: address QA feedback on undo/redo implementation - Normalize key comparison with .toLowerCase() so Ctrl/Cmd+Shift+Z works regardless of whether the browser emits 'z' or 'Z' - Accept both metaKey and ctrlKey as the modifier so macOS Cmd shortcuts work in all environments - Clear redo stack immediately in scheduleCommit so typing after undo disables redo before the debounce fires - Add onCut handler that commits a boundary entry, making cut its own undo group (mirrors the existing onPaste handling) - Include selection state in the commit duplicate-entry check so pre-shortcut selection is preserved for correct restoration on undo - Add tests: metaKey bindings, uppercase Z redo, immediate redo-clear after undo+new edit, selection restoration after Ctrl+B Co-Authored-By: Claude Sonnet 4.6 --- packages/editor/src/Editor.tsx | 23 ++- .../editor/src/__tests__/Editor.undo.test.tsx | 131 +++++++++++++++++- packages/editor/src/useUndoHistory.ts | 13 +- 3 files changed, 160 insertions(+), 7 deletions(-) diff --git a/packages/editor/src/Editor.tsx b/packages/editor/src/Editor.tsx index cda95b3..71c0238 100644 --- a/packages/editor/src/Editor.tsx +++ b/packages/editor/src/Editor.tsx @@ -88,8 +88,7 @@ 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, @@ -97,7 +96,7 @@ export const Editor = forwardRef(function Edit }; if (mod && !e.altKey) { - if (!e.shiftKey && e.key === 'z') { + if (!e.shiftKey && e.key.toLowerCase() === 'z') { e.preventDefault(); const current = { value: el.value, @@ -112,7 +111,7 @@ export const Editor = forwardRef(function Edit userOnKeyDown?.(e); return; } - if (e.shiftKey && e.key === 'z') { + if (e.shiftKey && e.key.toLowerCase() === 'z') { e.preventDefault(); const next = history.redo(); if (next) { @@ -181,6 +180,21 @@ export const Editor = forwardRef(function Edit [history, userOnPaste], ); + 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], + ); + return (