feat(devtool): 实现 ImageEditor 组件

类似于图像标注的图像编辑器。目前实现了拖拽工具、矩形工具。
This commit is contained in:
XcantloadX 2025-01-30 19:20:18 +08:00
parent 87427950fc
commit e25731cd12
14 changed files with 3007 additions and 158 deletions

View File

@ -23,6 +23,7 @@ export default tseslint.config(
'warn',
{ allowConstantExport: true },
],
"no-unused-vars": [2, {"vars": "none", "args": "after-used"}]
},
},
)

File diff suppressed because it is too large Load Diff

View File

@ -15,12 +15,14 @@
"@types/node": "^22.12.0",
"bootstrap": "^5.3.3",
"bootstrap-icons": "^1.11.3",
"fabric": "^6.5.4",
"immer": "^10.1.1",
"react": "^18.3.1",
"react-bootstrap": "^2.10.8",
"react-dom": "^18.3.1",
"react-router-dom": "^7.1.3",
"use-immer": "^0.11.0",
"uuid": "^11.0.5",
"zustand": "^5.0.3"
},
"devDependencies": {

View File

@ -0,0 +1,191 @@
import { ToolHandlerProps } from "./ImageEditor";
import RectMask from "./RectMask";
import { useState, useEffect } from "react";
import RectBox from "./RectBox";
import useLatestCallback from "../../hooks/useLatestCallback";
import { Annotation, RectPoints } from "./types";
function DragTool(props: ToolHandlerProps) {
const { editorState, containerRef, editorProps, Convertor, updateAnnotation, queryAnnotation } = props;
const [state, updateState] = editorState;
const [hoveredRectId, setHoveredRectId] = useState<string | null>(null);
const [selectedRectId, setSelectedRectId] = useState<string | null>(null);
// 处理拖拽开始
const handleMouseDown = useLatestCallback((e: React.MouseEvent) => {
e.preventDefault();
// 如果当前有选中的矩形,不启动图像拖拽
if (selectedRectId !== null || hoveredRectId !== null) return;
console.log('DragTool: handleMouseDown', 'selectedRectId=', selectedRectId, 'hoveredRectId=', hoveredRectId);
updateState(draft => {
draft.isDragging = true;
draft.dragStart = {
x: e.clientX - draft.position.x,
y: e.clientY - draft.position.y,
};
});
});
// 处理拖拽过程
const handleMouseMove = useLatestCallback((e: MouseEvent) => {
if (!state.isDragging) return;
const rect = containerRef.current?.getBoundingClientRect();
if (!rect) return;
console.log('DragTool: handleMouseMove', e);
updateState(draft => {
draft.position = {
x: e.clientX - draft.dragStart.x,
y: e.clientY - draft.dragStart.y,
};
draft.mousePosition = {
x: e.clientX - rect.left,
y: e.clientY - rect.top
};
});
});
// 处理拖拽结束
const handleMouseUp = useLatestCallback(() => {
updateState(draft => {
draft.isDragging = false;
});
});
// 处理矩形变换
const handleRectTransform = useLatestCallback((rectPoints: RectPoints, id: string) => {
console.log('DragTool: handleRectTransform');
updateAnnotation('rect', {
id: id,
type: 'rect',
data: rectPoints,
});
});
useEffect(() => {
const container = containerRef.current;
if (!container) return;
container.addEventListener('mousedown', handleMouseDown as any);
container.addEventListener('mousemove', handleMouseMove as any);
container.addEventListener('mouseup', handleMouseUp);
container.addEventListener('mouseleave', handleMouseUp);
return () => {
container.removeEventListener('mousedown', handleMouseDown as any);
container.removeEventListener('mousemove', handleMouseMove as any);
container.removeEventListener('mouseup', handleMouseUp);
container.removeEventListener('mouseleave', handleMouseUp);
};
}, [containerRef.current]);
const handleRectMouseEnter = (id: string) => {
console.log('DragTool: handleRectMouseEnter', id);
setHoveredRectId(id);
};
const handleRectMouseLeave = () => {
console.log('DragTool: handleRectMouseLeave');
setHoveredRectId(null);
};
// 点击矩形选中矩形矩形模式move -> resize
const handleRectClick = (id: string, e: MouseEvent) => {
e.stopPropagation(); // 阻止事件冒泡到容器
console.log('DragTool: handleRectClick', 'selectedRectId=', selectedRectId);
setSelectedRectId(id);
};
// 点击容器,取消选中
const handleContainerClick = (e: MouseEvent) => {
console.log('DragTool: handleContainerClick', 'selectedRectId=', selectedRectId, e);
setSelectedRectId(null);
setHoveredRectId(null);
};
// 点击容器中的非矩形区域,取消选中当前矩形
useEffect(() => {
const container = containerRef.current;
if (!container) return;
container.addEventListener('click', handleContainerClick);
return () => {
container.removeEventListener('click', handleContainerClick);
};
}, [containerRef]);
// 矩形框的颜色
// 1. 没有 hover 任何矩形,所有矩形显示为白色
// 2. hover 到某个矩形,该矩形显示为白色,其他矩形显示为半透明
// 3. 选中某个矩形,该矩形显示为白色,其他矩形显示为半透明
const getLineColor = (id: string) => {
if (hoveredRectId === null)
return "white";
if (selectedRectId === id || hoveredRectId === id)
return "white";
return "rgba(255, 255, 255, 0.3)";
};
// 是否显示遮罩
const shouldShowMask = () => {
if (!editorProps.enableMask)
return false;
if (selectedRectId === null && hoveredRectId === null)
return true;
if (selectedRectId === hoveredRectId)
return false;
if (selectedRectId !== null)
return false;
if (hoveredRectId !== null)
return true;
return false;
};
const renderAnnotationWithHover = (annotations?: Annotation[]) => {
if (!annotations) return null;
return annotations.map((anno) => (
<RectBox
key={anno.id}
mode={selectedRectId === anno.id ? "resize" : "move"}
rect={Convertor.rectImage2Container(anno.data)}
lineColor={getLineColor(anno.id)}
onNativeMouseEnter={() => handleRectMouseEnter(anno.id)}
onNativeMouseMove={handleMouseMove}
onNativeMouseLeave={handleRectMouseLeave}
onNativeClick={(e) => handleRectClick(anno.id, e)}
onTransform={(points) => handleRectTransform(points, anno.id)}
rectTip="rect"
showRectTip={hoveredRectId === anno.id && selectedRectId === null}
/>
));
};
console.log('DragTool: render', editorProps.annotations);
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}
/>
)
}
{renderAnnotationWithHover(editorProps.annotations)}
</>
);
}
export default DragTool;

