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
1 change: 1 addition & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
"devDependencies": {
"@types/node": "^25.5.2",
"@types/turndown": "^5.0.6",
"bun-types": "^1.3.11"
"bun-types": "^1.3.11",
"typescript": "~5.8.2"
}
}
251 changes: 157 additions & 94 deletions packages/editor/App.tsx

Large diffs are not rendered by default.

230 changes: 230 additions & 0 deletions packages/editor/hooks/useAnnotationCommands.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,230 @@
import { useCallback } from 'react';
import type { Dispatch, RefObject, SetStateAction } from 'react';
import { configStore } from '@plannotator/ui/config';
import type { ViewerHandle } from '@plannotator/ui/components/Viewer';
import type { CodeFileAnnotationInput } from '@plannotator/ui/components/CodeFilePopout';
import type { AnnotationHistoryApi, OverrideSnapshot } from '@plannotator/ui/hooks/useAnnotationHistory';
import type { Annotation, CodeAnnotation, ImageAttachment } from '@plannotator/ui/types';
import { generateId } from '@plannotator/ui/utils/generateId';
import type { CheckboxToggleRecord } from './useCheckboxOverrides';

interface UseAnnotationCommandsOptions {
history: Pick<AnnotationHistoryApi, 'push'>;
viewerRef: RefObject<ViewerHandle | null>;
allAnnotations: Annotation[];
externalAnnotations: Annotation[];
codeAnnotationsRef: RefObject<CodeAnnotation[]>;
annotationsRef: RefObject<Annotation[]>;
selectedAnnotationIdRef: RefObject<string | null>;
selectedCodeAnnotationIdRef: RefObject<string | null>;
globalAttachments: ImageAttachment[];
setAnnotations: Dispatch<SetStateAction<Annotation[]>>;
setCodeAnnotations: Dispatch<SetStateAction<CodeAnnotation[]>>;
setGlobalAttachments: Dispatch<SetStateAction<ImageAttachment[]>>;
setSelectedAnnotationId: (id: string | null) => void;
setSelectedCodeAnnotationId: (id: string | null) => void;
deleteExternalAnnotation: (id: string) => void;
updateExternalAnnotation: (id: string, updates: Partial<Annotation>) => void;
getCheckboxOverrides: () => OverrideSnapshot;
revertCheckboxOverride: (blockId: string) => void;
}

export interface AnnotationCommands {
addAnnotationRaw: (ann: Annotation) => void;
removeAnnotationRaw: (id: string) => void;
recordCheckboxToggle: (record: CheckboxToggleRecord) => void;
addAnnotation: (ann: Annotation) => void;
deleteAnnotation: (id: string) => void;
editAnnotation: (id: string, updates: Partial<Annotation>) => void;
addCodeAnnotation: (input: CodeFileAnnotationInput) => void;
deleteCodeAnnotation: (id: string) => void;
editCodeAnnotation: (id: string, updates: Partial<CodeAnnotation>) => void;
addGlobalAttachment: (image: ImageAttachment) => void;
removeGlobalAttachment: (path: string) => void;
changeIdentity: (oldIdentity: string, newIdentity: string) => void;
}

