Skip to content

Commit 7530f67

Browse files
committed
feat(web): add graph annotations feature (task-10)
- Add GraphAnnotationStorage interface to catalogue-db.ts - Add annotations table via v4 migration - Add annotation CRUD methods to CatalogueService - Create useGraphAnnotations hook for state management - Create GraphAnnotationLayer component for SVG rendering - Create AnnotationToolbar component for drawing tools - Create GraphAnnotations main component - Integrate annotations into graph page with toggle button - Support text labels, shapes (rectangles, circles), and freehand drawings - Annotations stored in IndexedDB for persistence - Share via graph snapshots (URL-encoded in future task)
1 parent 1943940 commit 7530f67

File tree

8 files changed

+1597
-0
lines changed

8 files changed

+1597
-0
lines changed
Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
/**
2+
* Annotation Toolbar
3+
*
4+
* Toolbar for creating and managing graph annotations.
5+
* Provides tools for text labels, shapes, and freehand drawings.
6+
*
7+
* @module components/graph/annotations/AnnotationToolbar
8+
*/
9+
10+
import {
11+
ActionIcon,
12+
Badge,
13+
Group,
14+
Popover,
15+
Stack,
16+
Text,
17+
TextInput,
18+
Tooltip,
19+
} from '@mantine/core';
20+
import {
21+
IconCircle,
22+
IconPencil,
23+
IconRectangle,
24+
IconSticker,
25+
IconTrash,
26+
IconX,
27+
} from '@tabler/icons-react';
28+
import { useState } from 'react';
29+
30+
import { ICON_SIZE } from '@/config/style-constants';
31+
32+
export type DrawingTool = 'select' | 'text' | 'rectangle' | 'circle' | 'drawing' | 'erase';
33+
34+
interface DrawingToolOption {
35+
icon: React.ReactNode;
36+
label: string;
37+
tool: DrawingTool;
38+
}
39+
40+
const DRAWING_TOOLS: DrawingToolOption[] = [
41+
{
42+
icon: <IconSticker size={ICON_SIZE.SM} />,
43+
label: 'Text Label',
44+
tool: 'text',
45+
},
46+
{
47+
icon: <IconRectangle size={ICON_SIZE.SM} />,
48+
label: 'Rectangle',
49+
tool: 'rectangle',
50+
},
51+
{
52+
icon: <IconCircle size={ICON_SIZE.SM} />,
53+
label: 'Circle',
54+
tool: 'circle',
55+
},
56+
{
57+
icon: <IconPencil size={ICON_SIZE.SM} />,
58+
label: 'Freehand',
59+
tool: 'drawing',
60+
},
61+
];
62+
63+
interface AnnotationToolbarProps {
64+
activeTool: DrawingTool;
65+
onToolChange: (tool: DrawingTool) => void;
66+
annotationCount: number;
67+
onClearAll?: () => void;
68+
}
69+
70+
/**
71+
* Annotation toolbar component
72+
* @param root0
73+
* @param root0.activeTool
74+
* @param root0.onToolChange
75+
* @param root0.annotationCount
76+
* @param root0.onClearAll
77+
*/
78+
export const AnnotationToolbar: React.FC<AnnotationToolbarProps> = ({
79+
activeTool,
80+
onToolChange,
81+
annotationCount,
82+
onClearAll,
83+
}) => {
84+
return (
85+
<Group gap="xs" bg="white" p="xs" style={{ borderRadius: '8px', border: '1px solid #e9ecef' }}>
86+
{DRAWING_TOOLS.map((tool) => (
87+
<Tooltip key={tool.label} label={tool.label}>
88+
<ActionIcon
89+
variant={activeTool === tool.tool ? 'filled' : 'light'}
90+
color={activeTool === tool.tool ? 'blue' : 'gray'}
91+
onClick={() => onToolChange(tool.tool)}
92+
aria-label={tool.label}
93+
>
94+
{tool.icon}
95+
</ActionIcon>
96+
</Tooltip>
97+
))}
98+
99+
<div style={{ width: 1, height: 24, backgroundColor: '#dee2e6', margin: '0 4px' }} />
100+
101+
{annotationCount > 0 && (
102+
<Badge size="sm" variant="light">
103+
{annotationCount}
104+
</Badge>
105+
)}
106+
107+
{onClearAll && annotationCount > 0 && (
108+
<Tooltip label="Clear All Annotations">
109+
<ActionIcon
110+
variant="subtle"
111+
color="red"
112+
onClick={onClearAll}
113+
aria-label="Clear all annotations"
114+
>
115+
<IconTrash size={ICON_SIZE.SM} />
116+
</ActionIcon>
117+
</Tooltip>
118+
)}
119+
</Group>
120+
);
121+
};
122+
123+
/**
124+
* Text annotation popover for entering label text
125+
*/
126+
interface TextAnnotationPopoverProps {
127+
opened: boolean;
128+
onClose: () => void;
129+
onSubmit: (text: string) => void;
130+
position: { x: number; y: number };
131+
}
132+
133+
export const TextAnnotationPopover: React.FC<TextAnnotationPopoverProps> = ({
134+
opened,
135+
onClose,
136+
onSubmit,
137+
position,
138+
}) => {
139+
const [text, setText] = useState('');
140+
141+
const handleSubmit = () => {
142+
if (text.trim()) {
143+
onSubmit(text.trim());
144+
setText('');
145+
onClose();
146+
}
147+
};
148+
149+
return (
150+
<Popover
151+
opened={opened}
152+
onClose={onClose}
153+
position="bottom"
154+
withArrow
155+
shadow="md"
156+
>
157+
<Popover.Target>
158+
<div style={{ position: 'absolute', left: position.x, top: position.y, visibility: 'hidden' }} />
159+
</Popover.Target>
160+
161+
<Popover.Dropdown>
162+
<Stack gap="xs">
163+
<Text size="sm" fw={500}>
164+
Add Text Label
165+
</Text>
166+
<TextInput
167+
placeholder="Enter text..."
168+
value={text}
169+
onChange={(e) => setText(e.currentTarget.value)}
170+
onKeyDown={(e) => {
171+
if (e.key === 'Enter') {
172+
handleSubmit();
173+
} else if (e.key === 'Escape') {
174+
onClose();
175+
}
176+
e.stopPropagation();
177+
}}
178+
size="xs"
179+
/>
180+
<Group justify="flex-end" gap="xs">
181+
<ActionIcon
182+
size="sm"
183+
variant="subtle"
184+
color="gray"
185+
onClick={onClose}
186+
>
187+
<IconX size={ICON_SIZE.XS} />
188+
</ActionIcon>
189+
<ActionIcon
190+
size="sm"
191+
variant="filled"
192+
color="blue"
193+
onClick={handleSubmit}
194+
disabled={!text.trim()}
195+
>
196+
197+
</ActionIcon>
198+
</Group>
199+
</Stack>
200+
</Popover.Dropdown>
201+
</Popover>
202+
);
203+
};

0 commit comments

Comments
 (0)