View File

@ -0,0 +1,287 @@
import React, { useRef, useEffect } 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 NativeDiv from '../NativeDiv';
const EditorContainer = styled(NativeDiv)<{ isDragging: boolean }>`
width: 100%;
height: 100%;
position: relative;
overflow: hidden;
background: #f0f0f0;
user-select: none;
cursor: ${props => props.isDragging ? 'grabbing' : 'grab'};
`;
const EditorImage = styled.img<{ scale: number; x: number; y: number }>`
image-rendering: pixelated;
position: absolute;
transform: translate(${props => props.x}px, ${props => props.y}px) scale(${props => props.scale});
transform-origin: left top;
pointer-events: none;
`;
const CrosshairLine = styled(NativeDiv)<{ isVertical?: boolean; position: number }>`
position: absolute;
background-color: rgba(255, 255, 255, 0);
border: none;
${props => props.isVertical
? `
height: 100%;
width: 0;
left: ${props.position}px;
border-left: 2px dashed rgba(255, 255, 255, 1);
`
: `
width: 100%;
height: 0;
top: ${props.position}px;
border-top: 2px dashed rgba(255, 255, 255, 1);
`
}
pointer-events: none;
`;
export type AnnotationChangeType = 'add' | 'remove' | 'update';
export interface AnnotationChangedEvent {
currentTool: Tool;
type: AnnotationChangeType;
annotationType: AnnotationType;
annotation: Annotation;
}
export interface ImageEditorProps {
image: string;
tool?: Tool;
initialScale?: number;
showCrosshair?: boolean;
annotations: Annotation[];
onAnnotationChanged?: (e: AnnotationChangedEvent) => void;
enableMask?: boolean;
maskAlpha?: number;
}
export interface ImageEditorRef {
reset: () => void;
setScale: (scale: number) => void;
getScale: () => number;
}
export interface EditorState {
scale: number;
position: Point;
isDragging: boolean;
dragStart: Point;
mousePosition: Point;
}
export interface PostionConvertor {
posContainer2Image: (pos: Point) => Point;
posImage2Container: (pos: Point) => Point;
rectImage2Container: (rect: RectPoints) => RectPoints;
rectContainer2Image: (rect: RectPoints) => RectPoints;
}
export interface ToolHandlerProps {
editorState: [EditorState, Updater<EditorState>];
editorProps: ImageEditorProps;
containerRef: React.RefObject<HTMLDivElement>;
renderAnnotation: (annotations?: Annotation[]) => React.ReactNode;
addAnnotation: (annotation: Annotation) => void;
updateAnnotation: (type: AnnotationType, annotation: Annotation) => void;
queryAnnotation: (id: string) => Annotation;
Convertor: PostionConvertor;
}
const ImageEditor = React.forwardRef<ImageEditorRef, ImageEditorProps>((props, ref) => {
const {
image,
initialScale = 1,
showCrosshair = false,
tool = Tool.Drag,
onAnnotationChanged,
annotations = [],
} = props;
const [state, updateState] = useImmer<EditorState>({
scale: initialScale,
position: { x: 0, y: 0 },
isDragging: false,
dragStart: { x: 0, y: 0 },
mousePosition: { x: 0, y: 0 }
});
const containerRef = useRef<HTMLDivElement>(null);
const Convertor = {
/**
*
* @param pos
* @returns
*/
posContainer2Image: (pos: Point) => {
return {
x: Math.round((pos.x - state.position.x) / state.scale),
y: Math.round((pos.y - state.position.y) / state.scale)
};
},
/**
*
* @param pos
* @returns
*/
posImage2Container: (pos: Point) => {
return {
x: pos.x * state.scale + state.position.x,
y: pos.y * state.scale + state.position.y
};
},
/**
*
* @param rect
* @returns
*/
rectImage2Container: (rect: RectPoints) => {
return {
x1: Convertor.posImage2Container({ x: rect.x1, y: rect.y1 }).x,
y1: Convertor.posImage2Container({ x: rect.x1, y: rect.y1 }).y,
x2: Convertor.posImage2Container({ x: rect.x2, y: rect.y2 }).x,
y2: Convertor.posImage2Container({ x: rect.x2, y: rect.y2 }).y
};
},
/**
*
* @param rect
* @returns
*/
rectContainer2Image: (rect: RectPoints) => {
return {
x1: Convertor.posContainer2Image({ x: rect.x1, y: rect.y1 }).x,
y1: Convertor.posContainer2Image({ x: rect.x1, y: rect.y1 }).y,
x2: Convertor.posContainer2Image({ x: rect.x2, y: rect.y2 }).x,
y2: Convertor.posContainer2Image({ x: rect.x2, y: rect.y2 }).y
};
}
};
const renderAnnotation = (annotations?: Annotation[]) => {
if (!annotations) return null;
return annotations.map((rect) => (
<RectBox
key={rect.id}
mode="resize"
rect={Convertor.rectImage2Container(rect.data)}
/>
));
};
const addAnnotation = (annotation: Annotation) => {
console.log('Add annotation: ', annotation);
onAnnotationChanged?.({
currentTool: Tool.Rect,
type: 'add',
annotationType: 'rect',
annotation: annotation
});
};
const updateAnnotation = (type: AnnotationType, annotation: Annotation) => {
console.log('Update rect: ', annotation);
onAnnotationChanged?.({
currentTool: Tool.Rect,
type: 'update',
annotationType: type,
annotation: annotation
});
};
const queryAnnotation = (id: string) => {
let ret = null;
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,
containerRef,
addAnnotation,
updateAnnotation,
renderAnnotation,
queryAnnotation,
Convertor,
};
// 处理鼠标滚轮缩放
const handleWheel = (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));
});
};
// 暴露组件方法
React.useImperativeHandle(ref, () => ({
reset: () => {
updateState(draft => {
draft.scale = initialScale;
draft.position = { x: 0, y: 0 };
});
},
setScale: (newScale: number) => {
updateState(draft => {
draft.scale = newScale;
});
},
getScale: () => state.scale
}));
// 添加和移除滚轮事件监听器
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 }}
>
<EditorImage
src={image}
scale={state.scale}
x={state.position.x}
y={state.position.y}
draggable={false}
/>
{showCrosshair && tool === Tool.Rect && (
<>
<CrosshairLine isVertical position={state.mousePosition.x} />
<CrosshairLine position={state.mousePosition.y} />
</>
)}
{tool === Tool.Rect && <RectTool {...toolHandlerProps} />}
{tool === Tool.Drag && <DragTool {...toolHandlerProps} />}
</EditorContainer>
);
});
ImageEditor.displayName = 'ImageEditor';
export default ImageEditor;

