UNPKG

@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
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 };