From 2f05136f20d7921e90e045b56ea86a869be35fa9 Mon Sep 17 00:00:00 2001 From: Marko Djordjevic Date: Sat, 14 Feb 2026 10:11:51 +0100 Subject: [PATCH] feat: implement section 8 - span selection, editing, and deletion --- openspec/changes/span-annotation/tasks.md | 12 +- src/components/SpanAnnotationManager.tsx | 223 +++++++++++++++++++--- 2 files changed, 202 insertions(+), 33 deletions(-) diff --git a/openspec/changes/span-annotation/tasks.md b/openspec/changes/span-annotation/tasks.md index 5b46f5a..a4fa00e 100644 --- a/openspec/changes/span-annotation/tasks.md +++ b/openspec/changes/span-annotation/tasks.md @@ -54,12 +54,12 @@ ## 8. Span Selection, Editing & Deletion -- [ ] 8.1 Implement span click-to-select using hitTest: set selectedSpanId, highlight rectangle, scroll sidebar list to selected item -- [ ] 8.2 Implement click-to-deselect (click selected span again or click outside any span) -- [ ] 8.3 Implement double-click / Enter to open edit popover pre-populated with current span data -- [ ] 8.4 Wire edit Save: PATCH to API, update primitive color/label, update state -- [ ] 8.5 Implement Delete/Backspace keyboard shortcut for selected span: DELETE API call, remove primitive, clear selection, update state -- [ ] 8.6 Implement delete-tool click on span rectangle: same DELETE flow as keyboard shortcut +- [x] 8.1 Implement span click-to-select using hitTest: set selectedSpanId, highlight rectangle, scroll sidebar list to selected item +- [x] 8.2 Implement click-to-deselect (click selected span again or click outside any span) +- [x] 8.3 Implement double-click / Enter to open edit popover pre-populated with current span data +- [x] 8.4 Wire edit Save: PATCH to API, update primitive color/label, update state +- [x] 8.5 Implement Delete/Backspace keyboard shortcut for selected span: DELETE API call, remove primitive, clear selection, update state +- [x] 8.6 Implement delete-tool click on span rectangle: same DELETE flow as keyboard shortcut ## 9. Span Annotation Sidebar List diff --git a/src/components/SpanAnnotationManager.tsx b/src/components/SpanAnnotationManager.tsx index db10d1c..bfc6468 100644 --- a/src/components/SpanAnnotationManager.tsx +++ b/src/components/SpanAnnotationManager.tsx @@ -70,6 +70,7 @@ export default function SpanAnnotationManager({ const [endCandle, setEndCandle] = useState(null); const [previewPrimitive, setPreviewPrimitive] = useState(null); const [popoverOpen, setPopoverOpen] = useState(false); + const [editingSpan, setEditingSpan] = useState(null); const primitivesRef = useRef>(new Map()); // Find nearest candle to a timestamp @@ -139,45 +140,97 @@ export default function SpanAnnotationManager({ chart.timeScale().fitContent(); }, [spanAnnotations, selectedSpanId, series, chart, candles]); - // Handle clicks on chart for span tool + // Handle clicks on chart for span tool and span selection useEffect(() => { - if (!chart || !series || activeTool !== 'span') { - // Clean up preview if tool changes - if (previewPrimitive && series) { - series.detachPrimitive(previewPrimitive); - setPreviewPrimitive(null); - } + if (!chart || !series) return; + + // Clean up preview if tool changes away from span + if (activeTool !== 'span' && previewPrimitive) { + series.detachPrimitive(previewPrimitive); + setPreviewPrimitive(null); setInteractionState('idle'); setStartCandle(null); setEndCandle(null); - return; } const handleClick = (param: any) => { if (!param.point) return; const time = chart.timeScale().coordinateToTime(param.point.x); - if (!time) return; + const price = series.coordinateToPrice(param.point.y); + if (!time || !price) return; - const timestamp = typeof time === 'string' ? Date.parse(time) / 1000 : (time as number); - const nearestCandle = findNearestCandle(timestamp); + // Handle span tool two-click interaction + if (activeTool === 'span') { + const timestamp = typeof time === 'string' ? Date.parse(time) / 1000 : (time as number); + const nearestCandle = findNearestCandle(timestamp); - if (!nearestCandle) return; + if (!nearestCandle) return; - if (interactionState === 'idle') { - // First click: set start candle - setStartCandle(nearestCandle); - setInteractionState('first-click-done'); - } else if (interactionState === 'first-click-done') { - // Second click: set end candle and open popover - setEndCandle(nearestCandle); - setInteractionState('popover-open'); - setPopoverOpen(true); + if (interactionState === 'idle') { + // Check if clicking on existing span for selection + let clickedSpanId: number | null = null; + primitivesRef.current.forEach((primitive, spanId) => { + if (primitive.hitTest(param.point.x, param.point.y)) { + clickedSpanId = spanId; + } + }); - // Clean up preview primitive - if (previewPrimitive) { - series.detachPrimitive(previewPrimitive); - setPreviewPrimitive(null); + if (clickedSpanId !== null) { + // Select/deselect span + if (clickedSpanId === selectedSpanId) { + onSelectedSpanChange(null); + } else { + onSelectedSpanChange(clickedSpanId); + } + return; + } + + // First click: set start candle + setStartCandle(nearestCandle); + setInteractionState('first-click-done'); + } else if (interactionState === 'first-click-done') { + // Second click: set end candle and open popover + setEndCandle(nearestCandle); + setInteractionState('popover-open'); + setPopoverOpen(true); + + // Clean up preview primitive + if (previewPrimitive) { + series.detachPrimitive(previewPrimitive); + setPreviewPrimitive(null); + } + } + } else if (activeTool === 'delete') { + // Delete span on click with delete tool + let clickedSpanId: number | null = null; + primitivesRef.current.forEach((primitive, spanId) => { + if (primitive.hitTest(param.point.x, param.point.y)) { + clickedSpanId = spanId; + } + }); + + if (clickedSpanId !== null) { + handleDeleteSpan(clickedSpanId); + } + } else if (!activeTool) { + // Click to select span when no tool is active + let clickedSpanId: number | null = null; + primitivesRef.current.forEach((primitive, spanId) => { + if (primitive.hitTest(param.point.x, param.point.y)) { + clickedSpanId = spanId; + } + }); + + if (clickedSpanId !== null) { + if (clickedSpanId === selectedSpanId) { + onSelectedSpanChange(null); + } else { + onSelectedSpanChange(clickedSpanId); + } + } else { + // Deselect if clicking outside any span + onSelectedSpanChange(null); } } }; @@ -187,7 +240,7 @@ export default function SpanAnnotationManager({ return () => { chart.unsubscribeClick(handleClick); }; - }, [chart, series, activeTool, interactionState, candles, previewPrimitive]); + }, [chart, series, activeTool, interactionState, candles, previewPrimitive, selectedSpanId, onSelectedSpanChange]); // Handle mouse move for preview useEffect(() => { @@ -270,6 +323,26 @@ export default function SpanAnnotationManager({ return () => window.removeEventListener('keydown', handleKeyDown); }, [activeTool, interactionState, previewPrimitive, series]); + // Handle span deletion + const handleDeleteSpan = async (spanId: number) => { + try { + const response = await fetch(`/api/span-annotations/${spanId}`, { + method: 'DELETE', + }); + + if (response.ok) { + onSpanAnnotationsChange(); + if (selectedSpanId === spanId) { + onSelectedSpanChange(null); + } + } else { + console.error('Failed to delete span annotation'); + } + } catch (error) { + console.error('Error deleting span annotation:', error); + } + }; + // Handle popover save const handlePopoverSave = async (data: { label: string; @@ -277,6 +350,39 @@ export default function SpanAnnotationManager({ outcome: string | null; notes: string | null; }) => { + // If editing existing span + if (editingSpan) { + // Find label type color + const labelType = spanLabelTypes.find((t) => t.name === data.label); + const color = labelType?.color || editingSpan.color; + + try { + const response = await fetch(`/api/span-annotations/${editingSpan.id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + label: data.label, + confidence: data.confidence, + outcome: data.outcome, + notes: data.notes, + color, + }), + }); + + if (response.ok) { + onSpanAnnotationsChange(); + setPopoverOpen(false); + setEditingSpan(null); + } else { + console.error('Failed to update span annotation'); + } + } catch (error) { + console.error('Error updating span annotation:', error); + } + return; + } + + // Creating new span if (!startCandle || !endCandle || !activeChartId) return; // Swap if end < start @@ -325,15 +431,78 @@ export default function SpanAnnotationManager({ setInteractionState('idle'); setStartCandle(null); setEndCandle(null); + setEditingSpan(null); }; + // Handle keyboard shortcuts for deletion and editing + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + // Delete/Backspace: delete selected span + if ((e.key === 'Delete' || e.key === 'Backspace') && selectedSpanId !== null) { + e.preventDefault(); + handleDeleteSpan(selectedSpanId); + } + + // Enter: open edit popover for selected span + if (e.key === 'Enter' && selectedSpanId !== null) { + const span = spanAnnotations.find((s) => s.id === selectedSpanId); + if (span) { + setEditingSpan(span); + setPopoverOpen(true); + } + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [selectedSpanId, spanAnnotations]); + + // Handle double-click to edit span + useEffect(() => { + if (!chart) return; + + const handleDblClick = (param: any) => { + if (!param.point) return; + + let clickedSpanId: number | null = null; + primitivesRef.current.forEach((primitive, spanId) => { + if (primitive.hitTest(param.point.x, param.point.y)) { + clickedSpanId = spanId; + } + }); + + if (clickedSpanId !== null) { + const span = spanAnnotations.find((s) => s.id === clickedSpanId); + if (span) { + setEditingSpan(span); + setPopoverOpen(true); + } + } + }; + + chart.subscribeDblClick(handleDblClick); + + return () => { + chart.unsubscribeDblClick(handleDblClick); + }; + }, [chart, spanAnnotations]); + return (