View File

@ -0,0 +1,285 @@
import styled from '@emotion/styled';
import { RectPoints } from './types';
import { useEffect, useRef, ReactNode } from 'react';
import NativeDiv from '../NativeDiv';
type RectMode = 'move' | 'resize' | 'none';
const Handle = styled(NativeDiv)<{ $mode: RectMode }>`
position: absolute;
width: 12px;
height: 12px;
background: white;
border: 1px solid #666;
transform: translate(-50%, -50%);
cursor: pointer;
z-index: 2;
box-sizing: border-box;
display: ${props => props.$mode === 'resize' ? 'block' : 'none'};
`;
const ResizeArea = styled(NativeDiv)<{ position: string }>`
position: absolute;
z-index: 1;
width: 100%;
${props => {
switch (props.position) {
case 'top':
return 'top: -3px; left: 0; height: 6px; cursor: n-resize;';
case 'bottom':
return 'bottom: -3px; left: 0; height: 6px; cursor: s-resize;';
case 'left':
return 'left: -3px; top: 0; bottom: 0; width: 6px; cursor: w-resize;';
case 'right':
return 'right: -3px; top: 0; bottom: 0; width: 6px; cursor: e-resize;';
default:
return '';
}
}}
`;
const RectBoxContainer = styled(NativeDiv)<{ rect: RectPoints; mode: RectMode; $lineColor: string }>`
position: absolute;
border: 3px solid ${props => props.$lineColor};
transition: border 0.1s ease-in;
background: transparent;
left: ${props => props.rect.x1}px;
top: ${props => props.rect.y1}px;
width: ${props => Math.abs(props.rect.x2 - props.rect.x1)}px;
height: ${props => Math.abs(props.rect.y2 - props.rect.y1)}px;
`;
const DragArea = styled(NativeDiv)`
position: absolute;
z-index: 1;
width: 100%;
height: 100%;
cursor: move;
`;
const RectTipContainer = styled(NativeDiv)`
position: absolute;
top: calc(-3px);
left: 0;
z-index: 3;
transform: translateY(-100%);
`;
interface ObjectRectProps {
rect: RectPoints;
/**
* resize
*
* * move: 移动
* * resize: 调整大小
* * none:
*/
mode?: RectMode;
/**
* 线
*/
lineColor?: string;
/**
*
*/
rectTip?: ReactNode;
/**
* `rectTip`
*/
showRectTip?: boolean;
onTransform?: (points: RectPoints) => void;
onNativeMouseEnter?: (e: MouseEvent) => void;
onNativeMouseMove?: (e: MouseEvent) => void;
onNativeMouseLeave?: (e: MouseEvent) => void;
onNativeClick?: (e: MouseEvent) => void;
}
type DragSource =
'top-edge' | 'bottom-edge' | 'left-edge' | 'right-edge' |
'top-left-corner' | 'top-right-corner' | 'bottom-left-corner' | 'bottom-right-corner' |
'move' |
null;
function useLatestProps<T>(props: T) {
const ref = useRef(props);
useEffect(() => {
ref.current = props;
}, [props]);
return ref;
}
/**
*
*/
function RectBox(props: ObjectRectProps) {
const {
rect,
mode = 'resize',
lineColor = 'white',
rectTip,
showRectTip = false,
onTransform,
onNativeMouseEnter,
onNativeMouseLeave,
onNativeClick
} = props;
const dragRef = useRef({
dragging: false,
source: null as DragSource,
mouseStartX: 0,
mouseStartY: 0,
originalRect: null as RectPoints | null,
newRect: null as RectPoints | null,
});
const propsRef = useLatestProps(props);
const containerRef = useRef<HTMLDivElement>(null);
const handleMouseDown = (e: MouseEvent, source: DragSource) => {
e.preventDefault();
e.stopPropagation();
const { rect } = propsRef.current;
dragRef.current.dragging = true;
dragRef.current.source = source;
dragRef.current.mouseStartX = e.clientX;
dragRef.current.mouseStartY = e.clientY;
dragRef.current.originalRect = rect;
dragRef.current.newRect = rect;
console.log('RectBox: handleMouseDown', source, dragRef.current.newRect);
};
const handleMouseMove = (e: MouseEvent) => {
if (!dragRef.current.dragging || !dragRef.current.originalRect) return;
e.preventDefault();
e.stopPropagation();
const deltaX = e.clientX - dragRef.current.mouseStartX;
const deltaY = e.clientY - dragRef.current.mouseStartY;
const startX = dragRef.current.originalRect.x1;
const startY = dragRef.current.originalRect.y1;
const endX = dragRef.current.originalRect.x2;
const endY = dragRef.current.originalRect.y2;
const points = {x1: startX, y1: startY, x2: endX, y2: endY};
if (dragRef.current.source === 'top-edge')
points.y1 = startY + deltaY;
else if (dragRef.current.source === 'bottom-edge')
points.y2 = endY + deltaY;
else if (dragRef.current.source === 'left-edge')
points.x1 = startX + deltaX;
else if (dragRef.current.source === 'right-edge')
points.x2 = endX + deltaX;
else if (dragRef.current.source === 'top-left-corner') {
points.x1 = startX + deltaX;
points.y1 = startY + deltaY;
}
else if (dragRef.current.source === 'top-right-corner') {
points.x2 = endX + deltaX;
points.y1 = startY + deltaY;
}
else if (dragRef.current.source === 'bottom-left-corner') {
points.x1 = startX + deltaX;
points.y2 = endY + deltaY;
}
else if (dragRef.current.source === 'bottom-right-corner') {
points.x2 = endX + deltaX;
points.y2 = endY + deltaY;
}
else if (dragRef.current.source === 'move') {
points.x1 = startX + deltaX;
points.y1 = startY + deltaY;
points.x2 = endX + deltaX;
points.y2 = endY + deltaY;
}
console.log('RectBox: handleMouseMove', dragRef.current.source);
dragRef.current.newRect = points;
onTransform?.(points);
};
const handleMouseUp = (e: MouseEvent) => {
if (!dragRef.current.dragging || !dragRef.current.newRect) return;
e.preventDefault();
e.stopPropagation();
console.log('RectBox: handleMouseUp');
onTransform?.({ ...dragRef.current.newRect });
dragRef.current.dragging = false;
dragRef.current.source = null;
dragRef.current.mouseStartX = 0;
dragRef.current.mouseStartY = 0;
dragRef.current.originalRect = null;
dragRef.current.newRect = null;
};
const handleClick = (e: MouseEvent) => {
console.log('RectBox: handleClick');
e.stopPropagation();
if (!dragRef.current.dragging) {
onNativeClick?.(e);
}
};
const stopPropagation = (e: MouseEvent) => {
console.log('RectBox: stopPropagation', e);
e.stopPropagation();
};
const makeEvents = (source: DragSource) => ({
onNativeMouseDown: (e: MouseEvent) => handleMouseDown(e, source),
});
useEffect(() => {
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
return () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
}, []);
return (
<RectBoxContainer
ref={containerRef}
rect={rect}
mode={mode}
$lineColor={lineColor}
>
{showRectTip && rectTip && (
<RectTipContainer>
{rectTip}
</RectTipContainer>
)}
<DragArea
{...makeEvents('move')}
onNativeMouseEnter={onNativeMouseEnter}
onNativeMouseLeave={onNativeMouseLeave}
onNativeClick={handleClick}
/>
{mode === 'resize' && (
<>
<ResizeArea position="top" {...makeEvents('top-edge')} onNativeClick={stopPropagation} />
<ResizeArea position="bottom" {...makeEvents('bottom-edge')} onNativeClick={stopPropagation} />
<ResizeArea position="left" {...makeEvents('left-edge')} onNativeClick={stopPropagation} />
<ResizeArea position="right" {...makeEvents('right-edge')} onNativeClick={stopPropagation} />
<Handle $mode={mode} style={{ left: 0, top: 0, cursor: 'nw-resize' }}
{...makeEvents('top-left-corner')}
onNativeClick={stopPropagation}
/>
<Handle $mode={mode} style={{ left: '100%', top: 0, cursor: 'ne-resize' }}
{...makeEvents('top-right-corner')}
onNativeClick={stopPropagation}
/>
<Handle $mode={mode} style={{ left: 0, top: '100%', cursor: 'sw-resize' }}
{...makeEvents('bottom-left-corner')}
onNativeClick={stopPropagation}
/>
<Handle $mode={mode} style={{ left: '100%', top: '100%', cursor: 'se-resize' }}
{...makeEvents('bottom-right-corner')}
onNativeClick={stopPropagation}
/>
</>
)}
</RectBoxContainer>
);
};
export default RectBox;

