From bf9fd66991a97674b1c53082d134df2a2af8646f Mon Sep 17 00:00:00 2001 From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com> Date: Fri, 10 Apr 2026 16:47:22 -0700 Subject: [PATCH] feat(annotate): add Cmd+Z undo for annotations Pressing Cmd/Ctrl+Z removes the most recent annotation that has no user-written content (no comment text, images, or quick labels). Transient UI state (empty comment popovers, quick-label pickers, toolbars) is dismissed first before reaching annotations. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/editor/App.tsx | 48 +++++++++ packages/ui/components/CommentPopover.tsx | 20 ++++ packages/ui/components/Viewer.tsx | 75 ++++++++++++-- packages/ui/utils/annotationUndo.test.ts | 115 ++++++++++++++++++++++ packages/ui/utils/annotationUndo.ts | 29 ++++++ 5 files changed, 281 insertions(+), 6 deletions(-) create mode 100644 packages/ui/utils/annotationUndo.test.ts create mode 100644 packages/ui/utils/annotationUndo.ts diff --git a/packages/editor/App.tsx b/packages/editor/App.tsx index ca75dd06..4706374f 100644 --- a/packages/editor/App.tsx +++ b/packages/editor/App.tsx @@ -60,6 +60,7 @@ import { hasNewSettings, markNewSettingsSeen } from '@plannotator/ui/utils/newSe import { useFileBrowser } from '@plannotator/ui/hooks/useFileBrowser'; import { isVaultBrowserEnabled } from '@plannotator/ui/utils/obsidian'; import { isFileBrowserEnabled, getFileBrowserSettings } from '@plannotator/ui/utils/fileBrowser'; +import { findLastUndoRemovableAnnotation } from '@plannotator/ui/utils/annotationUndo'; import { SidebarTabs } from '@plannotator/ui/components/sidebar/SidebarTabs'; import { SidebarContainer } from '@plannotator/ui/components/sidebar/SidebarContainer'; import type { ArchivedPlan } from '@plannotator/ui/components/sidebar/ArchiveBrowser'; @@ -989,6 +990,53 @@ const App: React.FC = () => { removeAnnotation(id); }; + useEffect(() => { + const handleUndoShortcut = (e: KeyboardEvent) => { + if (!(e.metaKey || e.ctrlKey) || e.shiftKey || e.altKey) return; + if (e.key.toLowerCase() !== 'z') return; + + if (showExport || showImport || showFeedbackPrompt || showClaudeCodeWarning || + showAgentWarning || showPermissionModeSetup || pendingPasteImage) return; + + if (submitted || isSubmitting) return; + + const target = e.target as HTMLElement | null; + const tag = target?.tagName; + const isTextField = tag === 'INPUT' || tag === 'TEXTAREA' || target?.isContentEditable; + const isCommentPopoverTarget = !!target?.closest?.('[data-comment-popover-root]'); + if (isTextField && !isCommentPopoverTarget) return; + + if (viewerRef.current?.dismissUndoableTransientState()) { + e.preventDefault(); + return; + } + + if (isCommentPopoverTarget) return; + if (isTextField) return; + + const lastUndoableAnnotation = findLastUndoRemovableAnnotation(annotations); + if (!lastUndoableAnnotation) return; + + e.preventDefault(); + handleDeleteAnnotation(lastUndoableAnnotation.id); + }; + + window.addEventListener('keydown', handleUndoShortcut); + return () => window.removeEventListener('keydown', handleUndoShortcut); + }, [ + annotations, + handleDeleteAnnotation, + isSubmitting, + pendingPasteImage, + showAgentWarning, + showClaudeCodeWarning, + showExport, + showFeedbackPrompt, + showImport, + showPermissionModeSetup, + submitted, + ]); + const handleEditAnnotation = (id: string, updates: Partial) => { const ann = allAnnotations.find(a => a.id === id); if (ann?.source && externalAnnotations.some(e => e.id === id)) { diff --git a/packages/ui/components/CommentPopover.tsx b/packages/ui/components/CommentPopover.tsx index f9a3259a..7ea49f2c 100644 --- a/packages/ui/components/CommentPopover.tsx +++ b/packages/ui/components/CommentPopover.tsx @@ -5,6 +5,11 @@ import { AttachmentsButton } from './AttachmentsButton'; import { submitHint } from '../utils/platform'; import { useDraggable } from '../hooks/useDraggable'; +export interface CommentDraftState { + hasContent: boolean; + hadContentEver: boolean; +} + interface CommentPopoverProps { /** Element to anchor the popover near (re-reads position on scroll) */ anchorEl: HTMLElement; @@ -18,6 +23,8 @@ interface CommentPopoverProps { onSubmit: (text: string, images?: ImageAttachment[]) => void; /** Called when popover is closed/cancelled */ onClose: () => void; + /** Reports whether the draft has content now, or has ever had content */ + onDraftStateChange?: (state: CommentDraftState) => void; } const MAX_POPOVER_WIDTH = 384; @@ -45,6 +52,7 @@ export const CommentPopover: React.FC = ({ initialText = '', onSubmit, onClose, + onDraftStateChange, }) => { const [mode, setMode] = useState<'popover' | 'dialog'>('popover'); const [text, setText] = useState(initialText); @@ -52,6 +60,7 @@ export const CommentPopover: React.FC = ({ const [position, setPosition] = useState<{ top: number; left: number; flipAbove: boolean; width: number } | null>(null); const textareaRef = useRef(null); const popoverRef = useRef(null); + const hadContentEverRef = useRef(initialText.trim().length > 0); const { dragPosition, dragHandleProps, wasDragged, reset: resetDrag } = useDraggable(popoverRef); // Reset drag when anchor changes (new annotation) or mode switches @@ -87,6 +96,15 @@ export const CommentPopover: React.FC = ({ return () => clearTimeout(id); }, [mode]); + useEffect(() => { + const hasContent = text.trim().length > 0 || images.length > 0; + if (hasContent) hadContentEverRef.current = true; + onDraftStateChange?.({ + hasContent, + hadContentEver: hadContentEverRef.current, + }); + }, [images.length, onDraftStateChange, text]); + // Click-outside for popover mode useEffect(() => { if (mode !== 'popover') return; @@ -144,6 +162,7 @@ export const CommentPopover: React.FC = ({ {/* Dialog card */}
= ({ return createPortal(
void; clearAllHighlights: () => void; applySharedAnnotations: (annotations: Annotation[]) => void; + dismissUndoableTransientState: () => boolean; } /** @@ -176,6 +177,14 @@ export const Viewer = forwardRef(({ isGlobal: boolean; codeBlock?: { block: Block; element: HTMLElement }; } | null>(null); + const [hookCommentDraftState, setHookCommentDraftState] = useState({ + hasContent: false, + hadContentEver: false, + }); + const [viewerCommentDraftState, setViewerCommentDraftState] = useState({ + hasContent: false, + hadContentEver: false, + }); // Viewer-specific quick label state (code blocks) const [codeBlockQuickLabelPicker, setCodeBlockQuickLabelPicker] = useState<{ anchorEl: HTMLElement; @@ -185,6 +194,10 @@ export const Viewer = forwardRef(({ const stickySentinelRef = useRef(null); const [isStuck, setIsStuck] = useState(false); + const handleViewerCommentClose = useCallback(() => { + setViewerCommentPopover(null); + }, []); + // Shared annotation infrastructure via hook const { highlighterRef, @@ -211,6 +224,18 @@ export const Viewer = forwardRef(({ mode, }); + useEffect(() => { + if (!hookCommentPopover) { + setHookCommentDraftState({ hasContent: false, hadContentEver: false }); + } + }, [hookCommentPopover]); + + useEffect(() => { + if (!viewerCommentPopover) { + setViewerCommentDraftState({ hasContent: false, hadContentEver: false }); + } + }, [viewerCommentPopover]); + // Refs for code block annotation path const onAddAnnotationRef = useRef(onAddAnnotation); useEffect(() => { onAddAnnotationRef.current = onAddAnnotation; }, [onAddAnnotation]); @@ -327,7 +352,47 @@ export const Viewer = forwardRef(({ }, clearAllHighlights, applySharedAnnotations: applyAnnotations, - }), [hookRemoveHighlight, clearAllHighlights, applyAnnotations, blocks]); + dismissUndoableTransientState: () => { + if (hookCommentPopover) { + if (hookCommentDraftState.hadContentEver) return false; + hookCommentClose(); + return true; + } + + if (viewerCommentPopover) { + if (viewerCommentDraftState.hadContentEver) return false; + handleViewerCommentClose(); + return true; + } + + if (hookQuickLabelPicker) { + hookQuickLabelPickerDismiss(); + return true; + } + + if (toolbarState) { + handleToolbarClose(); + return true; + } + + return false; + }, + }), [ + applyAnnotations, + blocks, + clearAllHighlights, + handleToolbarClose, + handleViewerCommentClose, + hookCommentClose, + hookCommentDraftState.hadContentEver, + hookCommentPopover, + hookQuickLabelPicker, + hookQuickLabelPickerDismiss, + hookRemoveHighlight, + toolbarState, + viewerCommentDraftState.hadContentEver, + viewerCommentPopover, + ]); // --- Viewer-specific: code block annotation --- @@ -435,10 +500,6 @@ export const Viewer = forwardRef(({ setViewerCommentPopover(null); }; - const handleViewerCommentClose = useCallback(() => { - setViewerCommentPopover(null); - }, []); - return (
{taterMode && } @@ -648,6 +709,7 @@ export const Viewer = forwardRef(({ initialText={hookCommentPopover.initialText} onSubmit={hookCommentSubmit} onClose={hookCommentClose} + onDraftStateChange={setHookCommentDraftState} /> )} {viewerCommentPopover && ( @@ -658,6 +720,7 @@ export const Viewer = forwardRef(({ initialText={viewerCommentPopover.initialText} onSubmit={handleViewerCommentSubmit} onClose={handleViewerCommentClose} + onDraftStateChange={setViewerCommentDraftState} /> )} diff --git a/packages/ui/utils/annotationUndo.test.ts b/packages/ui/utils/annotationUndo.test.ts new file mode 100644 index 00000000..cb141a95 --- /dev/null +++ b/packages/ui/utils/annotationUndo.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, test } from "bun:test"; +import { findLastUndoRemovableAnnotation, isUndoRemovableAnnotation } from "./annotationUndo"; +import { AnnotationType, type Annotation } from "../types"; + +function makeAnnotation(overrides: Partial = {}): Annotation { + return { + id: "ann-1", + blockId: "block-1", + startOffset: 0, + endOffset: 5, + type: AnnotationType.DELETION, + originalText: "hello", + createdA: 1, + ...overrides, + }; +} + +describe("annotationUndo", () => { + test("treats empty local deletion annotations as undo-removable", () => { + expect( + isUndoRemovableAnnotation( + makeAnnotation({ type: AnnotationType.DELETION }), + ), + ).toBe(true); + }); + + test("does not treat annotations with user-written content as undo-removable", () => { + expect( + isUndoRemovableAnnotation( + makeAnnotation({ + id: "ann-2", + type: AnnotationType.COMMENT, + text: "needs work", + }), + ), + ).toBe(false); + + expect( + isUndoRemovableAnnotation( + makeAnnotation({ + id: "ann-3", + type: AnnotationType.COMMENT, + text: "👍 Looks good", + isQuickLabel: true, + }), + ), + ).toBe(false); + + expect( + isUndoRemovableAnnotation( + makeAnnotation({ + id: "ann-4", + type: AnnotationType.GLOBAL_COMMENT, + text: "general feedback", + }), + ), + ).toBe(false); + + expect( + isUndoRemovableAnnotation( + makeAnnotation({ + id: "ann-5", + type: AnnotationType.DELETION, + images: [{ path: "/tmp/mock.png", name: "mock" }], + }), + ), + ).toBe(false); + }); + + test("finds the newest undo-removable local annotation", () => { + const annotations: Annotation[] = [ + makeAnnotation({ + id: "ann-1", + type: AnnotationType.COMMENT, + text: "keep this comment", + createdA: 1, + }), + makeAnnotation({ + id: "ann-2", + type: AnnotationType.DELETION, + source: "eslint", + createdA: 2, + }), + makeAnnotation({ + id: "ann-3", + type: AnnotationType.DELETION, + createdA: 3, + }), + ]; + + expect(findLastUndoRemovableAnnotation(annotations)?.id).toBe("ann-3"); + }); + + test("returns null when every annotation has user content or is external", () => { + const annotations: Annotation[] = [ + makeAnnotation({ + id: "ann-1", + type: AnnotationType.COMMENT, + text: "keep this", + }), + makeAnnotation({ + id: "ann-2", + type: AnnotationType.GLOBAL_COMMENT, + text: "keep this too", + }), + makeAnnotation({ + id: "ann-3", + type: AnnotationType.DELETION, + source: "eslint", + }), + ]; + + expect(findLastUndoRemovableAnnotation(annotations)).toBeNull(); + }); +}); diff --git a/packages/ui/utils/annotationUndo.ts b/packages/ui/utils/annotationUndo.ts new file mode 100644 index 00000000..3048d8bd --- /dev/null +++ b/packages/ui/utils/annotationUndo.ts @@ -0,0 +1,29 @@ +import { AnnotationType, type Annotation } from "../types"; + +function hasMeaningfulText(text?: string): boolean { + return (text?.trim().length ?? 0) > 0; +} + +export function isUndoRemovableAnnotation(annotation: Annotation): boolean { + if (annotation.source) return false; + if (annotation.type === AnnotationType.GLOBAL_COMMENT) return false; + if (annotation.isQuickLabel) return false; + if (hasMeaningfulText(annotation.text)) return false; + if ((annotation.images?.length ?? 0) > 0) return false; + return true; +} + +export function findLastUndoRemovableAnnotation( + annotations: Annotation[], +): Annotation | null { + let latest: Annotation | null = null; + + for (const annotation of annotations) { + if (!isUndoRemovableAnnotation(annotation)) continue; + if (!latest || annotation.createdA >= latest.createdA) { + latest = annotation; + } + } + + return latest; +}