parent
e25731cd12
commit
9b7ecd9884
|
@ -18,6 +18,7 @@
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-bootstrap": "^2.10.8",
|
"react-bootstrap": "^2.10.8",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
|
"react-icons": "^5.4.0",
|
||||||
"react-router-dom": "^7.1.3",
|
"react-router-dom": "^7.1.3",
|
||||||
"use-immer": "^0.11.0",
|
"use-immer": "^0.11.0",
|
||||||
"uuid": "^11.0.5",
|
"uuid": "^11.0.5",
|
||||||
|
@ -3939,6 +3940,14 @@
|
||||||
"react": "^18.3.1"
|
"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": {
|
"node_modules/react-is": {
|
||||||
"version": "16.13.1",
|
"version": "16.13.1",
|
||||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||||
|
|
|
@ -20,6 +20,7 @@
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-bootstrap": "^2.10.8",
|
"react-bootstrap": "^2.10.8",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
|
"react-icons": "^5.4.0",
|
||||||
"react-router-dom": "^7.1.3",
|
"react-router-dom": "^7.1.3",
|
||||||
"use-immer": "^0.11.0",
|
"use-immer": "^0.11.0",
|
||||||
"uuid": "^11.0.5",
|
"uuid": "^11.0.5",
|
||||||
|
|
|
@ -22,8 +22,8 @@ function DragTool(props: ToolHandlerProps) {
|
||||||
updateState(draft => {
|
updateState(draft => {
|
||||||
draft.isDragging = true;
|
draft.isDragging = true;
|
||||||
draft.dragStart = {
|
draft.dragStart = {
|
||||||
x: e.clientX - draft.position.x,
|
x: e.clientX - draft.imagePosition.x,
|
||||||
y: e.clientY - draft.position.y,
|
y: e.clientY - draft.imagePosition.y,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -36,7 +36,7 @@ function DragTool(props: ToolHandlerProps) {
|
||||||
console.log('DragTool: handleMouseMove', e);
|
console.log('DragTool: handleMouseMove', e);
|
||||||
|
|
||||||
updateState(draft => {
|
updateState(draft => {
|
||||||
draft.position = {
|
draft.imagePosition = {
|
||||||
x: e.clientX - draft.dragStart.x,
|
x: e.clientX - draft.dragStart.x,
|
||||||
y: e.clientY - draft.dragStart.y,
|
y: e.clientY - draft.dragStart.y,
|
||||||
};
|
};
|
||||||
|
@ -58,10 +58,16 @@ function DragTool(props: ToolHandlerProps) {
|
||||||
// 处理矩形变换
|
// 处理矩形变换
|
||||||
const handleRectTransform = useLatestCallback((rectPoints: RectPoints, id: string) => {
|
const handleRectTransform = useLatestCallback((rectPoints: RectPoints, id: string) => {
|
||||||
console.log('DragTool: handleRectTransform');
|
console.log('DragTool: handleRectTransform');
|
||||||
|
const rect = Convertor.rectContainer2Image({
|
||||||
|
x1: rectPoints.x1,
|
||||||
|
y1: rectPoints.y1,
|
||||||
|
x2: rectPoints.x2,
|
||||||
|
y2: rectPoints.y2,
|
||||||
|
});
|
||||||
updateAnnotation('rect', {
|
updateAnnotation('rect', {
|
||||||
id: id,
|
id: id,
|
||||||
type: 'rect',
|
type: 'rect',
|
||||||
data: rectPoints,
|
data: rect,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -97,6 +103,9 @@ function DragTool(props: ToolHandlerProps) {
|
||||||
e.stopPropagation(); // 阻止事件冒泡到容器
|
e.stopPropagation(); // 阻止事件冒泡到容器
|
||||||
console.log('DragTool: handleRectClick', 'selectedRectId=', selectedRectId);
|
console.log('DragTool: handleRectClick', 'selectedRectId=', selectedRectId);
|
||||||
setSelectedRectId(id);
|
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);
|
console.log('DragTool: handleContainerClick', 'selectedRectId=', selectedRectId, e);
|
||||||
setSelectedRectId(null);
|
setSelectedRectId(null);
|
||||||
setHoveredRectId(null);
|
setHoveredRectId(null);
|
||||||
|
editorProps.onAnnotationSelected?.(null);
|
||||||
};
|
};
|
||||||
// 点击容器中的非矩形区域,取消选中当前矩形
|
// 点击容器中的非矩形区域,取消选中当前矩形
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
@ -133,6 +143,8 @@ function DragTool(props: ToolHandlerProps) {
|
||||||
const shouldShowMask = () => {
|
const shouldShowMask = () => {
|
||||||
if (!editorProps.enableMask)
|
if (!editorProps.enableMask)
|
||||||
return false;
|
return false;
|
||||||
|
if (editorProps.annotations.length === 0)
|
||||||
|
return false;
|
||||||
if (selectedRectId === null && hoveredRectId === null)
|
if (selectedRectId === null && hoveredRectId === null)
|
||||||
return true;
|
return true;
|
||||||
if (selectedRectId === hoveredRectId)
|
if (selectedRectId === hoveredRectId)
|
||||||
|
@ -157,32 +169,56 @@ function DragTool(props: ToolHandlerProps) {
|
||||||
onNativeMouseLeave={handleRectMouseLeave}
|
onNativeMouseLeave={handleRectMouseLeave}
|
||||||
onNativeClick={(e) => handleRectClick(anno.id, e)}
|
onNativeClick={(e) => handleRectClick(anno.id, e)}
|
||||||
onTransform={(points) => handleRectTransform(points, anno.id)}
|
onTransform={(points) => handleRectTransform(points, anno.id)}
|
||||||
rectTip="rect"
|
rectTip={anno.tip}
|
||||||
showRectTip={hoveredRectId === anno.id && selectedRectId === null}
|
showRectTip={hoveredRectId === anno.id && selectedRectId === null}
|
||||||
/>
|
/>
|
||||||
));
|
));
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log('DragTool: render', editorProps.annotations);
|
let rectMask;
|
||||||
|
if (hoveredRectId !== null) {
|
||||||
|
// 当有矩形被悬停时,只显示该矩形的遮罩
|
||||||
|
const rect = queryAnnotation(hoveredRectId);
|
||||||
|
if (rect) {
|
||||||
|
rectMask = (
|
||||||
|
<RectMask
|
||||||
|
rects={[{...rect.data}]}
|
||||||
|
alpha={shouldShowMask() ? 0.7 : 0}
|
||||||
|
transition={true}
|
||||||
|
scale={state.imageScale}
|
||||||
|
transform={{
|
||||||
|
x: state.imagePosition.x,
|
||||||
|
y: state.imagePosition.y,
|
||||||
|
width: editorProps.imageSize?.width || 0,
|
||||||
|
height: editorProps.imageSize?.height || 0
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 默认显示所有矩形的遮罩
|
||||||
|
rectMask = (
|
||||||
|
<RectMask
|
||||||
|
rects={editorProps.annotations?.map(anno => ({
|
||||||
|
// 获取图像坐标
|
||||||
|
...anno.data
|
||||||
|
})) || []}
|
||||||
|
alpha={shouldShowMask() ? editorProps.maskAlpha : 0}
|
||||||
|
transition={true}
|
||||||
|
scale={state.imageScale}
|
||||||
|
transform={{
|
||||||
|
x: state.imagePosition.x,
|
||||||
|
y: state.imagePosition.y,
|
||||||
|
width: editorProps.imageSize?.width || 0,
|
||||||
|
height: editorProps.imageSize?.height || 0
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{
|
{rectMask}
|
||||||
hoveredRectId !== null ? (
|
|
||||||
// 当有矩形被悬停时,只显示该矩形的遮罩
|
|
||||||
<RectMask
|
|
||||||
rects={[Convertor.rectImage2Container(queryAnnotation(hoveredRectId)?.data)]}
|
|
||||||
alpha={shouldShowMask() ? 0.7 : 0}
|
|
||||||
transition={true}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
// 默认显示所有矩形的遮罩
|
|
||||||
<RectMask
|
|
||||||
rects={editorProps.annotations?.map(anno => Convertor.rectImage2Container(anno.data)) || []}
|
|
||||||
alpha={shouldShowMask() ? editorProps.maskAlpha : 0}
|
|
||||||
transition={true}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
{renderAnnotationWithHover(editorProps.annotations)}
|
{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 styled from '@emotion/styled';
|
||||||
import { Updater, useImmer } from 'use-immer';
|
import { Updater, useImmer } from 'use-immer';
|
||||||
import RectTool from './RectTool';
|
import RectTool from './RectTool';
|
||||||
import DragTool from './DragTool';
|
import DragTool from './DragTool';
|
||||||
import { Tool, Point, RectPoints, Annotation, AnnotationType } from './types';
|
import { Tool, Point, RectPoints, Annotation, AnnotationType, Optional } from './types';
|
||||||
import RectBox from './RectBox';
|
import RectBox, { RectBoxProps } from './RectBox';
|
||||||
import NativeDiv from '../NativeDiv';
|
import NativeDiv from '../NativeDiv';
|
||||||
|
import useLatestCallback from '../../hooks/useLatestCallback';
|
||||||
|
|
||||||
const EditorContainer = styled(NativeDiv)<{ isDragging: boolean }>`
|
const EditorContainer = styled(NativeDiv)<{ isDragging: boolean }>`
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
@ -61,8 +62,21 @@ export interface ImageEditorProps {
|
||||||
showCrosshair?: boolean;
|
showCrosshair?: boolean;
|
||||||
annotations: Annotation[];
|
annotations: Annotation[];
|
||||||
onAnnotationChanged?: (e: AnnotationChangedEvent) => void;
|
onAnnotationChanged?: (e: AnnotationChangedEvent) => void;
|
||||||
|
onAnnotationSelected?: (annotation: Annotation | null) => void;
|
||||||
enableMask?: boolean;
|
enableMask?: boolean;
|
||||||
maskAlpha?: number;
|
maskAlpha?: number;
|
||||||
|
imageSize?: {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* 编辑器的缩放模式。默认为 `wheel`。
|
||||||
|
*
|
||||||
|
* * `wheel` - 使用滚轮缩放,Ctrl + 滚轮上下移动,Shift + 滚轮左右移动
|
||||||
|
* * `ctrlWheel` - 使用 Ctrl + 滚轮缩放,滚轮上下移动,Shift + 滚轮左右移动
|
||||||
|
*/
|
||||||
|
scaleMode?: 'wheel' | 'ctrlWheel'
|
||||||
|
onNativeKeyDown?: (e: KeyboardEvent) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ImageEditorRef {
|
export interface ImageEditorRef {
|
||||||
|
@ -72,8 +86,12 @@ export interface ImageEditorRef {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EditorState {
|
export interface EditorState {
|
||||||
scale: number;
|
/** 图片的缩放比例 */
|
||||||
position: Point;
|
imageScale: number;
|
||||||
|
/** 图片相对于容器的偏移量/坐标 */
|
||||||
|
imagePosition: Point;
|
||||||
|
/** 图片相对于视口的偏移量/坐标 */
|
||||||
|
imageClientPosition: Point;
|
||||||
isDragging: boolean;
|
isDragging: boolean;
|
||||||
dragStart: Point;
|
dragStart: Point;
|
||||||
mousePosition: Point;
|
mousePosition: Point;
|
||||||
|
@ -90,10 +108,10 @@ export interface ToolHandlerProps {
|
||||||
editorState: [EditorState, Updater<EditorState>];
|
editorState: [EditorState, Updater<EditorState>];
|
||||||
editorProps: ImageEditorProps;
|
editorProps: ImageEditorProps;
|
||||||
containerRef: React.RefObject<HTMLDivElement>;
|
containerRef: React.RefObject<HTMLDivElement>;
|
||||||
renderAnnotation: (annotations?: Annotation[]) => React.ReactNode;
|
renderAnnotation: (annotations?: Annotation[], props?: Optional<RectBoxProps>) => React.ReactNode;
|
||||||
addAnnotation: (annotation: Annotation) => void;
|
addAnnotation: (annotation: Annotation) => void;
|
||||||
updateAnnotation: (type: AnnotationType, annotation: Annotation) => void;
|
updateAnnotation: (type: AnnotationType, annotation: Annotation) => void;
|
||||||
queryAnnotation: (id: string) => Annotation;
|
queryAnnotation: (id: string) => Annotation | null | undefined;
|
||||||
Convertor: PostionConvertor;
|
Convertor: PostionConvertor;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -108,17 +126,121 @@ const ImageEditor = React.forwardRef<ImageEditorRef, ImageEditorProps>((props, r
|
||||||
annotations = [],
|
annotations = [],
|
||||||
} = props;
|
} = 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>({
|
const [state, updateState] = useImmer<EditorState>({
|
||||||
scale: initialScale,
|
imageScale: initialScale,
|
||||||
position: { x: 0, y: 0 },
|
imagePosition: { x: 0, y: 0 },
|
||||||
|
imageClientPosition: { x: 0, y: 0 },
|
||||||
isDragging: false,
|
isDragging: false,
|
||||||
dragStart: { x: 0, y: 0 },
|
dragStart: { x: 0, y: 0 },
|
||||||
mousePosition: { 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 容器坐标
|
* @param pos 容器坐标
|
||||||
|
@ -126,8 +248,8 @@ const ImageEditor = React.forwardRef<ImageEditorRef, ImageEditorProps>((props, r
|
||||||
*/
|
*/
|
||||||
posContainer2Image: (pos: Point) => {
|
posContainer2Image: (pos: Point) => {
|
||||||
return {
|
return {
|
||||||
x: Math.round((pos.x - state.position.x) / state.scale),
|
x: Math.round((pos.x - state.imagePosition.x) / state.imageScale),
|
||||||
y: Math.round((pos.y - state.position.y) / state.scale)
|
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) => {
|
posImage2Container: (pos: Point) => {
|
||||||
return {
|
return {
|
||||||
x: pos.x * state.scale + state.position.x,
|
x: pos.x * state.imageScale + state.imagePosition.x,
|
||||||
y: pos.y * state.scale + state.position.y
|
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
|
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;
|
if (!annotations) return null;
|
||||||
return annotations.map((rect) => (
|
return annotations.map((rect) => (
|
||||||
<RectBox
|
<RectBox
|
||||||
key={rect.id}
|
key={rect.id}
|
||||||
mode="resize"
|
|
||||||
rect={Convertor.rectImage2Container(rect.data)}
|
rect={Convertor.rectImage2Container(rect.data)}
|
||||||
|
rectTip={rect.tip}
|
||||||
|
{...props}
|
||||||
/>
|
/>
|
||||||
));
|
));
|
||||||
};
|
};
|
||||||
|
@ -203,14 +326,15 @@ const ImageEditor = React.forwardRef<ImageEditorRef, ImageEditorProps>((props, r
|
||||||
if (annotations) {
|
if (annotations) {
|
||||||
ret = annotations.find(annotation => annotation.id === id);
|
ret = annotations.find(annotation => annotation.id === id);
|
||||||
}
|
}
|
||||||
if (!ret)
|
|
||||||
throw new Error(`Annotation not found: ${id}`);
|
|
||||||
return ret;
|
return ret;
|
||||||
};
|
};
|
||||||
|
|
||||||
const toolHandlerProps: ToolHandlerProps = {
|
const toolHandlerProps: ToolHandlerProps = {
|
||||||
editorState: [state, updateState],
|
editorState: [state, updateState],
|
||||||
editorProps: props,
|
editorProps: {
|
||||||
|
...props,
|
||||||
|
imageSize
|
||||||
|
},
|
||||||
containerRef,
|
containerRef,
|
||||||
addAnnotation,
|
addAnnotation,
|
||||||
updateAnnotation,
|
updateAnnotation,
|
||||||
|
@ -220,55 +344,82 @@ const ImageEditor = React.forwardRef<ImageEditorRef, ImageEditorProps>((props, r
|
||||||
};
|
};
|
||||||
|
|
||||||
// 处理鼠标滚轮缩放
|
// 处理鼠标滚轮缩放
|
||||||
const handleWheel = (e: WheelEvent) => {
|
const handleWheel = useLatestCallback((e: WheelEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const delta = -e.deltaY;
|
const delta = -e.deltaY;
|
||||||
const scaleChange = delta > 0 ? 1.1 : 0.9;
|
const scaleMode = props.scaleMode || 'wheel';
|
||||||
updateState(draft => {
|
|
||||||
draft.scale = Math.max(0.1, Math.min(10, draft.scale * scaleChange));
|
// 判断缩放还是滚动
|
||||||
});
|
const shouldScale =
|
||||||
};
|
(scaleMode === 'wheel' && !e.ctrlKey && !e.shiftKey) ||
|
||||||
|
(scaleMode === 'ctrlWheel' && e.ctrlKey);
|
||||||
|
|
||||||
|
if (shouldScale) {
|
||||||
|
const scaleChange = delta > 0 ? 1.1 : 0.9;
|
||||||
|
|
||||||
|
// 获取鼠标相对于容器的坐标
|
||||||
|
const container = containerRef.current;
|
||||||
|
if (!container) return;
|
||||||
|
const rect = container.getBoundingClientRect();
|
||||||
|
const mouseX = e.clientX - rect.left;
|
||||||
|
const mouseY = e.clientY - rect.top;
|
||||||
|
|
||||||
|
// 计算鼠标位置在图片上的相对位置
|
||||||
|
const mouseImageX = (mouseX - state.imagePosition.x) / state.imageScale;
|
||||||
|
const mouseImageY = (mouseY - state.imagePosition.y) / state.imageScale;
|
||||||
|
|
||||||
|
updateState(draft => {
|
||||||
|
draft.imageScale = Math.max(0.1, Math.min(10, draft.imageScale * scaleChange));
|
||||||
|
|
||||||
|
// 计算新的图片位置,保持鼠标指向的图片位置不变
|
||||||
|
draft.imagePosition.x = mouseX - mouseImageX * draft.imageScale;
|
||||||
|
draft.imagePosition.y = mouseY - mouseImageY * draft.imageScale;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const moveX = e.shiftKey ? delta : 0;
|
||||||
|
const moveY = !e.shiftKey ? delta : 0;
|
||||||
|
|
||||||
|
updateState(draft => {
|
||||||
|
draft.imagePosition.x += moveX;
|
||||||
|
draft.imagePosition.y += moveY;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// 暴露组件方法
|
// 暴露组件方法
|
||||||
React.useImperativeHandle(ref, () => ({
|
React.useImperativeHandle(ref, () => ({
|
||||||
reset: () => {
|
reset: () => {
|
||||||
updateState(draft => {
|
updateState(draft => {
|
||||||
draft.scale = initialScale;
|
draft.imageScale = initialScale;
|
||||||
draft.position = { x: 0, y: 0 };
|
draft.imagePosition = { x: 0, y: 0 };
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
setScale: (newScale: number) => {
|
setScale: (newScale: number) => {
|
||||||
updateState(draft => {
|
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 (
|
return (
|
||||||
<EditorContainer
|
<EditorContainer
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
isDragging={state.isDragging}
|
isDragging={state.isDragging}
|
||||||
style={{ cursor: tool === Tool.Rect ? 'crosshair' : undefined }}
|
style={{ cursor: tool === Tool.Rect ? 'crosshair' : undefined }}
|
||||||
|
onNativeKeyDown={props.onNativeKeyDown}
|
||||||
|
onNativeMouseWheel={handleWheel}
|
||||||
>
|
>
|
||||||
<EditorImage
|
{imageReady && (
|
||||||
src={image}
|
<EditorImage
|
||||||
scale={state.scale}
|
ref={imageRef}
|
||||||
x={state.position.x}
|
src={image}
|
||||||
y={state.position.y}
|
scale={state.imageScale}
|
||||||
draggable={false}
|
x={state.imagePosition.x}
|
||||||
/>
|
y={state.imagePosition.y}
|
||||||
|
onLoad={handleImageLoad}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{showCrosshair && tool === Tool.Rect && (
|
{showCrosshair && tool === Tool.Rect && (
|
||||||
<>
|
<>
|
||||||
|
|
|
@ -64,7 +64,7 @@ const RectTipContainer = styled(NativeDiv)`
|
||||||
transform: translateY(-100%);
|
transform: translateY(-100%);
|
||||||
`;
|
`;
|
||||||
|
|
||||||
interface ObjectRectProps {
|
export interface RectBoxProps {
|
||||||
rect: RectPoints;
|
rect: RectPoints;
|
||||||
/**
|
/**
|
||||||
* 矩形模式。默认为 resize。
|
* 矩形模式。默认为 resize。
|
||||||
|
@ -86,6 +86,13 @@ interface ObjectRectProps {
|
||||||
* 是否显示提示内容(`rectTip`)
|
* 是否显示提示内容(`rectTip`)
|
||||||
*/
|
*/
|
||||||
showRectTip?: boolean;
|
showRectTip?: boolean;
|
||||||
|
/**
|
||||||
|
* 矩形框变换事件。
|
||||||
|
*
|
||||||
|
* 变换后的坐标是在原坐标的基础上加上拖拽时鼠标的位移差得到的。
|
||||||
|
* 也就是说,原来传入的是什么坐标系的坐标,变换后的坐标也是什么坐标系的坐标。
|
||||||
|
* @param points 变换后的矩形框
|
||||||
|
*/
|
||||||
onTransform?: (points: RectPoints) => void;
|
onTransform?: (points: RectPoints) => void;
|
||||||
onNativeMouseEnter?: (e: MouseEvent) => void;
|
onNativeMouseEnter?: (e: MouseEvent) => void;
|
||||||
onNativeMouseMove?: (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 {
|
const {
|
||||||
rect,
|
rect,
|
||||||
mode = 'resize',
|
mode = 'resize',
|
||||||
|
|
|
@ -1,24 +1,30 @@
|
||||||
import { RectPoints } from "./types";
|
import { RectPoints } from "./types";
|
||||||
import styled from '@emotion/styled';
|
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;
|
position: absolute;
|
||||||
top: 0;
|
transform: translate(${props => props.transform.x}px, ${props => props.transform.y}px) scale(${props => props.scale});
|
||||||
left: 0;
|
transform-origin: left top;
|
||||||
width: 100%;
|
pointer-events: none;
|
||||||
height: 100%;
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
interface RectMaskProps {
|
interface RectMaskProps {
|
||||||
rects: RectPoints[];
|
rects: RectPoints[];
|
||||||
alpha?: number;
|
alpha?: number;
|
||||||
transition?: boolean;
|
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 (
|
return (
|
||||||
<MaskContainer>
|
<MaskContainer scale={scale} transform={transform}>
|
||||||
<svg width="100%" height="100%" style={{ position: 'absolute' }}>
|
<svg width={transform.width} height={transform.height} style={{ position: 'absolute' }}>
|
||||||
<defs>
|
<defs>
|
||||||
<mask id="rectMask">
|
<mask id="rectMask">
|
||||||
{/* 首先创建一个黑色背景(完全遮罩) */}
|
{/* 首先创建一个黑色背景(完全遮罩) */}
|
||||||
|
@ -33,7 +39,6 @@ function RectMask({ rects, alpha = 0.5, transition = false }: RectMaskProps) {
|
||||||
width={rect.x2 - rect.x1}
|
width={rect.x2 - rect.x1}
|
||||||
height={rect.y2 - rect.y1}
|
height={rect.y2 - rect.y1}
|
||||||
fill="black"
|
fill="black"
|
||||||
// style={transition ? { transition: 'all 0.1s ease-in-out' } : undefined}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</mask>
|
</mask>
|
||||||
|
|
|
@ -1,15 +1,17 @@
|
||||||
import { ToolHandlerProps } from "./ImageEditor";
|
import { ToolHandlerProps } from "./ImageEditor";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState, useRef } from "react";
|
||||||
import RectBox from "./RectBox";
|
import RectBox from "./RectBox";
|
||||||
import { Point } from "./types";
|
import { Annotation, Point } from "./types";
|
||||||
import useLatestCallback from "../../hooks/useLatestCallback";
|
import useLatestCallback from "../../hooks/useLatestCallback";
|
||||||
import { v4 } from "uuid";
|
import { v4 } from "uuid";
|
||||||
|
|
||||||
|
|
||||||
function RectTool(props: ToolHandlerProps) {
|
function RectTool(props: ToolHandlerProps) {
|
||||||
const { containerRef, addAnnotation, renderAnnotation, Convertor, editorProps } = props;
|
const { containerRef, addAnnotation, renderAnnotation, Convertor, editorProps } = props;
|
||||||
|
// 下面两个都为容器坐标
|
||||||
const [rectStart, setRectStart] = useState<Point | null>(null);
|
const [rectStart, setRectStart] = useState<Point | null>(null);
|
||||||
const [rectEnd, setRectEnd] = useState<Point | null>(null);
|
const [rectEnd, setRectEnd] = useState<Point | null>(null);
|
||||||
|
const drawingRef = useRef(false);
|
||||||
|
|
||||||
// 处理拖拽开始
|
// 处理拖拽开始
|
||||||
const handleMouseDown = useLatestCallback((e: MouseEvent) => {
|
const handleMouseDown = useLatestCallback((e: MouseEvent) => {
|
||||||
|
@ -18,9 +20,11 @@ function RectTool(props: ToolHandlerProps) {
|
||||||
if (rect) {
|
if (rect) {
|
||||||
const x = e.clientX - rect.left;
|
const x = e.clientX - rect.left;
|
||||||
const y = e.clientY - rect.top;
|
const y = e.clientY - rect.top;
|
||||||
|
// const imagePos = Convertor.posContainer2Image({ x, y });
|
||||||
setRectStart({ x, y });
|
setRectStart({ x, y });
|
||||||
setRectEnd({ x, y });
|
setRectEnd({ x, y });
|
||||||
}
|
}
|
||||||
|
drawingRef.current = true;
|
||||||
console.log('RectTool: handleMouseDown');
|
console.log('RectTool: handleMouseDown');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -41,17 +45,25 @@ function RectTool(props: ToolHandlerProps) {
|
||||||
let y1 = Math.min(rectStart.y, rectEnd.y);
|
let y1 = Math.min(rectStart.y, rectEnd.y);
|
||||||
let x2 = Math.max(rectStart.x, rectEnd.x);
|
let x2 = Math.max(rectStart.x, rectEnd.x);
|
||||||
let y2 = Math.max(rectStart.y, rectEnd.y);
|
let y2 = Math.max(rectStart.y, rectEnd.y);
|
||||||
let newRect = { x1, y1, x2, y2 };
|
if (Math.abs(x1 - x2) < 10 || Math.abs(y1 - y2) < 10) {
|
||||||
// 转换到图片坐标
|
console.log('RectTool: rect too small. skip add annotation');
|
||||||
newRect = Convertor.rectContainer2Image(newRect);
|
}
|
||||||
|
else {
|
||||||
addAnnotation({
|
let newRect = { x1, y1, x2, y2 };
|
||||||
id: v4(),
|
|
||||||
type: 'rect',
|
newRect = Convertor.rectContainer2Image(newRect);
|
||||||
data: newRect
|
|
||||||
});
|
const annotation: Annotation = {
|
||||||
|
id: v4(),
|
||||||
|
type: 'rect',
|
||||||
|
data: newRect
|
||||||
|
};
|
||||||
|
addAnnotation(annotation);
|
||||||
|
editorProps.onAnnotationSelected?.(annotation);
|
||||||
|
}
|
||||||
setRectStart(null);
|
setRectStart(null);
|
||||||
setRectEnd(null);
|
setRectEnd(null);
|
||||||
|
drawingRef.current = false;
|
||||||
console.log('RectTool: handleMouseUp');
|
console.log('RectTool: handleMouseUp');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -75,7 +87,10 @@ function RectTool(props: ToolHandlerProps) {
|
||||||
{rectStart && rectEnd && (
|
{rectStart && rectEnd && (
|
||||||
<RectBox rect={{x1: rectStart.x, y1: rectStart.y, x2: rectEnd.x, y2: rectEnd.y}} />
|
<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,
|
rect: RectPoints,
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface Annotation{
|
export interface Annotation {
|
||||||
id: string;
|
id: string;
|
||||||
type: AnnotationType;
|
type: AnnotationType;
|
||||||
data: AnnotationTypeMap[AnnotationType];
|
data: AnnotationTypeMap[AnnotationType];
|
||||||
|
/** 提示信息。 */
|
||||||
|
tip?: React.ReactNode;
|
||||||
}
|
}
|
||||||
export interface Point {
|
export interface Point {
|
||||||
x: number;
|
x: number;
|
||||||
|
@ -25,4 +27,11 @@ export interface RectPoints {
|
||||||
y1: number;
|
y1: number;
|
||||||
x2: number;
|
x2: number;
|
||||||
y2: 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 {
|
interface ImageViewerProps {
|
||||||
/** 图片地址 */
|
/** 图片地址 */
|
||||||
image: string;
|
image: string;
|
||||||
|
/** 图片渲染模式 */
|
||||||
|
imageRendering?: 'pixelated' | 'auto';
|
||||||
/** 是否可缩放 */
|
/** 是否可缩放 */
|
||||||
zoomable?: boolean;
|
zoomable?: boolean;
|
||||||
/** 是否可移动 */
|
/** 是否可移动 */
|
||||||
|
@ -47,6 +49,7 @@ const ImageContainer = styled.div`
|
||||||
|
|
||||||
interface StyledImageProps {
|
interface StyledImageProps {
|
||||||
withAnimation?: boolean;
|
withAnimation?: boolean;
|
||||||
|
imageRendering?: 'pixelated' | 'auto';
|
||||||
}
|
}
|
||||||
|
|
||||||
const StyledImage = styled.img<StyledImageProps>`
|
const StyledImage = styled.img<StyledImageProps>`
|
||||||
|
@ -57,6 +60,7 @@ const StyledImage = styled.img<StyledImageProps>`
|
||||||
user-select: none;
|
user-select: none;
|
||||||
position: relative;
|
position: relative;
|
||||||
transition: ${(props: StyledImageProps) => props.withAnimation ? 'transform 0.2s ease-out' : 'none'};
|
transition: ${(props: StyledImageProps) => props.withAnimation ? 'transform 0.2s ease-out' : 'none'};
|
||||||
|
image-rendering: ${props => props.imageRendering};
|
||||||
|
|
||||||
&.dragging {
|
&.dragging {
|
||||||
cursor: grabbing;
|
cursor: grabbing;
|
||||||
|
@ -67,6 +71,7 @@ const StyledImage = styled.img<StyledImageProps>`
|
||||||
const ImageViewer = forwardRef<ImageViewerRef, ImageViewerProps>(
|
const ImageViewer = forwardRef<ImageViewerRef, ImageViewerProps>(
|
||||||
({
|
({
|
||||||
image,
|
image,
|
||||||
|
imageRendering = 'auto',
|
||||||
zoomable: scalable = true,
|
zoomable: scalable = true,
|
||||||
movable = true,
|
movable = true,
|
||||||
minZoomScale: minScale = 0.1,
|
minZoomScale: minScale = 0.1,
|
||||||
|
@ -214,6 +219,7 @@ const ImageViewer = forwardRef<ImageViewerRef, ImageViewerProps>(
|
||||||
onDragStart={(e: React.DragEvent<HTMLImageElement>) => e.preventDefault()}
|
onDragStart={(e: React.DragEvent<HTMLImageElement>) => e.preventDefault()}
|
||||||
className={isDragging ? 'dragging' : ''}
|
className={isDragging ? 'dragging' : ''}
|
||||||
withAnimation={useAnimation}
|
withAnimation={useAnimation}
|
||||||
|
imageRendering={imageRendering}
|
||||||
style={{
|
style={{
|
||||||
transform: `translate(${translateX}px, ${translateY}px) scale(${scale})`
|
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;
|
onNativeMouseLeave?: (e: MouseEvent) => void;
|
||||||
onNativeMouseDown?: (e: MouseEvent) => void;
|
onNativeMouseDown?: (e: MouseEvent) => void;
|
||||||
onNativeClick?: (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
|
* * onNativeMouseLeave
|
||||||
* * onNativeMouseDown
|
* * onNativeMouseDown
|
||||||
* * onNativeClick
|
* * onNativeClick
|
||||||
|
* * onNativeKeyDown
|
||||||
|
* * onNativeKeyUp
|
||||||
|
* * onNativeMouseWheel
|
||||||
*
|
*
|
||||||
* 这些事件都是对原生 DOM 事件的封装。
|
* 这些事件都是对原生 DOM 事件的封装。
|
||||||
*/
|
*/
|
||||||
|
@ -27,6 +33,9 @@ const NativeDiv = forwardRef<HTMLDivElement, NativeDivProps>((props, ref) => {
|
||||||
onNativeMouseLeave,
|
onNativeMouseLeave,
|
||||||
onNativeMouseDown,
|
onNativeMouseDown,
|
||||||
onNativeClick,
|
onNativeClick,
|
||||||
|
onNativeKeyDown,
|
||||||
|
onNativeKeyUp,
|
||||||
|
onNativeMouseWheel,
|
||||||
...restProps
|
...restProps
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
|
@ -53,6 +62,12 @@ const NativeDiv = forwardRef<HTMLDivElement, NativeDivProps>((props, ref) => {
|
||||||
element.addEventListener('mousedown', onNativeMouseDown);
|
element.addEventListener('mousedown', onNativeMouseDown);
|
||||||
if (onNativeClick)
|
if (onNativeClick)
|
||||||
element.addEventListener('click', onNativeClick);
|
element.addEventListener('click', onNativeClick);
|
||||||
|
if (onNativeKeyDown)
|
||||||
|
element.addEventListener('keydown', onNativeKeyDown);
|
||||||
|
if (onNativeKeyUp)
|
||||||
|
element.addEventListener('keyup', onNativeKeyUp);
|
||||||
|
if (onNativeMouseWheel)
|
||||||
|
element.addEventListener('wheel', onNativeMouseWheel);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
if (onNativeMouseEnter)
|
if (onNativeMouseEnter)
|
||||||
|
@ -65,8 +80,14 @@ const NativeDiv = forwardRef<HTMLDivElement, NativeDivProps>((props, ref) => {
|
||||||
element.removeEventListener('mousedown', onNativeMouseDown);
|
element.removeEventListener('mousedown', onNativeMouseDown);
|
||||||
if (onNativeClick)
|
if (onNativeClick)
|
||||||
element.removeEventListener('click', 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} />;
|
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 { useMessageBox } from '../hooks/useMessageBox';
|
||||||
import { useFullscreenSpinner } from '../hooks/useFullscreenSpinner';
|
import { useFullscreenSpinner } from '../hooks/useFullscreenSpinner';
|
||||||
import ImageEditor, { ImageEditorRef } from '../components/ImageEditor/ImageEditor';
|
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 RectBox from '../components/ImageEditor/RectBox';
|
||||||
import RectMask from '../components/ImageEditor/RectMask';
|
import RectMask from '../components/ImageEditor/RectMask';
|
||||||
import FormRange from 'react-bootstrap/esm/FormRange';
|
import FormRange from 'react-bootstrap/esm/FormRange';
|
||||||
import NativeDiv from '../components/NativeDiv';
|
import NativeDiv from '../components/NativeDiv';
|
||||||
import { AnnotationChangedEvent } from '../components/ImageEditor/ImageEditor';
|
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`
|
const DemoContainer = styled.div`
|
||||||
display: flex;
|
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 {
|
function SingleImageViewerDemo(): JSX.Element {
|
||||||
const viewerRef = useRef<ImageViewerRef>(null);
|
const viewerRef = useRef<ImageViewerRef>(null);
|
||||||
|
@ -370,10 +432,11 @@ function MultipleImagesViewerDemo(): JSX.Element {
|
||||||
function ImageEditorDemo(): JSX.Element {
|
function ImageEditorDemo(): JSX.Element {
|
||||||
const editorRef = useRef<ImageEditorRef>(null);
|
const editorRef = useRef<ImageEditorRef>(null);
|
||||||
const [showCrosshair, setShowCrosshair] = useState(false);
|
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 [annotations, setAnnotations] = useState<Annotation[]>([]);
|
||||||
const [showMask, setShowMask] = useState(true);
|
const [showMask, setShowMask] = useState(true);
|
||||||
const [maskAlpha, setMaskAlpha] = useState(0.5);
|
const [maskAlpha, setMaskAlpha] = useState(0.5);
|
||||||
|
const [scaleMode, setScaleMode] = useState<'wheel' | 'ctrlWheel'>('wheel');
|
||||||
const demoImage = 'https://picsum.photos/800/600';
|
const demoImage = 'https://picsum.photos/800/600';
|
||||||
|
|
||||||
const handleEditorReset = () => {
|
const handleEditorReset = () => {
|
||||||
|
@ -438,6 +501,7 @@ function ImageEditorDemo(): JSX.Element {
|
||||||
onAnnotationChanged={handleAnnotationChanged}
|
onAnnotationChanged={handleAnnotationChanged}
|
||||||
enableMask={showMask}
|
enableMask={showMask}
|
||||||
maskAlpha={maskAlpha}
|
maskAlpha={maskAlpha}
|
||||||
|
scaleMode={scaleMode}
|
||||||
/>
|
/>
|
||||||
</ViewerContainer>
|
</ViewerContainer>
|
||||||
<ControlPanel>
|
<ControlPanel>
|
||||||
|
@ -447,13 +511,16 @@ function ImageEditorDemo(): JSX.Element {
|
||||||
<Button onClick={() => setShowCrosshair(!showCrosshair)}>
|
<Button onClick={() => setShowCrosshair(!showCrosshair)}>
|
||||||
{showCrosshair ? '隐藏准线' : '显示准线'}
|
{showCrosshair ? '隐藏准线' : '显示准线'}
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={() => setCurrentTool(currentTool === Tool.Drag ? Tool.Rect : Tool.Drag)}>
|
<Button onClick={() => setCurrentTool(currentTool === EditorTool.Drag ? EditorTool.Rect : EditorTool.Drag)}>
|
||||||
{currentTool === Tool.Drag ? '切换到矩形工具' : '切换到拖动工具'}
|
{currentTool === EditorTool.Drag ? '切换到矩形工具' : '切换到拖动工具'}
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleClearAnnotations}>清除标注</Button>
|
<Button onClick={handleClearAnnotations}>清除标注</Button>
|
||||||
<Button onClick={() => setShowMask(!showMask)}>
|
<Button onClick={() => setShowMask(!showMask)}>
|
||||||
{showMask ? '隐藏遮罩' : '显示遮罩'}
|
{showMask ? '隐藏遮罩' : '显示遮罩'}
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button onClick={() => setScaleMode(mode => mode === 'wheel' ? 'ctrlWheel' : 'wheel')}>
|
||||||
|
{scaleMode === 'wheel' ? '切换到Ctrl+滚轮缩放' : '切换到滚轮缩放'}
|
||||||
|
</Button>
|
||||||
<FormRange
|
<FormRange
|
||||||
style={{ flex: '0 1 200px' }}
|
style={{ flex: '0 1 200px' }}
|
||||||
value={maskAlpha}
|
value={maskAlpha}
|
||||||
|
@ -500,6 +567,11 @@ function ImageEditorDemo(): JSX.Element {
|
||||||
<li>点击"清除标注"可以删除所有已绘制的矩形</li>
|
<li>点击"清除标注"可以删除所有已绘制的矩形</li>
|
||||||
<li>点击"显示遮罩"可以显示/隐藏非标注区域的遮罩</li>
|
<li>点击"显示遮罩"可以显示/隐藏非标注区域的遮罩</li>
|
||||||
<li>使用滑块可以调整遮罩的透明度</li>
|
<li>使用滑块可以调整遮罩的透明度</li>
|
||||||
|
<li>点击"切换到Ctrl+滚轮缩放"可以切换缩放模式:</li>
|
||||||
|
<ul>
|
||||||
|
<li>滚轮缩放模式:直接使用滚轮缩放,Ctrl+滚轮上下移动,Shift+滚轮左右移动</li>
|
||||||
|
<li>Ctrl+滚轮缩放模式:使用Ctrl+滚轮缩放,直接滚轮上下移动,Shift+滚轮左右移动</li>
|
||||||
|
</ul>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -678,7 +750,13 @@ function RectMaskDemo(): JSX.Element {
|
||||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||||
alt="Demo"
|
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>
|
</RectBoxContainer>
|
||||||
<div>
|
<div>
|
||||||
<h3>使用说明:</h3>
|
<h3>使用说明:</h3>
|
||||||
|
@ -698,35 +776,64 @@ function RectMaskDemo(): JSX.Element {
|
||||||
|
|
||||||
// NativeDiv 演示组件
|
// NativeDiv 演示组件
|
||||||
function NativeDivDemo(): JSX.Element {
|
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 = {
|
const newEvent = {
|
||||||
type,
|
type,
|
||||||
time: new Date().toLocaleTimeString(),
|
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) => {
|
const handleNativeMouseEnter = (e: MouseEvent) => {
|
||||||
addEvent('mouseenter', e);
|
addEvent('mouseenter', `位置:(${e.clientX}, ${e.clientY})`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleNativeMouseMove = (e: MouseEvent) => {
|
const handleNativeMouseMove = (e: MouseEvent) => {
|
||||||
addEvent('mousemove', e);
|
addEvent('mousemove', `位置:(${e.clientX}, ${e.clientY})`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleNativeMouseLeave = (e: MouseEvent) => {
|
const handleNativeMouseLeave = (e: MouseEvent) => {
|
||||||
addEvent('mouseleave', e);
|
addEvent('mouseleave', `位置:(${e.clientX}, ${e.clientY})`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleNativeMouseDown = (e: MouseEvent) => {
|
const handleNativeMouseDown = (e: MouseEvent) => {
|
||||||
addEvent('mousedown', e);
|
addEvent('mousedown', `位置:(${e.clientX}, ${e.clientY})`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleNativeClick = (e: MouseEvent) => {
|
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 (
|
return (
|
||||||
|
@ -734,21 +841,59 @@ function NativeDivDemo(): JSX.Element {
|
||||||
<h2>原生 Div 事件演示</h2>
|
<h2>原生 Div 事件演示</h2>
|
||||||
<NativeDivPlayground>
|
<NativeDivPlayground>
|
||||||
<DemoNativeDiv
|
<DemoNativeDiv
|
||||||
|
tabIndex={0}
|
||||||
onNativeMouseEnter={handleNativeMouseEnter}
|
onNativeMouseEnter={handleNativeMouseEnter}
|
||||||
onNativeMouseMove={handleNativeMouseMove}
|
onNativeMouseMove={handleNativeMouseMove}
|
||||||
onNativeMouseLeave={handleNativeMouseLeave}
|
onNativeMouseLeave={handleNativeMouseLeave}
|
||||||
onNativeClick={handleNativeClick}
|
onNativeClick={handleNativeClick}
|
||||||
onNativeMouseDown={handleNativeMouseDown}
|
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>
|
</DemoNativeDiv>
|
||||||
</NativeDivPlayground>
|
</NativeDivPlayground>
|
||||||
<div style={{ marginTop: '20px' }}>
|
<div style={{ marginTop: '20px' }}>
|
||||||
<h3>最近的事件:</h3>
|
<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) => (
|
{events.map((event, index) => (
|
||||||
<li key={index}>
|
<li key={index} style={{
|
||||||
{event.type} - 时间:{event.time} - 位置:{event.position}
|
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>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
@ -761,13 +906,384 @@ function NativeDivDemo(): JSX.Element {
|
||||||
<li>在方块内移动鼠标来触发 mousemove 事件</li>
|
<li>在方块内移动鼠标来触发 mousemove 事件</li>
|
||||||
<li>将鼠标移出方块来触发 mouseleave 事件</li>
|
<li>将鼠标移出方块来触发 mouseleave 事件</li>
|
||||||
<li>点击方块来触发 click 事件</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>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</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组件
|
// 主Demo组件
|
||||||
function Demo() {
|
function Demo() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
@ -777,40 +1293,47 @@ function Demo() {
|
||||||
const demos = [
|
const demos = [
|
||||||
{ id: 'messageBox', name: '消息框', component: MessageBoxDemo },
|
{ id: 'messageBox', name: '消息框', component: MessageBoxDemo },
|
||||||
{ id: 'spinner', name: '加载动画', component: SpinnerDemo },
|
{ id: 'spinner', name: '加载动画', component: SpinnerDemo },
|
||||||
|
{ id: 'toast', name: 'Toast 消息', component: ToastMessageDemo },
|
||||||
{ id: 'singleViewer', name: '单图片查看器', component: SingleImageViewerDemo },
|
{ id: 'singleViewer', name: '单图片查看器', component: SingleImageViewerDemo },
|
||||||
{ id: 'multipleViewer', name: '多图片查看器', component: MultipleImagesViewerDemo },
|
{ id: 'multipleViewer', name: '多图片查看器', component: MultipleImagesViewerDemo },
|
||||||
|
{ id: 'imageViewerModal', name: '图片查看器模态框', component: ImageViewerModalDemo },
|
||||||
{ id: 'rectBox', name: '矩形框', component: RectBoxDemo },
|
{ id: 'rectBox', name: '矩形框', component: RectBoxDemo },
|
||||||
{ id: 'rectMask', name: '遮罩层', component: RectMaskDemo },
|
{ id: 'rectMask', name: '遮罩层', component: RectMaskDemo },
|
||||||
{ id: 'imageEditor', name: '图片标注器', component: ImageEditorDemo },
|
{ 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 (
|
return (
|
||||||
<DemoContainer>
|
<>
|
||||||
<Sidebar>
|
<GlobalStyle />
|
||||||
{demos.map(demo => (
|
<DemoContainer>
|
||||||
<MenuItem
|
<Sidebar>
|
||||||
key={demo.id}
|
|
||||||
active={currentDemo === demo.id}
|
|
||||||
onClick={() => navigate(`/demo/${demo.id}`)}
|
|
||||||
>
|
|
||||||
{demo.name}
|
|
||||||
</MenuItem>
|
|
||||||
))}
|
|
||||||
</Sidebar>
|
|
||||||
<Content>
|
|
||||||
<Routes>
|
|
||||||
<Route path="/" element={<Navigate to="/demo/messageBox" replace />} />
|
|
||||||
{demos.map(demo => (
|
{demos.map(demo => (
|
||||||
<Route
|
<MenuItem
|
||||||
key={demo.id}
|
key={demo.id}
|
||||||
path={`${demo.id}`}
|
active={currentDemo === demo.id}
|
||||||
element={<demo.component />}
|
onClick={() => navigate(`/demo/${demo.id}`)}
|
||||||
/>
|
>
|
||||||
|
{demo.name}
|
||||||
|
</MenuItem>
|
||||||
))}
|
))}
|
||||||
</Routes>
|
</Sidebar>
|
||||||
</Content>
|
<Content>
|
||||||
</DemoContainer>
|
<Routes>
|
||||||
|
<Route path="/" element={<Navigate to="/demo/messageBox" replace />} />
|
||||||
|
{demos.map(demo => (
|
||||||
|
<Route
|
||||||
|
key={demo.id}
|
||||||
|
path={`${demo.id}`}
|
||||||
|
element={<demo.component />}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Routes>
|
||||||
|
</Content>
|
||||||
|
</DemoContainer>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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 { createBrowserRouter } from 'react-router-dom';
|
||||||
import { Home } from './pages/Home';
|
import { Home } from './pages/Home';
|
||||||
import Demo from './pages/Demo';
|
import Demo from './pages/Demo';
|
||||||
|
import ImageAnnotation from './pages/ImageAnnotation/ImageAnnotation';
|
||||||
|
|
||||||
export const router = createBrowserRouter([
|
export const router = createBrowserRouter([
|
||||||
{
|
{
|
||||||
|
@ -11,4 +12,8 @@ export const router = createBrowserRouter([
|
||||||
path: '/demo/*',
|
path: '/demo/*',
|
||||||
element: <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 {
|
||||||
return new Promise((resolve, reject) => {
|
accept?: string;
|
||||||
const reader = new FileReader();
|
multiple?: boolean;
|
||||||
reader.onload = (event) => {
|
}
|
||||||
try {
|
|
||||||
const content = event.target?.result as string;
|
export interface OpenFilesResult {
|
||||||
const data = JSON.parse(content);
|
files: FileResult[];
|
||||||
if (Array.isArray(data) && data.every(item =>
|
}
|
||||||
item.id && item.timestamp && item.message)) {
|
|
||||||
resolve(data as DebugRecord[]);
|
/**
|
||||||
} else {
|
* 使用传统的 input 标签上传文件
|
||||||
reject(new Error('文件格式不正确'));
|
*/
|
||||||
|
export const openFileInput = async (options: OpenFilesOptions = {}): Promise<OpenFilesResult> => {
|
||||||
|
const {
|
||||||
|
multiple = false,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.type = 'file';
|
||||||
|
input.accept = '.json,.png,.jpg,.jpeg';
|
||||||
|
input.multiple = multiple;
|
||||||
|
|
||||||
|
input.onchange = async (e) => {
|
||||||
|
const files = Array.from((e.target as HTMLInputElement).files || []);
|
||||||
|
if (files.length === 0) {
|
||||||
|
resolve({ files: [] });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const results: FileResult[] = [];
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
const result: FileResult = {
|
||||||
|
file,
|
||||||
|
name: file.name,
|
||||||
|
fileSystem: 'input'
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
results.push(result);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to read file ${file.name}:`, error);
|
||||||
|
throw new Error(`无法读取文件 ${file.name}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve({ files: results });
|
||||||
|
};
|
||||||
|
|
||||||
|
input.click();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 使用 Web File System API 打开文件
|
||||||
|
*/
|
||||||
|
export const openFileWFS = async (options: OpenFilesOptions = {}): Promise<OpenFilesResult> => {
|
||||||
|
const {
|
||||||
|
multiple = false,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// @ts-ignore - FileSystemHandle API 可能在某些环境下不支持
|
||||||
|
const handles = await window.showOpenFilePicker({
|
||||||
|
types: [
|
||||||
|
{
|
||||||
|
description: '图片文件 / meta 数据',
|
||||||
|
accept: {
|
||||||
|
'image/jpeg': ['.jpg', '.jpeg'],
|
||||||
|
'image/png': ['.png'],
|
||||||
|
'application/json': ['.json']
|
||||||
|
}
|
||||||
|
},
|
||||||
|
],
|
||||||
|
multiple
|
||||||
|
});
|
||||||
|
|
||||||
|
const results: FileResult[] = [];
|
||||||
|
|
||||||
|
for (const handle of handles) {
|
||||||
|
const file = await handle.getFile();
|
||||||
|
results.push({
|
||||||
|
file,
|
||||||
|
name: file.name,
|
||||||
|
handle,
|
||||||
|
fileSystem: 'wfs'
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} catch {
|
|
||||||
reject(new Error('无法解析JSON文件'));
|
return { files: results };
|
||||||
}
|
} catch (error) {
|
||||||
};
|
if ((error as Error).name === 'AbortError') {
|
||||||
reader.onerror = () => reject(new Error('读取文件失败'));
|
return { files: [] };
|
||||||
reader.readAsText(file);
|
}
|
||||||
});
|
throw error;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const formatTimestamp = (timestamp: number): string => {
|
/**
|
||||||
const date = new Date(timestamp);
|
* 打开文件。优先使用 FileSystem API,失败时回退到上传方式
|
||||||
return date.toLocaleString('zh-CN', {
|
*/
|
||||||
year: 'numeric',
|
export const openFiles = async (options: OpenFilesOptions = {}): Promise<OpenFilesResult> => {
|
||||||
month: '2-digit',
|
try {
|
||||||
day: '2-digit',
|
// @ts-ignore - 检查是否支持 FileSystem API
|
||||||
hour: '2-digit',
|
if (window.showOpenFilePicker) {
|
||||||
minute: '2-digit',
|
return await openFileWFS(options);
|
||||||
second: '2-digit',
|
} else {
|
||||||
hour12: false
|
return await openFileInput(options);
|
||||||
});
|
}
|
||||||
|
} catch (error) {
|
||||||
|
return await openFileInput(options);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const generateUniqueId = (): string => {
|
/**
|
||||||
return Date.now().toString(36) + Math.random().toString(36).substring(2);
|
* 将文件读取为文本
|
||||||
};
|
*/
|
||||||
|
export const readFileAsText = (file: File): Promise<string> => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (e) => resolve(e.target?.result as string);
|
||||||
|
reader.onerror = reject;
|
||||||
|
reader.readAsText(file);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将文件读取为DataURL
|
||||||
|
*/
|
||||||
|
export const readFileAsDataURL = (file: File): Promise<string> => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (e) => resolve(e.target?.result as string);
|
||||||
|
reader.onerror = reject;
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将文件读取为JSON对象
|
||||||
|
*/
|
||||||
|
export const readFileAsJSON = (file: File): Promise<any> => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (e) => {
|
||||||
|
try {
|
||||||
|
const json = JSON.parse(e.target?.result as string);
|
||||||
|
resolve(json);
|
||||||
|
} catch (error) {
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
reader.onerror = reject;
|
||||||
|
reader.readAsText(file);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 保存JSON数据到文件
|
||||||
|
*/
|
||||||
|
export const downloadJSONToFile = (data: any, filename: string) => {
|
||||||
|
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = filename;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 裁剪图片
|
||||||
|
*/
|
||||||
|
export const cropImage = (
|
||||||
|
img: HTMLImageElement,
|
||||||
|
rect: { x1: number, y1: number, x2: number, y2: number }
|
||||||
|
): string => {
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
const width = rect.x2 - rect.x1;
|
||||||
|
const height = rect.y2 - rect.y1;
|
||||||
|
canvas.width = width;
|
||||||
|
canvas.height = height;
|
||||||
|
|
||||||
|
ctx?.drawImage(
|
||||||
|
img,
|
||||||
|
rect.x1, rect.y1, width, height,
|
||||||
|
0, 0, width, height
|
||||||
|
);
|
||||||
|
|
||||||
|
return canvas.toDataURL('image/png');
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 使用 Web File System API 创建新文件并保存
|
||||||
|
* @param content 要保存的内容(字符串、Blob 或 ArrayBuffer)
|
||||||
|
* @param suggestedName 建议的文件名
|
||||||
|
* @returns 新的 FileSystemFileHandle 对象
|
||||||
|
*/
|
||||||
|
export const saveFileAsWFS = async (content: string | Blob | ArrayBuffer, suggestedName: string): Promise<FileSystemFileHandle> => {
|
||||||
|
try {
|
||||||
|
// @ts-ignore - FileSystemHandle API 可能在某些环境下不支持
|
||||||
|
const handle = await window.showSaveFilePicker({
|
||||||
|
suggestedName,
|
||||||
|
types: [
|
||||||
|
{
|
||||||
|
description: 'JSON 文件',
|
||||||
|
accept: {
|
||||||
|
'application/json': ['.json']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
// 获取写入权限并写入内容
|
||||||
|
const writable = await handle.createWritable();
|
||||||
|
await writable.write(content);
|
||||||
|
await writable.close();
|
||||||
|
|
||||||
|
return handle;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to save file:', error);
|
||||||
|
throw new Error('保存文件失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 使用 Web File System API 保存文件
|
||||||
|
* @param handle FileSystemFileHandle 对象,如果为空则会弹出另存为对话框
|
||||||
|
* @param content 要保存的内容(字符串、Blob 或 ArrayBuffer)
|
||||||
|
* @param suggestedName 当需要另存为时的建议文件名
|
||||||
|
* @returns 如果是另存为,则返回新的 FileSystemFileHandle;否则返回原来的 handle
|
||||||
|
*/
|
||||||
|
export const saveFileWFS = async (
|
||||||
|
handle: FileSystemFileHandle | undefined | null,
|
||||||
|
content: string | Blob | ArrayBuffer,
|
||||||
|
suggestedName?: string
|
||||||
|
): Promise<FileSystemFileHandle> => {
|
||||||
|
try {
|
||||||
|
if (!handle) {
|
||||||
|
// 如果没有 handle,执行另存为操作
|
||||||
|
return await saveFileAsWFS(content, suggestedName || 'metadata.json');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 有 handle,直接保存
|
||||||
|
const writable = await handle.createWritable();
|
||||||
|
await writable.write(content);
|
||||||
|
await writable.close();
|
||||||
|
return handle;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to save file:', error);
|
||||||
|
throw new Error('保存文件失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查文件是否是通过 Web File System API 打开的
|
||||||
|
* @param fileResult FileResult 对象
|
||||||
|
* @returns 是否可以使用 WFS API 保存
|
||||||
|
*/
|
||||||
|
export const canSaveWithWFS = (fileResult?: FileResult): boolean => {
|
||||||
|
return !!fileResult?.handle;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 使用 Web File System API 保存图片数据到文件
|
||||||
|
* @param handle FileSystemFileHandle 对象
|
||||||
|
* @param imageData 图片数据(Base64 或 Blob)
|
||||||
|
*/
|
||||||
|
export const saveImageWithHandle = async (handle: FileSystemFileHandle, imageData: string | Blob): Promise<void> => {
|
||||||
|
if (typeof imageData === 'string' && imageData.startsWith('data:')) {
|
||||||
|
// 将 Base64 转换为 Blob
|
||||||
|
const response = await fetch(imageData);
|
||||||
|
imageData = await response.blob();
|
||||||
|
}
|
||||||
|
await saveFileWFS(handle, imageData);
|
||||||
|
};
|
||||||
|
|
|
@ -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