View File

@ -0,0 +1,57 @@
import { RectPoints } from "./types";
import styled from '@emotion/styled';
const MaskContainer = styled.div`
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
`;
interface RectMaskProps {
rects: RectPoints[];
alpha?: number;
transition?: boolean;
}
function RectMask({ rects, alpha = 0.5, transition = false }: RectMaskProps) {
return (
<MaskContainer>
<svg width="100%" height="100%" style={{ position: 'absolute' }}>
<defs>
<mask id="rectMask">
{/* 首先创建一个黑色背景(完全遮罩) */}
<rect x="0" y="0" width="100%" height="100%" fill="white" />
{/* 然后在矩形区域创建白色区域(透明) */}
{rects.map((rect, index) => (
<rect
key={index}
x={rect.x1}
y={rect.y1}
width={rect.x2 - rect.x1}
height={rect.y2 - rect.y1}
fill="black"
// style={transition ? { transition: 'all 0.1s ease-in-out' } : undefined}
/>
))}
</mask>
</defs>
{/* 使用遮罩的矩形 */}
<rect
x="0"
y="0"
width="100%"
height="100%"
fill={`rgba(0, 0, 0, ${alpha})`}
mask="url(#rectMask)"
style={transition ? { transition: 'fill 0.2s ease-in-out' } : undefined}
/>
</svg>
</MaskContainer>
);
}
export default RectMask;

View File

@ -0,0 +1,83 @@
import { ToolHandlerProps } from "./ImageEditor";
import { useEffect, useState } from "react";
import RectBox from "./RectBox";
import { 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 handleMouseDown = useLatestCallback((e: MouseEvent) => {
e.preventDefault();
const rect = containerRef.current?.getBoundingClientRect();
if (rect) {
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
setRectStart({ x, y });
setRectEnd({ x, y });
}
console.log('RectTool: handleMouseDown');
});
// 处理拖拽过程
const handleMouseMove = useLatestCallback((e: MouseEvent) => {
const rect = containerRef.current?.getBoundingClientRect();
if (!rect) return;
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
setRectEnd({ x, y });
});
// 处理拖拽结束
const handleMouseUp = useLatestCallback(() => {
if (!rectStart || !rectEnd) return;
let x1 = Math.min(rectStart.x, rectEnd.x);
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
});
setRectStart(null);
setRectEnd(null);
console.log('RectTool: handleMouseUp');
});
useEffect(() => {
const container = containerRef.current;
if (!container) return;
container.addEventListener('mousedown', handleMouseDown);
container.addEventListener('mousemove', handleMouseMove);
container.addEventListener('mouseup', handleMouseUp);
return () => {
container.removeEventListener('mousedown', handleMouseDown);
container.removeEventListener('mousemove', handleMouseMove);
container.removeEventListener('mouseup', handleMouseUp);
};
}, [containerRef]);
return (
<>
{rectStart && rectEnd && (
<RectBox rect={{x1: rectStart.x, y1: rectStart.y, x2: rectEnd.x, y2: rectEnd.y}} />
)}
{renderAnnotation(editorProps.annotations)}
</>
);
}
export default RectTool;

View File

@ -0,0 +1,28 @@
export enum Tool {
Drag = 'drag',
Rect = 'rect',
}
/** 标注类型。 */
export type AnnotationType = 'rect';
type AnnotationTypeMap = {
rect: RectPoints,
};
export interface Annotation{
id: string;
type: AnnotationType;
data: AnnotationTypeMap[AnnotationType];
}
export interface Point {
x: number;
y: number;
}
export interface RectPoints {
x1: number;
y1: number;
x2: number;
y2: number;
}

View File

