Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions packages/editor/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<Annotation>) => {
const ann = allAnnotations.find(a => a.id === id);
if (ann?.source && externalAnnotations.some(e => e.id === id)) {
Expand Down
20 changes: 20 additions & 0 deletions packages/ui/components/CommentPopover.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -45,13 +52,15 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
initialText = '',
onSubmit,
onClose,
onDraftStateChange,
}) => {
const [mode, setMode] = useState<'popover' | 'dialog'>('popover');
const [text, setText] = useState(initialText);
const [images, setImages] = useState<ImageAttachment[]>([]);
const [position, setPosition] = useState<{ top: number; left: number; flipAbove: boolean; width: number } | null>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const popoverRef = useRef<HTMLDivElement>(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
Expand Down Expand Up @@ -87,6 +96,15 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
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;
Expand Down Expand Up @@ -144,6 +162,7 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
{/* Dialog card */}
<div
ref={popoverRef}
data-comment-popover-root
className="relative w-full max-w-xl bg-popover border border-border rounded-xl shadow-2xl flex flex-col"
style={{
animation: 'comment-dialog-in 0.15s ease-out',
Expand Down Expand Up @@ -226,6 +245,7 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
return createPortal(
<div
ref={popoverRef}
data-comment-popover-root
className="fixed z-[100] bg-popover border border-border rounded-xl shadow-2xl flex flex-col"
style={dragPosition
? { top: dragPosition.top, left: dragPosition.left, width: position.width }
Expand Down
75 changes: 69 additions & 6 deletions packages/ui/components/Viewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ class ToolbarErrorBoundary extends React.Component<
return this.props.children;
}
}
import { CommentPopover } from './CommentPopover';
import { CommentPopover, type CommentDraftState } from './CommentPopover';
import { TaterSpriteSitting } from './TaterSpriteSitting';
import { AttachmentsButton } from './AttachmentsButton';
import { GraphvizBlock } from './GraphvizBlock';
Expand Down Expand Up @@ -86,6 +86,7 @@ export interface ViewerHandle {
removeHighlight: (id: string) => void;
clearAllHighlights: () => void;
applySharedAnnotations: (annotations: Annotation[]) => void;
dismissUndoableTransientState: () => boolean;
}

/**
Expand Down Expand Up @@ -176,6 +177,14 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
isGlobal: boolean;
codeBlock?: { block: Block; element: HTMLElement };
} | null>(null);
const [hookCommentDraftState, setHookCommentDraftState] = useState<CommentDraftState>({
hasContent: false,
hadContentEver: false,
});
const [viewerCommentDraftState, setViewerCommentDraftState] = useState<CommentDraftState>({
hasContent: false,
hadContentEver: false,
});
// Viewer-specific quick label state (code blocks)
const [codeBlockQuickLabelPicker, setCodeBlockQuickLabelPicker] = useState<{
anchorEl: HTMLElement;
Expand All @@ -185,6 +194,10 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
const stickySentinelRef = useRef<HTMLDivElement>(null);
const [isStuck, setIsStuck] = useState(false);

const handleViewerCommentClose = useCallback(() => {
setViewerCommentPopover(null);
}, []);

// Shared annotation infrastructure via hook
const {
highlighterRef,
Expand All @@ -211,6 +224,18 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
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]);
Expand Down Expand Up @@ -327,7 +352,47 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
},
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 ---

Expand Down Expand Up @@ -435,10 +500,6 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
setViewerCommentPopover(null);
};

const handleViewerCommentClose = useCallback(() => {
setViewerCommentPopover(null);
}, []);

return (
<div className="relative z-50 w-full" style={maxWidth ? { maxWidth } : { maxWidth: 832 }}>
{taterMode && <TaterSpriteSitting />}
Expand Down Expand Up @@ -648,6 +709,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
initialText={hookCommentPopover.initialText}
onSubmit={hookCommentSubmit}
onClose={hookCommentClose}
onDraftStateChange={setHookCommentDraftState}
/>
)}
{viewerCommentPopover && (
Expand All @@ -658,6 +720,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
initialText={viewerCommentPopover.initialText}
onSubmit={handleViewerCommentSubmit}
onClose={handleViewerCommentClose}
onDraftStateChange={setViewerCommentDraftState}
/>
)}

Expand Down
115 changes: 115 additions & 0 deletions packages/ui/utils/annotationUndo.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): 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();
});
});
Loading