Skip to content

Commit 08fcaaf

Browse files
YecatsCopilot
andauthored
feat: Interactive checkboxes with annotation tracking (#423)
* feat: interactive checkboxes — click to toggle with annotation Checkboxes in rendered markdown task lists are now clickable. Toggling is visual-only (does not modify source markdown) and automatically creates a comment annotation noting the change. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: uncheck checkbox removes override and deletes annotation (undo) When toggling back to the original state, the checkbox override is deleted and the associated annotation is removed — acting as an undo. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: address all code review issues for checkbox feature 1. Fix annotation prefix collision: match on blockId field instead of string prefix to avoid block-1 matching block-10, block-11 etc. 2. Fix rapid toggle orphans: delete ALL existing checkbox annotations for the block before creating a new one, preventing duplicates. 3. Fix stale overrides: useEffect cleans up checkboxOverrides entries when their block IDs no longer exist in the current blocks array. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: use actionable annotation text for checkbox toggles Annotations now read 'Mark as completed: {item}' or 'Mark as not completed: {item}' so the AI knows to action on them. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: include section heading in checkbox annotation for disambiguation Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: revert checkbox override when annotation is deleted from panel When a user deletes a checkbox annotation via the annotation panel, the checkboxOverrides entry is now cleared so the checkbox reverts to its original visual state. Previously only toggling the checkbox itself would revert the override. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * refactor: extract removeAnnotation to avoid redundant override cleanup in checkbox toggle * refactor: extract useCheckboxOverrides hook from App.tsx --------- Co-authored-by: Yecats <Yecats@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 2d90d38 commit 08fcaaf

3 files changed

Lines changed: 170 additions & 8 deletions

File tree

packages/editor/App.tsx

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ import type { ArchivedPlan } from '@plannotator/ui/components/sidebar/ArchiveBro
5757
import { PlanDiffViewer } from '@plannotator/ui/components/plan-diff/PlanDiffViewer';
5858
import type { PlanDiffMode } from '@plannotator/ui/components/plan-diff/PlanDiffModeSwitcher';
5959
import { DEMO_PLAN_CONTENT } from './demoPlan';
60+
import { useCheckboxOverrides } from './hooks/useCheckboxOverrides';
6061

6162
type NoteAutoSaveResults = {
6263
obsidian?: boolean;
@@ -805,12 +806,32 @@ const App: React.FC = () => {
805806
if (id && window.innerWidth < 768) setIsPanelOpen(true);
806807
}, []);
807808

808-
const handleDeleteAnnotation = (id: string) => {
809+
// Core annotation removal — highlight cleanup + state filter + selection clear
810+
const removeAnnotation = (id: string) => {
809811
viewerRef.current?.removeHighlight(id);
810812
setAnnotations(prev => prev.filter(a => a.id !== id));
811813
if (selectedAnnotationId === id) setSelectedAnnotationId(null);
812814
};
813815

816+
// Interactive checkbox toggling with annotation tracking
817+
const checkbox = useCheckboxOverrides({
818+
blocks,
819+
annotations,
820+
addAnnotation: handleAddAnnotation,
821+
removeAnnotation,
822+
});
823+
824+
const handleDeleteAnnotation = (id: string) => {
825+
// If this is a checkbox annotation, revert the visual override
826+
if (id.startsWith('ann-checkbox-')) {
827+
const ann = annotations.find(a => a.id === id);
828+
if (ann) {
829+
checkbox.revertOverride(ann.blockId);
830+
}
831+
}
832+
removeAnnotation(id);
833+
};
834+
814835
const handleEditAnnotation = (id: string, updates: Partial<Annotation>) => {
815836
setAnnotations(prev => prev.map(ann =>
816837
ann.id === id ? { ...ann, ...updates } : ann
@@ -1439,6 +1460,8 @@ const App: React.FC = () => {
14391460
imageBaseDir={imageBaseDir}
14401461
copyLabel={annotateSource === 'message' ? 'Copy message' : annotateSource === 'file' || annotateSource === 'folder' ? 'Copy file' : undefined}
14411462
archiveInfo={archive.currentInfo}
1463+
onToggleCheckbox={checkbox.toggle}
1464+
checkboxOverrides={checkbox.overrides}
14421465
/>
14431466
</div>
14441467
</div>
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
/**
2+
* Checkbox Overrides Hook
3+
*
4+
* Manages interactive checkbox toggling in the plan viewer. Each toggle creates
5+
* a COMMENT annotation capturing the action and section context; toggling back
6+
* to the original state removes the override and deletes the annotation.
7+
*/
8+
9+
import { useState, useEffect, useCallback, useRef } from 'react';
10+
import { Annotation, AnnotationType, Block } from '@plannotator/ui/types';
11+
12+
export interface UseCheckboxOverridesOptions {
13+
blocks: Block[];
14+
annotations: Annotation[];
15+
addAnnotation: (ann: Annotation) => void;
16+
removeAnnotation: (id: string) => void;
17+
}
18+
19+
export interface UseCheckboxOverridesReturn {
20+
/** Visual override state passed to the Viewer as `checkboxOverrides` */
21+
overrides: Map<string, boolean>;
22+
/** Toggle handler passed to the Viewer as `onToggleCheckbox` */
23+
toggle: (blockId: string, checked: boolean) => void;
24+
/** Revert an override when a checkbox annotation is deleted from the panel */
25+
revertOverride: (blockId: string) => void;
26+
}
27+
28+
export function useCheckboxOverrides({
29+
blocks,
30+
annotations,
31+
addAnnotation,
32+
removeAnnotation,
33+
}: UseCheckboxOverridesOptions): UseCheckboxOverridesReturn {
34+
const [overrides, setOverrides] = useState<Map<string, boolean>>(new Map());
35+
36+
// Refs so callbacks don't need annotations/blocks in their dep arrays
37+
const blocksRef = useRef(blocks);
38+
blocksRef.current = blocks;
39+
const annotationsRef = useRef(annotations);
40+
annotationsRef.current = annotations;
41+
42+
// Clean up stale overrides when blocks change (e.g. markdown reloaded)
43+
useEffect(() => {
44+
if (overrides.size === 0) return;
45+
const blockIds = new Set(blocks.map(b => b.id));
46+
const stale = [...overrides.keys()].filter(id => !blockIds.has(id));
47+
if (stale.length > 0) {
48+
setOverrides(prev => {
49+
const next = new Map(prev);
50+
stale.forEach(id => next.delete(id));
51+
return next;
52+
});
53+
}
54+
}, [blocks]);
55+
56+
const toggle = useCallback((blockId: string, checked: boolean) => {
57+
const blocks = blocksRef.current;
58+
const annotations = annotationsRef.current;
59+
const block = blocks.find(b => b.id === blockId);
60+
const isRevertingToOriginal = block && checked === block.checked;
61+
62+
if (isRevertingToOriginal) {
63+
// Undo: remove the override and delete ALL checkbox annotations for this block
64+
setOverrides(prev => {
65+
const next = new Map(prev);
66+
next.delete(blockId);
67+
return next;
68+
});
69+
const toDelete = annotations.filter(a => a.blockId === blockId && a.id.startsWith('ann-checkbox-'));
70+
toDelete.forEach(a => removeAnnotation(a.id));
71+
} else {
72+
// Toggle: remove any existing checkbox annotations for this block first (prevents duplicates from rapid clicks)
73+
const existing = annotations.filter(a => a.blockId === blockId && a.id.startsWith('ann-checkbox-'));
74+
existing.forEach(a => removeAnnotation(a.id));
75+
76+
setOverrides(prev => {
77+
const next = new Map(prev);
78+
next.set(blockId, checked);
79+
return next;
80+
});
81+
if (block) {
82+
// Find the nearest heading above this block for section context
83+
const blockIdx = blocks.indexOf(block);
84+
let sectionHeading = '';
85+
for (let i = blockIdx - 1; i >= 0; i--) {
86+
if (blocks[i].type === 'heading') {
87+
sectionHeading = blocks[i].content;
88+
break;
89+
}
90+
}
91+
92+
const action = checked ? 'Mark as completed' : 'Mark as not completed';
93+
const context = sectionHeading ? ` (under "${sectionHeading}")` : ` (line ${block.startLine})`;
94+
const ann: Annotation = {
95+
id: `ann-checkbox-${blockId}-${Date.now()}`,
96+
blockId,
97+
startOffset: 0,
98+
endOffset: block.content.length,
99+
type: AnnotationType.COMMENT,
100+
text: `${action}${context}: ${block.content}`,
101+
originalText: block.content,
102+
createdA: Date.now(),
103+
};
104+
addAnnotation(ann);
105+
}
106+
}
107+
}, [addAnnotation, removeAnnotation]);
108+
109+
const revertOverride = useCallback((blockId: string) => {
110+
setOverrides(prev => {
111+
const next = new Map(prev);
112+
next.delete(blockId);
113+
return next;
114+
});
115+
}, []);
116+
117+
return { overrides, toggle, revertOverride };
118+
}

packages/ui/components/Viewer.tsx

Lines changed: 28 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,9 @@ interface ViewerProps {
6969
/** Label for the copy button (default: "Copy plan") */
7070
copyLabel?: string;
7171
archiveInfo?: { status: 'approved' | 'denied' | 'unknown'; timestamp: string; title: string } | null;
72+
// Checkbox toggle props
73+
onToggleCheckbox?: (blockId: string, checked: boolean) => void;
74+
checkboxOverrides?: Map<string, boolean>;
7275
}
7376

7477
export interface ViewerHandle {
@@ -137,6 +140,8 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
137140
imageBaseDir,
138141
copyLabel,
139142
archiveInfo,
143+
onToggleCheckbox,
144+
checkboxOverrides,
140145
}, ref) => {
141146
const [copied, setCopied] = useState(false);
142147
const [lightbox, setLightbox] = useState<{ src: string; alt: string } | null>(null);
@@ -572,7 +577,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
572577
group.type === 'list-group' ? (
573578
<div key={group.key} data-pinpoint-group="list" className="py-1 -mx-2 px-2">
574579
{group.blocks.map(block => (
575-
<BlockRenderer imageBaseDir={imageBaseDir} onImageClick={(src, alt) => setLightbox({ src, alt })} key={block.id} block={block} onOpenLinkedDoc={onOpenLinkedDoc} />
580+
<BlockRenderer imageBaseDir={imageBaseDir} onImageClick={(src, alt) => setLightbox({ src, alt })} key={block.id} block={block} onOpenLinkedDoc={onOpenLinkedDoc} onToggleCheckbox={onToggleCheckbox} checkboxOverrides={checkboxOverrides} />
576581
))}
577582
</div>
578583
) : group.block.type === 'code' && isMermaidLanguage(group.block.language) ? (
@@ -610,7 +615,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
610615
isHovered={inputMethod !== 'pinpoint' && hoveredCodeBlock?.block.id === group.block.id}
611616
/>
612617
) : (
613-
<BlockRenderer imageBaseDir={imageBaseDir} onImageClick={(src, alt) => setLightbox({ src, alt })} key={group.block.id} block={group.block} onOpenLinkedDoc={onOpenLinkedDoc} />
618+
<BlockRenderer imageBaseDir={imageBaseDir} onImageClick={(src, alt) => setLightbox({ src, alt })} key={group.block.id} block={group.block} onOpenLinkedDoc={onOpenLinkedDoc} onToggleCheckbox={onToggleCheckbox} checkboxOverrides={checkboxOverrides} />
614619
)
615620
)}
616621

@@ -967,7 +972,14 @@ function groupBlocks(blocks: Block[]): RenderGroup[] {
967972
return groups;
968973
}
969974

970-
const BlockRenderer: React.FC<{ block: Block; onOpenLinkedDoc?: (path: string) => void; imageBaseDir?: string; onImageClick?: (src: string, alt: string) => void }> = ({ block, onOpenLinkedDoc, imageBaseDir, onImageClick }) => {
975+
const BlockRenderer: React.FC<{
976+
block: Block;
977+
onOpenLinkedDoc?: (path: string) => void;
978+
imageBaseDir?: string;
979+
onImageClick?: (src: string, alt: string) => void;
980+
onToggleCheckbox?: (blockId: string, checked: boolean) => void;
981+
checkboxOverrides?: Map<string, boolean>;
982+
}> = ({ block, onOpenLinkedDoc, imageBaseDir, onImageClick, onToggleCheckbox, checkboxOverrides }) => {
971983
switch (block.type) {
972984
case 'heading':
973985
const Tag = `h${block.level || 1}` as keyof JSX.IntrinsicElements;
@@ -992,20 +1004,29 @@ const BlockRenderer: React.FC<{ block: Block; onOpenLinkedDoc?: (path: string) =
9921004
case 'list-item': {
9931005
const indent = (block.level || 0) * 1.25; // 1.25rem per level
9941006
const isCheckbox = block.checked !== undefined;
1007+
const isChecked = checkboxOverrides?.has(block.id)
1008+
? checkboxOverrides.get(block.id)!
1009+
: block.checked;
1010+
const isInteractive = isCheckbox && !!onToggleCheckbox;
9951011
return (
9961012
<div
9971013
className="flex gap-3 my-1.5"
9981014
data-block-id={block.id}
9991015
style={{ marginLeft: `${indent}rem` }}
10001016
>
1001-
<span className="select-none shrink-0 flex items-center">
1017+
<span
1018+
className={`select-none shrink-0 flex items-center${isInteractive ? ' cursor-pointer' : ''}`}
1019+
onClick={isInteractive ? (e) => { e.stopPropagation(); onToggleCheckbox!(block.id, !isChecked); } : undefined}
1020+
role={isInteractive ? 'checkbox' : undefined}
1021+
aria-checked={isInteractive ? isChecked : undefined}
1022+
>
10021023
{isCheckbox ? (
1003-
block.checked ? (
1024+
isChecked ? (
10041025
<svg className="w-4 h-4 text-success" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}>
10051026
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
10061027
</svg>
10071028
) : (
1008-
<svg className="w-4 h-4 text-muted-foreground/50" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
1029+
<svg className={`w-4 h-4 text-muted-foreground/50${isInteractive ? ' hover:text-muted-foreground transition-colors' : ''}`} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
10091030
<circle cx="12" cy="12" r="9" />
10101031
</svg>
10111032
)
@@ -1015,7 +1036,7 @@ const BlockRenderer: React.FC<{ block: Block; onOpenLinkedDoc?: (path: string) =
10151036
</span>
10161037
)}
10171038
</span>
1018-
<span className={`text-sm leading-relaxed ${isCheckbox && block.checked ? 'text-muted-foreground line-through' : 'text-foreground/90'}`}>
1039+
<span className={`text-sm leading-relaxed ${isCheckbox && isChecked ? 'text-muted-foreground line-through' : 'text-foreground/90'}`}>
10191040
<InlineMarkdown imageBaseDir={imageBaseDir} onImageClick={onImageClick} text={block.content} onOpenLinkedDoc={onOpenLinkedDoc} />
10201041
</span>
10211042
</div>

0 commit comments

Comments
 (0)