@ -0,0 +1,75 @@
import { HTMLAttributes, forwardRef, useEffect, useRef } from 'react';
interface NativeDivProps extends HTMLAttributes<HTMLDivElement> {
onNativeMouseEnter?: (e: MouseEvent) => void;
onNativeMouseMove?: (e: MouseEvent) => void;
onNativeMouseLeave?: (e: MouseEvent) => void;
onNativeMouseDown?: (e: MouseEvent) => void;
onNativeClick?: (e: MouseEvent) => void;
}
/**
* div
*
*
* * onNativeMouseEnter
* * onNativeMouseMove
* * onNativeMouseLeave
* * onNativeMouseDown
* * onNativeClick
*
* DOM
*/
const NativeDiv = forwardRef<HTMLDivElement, NativeDivProps>((props, ref) => {
const {
onNativeMouseEnter,
onNativeMouseMove,
onNativeMouseLeave,
onNativeMouseDown,
onNativeClick,
...restProps
} = props;
const elementRef = useRef<HTMLDivElement | null>(null);
if (ref) {
if (typeof ref === 'function') {
ref(elementRef.current);
} else {
ref.current = elementRef.current;
}
}
useEffect(() => {
const element = elementRef.current;
if (!element) return;
if (onNativeMouseEnter)
element.addEventListener('mouseenter', onNativeMouseEnter);
if (onNativeMouseMove)
element.addEventListener('mousemove', onNativeMouseMove);
if (onNativeMouseLeave)
element.addEventListener('mouseleave', onNativeMouseLeave);
if (onNativeMouseDown)
element.addEventListener('mousedown', onNativeMouseDown);
if (onNativeClick)
element.addEventListener('click', onNativeClick);
return () => {
if (onNativeMouseEnter)
element.removeEventListener('mouseenter', onNativeMouseEnter);
if (onNativeMouseMove)
element.removeEventListener('mousemove', onNativeMouseMove);
if (onNativeMouseLeave)
element.removeEventListener('mouseleave', onNativeMouseLeave);
if (onNativeMouseDown)
element.removeEventListener('mousedown', onNativeMouseDown);
if (onNativeClick)
element.removeEventListener('click', onNativeClick);
};
}, [onNativeMouseEnter, onNativeMouseMove, onNativeMouseLeave, onNativeClick]);
return <div ref={elementRef} {...restProps} />;
});
NativeDiv.displayName = 'NativeDiv';
export default NativeDiv;

View File

@ -0,0 +1,100 @@
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

@ -0,0 +1,15 @@
import { useRef } from "react";
import { useCallback } from "react";
// https://github.com/facebook/react/issues/14099
function useLatestCallback<T extends (...args: any[]) => any>(callback: T): T {
const ref = useRef(callback);
const latest = useCallback((...args: Parameters<T>) => {
return ref.current(...args);
}, []);
ref.current = callback;
return latest as T;
}
export default useLatestCallback;

View File

