Skip to content

Commit b8520c7

Browse files
feat(Annotator): image annotator for drawing boxes with labels
1 parent 407bae4 commit b8520c7

File tree

7 files changed

+802
-0
lines changed

7 files changed

+802
-0
lines changed

assets/samples/sample_form.png

148 KB
Loading
Lines changed: 251 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,251 @@
1+
/*
2+
* React Fabric
3+
* @version: 1.0.0
4+
*
5+
*
6+
* The MIT License (MIT)
7+
* Copyright (c) 2025 Adarsh Pastakia
8+
*
9+
* Permission is hereby granted, free of charge, to any person obtaining a copy of this software
10+
* and associated documentation files (the "Software"), to deal in the Software without restriction,
11+
* including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense,
12+
* and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so,
13+
* subject to the following conditions:
14+
*
15+
* The above copyright notice and this permission notice shall be included in all copies or substantial
16+
* portions of the Software.
17+
*
18+
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
19+
* TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
20+
* THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
21+
* TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
22+
*/
23+
24+
import { CoreIcons, Icon } from "@react-fabric/core";
25+
import { getBox } from "@react-fabric/utilities";
26+
import classNames from "classnames";
27+
import { useCallback, useEffect, useMemo, useReducer } from "react";
28+
29+
export interface AnnotationItem<T = KeyValue> {
30+
box: string | [number, number, number, number];
31+
label?: string;
32+
color?: string;
33+
data?: T;
34+
}
35+
36+
export interface AnnotationProps extends AnnotationItem {
37+
index: number;
38+
ratio: number;
39+
maxWidth: number;
40+
maxHeight: number;
41+
onChange: (box: [number, number, number, number]) => void;
42+
onRemove: () => void;
43+
}
44+
45+
interface DragState {
46+
dragging: boolean;
47+
startPoint: [number, number];
48+
mode: "move" | "x" | "y" | "w" | "h" | "xy" | "xh" | "yw" | "wh";
49+
pos: { left: number; top: number; width: number; height: number };
50+
startPos: { left: number; top: number; width: number; height: number };
51+
}
52+
53+
type DragAction =
54+
| {
55+
type: "dragstart";
56+
mode: DragState["mode"];
57+
startPoint: [number, number];
58+
defaultPos: DragState["pos"];
59+
}
60+
| { type: "dragend"; cancelled?: boolean }
61+
| { type: DragState["mode"]; point: [number, number] };
62+
63+
export const Annotation = ({
64+
box,
65+
label,
66+
index,
67+
ratio,
68+
maxWidth,
69+
maxHeight,
70+
onChange,
71+
onRemove,
72+
color = "var(--color-marigold)",
73+
}: AnnotationProps) => {
74+
const defaultPos = useMemo(() => {
75+
const [left, top, width, height] = getBox(box);
76+
return {
77+
left: left * ratio,
78+
top: top * ratio,
79+
width: width * ratio,
80+
height: height * ratio,
81+
};
82+
}, [box, ratio]);
83+
84+
const [state, dispatch] = useReducer(
85+
(state: DragState, action: DragAction) => {
86+
if (action.type === "dragstart") {
87+
state.dragging = true;
88+
state.mode = action.mode;
89+
state.pos = { ...action.defaultPos };
90+
state.startPos = { ...action.defaultPos };
91+
state.startPoint = action.startPoint;
92+
}
93+
if (action.type === "dragend") {
94+
state.dragging = false;
95+
}
96+
if (action.type === "move") {
97+
const diffX = action.point[0] - state.startPoint[0];
98+
const diffY = action.point[1] - state.startPoint[1];
99+
state.pos.top = state.startPos.top + diffY;
100+
state.pos.left = state.startPos.left + diffX;
101+
}
102+
if (action.type === "x" || action.type === "xh" || action.type === "xy") {
103+
const diffX = action.point[0] - state.startPoint[0];
104+
state.pos.left = state.startPos.left + diffX;
105+
state.pos.left >= 0 && (state.pos.width = state.startPos.width - diffX);
106+
}
107+
if (action.type === "y" || action.type === "xy" || action.type === "yw") {
108+
const diffY = action.point[1] - state.startPoint[1];
109+
state.pos.top = state.startPos.top + diffY;
110+
state.pos.top && (state.pos.height = state.startPos.height - diffY);
111+
}
112+
if (action.type === "w" || action.type === "yw" || action.type === "wh") {
113+
const diffX = action.point[0] - state.startPoint[0];
114+
state.pos.width = state.startPos.width + diffX;
115+
}
116+
if (action.type === "h" || action.type === "xh" || action.type === "wh") {
117+
const diffY = action.point[1] - state.startPoint[1];
118+
state.pos.height = state.startPos.height + diffY;
119+
}
120+
state.pos.top = Math.max(state.pos.top, 0);
121+
state.pos.left = Math.max(state.pos.left, 0);
122+
state.pos.width = Math.max(state.pos.width, 16);
123+
state.pos.height = Math.max(state.pos.height, 16);
124+
if (state.pos.top + state.pos.height > maxHeight)
125+
state.pos.height = maxHeight - state.pos.top;
126+
if (state.pos.left + state.pos.width > maxWidth)
127+
state.pos.width = maxWidth - state.pos.left;
128+
return { ...state };
129+
},
130+
{
131+
dragging: false,
132+
} as any,
133+
);
134+
135+
const pos = useMemo(
136+
() => (state.dragging ? state.pos : defaultPos),
137+
[state.dragging, state.pos, defaultPos],
138+
);
139+
140+
const startDragging = useCallback(
141+
(mode: DragState["mode"], e: React.MouseEvent) => {
142+
e.stopPropagation();
143+
e.preventDefault();
144+
dispatch({
145+
type: "dragstart",
146+
mode,
147+
defaultPos,
148+
startPoint: [e.clientX, e.clientY],
149+
});
150+
},
151+
[defaultPos],
152+
);
153+
154+
useEffect(() => {
155+
const handleMove = (e: MouseEvent) => {
156+
dispatch({ type: state.mode, point: [e.clientX, e.clientY] });
157+
};
158+
const handleMouseUp = (e: MouseEvent) => {
159+
dispatch({ type: "dragend" });
160+
const { top, left, width, height } = state.pos;
161+
onChange?.([left, top, width, height]);
162+
};
163+
const handleEscape = (e: KeyboardEvent) => {
164+
e.code === "Escape" && dispatch({ type: "dragend", cancelled: true });
165+
};
166+
state.dragging && document.addEventListener("mousemove", handleMove);
167+
state.dragging && document.addEventListener("mouseup", handleMouseUp);
168+
state.dragging && document.addEventListener("keydown", handleEscape);
169+
170+
return () => {
171+
document.removeEventListener("mousemove", handleMove);
172+
document.removeEventListener("mouseup", handleMouseUp);
173+
document.removeEventListener("keydown", handleEscape);
174+
};
175+
}, [state.dragging, state.mode, onChange]);
176+
177+
return (
178+
<div
179+
role="none"
180+
className={classNames(
181+
"outline-2 -outline-offset-1 outline-current absolute group cursor-move hover:z-10",
182+
"before:absolute before:inset-0 before:bg-current",
183+
state.dragging ? "before:opacity-30" : "before:opacity-10",
184+
)}
185+
style={{ ...pos, color }}
186+
onMouseDown={(e) => startDragging("move", e)}
187+
>
188+
<button
189+
className="bg-danger text-white cursor-pointer leading-none p-0.5 text-sm opacity-0 group-hover:opacity-60 hover:opacity-90 absolute top-0 right-0"
190+
onMouseDown={(e) => [e.stopPropagation(), onRemove()]}
191+
>
192+
<Icon icon={CoreIcons.trash} />
193+
</button>
194+
<div
195+
className="absolute left-0 bottom-full max-w-full truncate select-none pointer-events-none text-xs bg-current font-medium leading-tight px-0.5"
196+
style={{
197+
color,
198+
}}
199+
>
200+
<span
201+
className="mix-blend-luminosity"
202+
style={{
203+
filter: "invert(1) grayscale(1) brightness(1.3) contrast(9000)",
204+
}}
205+
>
206+
{label ?? index}
207+
</span>
208+
</div>
209+
<div
210+
role="none"
211+
onMouseDown={(e) => startDragging("y", e)}
212+
className="absolute top-0 inset-x-0 h-2 cursor-ns-resize"
213+
/>
214+
<div
215+
role="none"
216+
onMouseDown={(e) => startDragging("h", e)}
217+
className="absolute bottom-0 inset-x-0 h-2 cursor-ns-resize"
218+
/>
219+
<div
220+
role="none"
221+
onMouseDown={(e) => startDragging("x", e)}
222+
className="absolute left-0 inset-y-0 w-2 cursor-ew-resize"
223+
/>
224+
<div
225+
role="none"
226+
onMouseDown={(e) => startDragging("w", e)}
227+
className="absolute right-0 inset-y-0 w-2 cursor-ew-resize"
228+
/>
229+
<div
230+
role="none"
231+
onMouseDown={(e) => startDragging("xy", e)}
232+
className="absolute top-0 left-0 size-2 cursor-nwse-resize border-t-2 border-l-2 border-black/90"
233+
/>
234+
<div
235+
role="none"
236+
onMouseDown={(e) => startDragging("xh", e)}
237+
className="absolute bottom-0 left-0 size-2 cursor-nesw-resize border-b-2 border-l-2 border-black/90"
238+
/>
239+
<div
240+
role="none"
241+
onMouseDown={(e) => startDragging("yw", e)}
242+
className="absolute top-0 right-0 size-2 cursor-nesw-resize border-t-2 border-r-2 border-black/90"
243+
/>
244+
<div
245+
role="none"
246+
onMouseDown={(e) => startDragging("wh", e)}
247+
className="absolute bottom-0 right-0 size-2 cursor-nwse-resize border-b-2 border-r-2 border-black/90"
248+
/>
249+
</div>
250+
);
251+
};
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
/*
2+
* React Fabric
3+
* @version: 1.0.0
4+
*
5+
*
6+
* The MIT License (MIT)
7+
* Copyright (c) 2025 Adarsh Pastakia
8+
*
9+
* Permission is hereby granted, free of charge, to any person obtaining a copy of this software
10+
* and associated documentation files (the "Software"), to deal in the Software without restriction,
11+
* including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense,
12+
* and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so,
13+
* subject to the following conditions:
14+
*
15+
* The above copyright notice and this permission notice shall be included in all copies or substantial
16+
* portions of the Software.
17+
*
18+
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
19+
* TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
20+
* THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
21+
* TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
22+
*/
23+
24+
import { Section } from "@react-fabric/core";
25+
import { type AnnotatorProps, AnnotatorProvider } from "./Context";
26+
27+
export const Annotator = <T extends KeyValue = KeyValue>(
28+
props: AnnotatorProps<T>,
29+
) => {
30+
return (
31+
<Section dir="ltr">
32+
<div className="area-content grid place-items-center overflow-auto p-2">
33+
<AnnotatorProvider {...props} />
34+
</div>
35+
</Section>
36+
);
37+
};

0 commit comments

Comments
 (0)