feat(devtool): 实现 ImageEditor 组件
类似于图像标注的图像编辑器。目前实现了拖拽工具、矩形工具。
This commit is contained in:
parent
87427950fc
commit
e25731cd12
|
@ -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
|
@ -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": {
|
||||
|
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
|
@ -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
|
||||
};
|
||||
};
|
|
@ -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;
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -8,7 +8,7 @@ export const router = createBrowserRouter([
|
|||
element: <Home />,
|
||||
},
|
||||
{
|
||||
path: '/demo',
|
||||
path: '/demo/*',
|
||||
element: <Demo />,
|
||||
},
|
||||
]);
|
Loading…
Reference in New Issue