From b5e4d6573e562a812811bb0c2632affc00f1d2f4 Mon Sep 17 00:00:00 2001 From: Marko Djordjevic Date: Sat, 14 Feb 2026 10:14:17 +0100 Subject: [PATCH] feat: implement section 10 - hotkey label assignment for span annotations --- openspec/changes/span-annotation/tasks.md | 6 +- src/components/SpanAnnotationManager.tsx | 76 ++++++++++++++++++++--- 2 files changed, 70 insertions(+), 12 deletions(-) diff --git a/openspec/changes/span-annotation/tasks.md b/openspec/changes/span-annotation/tasks.md index 291784e..46ff32c 100644 --- a/openspec/changes/span-annotation/tasks.md +++ b/openspec/changes/span-annotation/tasks.md @@ -73,9 +73,9 @@ ## 10. Hotkey Label Assignment -- [ ] 10.1 Add keydown listener for span label hotkeys (active only when span tool is active and span range is selected) -- [ ] 10.2 On hotkey press: save span with mapped label (default confidence/outcome/notes), render rectangle, update sidebar — skip popover -- [ ] 10.3 Ignore hotkeys when span tool is inactive or no span range selected +- [x] 10.1 Add keydown listener for span label hotkeys (active only when span tool is active and span range is selected) +- [x] 10.2 On hotkey press: save span with mapped label (default confidence/outcome/notes), render rectangle, update sidebar — skip popover +- [x] 10.3 Ignore hotkeys when span tool is inactive or no span range selected ## 11. Export Endpoints diff --git a/src/components/SpanAnnotationManager.tsx b/src/components/SpanAnnotationManager.tsx index bfc6468..bb140bb 100644 --- a/src/components/SpanAnnotationManager.tsx +++ b/src/components/SpanAnnotationManager.tsx @@ -68,6 +68,7 @@ export default function SpanAnnotationManager({ const [interactionState, setInteractionState] = useState('idle'); const [startCandle, setStartCandle] = useState(null); const [endCandle, setEndCandle] = useState(null); + const [cursorCandle, setCursorCandle] = useState(null); const [previewPrimitive, setPreviewPrimitive] = useState(null); const [popoverOpen, setPopoverOpen] = useState(false); const [editingSpan, setEditingSpan] = useState(null); @@ -255,18 +256,21 @@ export default function SpanAnnotationManager({ if (!time) return; const timestamp = typeof time === 'string' ? Date.parse(time) / 1000 : (time as number); - const cursorCandle = findNearestCandle(timestamp); + const nearestCandle = findNearestCandle(timestamp); - if (!cursorCandle) return; + if (!nearestCandle) return; + + // Update cursor candle for hotkey usage + setCursorCandle(nearestCandle); // Calculate price range for preview - const { max_high, min_low } = calculatePriceRange(startCandle, cursorCandle); + const { max_high, min_low } = calculatePriceRange(startCandle, nearestCandle); // Swap if end < start const [start_time, end_time] = - startCandle.time <= cursorCandle.time - ? [startCandle.time, cursorCandle.time] - : [cursorCandle.time, startCandle.time]; + startCandle.time <= nearestCandle.time + ? [startCandle.time, nearestCandle.time] + : [nearestCandle.time, startCandle.time]; const previewData: SpanData = { id: -1, // Preview ID @@ -434,13 +438,14 @@ export default function SpanAnnotationManager({ setEditingSpan(null); }; - // Handle keyboard shortcuts for deletion and editing + // Handle keyboard shortcuts for deletion, editing, and hotkey label assignment useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { + const handleKeyDown = async (e: KeyboardEvent) => { // Delete/Backspace: delete selected span if ((e.key === 'Delete' || e.key === 'Backspace') && selectedSpanId !== null) { e.preventDefault(); handleDeleteSpan(selectedSpanId); + return; } // Enter: open edit popover for selected span @@ -450,12 +455,65 @@ export default function SpanAnnotationManager({ setEditingSpan(span); setPopoverOpen(true); } + return; + } + + // Hotkey label assignment: only when span tool is active and a span range is selected (after first click) + if (activeTool === 'span' && interactionState === 'first-click-done' && startCandle) { + // Check if key matches any label hotkey + const matchingLabel = spanLabelTypes.find((lt) => lt.hotkey === e.key && lt.is_active === 1); + + if (matchingLabel && activeChartId) { + e.preventDefault(); + + // Use cursor candle if available, otherwise use start candle + const endCandleForHotkey = cursorCandle || startCandle; + + // Swap if end < start + const [start_time, end_time] = + startCandle.time <= endCandleForHotkey.time + ? [startCandle.time, endCandleForHotkey.time] + : [endCandleForHotkey.time, startCandle.time]; + + // Create span with hotkey label (default confidence/outcome/notes) + try { + const response = await fetch('/api/span-annotations', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + chart_id: activeChartId, + start_time, + end_time, + label: matchingLabel.name, + confidence: 3, // Default confidence + outcome: null, + notes: null, + color: matchingLabel.color, + }), + }); + + if (response.ok) { + onSpanAnnotationsChange(); + + // Clean up preview and reset state + if (previewPrimitive && series) { + series.detachPrimitive(previewPrimitive); + setPreviewPrimitive(null); + } + setInteractionState('idle'); + setStartCandle(null); + setEndCandle(null); + } + } catch (error) { + console.error('Error creating span with hotkey:', error); + } + } } }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); - }, [selectedSpanId, spanAnnotations]); + }, [selectedSpanId, spanAnnotations, activeTool, interactionState, startCandle, cursorCandle, spanLabelTypes, activeChartId, previewPrimitive, series, onSpanAnnotationsChange]); // Handle double-click to edit span useEffect(() => {