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>
This commit is contained in:
Stacey Haffner
2026-03-29 09:43:03 -07:00
committed by GitHub
parent 2d90d381c9
commit 08fcaaf086
3 changed files with 170 additions and 8 deletions
+24 -1
View File
@@ -57,6 +57,7 @@ import type { ArchivedPlan } from '@plannotator/ui/components/sidebar/ArchiveBro
import { PlanDiffViewer } from '@plannotator/ui/components/plan-diff/PlanDiffViewer';
import type { PlanDiffMode } from '@plannotator/ui/components/plan-diff/PlanDiffModeSwitcher';
import { DEMO_PLAN_CONTENT } from './demoPlan';
import { useCheckboxOverrides } from './hooks/useCheckboxOverrides';
type NoteAutoSaveResults = {
obsidian?: boolean;
@@ -805,12 +806,32 @@ const App: React.FC = () => {
if (id && window.innerWidth < 768) setIsPanelOpen(true);
}, []);
const handleDeleteAnnotation = (id: string) => {
// Core annotation removal — highlight cleanup + state filter + selection clear
const removeAnnotation = (id: string) => {
viewerRef.current?.removeHighlight(id);
setAnnotations(prev => prev.filter(a => a.id !== id));
if (selectedAnnotationId === id) setSelectedAnnotationId(null);
};
// Interactive checkbox toggling with annotation tracking
const checkbox = useCheckboxOverrides({
blocks,
annotations,
addAnnotation: handleAddAnnotation,
removeAnnotation,
});
const handleDeleteAnnotation = (id: string) => {
// If this is a checkbox annotation, revert the visual override
if (id.startsWith('ann-checkbox-')) {
const ann = annotations.find(a => a.id === id);
if (ann) {
checkbox.revertOverride(ann.blockId);
}
}
removeAnnotation(id);
};
const handleEditAnnotation = (id: string, updates: Partial<Annotation>) => {
setAnnotations(prev => prev.map(ann =>
ann.id === id ? { ...ann, ...updates } : ann
@@ -1439,6 +1460,8 @@ const App: React.FC = () => {
imageBaseDir={imageBaseDir}
copyLabel={annotateSource === 'message' ? 'Copy message' : annotateSource === 'file' || annotateSource === 'folder' ? 'Copy file' : undefined}
archiveInfo={archive.currentInfo}
onToggleCheckbox={checkbox.toggle}
checkboxOverrides={checkbox.overrides}
/>
</div>
</div>
@@ -0,0 +1,118 @@
/**
* Checkbox Overrides Hook
*
* 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.
*/
import { useState, useEffect, useCallback, useRef } from 'react';
import { Annotation, AnnotationType, Block } from '@plannotator/ui/types';
export interface UseCheckboxOverridesOptions {
blocks: Block[];
annotations: Annotation[];
addAnnotation: (ann: Annotation) => void;
removeAnnotation: (id: string) => void;
}
export interface UseCheckboxOverridesReturn {
/** Visual override state passed to the Viewer as `checkboxOverrides` */
overrides: Map<string, boolean>;
/** Toggle handler passed to the Viewer as `onToggleCheckbox` */
toggle: (blockId: string, checked: boolean) => void;
/** Revert an override when a checkbox annotation is deleted from the panel */
revertOverride: (blockId: string) => void;
}
export function useCheckboxOverrides({
blocks,
annotations,
addAnnotation,
removeAnnotation,
}: UseCheckboxOverridesOptions): UseCheckboxOverridesReturn {
const [overrides, setOverrides] = useState<Map<string, boolean>>(new Map());
// Refs so callbacks don't need annotations/blocks in their dep arrays
const blocksRef = useRef(blocks);
blocksRef.current = blocks;
const annotationsRef = useRef(annotations);
annotationsRef.current = annotations;
// Clean up stale overrides when blocks change (e.g. markdown reloaded)
useEffect(() => {
if (overrides.size === 0) return;
const blockIds = new Set(blocks.map(b => b.id));
const stale = [...overrides.keys()].filter(id => !blockIds.has(id));
if (stale.length > 0) {
setOverrides(prev => {
const next = new Map(prev);
stale.forEach(id => next.delete(id));
return next;
});
}
}, [blocks]);
const toggle = useCallback((blockId: string, checked: boolean) => {
const blocks = blocksRef.current;
const annotations = annotationsRef.current;
const block = blocks.find(b => b.id === blockId);
const isRevertingToOriginal = block && checked === block.checked;
if (isRevertingToOriginal) {
// Undo: remove the override and delete ALL checkbox annotations for this block
setOverrides(prev => {
const next = new Map(prev);
next.delete(blockId);
return next;
});
const toDelete = annotations.filter(a => a.blockId === blockId && a.id.startsWith('ann-checkbox-'));
toDelete.forEach(a => removeAnnotation(a.id));
} 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-'));
existing.forEach(a => removeAnnotation(a.id));
setOverrides(prev => {
const next = new Map(prev);
next.set(blockId, checked);
return next;
});
if (block) {
// Find the nearest heading above this block for section context
const blockIdx = blocks.indexOf(block);
let sectionHeading = '';
for (let i = blockIdx - 1; i >= 0; i--) {
if (blocks[i].type === 'heading') {
sectionHeading = blocks[i].content;
break;
}
}
const action = checked ? 'Mark as completed' : 'Mark as not completed';
const context = sectionHeading ? ` (under "${sectionHeading}")` : ` (line ${block.startLine})`;
const ann: Annotation = {
id: `ann-checkbox-${blockId}-${Date.now()}`,
blockId,
startOffset: 0,
endOffset: block.content.length,
type: AnnotationType.COMMENT,
text: `${action}${context}: ${block.content}`,
originalText: block.content,
createdA: Date.now(),
};
addAnnotation(ann);
}
}
}, [addAnnotation, removeAnnotation]);
const revertOverride = useCallback((blockId: string) => {
setOverrides(prev => {
const next = new Map(prev);
next.delete(blockId);
return next;
});
}, []);
return { overrides, toggle, revertOverride };
}
+28 -7
View File
@@ -69,6 +69,9 @@ interface ViewerProps {
/** Label for the copy button (default: "Copy plan") */
copyLabel?: string;
archiveInfo?: { status: 'approved' | 'denied' | 'unknown'; timestamp: string; title: string } | null;
// Checkbox toggle props
onToggleCheckbox?: (blockId: string, checked: boolean) => void;
checkboxOverrides?: Map<string, boolean>;
}
export interface ViewerHandle {
@@ -137,6 +140,8 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
imageBaseDir,
copyLabel,
archiveInfo,
onToggleCheckbox,
checkboxOverrides,
}, ref) => {
const [copied, setCopied] = useState(false);
const [lightbox, setLightbox] = useState<{ src: string; alt: string } | null>(null);
@@ -572,7 +577,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
group.type === 'list-group' ? (
<div key={group.key} data-pinpoint-group="list" className="py-1 -mx-2 px-2">
{group.blocks.map(block => (
<BlockRenderer imageBaseDir={imageBaseDir} onImageClick={(src, alt) => setLightbox({ src, alt })} key={block.id} block={block} onOpenLinkedDoc={onOpenLinkedDoc} />
<BlockRenderer imageBaseDir={imageBaseDir} onImageClick={(src, alt) => setLightbox({ src, alt })} key={block.id} block={block} onOpenLinkedDoc={onOpenLinkedDoc} onToggleCheckbox={onToggleCheckbox} checkboxOverrides={checkboxOverrides} />
))}
</div>
) : group.block.type === 'code' && isMermaidLanguage(group.block.language) ? (
@@ -610,7 +615,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
isHovered={inputMethod !== 'pinpoint' && hoveredCodeBlock?.block.id === group.block.id}
/>
) : (
<BlockRenderer imageBaseDir={imageBaseDir} onImageClick={(src, alt) => setLightbox({ src, alt })} key={group.block.id} block={group.block} onOpenLinkedDoc={onOpenLinkedDoc} />
<BlockRenderer imageBaseDir={imageBaseDir} onImageClick={(src, alt) => setLightbox({ src, alt })} key={group.block.id} block={group.block} onOpenLinkedDoc={onOpenLinkedDoc} onToggleCheckbox={onToggleCheckbox} checkboxOverrides={checkboxOverrides} />
)
)}
@@ -967,7 +972,14 @@ function groupBlocks(blocks: Block[]): RenderGroup[] {
return groups;
}
const BlockRenderer: React.FC<{ block: Block; onOpenLinkedDoc?: (path: string) => void; imageBaseDir?: string; onImageClick?: (src: string, alt: string) => void }> = ({ block, onOpenLinkedDoc, imageBaseDir, onImageClick }) => {
const BlockRenderer: React.FC<{
block: Block;
onOpenLinkedDoc?: (path: string) => void;
imageBaseDir?: string;
onImageClick?: (src: string, alt: string) => void;
onToggleCheckbox?: (blockId: string, checked: boolean) => void;
checkboxOverrides?: Map<string, boolean>;
}> = ({ block, onOpenLinkedDoc, imageBaseDir, onImageClick, onToggleCheckbox, checkboxOverrides }) => {
switch (block.type) {
case 'heading':
const Tag = `h${block.level || 1}` as keyof JSX.IntrinsicElements;
@@ -992,20 +1004,29 @@ const BlockRenderer: React.FC<{ block: Block; onOpenLinkedDoc?: (path: string) =
case 'list-item': {
const indent = (block.level || 0) * 1.25; // 1.25rem per level
const isCheckbox = block.checked !== undefined;
const isChecked = checkboxOverrides?.has(block.id)
? checkboxOverrides.get(block.id)!
: block.checked;
const isInteractive = isCheckbox && !!onToggleCheckbox;
return (
<div
className="flex gap-3 my-1.5"
data-block-id={block.id}
style={{ marginLeft: `${indent}rem` }}
>
<span className="select-none shrink-0 flex items-center">
<span
className={`select-none shrink-0 flex items-center${isInteractive ? ' cursor-pointer' : ''}`}
onClick={isInteractive ? (e) => { e.stopPropagation(); onToggleCheckbox!(block.id, !isChecked); } : undefined}
role={isInteractive ? 'checkbox' : undefined}
aria-checked={isInteractive ? isChecked : undefined}
>
{isCheckbox ? (
block.checked ? (
isChecked ? (
<svg className="w-4 h-4 text-success" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}>
<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" />
</svg>
) : (
<svg className="w-4 h-4 text-muted-foreground/50" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<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}>
<circle cx="12" cy="12" r="9" />
</svg>
)
@@ -1015,7 +1036,7 @@ const BlockRenderer: React.FC<{ block: Block; onOpenLinkedDoc?: (path: string) =
</span>
)}
</span>
<span className={`text-sm leading-relaxed ${isCheckbox && block.checked ? 'text-muted-foreground line-through' : 'text-foreground/90'}`}>
<span className={`text-sm leading-relaxed ${isCheckbox && isChecked ? 'text-muted-foreground line-through' : 'text-foreground/90'}`}>
<InlineMarkdown imageBaseDir={imageBaseDir} onImageClick={onImageClick} text={block.content} onOpenLinkedDoc={onOpenLinkedDoc} />
</span>
</div>