parent
e25731cd12
commit
9b7ecd9884
|
@ -18,6 +18,7 @@
|
|||
"react": "^18.3.1",
|
||||
"react-bootstrap": "^2.10.8",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-icons": "^5.4.0",
|
||||
"react-router-dom": "^7.1.3",
|
||||
"use-immer": "^0.11.0",
|
||||
"uuid": "^11.0.5",
|
||||
|
@ -3939,6 +3940,14 @@
|
|||
"react": "^18.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/react-icons": {
|
||||
"version": "5.4.0",
|
||||
"resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.4.0.tgz",
|
||||
"integrity": "sha512-7eltJxgVt7X64oHh6wSWNwwbKTCtMfK35hcjvJS0yxEAhPM8oUKdS3+kqaW1vicIltw+kR2unHaa12S9pPALoQ==",
|
||||
"peerDependencies": {
|
||||
"react": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/react-is": {
|
||||
"version": "16.13.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||
|
|
|
@ -20,6 +20,7 @@
|
|||
"react": "^18.3.1",
|
||||
"react-bootstrap": "^2.10.8",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-icons": "^5.4.0",
|
||||
"react-router-dom": "^7.1.3",
|
||||
"use-immer": "^0.11.0",
|
||||
"uuid": "^11.0.5",
|
||||
|
|
|
@ -22,8 +22,8 @@ function DragTool(props: ToolHandlerProps) {
|
|||
updateState(draft => {
|
||||
draft.isDragging = true;
|
||||
draft.dragStart = {
|
||||
x: e.clientX - draft.position.x,
|
||||
y: e.clientY - draft.position.y,
|
||||
x: e.clientX - draft.imagePosition.x,
|
||||
y: e.clientY - draft.imagePosition.y,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
@ -36,7 +36,7 @@ function DragTool(props: ToolHandlerProps) {
|
|||
console.log('DragTool: handleMouseMove', e);
|
||||
|
||||
updateState(draft => {
|
||||
draft.position = {
|
||||
draft.imagePosition = {
|
||||
x: e.clientX - draft.dragStart.x,
|
||||
y: e.clientY - draft.dragStart.y,
|
||||
};
|
||||
|
@ -58,10 +58,16 @@ function DragTool(props: ToolHandlerProps) {
|
|||
// 处理矩形变换
|
||||
const handleRectTransform = useLatestCallback((rectPoints: RectPoints, id: string) => {
|
||||
console.log('DragTool: handleRectTransform');
|
||||
const rect = Convertor.rectContainer2Image({
|
||||
x1: rectPoints.x1,
|
||||
y1: rectPoints.y1,
|
||||
x2: rectPoints.x2,
|
||||
y2: rectPoints.y2,
|
||||
});
|
||||
updateAnnotation('rect', {
|
||||
id: id,
|
||||
type: 'rect',
|
||||
data: rectPoints,
|
||||
data: rect,
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -97,6 +103,9 @@ function DragTool(props: ToolHandlerProps) {
|
|||
e.stopPropagation(); // 阻止事件冒泡到容器
|
||||
console.log('DragTool: handleRectClick', 'selectedRectId=', selectedRectId);
|
||||
setSelectedRectId(id);
|
||||
const anno = queryAnnotation(id);
|
||||
if (anno)
|
||||
editorProps.onAnnotationSelected?.(anno);
|
||||
};
|
||||
|
||||
// 点击容器,取消选中
|
||||
|
@ -104,6 +113,7 @@ function DragTool(props: ToolHandlerProps) {
|
|||
console.log('DragTool: handleContainerClick', 'selectedRectId=', selectedRectId, e);
|
||||
setSelectedRectId(null);
|
||||
setHoveredRectId(null);
|
||||
editorProps.onAnnotationSelected?.(null);
|
||||
};
|
||||
// 点击容器中的非矩形区域,取消选中当前矩形
|
||||
useEffect(() => {
|
||||
|
@ -133,6 +143,8 @@ function DragTool(props: ToolHandlerProps) {
|
|||
const shouldShowMask = () => {
|
||||
if (!editorProps.enableMask)
|
||||
return false;
|
||||
if (editorProps.annotations.length === 0)
|
||||
return false;
|
||||
if (selectedRectId === null && hoveredRectId === null)
|
||||
return true;
|
||||
if (selectedRectId === hoveredRectId)
|
||||
|
@ -157,32 +169,56 @@ function DragTool(props: ToolHandlerProps) {
|
|||
onNativeMouseLeave={handleRectMouseLeave}
|
||||
onNativeClick={(e) => handleRectClick(anno.id, e)}
|
||||
onTransform={(points) => handleRectTransform(points, anno.id)}
|
||||
rectTip="rect"
|
||||
rectTip={anno.tip}
|
||||
showRectTip={hoveredRectId === anno.id && selectedRectId === null}
|
||||
/>
|
||||
));
|
||||
};
|
||||
|
||||
console.log('DragTool: render', editorProps.annotations);
|
||||
return (
|
||||
<>
|
||||
{
|
||||
hoveredRectId !== null ? (
|
||||
let rectMask;
|
||||
if (hoveredRectId !== null) {
|
||||
// 当有矩形被悬停时,只显示该矩形的遮罩
|
||||
const rect = queryAnnotation(hoveredRectId);
|
||||
if (rect) {
|
||||
rectMask = (
|
||||
<RectMask
|
||||
rects={[Convertor.rectImage2Container(queryAnnotation(hoveredRectId)?.data)]}
|
||||
rects={[{...rect.data}]}
|
||||
alpha={shouldShowMask() ? 0.7 : 0}
|
||||
transition={true}
|
||||
scale={state.imageScale}
|
||||
transform={{
|
||||
x: state.imagePosition.x,
|
||||
y: state.imagePosition.y,
|
||||
width: editorProps.imageSize?.width || 0,
|
||||
height: editorProps.imageSize?.height || 0
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// 默认显示所有矩形的遮罩
|
||||
rectMask = (
|
||||
<RectMask
|
||||
rects={editorProps.annotations?.map(anno => Convertor.rectImage2Container(anno.data)) || []}
|
||||
rects={editorProps.annotations?.map(anno => ({
|
||||
// 获取图像坐标
|
||||
...anno.data
|
||||
})) || []}
|
||||
alpha={shouldShowMask() ? editorProps.maskAlpha : 0}
|
||||
transition={true}
|
||||
scale={state.imageScale}
|
||||
transform={{
|
||||
x: state.imagePosition.x,
|
||||
y: state.imagePosition.y,
|
||||
width: editorProps.imageSize?.width || 0,
|
||||
height: editorProps.imageSize?.height || 0
|
||||
}}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{rectMask}
|
||||
{renderAnnotationWithHover(editorProps.annotations)}
|
||||
</>
|
||||
);
|
||||
|
|
|
@ -1,11 +1,12 @@
|
|||
import React, { useRef, useEffect } from 'react';
|
||||
import React, { useRef, useEffect, useState, useMemo } from 'react';
|
||||
import styled from '@emotion/styled';
|
||||
import { Updater, useImmer } from 'use-immer';
|
||||
import RectTool from './RectTool';
|
||||
import DragTool from './DragTool';
|
||||
import { Tool, Point, RectPoints, Annotation, AnnotationType } from './types';
|
||||
import RectBox from './RectBox';
|
||||
import { Tool, Point, RectPoints, Annotation, AnnotationType, Optional } from './types';
|
||||
import RectBox, { RectBoxProps } from './RectBox';
|
||||
import NativeDiv from '../NativeDiv';
|
||||
import useLatestCallback from '../../hooks/useLatestCallback';
|
||||
|
||||
const EditorContainer = styled(NativeDiv)<{ isDragging: boolean }>`
|
||||
width: 100%;
|
||||
|
@ -61,8 +62,21 @@ export interface ImageEditorProps {
|
|||
showCrosshair?: boolean;
|
||||
annotations: Annotation[];
|
||||
onAnnotationChanged?: (e: AnnotationChangedEvent) => void;
|
||||
onAnnotationSelected?: (annotation: Annotation | null) => void;
|
||||
enableMask?: boolean;
|
||||
maskAlpha?: number;
|
||||
imageSize?: {
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
/**
|
||||
* 编辑器的缩放模式。默认为 `wheel`。
|
||||
*
|
||||
* * `wheel` - 使用滚轮缩放,Ctrl + 滚轮上下移动,Shift + 滚轮左右移动
|
||||
* * `ctrlWheel` - 使用 Ctrl + 滚轮缩放,滚轮上下移动,Shift + 滚轮左右移动
|
||||
*/
|
||||
scaleMode?: 'wheel' | 'ctrlWheel'
|
||||
onNativeKeyDown?: (e: KeyboardEvent) => void;
|
||||
}
|
||||
|
||||
export interface ImageEditorRef {
|
||||
|
@ -72,8 +86,12 @@ export interface ImageEditorRef {
|
|||
}
|
||||
|
||||
export interface EditorState {
|
||||
scale: number;
|
||||
position: Point;
|
||||
/** 图片的缩放比例 */
|
||||
imageScale: number;
|
||||
/** 图片相对于容器的偏移量/坐标 */
|
||||
imagePosition: Point;
|
||||
/** 图片相对于视口的偏移量/坐标 */
|
||||
imageClientPosition: Point;
|
||||
isDragging: boolean;
|
||||
dragStart: Point;
|
||||
mousePosition: Point;
|
||||
|
@ -90,10 +108,10 @@ export interface ToolHandlerProps {
|
|||
editorState: [EditorState, Updater<EditorState>];
|
||||
editorProps: ImageEditorProps;
|
||||
containerRef: React.RefObject<HTMLDivElement>;
|
||||
renderAnnotation: (annotations?: Annotation[]) => React.ReactNode;
|
||||
renderAnnotation: (annotations?: Annotation[], props?: Optional<RectBoxProps>) => React.ReactNode;
|
||||
addAnnotation: (annotation: Annotation) => void;
|
||||
updateAnnotation: (type: AnnotationType, annotation: Annotation) => void;
|
||||
queryAnnotation: (id: string) => Annotation;
|
||||
queryAnnotation: (id: string) => Annotation | null | undefined;
|
||||
Convertor: PostionConvertor;
|
||||
}
|
||||
|
||||
|
@ -108,17 +126,121 @@ const ImageEditor = React.forwardRef<ImageEditorRef, ImageEditorProps>((props, r
|
|||
annotations = [],
|
||||
} = props;
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const imageRef = useRef<HTMLImageElement>(null);
|
||||
// 图片是否可以显示了(加载完成,尺寸计算完成)
|
||||
const [imageReady, setImageReady] = useState(false);
|
||||
|
||||
// 预加载图片以获取尺寸
|
||||
useEffect(() => {
|
||||
const img = new Image();
|
||||
img.src = image;
|
||||
img.onload = () => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const containerWidth = container.clientWidth;
|
||||
const containerHeight = container.clientHeight;
|
||||
const imageRatio = img.naturalWidth / img.naturalHeight;
|
||||
const containerRatio = containerWidth / containerHeight;
|
||||
|
||||
let scale = initialScale;
|
||||
if (imageRatio > containerRatio) {
|
||||
scale = containerWidth / img.naturalWidth * 0.9;
|
||||
} else {
|
||||
scale = containerHeight / img.naturalHeight * 0.9;
|
||||
}
|
||||
|
||||
const scaledWidth = img.naturalWidth * scale;
|
||||
const scaledHeight = img.naturalHeight * scale;
|
||||
const x = (containerWidth - scaledWidth) / 2;
|
||||
const y = (containerHeight - scaledHeight) / 2;
|
||||
|
||||
// 设置初始状态
|
||||
updateState(draft => {
|
||||
draft.imageScale = scale;
|
||||
draft.imagePosition = { x, y };
|
||||
draft.imageClientPosition = {
|
||||
x: container.getBoundingClientRect().left + x,
|
||||
y: container.getBoundingClientRect().top + y
|
||||
};
|
||||
});
|
||||
|
||||
setImageSize({
|
||||
width: img.naturalWidth,
|
||||
height: img.naturalHeight
|
||||
});
|
||||
setImageReady(true);
|
||||
};
|
||||
}, [image]);
|
||||
|
||||
const [state, updateState] = useImmer<EditorState>({
|
||||
scale: initialScale,
|
||||
position: { x: 0, y: 0 },
|
||||
imageScale: initialScale,
|
||||
imagePosition: { x: 0, y: 0 },
|
||||
imageClientPosition: { x: 0, y: 0 },
|
||||
isDragging: false,
|
||||
dragStart: { x: 0, y: 0 },
|
||||
mousePosition: { x: 0, y: 0 }
|
||||
});
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [imageSize, setImageSize] = useState<{ width: number; height: number }>({ width: 0, height: 0 });
|
||||
|
||||
const Convertor = {
|
||||
// 监听图像加载完成事件(保留这个事件以防需要做其他处理)
|
||||
const handleImageLoad = () => {
|
||||
const img = imageRef.current;
|
||||
if (img && !imageReady) {
|
||||
const fitResult = calculateImageFit();
|
||||
if (fitResult) {
|
||||
updateState(draft => {
|
||||
draft.imageScale = fitResult.scale;
|
||||
draft.imagePosition = fitResult.position;
|
||||
draft.imageClientPosition = fitResult.clientPosition;
|
||||
});
|
||||
}
|
||||
setImageReady(true);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 计算图像在容器中的居中位置和缩放比例
|
||||
* @returns 返回图像的缩放比例和位置信息
|
||||
*/
|
||||
const calculateImageFit = () => {
|
||||
const img = imageRef.current;
|
||||
const container = containerRef.current;
|
||||
if (!img || !container) return null;
|
||||
|
||||
const containerWidth = container.clientWidth;
|
||||
const containerHeight = container.clientHeight;
|
||||
const imageRatio = img.naturalWidth / img.naturalHeight;
|
||||
const containerRatio = containerWidth / containerHeight;
|
||||
|
||||
let scale = initialScale;
|
||||
if (imageRatio > containerRatio) {
|
||||
// 图片更宽,以容器宽度为基准
|
||||
scale = containerWidth / img.naturalWidth * 0.9;
|
||||
} else {
|
||||
// 图片更高,以容器高度为基准
|
||||
scale = containerHeight / img.naturalHeight * 0.9;
|
||||
}
|
||||
|
||||
// 计算居中位置
|
||||
const scaledWidth = img.naturalWidth * scale;
|
||||
const scaledHeight = img.naturalHeight * scale;
|
||||
const x = (containerWidth - scaledWidth) / 2;
|
||||
const y = (containerHeight - scaledHeight) / 2;
|
||||
|
||||
return {
|
||||
scale,
|
||||
position: { x, y },
|
||||
clientPosition: {
|
||||
x: container.getBoundingClientRect().left + x,
|
||||
y: container.getBoundingClientRect().top + y
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const Convertor = useMemo(() => ({
|
||||
/**
|
||||
* 从容器坐标转换到图片坐标
|
||||
* @param pos 容器坐标
|
||||
|
@ -126,8 +248,8 @@ const ImageEditor = React.forwardRef<ImageEditorRef, ImageEditorProps>((props, r
|
|||
*/
|
||||
posContainer2Image: (pos: Point) => {
|
||||
return {
|
||||
x: Math.round((pos.x - state.position.x) / state.scale),
|
||||
y: Math.round((pos.y - state.position.y) / state.scale)
|
||||
x: Math.round((pos.x - state.imagePosition.x) / state.imageScale),
|
||||
y: Math.round((pos.y - state.imagePosition.y) / state.imageScale)
|
||||
};
|
||||
},
|
||||
/**
|
||||
|
@ -137,8 +259,8 @@ const ImageEditor = React.forwardRef<ImageEditorRef, ImageEditorProps>((props, r
|
|||
*/
|
||||
posImage2Container: (pos: Point) => {
|
||||
return {
|
||||
x: pos.x * state.scale + state.position.x,
|
||||
y: pos.y * state.scale + state.position.y
|
||||
x: pos.x * state.imageScale + state.imagePosition.x,
|
||||
y: pos.y * state.imageScale + state.imagePosition.y
|
||||
};
|
||||
},
|
||||
/**
|
||||
|
@ -167,15 +289,16 @@ const ImageEditor = React.forwardRef<ImageEditorRef, ImageEditorProps>((props, r
|
|||
y2: Convertor.posContainer2Image({ x: rect.x2, y: rect.y2 }).y
|
||||
};
|
||||
}
|
||||
};
|
||||
}), [state.imageScale, state.imagePosition]);
|
||||
|
||||
const renderAnnotation = (annotations?: Annotation[]) => {
|
||||
const renderAnnotation = (annotations?: Annotation[], props?: Optional<RectBoxProps>) => {
|
||||
if (!annotations) return null;
|
||||
return annotations.map((rect) => (
|
||||
<RectBox
|
||||
key={rect.id}
|
||||
mode="resize"
|
||||
rect={Convertor.rectImage2Container(rect.data)}
|
||||
rectTip={rect.tip}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
};
|
||||
|
@ -203,14 +326,15 @@ const ImageEditor = React.forwardRef<ImageEditorRef, ImageEditorProps>((props, r
|
|||
if (annotations) {
|
||||
ret = annotations.find(annotation => annotation.id === id);
|
||||
}
|
||||
if (!ret)
|
||||
throw new Error(`Annotation not found: ${id}`);
|
||||
return ret;
|
||||
};
|
||||
|
||||
const toolHandlerProps: ToolHandlerProps = {
|
||||
editorState: [state, updateState],
|
||||
editorProps: props,
|
||||
editorProps: {
|
||||
...props,
|
||||
imageSize
|
||||
},
|
||||
containerRef,
|
||||
addAnnotation,
|
||||
updateAnnotation,
|
||||
|
@ -220,55 +344,82 @@ const ImageEditor = React.forwardRef<ImageEditorRef, ImageEditorProps>((props, r
|
|||
};
|
||||
|
||||
// 处理鼠标滚轮缩放
|
||||
const handleWheel = (e: WheelEvent) => {
|
||||
const handleWheel = useLatestCallback((e: WheelEvent) => {
|
||||
e.preventDefault();
|
||||
const delta = -e.deltaY;
|
||||
const scaleMode = props.scaleMode || 'wheel';
|
||||
|
||||
// 判断缩放还是滚动
|
||||
const shouldScale =
|
||||
(scaleMode === 'wheel' && !e.ctrlKey && !e.shiftKey) ||
|
||||
(scaleMode === 'ctrlWheel' && e.ctrlKey);
|
||||
|
||||
if (shouldScale) {
|
||||
const scaleChange = delta > 0 ? 1.1 : 0.9;
|
||||
|
||||
// 获取鼠标相对于容器的坐标
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
const rect = container.getBoundingClientRect();
|
||||
const mouseX = e.clientX - rect.left;
|
||||
const mouseY = e.clientY - rect.top;
|
||||
|
||||
// 计算鼠标位置在图片上的相对位置
|
||||
const mouseImageX = (mouseX - state.imagePosition.x) / state.imageScale;
|
||||
const mouseImageY = (mouseY - state.imagePosition.y) / state.imageScale;
|
||||
|
||||
updateState(draft => {
|
||||
draft.scale = Math.max(0.1, Math.min(10, draft.scale * scaleChange));
|
||||
draft.imageScale = Math.max(0.1, Math.min(10, draft.imageScale * scaleChange));
|
||||
|
||||
// 计算新的图片位置,保持鼠标指向的图片位置不变
|
||||
draft.imagePosition.x = mouseX - mouseImageX * draft.imageScale;
|
||||
draft.imagePosition.y = mouseY - mouseImageY * draft.imageScale;
|
||||
});
|
||||
} else {
|
||||
const moveX = e.shiftKey ? delta : 0;
|
||||
const moveY = !e.shiftKey ? delta : 0;
|
||||
|
||||
updateState(draft => {
|
||||
draft.imagePosition.x += moveX;
|
||||
draft.imagePosition.y += moveY;
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// 暴露组件方法
|
||||
React.useImperativeHandle(ref, () => ({
|
||||
reset: () => {
|
||||
updateState(draft => {
|
||||
draft.scale = initialScale;
|
||||
draft.position = { x: 0, y: 0 };
|
||||
draft.imageScale = initialScale;
|
||||
draft.imagePosition = { x: 0, y: 0 };
|
||||
});
|
||||
},
|
||||
setScale: (newScale: number) => {
|
||||
updateState(draft => {
|
||||
draft.scale = newScale;
|
||||
draft.imageScale = newScale;
|
||||
});
|
||||
},
|
||||
getScale: () => state.scale
|
||||
getScale: () => state.imageScale
|
||||
}));
|
||||
|
||||
// 添加和移除滚轮事件监听器
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (container) {
|
||||
container.addEventListener('wheel', handleWheel, { passive: false });
|
||||
return () => {
|
||||
container.removeEventListener('wheel', handleWheel);
|
||||
};
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<EditorContainer
|
||||
ref={containerRef}
|
||||
isDragging={state.isDragging}
|
||||
style={{ cursor: tool === Tool.Rect ? 'crosshair' : undefined }}
|
||||
onNativeKeyDown={props.onNativeKeyDown}
|
||||
onNativeMouseWheel={handleWheel}
|
||||
>
|
||||
{imageReady && (
|
||||
<EditorImage
|
||||
ref={imageRef}
|
||||
src={image}
|
||||
scale={state.scale}
|
||||
x={state.position.x}
|
||||
y={state.position.y}
|
||||
draggable={false}
|
||||
scale={state.imageScale}
|
||||
x={state.imagePosition.x}
|
||||
y={state.imagePosition.y}
|
||||
onLoad={handleImageLoad}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showCrosshair && tool === Tool.Rect && (
|
||||
<>
|
||||
|
|
|
@ -64,7 +64,7 @@ const RectTipContainer = styled(NativeDiv)`
|
|||
transform: translateY(-100%);
|
||||
`;
|
||||
|
||||
interface ObjectRectProps {
|
||||
export interface RectBoxProps {
|
||||
rect: RectPoints;
|
||||
/**
|
||||
* 矩形模式。默认为 resize。
|
||||
|
@ -86,6 +86,13 @@ interface ObjectRectProps {
|
|||
* 是否显示提示内容(`rectTip`)
|
||||
*/
|
||||
showRectTip?: boolean;
|
||||
/**
|
||||
* 矩形框变换事件。
|
||||
*
|
||||
* 变换后的坐标是在原坐标的基础上加上拖拽时鼠标的位移差得到的。
|
||||
* 也就是说,原来传入的是什么坐标系的坐标,变换后的坐标也是什么坐标系的坐标。
|
||||
* @param points 变换后的矩形框
|
||||
*/
|
||||
onTransform?: (points: RectPoints) => void;
|
||||
onNativeMouseEnter?: (e: MouseEvent) => void;
|
||||
onNativeMouseMove?: (e: MouseEvent) => void;
|
||||
|
@ -110,7 +117,7 @@ function useLatestProps<T>(props: T) {
|
|||
/**
|
||||
* 可调整位置、大小的矩形框组件。
|
||||
*/
|
||||
function RectBox(props: ObjectRectProps) {
|
||||
function RectBox(props: RectBoxProps) {
|
||||
const {
|
||||
rect,
|
||||
mode = 'resize',
|
||||
|
|
|
@ -1,24 +1,30 @@
|
|||
import { RectPoints } from "./types";
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
const MaskContainer = styled.div`
|
||||
const MaskContainer = styled.div<{ scale: number; transform: { x: number; y: number; width: number; height: number } }>`
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
transform: translate(${props => props.transform.x}px, ${props => props.transform.y}px) scale(${props => props.scale});
|
||||
transform-origin: left top;
|
||||
pointer-events: none;
|
||||
`;
|
||||
|
||||
interface RectMaskProps {
|
||||
rects: RectPoints[];
|
||||
alpha?: number;
|
||||
transition?: boolean;
|
||||
scale: number;
|
||||
transform: {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
}
|
||||
|
||||
function RectMask({ rects, alpha = 0.5, transition = false }: RectMaskProps) {
|
||||
function RectMask({ rects, alpha = 0.5, transition = false, scale, transform }: RectMaskProps) {
|
||||
return (
|
||||
<MaskContainer>
|
||||
<svg width="100%" height="100%" style={{ position: 'absolute' }}>
|
||||
<MaskContainer scale={scale} transform={transform}>
|
||||
<svg width={transform.width} height={transform.height} style={{ position: 'absolute' }}>
|
||||
<defs>
|
||||
<mask id="rectMask">
|
||||
{/* 首先创建一个黑色背景(完全遮罩) */}
|
||||
|
@ -33,7 +39,6 @@ function RectMask({ rects, alpha = 0.5, transition = false }: RectMaskProps) {
|
|||
width={rect.x2 - rect.x1}
|
||||
height={rect.y2 - rect.y1}
|
||||
fill="black"
|
||||
// style={transition ? { transition: 'all 0.1s ease-in-out' } : undefined}
|
||||
/>
|
||||
))}
|
||||
</mask>
|
||||
|
|
|
@ -1,15 +1,17 @@
|
|||
import { ToolHandlerProps } from "./ImageEditor";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useState, useRef } from "react";
|
||||
import RectBox from "./RectBox";
|
||||
import { Point } from "./types";
|
||||
import { Annotation, Point } from "./types";
|
||||
import useLatestCallback from "../../hooks/useLatestCallback";
|
||||
import { v4 } from "uuid";
|
||||
|
||||
|
||||
function RectTool(props: ToolHandlerProps) {
|
||||
const { containerRef, addAnnotation, renderAnnotation, Convertor, editorProps } = props;
|
||||
// 下面两个都为容器坐标
|
||||
const [rectStart, setRectStart] = useState<Point | null>(null);
|
||||
const [rectEnd, setRectEnd] = useState<Point | null>(null);
|
||||
const drawingRef = useRef(false);
|
||||
|
||||
// 处理拖拽开始
|
||||
const handleMouseDown = useLatestCallback((e: MouseEvent) => {
|
||||
|
@ -18,9 +20,11 @@ function RectTool(props: ToolHandlerProps) {
|
|||
if (rect) {
|
||||
const x = e.clientX - rect.left;
|
||||
const y = e.clientY - rect.top;
|
||||
// const imagePos = Convertor.posContainer2Image({ x, y });
|
||||
setRectStart({ x, y });
|
||||
setRectEnd({ x, y });
|
||||
}
|
||||
drawingRef.current = true;
|
||||
console.log('RectTool: handleMouseDown');
|
||||
});
|
||||
|
||||
|
@ -41,17 +45,25 @@ function RectTool(props: ToolHandlerProps) {
|
|||
let y1 = Math.min(rectStart.y, rectEnd.y);
|
||||
let x2 = Math.max(rectStart.x, rectEnd.x);
|
||||
let y2 = Math.max(rectStart.y, rectEnd.y);
|
||||
if (Math.abs(x1 - x2) < 10 || Math.abs(y1 - y2) < 10) {
|
||||
console.log('RectTool: rect too small. skip add annotation');
|
||||
}
|
||||
else {
|
||||
let newRect = { x1, y1, x2, y2 };
|
||||
// 转换到图片坐标
|
||||
|
||||
newRect = Convertor.rectContainer2Image(newRect);
|
||||
|
||||
addAnnotation({
|
||||
const annotation: Annotation = {
|
||||
id: v4(),
|
||||
type: 'rect',
|
||||
data: newRect
|
||||
});
|
||||
};
|
||||
addAnnotation(annotation);
|
||||
editorProps.onAnnotationSelected?.(annotation);
|
||||
}
|
||||
setRectStart(null);
|
||||
setRectEnd(null);
|
||||
drawingRef.current = false;
|
||||
console.log('RectTool: handleMouseUp');
|
||||
});
|
||||
|
||||
|
@ -75,7 +87,10 @@ function RectTool(props: ToolHandlerProps) {
|
|||
{rectStart && rectEnd && (
|
||||
<RectBox rect={{x1: rectStart.x, y1: rectStart.y, x2: rectEnd.x, y2: rectEnd.y}} />
|
||||
)}
|
||||
{renderAnnotation(editorProps.annotations)}
|
||||
{renderAnnotation(editorProps.annotations, {
|
||||
mode: 'move',
|
||||
lineColor: drawingRef.current ? 'rgba(255, 255, 255, 0.3)' : 'white'
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -10,10 +10,12 @@ type AnnotationTypeMap = {
|
|||
rect: RectPoints,
|
||||
};
|
||||
|
||||
export interface Annotation{
|
||||
export interface Annotation {
|
||||
id: string;
|
||||
type: AnnotationType;
|
||||
data: AnnotationTypeMap[AnnotationType];
|
||||
/** 提示信息。 */
|
||||
tip?: React.ReactNode;
|
||||
}
|
||||
export interface Point {
|
||||
x: number;
|
||||
|
@ -26,3 +28,10 @@ export interface RectPoints {
|
|||
x2: number;
|
||||
y2: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 使对象中的所有属性都变为可选。
|
||||
*/
|
||||
export type Optional<T> = {
|
||||
[P in keyof T]?: T[P] | undefined;
|
||||
};
|
||||
|
|
|
@ -4,6 +4,8 @@ import styled from '@emotion/styled';
|
|||
interface ImageViewerProps {
|
||||
/** 图片地址 */
|
||||
image: string;
|
||||
/** 图片渲染模式 */
|
||||
imageRendering?: 'pixelated' | 'auto';
|
||||
/** 是否可缩放 */
|
||||
zoomable?: boolean;
|
||||
/** 是否可移动 */
|
||||
|
@ -47,6 +49,7 @@ const ImageContainer = styled.div`
|
|||
|
||||
interface StyledImageProps {
|
||||
withAnimation?: boolean;
|
||||
imageRendering?: 'pixelated' | 'auto';
|
||||
}
|
||||
|
||||
const StyledImage = styled.img<StyledImageProps>`
|
||||
|
@ -57,6 +60,7 @@ const StyledImage = styled.img<StyledImageProps>`
|
|||
user-select: none;
|
||||
position: relative;
|
||||
transition: ${(props: StyledImageProps) => props.withAnimation ? 'transform 0.2s ease-out' : 'none'};
|
||||
image-rendering: ${props => props.imageRendering};
|
||||
|
||||
&.dragging {
|
||||
cursor: grabbing;
|
||||
|
@ -67,6 +71,7 @@ const StyledImage = styled.img<StyledImageProps>`
|
|||
const ImageViewer = forwardRef<ImageViewerRef, ImageViewerProps>(
|
||||
({
|
||||
image,
|
||||
imageRendering = 'auto',
|
||||
zoomable: scalable = true,
|
||||
movable = true,
|
||||
minZoomScale: minScale = 0.1,
|
||||
|
@ -214,6 +219,7 @@ const ImageViewer = forwardRef<ImageViewerRef, ImageViewerProps>(
|
|||
onDragStart={(e: React.DragEvent<HTMLImageElement>) => e.preventDefault()}
|
||||
className={isDragging ? 'dragging' : ''}
|
||||
withAnimation={useAnimation}
|
||||
imageRendering={imageRendering}
|
||||
style={{
|
||||
transform: `translate(${translateX}px, ${translateY}px) scale(${scale})`
|
||||
}}
|
||||
|
|
|
@ -0,0 +1,165 @@
|
|||
import { Modal } from 'react-bootstrap';
|
||||
import ImageViewer, { ImageViewerRef } from './ImageViewer';
|
||||
import { useRef, useState } from 'react';
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
const ModalBackdrop = styled.div`
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.4);
|
||||
z-index: 1040;
|
||||
`;
|
||||
|
||||
const ToolBar = styled.div`
|
||||
position: absolute;
|
||||
bottom: 20px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
padding: 8px;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
border-radius: 8px;
|
||||
z-index: 1;
|
||||
`;
|
||||
|
||||
const ToolButton = styled.button`
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 20px;
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
&:active {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
`;
|
||||
|
||||
interface ImageViewerModalProps {
|
||||
show: boolean;
|
||||
onHide: () => void;
|
||||
image: string;
|
||||
imageRendering?: 'pixelated' | 'auto';
|
||||
title?: string;
|
||||
}
|
||||
|
||||
function ImageViewerModal(props: ImageViewerModalProps) {
|
||||
const { show, onHide, image, imageRendering = 'auto', title } = props;
|
||||
const imageViewerRef = useRef<ImageViewerRef>(null);
|
||||
|
||||
const handleZoomIn = () => {
|
||||
if (imageViewerRef.current) {
|
||||
const currentScale = imageViewerRef.current.scale;
|
||||
imageViewerRef.current.setScale(currentScale * 1.2);
|
||||
}
|
||||
};
|
||||
|
||||
const handleZoomOut = () => {
|
||||
if (imageViewerRef.current) {
|
||||
const currentScale = imageViewerRef.current.scale;
|
||||
imageViewerRef.current.setScale(currentScale / 1.2);
|
||||
}
|
||||
};
|
||||
|
||||
const handleResetZoom = () => {
|
||||
imageViewerRef.current?.reset('zoom');
|
||||
};
|
||||
|
||||
const handleFit = () => {
|
||||
imageViewerRef.current?.fit();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{show && <ModalBackdrop />}
|
||||
<Modal
|
||||
show={show}
|
||||
onHide={onHide}
|
||||
size="xl"
|
||||
dialogClassName="modal-90w"
|
||||
centered
|
||||
style={{ zIndex: 1050 }}
|
||||
>
|
||||
<Modal.Header closeButton style={{ border: 'none' }}>
|
||||
{title && <Modal.Title>{title}</Modal.Title>}
|
||||
</Modal.Header>
|
||||
<Modal.Body className="p-0" style={{ position: 'relative' }}>
|
||||
<div style={{ width: '100%', height: 'calc(90vh - 56px)' }}>
|
||||
<ImageViewer
|
||||
ref={imageViewerRef}
|
||||
image={image}
|
||||
zoomable={true}
|
||||
movable={true}
|
||||
imageRendering={imageRendering}
|
||||
/>
|
||||
</div>
|
||||
<ToolBar>
|
||||
<ToolButton onClick={handleZoomIn} title="放大">
|
||||
<i className="bi bi-zoom-in"></i>
|
||||
</ToolButton>
|
||||
<ToolButton onClick={handleZoomOut} title="缩小">
|
||||
<i className="bi bi-zoom-out"></i>
|
||||
</ToolButton>
|
||||
<ToolButton onClick={handleFit} title="适应容器">
|
||||
<i className="bi bi-aspect-ratio"></i>
|
||||
</ToolButton>
|
||||
<ToolButton onClick={handleResetZoom} title="重置缩放">
|
||||
<i className="bi bi-arrow-counterclockwise"></i>
|
||||
</ToolButton>
|
||||
</ToolBar>
|
||||
</Modal.Body>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface ImageViewerModalOpenOptions {
|
||||
imageRendering?: 'pixelated' | 'auto';
|
||||
}
|
||||
|
||||
export function useImageViewerModal(title?: string, options?: ImageViewerModalOpenOptions) {
|
||||
const [show, setShow] = useState(false);
|
||||
const [image, setImage] = useState<string>('');
|
||||
const [imageRendering, setImageRendering] = useState<'pixelated' | 'auto'>('auto');
|
||||
|
||||
const openModal = (imageUrl: string, options?: ImageViewerModalOpenOptions) => {
|
||||
setImage(imageUrl);
|
||||
setImageRendering(options?.imageRendering || 'auto');
|
||||
setShow(true);
|
||||
};
|
||||
|
||||
const closeModal = () => {
|
||||
setShow(false);
|
||||
};
|
||||
|
||||
const modal = (
|
||||
<ImageViewerModal
|
||||
show={show}
|
||||
onHide={closeModal}
|
||||
image={image}
|
||||
imageRendering={imageRendering}
|
||||
title={title}
|
||||
/>
|
||||
);
|
||||
|
||||
return {
|
||||
modal,
|
||||
openModal,
|
||||
closeModal
|
||||
};
|
||||
}
|
||||
|
||||
export default ImageViewerModal;
|
|
@ -6,6 +6,9 @@ interface NativeDivProps extends HTMLAttributes<HTMLDivElement> {
|
|||
onNativeMouseLeave?: (e: MouseEvent) => void;
|
||||
onNativeMouseDown?: (e: MouseEvent) => void;
|
||||
onNativeClick?: (e: MouseEvent) => void;
|
||||
onNativeKeyDown?: (e: KeyboardEvent) => void;
|
||||
onNativeKeyUp?: (e: KeyboardEvent) => void;
|
||||
onNativeMouseWheel?: (e: WheelEvent) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -17,6 +20,9 @@ interface NativeDivProps extends HTMLAttributes<HTMLDivElement> {
|
|||
* * onNativeMouseLeave
|
||||
* * onNativeMouseDown
|
||||
* * onNativeClick
|
||||
* * onNativeKeyDown
|
||||
* * onNativeKeyUp
|
||||
* * onNativeMouseWheel
|
||||
*
|
||||
* 这些事件都是对原生 DOM 事件的封装。
|
||||
*/
|
||||
|
@ -27,6 +33,9 @@ const NativeDiv = forwardRef<HTMLDivElement, NativeDivProps>((props, ref) => {
|
|||
onNativeMouseLeave,
|
||||
onNativeMouseDown,
|
||||
onNativeClick,
|
||||
onNativeKeyDown,
|
||||
onNativeKeyUp,
|
||||
onNativeMouseWheel,
|
||||
...restProps
|
||||
} = props;
|
||||
|
||||
|
@ -53,6 +62,12 @@ const NativeDiv = forwardRef<HTMLDivElement, NativeDivProps>((props, ref) => {
|
|||
element.addEventListener('mousedown', onNativeMouseDown);
|
||||
if (onNativeClick)
|
||||
element.addEventListener('click', onNativeClick);
|
||||
if (onNativeKeyDown)
|
||||
element.addEventListener('keydown', onNativeKeyDown);
|
||||
if (onNativeKeyUp)
|
||||
element.addEventListener('keyup', onNativeKeyUp);
|
||||
if (onNativeMouseWheel)
|
||||
element.addEventListener('wheel', onNativeMouseWheel);
|
||||
|
||||
return () => {
|
||||
if (onNativeMouseEnter)
|
||||
|
@ -65,8 +80,14 @@ const NativeDiv = forwardRef<HTMLDivElement, NativeDivProps>((props, ref) => {
|
|||
element.removeEventListener('mousedown', onNativeMouseDown);
|
||||
if (onNativeClick)
|
||||
element.removeEventListener('click', onNativeClick);
|
||||
if (onNativeKeyDown)
|
||||
element.removeEventListener('keydown', onNativeKeyDown);
|
||||
if (onNativeKeyUp)
|
||||
element.removeEventListener('keyup', onNativeKeyUp);
|
||||
if (onNativeMouseWheel)
|
||||
element.removeEventListener('wheel', onNativeMouseWheel);
|
||||
};
|
||||
}, [onNativeMouseEnter, onNativeMouseMove, onNativeMouseLeave, onNativeClick]);
|
||||
}, [onNativeMouseEnter, onNativeMouseMove, onNativeMouseLeave, onNativeClick, onNativeKeyDown, onNativeKeyUp, onNativeMouseWheel]);
|
||||
|
||||
return <div ref={elementRef} {...restProps} />;
|
||||
});
|
||||
|
|
|
@ -0,0 +1,205 @@
|
|||
import styled from '@emotion/styled';
|
||||
import React, { useState, useRef } from 'react';
|
||||
|
||||
export interface PropertyRenderBase {
|
||||
required?: boolean,
|
||||
}
|
||||
|
||||
interface PropertyRenderInputOptions {
|
||||
text: {
|
||||
value: string,
|
||||
onChange: (value: string) => void
|
||||
},
|
||||
checkbox: {
|
||||
value: boolean,
|
||||
onChange: (value: boolean) => void
|
||||
}
|
||||
}
|
||||
type RenderType = keyof PropertyRenderInputOptions;
|
||||
|
||||
type PropertyRender =
|
||||
(PropertyRenderBase & PropertyRenderInputOptions[RenderType] & { type: RenderType }) |
|
||||
(() => React.ReactNode);
|
||||
|
||||
/**
|
||||
* 表示一个属性项的配置
|
||||
* @property title - 可选的属性标题。如果不提供,属性将占据整行显示
|
||||
* @property render - 渲染属性内容的函数。返回的内容将显示在属性值区域
|
||||
*/
|
||||
export interface Property {
|
||||
title?: string;
|
||||
render: PropertyRender;
|
||||
}
|
||||
|
||||
/**
|
||||
* 表示一个属性分类的配置
|
||||
* @property title - 分类的标题
|
||||
* @property properties - 该分类下的属性数组
|
||||
* @property foldable - 是否可以折叠。如果为 true,分类标题将显示折叠按钮
|
||||
*/
|
||||
export interface PropertyCategory {
|
||||
title: string;
|
||||
properties: Property[];
|
||||
foldable?: boolean;
|
||||
}
|
||||
|
||||
export interface PropertyGridProps {
|
||||
properties: Array<Property | PropertyCategory>;
|
||||
titleColumnWidth?: string;
|
||||
}
|
||||
|
||||
const GridContainer = styled.div<{ titleColumnWidth: string }>`
|
||||
display: grid;
|
||||
grid-template-columns: ${props => props.titleColumnWidth} 1fr;
|
||||
gap: 1px;
|
||||
background-color: #e0e0e0;
|
||||
border: 1px solid #e0e0e0;
|
||||
`;
|
||||
|
||||
const PropertyTitle = styled.div`
|
||||
padding: 8px;
|
||||
background-color: #f5f5f5;
|
||||
font-size: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
`;
|
||||
|
||||
const PropertyContent = styled.div`
|
||||
padding: 4px 8px;
|
||||
background-color: white;
|
||||
min-height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
`;
|
||||
|
||||
const FullWidthPropertyContent = styled(PropertyContent)`
|
||||
grid-column: 1 / -1;
|
||||
`;
|
||||
|
||||
const CategoryTitle = styled.div<{ foldable?: boolean }>`
|
||||
grid-column: 1 / -1;
|
||||
padding: 8px;
|
||||
background-color: #edf2f7;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
color: #2d3748;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: ${props => props.foldable ? 'pointer' : 'default'};
|
||||
|
||||
&:hover {
|
||||
background-color: ${props => props.foldable ? '#e2e8f0' : '#edf2f7'};
|
||||
}
|
||||
`;
|
||||
|
||||
const FoldIcon = styled.span<{ folded: boolean }>`
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: transform 0.2s;
|
||||
transform: rotate(${props => props.folded ? '-90deg' : '0deg'});
|
||||
flex-shrink: 0;
|
||||
|
||||
&::before {
|
||||
content: '▼';
|
||||
font-size: 12px;
|
||||
}
|
||||
`;
|
||||
|
||||
const CategoryContent = styled.div<{ folded: boolean }>`
|
||||
display: grid;
|
||||
grid-template-columns: subgrid;
|
||||
grid-column: 1 / -1;
|
||||
display: ${props => props.folded ? 'none' : 'grid'};
|
||||
`;
|
||||
|
||||
const PropertyGrid: React.FC<PropertyGridProps> = ({ properties, titleColumnWidth = 'auto' }) => {
|
||||
const [foldedCategories, setFoldedCategories] = useState<Record<string, boolean>>({});
|
||||
|
||||
const toggleCategory = (categoryTitle: string) => {
|
||||
setFoldedCategories(prev => ({
|
||||
...prev,
|
||||
[categoryTitle]: !prev[categoryTitle]
|
||||
}));
|
||||
};
|
||||
|
||||
/**
|
||||
* 渲染单个属性项
|
||||
* @param property - 属性配置
|
||||
*/
|
||||
const makeProperty = (property: Property) => {
|
||||
let field: React.ReactNode;
|
||||
if (typeof property.render === 'function') {
|
||||
field = property.render();
|
||||
} else if ('type' in property.render) {
|
||||
const type = property.render.type;
|
||||
if (type === 'text') {
|
||||
const propertyText = property.render as PropertyRenderInputOptions['text'];
|
||||
field = <input type="text" value={propertyText.value} onChange={(e) => propertyText.onChange(e.target.value)} />;
|
||||
} else if (type === 'checkbox') {
|
||||
const propertyCheckbox = property.render as PropertyRenderInputOptions['checkbox'];
|
||||
field = <input type="checkbox" checked={propertyCheckbox.value} onChange={(e) => propertyCheckbox.onChange(e.target.checked)} />;
|
||||
}
|
||||
} else {
|
||||
console.error('Invalid property render type:', property.render);
|
||||
}
|
||||
if (property.title) {
|
||||
return (
|
||||
<>
|
||||
<PropertyTitle>{property.title}</PropertyTitle>
|
||||
<PropertyContent>{field}</PropertyContent>
|
||||
</>
|
||||
);
|
||||
}
|
||||
return <FullWidthPropertyContent>{field}</FullWidthPropertyContent>;
|
||||
};
|
||||
|
||||
/**
|
||||
* 渲染属性分类
|
||||
* @param category - 分类配置
|
||||
* @param index - 分类索引
|
||||
*/
|
||||
const makeCategory = (category: PropertyCategory, index: number) => {
|
||||
return (
|
||||
<>
|
||||
<CategoryTitle
|
||||
foldable={category.foldable}
|
||||
onClick={() => category.foldable && toggleCategory(category.title)}
|
||||
>
|
||||
{category.foldable && (
|
||||
<FoldIcon folded={foldedCategories[category.title]} />
|
||||
)}
|
||||
<span>{category.title}</span>
|
||||
</CategoryTitle>
|
||||
<CategoryContent
|
||||
folded={category.foldable ? !!foldedCategories[category.title] : false}
|
||||
>
|
||||
{category.properties.map((subProperty, subIndex) => (
|
||||
<React.Fragment key={`${index}-${subIndex}`}>
|
||||
{makeProperty(subProperty)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</CategoryContent>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<GridContainer titleColumnWidth={titleColumnWidth}>
|
||||
{properties.map((property, index) => (
|
||||
<React.Fragment key={index}>
|
||||
{'properties' in property
|
||||
? makeCategory(property, index)
|
||||
: makeProperty(property)
|
||||
}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</GridContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default PropertyGrid;
|
|
@ -0,0 +1,197 @@
|
|||
import React, { useState } from 'react';
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
export interface Tool {
|
||||
id: string;
|
||||
icon: React.ReactNode;
|
||||
title: string;
|
||||
onClick?: () => void;
|
||||
selectable?: boolean;
|
||||
}
|
||||
|
||||
export interface SideToolBarProps {
|
||||
tools: Array<Tool | 'separator'>;
|
||||
selectedToolId?: string;
|
||||
/**
|
||||
* 选择工具事件。
|
||||
* 只有 selectable 为 true 的工具才会触发此事件。
|
||||
* @param id 工具id
|
||||
*/
|
||||
onSelectTool?: (id: string) => void;
|
||||
/**
|
||||
* 点击工具事件。
|
||||
* 所有工具都会触发此事件。
|
||||
*
|
||||
* 可以使用 Tool 对象中的 onClick 事件单独处理每个工具的点击事件,
|
||||
* 也可以使用此事件统一处理所有工具的点击事件。
|
||||
*
|
||||
* (触发顺序:onClick > onClickTool > onSelectTool)
|
||||
* @param id 工具id
|
||||
*/
|
||||
onClickTool?: (id: string) => void;
|
||||
}
|
||||
|
||||
const ToolBarContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 8px;
|
||||
border-radius: 8px;
|
||||
width: fit-content;
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
background-color: #2c2c2c;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
background-color: #f8f9fa;
|
||||
border: 1px solid #dee2e6;
|
||||
}
|
||||
`;
|
||||
|
||||
const ToolButton = styled.button<{ isSelected?: boolean }>`
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background-color: ${props => props.isSelected ? 'rgba(255, 255, 255, 0.2)' : 'transparent'};
|
||||
transition: background-color 0.2s;
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
color: #fff;
|
||||
|
||||
&:hover {
|
||||
background-color: ${props => props.isSelected ? 'rgba(255, 255, 255, 0.3)' : 'rgba(255, 255, 255, 0.1)'};
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
&:active {
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
color: #212529;
|
||||
background-color: ${props => props.isSelected ? 'rgba(0, 0, 0, 0.1)' : 'transparent'};
|
||||
|
||||
&:hover {
|
||||
background-color: ${props => props.isSelected ? 'rgba(0, 0, 0, 0.15)' : 'rgba(0, 0, 0, 0.05)'};
|
||||
color: #212529;
|
||||
}
|
||||
|
||||
&:active {
|
||||
background-color: rgba(0, 0, 0, 0.1);
|
||||
color: #212529;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
color: #212529;
|
||||
}
|
||||
}
|
||||
|
||||
// 覆盖 Bootstrap 按钮的默认样式
|
||||
&.btn:hover,
|
||||
&.btn:active,
|
||||
&.btn:focus {
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
`;
|
||||
|
||||
const Separator = styled.div`
|
||||
height: 1px;
|
||||
margin: 4px 0;
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
background-color: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
`;
|
||||
|
||||
const Tooltip = styled.div<{ visible: boolean }>`
|
||||
position: absolute;
|
||||
left: 100%;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
margin-left: 8px;
|
||||
padding: 4px 8px;
|
||||
background-color: rgba(0, 0, 0, 0.75);
|
||||
color: white;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
opacity: ${props => props.visible ? 1 : 0};
|
||||
transition: opacity 0.2s;
|
||||
z-index: 1000;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
right: 100%;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
border-width: 4px;
|
||||
border-style: solid;
|
||||
border-color: transparent rgba(0, 0, 0, 0.75) transparent transparent;
|
||||
}
|
||||
`;
|
||||
|
||||
const ToolButtonWrapper = styled.div`
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
export const SideToolBar: React.FC<SideToolBarProps> = ({
|
||||
tools,
|
||||
selectedToolId,
|
||||
onSelectTool,
|
||||
onClickTool
|
||||
}) => {
|
||||
return (
|
||||
<ToolBarContainer>
|
||||
{tools.map((tool, index) => {
|
||||
if (tool === 'separator') {
|
||||
return <Separator key={`separator-${index}`} />;
|
||||
}
|
||||
|
||||
const [showTooltip, setShowTooltip] = useState(false);
|
||||
|
||||
const handleClick = () => {
|
||||
tool.onClick?.();
|
||||
onClickTool?.(tool.id);
|
||||
if (tool.selectable !== false && onSelectTool) {
|
||||
onSelectTool(tool.id);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ToolButtonWrapper
|
||||
key={`tool-${tool.id}`}
|
||||
onMouseEnter={() => setShowTooltip(true)}
|
||||
onMouseLeave={() => setShowTooltip(false)}
|
||||
>
|
||||
<ToolButton
|
||||
onClick={handleClick}
|
||||
isSelected={tool.selectable !== false && selectedToolId === tool.id}
|
||||
>
|
||||
{tool.icon}
|
||||
</ToolButton>
|
||||
<Tooltip visible={showTooltip}>{tool.title}</Tooltip>
|
||||
</ToolButtonWrapper>
|
||||
);
|
||||
})}
|
||||
</ToolBarContainer>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,80 @@
|
|||
import React, { useState, useCallback } from 'react';
|
||||
import Toast from 'react-bootstrap/Toast';
|
||||
import ToastContainer from 'react-bootstrap/ToastContainer';
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
// 定义消息类型
|
||||
export type ToastType = 'success' | 'danger' | 'info' | 'warning';
|
||||
|
||||
// 定义单个消息的数据结构
|
||||
export interface ToastMessage {
|
||||
id: string;
|
||||
type: ToastType;
|
||||
title: string;
|
||||
message: string;
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
// 定义 Hook 返回的数据结构
|
||||
export interface ToastHook {
|
||||
showToast: (type: ToastType, title: string, message: string, duration?: number) => void;
|
||||
ToastComponent: React.ReactNode;
|
||||
}
|
||||
|
||||
// 样式化的 Toast 容器
|
||||
const StyledToastContainer = styled(ToastContainer)`
|
||||
z-index: 9999;
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
`;
|
||||
|
||||
// 自定义 Hook 用于管理 Toast 消息
|
||||
export const useToast = (): ToastHook => {
|
||||
const [toasts, setToasts] = useState<ToastMessage[]>([]);
|
||||
|
||||
// 移除指定 ID 的消息
|
||||
const removeToast = useCallback((id: string) => {
|
||||
setToasts(prev => prev.filter(toast => toast.id !== id));
|
||||
}, []);
|
||||
|
||||
// 显示新消息
|
||||
const showToast = useCallback((
|
||||
type: ToastType,
|
||||
title: string,
|
||||
message: string,
|
||||
duration: number = 3000
|
||||
) => {
|
||||
const id = Math.random().toString(36).substr(2, 9);
|
||||
setToasts(prev => [...prev, { id, type, title, message, duration }]);
|
||||
}, []);
|
||||
|
||||
// 渲染 Toast 组件
|
||||
const ToastComponent = (
|
||||
<StyledToastContainer position="top-center" className="p-3">
|
||||
{toasts.map(toast => (
|
||||
<Toast
|
||||
key={toast.id}
|
||||
onClose={() => removeToast(toast.id)}
|
||||
show={true}
|
||||
delay={toast.duration}
|
||||
autohide
|
||||
bg={toast.type}
|
||||
className="mb-2"
|
||||
>
|
||||
<Toast.Header closeButton>
|
||||
<strong className="me-auto">{toast.title}</strong>
|
||||
</Toast.Header>
|
||||
<Toast.Body className='text-white'>
|
||||
{toast.message}
|
||||
</Toast.Body>
|
||||
</Toast>
|
||||
))}
|
||||
</StyledToastContainer>
|
||||
);
|
||||
|
||||
return {
|
||||
showToast,
|
||||
ToastComponent
|
||||
};
|
||||
};
|
|
@ -1,100 +0,0 @@
|
|||
import { ToolHandler } from './ImageEditor/ImageEditor';
|
||||
import { Rect } from './ImageEditor/ImageEditor';
|
||||
|
||||
export const useDragTool : ToolHandler = (args) => {
|
||||
const { editorState } = args;
|
||||
const [state, updateState] = editorState;
|
||||
|
||||
const handleMouseDown = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
updateState(draft => {
|
||||
draft.isDragging = true;
|
||||
draft.dragStart = {
|
||||
x: e.clientX - draft.position.x,
|
||||
y: e.clientY - draft.position.y
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const handleMouseMove = (e: React.MouseEvent) => {
|
||||
if (!state.isDragging) return;
|
||||
|
||||
updateState(draft => {
|
||||
draft.position = {
|
||||
x: e.clientX - draft.dragStart.x,
|
||||
y: e.clientY - draft.dragStart.y
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
updateState(draft => {
|
||||
draft.isDragging = false;
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
handleMouseDown,
|
||||
handleMouseMove,
|
||||
handleMouseUp
|
||||
};
|
||||
};
|
||||
|
||||
export const useRectTool : ToolHandler = (args) => {
|
||||
const { editorState, containerRef, onAnnotationChange } = args;
|
||||
const [state, updateState] = editorState;
|
||||
|
||||
const handleMouseDown = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
const rect = containerRef.current?.getBoundingClientRect();
|
||||
if (!rect) return;
|
||||
|
||||
const x = e.clientX - rect.left;
|
||||
const y = e.clientY - rect.top;
|
||||
updateState(draft => {
|
||||
draft.rectStart = { x, y };
|
||||
draft.rectEnd = { x, y };
|
||||
});
|
||||
};
|
||||
|
||||
const handleMouseMove = (e: React.MouseEvent) => {
|
||||
const rect = containerRef.current?.getBoundingClientRect();
|
||||
if (!rect) return;
|
||||
|
||||
const x = e.clientX - rect.left;
|
||||
const y = e.clientY - rect.top;
|
||||
|
||||
updateState(draft => {
|
||||
draft.mousePosition = { x, y };
|
||||
|
||||
if (draft.rectStart) {
|
||||
draft.rectEnd = { x, y };
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
if (state.rectStart && state.rectEnd) {
|
||||
const newRect: Rect = {
|
||||
x: Math.min(state.rectStart.x, state.rectEnd.x),
|
||||
y: Math.min(state.rectStart.y, state.rectEnd.y),
|
||||
width: Math.abs(state.rectEnd.x - state.rectStart.x),
|
||||
height: Math.abs(state.rectEnd.y - state.rectStart.y)
|
||||
};
|
||||
|
||||
updateState(draft => {
|
||||
draft.savedRects.push(newRect);
|
||||
draft.rectStart = null;
|
||||
draft.rectEnd = null;
|
||||
});
|
||||
|
||||
onAnnotationChange?.({ rects: [...state.savedRects, newRect] });
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
handleMouseDown,
|
||||
handleMouseMove,
|
||||
handleMouseUp
|
||||
};
|
||||
};
|
|
@ -6,12 +6,17 @@ import MultipleImagesViewer from '../components/MutipleImagesViewer';
|
|||
import { useMessageBox } from '../hooks/useMessageBox';
|
||||
import { useFullscreenSpinner } from '../hooks/useFullscreenSpinner';
|
||||
import ImageEditor, { ImageEditorRef } from '../components/ImageEditor/ImageEditor';
|
||||
import { Annotation, RectPoints, Tool } from '../components/ImageEditor/types';
|
||||
import { Annotation, RectPoints, Tool as EditorTool } from '../components/ImageEditor/types';
|
||||
import RectBox from '../components/ImageEditor/RectBox';
|
||||
import RectMask from '../components/ImageEditor/RectMask';
|
||||
import FormRange from 'react-bootstrap/esm/FormRange';
|
||||
import NativeDiv from '../components/NativeDiv';
|
||||
import { AnnotationChangedEvent } from '../components/ImageEditor/ImageEditor';
|
||||
import { SideToolBar, Tool } from '../components/SideToolBar';
|
||||
import PropertyGrid, { Property, PropertyCategory } from '../components/PropertyGrid';
|
||||
import ImageViewerModal, { useImageViewerModal } from '../components/ImageViewerModal';
|
||||
import { useToast } from '../components/ToastMessage';
|
||||
|
||||
// 布局相关的样式组件
|
||||
const DemoContainer = styled.div`
|
||||
display: flex;
|
||||
|
@ -239,6 +244,63 @@ function SpinnerDemo(): JSX.Element {
|
|||
);
|
||||
}
|
||||
|
||||
// 添加 Toast 消息演示组件
|
||||
function ToastMessageDemo(): JSX.Element {
|
||||
const { showToast, ToastComponent } = useToast();
|
||||
|
||||
const handleShowSuccess = () => {
|
||||
showToast('success', '成功', '操作已成功完成!');
|
||||
};
|
||||
|
||||
const handleShowError = () => {
|
||||
showToast('danger', '错误', '操作执行失败,请重试。');
|
||||
};
|
||||
|
||||
const handleShowInfo = () => {
|
||||
showToast('info', '提示', '这是一条信息提示。');
|
||||
};
|
||||
|
||||
const handleShowWarning = () => {
|
||||
showToast('warning', '警告', '请注意,这是一条警告消息。');
|
||||
};
|
||||
|
||||
const handleShowCustomDuration = () => {
|
||||
showToast('info', '自定义时长', '这条消息会显示 10 秒钟。', 10000);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2>Toast 消息演示</h2>
|
||||
<ControlPanel>
|
||||
<Button onClick={handleShowSuccess}>显示成功消息</Button>
|
||||
<Button onClick={handleShowError}>显示错误消息</Button>
|
||||
<Button onClick={handleShowInfo}>显示信息提示</Button>
|
||||
<Button onClick={handleShowWarning}>显示警告消息</Button>
|
||||
<Button onClick={handleShowCustomDuration}>显示长时消息</Button>
|
||||
</ControlPanel>
|
||||
{ToastComponent}
|
||||
<div>
|
||||
<h3>使用说明:</h3>
|
||||
<ul>
|
||||
<li>点击不同的按钮可以显示不同类型的 Toast 消息</li>
|
||||
<li>每种消息类型都有其独特的颜色样式:</li>
|
||||
<ul>
|
||||
<li>成功消息 - 绿色</li>
|
||||
<li>错误消息 - 红色</li>
|
||||
<li>信息提示 - 蓝色</li>
|
||||
<li>警告消息 - 黄色</li>
|
||||
</ul>
|
||||
<li>默认情况下,消息会在 3 秒后自动消失</li>
|
||||
<li>"显示长时消息"按钮会显示一个持续 10 秒的消息</li>
|
||||
<li>可以通过点击消息右上角的关闭按钮手动关闭消息</li>
|
||||
<li>多个消息会按顺序堆叠显示</li>
|
||||
<li>消息会显示在屏幕右上角</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 单图片查看器演示组件
|
||||
function SingleImageViewerDemo(): JSX.Element {
|
||||
const viewerRef = useRef<ImageViewerRef>(null);
|
||||
|
@ -370,10 +432,11 @@ function MultipleImagesViewerDemo(): JSX.Element {
|
|||
function ImageEditorDemo(): JSX.Element {
|
||||
const editorRef = useRef<ImageEditorRef>(null);
|
||||
const [showCrosshair, setShowCrosshair] = useState(false);
|
||||
const [currentTool, setCurrentTool] = useState<Tool>(Tool.Drag);
|
||||
const [currentTool, setCurrentTool] = useState<EditorTool>(EditorTool.Drag);
|
||||
const [annotations, setAnnotations] = useState<Annotation[]>([]);
|
||||
const [showMask, setShowMask] = useState(true);
|
||||
const [maskAlpha, setMaskAlpha] = useState(0.5);
|
||||
const [scaleMode, setScaleMode] = useState<'wheel' | 'ctrlWheel'>('wheel');
|
||||
const demoImage = 'https://picsum.photos/800/600';
|
||||
|
||||
const handleEditorReset = () => {
|
||||
|
@ -438,6 +501,7 @@ function ImageEditorDemo(): JSX.Element {
|
|||
onAnnotationChanged={handleAnnotationChanged}
|
||||
enableMask={showMask}
|
||||
maskAlpha={maskAlpha}
|
||||
scaleMode={scaleMode}
|
||||
/>
|
||||
</ViewerContainer>
|
||||
<ControlPanel>
|
||||
|
@ -447,13 +511,16 @@ function ImageEditorDemo(): JSX.Element {
|
|||
<Button onClick={() => setShowCrosshair(!showCrosshair)}>
|
||||
{showCrosshair ? '隐藏准线' : '显示准线'}
|
||||
</Button>
|
||||
<Button onClick={() => setCurrentTool(currentTool === Tool.Drag ? Tool.Rect : Tool.Drag)}>
|
||||
{currentTool === Tool.Drag ? '切换到矩形工具' : '切换到拖动工具'}
|
||||
<Button onClick={() => setCurrentTool(currentTool === EditorTool.Drag ? EditorTool.Rect : EditorTool.Drag)}>
|
||||
{currentTool === EditorTool.Drag ? '切换到矩形工具' : '切换到拖动工具'}
|
||||
</Button>
|
||||
<Button onClick={handleClearAnnotations}>清除标注</Button>
|
||||
<Button onClick={() => setShowMask(!showMask)}>
|
||||
{showMask ? '隐藏遮罩' : '显示遮罩'}
|
||||
</Button>
|
||||
<Button onClick={() => setScaleMode(mode => mode === 'wheel' ? 'ctrlWheel' : 'wheel')}>
|
||||
{scaleMode === 'wheel' ? '切换到Ctrl+滚轮缩放' : '切换到滚轮缩放'}
|
||||
</Button>
|
||||
<FormRange
|
||||
style={{ flex: '0 1 200px' }}
|
||||
value={maskAlpha}
|
||||
|
@ -500,6 +567,11 @@ function ImageEditorDemo(): JSX.Element {
|
|||
<li>点击"清除标注"可以删除所有已绘制的矩形</li>
|
||||
<li>点击"显示遮罩"可以显示/隐藏非标注区域的遮罩</li>
|
||||
<li>使用滑块可以调整遮罩的透明度</li>
|
||||
<li>点击"切换到Ctrl+滚轮缩放"可以切换缩放模式:</li>
|
||||
<ul>
|
||||
<li>滚轮缩放模式:直接使用滚轮缩放,Ctrl+滚轮上下移动,Shift+滚轮左右移动</li>
|
||||
<li>Ctrl+滚轮缩放模式:使用Ctrl+滚轮缩放,直接滚轮上下移动,Shift+滚轮左右移动</li>
|
||||
</ul>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -678,7 +750,13 @@ function RectMaskDemo(): JSX.Element {
|
|||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
alt="Demo"
|
||||
/>
|
||||
<RectMask rects={rects} alpha={alpha} transition={enableTransition} />
|
||||
<RectMask
|
||||
rects={rects}
|
||||
alpha={alpha}
|
||||
transition={enableTransition}
|
||||
scale={1}
|
||||
transform={{ x: 0, y: 0, width: 800, height: 600 }}
|
||||
/>
|
||||
</RectBoxContainer>
|
||||
<div>
|
||||
<h3>使用说明:</h3>
|
||||
|
@ -698,35 +776,64 @@ function RectMaskDemo(): JSX.Element {
|
|||
|
||||
// NativeDiv 演示组件
|
||||
function NativeDivDemo(): JSX.Element {
|
||||
const [events, setEvents] = useState<Array<{ type: string; time: string; position: string }>>([]);
|
||||
const [events, setEvents] = useState<Array<{ type: string; time: string; details: string }>>([]);
|
||||
const [wheelValue, setWheelValue] = useState(0);
|
||||
|
||||
const addEvent = (type: string, e: MouseEvent) => {
|
||||
const addEvent = (type: string, details: string) => {
|
||||
const newEvent = {
|
||||
type,
|
||||
time: new Date().toLocaleTimeString(),
|
||||
position: `(${e.clientX}, ${e.clientY})`
|
||||
details
|
||||
};
|
||||
setEvents(prev => [newEvent, ...prev].slice(0, 5));
|
||||
setEvents(prev => [newEvent, ...prev].slice(0, 10));
|
||||
};
|
||||
|
||||
const handleNativeMouseEnter = (e: MouseEvent) => {
|
||||
addEvent('mouseenter', e);
|
||||
addEvent('mouseenter', `位置:(${e.clientX}, ${e.clientY})`);
|
||||
};
|
||||
|
||||
const handleNativeMouseMove = (e: MouseEvent) => {
|
||||
addEvent('mousemove', e);
|
||||
addEvent('mousemove', `位置:(${e.clientX}, ${e.clientY})`);
|
||||
};
|
||||
|
||||
const handleNativeMouseLeave = (e: MouseEvent) => {
|
||||
addEvent('mouseleave', e);
|
||||
addEvent('mouseleave', `位置:(${e.clientX}, ${e.clientY})`);
|
||||
};
|
||||
|
||||
const handleNativeMouseDown = (e: MouseEvent) => {
|
||||
addEvent('mousedown', e);
|
||||
addEvent('mousedown', `位置:(${e.clientX}, ${e.clientY})`);
|
||||
};
|
||||
|
||||
const handleNativeClick = (e: MouseEvent) => {
|
||||
addEvent('click', e);
|
||||
addEvent('click', `位置:(${e.clientX}, ${e.clientY})`);
|
||||
};
|
||||
|
||||
const handleNativeKeyDown = (e: KeyboardEvent) => {
|
||||
addEvent('keydown', `按键:${e.key},键码:${e.keyCode},修饰键:${[
|
||||
e.ctrlKey && 'Ctrl',
|
||||
e.shiftKey && 'Shift',
|
||||
e.altKey && 'Alt',
|
||||
e.metaKey && 'Meta'
|
||||
].filter(Boolean).join('+') || '无'}`);
|
||||
};
|
||||
|
||||
const handleNativeKeyUp = (e: KeyboardEvent) => {
|
||||
addEvent('keyup', `按键:${e.key},键码:${e.keyCode},修饰键:${[
|
||||
e.ctrlKey && 'Ctrl',
|
||||
e.shiftKey && 'Shift',
|
||||
e.altKey && 'Alt',
|
||||
e.metaKey && 'Meta'
|
||||
].filter(Boolean).join('+') || '无'}`);
|
||||
};
|
||||
|
||||
const handleNativeMouseWheel = (e: WheelEvent) => {
|
||||
e.preventDefault();
|
||||
const delta = e.deltaY;
|
||||
setWheelValue(prev => {
|
||||
const newValue = prev + delta;
|
||||
addEvent('mousewheel', `滚动值:${newValue},增量:${delta}`);
|
||||
return newValue;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
|
@ -734,21 +841,59 @@ function NativeDivDemo(): JSX.Element {
|
|||
<h2>原生 Div 事件演示</h2>
|
||||
<NativeDivPlayground>
|
||||
<DemoNativeDiv
|
||||
tabIndex={0}
|
||||
onNativeMouseEnter={handleNativeMouseEnter}
|
||||
onNativeMouseMove={handleNativeMouseMove}
|
||||
onNativeMouseLeave={handleNativeMouseLeave}
|
||||
onNativeClick={handleNativeClick}
|
||||
onNativeMouseDown={handleNativeMouseDown}
|
||||
onNativeKeyDown={handleNativeKeyDown}
|
||||
onNativeKeyUp={handleNativeKeyUp}
|
||||
onNativeMouseWheel={handleNativeMouseWheel}
|
||||
style={{ transform: `rotate(${wheelValue * 0.1}deg)` }}
|
||||
>
|
||||
与我互动!
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div>与我互动!</div>
|
||||
<small style={{
|
||||
fontSize: '0.8em',
|
||||
display: 'block',
|
||||
marginTop: '8px',
|
||||
color: 'rgba(255, 255, 255, 0.8)'
|
||||
}}>
|
||||
点击获取焦点
|
||||
<br />
|
||||
然后按键盘或滚动鼠标
|
||||
</small>
|
||||
<div style={{
|
||||
fontSize: '0.8em',
|
||||
marginTop: '8px',
|
||||
color: 'rgba(255, 255, 255, 0.8)'
|
||||
}}>
|
||||
滚动值:{wheelValue.toFixed(0)}
|
||||
</div>
|
||||
</div>
|
||||
</DemoNativeDiv>
|
||||
</NativeDivPlayground>
|
||||
<div style={{ marginTop: '20px' }}>
|
||||
<h3>最近的事件:</h3>
|
||||
<ul>
|
||||
<ul style={{
|
||||
listStyle: 'none',
|
||||
padding: 0,
|
||||
margin: 0,
|
||||
maxHeight: '200px',
|
||||
overflowY: 'auto',
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '4px'
|
||||
}}>
|
||||
{events.map((event, index) => (
|
||||
<li key={index}>
|
||||
{event.type} - 时间:{event.time} - 位置:{event.position}
|
||||
<li key={index} style={{
|
||||
padding: '8px',
|
||||
borderBottom: index < events.length - 1 ? '1px solid #eee' : 'none',
|
||||
backgroundColor: index === 0 ? '#f5f5f5' : 'transparent'
|
||||
}}>
|
||||
<strong>{event.type}</strong> - {event.time}
|
||||
<br />
|
||||
<span style={{ color: '#666', fontSize: '0.9em' }}>{event.details}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
@ -761,13 +906,384 @@ function NativeDivDemo(): JSX.Element {
|
|||
<li>在方块内移动鼠标来触发 mousemove 事件</li>
|
||||
<li>将鼠标移出方块来触发 mouseleave 事件</li>
|
||||
<li>点击方块来触发 click 事件</li>
|
||||
<li>所有事件都会显示在下方的列表中,包括触发时间和鼠标位置</li>
|
||||
<li><strong>点击方块获取焦点后,按下键盘按键来触发 keydown 和 keyup 事件</strong></li>
|
||||
<li><strong>在方块上滚动鼠标滚轮来旋转方块,并触发 mousewheel 事件</strong></li>
|
||||
<li>所有事件都会显示在下方的列表中,包括:</li>
|
||||
<ul>
|
||||
<li>事件类型和触发时间</li>
|
||||
<li>鼠标事件:鼠标位置坐标</li>
|
||||
<li>键盘事件:按键名称、键码和修饰键状态</li>
|
||||
<li>滚轮事件:滚动值和增量</li>
|
||||
</ul>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 添加 SideToolBar 演示组件
|
||||
function SideToolBarDemo(): JSX.Element {
|
||||
const [count, setCount] = useState(0);
|
||||
const [selectedToolId, setSelectedToolId] = useState<string | undefined>(undefined);
|
||||
|
||||
const handleToolSelect = (id: string) => {
|
||||
setSelectedToolId(id);
|
||||
};
|
||||
|
||||
const tools: Array<Tool | 'separator'> = [
|
||||
{
|
||||
id: 'select',
|
||||
icon: <i className="bi bi-hand-index"></i>,
|
||||
title: '选择工具',
|
||||
selectable: true,
|
||||
onClick: () => {
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'crop',
|
||||
icon: <i className="bi bi-crop"></i>,
|
||||
title: '裁剪工具',
|
||||
selectable: true,
|
||||
onClick: () => {
|
||||
}
|
||||
},
|
||||
'separator',
|
||||
{
|
||||
id: 'add',
|
||||
icon: <i className="bi bi-plus-lg"></i>,
|
||||
title: '添加',
|
||||
selectable: false,
|
||||
onClick: () => {
|
||||
setCount(prev => prev + 1);
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'subtract',
|
||||
icon: <i className="bi bi-dash-lg"></i>,
|
||||
title: '减少',
|
||||
selectable: false,
|
||||
onClick: () => {
|
||||
setCount(prev => prev - 1);
|
||||
}
|
||||
},
|
||||
'separator',
|
||||
{
|
||||
id: 'reset',
|
||||
icon: <i className="bi bi-arrow-clockwise"></i>,
|
||||
title: '重置',
|
||||
selectable: false,
|
||||
onClick: () => {
|
||||
setCount(0);
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2>工具栏演示</h2>
|
||||
<div style={{ display: 'flex', gap: '20px', alignItems: 'flex-start', marginTop: '20px' }}>
|
||||
<SideToolBar
|
||||
tools={tools}
|
||||
selectedToolId={selectedToolId}
|
||||
onSelectTool={handleToolSelect}
|
||||
/>
|
||||
<div>
|
||||
<h3>计数器: {count}</h3>
|
||||
<p>使用加号和减号按钮来改变计数器的值</p>
|
||||
<p>当前选中的工具ID: {selectedToolId || '无'}</p>
|
||||
<p>提示:只有选择工具和裁剪工具是可选择的,其他工具点击后不会保持选中状态</p>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ marginTop: '20px' }}>
|
||||
<h3>使用说明:</h3>
|
||||
<ul>
|
||||
<li>工具栏包含了多个工具按钮和分隔符</li>
|
||||
<li>鼠标悬停在按钮上会显示工具提示</li>
|
||||
<li>点击按钮会触发相应的操作</li>
|
||||
<li>只有 selectable=true 的工具(选择工具和裁剪工具)可以保持选中状态</li>
|
||||
<li>其他工具(加号、减号、重置)点击后不会保持选中状态</li>
|
||||
<li>分隔符用于分组相关的工具</li>
|
||||
<li>工具栏支持垂直布局和自定义样式</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// PropertyGrid 演示组件
|
||||
function PropertyGridDemo(): JSX.Element {
|
||||
const [text, setText] = useState('示例文本');
|
||||
const [number, setNumber] = useState(42);
|
||||
const [color, setColor] = useState('#4a90e2');
|
||||
const [checked, setChecked] = useState(false);
|
||||
const [selected, setSelected] = useState('option1');
|
||||
const [petName, setPetName] = useState('Kotone');
|
||||
const [favoriteColor, setFavoriteColor] = useState('Blue');
|
||||
const [firstName, setFirstName] = useState('John');
|
||||
const [notes, setNotes] = useState('这是一段备注信息...');
|
||||
|
||||
const properties: Array<Property | PropertyCategory> = [
|
||||
// 无分类的单独属性
|
||||
{
|
||||
title: '快速设置',
|
||||
render: () => (
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<Button onClick={() => setChecked(!checked)}>
|
||||
{checked ? '禁用' : '启用'}
|
||||
</Button>
|
||||
<Button onClick={() => setNumber(prev => prev + 1)}>
|
||||
数值 +1
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
// 无标题的全宽属性
|
||||
{
|
||||
render: () => (
|
||||
<div style={{
|
||||
padding: '8px',
|
||||
background: '#fff9e6',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
color: '#666'
|
||||
}}>
|
||||
提示:可以通过点击分类标题来展开或折叠属性组。
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '连接设置',
|
||||
properties: [
|
||||
{
|
||||
title: '宠物名称',
|
||||
render: () => (
|
||||
<input
|
||||
type="text"
|
||||
value={petName}
|
||||
onChange={(e) => setPetName(e.target.value)}
|
||||
style={{ width: '100%', padding: '4px' }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
],
|
||||
foldable: true
|
||||
},
|
||||
// 又一个无分类的属性
|
||||
{
|
||||
title: '状态',
|
||||
render: () => (
|
||||
<span style={{
|
||||
padding: '4px 8px',
|
||||
borderRadius: '4px',
|
||||
backgroundColor: checked ? '#e6ffe6' : '#ffe6e6',
|
||||
color: checked ? '#006600' : '#cc0000'
|
||||
}}>
|
||||
{checked ? '已启用' : '已禁用'}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '基本信息',
|
||||
properties: [
|
||||
{
|
||||
title: '喜欢的颜色',
|
||||
render: () => (
|
||||
<input
|
||||
type="text"
|
||||
value={favoriteColor}
|
||||
onChange={(e) => setFavoriteColor(e.target.value)}
|
||||
style={{ width: '100%', padding: '4px' }}
|
||||
/>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '名字',
|
||||
render: () => (
|
||||
<input
|
||||
type="text"
|
||||
value={firstName}
|
||||
onChange={(e) => setFirstName(e.target.value)}
|
||||
style={{ width: '100%', padding: '4px' }}
|
||||
/>
|
||||
)
|
||||
},
|
||||
// 分类中的无标题属性
|
||||
{
|
||||
render: () => (
|
||||
<div style={{
|
||||
padding: '8px',
|
||||
background: '#f0f0f0',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px'
|
||||
}}>
|
||||
这是基本信息分类中的一条说明文本。
|
||||
</div>
|
||||
)
|
||||
}
|
||||
],
|
||||
foldable: true
|
||||
},
|
||||
{
|
||||
title: '其他设置',
|
||||
properties: [
|
||||
{
|
||||
title: '文本输入',
|
||||
render: () => (
|
||||
<input
|
||||
type="text"
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
style={{ width: '100%', padding: '4px' }}
|
||||
/>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '数字输入',
|
||||
render: () => (
|
||||
<input
|
||||
type="number"
|
||||
value={number}
|
||||
onChange={(e) => setNumber(Number(e.target.value))}
|
||||
style={{ width: '100px', padding: '4px' }}
|
||||
/>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '颜色选择',
|
||||
render: () => (
|
||||
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
|
||||
<input
|
||||
type="color"
|
||||
value={color}
|
||||
onChange={(e) => setColor(e.target.value)}
|
||||
/>
|
||||
<span>{color}</span>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '复选框',
|
||||
render: () => (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={(e) => setChecked(e.target.checked)}
|
||||
/>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '超长文本',
|
||||
render: () => (
|
||||
<div style={{ width: '100%', height: '100px', backgroundColor: '#f0f0f0' }}>
|
||||
{Array(100).fill("这是一个超长文本,用于测试PropertyGrid的折叠功能。").join("")}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
],
|
||||
foldable: true
|
||||
},
|
||||
// 最后添加一个备注输入框
|
||||
{
|
||||
title: '备注',
|
||||
render: () => (
|
||||
<textarea
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '4px',
|
||||
minHeight: '60px',
|
||||
resize: 'vertical'
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2>属性网格演示</h2>
|
||||
<div style={{ maxWidth: '600px', marginTop: '20px' }}>
|
||||
<PropertyGrid properties={properties} />
|
||||
</div>
|
||||
<div style={{ marginTop: '20px' }}>
|
||||
<h3>当前值:</h3>
|
||||
<pre style={{
|
||||
backgroundColor: '#f5f5f5',
|
||||
padding: '15px',
|
||||
borderRadius: '4px'
|
||||
}}>
|
||||
{JSON.stringify({
|
||||
petName,
|
||||
favoriteColor,
|
||||
firstName,
|
||||
text,
|
||||
number,
|
||||
color,
|
||||
checked,
|
||||
selected,
|
||||
notes
|
||||
}, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
<div>
|
||||
<h3>使用说明:</h3>
|
||||
<ul>
|
||||
<li>PropertyGrid 组件用于展示和编辑一组属性</li>
|
||||
<li>属性可以按类别分组显示,也可以单独显示</li>
|
||||
<li>属性可以有标题,也可以占据整行显示</li>
|
||||
<li>每个类别有自己的标题栏</li>
|
||||
<li>可折叠的类别可以通过点击标题栏来展开/折叠</li>
|
||||
<li>支持在分类中和分类外混合显示属性</li>
|
||||
<li>支持各种类型的输入控件:文本框、数字输入、颜色选择器等</li>
|
||||
<li>可以通过 render 函数自定义属性的展示方式</li>
|
||||
<li>所有的属性值改变都会实时反映在下方的当前值显示中</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ImageViewerModal 演示组件
|
||||
function ImageViewerModalDemo(): JSX.Element {
|
||||
const { modal, openModal } = useImageViewerModal('示例图片');
|
||||
const demoImage = 'https://picsum.photos/1920/1080';
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2>图片查看器模态框演示</h2>
|
||||
<ControlPanel>
|
||||
<Button onClick={() => openModal(demoImage)}>打开图片</Button>
|
||||
</ControlPanel>
|
||||
{modal}
|
||||
<div>
|
||||
<h3>使用说明:</h3>
|
||||
<ul>
|
||||
<li>点击"打开图片"按钮显示图片查看器模态框</li>
|
||||
<li>在模态框中可以使用鼠标拖动和滚轮缩放图片</li>
|
||||
<li>使用底部工具栏的按钮可以:</li>
|
||||
<ul>
|
||||
<li>放大/缩小图片</li>
|
||||
<li>将图片缩放到适合容器大小</li>
|
||||
<li>重置图片缩放</li>
|
||||
</ul>
|
||||
<li>点击右上角的 X 按钮或按 ESC 键关闭模态框</li>
|
||||
<li>模态框支持设置标题,当前显示为"示例图片"</li>
|
||||
<li>模态框使用半透明背景,可以隐约看到背景内容</li>
|
||||
<li>使用 useImageViewerModal Hook 可以更方便地集成此组件</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const GlobalStyle = styled.div`
|
||||
.modal-90w {
|
||||
width: 90vw;
|
||||
max-width: 90vw !important;
|
||||
}
|
||||
`;
|
||||
|
||||
// 主Demo组件
|
||||
function Demo() {
|
||||
const navigate = useNavigate();
|
||||
|
@ -777,15 +1293,21 @@ function Demo() {
|
|||
const demos = [
|
||||
{ id: 'messageBox', name: '消息框', component: MessageBoxDemo },
|
||||
{ id: 'spinner', name: '加载动画', component: SpinnerDemo },
|
||||
{ id: 'toast', name: 'Toast 消息', component: ToastMessageDemo },
|
||||
{ id: 'singleViewer', name: '单图片查看器', component: SingleImageViewerDemo },
|
||||
{ id: 'multipleViewer', name: '多图片查看器', component: MultipleImagesViewerDemo },
|
||||
{ id: 'imageViewerModal', name: '图片查看器模态框', component: ImageViewerModalDemo },
|
||||
{ id: 'rectBox', name: '矩形框', component: RectBoxDemo },
|
||||
{ id: 'rectMask', name: '遮罩层', component: RectMaskDemo },
|
||||
{ id: 'imageEditor', name: '图片标注器', component: ImageEditorDemo },
|
||||
{ id: 'nativeDiv', name: '原生 Div', component: NativeDivDemo }
|
||||
{ id: 'nativeDiv', name: '原生 Div', component: NativeDivDemo },
|
||||
{ id: 'sideToolBar', name: '工具栏', component: SideToolBarDemo },
|
||||
{ id: 'propertyGrid', name: '属性网格', component: PropertyGridDemo }
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<GlobalStyle />
|
||||
<DemoContainer>
|
||||
<Sidebar>
|
||||
{demos.map(demo => (
|
||||
|
@ -811,6 +1333,7 @@ function Demo() {
|
|||
</Routes>
|
||||
</Content>
|
||||
</DemoContainer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,91 @@
|
|||
import React from 'react';
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
const DragAreaContainer = styled.div<{ isDragging: boolean }>`
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: ${props => props.isDragging ? 'rgba(0, 0, 0, 0.05)' : 'transparent'};
|
||||
transition: background-color 0.2s;
|
||||
`;
|
||||
|
||||
const DragOverlay = styled.div<{ isDragging: boolean }>`
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: rgba(255, 255, 255, 0.9);
|
||||
border: 2px dashed #ccc;
|
||||
pointer-events: none;
|
||||
opacity: ${props => props.isDragging ? 1 : 0};
|
||||
transition: opacity 0.2s;
|
||||
z-index: 1000;
|
||||
`;
|
||||
|
||||
const DragText = styled.div`
|
||||
font-size: 1.2rem;
|
||||
color: #666;
|
||||
`;
|
||||
|
||||
interface DragAreaProps {
|
||||
children?: React.ReactNode;
|
||||
onImageLoad?: (imageUrl: string) => void;
|
||||
}
|
||||
|
||||
const DragArea: React.FC<DragAreaProps> = ({ children, onImageLoad }) => {
|
||||
const [isDragging, setIsDragging] = React.useState(false);
|
||||
|
||||
const handleDragOver = React.useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(true);
|
||||
}, []);
|
||||
|
||||
const handleDragLeave = React.useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(false);
|
||||
}, []);
|
||||
|
||||
const handleDrop = React.useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(false);
|
||||
|
||||
const files = Array.from(e.dataTransfer.files);
|
||||
const imageFile = files.find(file => file.type.startsWith('image/'));
|
||||
|
||||
if (imageFile && onImageLoad) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
if (event.target?.result) {
|
||||
onImageLoad(event.target.result as string);
|
||||
}
|
||||
};
|
||||
reader.readAsDataURL(imageFile);
|
||||
}
|
||||
}, [onImageLoad]);
|
||||
|
||||
return (
|
||||
<DragAreaContainer
|
||||
isDragging={isDragging}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
{children}
|
||||
<DragOverlay isDragging={isDragging}>
|
||||
<DragText>拖放图片到此处</DragText>
|
||||
</DragOverlay>
|
||||
</DragAreaContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default DragArea;
|
|
@ -0,0 +1,583 @@
|
|||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import styled from '@emotion/styled';
|
||||
import { SideToolBar, Tool } from '../../components/SideToolBar';
|
||||
import PropertyGrid, { Property, PropertyCategory } from '../../components/PropertyGrid';
|
||||
import ImageEditor, { AnnotationChangedEvent } from '../../components/ImageEditor/ImageEditor';
|
||||
import { Annotation, Tool as EditorTool } from '../../components/ImageEditor/types';
|
||||
import { BsCursor, BsSquare, BsFolder2Open, BsUpload, BsFloppy, BsDownload } from 'react-icons/bs';
|
||||
import { useImmer } from 'use-immer';
|
||||
import { useImageViewerModal } from '../../components/ImageViewerModal';
|
||||
import { useMessageBox } from '../../hooks/useMessageBox';
|
||||
import { useToast } from '../../components/ToastMessage';
|
||||
import DragArea from './DragArea';
|
||||
import { Definitions, ImageMetaData, TemplateDefinition, DefinitionType } from './types';
|
||||
import { cropImage, openFileWFS, openFileInput, downloadJSONToFile, readFileAsJSON, readFileAsDataURL, FileResult, saveFileWFS } from '../../utils/fileUtils';
|
||||
import NativeDiv from '../../components/NativeDiv';
|
||||
|
||||
const PageContainer = styled.div`
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
background-color: #f8f9fa;
|
||||
`;
|
||||
|
||||
const EditorContainer = styled(NativeDiv)`
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
background-color: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
`;
|
||||
|
||||
const PropertyContainer = styled.div`
|
||||
width: 300px;
|
||||
background-color: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
padding: 16px;
|
||||
`;
|
||||
|
||||
const Tip = styled.span`
|
||||
font-weight: bold;
|
||||
color: white;
|
||||
text-shadow:
|
||||
-1px -1px 0 black,
|
||||
1px -1px 0 black,
|
||||
-1px 1px 0 black,
|
||||
1px 1px 0 black;
|
||||
`;
|
||||
|
||||
// 工具栏配置
|
||||
const tools: Array<Tool | 'separator'> = [
|
||||
{
|
||||
id: 'open',
|
||||
icon: <BsFolder2Open size={24} />,
|
||||
title: '打开 (WebFileSystem)',
|
||||
selectable: false,
|
||||
},
|
||||
{
|
||||
id: 'save',
|
||||
icon: <BsFloppy size={24} />,
|
||||
title: '保存 (WebFileSystem)',
|
||||
selectable: false,
|
||||
},
|
||||
{
|
||||
id: 'upload',
|
||||
icon: <BsUpload size={24} />,
|
||||
title: '上传 (Input)',
|
||||
selectable: false,
|
||||
},
|
||||
{
|
||||
id: 'download',
|
||||
icon: <BsDownload size={24} />,
|
||||
title: '下载 (Input)',
|
||||
selectable: false,
|
||||
},
|
||||
'separator',
|
||||
{
|
||||
id: 'drag',
|
||||
icon: <BsCursor size={24} />,
|
||||
title: '拖动工具 (V)',
|
||||
selectable: true,
|
||||
},
|
||||
{
|
||||
id: 'rect',
|
||||
icon: <BsSquare size={24} />,
|
||||
title: '矩形工具 (R)',
|
||||
selectable: true,
|
||||
},
|
||||
];
|
||||
const toolsMap: Record<string, EditorTool> = {
|
||||
drag: EditorTool.Drag,
|
||||
rect: EditorTool.Rect,
|
||||
};
|
||||
|
||||
// 示例图片URL
|
||||
const SAMPLE_IMAGE_URL = 'https://picsum.photos/seed/123/800/600';
|
||||
|
||||
// 计算最大公约数
|
||||
const gcd = (a: number, b: number): number => {
|
||||
a = Math.abs(a);
|
||||
b = Math.abs(b);
|
||||
while (b) {
|
||||
const t = b;
|
||||
b = a % b;
|
||||
a = t;
|
||||
}
|
||||
return a;
|
||||
};
|
||||
|
||||
// 计算最简比例
|
||||
const getSimplestRatio = (width: number, height: number): string => {
|
||||
const divisor = gcd(width, height);
|
||||
const simpleWidth = width / divisor;
|
||||
const simpleHeight = height / divisor;
|
||||
return `${simpleWidth}:${simpleHeight}`;
|
||||
};
|
||||
|
||||
// 属性栏数据 Hook
|
||||
const usePropertyGridData = (
|
||||
selectedAnnotation: Annotation | null,
|
||||
definitions: Definitions,
|
||||
image: HTMLImageElement | null,
|
||||
onImageClick: (imageUrl: string) => void,
|
||||
onDefinitionChange?: (id: string, changes: Partial<TemplateDefinition>) => void,
|
||||
imageFileName?: string,
|
||||
annotations?: Annotation[],
|
||||
currentFileResult?: FileResult | null
|
||||
) => {
|
||||
const [croppedImageUrl, setCroppedImageUrl] = React.useState<string>('');
|
||||
|
||||
React.useEffect(() => {
|
||||
if (selectedAnnotation && image) {
|
||||
const url = cropImage(image, selectedAnnotation.data);
|
||||
setCroppedImageUrl(url);
|
||||
} else {
|
||||
setCroppedImageUrl('');
|
||||
}
|
||||
}, [selectedAnnotation, image]);
|
||||
|
||||
if (!selectedAnnotation) {
|
||||
if (!image) return [];
|
||||
|
||||
return [
|
||||
{
|
||||
title: '文件名',
|
||||
render: () => imageFileName || '未命名',
|
||||
},
|
||||
{
|
||||
title: '打开方式',
|
||||
render: () => currentFileResult?.fileSystem === 'wfs' ? 'WebFileSystem' : 'Input',
|
||||
},
|
||||
{
|
||||
title: '宽高',
|
||||
render: () => `${image.width} × ${image.height}`,
|
||||
},
|
||||
{
|
||||
title: '宽高比',
|
||||
render: () => getSimplestRatio(image.width, image.height),
|
||||
},
|
||||
{
|
||||
title: '标注数量',
|
||||
render: () => annotations?.length || 0,
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
const definition = definitions[selectedAnnotation.id];
|
||||
const { x1, y1, x2, y2 } = selectedAnnotation.data;
|
||||
|
||||
const generalProperties: Array<PropertyCategory | Property> = [
|
||||
{
|
||||
render: () => {
|
||||
if (!image) return <span>图片加载中...</span>;
|
||||
if (!croppedImageUrl) return <span>裁剪中...</span>;
|
||||
return (
|
||||
<div style={{
|
||||
height: '100px',
|
||||
margin: '0 auto'
|
||||
}}>
|
||||
<img
|
||||
src={croppedImageUrl}
|
||||
style={{
|
||||
maxWidth: '100%',
|
||||
maxHeight: '100px',
|
||||
objectFit: 'contain',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
onClick={() => onImageClick(croppedImageUrl)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '通用',
|
||||
properties: [
|
||||
{
|
||||
title: '名称',
|
||||
render: {
|
||||
type: 'text',
|
||||
required: true,
|
||||
value: definition.name,
|
||||
onChange: (value: string) => onDefinitionChange?.(selectedAnnotation.id, { name: value }),
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '显示名称',
|
||||
render: {
|
||||
type: 'text',
|
||||
required: true,
|
||||
value: definition.displayName,
|
||||
onChange: (value: string) => onDefinitionChange?.(selectedAnnotation.id, { displayName: value }),
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '类型',
|
||||
render: () => definition.type,
|
||||
}
|
||||
],
|
||||
foldable: true
|
||||
},
|
||||
];
|
||||
const annotationProperties: Array<PropertyCategory | Property> = [
|
||||
{
|
||||
title: '标注',
|
||||
properties: [
|
||||
{
|
||||
title: 'ID',
|
||||
render: () => selectedAnnotation.id,
|
||||
},
|
||||
{
|
||||
title: '类型',
|
||||
render: () => '矩形',
|
||||
},
|
||||
{
|
||||
title: '范围',
|
||||
render: () => `(${x1}, ${y1}, ${x2}, ${y2})`,
|
||||
},
|
||||
{
|
||||
title: '宽高',
|
||||
render: () => `${x2 - x1} × ${y2 - y1}`,
|
||||
}
|
||||
],
|
||||
foldable: true
|
||||
}
|
||||
];
|
||||
|
||||
let specificProperties: Array<PropertyCategory | Property> = [];
|
||||
if (definition.type === 'template') {
|
||||
const rectDef = definition as TemplateDefinition;
|
||||
specificProperties = [
|
||||
{
|
||||
title: '模板',
|
||||
properties: [
|
||||
{
|
||||
title: '提示矩形',
|
||||
render: {
|
||||
type: 'checkbox',
|
||||
required: false,
|
||||
value: rectDef.useHintRect,
|
||||
onChange: (value: boolean) => onDefinitionChange?.(selectedAnnotation.id, { useHintRect: value }),
|
||||
}
|
||||
}
|
||||
],
|
||||
foldable: true,
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
...generalProperties,
|
||||
...specificProperties,
|
||||
...annotationProperties,
|
||||
];
|
||||
};
|
||||
|
||||
const ImageAnnotation: React.FC = () => {
|
||||
const [currentTool, setCurrentTool] = useState<EditorTool>(EditorTool.Drag);
|
||||
const [imageMetaData, updateImageMetaData] = useImmer<ImageMetaData>({
|
||||
definitions: {},
|
||||
annotations: [],
|
||||
});
|
||||
const [selectedAnnotation, setSelectedAnnotation] = useState<Annotation | null>(null);
|
||||
const [isDirty, setIsDirty] = useState(false);
|
||||
const [image, setImage] = useState<HTMLImageElement | null>(null);
|
||||
const imageFileNameRef = useRef<string>('');
|
||||
const { modal, openModal } = useImageViewerModal('裁剪预览');
|
||||
const [imageUrl, setImageUrl] = useState<string>(SAMPLE_IMAGE_URL);
|
||||
const { yesNo, MessageBoxComponent } = useMessageBox();
|
||||
const { showToast, ToastComponent } = useToast();
|
||||
const currentFileResult = useRef<FileResult | null>(null);
|
||||
|
||||
// 预加载图片
|
||||
useEffect(() => {
|
||||
const img = new Image();
|
||||
img.crossOrigin = 'anonymous';
|
||||
img.onload = () => {
|
||||
setImage(img);
|
||||
};
|
||||
img.src = imageUrl;
|
||||
}, [imageUrl]);
|
||||
|
||||
const handleImageLoad = useCallback((newImageUrl: string, shouldClearMetaData: boolean = true) => {
|
||||
setImageUrl(newImageUrl);
|
||||
if (shouldClearMetaData) {
|
||||
// 只有在不是同时加载 meta 数据时才清空标注
|
||||
updateImageMetaData(draft => {
|
||||
draft.annotations = [];
|
||||
draft.definitions = {};
|
||||
});
|
||||
setSelectedAnnotation(null);
|
||||
setIsDirty(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleAnnotationChange = (e: AnnotationChangedEvent) => {
|
||||
if (e.type === 'add') {
|
||||
let type: DefinitionType | undefined = undefined;
|
||||
if (currentTool === EditorTool.Rect) {
|
||||
type = 'template';
|
||||
}
|
||||
if (!type) {
|
||||
showToast('danger', '错误', '无法识别的标注类型');
|
||||
return;
|
||||
}
|
||||
updateImageMetaData(draft => {
|
||||
draft.annotations.push(e.annotation);
|
||||
draft.definitions[e.annotation.id] = {
|
||||
name: '',
|
||||
displayName: '',
|
||||
type: type,
|
||||
annotationId: e.annotation.id,
|
||||
path: '',
|
||||
useHintRect: false,
|
||||
} as TemplateDefinition;
|
||||
});
|
||||
setIsDirty(true);
|
||||
} else if (e.type === 'update') {
|
||||
updateImageMetaData(draft => {
|
||||
const oldAnnotation = draft.annotations.find(a => a.id === e.annotation.id);
|
||||
if (!oldAnnotation) return;
|
||||
Object.assign(oldAnnotation, e.annotation);
|
||||
});
|
||||
if (selectedAnnotation?.id === e.annotation.id) {
|
||||
setSelectedAnnotation(e.annotation);
|
||||
}
|
||||
setIsDirty(true);
|
||||
} else if (e.type === 'remove') {
|
||||
updateImageMetaData(draft => {
|
||||
const index = draft.annotations.findIndex(a => a.id === e.annotation.id);
|
||||
if (index !== -1) {
|
||||
draft.annotations.splice(index, 1);
|
||||
}
|
||||
});
|
||||
if (selectedAnnotation?.id === e.annotation.id) {
|
||||
setSelectedAnnotation(null);
|
||||
updateImageMetaData(draft => {
|
||||
delete draft.definitions[e.annotation.id];
|
||||
});
|
||||
}
|
||||
setIsDirty(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpen = useCallback(async (useFileSystem: boolean = false) => {
|
||||
// 如果有未保存的修改,显示确认对话框
|
||||
if (isDirty) {
|
||||
const result = await yesNo({
|
||||
title: '未保存的修改',
|
||||
text: '当前有未保存的修改,是否继续打开新图片?未保存的修改将会丢失。'
|
||||
});
|
||||
if (result === 'no') {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const openFunc = useFileSystem ? openFileWFS : openFileInput;
|
||||
const result = await openFunc({
|
||||
accept: 'image/*,.json',
|
||||
multiple: true,
|
||||
});
|
||||
|
||||
const imageFile = result.files.find((f: FileResult) => f.file.type.startsWith('image/'));
|
||||
const jsonFile = result.files.find((f: FileResult) => f.file.name.endsWith('.json'));
|
||||
|
||||
if (imageFile) {
|
||||
imageFileNameRef.current = imageFile.name;
|
||||
const dataUrl = await readFileAsDataURL(imageFile.file);
|
||||
handleImageLoad(dataUrl, !jsonFile);
|
||||
}
|
||||
|
||||
// 保存文件句柄
|
||||
if (jsonFile) {
|
||||
currentFileResult.current = jsonFile;
|
||||
try {
|
||||
const metaData = await readFileAsJSON(jsonFile.file);
|
||||
updateImageMetaData(draft => {
|
||||
draft.annotations = metaData?.annotations || [];
|
||||
draft.definitions = metaData?.definitions || {};
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to parse JSON file:', error);
|
||||
throw new Error('JSON文件格式错误,无法加载。');
|
||||
}
|
||||
}
|
||||
|
||||
if (imageFile && jsonFile) {
|
||||
showToast('success', '加载成功', '已载入图片与 meta 数据');
|
||||
} else if (imageFile) {
|
||||
showToast('success', '加载成功', '已载入新图片');
|
||||
} else if (jsonFile) {
|
||||
showToast('success', '加载成功', '已载入 meta 数据');
|
||||
}
|
||||
} catch (error) {
|
||||
await yesNo({
|
||||
title: '错误',
|
||||
text: error instanceof Error ? error.message : '加载文件时发生错误'
|
||||
});
|
||||
showToast('danger', '加载失败', '无法加载文件');
|
||||
}
|
||||
}, [handleImageLoad, isDirty, yesNo, showToast, updateImageMetaData]);
|
||||
|
||||
const handleUpload = useCallback(async () => {
|
||||
await handleOpen(false);
|
||||
}, [handleOpen]);
|
||||
|
||||
const handleDownload = useCallback(() => {
|
||||
const data = imageMetaData;
|
||||
const filename = imageFileNameRef.current ? `${imageFileNameRef.current}.json` : 'metadata.json';
|
||||
downloadJSONToFile(data, filename);
|
||||
setIsDirty(false);
|
||||
}, [imageMetaData]);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (currentFileResult.current?.fileSystem !== 'wfs') {
|
||||
showToast('warning', '无法保存', '当前文件不是通过文件系统打开的');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const handle = await saveFileWFS(
|
||||
currentFileResult.current?.handle,
|
||||
JSON.stringify(imageMetaData),
|
||||
imageFileNameRef.current ? `${imageFileNameRef.current}.json` : 'metadata.json'
|
||||
);
|
||||
// 更新文件句柄
|
||||
if (handle !== currentFileResult.current?.handle) {
|
||||
currentFileResult.current = {
|
||||
file: await handle.getFile(),
|
||||
name: (await handle.getFile()).name,
|
||||
handle,
|
||||
fileSystem: 'wfs'
|
||||
};
|
||||
}
|
||||
setIsDirty(false);
|
||||
showToast('success', '保存成功', '文件已保存');
|
||||
} catch (error) {
|
||||
console.error('Failed to save file:', error);
|
||||
showToast('danger', '保存失败', '无法保存文件');
|
||||
}
|
||||
}, [currentFileResult, imageMetaData, showToast]);
|
||||
|
||||
const handleToolSelect = useCallback((id: string) => {
|
||||
setCurrentTool(toolsMap[id]);
|
||||
}, [toolsMap]);
|
||||
const handleToolClick = useCallback((id: string) => {
|
||||
if (id === 'upload') {
|
||||
handleUpload();
|
||||
} else if (id === 'open') {
|
||||
handleOpen(true);
|
||||
} else if (id === 'download') {
|
||||
handleDownload();
|
||||
} else if (id === 'save') {
|
||||
handleSave();
|
||||
}
|
||||
}, [handleUpload, handleOpen, handleDownload, handleSave]);
|
||||
|
||||
const handleAnnotationSelect = (annotation: Annotation | null) => {
|
||||
setSelectedAnnotation(annotation);
|
||||
};
|
||||
|
||||
const handleDefinitionChange = (id: string, changes: Partial<TemplateDefinition>) => {
|
||||
updateImageMetaData(draft => {
|
||||
Object.assign(draft.definitions[id], changes);
|
||||
const oldDef = draft.definitions[id];
|
||||
const annotation = draft.annotations.find(a => a.id === id);
|
||||
const displayName = changes.displayName || oldDef.displayName;
|
||||
const name = changes.name || oldDef.name;
|
||||
if (oldDef && annotation) {
|
||||
annotation.tip = <Tip>{displayName} ({name})</Tip>;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleKeyDown = useCallback((e: KeyboardEvent) => {
|
||||
// 如果正在输入文本,不处理快捷键
|
||||
console.log(e.target);
|
||||
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 处理 Ctrl + S 保存快捷键
|
||||
if (e.ctrlKey && e.key.toLowerCase() === 's') {
|
||||
e.preventDefault(); // 阻止浏览器默认的保存行为
|
||||
handleSave();
|
||||
return;
|
||||
}
|
||||
|
||||
const key = e.key.toLowerCase();
|
||||
switch (key) {
|
||||
case 'v':
|
||||
setCurrentTool(EditorTool.Drag);
|
||||
break;
|
||||
case 'r':
|
||||
setCurrentTool(EditorTool.Rect);
|
||||
break;
|
||||
case 'delete':
|
||||
if (selectedAnnotation) {
|
||||
handleAnnotationChange({
|
||||
currentTool: EditorTool.Drag,
|
||||
type: 'remove',
|
||||
annotationType: 'rect',
|
||||
annotation: selectedAnnotation
|
||||
});
|
||||
setSelectedAnnotation(null);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}, [selectedAnnotation, handleAnnotationChange, handleSave]);
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [handleKeyDown]);
|
||||
|
||||
const properties = usePropertyGridData(
|
||||
selectedAnnotation,
|
||||
imageMetaData.definitions,
|
||||
image,
|
||||
(imageUrl) => openModal(imageUrl, { imageRendering: 'pixelated' }),
|
||||
handleDefinitionChange,
|
||||
imageFileNameRef.current,
|
||||
imageMetaData.annotations,
|
||||
currentFileResult.current
|
||||
);
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<SideToolBar
|
||||
tools={tools}
|
||||
selectedToolId={currentTool}
|
||||
onSelectTool={handleToolSelect}
|
||||
onClickTool={handleToolClick}
|
||||
/>
|
||||
<EditorContainer>
|
||||
<DragArea onImageLoad={handleImageLoad}>
|
||||
<ImageEditor
|
||||
image={imageUrl}
|
||||
tool={currentTool}
|
||||
annotations={imageMetaData.annotations}
|
||||
onAnnotationChanged={handleAnnotationChange}
|
||||
onAnnotationSelected={handleAnnotationSelect}
|
||||
enableMask
|
||||
showCrosshair
|
||||
/>
|
||||
</DragArea>
|
||||
</EditorContainer>
|
||||
<PropertyContainer>
|
||||
<PropertyGrid properties={properties} />
|
||||
</PropertyContainer>
|
||||
{modal}
|
||||
{MessageBoxComponent}
|
||||
{ToastComponent}
|
||||
</PageContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default ImageAnnotation;
|
|
@ -0,0 +1,34 @@
|
|||
import { Annotation } from '../../components/ImageEditor/types';
|
||||
|
||||
export type DefinitionType = 'template' | 'ocr' | 'color';
|
||||
|
||||
export interface BaseDefinition {
|
||||
/** 最终出现在 R.py 中的名称 */
|
||||
name: string;
|
||||
/** 显示在调试器与调试输出中的名称 */
|
||||
displayName: string;
|
||||
type: DefinitionType;
|
||||
/** 标注 ID */
|
||||
annotationId: string;
|
||||
}
|
||||
|
||||
|
||||
export interface TemplateDefinition extends BaseDefinition {
|
||||
type: 'template';
|
||||
/**
|
||||
* 是否将这个模板的矩形范围作为运行时
|
||||
* 执行模板寻找函数时的提示范围。
|
||||
*
|
||||
* 若为 true,则运行时会先在这个范围内寻找,
|
||||
* 如果没找到,再在整张截图中寻找。
|
||||
*/
|
||||
useHintRect: boolean
|
||||
}
|
||||
|
||||
|
||||
export type Definitions = Record<string, BaseDefinition>;
|
||||
|
||||
export interface ImageMetaData {
|
||||
definitions: Definitions;
|
||||
annotations: Annotation[];
|
||||
}
|
|
@ -1,6 +1,7 @@
|
|||
import { createBrowserRouter } from 'react-router-dom';
|
||||
import { Home } from './pages/Home';
|
||||
import Demo from './pages/Demo';
|
||||
import ImageAnnotation from './pages/ImageAnnotation/ImageAnnotation';
|
||||
|
||||
export const router = createBrowserRouter([
|
||||
{
|
||||
|
@ -11,4 +12,8 @@ export const router = createBrowserRouter([
|
|||
path: '/demo/*',
|
||||
element: <Demo />,
|
||||
},
|
||||
{
|
||||
path: '/image-annotation',
|
||||
element: <ImageAnnotation />,
|
||||
},
|
||||
]);
|
|
@ -1,40 +1,286 @@
|
|||
import { DebugRecord } from '../types/debug';
|
||||
export interface FileResult {
|
||||
file: File;
|
||||
name: string;
|
||||
handle?: FileSystemFileHandle;
|
||||
fileSystem: 'wfs' | 'input';
|
||||
}
|
||||
|
||||
export const readJsonFile = async (file: File): Promise<DebugRecord[]> => {
|
||||
export interface OpenFilesOptions {
|
||||
accept?: string;
|
||||
multiple?: boolean;
|
||||
}
|
||||
|
||||
export interface OpenFilesResult {
|
||||
files: FileResult[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用传统的 input 标签上传文件
|
||||
*/
|
||||
export const openFileInput = async (options: OpenFilesOptions = {}): Promise<OpenFilesResult> => {
|
||||
const {
|
||||
multiple = false,
|
||||
} = options;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = '.json,.png,.jpg,.jpeg';
|
||||
input.multiple = multiple;
|
||||
|
||||
input.onchange = async (e) => {
|
||||
const files = Array.from((e.target as HTMLInputElement).files || []);
|
||||
if (files.length === 0) {
|
||||
resolve({ files: [] });
|
||||
return;
|
||||
}
|
||||
|
||||
const results: FileResult[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
const result: FileResult = {
|
||||
file,
|
||||
name: file.name,
|
||||
fileSystem: 'input'
|
||||
};
|
||||
|
||||
try {
|
||||
results.push(result);
|
||||
} catch (error) {
|
||||
console.error(`Failed to read file ${file.name}:`, error);
|
||||
throw new Error(`无法读取文件 ${file.name}`);
|
||||
}
|
||||
}
|
||||
|
||||
resolve({ files: results });
|
||||
};
|
||||
|
||||
input.click();
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 使用 Web File System API 打开文件
|
||||
*/
|
||||
export const openFileWFS = async (options: OpenFilesOptions = {}): Promise<OpenFilesResult> => {
|
||||
const {
|
||||
multiple = false,
|
||||
} = options;
|
||||
|
||||
try {
|
||||
// @ts-ignore - FileSystemHandle API 可能在某些环境下不支持
|
||||
const handles = await window.showOpenFilePicker({
|
||||
types: [
|
||||
{
|
||||
description: '图片文件 / meta 数据',
|
||||
accept: {
|
||||
'image/jpeg': ['.jpg', '.jpeg'],
|
||||
'image/png': ['.png'],
|
||||
'application/json': ['.json']
|
||||
}
|
||||
},
|
||||
],
|
||||
multiple
|
||||
});
|
||||
|
||||
const results: FileResult[] = [];
|
||||
|
||||
for (const handle of handles) {
|
||||
const file = await handle.getFile();
|
||||
results.push({
|
||||
file,
|
||||
name: file.name,
|
||||
handle,
|
||||
fileSystem: 'wfs'
|
||||
});
|
||||
}
|
||||
|
||||
return { files: results };
|
||||
} catch (error) {
|
||||
if ((error as Error).name === 'AbortError') {
|
||||
return { files: [] };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 打开文件。优先使用 FileSystem API,失败时回退到上传方式
|
||||
*/
|
||||
export const openFiles = async (options: OpenFilesOptions = {}): Promise<OpenFilesResult> => {
|
||||
try {
|
||||
// @ts-ignore - 检查是否支持 FileSystem API
|
||||
if (window.showOpenFilePicker) {
|
||||
return await openFileWFS(options);
|
||||
} else {
|
||||
return await openFileInput(options);
|
||||
}
|
||||
} catch (error) {
|
||||
return await openFileInput(options);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 将文件读取为文本
|
||||
*/
|
||||
export const readFileAsText = (file: File): Promise<string> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
try {
|
||||
const content = event.target?.result as string;
|
||||
const data = JSON.parse(content);
|
||||
if (Array.isArray(data) && data.every(item =>
|
||||
item.id && item.timestamp && item.message)) {
|
||||
resolve(data as DebugRecord[]);
|
||||
} else {
|
||||
reject(new Error('文件格式不正确'));
|
||||
}
|
||||
} catch {
|
||||
reject(new Error('无法解析JSON文件'));
|
||||
}
|
||||
};
|
||||
reader.onerror = () => reject(new Error('读取文件失败'));
|
||||
reader.onload = (e) => resolve(e.target?.result as string);
|
||||
reader.onerror = reject;
|
||||
reader.readAsText(file);
|
||||
});
|
||||
};
|
||||
|
||||
export const formatTimestamp = (timestamp: number): string => {
|
||||
const date = new Date(timestamp);
|
||||
return date.toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false
|
||||
/**
|
||||
* 将文件读取为DataURL
|
||||
*/
|
||||
export const readFileAsDataURL = (file: File): Promise<string> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => resolve(e.target?.result as string);
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
};
|
||||
|
||||
export const generateUniqueId = (): string => {
|
||||
return Date.now().toString(36) + Math.random().toString(36).substring(2);
|
||||
/**
|
||||
* 将文件读取为JSON对象
|
||||
*/
|
||||
export const readFileAsJSON = (file: File): Promise<any> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
try {
|
||||
const json = JSON.parse(e.target?.result as string);
|
||||
resolve(json);
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
};
|
||||
reader.onerror = reject;
|
||||
reader.readAsText(file);
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* 保存JSON数据到文件
|
||||
*/
|
||||
export const downloadJSONToFile = (data: any, filename: string) => {
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
/**
|
||||
* 裁剪图片
|
||||
*/
|
||||
export const cropImage = (
|
||||
img: HTMLImageElement,
|
||||
rect: { x1: number, y1: number, x2: number, y2: number }
|
||||
): string => {
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
const width = rect.x2 - rect.x1;
|
||||
const height = rect.y2 - rect.y1;
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
|
||||
ctx?.drawImage(
|
||||
img,
|
||||
rect.x1, rect.y1, width, height,
|
||||
0, 0, width, height
|
||||
);
|
||||
|
||||
return canvas.toDataURL('image/png');
|
||||
};
|
||||
|
||||
/**
|
||||
* 使用 Web File System API 创建新文件并保存
|
||||
* @param content 要保存的内容(字符串、Blob 或 ArrayBuffer)
|
||||
* @param suggestedName 建议的文件名
|
||||
* @returns 新的 FileSystemFileHandle 对象
|
||||
*/
|
||||
export const saveFileAsWFS = async (content: string | Blob | ArrayBuffer, suggestedName: string): Promise<FileSystemFileHandle> => {
|
||||
try {
|
||||
// @ts-ignore - FileSystemHandle API 可能在某些环境下不支持
|
||||
const handle = await window.showSaveFilePicker({
|
||||
suggestedName,
|
||||
types: [
|
||||
{
|
||||
description: 'JSON 文件',
|
||||
accept: {
|
||||
'application/json': ['.json']
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
// 获取写入权限并写入内容
|
||||
const writable = await handle.createWritable();
|
||||
await writable.write(content);
|
||||
await writable.close();
|
||||
|
||||
return handle;
|
||||
} catch (error) {
|
||||
console.error('Failed to save file:', error);
|
||||
throw new Error('保存文件失败');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 使用 Web File System API 保存文件
|
||||
* @param handle FileSystemFileHandle 对象,如果为空则会弹出另存为对话框
|
||||
* @param content 要保存的内容(字符串、Blob 或 ArrayBuffer)
|
||||
* @param suggestedName 当需要另存为时的建议文件名
|
||||
* @returns 如果是另存为,则返回新的 FileSystemFileHandle;否则返回原来的 handle
|
||||
*/
|
||||
export const saveFileWFS = async (
|
||||
handle: FileSystemFileHandle | undefined | null,
|
||||
content: string | Blob | ArrayBuffer,
|
||||
suggestedName?: string
|
||||
): Promise<FileSystemFileHandle> => {
|
||||
try {
|
||||
if (!handle) {
|
||||
// 如果没有 handle,执行另存为操作
|
||||
return await saveFileAsWFS(content, suggestedName || 'metadata.json');
|
||||
}
|
||||
|
||||
// 有 handle,直接保存
|
||||
const writable = await handle.createWritable();
|
||||
await writable.write(content);
|
||||
await writable.close();
|
||||
return handle;
|
||||
} catch (error) {
|
||||
console.error('Failed to save file:', error);
|
||||
throw new Error('保存文件失败');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 检查文件是否是通过 Web File System API 打开的
|
||||
* @param fileResult FileResult 对象
|
||||
* @returns 是否可以使用 WFS API 保存
|
||||
*/
|
||||
export const canSaveWithWFS = (fileResult?: FileResult): boolean => {
|
||||
return !!fileResult?.handle;
|
||||
};
|
||||
|
||||
/**
|
||||
* 使用 Web File System API 保存图片数据到文件
|
||||
* @param handle FileSystemFileHandle 对象
|
||||
* @param imageData 图片数据(Base64 或 Blob)
|
||||
*/
|
||||
export const saveImageWithHandle = async (handle: FileSystemFileHandle, imageData: string | Blob): Promise<void> => {
|
||||
if (typeof imageData === 'string' && imageData.startsWith('data:')) {
|
||||
// 将 Base64 转换为 Blob
|
||||
const response = await fetch(imageData);
|
||||
imageData = await response.blob();
|
||||
}
|
||||
await saveFileWFS(handle, imageData);
|
||||
};
|
|
@ -1,82 +0,0 @@
|
|||
import { ViewState } from '../types/debug';
|
||||
|
||||
export const DEFAULT_VIEW_STATE: ViewState = {
|
||||
scale: 1,
|
||||
x: 0,
|
||||
y: 0,
|
||||
locked: false,
|
||||
};
|
||||
|
||||
export const calculateImageDimensions = (
|
||||
imageWidth: number,
|
||||
imageHeight: number,
|
||||
containerWidth: number,
|
||||
containerHeight: number
|
||||
): { width: number; height: number; scale: number } => {
|
||||
const imageRatio = imageWidth / imageHeight;
|
||||
const containerRatio = containerWidth / containerHeight;
|
||||
|
||||
let scale = 1;
|
||||
let width = imageWidth;
|
||||
let height = imageHeight;
|
||||
|
||||
if (imageRatio > containerRatio) {
|
||||
// 图片更宽,以容器宽度为基准
|
||||
if (imageWidth > containerWidth) {
|
||||
scale = containerWidth / imageWidth;
|
||||
width = containerWidth;
|
||||
height = imageHeight * scale;
|
||||
}
|
||||
} else {
|
||||
// 图片更高,以容器高度为基准
|
||||
if (imageHeight > containerHeight) {
|
||||
scale = containerHeight / imageHeight;
|
||||
height = containerHeight;
|
||||
width = imageWidth * scale;
|
||||
}
|
||||
}
|
||||
|
||||
return { width, height, scale };
|
||||
};
|
||||
|
||||
export const calculateZoom = (
|
||||
currentScale: number,
|
||||
delta: number,
|
||||
minScale = 0.1,
|
||||
maxScale = 5
|
||||
): number => {
|
||||
const ZOOM_SENSITIVITY = 0.001;
|
||||
const newScale = currentScale * (1 - delta * ZOOM_SENSITIVITY);
|
||||
return Math.min(Math.max(newScale, minScale), maxScale);
|
||||
};
|
||||
|
||||
export const getImageUrl = (
|
||||
type: 'memory' | 'file',
|
||||
path: string,
|
||||
baseUrl = ''
|
||||
): string => {
|
||||
if (type === 'memory') {
|
||||
return `${baseUrl}/api/read_memory?key=${encodeURIComponent(path)}`;
|
||||
}
|
||||
return `${baseUrl}/api/read_file?path=${encodeURIComponent(path)}`;
|
||||
};
|
||||
|
||||
export const downloadImage = async (url: string, filename: string): Promise<void> => {
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
const blob = await response.blob();
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
|
||||
const link = document.createElement('a');
|
||||
link.href = objectUrl;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
} catch (error) {
|
||||
console.error('下载图片失败:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
Loading…
Reference in New Issue