@rockshin/react-image-annotation
Version:
An image annotation tool for ai project that manual annotation for images, easy to use!
544 lines (543 loc) • 25.5 kB
JavaScript
import * as __WEBPACK_EXTERNAL_MODULE_react_jsx_runtime_225474f2__ from "react/jsx-runtime";
import * as __WEBPACK_EXTERNAL_MODULE_react__ from "react";
import * as __WEBPACK_EXTERNAL_MODULE_tldraw__ from "tldraw";
import * as __WEBPACK_EXTERNAL_MODULE__loading_index_js_20c1c27f__ from "../loading/index.js";
import * as __WEBPACK_EXTERNAL_MODULE__components_custom_done_button_js_aed21166__ from "./_components/custom-done-button.js";
import * as __WEBPACK_EXTERNAL_MODULE__components_top_panel_index_js_046ebc34__ from "./_components/top-panel/index.js";
import "./tldraw-reset.css";
import * as __WEBPACK_EXTERNAL_MODULE__utils_js_d88b7fe1__ from "./utils.js";
function ImageAnnotationEditor({ images, tools, initialImageIndex = 0, outputTriggerOn, onDone, onAnnotationCreated, onAnnotationChange, onAnnotationDeleted, onImageChange, onImageLoadError }) {
if (0 === images.length) return /*#__PURE__*/ (0, __WEBPACK_EXTERNAL_MODULE_react_jsx_runtime_225474f2__.jsx)("div", {
children: "Please provided at least one image"
});
const { eraser, text } = tools || {};
const [imageShapeId, setImageShapeId] = (0, __WEBPACK_EXTERNAL_MODULE_react__.useState)(null);
const [editor, setEditor] = (0, __WEBPACK_EXTERNAL_MODULE_react__.useState)(null);
const [currentImageIndex, setCurrentImageIndex] = (0, __WEBPACK_EXTERNAL_MODULE_react__.useState)(initialImageIndex < images.length ? initialImageIndex : 0);
const [image, setImage] = (0, __WEBPACK_EXTERNAL_MODULE_react__.useState)(null);
const [usedNumbers, setUsedNumbers] = (0, __WEBPACK_EXTERNAL_MODULE_react__.useState)(new Set());
const [deletedNumbers, setDeletedNumbers] = (0, __WEBPACK_EXTERNAL_MODULE_react__.useState)([]);
const isChangingImage = (0, __WEBPACK_EXTERNAL_MODULE_react__.useRef)(false);
const lastChangeTimestamp = (0, __WEBPACK_EXTERNAL_MODULE_react__.useRef)(0);
const [isLoading, setIsLoading] = (0, __WEBPACK_EXTERNAL_MODULE_react__.useState)(false);
const [imageLoadError, setImageLoadError] = (0, __WEBPACK_EXTERNAL_MODULE_react__.useState)(null);
(0, __WEBPACK_EXTERNAL_MODULE_react__.useEffect)(()=>{
(0, __WEBPACK_EXTERNAL_MODULE__utils_js_d88b7fe1__.loadImageForIndex)({
imageIndex: currentImageIndex,
images,
setIsLoading,
setImageLoadError,
setImage,
setUsedNumbers,
setDeletedNumbers,
onImageLoadError
});
}, [
currentImageIndex,
images,
onImageLoadError
]);
function onMount(editor) {
setEditor(editor);
editor.setStyleForNextShapes(__WEBPACK_EXTERNAL_MODULE_tldraw__.DefaultColorStyle, 'red');
editor.setStyleForNextShapes(__WEBPACK_EXTERNAL_MODULE_tldraw__.DefaultSizeStyle, 's');
editor.setStyleForNextShapes(__WEBPACK_EXTERNAL_MODULE_tldraw__.DefaultFontStyle, 'mono');
}
(0, __WEBPACK_EXTERNAL_MODULE_react__.useEffect)(()=>{
if (!editor || !imageShapeId || !image) return;
images[currentImageIndex].annotations.forEach((annotation)=>{
const shapeId = annotation.id || (0, __WEBPACK_EXTERNAL_MODULE_tldraw__.createShapeId)();
editor.createShape({
id: shapeId,
type: 'geo',
x: annotation.x,
y: annotation.y,
rotation: annotation.rotation,
props: {
geo: 'rectangle',
w: annotation.width,
h: annotation.height,
text: annotation.label || '',
color: annotation.metadata?.color || 'default',
labelColor: annotation.metadata?.color || 'default'
}
});
});
}, [
editor,
imageShapeId,
image
]);
const handleOnDone = (0, __WEBPACK_EXTERNAL_MODULE_react__.useCallback)(async ()=>{
if (!editor || !image) return;
const currentAnnotations = (0, __WEBPACK_EXTERNAL_MODULE__utils_js_d88b7fe1__.getRectangleAnnotations)(editor);
onDone?.({
annotations: currentAnnotations,
image: {
id: image.id,
src: images[currentImageIndex].src
}
});
}, [
onDone,
editor,
image,
images,
currentImageIndex
]);
const generateShortId = ()=>{
if (deletedNumbers.length > 0) {
const nextNumber = Math.min(...deletedNumbers);
setDeletedNumbers((prev)=>prev.filter((n)=>n !== nextNumber));
setUsedNumbers((prev)=>{
const newSet = new Set(prev);
newSet.add(nextNumber);
return newSet;
});
return nextNumber.toString();
}
let nextNumber = 1;
while(usedNumbers.has(nextNumber))nextNumber++;
if (nextNumber > 999) {
console.warn('Exceeded maximum number of annotations (999)');
return '999';
}
setUsedNumbers((prev)=>{
const newSet = new Set(prev);
newSet.add(nextNumber);
return newSet;
});
return nextNumber.toString();
};
(0, __WEBPACK_EXTERNAL_MODULE_react__.useEffect)(()=>{
if (!editor) return;
let creatingShapeId = null;
const handlePointerUp = ()=>{
if (creatingShapeId) {
const shape = editor.getShape(creatingShapeId);
if (shape && 'geo' === shape.type && 'rectangle' === shape.props.geo) {
const newId = generateShortId();
editor.updateShape({
id: shape.id,
type: 'geo',
props: {
...shape.props,
labelColor: 'red',
text: newId
}
});
const annotation = {
id: shape.id,
x: shape.x,
y: shape.y,
width: shape.props.w,
height: shape.props.h,
rotation: shape.rotation || 0,
label: newId,
timestamp: Date.now(),
metadata: {
color: shape.props.color,
createdBy: 'user',
modifiedAt: Date.now(),
version: 1,
tags: [],
isVerified: false
}
};
onAnnotationCreated?.({
image: {
id: image?.id
},
annotation
});
if (outputTriggerOn?.created) handleOnDone();
}
creatingShapeId = null;
}
};
const handleShapeCreated = (shape)=>{
const internalShape = shape;
if ('geo' === internalShape.type && 'rectangle' === internalShape.props.geo) creatingShapeId = internalShape.id;
};
const handleShapeChange = async (eventType, options)=>{
if (isChangingImage.current) return;
const { prev } = options || {};
const shape = prev;
if (!prev || prev.id === creatingShapeId) return;
const currentTime = Date.now();
if ('change' === eventType && currentTime - lastChangeTimestamp.current < 50) return;
lastChangeTimestamp.current = currentTime;
const existingAnnotation = images[currentImageIndex].annotations.find((a)=>a.id === shape.id);
const annotation = {
id: shape.id,
x: shape.x,
y: shape.y,
width: shape.props.w,
height: shape.props.h,
rotation: shape.rotation,
label: shape.props.text,
timestamp: Date.now(),
metadata: {
...existingAnnotation?.metadata || {},
color: shape.props.color,
modifiedAt: Date.now(),
createdBy: existingAnnotation?.metadata?.createdBy || 'user',
version: existingAnnotation?.metadata?.version || 1,
tags: existingAnnotation?.metadata?.tags || [],
isVerified: existingAnnotation?.metadata?.isVerified ?? false
}
};
if ('delete' === eventType) {
const deletedNumber = parseInt(shape.props.text);
if (!isNaN(deletedNumber)) {
setDeletedNumbers((prev)=>[
...prev,
deletedNumber
].sort((a, b)=>a - b));
setUsedNumbers((prev)=>{
const newSet = new Set(prev);
newSet.delete(deletedNumber);
return newSet;
});
}
onAnnotationDeleted?.({
image: {
id: image?.id
},
annotation
});
if (outputTriggerOn?.deleted) handleOnDone();
} else if ('change' === eventType) {
onAnnotationChange?.({
image: {
id: image?.id
},
annotation
});
if (outputTriggerOn?.changed) handleOnDone();
}
};
const debouncedDeleteHandler = (0, __WEBPACK_EXTERNAL_MODULE_tldraw__.debounce)(()=>{
handleOnDone();
}, 50);
const debouncedHandler = (0, __WEBPACK_EXTERNAL_MODULE_tldraw__.debounce)(handleShapeChange, 100);
const removeCreateHandler = editor.sideEffects.registerAfterCreateHandler('shape', handleShapeCreated);
const removeChangeHandler = editor.sideEffects.registerAfterChangeHandler('shape', (prev, next)=>debouncedHandler('change', {
prev,
next
}));
const removeDeleteHandler = editor.sideEffects.registerAfterDeleteHandler('shape', (prev)=>handleShapeChange('delete', {
prev
}));
editor.on('event', (e)=>{
if ('pointer_up' === e.name) handlePointerUp();
});
return ()=>{
removeCreateHandler();
removeChangeHandler();
removeDeleteHandler();
debouncedDeleteHandler.cancel();
debouncedHandler.cancel();
};
}, [
editor,
imageShapeId,
__WEBPACK_EXTERNAL_MODULE__utils_js_d88b7fe1__.getRectangleAnnotations,
handleOnDone,
generateShortId
]);
(0, __WEBPACK_EXTERNAL_MODULE_react__.useEffect)(()=>{
if (!editor || !image) return;
const assetId = __WEBPACK_EXTERNAL_MODULE_tldraw__.AssetRecordType.createId();
editor.createAssets([
{
id: assetId,
typeName: 'asset',
type: 'image',
meta: {},
props: {
w: image.width,
h: image.height,
mimeType: image.type,
src: image.src,
name: `image-${assetId}`,
isAnimated: false
}
}
]);
const shapeId = (0, __WEBPACK_EXTERNAL_MODULE_tldraw__.createShapeId)();
editor.createShape({
id: shapeId,
type: 'image',
x: 0,
y: 0,
isLocked: true,
props: {
w: image.width,
h: image.height,
assetId
}
});
const removeOnCreate = editor.sideEffects.registerAfterCreateHandler('shape', ()=>(0, __WEBPACK_EXTERNAL_MODULE__utils_js_d88b7fe1__.makeSureShapeIsAtBottom)(editor, shapeId));
const removeOnChange = editor.sideEffects.registerAfterChangeHandler('shape', ()=>(0, __WEBPACK_EXTERNAL_MODULE__utils_js_d88b7fe1__.makeSureShapeIsAtBottom)(editor, shapeId));
const cleanupKeepShapeLocked = editor.sideEffects.registerBeforeChangeHandler('shape', (prev, next)=>{
if (next.id !== shapeId) return next;
if (next.isLocked) return next;
return {
...prev,
isLocked: true
};
});
editor.clearHistory();
setImageShapeId(shapeId);
return ()=>{
removeOnChange();
removeOnCreate();
cleanupKeepShapeLocked();
};
}, [
editor,
image?.id
]);
(0, __WEBPACK_EXTERNAL_MODULE_react__.useEffect)(()=>{
if (!editor || !image || !imageShapeId) return;
editor.setCameraOptions({
constraints: {
initialZoom: 'fit-max-100',
baseZoom: 'default',
bounds: {
w: image.width,
h: image.height,
x: 0,
y: 0
},
padding: {
x: 16,
y: 16
},
origin: {
x: 0.5,
y: 0.5
},
behavior: 'free'
},
zoomSteps: [
0.5,
1,
2,
4,
8
],
zoomSpeed: 1,
panSpeed: 1,
isLocked: false
});
editor.setCamera(editor.getCamera(), {
reset: true
});
}, [
editor,
imageShapeId,
image
]);
const changeImage = (0, __WEBPACK_EXTERNAL_MODULE_react__.useCallback)((direction)=>{
if (images.length <= 1 || !editor) return;
isChangingImage.current = true;
(0, __WEBPACK_EXTERNAL_MODULE__utils_js_d88b7fe1__.cleanUpEditor)(editor);
setCurrentImageIndex((prev)=>{
const newIndex = 'prev' === direction ? 0 === prev ? images.length - 1 : prev - 1 : prev === images.length - 1 ? 0 : prev + 1;
return newIndex;
});
setImageShapeId(null);
setTimeout(()=>{
isChangingImage.current = false;
}, 100);
}, [
editor,
images.length
]);
const prevImage = (0, __WEBPACK_EXTERNAL_MODULE_react__.useCallback)(()=>changeImage('prev'), [
changeImage
]);
const nextImage = (0, __WEBPACK_EXTERNAL_MODULE_react__.useCallback)(()=>changeImage('next'), [
changeImage
]);
(0, __WEBPACK_EXTERNAL_MODULE_react__.useEffect)(()=>{
onImageChange?.({
index: currentImageIndex,
image: {
id: images[currentImageIndex].id || '',
src: images[currentImageIndex].src
}
});
}, [
currentImageIndex
]);
(0, __WEBPACK_EXTERNAL_MODULE_react__.useEffect)(()=>{
if (isChangingImage.current && editor && image) {
if (outputTriggerOn?.navigated) {
(0, __WEBPACK_EXTERNAL_MODULE__utils_js_d88b7fe1__.cleanUpEditor)(editor);
setTimeout(()=>{
handleOnDone();
}, 100);
}
}
}, [
currentImageIndex,
outputTriggerOn?.navigated
]);
(0, __WEBPACK_EXTERNAL_MODULE_react__.useEffect)(()=>{
if (isChangingImage.current) {
setImage(null);
(async ()=>{
try {
setIsLoading(true);
setImageLoadError(null);
await (0, __WEBPACK_EXTERNAL_MODULE__utils_js_d88b7fe1__.loadImageForIndex)({
imageIndex: currentImageIndex,
images,
setIsLoading,
setImageLoadError,
setImage,
setUsedNumbers,
setDeletedNumbers,
onImageLoadError
});
} catch (error) {} finally{
setIsLoading(false);
}
})();
}
}, [
currentImageIndex,
images,
onImageLoadError,
isChangingImage.current
]);
return /*#__PURE__*/ (0, __WEBPACK_EXTERNAL_MODULE_react_jsx_runtime_225474f2__.jsxs)("div", {
className: "absolute inset-0",
children: [
imageLoadError && /*#__PURE__*/ (0, __WEBPACK_EXTERNAL_MODULE_react_jsx_runtime_225474f2__.jsx)("div", {
className: "absolute inset-0 flex items-center justify-center bg-gray-50 z-50",
children: /*#__PURE__*/ (0, __WEBPACK_EXTERNAL_MODULE_react_jsx_runtime_225474f2__.jsxs)("div", {
className: "text-center p-4",
children: [
/*#__PURE__*/ (0, __WEBPACK_EXTERNAL_MODULE_react_jsx_runtime_225474f2__.jsxs)("div", {
className: "text-red-600 mb-4",
children: [
/*#__PURE__*/ (0, __WEBPACK_EXTERNAL_MODULE_react_jsx_runtime_225474f2__.jsx)("span", {
className: "mr-2",
children: `Failed to load image ${currentImageIndex + 1} of ${images.length}:`
}),
/*#__PURE__*/ (0, __WEBPACK_EXTERNAL_MODULE_react_jsx_runtime_225474f2__.jsx)("span", {
className: "text-gray-600",
children: imageLoadError.message
})
]
}),
/*#__PURE__*/ (0, __WEBPACK_EXTERNAL_MODULE_react_jsx_runtime_225474f2__.jsxs)("div", {
className: "space-x-2",
children: [
/*#__PURE__*/ (0, __WEBPACK_EXTERNAL_MODULE_react_jsx_runtime_225474f2__.jsx)("button", {
onClick: ()=>{
(0, __WEBPACK_EXTERNAL_MODULE__utils_js_d88b7fe1__.loadImageForIndex)({
imageIndex: currentImageIndex,
images,
setIsLoading,
setImageLoadError,
setImage,
setUsedNumbers,
setDeletedNumbers,
onImageLoadError
});
},
className: "px-4 py-2 rounded-3xl border border-blue-500 text-blue-500 hover:bg-blue-50 leading-[24px]",
children: "Retry"
}),
currentImageIndex > 0 && /*#__PURE__*/ (0, __WEBPACK_EXTERNAL_MODULE_react_jsx_runtime_225474f2__.jsx)("button", {
onClick: ()=>prevImage(),
className: "px-4 py-2 bg-blue-500 text-white rounded-3xl hover:bg-blue-600",
children: "Prev Image"
}),
currentImageIndex < images.length - 1 && /*#__PURE__*/ (0, __WEBPACK_EXTERNAL_MODULE_react_jsx_runtime_225474f2__.jsx)("button", {
onClick: ()=>nextImage(),
className: "px-4 py-2 bg-blue-500 text-white rounded-3xl hover:bg-blue-600",
children: "Next Images"
})
]
})
]
})
}),
isLoading && /*#__PURE__*/ (0, __WEBPACK_EXTERNAL_MODULE_react_jsx_runtime_225474f2__.jsx)("div", {
className: "absolute top-12 bottom-12 left-0 right-0 flex items-center justify-center z-50",
children: /*#__PURE__*/ (0, __WEBPACK_EXTERNAL_MODULE_react_jsx_runtime_225474f2__.jsx)(__WEBPACK_EXTERNAL_MODULE__loading_index_js_20c1c27f__["default"], {
isLoading: true,
loadingText: `Loading image ${currentImageIndex + 1} of ${images.length}...`,
className: "w-[300px] bg-gradient-to-r from-blue-50 to-sky-50",
loadingSpinnerClassName: "border-blue-500",
progressBarClassName: "bg-gradient-to-r from-blue-500 to-sky-500"
})
}),
/*#__PURE__*/ (0, __WEBPACK_EXTERNAL_MODULE_react_jsx_runtime_225474f2__.jsx)(__WEBPACK_EXTERNAL_MODULE_tldraw__.Tldraw, {
onMount: onMount,
components: {
Toolbar: (props)=>{
const tools = (0, __WEBPACK_EXTERNAL_MODULE_tldraw__.useTools)();
const isCardSelected = (0, __WEBPACK_EXTERNAL_MODULE_tldraw__.useIsToolSelected)(tools['select']);
const isRectangleSelected = (0, __WEBPACK_EXTERNAL_MODULE_tldraw__.useIsToolSelected)(tools['rectangle']);
const isTextSelected = (0, __WEBPACK_EXTERNAL_MODULE_tldraw__.useIsToolSelected)(tools['text']);
const isEraserSelected = (0, __WEBPACK_EXTERNAL_MODULE_tldraw__.useIsToolSelected)(tools['eraser']);
const isHandSelected = (0, __WEBPACK_EXTERNAL_MODULE_tldraw__.useIsToolSelected)(tools['hand']);
return /*#__PURE__*/ (0, __WEBPACK_EXTERNAL_MODULE_react_jsx_runtime_225474f2__.jsxs)(__WEBPACK_EXTERNAL_MODULE_tldraw__.DefaultToolbar, {
...props,
children: [
/*#__PURE__*/ (0, __WEBPACK_EXTERNAL_MODULE_react_jsx_runtime_225474f2__.jsx)(__WEBPACK_EXTERNAL_MODULE_tldraw__.TldrawUiMenuItem, {
...tools['select'],
isSelected: isCardSelected
}),
/*#__PURE__*/ (0, __WEBPACK_EXTERNAL_MODULE_react_jsx_runtime_225474f2__.jsx)(__WEBPACK_EXTERNAL_MODULE_tldraw__.TldrawUiMenuItem, {
...tools['hand'],
isSelected: isHandSelected
}),
eraser?.enabled && /*#__PURE__*/ (0, __WEBPACK_EXTERNAL_MODULE_react_jsx_runtime_225474f2__.jsx)(__WEBPACK_EXTERNAL_MODULE_tldraw__.TldrawUiMenuItem, {
...tools['eraser'],
isSelected: isEraserSelected
}),
text?.enabled && /*#__PURE__*/ (0, __WEBPACK_EXTERNAL_MODULE_react_jsx_runtime_225474f2__.jsx)(__WEBPACK_EXTERNAL_MODULE_tldraw__.TldrawUiMenuItem, {
...tools['text'],
isSelected: isTextSelected
}),
/*#__PURE__*/ (0, __WEBPACK_EXTERNAL_MODULE_react_jsx_runtime_225474f2__.jsx)(__WEBPACK_EXTERNAL_MODULE_tldraw__.TldrawUiMenuItem, {
...tools['rectangle'],
isSelected: isRectangleSelected
})
]
});
},
PageMenu: null,
ActionsMenu: null,
StylePanel: null,
TopPanel: (0, __WEBPACK_EXTERNAL_MODULE_react__.useCallback)(()=>/*#__PURE__*/ (0, __WEBPACK_EXTERNAL_MODULE_react_jsx_runtime_225474f2__.jsx)(__WEBPACK_EXTERNAL_MODULE__components_top_panel_index_js_046ebc34__.TopPanel, {
onPrevious: prevImage,
onNext: nextImage,
currentIndex: currentImageIndex + 1,
totalCount: images.length
}), [
imageShapeId,
onDone,
currentImageIndex,
images.length
]),
SharePanel: (0, __WEBPACK_EXTERNAL_MODULE_react__.useCallback)(()=>/*#__PURE__*/ (0, __WEBPACK_EXTERNAL_MODULE_react_jsx_runtime_225474f2__.jsx)(__WEBPACK_EXTERNAL_MODULE__components_custom_done_button_js_aed21166__.CustomDoneButton, {
onClick: handleOnDone
}), [
imageShapeId,
__WEBPACK_EXTERNAL_MODULE__components_custom_done_button_js_aed21166__.CustomDoneButton
])
}
})
]
});
}
export { ImageAnnotationEditor };