feat(devtool): ImageAnnotation 页面

用于标注图像上的模板
This commit is contained in:
XcantloadX 2025-01-31 19:23:06 +08:00
parent e25731cd12
commit 9b7ecd9884
22 changed files with 2563 additions and 356 deletions

View File

@ -18,6 +18,7 @@
"react": "^18.3.1",
"react-bootstrap": "^2.10.8",
"react-dom": "^18.3.1",
"react-icons": "^5.4.0",
"react-router-dom": "^7.1.3",
"use-immer": "^0.11.0",
"uuid": "^11.0.5",
@ -3939,6 +3940,14 @@
"react": "^18.3.1"
}
},
"node_modules/react-icons": {
"version": "5.4.0",
"resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.4.0.tgz",
"integrity": "sha512-7eltJxgVt7X64oHh6wSWNwwbKTCtMfK35hcjvJS0yxEAhPM8oUKdS3+kqaW1vicIltw+kR2unHaa12S9pPALoQ==",
"peerDependencies": {
"react": "*"
}
},
"node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",

View File

@ -20,6 +20,7 @@
"react": "^18.3.1",
"react-bootstrap": "^2.10.8",
"react-dom": "^18.3.1",
"react-icons": "^5.4.0",
"react-router-dom": "^7.1.3",
"use-immer": "^0.11.0",
"uuid": "^11.0.5",

View File