export function useAnnotationCommands({
history,
viewerRef,
allAnnotations,
externalAnnotations,
codeAnnotationsRef,
annotationsRef,
selectedAnnotationIdRef,
selectedCodeAnnotationIdRef,
globalAttachments,
setAnnotations,
setCodeAnnotations,
setGlobalAttachments,
setSelectedAnnotationId,
setSelectedCodeAnnotationId,
deleteExternalAnnotation,
updateExternalAnnotation,
getCheckboxOverrides,
revertCheckboxOverride,
}: UseAnnotationCommandsOptions): AnnotationCommands {
const addAnnotationRaw = useCallback((ann: Annotation) => {
setAnnotations(prev => [...prev, ann]);
setSelectedAnnotationId(ann.id);
setSelectedCodeAnnotationId(null);
}, [setAnnotations, setSelectedAnnotationId, setSelectedCodeAnnotationId]);

const removeAnnotationRaw = useCallback((id: string) => {
viewerRef.current?.removeHighlight(id);
setAnnotations(prev => prev.filter(a => a.id !== id));
if (selectedAnnotationIdRef.current === id) setSelectedAnnotationId(null);
}, [selectedAnnotationIdRef, setAnnotations, setSelectedAnnotationId, viewerRef]);

const recordCheckboxToggle = useCallback((record: CheckboxToggleRecord) => {
history.push({ kind: 'checkbox-toggle', ...record });
}, [history]);

const addAnnotation = useCallback((ann: Annotation) => {
const prevSelectedId = selectedAnnotationIdRef.current;
const prevSelectedCodeId = selectedCodeAnnotationIdRef.current;
addAnnotationRaw(ann);
history.push({ kind: 'add-annotation', ann, prevSelectedId, prevSelectedCodeId });
}, [addAnnotationRaw, history, selectedAnnotationIdRef, selectedCodeAnnotationIdRef]);

const addCodeAnnotation = useCallback((input: CodeFileAnnotationInput) => {
const prevSelectedId = selectedAnnotationIdRef.current;
const prevSelectedCodeId = selectedCodeAnnotationIdRef.current;
const annotation: CodeAnnotation = {
id: generateId('code-ann'),
type: 'comment',
scope: 'line',
filePath: input.filePath,
lineStart: input.lineStart,
lineEnd: input.lineEnd,
side: 'new',
text: input.text,
images: input.images,
originalCode: input.originalCode,
createdAt: Date.now(),
author: configStore.get('displayName') || undefined,
};

setCodeAnnotations(prev => [...prev, annotation]);
setSelectedAnnotationId(null);
setSelectedCodeAnnotationId(annotation.id);
history.push({ kind: 'add-code-annotation', ann: annotation, prevSelectedId, prevSelectedCodeId });
}, [
history,
selectedAnnotationIdRef,
selectedCodeAnnotationIdRef,
setCodeAnnotations,
setSelectedAnnotationId,
setSelectedCodeAnnotationId,
]);

const deleteCodeAnnotation = useCallback((id: string) => {
const ann = codeAnnotationsRef.current.find(a => a.id === id);
const prevSelectedCodeId = selectedCodeAnnotationIdRef.current;
setCodeAnnotations(prev => prev.filter(a => a.id !== id));
if (prevSelectedCodeId === id) setSelectedCodeAnnotationId(null);
if (ann) history.push({ kind: 'delete-code-annotation', ann, prevSelectedCodeId });
}, [codeAnnotationsRef, history, selectedCodeAnnotationIdRef, setCodeAnnotations, setSelectedCodeAnnotationId]);

const editCodeAnnotation = useCallback((id: string, updates: Partial<CodeAnnotation>) => {
const prevAnn = codeAnnotationsRef.current.find(a => a.id === id);
setCodeAnnotations(prev => prev.map(a => a.id === id ? { ...a, ...updates } : a));
if (prevAnn) history.push({ kind: 'update-code-annotation', prevAnn, nextAnn: { ...prevAnn, ...updates } });
}, [codeAnnotationsRef, history, setCodeAnnotations]);

const deleteAnnotation = useCallback((id: string) => {
const ann = allAnnotations.find(a => a.id === id);
// External annotations live in the SSE hook, not local annotation state.
if (ann?.source && externalAnnotations.some(e => e.id === id)) {
deleteExternalAnnotation(id);
if (selectedAnnotationIdRef.current === id) setSelectedAnnotationId(null);
return;
}

if (id.startsWith('ann-checkbox-') && ann) {
const prevOverrides = getCheckboxOverrides();
const nextOverrides = prevOverrides.filter(([bid]) => bid !== ann.blockId);
revertCheckboxOverride(ann.blockId);
removeAnnotationRaw(id);
history.push({
kind: 'checkbox-toggle',
blockId: ann.blockId,
prevOverrides,
nextOverrides,
prevCheckboxAnns: [ann],
nextCheckboxAnns: [],
});
return;
}

const prevSelectedId = selectedAnnotationIdRef.current;
removeAnnotationRaw(id);
if (ann) history.push({ kind: 'delete-annotation', ann, prevSelectedId });
}, [
allAnnotations,
deleteExternalAnnotation,
externalAnnotations,
getCheckboxOverrides,
history,
removeAnnotationRaw,
revertCheckboxOverride,
selectedAnnotationIdRef,
setSelectedAnnotationId,
]);

const editAnnotation = useCallback((id: string, updates: Partial<Annotation>) => {
const ann = allAnnotations.find(a => a.id === id);
if (ann?.source && externalAnnotations.some(e => e.id === id)) {
updateExternalAnnotation(id, updates);
return;
}

if (ann) {
const nextAnn: Annotation = { ...ann, ...updates };
setAnnotations(prev => prev.map(a => a.id === id ? nextAnn : a));
history.push({ kind: 'update-annotation', prevAnn: ann, nextAnn });
} else {
setAnnotations(prev => prev.map(a => a.id === id ? { ...a, ...updates } : a));
}
}, [allAnnotations, externalAnnotations, history, setAnnotations, updateExternalAnnotation]);

const changeIdentity = useCallback((oldIdentity: string, newIdentity: string) => {
const affectedAnnIds = annotationsRef.current.filter(a => a.author === oldIdentity).map(a => a.id);
const affectedCodeAnnIds = codeAnnotationsRef.current.filter(a => a.author === oldIdentity).map(a => a.id);
setAnnotations(prev => prev.map(ann =>
ann.author === oldIdentity ? { ...ann, author: newIdentity } : ann
));
setCodeAnnotations(prev => prev.map(ann =>
ann.author === oldIdentity ? { ...ann, author: newIdentity } : ann
));
if (affectedAnnIds.length > 0 || affectedCodeAnnIds.length > 0) {
history.push({ kind: 'identity-change', oldIdentity, newIdentity, affectedAnnIds, affectedCodeAnnIds });
}
}, [annotationsRef, codeAnnotationsRef, history, setAnnotations, setCodeAnnotations]);

const addGlobalAttachment = useCallback((image: ImageAttachment) => {
setGlobalAttachments(prev => [...prev, image]);
history.push({ kind: 'add-global-attachment', img: image });
}, [history, setGlobalAttachments]);

const removeGlobalAttachment = useCallback((path: string) => {
const img = globalAttachments.find(p => p.path === path);
setGlobalAttachments(prev => prev.filter(p => p.path !== path));
if (img) history.push({ kind: 'remove-global-attachment', img });
}, [globalAttachments, history, setGlobalAttachments]);

return {
addAnnotationRaw,
removeAnnotationRaw,
recordCheckboxToggle,
addAnnotation,
deleteAnnotation,
editAnnotation,
addCodeAnnotation,
deleteCodeAnnotation,
editCodeAnnotation,
addGlobalAttachment,
removeGlobalAttachment,
changeIdentity,
};
}
62 changes: 60 additions & 2 deletions packages/editor/hooks/useCheckboxOverrides.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,36 @@
* Manages interactive checkbox toggling in the plan viewer. Each toggle creates
* a COMMENT annotation capturing the action and section context; toggling back
* to the original state removes the override and deletes the annotation.
*
* Undo integration: the hook records each toggle as a single composite action
* via `onRecordToggle` (capturing before/after override maps and checkbox
* annotations) so the history stack can reverse it as one step. `applyOverrides`
* restores a prior override map during undo/redo. The `addAnnotation` /
* `removeAnnotation` callbacks passed in are expected to be the *raw*
* (non-history-recording) variants so the toggle's internal add/remove do not
* each push their own history entry.
*/

