mirror of
https://github.com/backnotprop/plannotator.git
synced 2026-09-14 14:17:26 +08:00
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:
+24
-1
@@ -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 };
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user