@ -1,19 +1,50 @@
import React, { useRef } from 'react';
import { useRef, useState } from 'react';
import styled from '@emotion/styled';
import { useNavigate, useLocation, Routes, Route, Navigate } from 'react-router-dom';
import ImageViewer, { ImageViewerRef } from '../components/ImageViewer';
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 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';
// 布局相关的样式组件
const DemoContainer = styled.div`
padding: 20px;
display: flex;
flex-direction: column;
gap: 20px;
width: 80%;
margin: 0 auto;
height: 100vh;
width: 100%;
`;
const Sidebar = styled.div`
width: 200px;
background-color: #f5f5f5;
padding: 20px;
border-right: 1px solid #ddd;
`;
const Content = styled.div`
flex: 1;
padding: 20px;
overflow-y: auto;
`;
const MenuItem = styled.div<{ active: boolean }>`
padding: 10px;
margin-bottom: 8px;
cursor: pointer;
border-radius: 4px;
background-color: ${props => props.active ? '#e0e0e0' : 'transparent'};
&:hover {
background-color: ${props => props.active ? '#e0e0e0' : '#eaeaea'};
}
`;
// 通用样式组件
const ViewerContainer = styled.div`
height: 500px;
border: 1px solid #ccc;
@ -24,6 +55,7 @@ const ControlPanel = styled.div`
display: flex;
gap: 10px;
align-items: center;
margin: 20px 0;
`;
const Button = styled.button`
@ -38,45 +70,179 @@ const Button = styled.button`
}
`;
const Section = styled.section`
margin-bottom: 40px;
// 新增 RectBox 演示相关的样式组件
const RectBoxContainer = styled.div`
width: 100%;
height: 400px;
background-color: #2c2c2c;
position: relative;
border-radius: 4px;
overflow: hidden;
`;
function Demo() {
const viewerRef = useRef<ImageViewerRef>(null);
const RectBoxPlayground = styled.div`
width: 100%;
height: 100%;
position: relative;
`;
const ModeSelect = styled.select`
padding: 8px;
border-radius: 4px;
margin-right: 10px;
`;
// 添加 NativeDiv 演示组件的样式
const NativeDivPlayground = styled.div`
width: 100%;
height: 400px;
background-color: #f0f0f0;
position: relative;
border-radius: 4px;
padding: 20px;
`;
const DemoNativeDiv = styled(NativeDiv)`
width: 200px;
height: 200px;
background-color: #4a90e2;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
color: white;
cursor: pointer;
transition: transform 0.2s;
user-select: none;
&:hover {
transform: scale(1.05);
}
`;
// 图片标注器相关的样式组件
const EditorLayout = styled.div`
display: flex;
gap: 20px;
margin-bottom: 20px;
`;
const EditorMain = styled.div`
flex: 7;
display: flex;
flex-direction: column;
`;
const EditorSidebar = styled.div`
flex: 3;
background: #f5f5f5;
border-radius: 4px;
padding: 15px;
height: 500px;
overflow-y: auto;
`;
const AnnotationItem = styled.div`
padding: 10px;
background: white;
border-radius: 4px;
margin-bottom: 10px;
border: 1px solid #ddd;
&:hover {
border-color: #aaa;
}
`;
// 消息框演示组件
function MessageBoxDemo(): JSX.Element {
const messageBox = useMessageBox();
const handleShowYesNo = async () => {
const result = await messageBox.yesNo({
title: '确认操作',
text: '您确定要执行此操作吗?'
});
alert(`您选择了: ${result === 'yes' ? '是' : '否'}`);
};
const handleShowOk = async () => {
await messageBox.ok({
title: '操作成功',
text: '数据已成功保存!'
});
};
const handleShowConfirmCancel = async () => {
const result = await messageBox.confirmCancel({
title: '删除确认',
text: '此操作将永久删除所选项目,是否继续?'
});
alert(`您选择了: ${result === 'confirm' ? '确认' : '取消'}`);
};
return (
<div>
<h2></h2>
<ControlPanel>
<Button onClick={handleShowYesNo}>/</Button>
<Button onClick={handleShowOk}></Button>
<Button onClick={handleShowConfirmCancel}>/</Button>
</ControlPanel>
<div>
<h3>使</h3>
<ul>
<li>"是/否对话框"/</li>
<li>"提示对话框"</li>
<li>"确认/取消对话框"/</li>
</ul>
</div>
{messageBox.MessageBoxComponent}
</div>
);
}
// 加载动画演示组件
function SpinnerDemo(): JSX.Element {
const spinner = useFullscreenSpinner();
// 示例图片,这里使用一个在线图片
const demoImage = 'https://picsum.photos/800/600';
const handleShowSpinner = () => {
spinner.show('加载中...');
setTimeout(() => {
spinner.hide();
}, 3000);
};
// 多图片查看器的示例数据
const demoImageGroups = [
{
mainIndex: 0,
images: [
'https://picsum.photos/800/600?random=1',
'https://picsum.photos/800/600?random=2',
'https://picsum.photos/800/600?random=3'
]
},
{
mainIndex: 1,
images: [
'https://picsum.photos/800/600?random=4',
'https://picsum.photos/800/600?random=5'
]
},
{
mainIndex: 0,
images: [
'https://picsum.photos/800/600?random=6',
'https://picsum.photos/800/600?random=7',
'https://picsum.photos/800/600?random=8',
'https://picsum.photos/800/600?random=9'
]
}
];
const handleShowSpinnerWithoutMessage = () => {
spinner.show();
setTimeout(() => {
spinner.hide();
}, 3000);
};
return (
<div>
<h2></h2>
<ControlPanel>
<Button onClick={handleShowSpinner}></Button>
<Button onClick={handleShowSpinnerWithoutMessage}></Button>
</ControlPanel>
<div>
<h3>使</h3>
<ul>
<li>3</li>
<li></li>
<li></li>
</ul>
</div>
</div>
);
}
// 单图片查看器演示组件
function SingleImageViewerDemo(): JSX.Element {
const viewerRef = useRef<ImageViewerRef>(null);
const demoImage = 'https://picsum.photos/800/600';
const handleReset = () => {
viewerRef.current?.reset('all');
@ -106,134 +272,544 @@ function Demo() {
}
};
const handleShowYesNo = async () => {
const result = await messageBox.yesNo({
title: '确认操作',
text: '您确定要执行此操作吗?'
});
alert(`您选择了: ${result === 'yes' ? '是' : '否'}`);
return (
<div>
<h2></h2>
<ViewerContainer>
<ImageViewer
ref={viewerRef}
image={demoImage}
zoomable={true}
movable={true}
/>
</ViewerContainer>
<ControlPanel>
<Button onClick={handleZoomIn}></Button>
<Button onClick={handleZoomOut}></Button>
<Button onClick={handleFit}></Button>
<Button onClick={handleReset}></Button>
<Button onClick={handleResetPosition}></Button>
<Button onClick={handleResetZoom}></Button>
</ControlPanel>
<div>
<h3>使</h3>
<ul>
<li></li>
<li></li>
<li>"适应"使</li>
<li>"重置位置"</li>
<li>"重置缩放"</li>
<li>"完全重置"</li>
</ul>
</div>
</div>
);
}
// 多图片查看器演示组件
function MultipleImagesViewerDemo(): JSX.Element {
const [currentGroupIndex, setCurrentGroupIndex] = useState(0);
const [currentImageIndex, setCurrentImageIndex] = useState(0);
const demoImageGroups = [
{
mainIndex: 0,
images: [
'https://picsum.photos/800/600?random=1',
'https://picsum.photos/800/600?random=2',
'https://picsum.photos/800/600?random=3'
]
},
{
mainIndex: 1,
images: [
'https://picsum.photos/800/600?random=4',
'https://picsum.photos/800/600?random=5'
]
},
{
mainIndex: 0,
images: [
'https://picsum.photos/800/600?random=6',
'https://picsum.photos/800/600?random=7',
'https://picsum.photos/800/600?random=8',
'https://picsum.photos/800/600?random=9'
]
}
];
return (
<div>
<h2></h2>
<ViewerContainer>
<MultipleImagesViewer
currentGroup={demoImageGroups[currentGroupIndex]}
groupCount={demoImageGroups.length}
groupIndex={currentGroupIndex}
imageIndex={currentImageIndex}
onGotoGroup={setCurrentGroupIndex}
onGotoImage={setCurrentImageIndex}
/>
</ViewerContainer>
<div>
<h3>使</h3>
<ul>
<li>使</li>
<li>使</li>
<li>Home/End /</li>
<li></li>
<li></li>
<li></li>
</ul>
</div>
</div>
);
}
// 图片标注器演示组件
function ImageEditorDemo(): JSX.Element {
const editorRef = useRef<ImageEditorRef>(null);
const [showCrosshair, setShowCrosshair] = useState(false);
const [currentTool, setCurrentTool] = useState<Tool>(Tool.Drag);
const [annotations, setAnnotations] = useState<Annotation[]>([]);
const [showMask, setShowMask] = useState(true);
const [maskAlpha, setMaskAlpha] = useState(0.5);
const demoImage = 'https://picsum.photos/800/600';
const handleEditorReset = () => {
editorRef.current?.reset();
};
const handleShowOk = async () => {
await messageBox.ok({
title: '操作成功',
text: '数据已成功保存!'
});
const handleEditorZoomIn = () => {
if (editorRef.current) {
const currentScale = editorRef.current.getScale();
editorRef.current.setScale(currentScale * 1.1);
}
};
const handleShowConfirmCancel = async () => {
const result = await messageBox.confirmCancel({
title: '删除确认',
text: '此操作将永久删除所选项目,是否继续?'
});
alert(`您选择了: ${result === 'confirm' ? '确认' : '取消'}`);
const handleEditorZoomOut = () => {
if (editorRef.current) {
const currentScale = editorRef.current.getScale();
editorRef.current.setScale(currentScale * 0.9);
}
};
const handleShowSpinner = () => {
spinner.show('加载中...');
setTimeout(() => {
spinner.hide();
}, 3000);
const handleClearAnnotations = () => {
setAnnotations([]);
};
const handleShowSpinnerWithoutMessage = () => {
spinner.show();
setTimeout(() => {
spinner.hide();
}, 3000);
const handleAnnotationChanged = (e: AnnotationChangedEvent) => {
console.log('AnnotationChangedEvent: ', e);
if (e.type === 'update') {
const target = annotations.find(anno => anno.id === e.annotation.id);
if (target) {
target.data = e.annotation.data;
}
setAnnotations([...annotations]);
}
else if (e.type === 'remove') {
const targetIndex = annotations.findIndex(anno => anno.id === e.annotation.id);
if (targetIndex !== -1) {
setAnnotations(prev => {
const newAnnotations = [...prev];
newAnnotations.splice(targetIndex, 1);
return newAnnotations;
});
}
}
else if (e.type === 'add') {
setAnnotations(prev => [e.annotation, ...prev]);
}
};
return (
<DemoContainer>
{messageBox.MessageBoxComponent}
<Section>
<h2></h2>
<ControlPanel>
<Button onClick={handleShowYesNo}>/</Button>
<Button onClick={handleShowOk}></Button>
<Button onClick={handleShowConfirmCancel}>/</Button>
</ControlPanel>
<div>
<h2></h2>
<EditorLayout>
<EditorMain>
<ViewerContainer>
<ImageEditor
ref={editorRef}
annotations={annotations}
image={demoImage}
initialScale={1}
showCrosshair={showCrosshair}
tool={currentTool}
onAnnotationChanged={handleAnnotationChanged}
enableMask={showMask}
maskAlpha={maskAlpha}
/>
</ViewerContainer>
<ControlPanel>
<Button onClick={handleEditorZoomIn}></Button>
<Button onClick={handleEditorZoomOut}></Button>
<Button onClick={handleEditorReset}></Button>
<Button onClick={() => setShowCrosshair(!showCrosshair)}>
{showCrosshair ? '隐藏准线' : '显示准线'}
</Button>
<Button onClick={() => setCurrentTool(currentTool === Tool.Drag ? Tool.Rect : Tool.Drag)}>
{currentTool === Tool.Drag ? '切换到矩形工具' : '切换到拖动工具'}
</Button>
<Button onClick={handleClearAnnotations}></Button>
<Button onClick={() => setShowMask(!showMask)}>
{showMask ? '隐藏遮罩' : '显示遮罩'}
</Button>
<FormRange
style={{ flex: '0 1 200px' }}
value={maskAlpha}
onChange={(e) => setMaskAlpha(Number(e.target.value))}
min={0}
max={1}
step={0.01}
/>
<span>{annotations.length}</span>
</ControlPanel>
</EditorMain>
<EditorSidebar>
<h3></h3>
{annotations.length === 0 ? (
<div style={{ color: '#666', textAlign: 'center', marginTop: '20px' }}>
</div>
) : (
annotations.map((annotation, index) => (
<AnnotationItem key={annotation.id}>
<div> #{index + 1}</div>
<div>ID: {annotation.id}</div>
<div>
: ({annotation.data.x1}, {annotation.data.y1}) - ({annotation.data.x2}, {annotation.data.y2})
</div>
<div>
: {Math.abs(annotation.data.x2 - annotation.data.x1)} x {Math.abs(annotation.data.y2 - annotation.data.y1)}
</div>
</AnnotationItem>
))
)}
</EditorSidebar>
</EditorLayout>
<div>
<h3>使</h3>
<ul>
<li>使</li>
<li></li>
<li>"重置"</li>
<li>使"放大""缩小"</li>
<li>"显示准线"/线</li>
<li>"切换到矩形工具"</li>
<li></li>
<li>"清除标注"</li>
<li>"显示遮罩"/</li>
<li>使</li>
</ul>
</div>
</div>
);
}
<div>
<h3>使</h3>
<ul>
<li>"是/否对话框"/</li>
<li>"提示对话框"</li>
<li>"确认/取消对话框"/</li>
</ul>
</div>
</Section>
// RectBox 演示组件
function RectBoxDemo(): JSX.Element {
const [mode, setMode] = useState<'move' | 'resize' | 'none'>('resize');
const [rect, setRect] = useState({ x1: 50, y1: 50, x2: 200, y2: 200 });
const [lineColor, setLineColor] = useState('#ffffff');
const [showRectTip, setShowRectTip] = useState(true);
const [eventStatus, setEventStatus] = useState({
isHovered: false,
lastClickTime: '',
mousePosition: { x: 0, y: 0 }
});
<Section>
<h2></h2>
<ControlPanel>
<Button onClick={handleShowSpinner}></Button>
<Button onClick={handleShowSpinnerWithoutMessage}></Button>
</ControlPanel>
const rectTip = (
<div style={{
background: 'rgba(0, 0, 0, 0.7)',
color: 'white',
padding: '4px 8px',
borderRadius: '4px',
fontSize: '12px',
whiteSpace: 'nowrap'
}}>
{`${rect.x2 - rect.x1}x${rect.y2 - rect.y1}`}
</div>
);
<div>
<h3>使</h3>
<ul>
<li>3</li>
<li></li>
<li></li>
</ul>
</div>
</Section>
const handleTransform = (points: RectPoints) => {
setRect(points);
};
<Section>
<h2></h2>
<ViewerContainer>
<ImageViewer
ref={viewerRef}
image={demoImage}
zoomable={true}
movable={true}
const handleMouseEnter = (e: MouseEvent) => {
setEventStatus(prev => ({
...prev,
isHovered: true,
mousePosition: { x: e.clientX, y: e.clientY }
}));
};
const handleMouseLeave = (e: MouseEvent) => {
setEventStatus(prev => ({
...prev,
isHovered: false,
mousePosition: { x: e.clientX, y: e.clientY }
}));
};
const handleClick = (e: MouseEvent) => {
const now = new Date().toLocaleTimeString();
setEventStatus(prev => ({
...prev,
lastClickTime: now,
mousePosition: { x: e.clientX, y: e.clientY }
}));
};
return (
<div>
<h2></h2>
<ControlPanel>
<ModeSelect value={mode} onChange={(e) => setMode(e.target.value as 'move' | 'resize' | 'none')}>
<option value="resize"></option>
<option value="move"></option>
<option value="none"></option>
</ModeSelect>
<input
type="color"
value={lineColor}
onChange={(e) => setLineColor(e.target.value)}
style={{ marginLeft: '10px', marginRight: '10px' }}
/>
<Button onClick={() => {
setRect({ x1: 50, y1: 50, x2: 200, y2: 200 });
}}></Button>
<Button onClick={() => setShowRectTip(!showRectTip)}>
{showRectTip ? '隐藏尺寸提示' : '显示尺寸提示'}
</Button>
</ControlPanel>
<RectBoxContainer>
<RectBoxPlayground>
<RectBox
rect={rect}
mode={mode}
lineColor={lineColor}
rectTip={rectTip}
showRectTip={showRectTip}
onTransform={handleTransform}
onNativeMouseEnter={handleMouseEnter}
onNativeMouseLeave={handleMouseLeave}
onNativeClick={handleClick}
/>
</ViewerContainer>
</RectBoxPlayground>
</RectBoxContainer>
<div style={{
marginTop: '20px',
padding: '15px',
border: '1px solid #ddd',
borderRadius: '4px',
backgroundColor: '#f9f9f9'
}}>
<h3></h3>
<ul>
<li>{eventStatus.isHovered ? '是' : '否'}</li>
<li>{eventStatus.lastClickTime || '未点击'}</li>
<li>X: {eventStatus.mousePosition.x}, Y: {eventStatus.mousePosition.y}</li>
</ul>
</div>
<div>
<h3>使</h3>
<ul>
<li></li>
<li>使线</li>
<li></li>
<li></li>
<li></li>
<li>"重置位置"</li>
<li>"显示/隐藏尺寸提示"</li>
<li>({rect.x1}, {rect.y1}), ({rect.x2}, {rect.y2})</li>
<li> onMouseEnter </li>
<li> onMouseLeave </li>
<li> onClick </li>
</ul>
</div>
</div>
);
}
<ControlPanel>
<Button onClick={handleZoomIn}></Button>
<Button onClick={handleZoomOut}></Button>
<Button onClick={handleFit}></Button>
<Button onClick={handleReset}></Button>
<Button onClick={handleResetPosition}></Button>
<Button onClick={handleResetZoom}></Button>
</ControlPanel>
// RectMask 演示组件
function RectMaskDemo(): JSX.Element {
const [rects, setRects] = useState<RectPoints[]>([
{ x1: 50, y1: 50, x2: 150, y2: 150 },
{ x1: 200, y1: 150, x2: 350, y2: 250 }
]);
const [alpha, setAlpha] = useState(0.5);
const [enableTransition, setEnableTransition] = useState(false);
<div>
<h3>使</h3>
<ul>
<li></li>
<li></li>
<li>"适应"使</li>
<li>"重置位置"</li>
<li>"重置缩放"</li>
<li>"完全重置"</li>
</ul>
</div>
</Section>
const handleAddRect = () => {
setRects([...rects, {
x1: Math.random() * 300,
y1: Math.random() * 300,
x2: Math.random() * 300,
y2: Math.random() * 300
}]);
};
<Section>
<h2></h2>
<ViewerContainer>
<MultipleImagesViewer images={demoImageGroups} />
</ViewerContainer>
const handleClear = () => {
setRects([]);
};
<div>
<h3>使</h3>
<ul>
<li>使</li>
<li>使</li>
<li>Home/End /</li>
<li></li>
<li></li>
<li></li>
</ul>
</div>
</Section>
return (
<div>
<h2></h2>
<ControlPanel>
<Button onClick={handleAddRect}></Button>
<Button onClick={handleClear}></Button>
<Button onClick={() => setEnableTransition(!enableTransition)}>
{enableTransition ? '禁用过渡动画' : '启用过渡动画'}
</Button>
<FormRange
style={{ flex: '0 1 300px' }}
value={alpha}
onChange={(e) => setAlpha(Number(e.target.value))}
min={0}
max={1}
step={0.01}
/>
<span>: {rects.length}</span>
</ControlPanel>
<RectBoxContainer>
<img
src="https://picsum.photos/800/600"
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
alt="Demo"
/>
<RectMask rects={rects} alpha={alpha} transition={enableTransition} />
</RectBoxContainer>
<div>
<h3>使</h3>
<ul>
<li></li>
<li></li>
<li>"添加随机矩形"</li>
<li>"清除所有"</li>
<li>"启用过渡动画"/</li>
<li> {rects.length} </li>
<li>{enableTransition ? '已启用' : '已禁用'}</li>
</ul>
</div>
</div>
);
}
// NativeDiv 演示组件
function NativeDivDemo(): JSX.Element {
const [events, setEvents] = useState<Array<{ type: string; time: string; position: string }>>([]);
const addEvent = (type: string, e: MouseEvent) => {
const newEvent = {
type,
time: new Date().toLocaleTimeString(),
position: `(${e.clientX}, ${e.clientY})`
};
setEvents(prev => [newEvent, ...prev].slice(0, 5));
};
const handleNativeMouseEnter = (e: MouseEvent) => {
addEvent('mouseenter', e);
};
const handleNativeMouseMove = (e: MouseEvent) => {
addEvent('mousemove', e);
};
const handleNativeMouseLeave = (e: MouseEvent) => {
addEvent('mouseleave', e);
};
const handleNativeMouseDown = (e: MouseEvent) => {
addEvent('mousedown', e);
};
const handleNativeClick = (e: MouseEvent) => {
addEvent('click', e);
};
return (
<div>
<h2> Div </h2>
<NativeDivPlayground>
<DemoNativeDiv
onNativeMouseEnter={handleNativeMouseEnter}
onNativeMouseMove={handleNativeMouseMove}
onNativeMouseLeave={handleNativeMouseLeave}
onNativeClick={handleNativeClick}
onNativeMouseDown={handleNativeMouseDown}
>
</DemoNativeDiv>
</NativeDivPlayground>
<div style={{ marginTop: '20px' }}>
<h3></h3>
<ul>
{events.map((event, index) => (
<li key={index}>
{event.type} - {event.time} - {event.position}
</li>
))}
</ul>
</div>
<div>
<h3>使</h3>
<ul>
<li> NativeDiv DOM </li>
<li> mouseenter </li>
<li> mousemove </li>
<li> mouseleave </li>
<li> click </li>
<li></li>
</ul>
</div>
</div>
);
}
// 主Demo组件
function Demo() {
const navigate = useNavigate();
const location = useLocation();
const currentDemo = location.pathname.split('/').pop() || 'messageBox';
const demos = [
{ id: 'messageBox', name: '消息框', component: MessageBoxDemo },
{ id: 'spinner', name: '加载动画', component: SpinnerDemo },
{ id: 'singleViewer', name: '单图片查看器', component: SingleImageViewerDemo },
{ id: 'multipleViewer', name: '多图片查看器', component: MultipleImagesViewerDemo },
{ id: 'rectBox', name: '矩形框', component: RectBoxDemo },
{ id: 'rectMask', name: '遮罩层', component: RectMaskDemo },
{ id: 'imageEditor', name: '图片标注器', component: ImageEditorDemo },
{ id: 'nativeDiv', name: '原生 Div', component: NativeDivDemo }
];
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 />} />
{demos.map(demo => (
<Route
key={demo.id}
path={`${demo.id}`}
element={<demo.component />}
/>
))}
</Routes>
</Content>
</DemoContainer>
);
}

View File

@ -8,7 +8,7 @@ export const router = createBrowserRouter([
element: <Home />,
},
{
path: '/demo',
path: '/demo/*',
element: <Demo />,
},
]);