import { useState, useEffect, useCallback, useRef } from 'react';
import { Annotation, AnnotationType, Block } from '@plannotator/ui/types';
import type { OverrideSnapshot } from '@plannotator/ui/hooks/useAnnotationHistory';

/** Payload recorded for the history stack on each interactive toggle. */
export interface CheckboxToggleRecord {
blockId: string;
prevOverrides: OverrideSnapshot;
nextOverrides: OverrideSnapshot;
prevCheckboxAnns: Annotation[];
nextCheckboxAnns: Annotation[];
}

export interface UseCheckboxOverridesOptions {
blocks: Block[];
annotations: Annotation[];
addAnnotation: (ann: Annotation) => void;
removeAnnotation: (id: string) => void;
/** Record a composite toggle action for undo/redo. */
onRecordToggle?: (record: CheckboxToggleRecord) => void;
}

export interface UseCheckboxOverridesReturn {
Expand All @@ -23,21 +43,28 @@ export interface UseCheckboxOverridesReturn {
toggle: (blockId: string, checked: boolean) => void;
/** Revert an override when a checkbox annotation is deleted from the panel */
revertOverride: (blockId: string) => void;
/** Replace the entire override map (undo/redo). Accepts a serializable snapshot. */
applyOverrides: (next: OverrideSnapshot) => void;
}

export function useCheckboxOverrides({
blocks,
annotations,
addAnnotation,
removeAnnotation,
onRecordToggle,
}: UseCheckboxOverridesOptions): UseCheckboxOverridesReturn {
const [overrides, setOverrides] = useState<Map<string, boolean>>(new Map());

// Refs so callbacks don't need annotations/blocks in their dep arrays
// Refs so callbacks don't need annotations/blocks/overrides in their dep arrays
const blocksRef = useRef(blocks);
blocksRef.current = blocks;
const annotationsRef = useRef(annotations);
annotationsRef.current = annotations;
const overridesRef = useRef(overrides);
overridesRef.current = overrides;
const onRecordToggleRef = useRef(onRecordToggle);
onRecordToggleRef.current = onRecordToggle;

// Clean up stale overrides when blocks change (e.g. markdown reloaded)
useEffect(() => {
Expand All @@ -59,6 +86,12 @@ export function useCheckboxOverrides({
const block = blocks.find(b => b.id === blockId);
const isRevertingToOriginal = block && checked === block.checked;

// Snapshot before-state for the composite undo record.
const prevOverrides: OverrideSnapshot = [...overridesRef.current.entries()];
const prevCheckboxAnns = annotations.filter(
a => a.blockId === blockId && a.id.startsWith('ann-checkbox-'),
);

if (isRevertingToOriginal) {
// Undo: remove the override and delete ALL checkbox annotations for this block
setOverrides(prev => {
Expand All @@ -68,6 +101,15 @@ export function useCheckboxOverrides({
});
const toDelete = annotations.filter(a => a.blockId === blockId && a.id.startsWith('ann-checkbox-'));
toDelete.forEach(a => removeAnnotation(a.id));

const nextOverrides = prevOverrides.filter(([id]) => id !== blockId);
onRecordToggleRef.current?.({
blockId,
prevOverrides,
nextOverrides,
prevCheckboxAnns,
nextCheckboxAnns: [],
});
} else {
// Toggle: remove any existing checkbox annotations for this block first (prevents duplicates from rapid clicks)
const existing = annotations.filter(a => a.blockId === blockId && a.id.startsWith('ann-checkbox-'));
Expand All @@ -78,6 +120,8 @@ export function useCheckboxOverrides({
next.set(blockId, checked);
return next;
});

let nextCheckboxAnns: Annotation[] = [];
if (block) {
// Find the nearest heading above this block for section context
const blockIdx = blocks.indexOf(block);
Expand All @@ -102,7 +146,17 @@ export function useCheckboxOverrides({
createdA: Date.now(),
};
addAnnotation(ann);
nextCheckboxAnns = [ann];
}

const nextOverrides: OverrideSnapshot = [...prevOverrides.filter(([id]) => id !== blockId), [blockId, checked]];
onRecordToggleRef.current?.({
blockId,
prevOverrides,
nextOverrides,
prevCheckboxAnns,
nextCheckboxAnns,
});
}
}, [addAnnotation, removeAnnotation]);

Expand All @@ -114,5 +168,9 @@ export function useCheckboxOverrides({
});
}, []);

return { overrides, toggle, revertOverride };
const applyOverrides = useCallback((next: OverrideSnapshot) => {
setOverrides(new Map(next));
}, []);

return { overrides, toggle, revertOverride, applyOverrides };
}
2 changes: 2 additions & 0 deletions packages/editor/shortcuts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
createShortcutScopeHook,
defineShortcutScope,
goalSetupShortcuts,
historyShortcuts,
imageAnnotatorShortcuts,
inputMethodShortcuts,
viewerShortcuts,
Expand Down Expand Up @@ -83,6 +84,7 @@ const sharedPlanSurfaceShortcuts = [
commentPopoverShortcuts,
annotationPanelShortcuts,
imageAnnotatorShortcuts,
historyShortcuts,
] as const;

export const planReviewSettingsShortcutRegistry = createShortcutRegistry([
Expand Down
Loading