@ -22,8 +22,8 @@ function DragTool(props: ToolHandlerProps) {
updateState(draft => {
draft.isDragging = true;
draft.dragStart = {
x: e.clientX - draft.position.x,
y: e.clientY - draft.position.y,
x: e.clientX - draft.imagePosition.x,
y: e.clientY - draft.imagePosition.y,
};
});
});
@ -36,7 +36,7 @@ function DragTool(props: ToolHandlerProps) {
console.log('DragTool: handleMouseMove', e);
updateState(draft => {
draft.position = {
draft.imagePosition = {
x: e.clientX - draft.dragStart.x,
y: e.clientY - draft.dragStart.y,
};
@ -58,10 +58,16 @@ function DragTool(props: ToolHandlerProps) {
// 处理矩形变换
const handleRectTransform = useLatestCallback((rectPoints: RectPoints, id: string) => {
console.log('DragTool: handleRectTransform');
const rect = Convertor.rectContainer2Image({
x1: rectPoints.x1,
y1: rectPoints.y1,
x2: rectPoints.x2,
y2: rectPoints.y2,
});
updateAnnotation('rect', {
id: id,
type: 'rect',
data: rectPoints,
data: rect,
});
});
@ -97,6 +103,9 @@ function DragTool(props: ToolHandlerProps) {
e.stopPropagation(); // 阻止事件冒泡到容器
console.log('DragTool: handleRectClick', 'selectedRectId=', selectedRectId);
setSelectedRectId(id);
const anno = queryAnnotation(id);
if (anno)
editorProps.onAnnotationSelected?.(anno);
};
// 点击容器,取消选中
@ -104,6 +113,7 @@ function DragTool(props: ToolHandlerProps) {
console.log('DragTool: handleContainerClick', 'selectedRectId=', selectedRectId, e);
setSelectedRectId(null);
setHoveredRectId(null);
editorProps.onAnnotationSelected?.(null);
};
// 点击容器中的非矩形区域,取消选中当前矩形
useEffect(() => {
@ -133,6 +143,8 @@ function DragTool(props: ToolHandlerProps) {
const shouldShowMask = () => {
if (!editorProps.enableMask)
return false;
if (editorProps.annotations.length === 0)
return false;
if (selectedRectId === null && hoveredRectId === null)
return true;
if (selectedRectId === hoveredRectId)
@ -157,32 +169,56 @@ function DragTool(props: ToolHandlerProps) {
onNativeMouseLeave={handleRectMouseLeave}
onNativeClick={(e) => handleRectClick(anno.id, e)}
onTransform={(points) => handleRectTransform(points, anno.id)}
rectTip="rect"
rectTip={anno.tip}
showRectTip={hoveredRectId === anno.id && selectedRectId === null}
/>
));
};
console.log('DragTool: render', editorProps.annotations);
let rectMask;
if (hoveredRectId !== null) {
// 当有矩形被悬停时,只显示该矩形的遮罩
const rect = queryAnnotation(hoveredRectId);
if (rect) {
rectMask = (
<RectMask
rects={[{...rect.data}]}
alpha={shouldShowMask() ? 0.7 : 0}
transition={true}
scale={state.imageScale}
transform={{
x: state.imagePosition.x,
y: state.imagePosition.y,
width: editorProps.imageSize?.width || 0,
height: editorProps.imageSize?.height || 0
}}
/>
);
}
} else {
// 默认显示所有矩形的遮罩
rectMask = (
<RectMask
rects={editorProps.annotations?.map(anno => ({
// 获取图像坐标
...anno.data
})) || []}
alpha={shouldShowMask() ? editorProps.maskAlpha : 0}
transition={true}
scale={state.imageScale}
transform={{
x: state.imagePosition.x,
y: state.imagePosition.y,
width: editorProps.imageSize?.width || 0,
height: editorProps.imageSize?.height || 0
}}
/>
);
}
return (
<>
{
hoveredRectId !== null ? (
// 当有矩形被悬停时,只显示该矩形的遮罩
<RectMask
rects={[Convertor.rectImage2Container(queryAnnotation(hoveredRectId)?.data)]}
alpha={shouldShowMask() ? 0.7 : 0}
transition={true}
/>
) : (
// 默认显示所有矩形的遮罩
<RectMask
rects={editorProps.annotations?.map(anno => Convertor.rectImage2Container(anno.data)) || []}
alpha={shouldShowMask() ? editorProps.maskAlpha : 0}
transition={true}
/>
)
}
{rectMask}
{renderAnnotationWithHover(editorProps.annotations)}
</>
);

View File

@ -1,11 +1,12 @@
import React, { useRef, useEffect } from 'react';
import React, { useRef, useEffect, useState, useMemo } from 'react';
import styled from '@emotion/styled';
import { Updater, useImmer } from 'use-immer';
import RectTool from './RectTool';
import DragTool from './DragTool';
import { Tool, Point, RectPoints, Annotation, AnnotationType } from './types';
import RectBox from './RectBox';
import { Tool, Point, RectPoints, Annotation, AnnotationType, Optional } from './types';
import RectBox, { RectBoxProps } from './RectBox';
import NativeDiv from '../NativeDiv';
import useLatestCallback from '../../hooks/useLatestCallback';
const EditorContainer = styled(NativeDiv)<{ isDragging: boolean }>`
width: 100%;
@ -61,8 +62,21 @@ export interface ImageEditorProps {
showCrosshair?: boolean;
annotations: Annotation[];
onAnnotationChanged?: (e: AnnotationChangedEvent) => void;
onAnnotationSelected?: (annotation: Annotation | null) => void;
enableMask?: boolean;
maskAlpha?: number;
imageSize?: {
width: number;
height: number;
};
/**
* `wheel`
*
* * `wheel` - 使Ctrl + Shift +
* * `ctrlWheel` - 使 Ctrl + Shift +
*/
scaleMode?: 'wheel' | 'ctrlWheel'
onNativeKeyDown?: (e: KeyboardEvent) => void;
}
export interface ImageEditorRef {
@ -72,8 +86,12 @@ export interface ImageEditorRef {
}
export interface EditorState {
scale: number;
position: Point;
/** 图片的缩放比例 */
imageScale: number;
/** 图片相对于容器的偏移量/坐标 */
imagePosition: Point;
/** 图片相对于视口的偏移量/坐标 */
imageClientPosition: Point;
isDragging: boolean;
dragStart: Point;
mousePosition: Point;
@ -90,10 +108,10 @@ export interface ToolHandlerProps {
editorState: [EditorState, Updater<EditorState>];
editorProps: ImageEditorProps;
containerRef: React.RefObject<HTMLDivElement>;
renderAnnotation: (annotations?: Annotation[]) => React.ReactNode;
renderAnnotation: (annotations?: Annotation[], props?: Optional<RectBoxProps>) => React.ReactNode;
addAnnotation: (annotation: Annotation) => void;
updateAnnotation: (type: AnnotationType, annotation: Annotation) => void;
queryAnnotation: (id: string) => Annotation;
queryAnnotation: (id: string) => Annotation | null | undefined;
Convertor: PostionConvertor;
}
@ -108,17 +126,121 @@ const ImageEditor = React.forwardRef<ImageEditorRef, ImageEditorProps>((props, r
annotations = [],
} = props;
const containerRef = useRef<HTMLDivElement>(null);
const imageRef = useRef<HTMLImageElement>(null);
// 图片是否可以显示了(加载完成,尺寸计算完成)
const [imageReady, setImageReady] = useState(false);
// 预加载图片以获取尺寸
useEffect(() => {
const img = new Image();
img.src = image;
img.onload = () => {
const container = containerRef.current;
if (!container) return;
const containerWidth = container.clientWidth;
const containerHeight = container.clientHeight;
const imageRatio = img.naturalWidth / img.naturalHeight;
const containerRatio = containerWidth / containerHeight;
let scale = initialScale;
if (imageRatio > containerRatio) {
scale = containerWidth / img.naturalWidth * 0.9;
} else {
scale = containerHeight / img.naturalHeight * 0.9;
}
const scaledWidth = img.naturalWidth * scale;
const scaledHeight = img.naturalHeight * scale;
const x = (containerWidth - scaledWidth) / 2;
const y = (containerHeight - scaledHeight) / 2;
// 设置初始状态
updateState(draft => {
draft.imageScale = scale;
draft.imagePosition = { x, y };
draft.imageClientPosition = {
x: container.getBoundingClientRect().left + x,
y: container.getBoundingClientRect().top + y
};
});
setImageSize({
width: img.naturalWidth,
height: img.naturalHeight
});
setImageReady(true);
};
}, [image]);
const [state, updateState] = useImmer<EditorState>({
scale: initialScale,
position: { x: 0, y: 0 },
imageScale: initialScale,
imagePosition: { x: 0, y: 0 },
imageClientPosition: { x: 0, y: 0 },
isDragging: false,
dragStart: { x: 0, y: 0 },
mousePosition: { x: 0, y: 0 }
});
const containerRef = useRef<HTMLDivElement>(null);
const [imageSize, setImageSize] = useState<{ width: number; height: number }>({ width: 0, height: 0 });
const Convertor = {
// 监听图像加载完成事件(保留这个事件以防需要做其他处理)
const handleImageLoad = () => {
const img = imageRef.current;
if (img && !imageReady) {
const fitResult = calculateImageFit();
if (fitResult) {
updateState(draft => {
draft.imageScale = fitResult.scale;
draft.imagePosition = fitResult.position;
draft.imageClientPosition = fitResult.clientPosition;
});
}
setImageReady(true);
}
};
/**
*
* @returns
*/
const calculateImageFit = () => {
const img = imageRef.current;
const container = containerRef.current;
if (!img || !container) return null;
const containerWidth = container.clientWidth;
const containerHeight = container.clientHeight;
const imageRatio = img.naturalWidth / img.naturalHeight;
const containerRatio = containerWidth / containerHeight;
let scale = initialScale;
if (imageRatio > containerRatio) {
// 图片更宽,以容器宽度为基准
scale = containerWidth / img.naturalWidth * 0.9;
} else {
// 图片更高,以容器高度为基准
scale = containerHeight / img.naturalHeight * 0.9;
}
// 计算居中位置
const scaledWidth = img.naturalWidth * scale;
const scaledHeight = img.naturalHeight * scale;
const x = (containerWidth - scaledWidth) / 2;
const y = (containerHeight - scaledHeight) / 2;
return {
scale,
position: { x, y },
clientPosition: {
x: container.getBoundingClientRect().left + x,
y: container.getBoundingClientRect().top + y
}
};
};
const Convertor = useMemo(() => ({
/**
*
* @param pos
@ -126,8 +248,8 @@ const ImageEditor = React.forwardRef<ImageEditorRef, ImageEditorProps>((props, r
*/
posContainer2Image: (pos: Point) => {
return {
x: Math.round((pos.x - state.position.x) / state.scale),
y: Math.round((pos.y - state.position.y) / state.scale)
x: Math.round((pos.x - state.imagePosition.x) / state.imageScale),
y: Math.round((pos.y - state.imagePosition.y) / state.imageScale)
};
},
/**
@ -137,8 +259,8 @@ const ImageEditor = React.forwardRef<ImageEditorRef, ImageEditorProps>((props, r
*/
posImage2Container: (pos: Point) => {
return {
x: pos.x * state.scale + state.position.x,
y: pos.y * state.scale + state.position.y
x: pos.x * state.imageScale + state.imagePosition.x,
y: pos.y * state.imageScale + state.imagePosition.y
};
},
/**
@ -167,15 +289,16 @@ const ImageEditor = React.forwardRef<ImageEditorRef, ImageEditorProps>((props, r
y2: Convertor.posContainer2Image({ x: rect.x2, y: rect.y2 }).y
};
}
};
}), [state.imageScale, state.imagePosition]);
const renderAnnotation = (annotations?: Annotation[]) => {
const renderAnnotation = (annotations?: Annotation[], props?: Optional<RectBoxProps>) => {
if (!annotations) return null;
return annotations.map((rect) => (
<RectBox
key={rect.id}
mode="resize"
rect={Convertor.rectImage2Container(rect.data)}
rectTip={rect.tip}
{...props}
/>
));
};
@ -203,14 +326,15 @@ const ImageEditor = React.forwardRef<ImageEditorRef, ImageEditorProps>((props, r
if (annotations) {
ret = annotations.find(annotation => annotation.id === id);
}
if (!ret)
throw new Error(`Annotation not found: ${id}`);
return ret;
};
const toolHandlerProps: ToolHandlerProps = {
editorState: [state, updateState],
editorProps: props,
editorProps: {
...props,
imageSize
},
containerRef,
addAnnotation,
updateAnnotation,
@ -220,55 +344,82 @@ const ImageEditor = React.forwardRef<ImageEditorRef, ImageEditorProps>((props, r
};
// 处理鼠标滚轮缩放
const handleWheel = (e: WheelEvent) => {
const handleWheel = useLatestCallback((e: WheelEvent) => {
e.preventDefault();
const delta = -e.deltaY;
const scaleChange = delta > 0 ? 1.1 : 0.9;
updateState(draft => {
draft.scale = Math.max(0.1, Math.min(10, draft.scale * scaleChange));
});
};
const scaleMode = props.scaleMode || 'wheel';
// 判断缩放还是滚动
const shouldScale =
(scaleMode === 'wheel' && !e.ctrlKey && !e.shiftKey) ||
(scaleMode === 'ctrlWheel' && e.ctrlKey);
if (shouldScale) {
const scaleChange = delta > 0 ? 1.1 : 0.9;
// 获取鼠标相对于容器的坐标
const container = containerRef.current;
if (!container) return;
const rect = container.getBoundingClientRect();
const mouseX = e.clientX - rect.left;
const mouseY = e.clientY - rect.top;
// 计算鼠标位置在图片上的相对位置
const mouseImageX = (mouseX - state.imagePosition.x) / state.imageScale;
const mouseImageY = (mouseY - state.imagePosition.y) / state.imageScale;
updateState(draft => {
draft.imageScale = Math.max(0.1, Math.min(10, draft.imageScale * scaleChange));
// 计算新的图片位置,保持鼠标指向的图片位置不变
draft.imagePosition.x = mouseX - mouseImageX * draft.imageScale;
draft.imagePosition.y = mouseY - mouseImageY * draft.imageScale;
});
} else {
const moveX = e.shiftKey ? delta : 0;
const moveY = !e.shiftKey ? delta : 0;
updateState(draft => {
draft.imagePosition.x += moveX;
draft.imagePosition.y += moveY;
});
}
});
// 暴露组件方法
React.useImperativeHandle(ref, () => ({
reset: () => {
updateState(draft => {
draft.scale = initialScale;
draft.position = { x: 0, y: 0 };
draft.imageScale = initialScale;
draft.imagePosition = { x: 0, y: 0 };
});
},
setScale: (newScale: number) => {
updateState(draft => {
draft.scale = newScale;
draft.imageScale = newScale;
});
},
getScale: () => state.scale
getScale: () => state.imageScale
}));
// 添加和移除滚轮事件监听器
useEffect(() => {
const container = containerRef.current;
if (container) {
container.addEventListener('wheel', handleWheel, { passive: false });
return () => {
container.removeEventListener('wheel', handleWheel);
};
}
}, []);
return (
<EditorContainer
ref={containerRef}
isDragging={state.isDragging}
style={{ cursor: tool === Tool.Rect ? 'crosshair' : undefined }}
onNativeKeyDown={props.onNativeKeyDown}
onNativeMouseWheel={handleWheel}
>
<EditorImage
src={image}
scale={state.scale}
x={state.position.x}
y={state.position.y}
draggable={false}
/>
{imageReady && (
<EditorImage
ref={imageRef}
src={image}
scale={state.imageScale}
x={state.imagePosition.x}
y={state.imagePosition.y}
onLoad={handleImageLoad}
/>
)}
{showCrosshair && tool === Tool.Rect && (
<>

View File

@ -64,7 +64,7 @@ const RectTipContainer = styled(NativeDiv)`
transform: translateY(-100%);
`;
interface ObjectRectProps {
export interface RectBoxProps {
rect: RectPoints;
/**
* resize
@ -86,6 +86,13 @@ interface ObjectRectProps {
* `rectTip`
*/
showRectTip?: boolean;
/**
*
*
*
*
* @param points
*/
onTransform?: (points: RectPoints) => void;
onNativeMouseEnter?: (e: MouseEvent) => void;
onNativeMouseMove?: (e: MouseEvent) => void;
@ -110,7 +117,7 @@ function useLatestProps<T>(props: T) {
/**
*
*/
function RectBox(props: ObjectRectProps) {
function RectBox(props: RectBoxProps) {
const {
rect,
mode = 'resize',

View File

@ -1,24 +1,30 @@
import { RectPoints } from "./types";
import styled from '@emotion/styled';
const MaskContainer = styled.div`
const MaskContainer = styled.div<{ scale: number; transform: { x: number; y: number; width: number; height: number } }>`
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
transform: translate(${props => props.transform.x}px, ${props => props.transform.y}px) scale(${props => props.scale});
transform-origin: left top;
pointer-events: none;
`;
interface RectMaskProps {
rects: RectPoints[];
alpha?: number;
transition?: boolean;
scale: number;
transform: {
x: number;
y: number;
width: number;
height: number;
};
}
function RectMask({ rects, alpha = 0.5, transition = false }: RectMaskProps) {
function RectMask({ rects, alpha = 0.5, transition = false, scale, transform }: RectMaskProps) {
return (
<MaskContainer>
<svg width="100%" height="100%" style={{ position: 'absolute' }}>
<MaskContainer scale={scale} transform={transform}>
<svg width={transform.width} height={transform.height} style={{ position: 'absolute' }}>
<defs>
<mask id="rectMask">
{/* 首先创建一个黑色背景(完全遮罩) */}
@ -33,7 +39,6 @@ function RectMask({ rects, alpha = 0.5, transition = false }: RectMaskProps) {
width={rect.x2 - rect.x1}
height={rect.y2 - rect.y1}
fill="black"
// style={transition ? { transition: 'all 0.1s ease-in-out' } : undefined}
/>
))}
</mask>

View File

@ -1,15 +1,17 @@
import { ToolHandlerProps } from "./ImageEditor";
import { useEffect, useState } from "react";
import { useEffect, useState, useRef } from "react";
import RectBox from "./RectBox";
import { Point } from "./types";
import { Annotation, Point } from "./types";
import useLatestCallback from "../../hooks/useLatestCallback";
import { v4 } from "uuid";
function RectTool(props: ToolHandlerProps) {
const { containerRef, addAnnotation, renderAnnotation, Convertor, editorProps } = props;
// 下面两个都为容器坐标
const [rectStart, setRectStart] = useState<Point | null>(null);
const [rectEnd, setRectEnd] = useState<Point | null>(null);
const drawingRef = useRef(false);
// 处理拖拽开始
const handleMouseDown = useLatestCallback((e: MouseEvent) => {
@ -18,9 +20,11 @@ function RectTool(props: ToolHandlerProps) {
if (rect) {
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
// const imagePos = Convertor.posContainer2Image({ x, y });
setRectStart({ x, y });
setRectEnd({ x, y });
}
drawingRef.current = true;
console.log('RectTool: handleMouseDown');
});
@ -41,17 +45,25 @@ function RectTool(props: ToolHandlerProps) {
let y1 = Math.min(rectStart.y, rectEnd.y);
let x2 = Math.max(rectStart.x, rectEnd.x);
let y2 = Math.max(rectStart.y, rectEnd.y);
let newRect = { x1, y1, x2, y2 };
// 转换到图片坐标
newRect = Convertor.rectContainer2Image(newRect);
addAnnotation({
id: v4(),
type: 'rect',
data: newRect
});
if (Math.abs(x1 - x2) < 10 || Math.abs(y1 - y2) < 10) {
console.log('RectTool: rect too small. skip add annotation');
}
else {
let newRect = { x1, y1, x2, y2 };
newRect = Convertor.rectContainer2Image(newRect);
const annotation: Annotation = {
id: v4(),
type: 'rect',
data: newRect
};
addAnnotation(annotation);
editorProps.onAnnotationSelected?.(annotation);
}
setRectStart(null);
setRectEnd(null);
drawingRef.current = false;
console.log('RectTool: handleMouseUp');
});
@ -75,7 +87,10 @@ function RectTool(props: ToolHandlerProps) {
{rectStart && rectEnd && (
<RectBox rect={{x1: rectStart.x, y1: rectStart.y, x2: rectEnd.x, y2: rectEnd.y}} />
)}
{renderAnnotation(editorProps.annotations)}
{renderAnnotation(editorProps.annotations, {
mode: 'move',
lineColor: drawingRef.current ? 'rgba(255, 255, 255, 0.3)' : 'white'
})}
</>
);
}

View File

@ -10,10 +10,12 @@ type AnnotationTypeMap = {
rect: RectPoints,
};
export interface Annotation{
export interface Annotation {
id: string;
type: AnnotationType;
data: AnnotationTypeMap[AnnotationType];
/** 提示信息。 */
tip?: React.ReactNode;
}
export interface Point {
x: number;
@ -25,4 +27,11 @@ export interface RectPoints {
y1: number;
x2: number;
y2: number;
}
}
/**
* 使
*/
export type Optional<T> = {
[P in keyof T]?: T[P] | undefined;
};

View File

@ -4,6 +4,8 @@ import styled from '@emotion/styled';
interface ImageViewerProps {
/** 图片地址 */
image: string;
/** 图片渲染模式 */
imageRendering?: 'pixelated' | 'auto';
/** 是否可缩放 */
zoomable?: boolean;
/** 是否可移动 */
@ -47,6 +49,7 @@ const ImageContainer = styled.div`
interface StyledImageProps {
withAnimation?: boolean;
imageRendering?: 'pixelated' | 'auto';
}
const StyledImage = styled.img<StyledImageProps>`
@ -57,6 +60,7 @@ const StyledImage = styled.img<StyledImageProps>`
user-select: none;
position: relative;
transition: ${(props: StyledImageProps) => props.withAnimation ? 'transform 0.2s ease-out' : 'none'};
image-rendering: ${props => props.imageRendering};
&.dragging {
cursor: grabbing;
@ -67,6 +71,7 @@ const StyledImage = styled.img<StyledImageProps>`
const ImageViewer = forwardRef<ImageViewerRef, ImageViewerProps>(
({
image,
imageRendering = 'auto',
zoomable: scalable = true,
movable = true,
minZoomScale: minScale = 0.1,
@ -214,6 +219,7 @@ const ImageViewer = forwardRef<ImageViewerRef, ImageViewerProps>(
onDragStart={(e: React.DragEvent<HTMLImageElement>) => e.preventDefault()}
className={isDragging ? 'dragging' : ''}
withAnimation={useAnimation}
imageRendering={imageRendering}
style={{
transform: `translate(${translateX}px, ${translateY}px) scale(${scale})`
}}

View File

@ -0,0 +1,165 @@
import { Modal } from 'react-bootstrap';
import ImageViewer, { ImageViewerRef } from './ImageViewer';
import { useRef, useState } from 'react';
import styled from '@emotion/styled';
const ModalBackdrop = styled.div`
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.4);
z-index: 1040;
`;
const ToolBar = styled.div`
position: absolute;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
display: flex;
gap: 10px;
padding: 8px;
background: rgba(0, 0, 0, 0.6);
border-radius: 8px;
z-index: 1;
`;
const ToolButton = styled.button`
width: 40px;
height: 40px;
border: none;
border-radius: 4px;
background: transparent;
color: white;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
&:hover {
background: rgba(255, 255, 255, 0.1);
}
&:active {
background: rgba(255, 255, 255, 0.2);
}
`;
interface ImageViewerModalProps {
show: boolean;
onHide: () => void;
image: string;
imageRendering?: 'pixelated' | 'auto';
title?: string;
}
function ImageViewerModal(props: ImageViewerModalProps) {
const { show, onHide, image, imageRendering = 'auto', title } = props;
const imageViewerRef = useRef<ImageViewerRef>(null);
const handleZoomIn = () => {
if (imageViewerRef.current) {
const currentScale = imageViewerRef.current.scale;
imageViewerRef.current.setScale(currentScale * 1.2);
}
};
const handleZoomOut = () => {
if (imageViewerRef.current) {
const currentScale = imageViewerRef.current.scale;
imageViewerRef.current.setScale(currentScale / 1.2);
}
};
const handleResetZoom = () => {
imageViewerRef.current?.reset('zoom');
};
const handleFit = () => {
imageViewerRef.current?.fit();
};
return (
<>
{show && <ModalBackdrop />}
<Modal
show={show}
onHide={onHide}
size="xl"
dialogClassName="modal-90w"
centered
style={{ zIndex: 1050 }}
>
<Modal.Header closeButton style={{ border: 'none' }}>
{title && <Modal.Title>{title}</Modal.Title>}
</Modal.Header>
<Modal.Body className="p-0" style={{ position: 'relative' }}>
<div style={{ width: '100%', height: 'calc(90vh - 56px)' }}>
<ImageViewer
ref={imageViewerRef}
image={image}
zoomable={true}
movable={true}
imageRendering={imageRendering}
/>
</div>
<ToolBar>
<ToolButton onClick={handleZoomIn} title="放大">
<i className="bi bi-zoom-in"></i>
</ToolButton>
<ToolButton onClick={handleZoomOut} title="缩小">
<i className="bi bi-zoom-out"></i>
</ToolButton>
<ToolButton onClick={handleFit} title="适应容器">
<i className="bi bi-aspect-ratio"></i>
</ToolButton>
<ToolButton onClick={handleResetZoom} title="重置缩放">
<i className="bi bi-arrow-counterclockwise"></i>
</ToolButton>
</ToolBar>
</Modal.Body>
</Modal>
</>
);
}
interface ImageViewerModalOpenOptions {
imageRendering?: 'pixelated' | 'auto';
}
export function useImageViewerModal(title?: string, options?: ImageViewerModalOpenOptions) {
const [show, setShow] = useState(false);
const [image, setImage] = useState<string>('');
const [imageRendering, setImageRendering] = useState<'pixelated' | 'auto'>('auto');
const openModal = (imageUrl: string, options?: ImageViewerModalOpenOptions) => {
setImage(imageUrl);
setImageRendering(options?.imageRendering || 'auto');
setShow(true);
};
const closeModal = () => {
setShow(false);
};
const modal = (
<ImageViewerModal
show={show}
onHide={closeModal}
image={image}
imageRendering={imageRendering}
title={title}
/>
);
return {
modal,
openModal,
closeModal
};
}
export default ImageViewerModal;

View File

@ -6,6 +6,9 @@ interface NativeDivProps extends HTMLAttributes<HTMLDivElement> {
onNativeMouseLeave?: (e: MouseEvent) => void;
onNativeMouseDown?: (e: MouseEvent) => void;
onNativeClick?: (e: MouseEvent) => void;
onNativeKeyDown?: (e: KeyboardEvent) => void;
onNativeKeyUp?: (e: KeyboardEvent) => void;
onNativeMouseWheel?: (e: WheelEvent) => void;
}
/**
@ -17,6 +20,9 @@ interface NativeDivProps extends HTMLAttributes<HTMLDivElement> {
* * onNativeMouseLeave
* * onNativeMouseDown
* * onNativeClick
* * onNativeKeyDown
* * onNativeKeyUp
* * onNativeMouseWheel
*
* DOM
*/
@ -27,6 +33,9 @@ const NativeDiv = forwardRef<HTMLDivElement, NativeDivProps>((props, ref) => {
onNativeMouseLeave,
onNativeMouseDown,
onNativeClick,
onNativeKeyDown,
onNativeKeyUp,
onNativeMouseWheel,
...restProps
} = props;
@ -53,6 +62,12 @@ const NativeDiv = forwardRef<HTMLDivElement, NativeDivProps>((props, ref) => {
element.addEventListener('mousedown', onNativeMouseDown);
if (onNativeClick)
element.addEventListener('click', onNativeClick);
if (onNativeKeyDown)
element.addEventListener('keydown', onNativeKeyDown);
if (onNativeKeyUp)
element.addEventListener('keyup', onNativeKeyUp);
if (onNativeMouseWheel)
element.addEventListener('wheel', onNativeMouseWheel);
return () => {
if (onNativeMouseEnter)
@ -65,8 +80,14 @@ const NativeDiv = forwardRef<HTMLDivElement, NativeDivProps>((props, ref) => {
element.removeEventListener('mousedown', onNativeMouseDown);
if (onNativeClick)
element.removeEventListener('click', onNativeClick);
if (onNativeKeyDown)
element.removeEventListener('keydown', onNativeKeyDown);
if (onNativeKeyUp)
element.removeEventListener('keyup', onNativeKeyUp);
if (onNativeMouseWheel)
element.removeEventListener('wheel', onNativeMouseWheel);
};
}, [onNativeMouseEnter, onNativeMouseMove, onNativeMouseLeave, onNativeClick]);
}, [onNativeMouseEnter, onNativeMouseMove, onNativeMouseLeave, onNativeClick, onNativeKeyDown, onNativeKeyUp, onNativeMouseWheel]);
return <div ref={elementRef} {...restProps} />;
});

View File

@ -0,0 +1,205 @@
import styled from '@emotion/styled';
import React, { useState, useRef } from 'react';
export interface PropertyRenderBase {
required?: boolean,
}
interface PropertyRenderInputOptions {
text: {
value: string,
onChange: (value: string) => void
},
checkbox: {
value: boolean,
onChange: (value: boolean) => void
}
}
type RenderType = keyof PropertyRenderInputOptions;
type PropertyRender =
(PropertyRenderBase & PropertyRenderInputOptions[RenderType] & { type: RenderType }) |
(() => React.ReactNode);
/**
*
* @property title - ,
* @property render -
*/
export interface Property {
title?: string;
render: PropertyRender;
}
/**
*
* @property title -
* @property properties -
* @property foldable - true,
*/
export interface PropertyCategory {
title: string;
properties: Property[];
foldable?: boolean;
}
export interface PropertyGridProps {
properties: Array<Property | PropertyCategory>;
titleColumnWidth?: string;
}
const GridContainer = styled.div<{ titleColumnWidth: string }>`
display: grid;
grid-template-columns: ${props => props.titleColumnWidth} 1fr;
gap: 1px;
background-color: #e0e0e0;
border: 1px solid #e0e0e0;
`;
const PropertyTitle = styled.div`
padding: 8px;
background-color: #f5f5f5;
font-size: 14px;
display: flex;
align-items: center;
`;
const PropertyContent = styled.div`
padding: 4px 8px;
background-color: white;
min-height: 32px;
display: flex;
align-items: center;
`;
const FullWidthPropertyContent = styled(PropertyContent)`
grid-column: 1 / -1;
`;
const CategoryTitle = styled.div<{ foldable?: boolean }>`
grid-column: 1 / -1;
padding: 8px;
background-color: #edf2f7;
font-weight: 600;
font-size: 14px;
color: #2d3748;
border-bottom: 1px solid #e0e0e0;
display: flex;
align-items: center;
gap: 8px;
cursor: ${props => props.foldable ? 'pointer' : 'default'};
&:hover {
background-color: ${props => props.foldable ? '#e2e8f0' : '#edf2f7'};
}
`;
const FoldIcon = styled.span<{ folded: boolean }>`
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
transition: transform 0.2s;
transform: rotate(${props => props.folded ? '-90deg' : '0deg'});
flex-shrink: 0;
&::before {
content: '▼';
font-size: 12px;
}
`;
const CategoryContent = styled.div<{ folded: boolean }>`
display: grid;
grid-template-columns: subgrid;
grid-column: 1 / -1;
display: ${props => props.folded ? 'none' : 'grid'};
`;
const PropertyGrid: React.FC<PropertyGridProps> = ({ properties, titleColumnWidth = 'auto' }) => {
const [foldedCategories, setFoldedCategories] = useState<Record<string, boolean>>({});
const toggleCategory = (categoryTitle: string) => {
setFoldedCategories(prev => ({
...prev,
[categoryTitle]: !prev[categoryTitle]
}));
};
/**
*
* @param property -
*/
const makeProperty = (property: Property) => {
let field: React.ReactNode;
if (typeof property.render === 'function') {
field = property.render();
} else if ('type' in property.render) {
const type = property.render.type;
if (type === 'text') {
const propertyText = property.render as PropertyRenderInputOptions['text'];
field = <input type="text" value={propertyText.value} onChange={(e) => propertyText.onChange(e.target.value)} />;
} else if (type === 'checkbox') {
const propertyCheckbox = property.render as PropertyRenderInputOptions['checkbox'];
field = <input type="checkbox" checked={propertyCheckbox.value} onChange={(e) => propertyCheckbox.onChange(e.target.checked)} />;
}
} else {
console.error('Invalid property render type:', property.render);
}
if (property.title) {
return (
<>
<PropertyTitle>{property.title}</PropertyTitle>
<PropertyContent>{field}</PropertyContent>
</>
);
}
return <FullWidthPropertyContent>{field}</FullWidthPropertyContent>;
};
/**
*
* @param category -
* @param index -
*/
const makeCategory = (category: PropertyCategory, index: number) => {
return (
<>
<CategoryTitle
foldable={category.foldable}
onClick={() => category.foldable && toggleCategory(category.title)}
>
{category.foldable && (
<FoldIcon folded={foldedCategories[category.title]} />
)}
<span>{category.title}</span>
</CategoryTitle>
<CategoryContent
folded={category.foldable ? !!foldedCategories[category.title] : false}
>
{category.properties.map((subProperty, subIndex) => (
<React.Fragment key={`${index}-${subIndex}`}>
{makeProperty(subProperty)}
</React.Fragment>
))}
</CategoryContent>
</>
);
};
return (
<GridContainer titleColumnWidth={titleColumnWidth}>
{properties.map((property, index) => (
<React.Fragment key={index}>
{'properties' in property
? makeCategory(property, index)
: makeProperty(property)
}
</React.Fragment>
))}
</GridContainer>
);
};
export default PropertyGrid;

View File

@ -0,0 +1,197 @@
import React, { useState } from 'react';
import styled from '@emotion/styled';
export interface Tool {
id: string;
icon: React.ReactNode;
title: string;
onClick?: () => void;
selectable?: boolean;
}
export interface SideToolBarProps {
tools: Array<Tool | 'separator'>;
selectedToolId?: string;
/**
*
* selectable true
* @param id id
*/
onSelectTool?: (id: string) => void;
/**
*
*
*
* 使 Tool onClick
* 使
*
* onClick > onClickTool > onSelectTool
* @param id id
*/
onClickTool?: (id: string) => void;
}
const ToolBarContainer = styled.div`
display: flex;
flex-direction: column;
gap: 4px;
padding: 8px;
border-radius: 8px;
width: fit-content;
@media (prefers-color-scheme: dark) {
background-color: #2c2c2c;
}
@media (prefers-color-scheme: light) {
background-color: #f8f9fa;
border: 1px solid #dee2e6;
}
`;
const ToolButton = styled.button<{ isSelected?: boolean }>`
width: 40px;
height: 40px;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
border: none;
border-radius: 8px;
background-color: ${props => props.isSelected ? 'rgba(255, 255, 255, 0.2)' : 'transparent'};
transition: background-color 0.2s;
@media (prefers-color-scheme: dark) {
color: #fff;
&:hover {
background-color: ${props => props.isSelected ? 'rgba(255, 255, 255, 0.3)' : 'rgba(255, 255, 255, 0.1)'};
color: #fff;
}
&:active {
background-color: rgba(255, 255, 255, 0.2);
color: #fff;
}
&:focus {
color: #fff;
}
}
@media (prefers-color-scheme: light) {
color: #212529;
background-color: ${props => props.isSelected ? 'rgba(0, 0, 0, 0.1)' : 'transparent'};
&:hover {
background-color: ${props => props.isSelected ? 'rgba(0, 0, 0, 0.15)' : 'rgba(0, 0, 0, 0.05)'};
color: #212529;
}
&:active {
background-color: rgba(0, 0, 0, 0.1);
color: #212529;
}
&:focus {
color: #212529;
}
}
// 覆盖 Bootstrap 按钮的默认样式
&.btn:hover,
&.btn:active,
&.btn:focus {
border: none;
box-shadow: none;
}
`;
const Separator = styled.div`
height: 1px;
margin: 4px 0;
@media (prefers-color-scheme: dark) {
background-color: rgba(255, 255, 255, 0.2);
}
@media (prefers-color-scheme: light) {
background-color: rgba(0, 0, 0, 0.1);
}
`;
const Tooltip = styled.div<{ visible: boolean }>`
position: absolute;
left: 100%;
top: 50%;
transform: translateY(-50%);
margin-left: 8px;
padding: 4px 8px;
background-color: rgba(0, 0, 0, 0.75);
color: white;
border-radius: 4px;
font-size: 12px;
white-space: nowrap;
pointer-events: none;
opacity: ${props => props.visible ? 1 : 0};
transition: opacity 0.2s;
z-index: 1000;
&::before {
content: '';
position: absolute;
right: 100%;
top: 50%;
transform: translateY(-50%);
border-width: 4px;
border-style: solid;
border-color: transparent rgba(0, 0, 0, 0.75) transparent transparent;
}
`;
const ToolButtonWrapper = styled.div`
position: relative;
`;
export const SideToolBar: React.FC<SideToolBarProps> = ({
tools,
selectedToolId,
onSelectTool,
onClickTool
}) => {
return (
<ToolBarContainer>
{tools.map((tool, index) => {
if (tool === 'separator') {
return <Separator key={`separator-${index}`} />;
}
const [showTooltip, setShowTooltip] = useState(false);
const handleClick = () => {
tool.onClick?.();
onClickTool?.(tool.id);
if (tool.selectable !== false && onSelectTool) {
onSelectTool(tool.id);
}
};
return (
<ToolButtonWrapper
key={`tool-${tool.id}`}
onMouseEnter={() => setShowTooltip(true)}
onMouseLeave={() => setShowTooltip(false)}
>
<ToolButton
onClick={handleClick}
isSelected={tool.selectable !== false && selectedToolId === tool.id}
>
{tool.icon}
</ToolButton>
<Tooltip visible={showTooltip}>{tool.title}</Tooltip>
</ToolButtonWrapper>
);
})}
</ToolBarContainer>
);
};

View File

@ -0,0 +1,80 @@
import React, { useState, useCallback } from 'react';
import Toast from 'react-bootstrap/Toast';
import ToastContainer from 'react-bootstrap/ToastContainer';
import styled from '@emotion/styled';
// 定义消息类型
export type ToastType = 'success' | 'danger' | 'info' | 'warning';
// 定义单个消息的数据结构
export interface ToastMessage {
id: string;
type: ToastType;
title: string;
message: string;
duration?: number;
}
// 定义 Hook 返回的数据结构
export interface ToastHook {
showToast: (type: ToastType, title: string, message: string, duration?: number) => void;
ToastComponent: React.ReactNode;
}
// 样式化的 Toast 容器
const StyledToastContainer = styled(ToastContainer)`
z-index: 9999;
position: fixed;
left: 50%;
transform: translateX(-50%);
`;
// 自定义 Hook 用于管理 Toast 消息
export const useToast = (): ToastHook => {
const [toasts, setToasts] = useState<ToastMessage[]>([]);
// 移除指定 ID 的消息
const removeToast = useCallback((id: string) => {
setToasts(prev => prev.filter(toast => toast.id !== id));
}, []);
// 显示新消息
const showToast = useCallback((
type: ToastType,
title: string,
message: string,
duration: number = 3000
) => {
const id = Math.random().toString(36).substr(2, 9);
setToasts(prev => [...prev, { id, type, title, message, duration }]);
}, []);
// 渲染 Toast 组件
const ToastComponent = (
<StyledToastContainer position="top-center" className="p-3">
{toasts.map(toast => (
<Toast
key={toast.id}
onClose={() => removeToast(toast.id)}
show={true}
delay={toast.duration}
autohide
bg={toast.type}
className="mb-2"
>
<Toast.Header closeButton>
<strong className="me-auto">{toast.title}</strong>
</Toast.Header>
<Toast.Body className='text-white'>
{toast.message}
</Toast.Body>
</Toast>
))}
</StyledToastContainer>
);
return {
showToast,
ToastComponent
};
};

View File

@ -1,100 +0,0 @@
import { ToolHandler } from './ImageEditor/ImageEditor';
import { Rect } from './ImageEditor/ImageEditor';
export const useDragTool : ToolHandler = (args) => {
const { editorState } = args;
const [state, updateState] = editorState;
const handleMouseDown = (e: React.MouseEvent) => {
e.preventDefault();
updateState(draft => {
draft.isDragging = true;
draft.dragStart = {
x: e.clientX - draft.position.x,
y: e.clientY - draft.position.y
};
});
};
const handleMouseMove = (e: React.MouseEvent) => {
if (!state.isDragging) return;
updateState(draft => {
draft.position = {
x: e.clientX - draft.dragStart.x,
y: e.clientY - draft.dragStart.y
};
});
};
const handleMouseUp = () => {
updateState(draft => {
draft.isDragging = false;
});
};
return {
handleMouseDown,
handleMouseMove,
handleMouseUp
};
};
export const useRectTool : ToolHandler = (args) => {
const { editorState, containerRef, onAnnotationChange } = args;
const [state, updateState] = editorState;
const handleMouseDown = (e: React.MouseEvent) => {
e.preventDefault();
const rect = containerRef.current?.getBoundingClientRect();
if (!rect) return;
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
updateState(draft => {
draft.rectStart = { x, y };
draft.rectEnd = { x, y };
});
};
const handleMouseMove = (e: React.MouseEvent) => {
const rect = containerRef.current?.getBoundingClientRect();
if (!rect) return;
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
updateState(draft => {
draft.mousePosition = { x, y };
if (draft.rectStart) {
draft.rectEnd = { x, y };
}
});
};
const handleMouseUp = () => {
if (state.rectStart && state.rectEnd) {
const newRect: Rect = {
x: Math.min(state.rectStart.x, state.rectEnd.x),
y: Math.min(state.rectStart.y, state.rectEnd.y),
width: Math.abs(state.rectEnd.x - state.rectStart.x),
height: Math.abs(state.rectEnd.y - state.rectStart.y)
};
updateState(draft => {
draft.savedRects.push(newRect);
draft.rectStart = null;
draft.rectEnd = null;
});
onAnnotationChange?.({ rects: [...state.savedRects, newRect] });
}
};
return {
handleMouseDown,
handleMouseMove,
handleMouseUp
};
};

View File

@ -6,12 +6,17 @@ import MultipleImagesViewer from '../components/MutipleImagesViewer';
import { useMessageBox } from '../hooks/useMessageBox';
import { useFullscreenSpinner } from '../hooks/useFullscreenSpinner';
import ImageEditor, { ImageEditorRef } from '../components/ImageEditor/ImageEditor';
import { Annotation, RectPoints, Tool } from '../components/ImageEditor/types';
import { Annotation, RectPoints, Tool as EditorTool } from '../components/ImageEditor/types';
import RectBox from '../components/ImageEditor/RectBox';
import RectMask from '../components/ImageEditor/RectMask';
import FormRange from 'react-bootstrap/esm/FormRange';
import NativeDiv from '../components/NativeDiv';
import { AnnotationChangedEvent } from '../components/ImageEditor/ImageEditor';
import { SideToolBar, Tool } from '../components/SideToolBar';
import PropertyGrid, { Property, PropertyCategory } from '../components/PropertyGrid';
import ImageViewerModal, { useImageViewerModal } from '../components/ImageViewerModal';
import { useToast } from '../components/ToastMessage';
// 布局相关的样式组件
const DemoContainer = styled.div`
display: flex;
@ -239,6 +244,63 @@ function SpinnerDemo(): JSX.Element {
);
}
// 添加 Toast 消息演示组件
function ToastMessageDemo(): JSX.Element {
const { showToast, ToastComponent } = useToast();
const handleShowSuccess = () => {
showToast('success', '成功', '操作已成功完成!');
};
const handleShowError = () => {
showToast('danger', '错误', '操作执行失败,请重试。');
};
const handleShowInfo = () => {
showToast('info', '提示', '这是一条信息提示。');
};
const handleShowWarning = () => {
showToast('warning', '警告', '请注意,这是一条警告消息。');
};
const handleShowCustomDuration = () => {
showToast('info', '自定义时长', '这条消息会显示 10 秒钟。', 10000);
};
return (
<div>
<h2>Toast </h2>
<ControlPanel>
<Button onClick={handleShowSuccess}></Button>
<Button onClick={handleShowError}></Button>
<Button onClick={handleShowInfo}></Button>
<Button onClick={handleShowWarning}></Button>
<Button onClick={handleShowCustomDuration}></Button>
</ControlPanel>
{ToastComponent}
<div>
<h3>使</h3>
<ul>
<li> Toast </li>
<li></li>
<ul>
<li> - 绿</li>
<li> - </li>
<li> - </li>
<li> - </li>
</ul>
<li> 3 </li>
<li>"显示长时消息" 10 </li>
<li></li>
<li></li>
<li></li>
</ul>
</div>
</div>
);
}
// 单图片查看器演示组件
function SingleImageViewerDemo(): JSX.Element {
const viewerRef = useRef<ImageViewerRef>(null);
@ -370,10 +432,11 @@ function MultipleImagesViewerDemo(): JSX.Element {
function ImageEditorDemo(): JSX.Element {
const editorRef = useRef<ImageEditorRef>(null);
const [showCrosshair, setShowCrosshair] = useState(false);
const [currentTool, setCurrentTool] = useState<Tool>(Tool.Drag);
const [currentTool, setCurrentTool] = useState<EditorTool>(EditorTool.Drag);
const [annotations, setAnnotations] = useState<Annotation[]>([]);
const [showMask, setShowMask] = useState(true);
const [maskAlpha, setMaskAlpha] = useState(0.5);
const [scaleMode, setScaleMode] = useState<'wheel' | 'ctrlWheel'>('wheel');
const demoImage = 'https://picsum.photos/800/600';
const handleEditorReset = () => {
@ -438,6 +501,7 @@ function ImageEditorDemo(): JSX.Element {
onAnnotationChanged={handleAnnotationChanged}
enableMask={showMask}
maskAlpha={maskAlpha}
scaleMode={scaleMode}
/>
</ViewerContainer>
<ControlPanel>
@ -447,13 +511,16 @@ function ImageEditorDemo(): JSX.Element {
<Button onClick={() => setShowCrosshair(!showCrosshair)}>
{showCrosshair ? '隐藏准线' : '显示准线'}
</Button>
<Button onClick={() => setCurrentTool(currentTool === Tool.Drag ? Tool.Rect : Tool.Drag)}>
{currentTool === Tool.Drag ? '切换到矩形工具' : '切换到拖动工具'}
<Button onClick={() => setCurrentTool(currentTool === EditorTool.Drag ? EditorTool.Rect : EditorTool.Drag)}>
{currentTool === EditorTool.Drag ? '切换到矩形工具' : '切换到拖动工具'}
</Button>
<Button onClick={handleClearAnnotations}></Button>
<Button onClick={() => setShowMask(!showMask)}>
{showMask ? '隐藏遮罩' : '显示遮罩'}
</Button>
<Button onClick={() => setScaleMode(mode => mode === 'wheel' ? 'ctrlWheel' : 'wheel')}>
{scaleMode === 'wheel' ? '切换到Ctrl+滚轮缩放' : '切换到滚轮缩放'}
</Button>
<FormRange
style={{ flex: '0 1 200px' }}
value={maskAlpha}
@ -500,6 +567,11 @@ function ImageEditorDemo(): JSX.Element {
<li>"清除标注"</li>
<li>"显示遮罩"/</li>
<li>使</li>
<li>"切换到Ctrl+滚轮缩放"</li>
<ul>
<li>使Ctrl+Shift+</li>
<li>Ctrl+使Ctrl+Shift+</li>
</ul>
</ul>
</div>
</div>
@ -678,7 +750,13 @@ function RectMaskDemo(): JSX.Element {
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
alt="Demo"
/>
<RectMask rects={rects} alpha={alpha} transition={enableTransition} />
<RectMask
rects={rects}
alpha={alpha}
transition={enableTransition}
scale={1}
transform={{ x: 0, y: 0, width: 800, height: 600 }}
/>
</RectBoxContainer>
<div>
<h3>使</h3>
@ -698,35 +776,64 @@ function RectMaskDemo(): JSX.Element {
// NativeDiv 演示组件
function NativeDivDemo(): JSX.Element {
const [events, setEvents] = useState<Array<{ type: string; time: string; position: string }>>([]);
const [events, setEvents] = useState<Array<{ type: string; time: string; details: string }>>([]);
const [wheelValue, setWheelValue] = useState(0);
const addEvent = (type: string, e: MouseEvent) => {
const addEvent = (type: string, details: string) => {
const newEvent = {
type,
time: new Date().toLocaleTimeString(),
position: `(${e.clientX}, ${e.clientY})`
details
};
setEvents(prev => [newEvent, ...prev].slice(0, 5));
setEvents(prev => [newEvent, ...prev].slice(0, 10));
};
const handleNativeMouseEnter = (e: MouseEvent) => {
addEvent('mouseenter', e);
addEvent('mouseenter', `位置:(${e.clientX}, ${e.clientY})`);
};
const handleNativeMouseMove = (e: MouseEvent) => {
addEvent('mousemove', e);
addEvent('mousemove', `位置:(${e.clientX}, ${e.clientY})`);
};
const handleNativeMouseLeave = (e: MouseEvent) => {
addEvent('mouseleave', e);
addEvent('mouseleave', `位置:(${e.clientX}, ${e.clientY})`);
};
const handleNativeMouseDown = (e: MouseEvent) => {
addEvent('mousedown', e);
addEvent('mousedown', `位置:(${e.clientX}, ${e.clientY})`);
};
const handleNativeClick = (e: MouseEvent) => {
addEvent('click', e);
addEvent('click', `位置:(${e.clientX}, ${e.clientY})`);
};
const handleNativeKeyDown = (e: KeyboardEvent) => {
addEvent('keydown', `按键:${e.key},键码:${e.keyCode},修饰键:${[
e.ctrlKey && 'Ctrl',
e.shiftKey && 'Shift',
e.altKey && 'Alt',
e.metaKey && 'Meta'
].filter(Boolean).join('+') || '无'}`);
};
const handleNativeKeyUp = (e: KeyboardEvent) => {
addEvent('keyup', `按键:${e.key},键码:${e.keyCode},修饰键:${[
e.ctrlKey && 'Ctrl',
e.shiftKey && 'Shift',
e.altKey && 'Alt',
e.metaKey && 'Meta'
].filter(Boolean).join('+') || '无'}`);
};
const handleNativeMouseWheel = (e: WheelEvent) => {
e.preventDefault();
const delta = e.deltaY;
setWheelValue(prev => {
const newValue = prev + delta;
addEvent('mousewheel', `滚动值:${newValue},增量:${delta}`);
return newValue;
});
};
return (
@ -734,21 +841,59 @@ function NativeDivDemo(): JSX.Element {
<h2> Div </h2>
<NativeDivPlayground>
<DemoNativeDiv
tabIndex={0}
onNativeMouseEnter={handleNativeMouseEnter}
onNativeMouseMove={handleNativeMouseMove}
onNativeMouseLeave={handleNativeMouseLeave}
onNativeClick={handleNativeClick}
onNativeMouseDown={handleNativeMouseDown}
onNativeKeyDown={handleNativeKeyDown}
onNativeKeyUp={handleNativeKeyUp}
onNativeMouseWheel={handleNativeMouseWheel}
style={{ transform: `rotate(${wheelValue * 0.1}deg)` }}
>
<div style={{ textAlign: 'center' }}>
<div></div>
<small style={{
fontSize: '0.8em',
display: 'block',
marginTop: '8px',
color: 'rgba(255, 255, 255, 0.8)'
}}>
<br />
</small>
<div style={{
fontSize: '0.8em',
marginTop: '8px',
color: 'rgba(255, 255, 255, 0.8)'
}}>
{wheelValue.toFixed(0)}
</div>
</div>
</DemoNativeDiv>
</NativeDivPlayground>
<div style={{ marginTop: '20px' }}>
<h3></h3>
<ul>
<ul style={{
listStyle: 'none',
padding: 0,
margin: 0,
maxHeight: '200px',
overflowY: 'auto',
border: '1px solid #ddd',
borderRadius: '4px'
}}>
{events.map((event, index) => (
<li key={index}>
{event.type} - {event.time} - {event.position}
<li key={index} style={{
padding: '8px',
borderBottom: index < events.length - 1 ? '1px solid #eee' : 'none',
backgroundColor: index === 0 ? '#f5f5f5' : 'transparent'
}}>
<strong>{event.type}</strong> - {event.time}
<br />
<span style={{ color: '#666', fontSize: '0.9em' }}>{event.details}</span>
</li>
))}
</ul>
@ -761,13 +906,384 @@ function NativeDivDemo(): JSX.Element {
<li> mousemove </li>
<li> mouseleave </li>
<li> click </li>
<li></li>
<li><strong> keydown keyup </strong></li>
<li><strong> mousewheel </strong></li>
<li></li>
<ul>
<li></li>
<li></li>
<li></li>
<li></li>
</ul>
</ul>
</div>
</div>
);
}
// 添加 SideToolBar 演示组件
function SideToolBarDemo(): JSX.Element {
const [count, setCount] = useState(0);
const [selectedToolId, setSelectedToolId] = useState<string | undefined>(undefined);
const handleToolSelect = (id: string) => {
setSelectedToolId(id);
};
const tools: Array<Tool | 'separator'> = [
{
id: 'select',
icon: <i className="bi bi-hand-index"></i>,
title: '选择工具',
selectable: true,
onClick: () => {
}
},
{
id: 'crop',
icon: <i className="bi bi-crop"></i>,
title: '裁剪工具',
selectable: true,
onClick: () => {
}
},
'separator',
{
id: 'add',
icon: <i className="bi bi-plus-lg"></i>,
title: '添加',
selectable: false,
onClick: () => {
setCount(prev => prev + 1);
}
},
{
id: 'subtract',
icon: <i className="bi bi-dash-lg"></i>,
title: '减少',
selectable: false,
onClick: () => {
setCount(prev => prev - 1);
}
},
'separator',
{
id: 'reset',
icon: <i className="bi bi-arrow-clockwise"></i>,
title: '重置',
selectable: false,
onClick: () => {
setCount(0);
}
}
];
return (
<div>
<h2></h2>
<div style={{ display: 'flex', gap: '20px', alignItems: 'flex-start', marginTop: '20px' }}>
<SideToolBar
tools={tools}
selectedToolId={selectedToolId}
onSelectTool={handleToolSelect}
/>
<div>
<h3>: {count}</h3>
<p>使</p>
<p>ID: {selectedToolId || '无'}</p>
<p></p>
</div>
</div>
<div style={{ marginTop: '20px' }}>
<h3>使</h3>
<ul>
<li></li>
<li></li>
<li></li>
<li> selectable=true </li>
<li></li>
<li></li>
<li></li>
</ul>
</div>
</div>
);
}
// PropertyGrid 演示组件
function PropertyGridDemo(): JSX.Element {
const [text, setText] = useState('示例文本');
const [number, setNumber] = useState(42);
const [color, setColor] = useState('#4a90e2');
const [checked, setChecked] = useState(false);
const [selected, setSelected] = useState('option1');
const [petName, setPetName] = useState('Kotone');
const [favoriteColor, setFavoriteColor] = useState('Blue');
const [firstName, setFirstName] = useState('John');
const [notes, setNotes] = useState('这是一段备注信息...');
const properties: Array<Property | PropertyCategory> = [
// 无分类的单独属性
{
title: '快速设置',
render: () => (
<div style={{ display: 'flex', gap: '8px' }}>
<Button onClick={() => setChecked(!checked)}>
{checked ? '禁用' : '启用'}
</Button>
<Button onClick={() => setNumber(prev => prev + 1)}>
+1
</Button>
</div>
)
},
// 无标题的全宽属性
{
render: () => (
<div style={{
padding: '8px',
background: '#fff9e6',
borderRadius: '4px',
fontSize: '14px',
color: '#666'
}}>
</div>
)
},
{
title: '连接设置',
properties: [
{
title: '宠物名称',
render: () => (
<input
type="text"
value={petName}
onChange={(e) => setPetName(e.target.value)}
style={{ width: '100%', padding: '4px' }}
/>
)
}
],
foldable: true
},
// 又一个无分类的属性
{
title: '状态',
render: () => (
<span style={{
padding: '4px 8px',
borderRadius: '4px',
backgroundColor: checked ? '#e6ffe6' : '#ffe6e6',
color: checked ? '#006600' : '#cc0000'
}}>
{checked ? '已启用' : '已禁用'}
</span>
)
},
{
title: '基本信息',
properties: [
{
title: '喜欢的颜色',
render: () => (
<input
type="text"
value={favoriteColor}
onChange={(e) => setFavoriteColor(e.target.value)}
style={{ width: '100%', padding: '4px' }}
/>
)
},
{
title: '名字',
render: () => (
<input
type="text"
value={firstName}
onChange={(e) => setFirstName(e.target.value)}
style={{ width: '100%', padding: '4px' }}
/>
)
},
// 分类中的无标题属性
{
render: () => (
<div style={{
padding: '8px',
background: '#f0f0f0',
borderRadius: '4px',
fontSize: '14px'
}}>
</div>
)
}
],
foldable: true
},
{
title: '其他设置',
properties: [
{
title: '文本输入',
render: () => (
<input
type="text"
value={text}
onChange={(e) => setText(e.target.value)}
style={{ width: '100%', padding: '4px' }}
/>
)
},
{
title: '数字输入',
render: () => (
<input
type="number"
value={number}
onChange={(e) => setNumber(Number(e.target.value))}
style={{ width: '100px', padding: '4px' }}
/>
)
},
{
title: '颜色选择',
render: () => (
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
<input
type="color"
value={color}
onChange={(e) => setColor(e.target.value)}
/>
<span>{color}</span>
</div>
)
},
{
title: '复选框',
render: () => (
<input
type="checkbox"
checked={checked}
onChange={(e) => setChecked(e.target.checked)}
/>
)
},
{
title: '超长文本',
render: () => (
<div style={{ width: '100%', height: '100px', backgroundColor: '#f0f0f0' }}>
{Array(100).fill("这是一个超长文本用于测试PropertyGrid的折叠功能。").join("")}
</div>
)
}
],
foldable: true
},
// 最后添加一个备注输入框
{
title: '备注',
render: () => (
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
style={{
width: '100%',
padding: '4px',
minHeight: '60px',
resize: 'vertical'
}}
/>
)
}
];
return (
<div>
<h2></h2>
<div style={{ maxWidth: '600px', marginTop: '20px' }}>
<PropertyGrid properties={properties} />
</div>
<div style={{ marginTop: '20px' }}>
<h3></h3>
<pre style={{
backgroundColor: '#f5f5f5',
padding: '15px',
borderRadius: '4px'
}}>
{JSON.stringify({
petName,
favoriteColor,
firstName,
text,
number,
color,
checked,
selected,
notes
}, null, 2)}
</pre>
</div>
<div>
<h3>使</h3>
<ul>
<li>PropertyGrid </li>
<li></li>
<li></li>
<li></li>
<li>/</li>
<li></li>
<li></li>
<li> render </li>
<li></li>
</ul>
</div>
</div>
);
}
// ImageViewerModal 演示组件
function ImageViewerModalDemo(): JSX.Element {
const { modal, openModal } = useImageViewerModal('示例图片');
const demoImage = 'https://picsum.photos/1920/1080';
return (
<div>
<h2></h2>
<ControlPanel>
<Button onClick={() => openModal(demoImage)}></Button>
</ControlPanel>
{modal}
<div>
<h3>使</h3>
<ul>
<li>"打开图片"</li>
<li>使</li>
<li>使</li>
<ul>
<li>/</li>
<li></li>
<li></li>
</ul>
<li> X ESC </li>
<li>"示例图片"</li>
<li>使</li>
<li>使 useImageViewerModal Hook 便</li>
</ul>
</div>
</div>
);
}
const GlobalStyle = styled.div`
.modal-90w {
width: 90vw;
max-width: 90vw !important;
}
`;
// 主Demo组件
function Demo() {
const navigate = useNavigate();
@ -777,40 +1293,47 @@ function Demo() {
const demos = [
{ id: 'messageBox', name: '消息框', component: MessageBoxDemo },
{ id: 'spinner', name: '加载动画', component: SpinnerDemo },
{ id: 'toast', name: 'Toast 消息', component: ToastMessageDemo },
{ id: 'singleViewer', name: '单图片查看器', component: SingleImageViewerDemo },
{ id: 'multipleViewer', name: '多图片查看器', component: MultipleImagesViewerDemo },
{ id: 'imageViewerModal', name: '图片查看器模态框', component: ImageViewerModalDemo },
{ id: 'rectBox', name: '矩形框', component: RectBoxDemo },
{ id: 'rectMask', name: '遮罩层', component: RectMaskDemo },
{ id: 'imageEditor', name: '图片标注器', component: ImageEditorDemo },
{ id: 'nativeDiv', name: '原生 Div', component: NativeDivDemo }
{ id: 'nativeDiv', name: '原生 Div', component: NativeDivDemo },
{ id: 'sideToolBar', name: '工具栏', component: SideToolBarDemo },
{ id: 'propertyGrid', name: '属性网格', component: PropertyGridDemo }
];
return (
<DemoContainer>
<Sidebar>
{demos.map(demo => (
<MenuItem
key={demo.id}
active={currentDemo === demo.id}
onClick={() => navigate(`/demo/${demo.id}`)}
>
{demo.name}
</MenuItem>
))}
</Sidebar>
<Content>
<Routes>
<Route path="/" element={<Navigate to="/demo/messageBox" replace />} />
<>
<GlobalStyle />
<DemoContainer>
<Sidebar>
{demos.map(demo => (
<Route
<MenuItem
key={demo.id}
path={`${demo.id}`}
element={<demo.component />}
/>
active={currentDemo === demo.id}
onClick={() => navigate(`/demo/${demo.id}`)}
>
{demo.name}
</MenuItem>
))}
</Routes>
</Content>
</DemoContainer>
</Sidebar>
<Content>
<Routes>
<Route path="/" element={<Navigate to="/demo/messageBox" replace />} />
{demos.map(demo => (
<Route
key={demo.id}
path={`${demo.id}`}
element={<demo.component />}
/>
))}
</Routes>
</Content>
</DemoContainer>
</>
);
}

View File

@ -0,0 +1,91 @@
import React from 'react';
import styled from '@emotion/styled';
const DragAreaContainer = styled.div<{ isDragging: boolean }>`
position: relative;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background-color: ${props => props.isDragging ? 'rgba(0, 0, 0, 0.05)' : 'transparent'};
transition: background-color 0.2s;
`;
const DragOverlay = styled.div<{ isDragging: boolean }>`
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
background-color: rgba(255, 255, 255, 0.9);
border: 2px dashed #ccc;
pointer-events: none;
opacity: ${props => props.isDragging ? 1 : 0};
transition: opacity 0.2s;
z-index: 1000;
`;
const DragText = styled.div`
font-size: 1.2rem;
color: #666;
`;
interface DragAreaProps {
children?: React.ReactNode;
onImageLoad?: (imageUrl: string) => void;
}
const DragArea: React.FC<DragAreaProps> = ({ children, onImageLoad }) => {
const [isDragging, setIsDragging] = React.useState(false);
const handleDragOver = React.useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(true);
}, []);
const handleDragLeave = React.useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
}, []);
const handleDrop = React.useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
const files = Array.from(e.dataTransfer.files);
const imageFile = files.find(file => file.type.startsWith('image/'));
if (imageFile && onImageLoad) {
const reader = new FileReader();
reader.onload = (event) => {
if (event.target?.result) {
onImageLoad(event.target.result as string);
}
};
reader.readAsDataURL(imageFile);
}
}, [onImageLoad]);
return (
<DragAreaContainer
isDragging={isDragging}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
{children}
<DragOverlay isDragging={isDragging}>
<DragText></DragText>
</DragOverlay>
</DragAreaContainer>
);
};
export default DragArea;

View File

@ -0,0 +1,583 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import styled from '@emotion/styled';
import { SideToolBar, Tool } from '../../components/SideToolBar';
import PropertyGrid, { Property, PropertyCategory } from '../../components/PropertyGrid';
import ImageEditor, { AnnotationChangedEvent } from '../../components/ImageEditor/ImageEditor';
import { Annotation, Tool as EditorTool } from '../../components/ImageEditor/types';
import { BsCursor, BsSquare, BsFolder2Open, BsUpload, BsFloppy, BsDownload } from 'react-icons/bs';
import { useImmer } from 'use-immer';
import { useImageViewerModal } from '../../components/ImageViewerModal';
import { useMessageBox } from '../../hooks/useMessageBox';
import { useToast } from '../../components/ToastMessage';
import DragArea from './DragArea';
import { Definitions, ImageMetaData, TemplateDefinition, DefinitionType } from './types';
import { cropImage, openFileWFS, openFileInput, downloadJSONToFile, readFileAsJSON, readFileAsDataURL, FileResult, saveFileWFS } from '../../utils/fileUtils';
import NativeDiv from '../../components/NativeDiv';
const PageContainer = styled.div`
display: flex;
width: 100%;
height: 100vh;
gap: 16px;
padding: 16px;
background-color: #f8f9fa;
`;
const EditorContainer = styled(NativeDiv)`
flex: 1;
min-width: 0;
background-color: white;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
`;
const PropertyContainer = styled.div`
width: 300px;
background-color: white;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
padding: 16px;
`;
const Tip = styled.span`
font-weight: bold;
color: white;
text-shadow:
-1px -1px 0 black,
1px -1px 0 black,
-1px 1px 0 black,
1px 1px 0 black;
`;
// 工具栏配置
const tools: Array<Tool | 'separator'> = [
{
id: 'open',
icon: <BsFolder2Open size={24} />,
title: '打开 (WebFileSystem)',
selectable: false,
},
{
id: 'save',
icon: <BsFloppy size={24} />,
title: '保存 (WebFileSystem)',
selectable: false,
},
{
id: 'upload',
icon: <BsUpload size={24} />,
title: '上传 (Input)',
selectable: false,
},
{
id: 'download',
icon: <BsDownload size={24} />,
title: '下载 (Input)',
selectable: false,
},
'separator',
{
id: 'drag',
icon: <BsCursor size={24} />,
title: '拖动工具 (V)',
selectable: true,
},
{
id: 'rect',
icon: <BsSquare size={24} />,
title: '矩形工具 (R)',
selectable: true,
},
];
const toolsMap: Record<string, EditorTool> = {
drag: EditorTool.Drag,
rect: EditorTool.Rect,
};
// 示例图片URL
const SAMPLE_IMAGE_URL = 'https://picsum.photos/seed/123/800/600';
// 计算最大公约数
const gcd = (a: number, b: number): number => {
a = Math.abs(a);
b = Math.abs(b);
while (b) {
const t = b;
b = a % b;
a = t;
}
return a;
};
// 计算最简比例
const getSimplestRatio = (width: number, height: number): string => {
const divisor = gcd(width, height);
const simpleWidth = width / divisor;
const simpleHeight = height / divisor;
return `${simpleWidth}:${simpleHeight}`;
};
// 属性栏数据 Hook
const usePropertyGridData = (
selectedAnnotation: Annotation | null,
definitions: Definitions,
image: HTMLImageElement | null,
onImageClick: (imageUrl: string) => void,
onDefinitionChange?: (id: string, changes: Partial<TemplateDefinition>) => void,
imageFileName?: string,
annotations?: Annotation[],
currentFileResult?: FileResult | null
) => {
const [croppedImageUrl, setCroppedImageUrl] = React.useState<string>('');
React.useEffect(() => {
if (selectedAnnotation && image) {
const url = cropImage(image, selectedAnnotation.data);
setCroppedImageUrl(url);
} else {
setCroppedImageUrl('');
}
}, [selectedAnnotation, image]);
if (!selectedAnnotation) {
if (!image) return [];
return [
{
title: '文件名',
render: () => imageFileName || '未命名',
},
{
title: '打开方式',
render: () => currentFileResult?.fileSystem === 'wfs' ? 'WebFileSystem' : 'Input',
},
{
title: '宽高',
render: () => `${image.width} × ${image.height}`,
},
{
title: '宽高比',
render: () => getSimplestRatio(image.width, image.height),
},
{
title: '标注数量',
render: () => annotations?.length || 0,
}
];
}
const definition = definitions[selectedAnnotation.id];
const { x1, y1, x2, y2 } = selectedAnnotation.data;
const generalProperties: Array<PropertyCategory | Property> = [
{
render: () => {
if (!image) return <span>...</span>;
if (!croppedImageUrl) return <span>...</span>;
return (
<div style={{
height: '100px',
margin: '0 auto'
}}>
<img
src={croppedImageUrl}
style={{
maxWidth: '100%',
maxHeight: '100px',
objectFit: 'contain',
cursor: 'pointer',
}}
onClick={() => onImageClick(croppedImageUrl)}
/>
</div>
);
},
},
{
title: '通用',
properties: [
{
title: '名称',
render: {
type: 'text',
required: true,
value: definition.name,
onChange: (value: string) => onDefinitionChange?.(selectedAnnotation.id, { name: value }),
}
},
{
title: '显示名称',
render: {
type: 'text',
required: true,
value: definition.displayName,
onChange: (value: string) => onDefinitionChange?.(selectedAnnotation.id, { displayName: value }),
}
},
{
title: '类型',
render: () => definition.type,
}
],
foldable: true
},
];
const annotationProperties: Array<PropertyCategory | Property> = [
{
title: '标注',
properties: [
{
title: 'ID',
render: () => selectedAnnotation.id,
},
{
title: '类型',
render: () => '矩形',
},
{
title: '范围',
render: () => `(${x1}, ${y1}, ${x2}, ${y2})`,
},
{
title: '宽高',
render: () => `${x2 - x1} × ${y2 - y1}`,
}
],
foldable: true
}
];
let specificProperties: Array<PropertyCategory | Property> = [];
if (definition.type === 'template') {
const rectDef = definition as TemplateDefinition;
specificProperties = [
{
title: '模板',
properties: [
{
title: '提示矩形',
render: {
type: 'checkbox',
required: false,
value: rectDef.useHintRect,
onChange: (value: boolean) => onDefinitionChange?.(selectedAnnotation.id, { useHintRect: value }),
}
}
],
foldable: true,
}
];
}
return [
...generalProperties,
...specificProperties,
...annotationProperties,
];
};
const ImageAnnotation: React.FC = () => {
const [currentTool, setCurrentTool] = useState<EditorTool>(EditorTool.Drag);
const [imageMetaData, updateImageMetaData] = useImmer<ImageMetaData>({
definitions: {},
annotations: [],
});
const [selectedAnnotation, setSelectedAnnotation] = useState<Annotation | null>(null);
const [isDirty, setIsDirty] = useState(false);
const [image, setImage] = useState<HTMLImageElement | null>(null);
const imageFileNameRef = useRef<string>('');
const { modal, openModal } = useImageViewerModal('裁剪预览');
const [imageUrl, setImageUrl] = useState<string>(SAMPLE_IMAGE_URL);
const { yesNo, MessageBoxComponent } = useMessageBox();
const { showToast, ToastComponent } = useToast();
const currentFileResult = useRef<FileResult | null>(null);
// 预加载图片
useEffect(() => {
const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = () => {
setImage(img);
};
img.src = imageUrl;
}, [imageUrl]);
const handleImageLoad = useCallback((newImageUrl: string, shouldClearMetaData: boolean = true) => {
setImageUrl(newImageUrl);
if (shouldClearMetaData) {
// 只有在不是同时加载 meta 数据时才清空标注
updateImageMetaData(draft => {
draft.annotations = [];
draft.definitions = {};
});
setSelectedAnnotation(null);
setIsDirty(false);
}
}, []);
const handleAnnotationChange = (e: AnnotationChangedEvent) => {
if (e.type === 'add') {
let type: DefinitionType | undefined = undefined;
if (currentTool === EditorTool.Rect) {
type = 'template';
}
if (!type) {
showToast('danger', '错误', '无法识别的标注类型');
return;
}
updateImageMetaData(draft => {
draft.annotations.push(e.annotation);
draft.definitions[e.annotation.id] = {
name: '',
displayName: '',
type: type,
annotationId: e.annotation.id,
path: '',
useHintRect: false,
} as TemplateDefinition;
});
setIsDirty(true);
} else if (e.type === 'update') {
updateImageMetaData(draft => {
const oldAnnotation = draft.annotations.find(a => a.id === e.annotation.id);
if (!oldAnnotation) return;
Object.assign(oldAnnotation, e.annotation);
});
if (selectedAnnotation?.id === e.annotation.id) {
setSelectedAnnotation(e.annotation);
}
setIsDirty(true);
} else if (e.type === 'remove') {
updateImageMetaData(draft => {
const index = draft.annotations.findIndex(a => a.id === e.annotation.id);
if (index !== -1) {
draft.annotations.splice(index, 1);
}
});
if (selectedAnnotation?.id === e.annotation.id) {
setSelectedAnnotation(null);
updateImageMetaData(draft => {
delete draft.definitions[e.annotation.id];
});
}
setIsDirty(true);
}
};
const handleOpen = useCallback(async (useFileSystem: boolean = false) => {
// 如果有未保存的修改,显示确认对话框
if (isDirty) {
const result = await yesNo({
title: '未保存的修改',
text: '当前有未保存的修改,是否继续打开新图片?未保存的修改将会丢失。'
});
if (result === 'no') {
return;
}
}
try {
const openFunc = useFileSystem ? openFileWFS : openFileInput;
const result = await openFunc({
accept: 'image/*,.json',
multiple: true,
});
const imageFile = result.files.find((f: FileResult) => f.file.type.startsWith('image/'));
const jsonFile = result.files.find((f: FileResult) => f.file.name.endsWith('.json'));
if (imageFile) {
imageFileNameRef.current = imageFile.name;
const dataUrl = await readFileAsDataURL(imageFile.file);
handleImageLoad(dataUrl, !jsonFile);
}
// 保存文件句柄
if (jsonFile) {
currentFileResult.current = jsonFile;
try {
const metaData = await readFileAsJSON(jsonFile.file);
updateImageMetaData(draft => {
draft.annotations = metaData?.annotations || [];
draft.definitions = metaData?.definitions || {};
});
} catch (error) {
console.error('Failed to parse JSON file:', error);
throw new Error('JSON文件格式错误无法加载。');
}
}
if (imageFile && jsonFile) {
showToast('success', '加载成功', '已载入图片与 meta 数据');
} else if (imageFile) {
showToast('success', '加载成功', '已载入新图片');
} else if (jsonFile) {
showToast('success', '加载成功', '已载入 meta 数据');
}
} catch (error) {
await yesNo({
title: '错误',
text: error instanceof Error ? error.message : '加载文件时发生错误'
});
showToast('danger', '加载失败', '无法加载文件');
}
}, [handleImageLoad, isDirty, yesNo, showToast, updateImageMetaData]);
const handleUpload = useCallback(async () => {
await handleOpen(false);
}, [handleOpen]);
const handleDownload = useCallback(() => {
const data = imageMetaData;
const filename = imageFileNameRef.current ? `${imageFileNameRef.current}.json` : 'metadata.json';
downloadJSONToFile(data, filename);
setIsDirty(false);
}, [imageMetaData]);
const handleSave = useCallback(async () => {
if (currentFileResult.current?.fileSystem !== 'wfs') {
showToast('warning', '无法保存', '当前文件不是通过文件系统打开的');
return;
}
try {
const handle = await saveFileWFS(
currentFileResult.current?.handle,
JSON.stringify(imageMetaData),
imageFileNameRef.current ? `${imageFileNameRef.current}.json` : 'metadata.json'
);
// 更新文件句柄
if (handle !== currentFileResult.current?.handle) {
currentFileResult.current = {
file: await handle.getFile(),
name: (await handle.getFile()).name,
handle,
fileSystem: 'wfs'
};
}
setIsDirty(false);
showToast('success', '保存成功', '文件已保存');
} catch (error) {
console.error('Failed to save file:', error);
showToast('danger', '保存失败', '无法保存文件');
}
}, [currentFileResult, imageMetaData, showToast]);
const handleToolSelect = useCallback((id: string) => {
setCurrentTool(toolsMap[id]);
}, [toolsMap]);
const handleToolClick = useCallback((id: string) => {
if (id === 'upload') {
handleUpload();
} else if (id === 'open') {
handleOpen(true);
} else if (id === 'download') {
handleDownload();
} else if (id === 'save') {
handleSave();
}
}, [handleUpload, handleOpen, handleDownload, handleSave]);
const handleAnnotationSelect = (annotation: Annotation | null) => {
setSelectedAnnotation(annotation);
};
const handleDefinitionChange = (id: string, changes: Partial<TemplateDefinition>) => {
updateImageMetaData(draft => {
Object.assign(draft.definitions[id], changes);
const oldDef = draft.definitions[id];
const annotation = draft.annotations.find(a => a.id === id);
const displayName = changes.displayName || oldDef.displayName;
const name = changes.name || oldDef.name;
if (oldDef && annotation) {
annotation.tip = <Tip>{displayName} ({name})</Tip>;
}
});
};
const handleKeyDown = useCallback((e: KeyboardEvent) => {
// 如果正在输入文本,不处理快捷键
console.log(e.target);
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
return;
}
// 处理 Ctrl + S 保存快捷键
if (e.ctrlKey && e.key.toLowerCase() === 's') {
e.preventDefault(); // 阻止浏览器默认的保存行为
handleSave();
return;
}
const key = e.key.toLowerCase();
switch (key) {
case 'v':
setCurrentTool(EditorTool.Drag);
break;
case 'r':
setCurrentTool(EditorTool.Rect);
break;
case 'delete':
if (selectedAnnotation) {
handleAnnotationChange({
currentTool: EditorTool.Drag,
type: 'remove',
annotationType: 'rect',
annotation: selectedAnnotation
});
setSelectedAnnotation(null);
}
break;
}
}, [selectedAnnotation, handleAnnotationChange, handleSave]);
useEffect(() => {
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
};
}, [handleKeyDown]);
const properties = usePropertyGridData(
selectedAnnotation,
imageMetaData.definitions,
image,
(imageUrl) => openModal(imageUrl, { imageRendering: 'pixelated' }),
handleDefinitionChange,
imageFileNameRef.current,
imageMetaData.annotations,
currentFileResult.current
);
return (
<PageContainer>
<SideToolBar
tools={tools}
selectedToolId={currentTool}
onSelectTool={handleToolSelect}
onClickTool={handleToolClick}
/>
<EditorContainer>
<DragArea onImageLoad={handleImageLoad}>
<ImageEditor
image={imageUrl}
tool={currentTool}
annotations={imageMetaData.annotations}
onAnnotationChanged={handleAnnotationChange}
onAnnotationSelected={handleAnnotationSelect}
enableMask
showCrosshair
/>
</DragArea>
</EditorContainer>
<PropertyContainer>
<PropertyGrid properties={properties} />
</PropertyContainer>
{modal}
{MessageBoxComponent}
{ToastComponent}
</PageContainer>
);
};
export default ImageAnnotation;

View File

@ -0,0 +1,34 @@
import { Annotation } from '../../components/ImageEditor/types';
export type DefinitionType = 'template' | 'ocr' | 'color';
export interface BaseDefinition {
/** 最终出现在 R.py 中的名称 */
name: string;
/** 显示在调试器与调试输出中的名称 */
displayName: string;
type: DefinitionType;
/** 标注 ID */
annotationId: string;
}
export interface TemplateDefinition extends BaseDefinition {
type: 'template';
/**
*
*
*
* true
*
*/
useHintRect: boolean
}
export type Definitions = Record<string, BaseDefinition>;
export interface ImageMetaData {
definitions: Definitions;
annotations: Annotation[];
}

View File

@ -1,6 +1,7 @@
import { createBrowserRouter } from 'react-router-dom';
import { Home } from './pages/Home';
import Demo from './pages/Demo';
import ImageAnnotation from './pages/ImageAnnotation/ImageAnnotation';
export const router = createBrowserRouter([
{
@ -11,4 +12,8 @@ export const router = createBrowserRouter([
path: '/demo/*',
element: <Demo />,
},
{
path: '/image-annotation',
element: <ImageAnnotation />,
},
]);

View File

@ -1,40 +1,286 @@
import { DebugRecord } from '../types/debug';
export interface FileResult {
file: File;
name: string;
handle?: FileSystemFileHandle;
fileSystem: 'wfs' | 'input';
}
export const readJsonFile = async (file: File): Promise<DebugRecord[]> => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (event) => {
try {
const content = event.target?.result as string;
const data = JSON.parse(content);
if (Array.isArray(data) && data.every(item =>
item.id && item.timestamp && item.message)) {
resolve(data as DebugRecord[]);
} else {
reject(new Error('文件格式不正确'));
export interface OpenFilesOptions {
accept?: string;
multiple?: boolean;
}
export interface OpenFilesResult {
files: FileResult[];
}
/**
* 使 input
*/
export const openFileInput = async (options: OpenFilesOptions = {}): Promise<OpenFilesResult> => {
const {
multiple = false,
} = options;
return new Promise((resolve) => {
const input = document.createElement('input');
input.type = 'file';
input.accept = '.json,.png,.jpg,.jpeg';
input.multiple = multiple;
input.onchange = async (e) => {
const files = Array.from((e.target as HTMLInputElement).files || []);
if (files.length === 0) {
resolve({ files: [] });
return;
}
const results: FileResult[] = [];
for (const file of files) {
const result: FileResult = {
file,
name: file.name,
fileSystem: 'input'
};
try {
results.push(result);
} catch (error) {
console.error(`Failed to read file ${file.name}:`, error);
throw new Error(`无法读取文件 ${file.name}`);
}
}
resolve({ files: results });
};
input.click();
});
};
/**
* 使 Web File System API
*/
export const openFileWFS = async (options: OpenFilesOptions = {}): Promise<OpenFilesResult> => {
const {
multiple = false,
} = options;
try {
// @ts-ignore - FileSystemHandle API 可能在某些环境下不支持
const handles = await window.showOpenFilePicker({
types: [
{
description: '图片文件 / meta 数据',
accept: {
'image/jpeg': ['.jpg', '.jpeg'],
'image/png': ['.png'],
'application/json': ['.json']
}
},
],
multiple
});
const results: FileResult[] = [];
for (const handle of handles) {
const file = await handle.getFile();
results.push({
file,
name: file.name,
handle,
fileSystem: 'wfs'
});
}
} catch {
reject(new Error('无法解析JSON文件'));
}
};
reader.onerror = () => reject(new Error('读取文件失败'));
reader.readAsText(file);
});
return { files: results };
} catch (error) {
if ((error as Error).name === 'AbortError') {
return { files: [] };
}
throw error;
}
};
export const formatTimestamp = (timestamp: number): string => {
const date = new Date(timestamp);
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false
});
/**
* 使 FileSystem API退
*/
export const openFiles = async (options: OpenFilesOptions = {}): Promise<OpenFilesResult> => {
try {
// @ts-ignore - 检查是否支持 FileSystem API
if (window.showOpenFilePicker) {
return await openFileWFS(options);
} else {
return await openFileInput(options);
}
} catch (error) {
return await openFileInput(options);
}
};
export const generateUniqueId = (): string => {
return Date.now().toString(36) + Math.random().toString(36).substring(2);
};
/**
*
*/
export const readFileAsText = (file: File): Promise<string> => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (e) => resolve(e.target?.result as string);
reader.onerror = reject;
reader.readAsText(file);
});
};
/**
* DataURL
*/
export const readFileAsDataURL = (file: File): Promise<string> => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (e) => resolve(e.target?.result as string);
reader.onerror = reject;
reader.readAsDataURL(file);
});
};
/**
* JSON对象
*/
export const readFileAsJSON = (file: File): Promise<any> => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (e) => {
try {
const json = JSON.parse(e.target?.result as string);
resolve(json);
} catch (error) {
reject(error);
}
};
reader.onerror = reject;
reader.readAsText(file);
});
};
/**
* JSON数据到文件
*/
export const downloadJSONToFile = (data: any, filename: string) => {
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
};
/**
*
*/
export const cropImage = (
img: HTMLImageElement,
rect: { x1: number, y1: number, x2: number, y2: number }
): string => {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
const width = rect.x2 - rect.x1;
const height = rect.y2 - rect.y1;
canvas.width = width;
canvas.height = height;
ctx?.drawImage(
img,
rect.x1, rect.y1, width, height,
0, 0, width, height
);
return canvas.toDataURL('image/png');
};
/**
* 使 Web File System API
* @param content Blob ArrayBuffer
* @param suggestedName
* @returns FileSystemFileHandle
*/
export const saveFileAsWFS = async (content: string | Blob | ArrayBuffer, suggestedName: string): Promise<FileSystemFileHandle> => {
try {
// @ts-ignore - FileSystemHandle API 可能在某些环境下不支持
const handle = await window.showSaveFilePicker({
suggestedName,
types: [
{
description: 'JSON 文件',
accept: {
'application/json': ['.json']
}
}
]
});
// 获取写入权限并写入内容
const writable = await handle.createWritable();
await writable.write(content);
await writable.close();
return handle;
} catch (error) {
console.error('Failed to save file:', error);
throw new Error('保存文件失败');
}
};
/**
* 使 Web File System API
* @param handle FileSystemFileHandle
* @param content Blob ArrayBuffer
* @param suggestedName
* @returns FileSystemFileHandle handle
*/
export const saveFileWFS = async (
handle: FileSystemFileHandle | undefined | null,
content: string | Blob | ArrayBuffer,
suggestedName?: string
): Promise<FileSystemFileHandle> => {
try {
if (!handle) {
// 如果没有 handle执行另存为操作
return await saveFileAsWFS(content, suggestedName || 'metadata.json');
}
// 有 handle直接保存
const writable = await handle.createWritable();
await writable.write(content);
await writable.close();
return handle;
} catch (error) {
console.error('Failed to save file:', error);
throw new Error('保存文件失败');
}
};
/**
* Web File System API
* @param fileResult FileResult
* @returns 使 WFS API
*/
export const canSaveWithWFS = (fileResult?: FileResult): boolean => {
return !!fileResult?.handle;
};
/**
* 使 Web File System API
* @param handle FileSystemFileHandle
* @param imageData Base64 Blob
*/
export const saveImageWithHandle = async (handle: FileSystemFileHandle, imageData: string | Blob): Promise<void> => {
if (typeof imageData === 'string' && imageData.startsWith('data:')) {
// 将 Base64 转换为 Blob
const response = await fetch(imageData);
imageData = await response.blob();
}
await saveFileWFS(handle, imageData);
};

View File

@ -1,82 +0,0 @@
import { ViewState } from '../types/debug';
export const DEFAULT_VIEW_STATE: ViewState = {
scale: 1,
x: 0,
y: 0,
locked: false,
};
export const calculateImageDimensions = (
imageWidth: number,
imageHeight: number,
containerWidth: number,
containerHeight: number
): { width: number; height: number; scale: number } => {
const imageRatio = imageWidth / imageHeight;
const containerRatio = containerWidth / containerHeight;
let scale = 1;
let width = imageWidth;
let height = imageHeight;
if (imageRatio > containerRatio) {
// 图片更宽,以容器宽度为基准
if (imageWidth > containerWidth) {
scale = containerWidth / imageWidth;
width = containerWidth;
height = imageHeight * scale;
}
} else {
// 图片更高,以容器高度为基准
if (imageHeight > containerHeight) {
scale = containerHeight / imageHeight;
height = containerHeight;
width = imageWidth * scale;
}
}
return { width, height, scale };
};
export const calculateZoom = (
currentScale: number,
delta: number,
minScale = 0.1,
maxScale = 5
): number => {
const ZOOM_SENSITIVITY = 0.001;
const newScale = currentScale * (1 - delta * ZOOM_SENSITIVITY);
return Math.min(Math.max(newScale, minScale), maxScale);
};
export const getImageUrl = (
type: 'memory' | 'file',
path: string,
baseUrl = ''
): string => {
if (type === 'memory') {
return `${baseUrl}/api/read_memory?key=${encodeURIComponent(path)}`;
}
return `${baseUrl}/api/read_file?path=${encodeURIComponent(path)}`;
};
export const downloadImage = async (url: string, filename: string): Promise<void> => {
try {
const response = await fetch(url);
const blob = await response.blob();
const objectUrl = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = objectUrl;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(objectUrl);
} catch (error) {
console.error('下载图片失败:', error);
throw error;
}
};