kotones-auto-assistant/kotonebot-devtool/src/components/ImageEditor/ImageEditor.tsx

439 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useRef, useEffect, useState, useMemo } from 'react';
import styled from '@emotion/styled';
import { Updater, useImmer } from 'use-immer';
import RectTool from './RectTool';
import DragTool from './DragTool';
import { Tool, Point, RectPoints, Annotation, AnnotationType, Optional } from './types';
import RectBox, { RectBoxProps } from './RectBox';
import NativeDiv from '../NativeDiv';
import useLatestCallback from '../../hooks/useLatestCallback';
const EditorContainer = styled(NativeDiv)<{ isDragging: boolean }>`
width: 100%;
height: 100%;
position: relative;
overflow: hidden;
background: #f0f0f0;
user-select: none;
cursor: ${props => props.isDragging ? 'grabbing' : 'grab'};
`;
const EditorImage = styled.img<{ scale: number; x: number; y: number }>`
image-rendering: pixelated;
position: absolute;
transform: translate(${props => props.x}px, ${props => props.y}px) scale(${props => props.scale});
transform-origin: left top;
pointer-events: none;
`;
const CrosshairLine = styled(NativeDiv)<{ isVertical?: boolean; position: number }>`
position: absolute;
background-color: rgba(255, 255, 255, 0);
border: none;
${props => props.isVertical
? `
height: 100%;
width: 0;
left: ${props.position}px;
border-left: 2px dashed rgba(255, 255, 255, 1);
`
: `
width: 100%;
height: 0;
top: ${props.position}px;
border-top: 2px dashed rgba(255, 255, 255, 1);
`
}
pointer-events: none;
`;
export type AnnotationChangeType = 'add' | 'remove' | 'update';
export interface AnnotationChangedEvent {
currentTool: Tool;
type: AnnotationChangeType;
annotationType: AnnotationType;
annotation: Annotation;
}
export interface ImageEditorProps {
image: string;
tool?: Tool;
initialScale?: number;
showCrosshair?: boolean;
annotations: Annotation[];
onAnnotationChanged?: (e: AnnotationChangedEvent) => void;
onAnnotationSelected?: (annotation: Annotation | null) => void;
enableMask?: boolean;
maskAlpha?: number;
imageSize?: {
width: number;
height: number;
};
/**
* 编辑器的缩放模式。默认为 `wheel`。
*
* * `wheel` - 使用滚轮缩放Ctrl + 滚轮上下移动Shift + 滚轮左右移动
* * `ctrlWheel` - 使用 Ctrl + 滚轮缩放滚轮上下移动Shift + 滚轮左右移动
*/
scaleMode?: 'wheel' | 'ctrlWheel'
onNativeKeyDown?: (e: KeyboardEvent) => void;
}
export interface ImageEditorRef {
reset: () => void;
setScale: (scale: number) => void;
getScale: () => number;
}
export interface EditorState {
/** 图片的缩放比例 */
imageScale: number;
/** 图片相对于容器的偏移量/坐标 */
imagePosition: Point;
/** 图片相对于视口的偏移量/坐标 */
imageClientPosition: Point;
isDragging: boolean;
dragStart: Point;
mousePosition: Point;
}
export interface PostionConvertor {
posContainer2Image: (pos: Point) => Point;
posImage2Container: (pos: Point) => Point;
rectImage2Container: (rect: RectPoints) => RectPoints;
rectContainer2Image: (rect: RectPoints) => RectPoints;
}
export interface ToolHandlerProps {
editorState: [EditorState, Updater<EditorState>];
editorProps: ImageEditorProps;
containerRef: React.RefObject<HTMLDivElement>;
renderAnnotation: (annotations?: Annotation[], props?: Optional<RectBoxProps>) => React.ReactNode;
addAnnotation: (annotation: Annotation) => void;
updateAnnotation: (type: AnnotationType, annotation: Annotation) => void;
queryAnnotation: (id: string) => Annotation | null | undefined;
Convertor: PostionConvertor;
}
const ImageEditor = React.forwardRef<ImageEditorRef, ImageEditorProps>((props, ref) => {
const {
image,
initialScale = 1,
showCrosshair = false,
tool = Tool.Drag,
onAnnotationChanged,
annotations = [],
} = props;
const containerRef = useRef<HTMLDivElement>(null);
const imageRef = useRef<HTMLImageElement>(null);
// 图片是否可以显示了(加载完成,尺寸计算完成)
const [imageReady, setImageReady] = useState(false);
// 预加载图片以获取尺寸
useEffect(() => {
const img = new Image();
img.src = image;
img.onload = () => {
const container = containerRef.current;
if (!container) return;
const containerWidth = container.clientWidth;
const containerHeight = container.clientHeight;
const imageRatio = img.naturalWidth / img.naturalHeight;
const containerRatio = containerWidth / containerHeight;
let scale = initialScale;
if (imageRatio > containerRatio) {
scale = containerWidth / img.naturalWidth * 0.9;
} else {
scale = containerHeight / img.naturalHeight * 0.9;
}
const scaledWidth = img.naturalWidth * scale;
const scaledHeight = img.naturalHeight * scale;
const x = (containerWidth - scaledWidth) / 2;
const y = (containerHeight - scaledHeight) / 2;
// 设置初始状态
updateState(draft => {
draft.imageScale = scale;
draft.imagePosition = { x, y };
draft.imageClientPosition = {
x: container.getBoundingClientRect().left + x,
y: container.getBoundingClientRect().top + y
};
});
setImageSize({
width: img.naturalWidth,
height: img.naturalHeight
});
setImageReady(true);
};
}, [image]);
const [state, updateState] = useImmer<EditorState>({
imageScale: initialScale,
imagePosition: { x: 0, y: 0 },
imageClientPosition: { x: 0, y: 0 },
isDragging: false,
dragStart: { x: 0, y: 0 },
mousePosition: { x: 0, y: 0 }
});
const [imageSize, setImageSize] = useState<{ width: number; height: number }>({ width: 0, height: 0 });
// 监听图像加载完成事件(保留这个事件以防需要做其他处理)
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 容器坐标
* @returns 图片坐标
*/
posContainer2Image: (pos: Point) => {
return {
x: Math.round((pos.x - state.imagePosition.x) / state.imageScale),
y: Math.round((pos.y - state.imagePosition.y) / state.imageScale)
};
},
/**
* 从图片坐标转换到容器坐标
* @param pos 图片坐标
* @returns 容器坐标
*/
posImage2Container: (pos: Point) => {
return {
x: pos.x * state.imageScale + state.imagePosition.x,
y: pos.y * state.imageScale + state.imagePosition.y
};
},
/**
* 从图片坐标转换到容器坐标
* @param rect 图片坐标
* @returns 容器坐标
*/
rectImage2Container: (rect: RectPoints) => {
return {
x1: Convertor.posImage2Container({ x: rect.x1, y: rect.y1 }).x,
y1: Convertor.posImage2Container({ x: rect.x1, y: rect.y1 }).y,
x2: Convertor.posImage2Container({ x: rect.x2, y: rect.y2 }).x,
y2: Convertor.posImage2Container({ x: rect.x2, y: rect.y2 }).y
};
},
/**
* 从容器坐标转换到图片坐标
* @param rect 容器坐标
* @returns 图片坐标
*/
rectContainer2Image: (rect: RectPoints) => {
return {
x1: Convertor.posContainer2Image({ x: rect.x1, y: rect.y1 }).x,
y1: Convertor.posContainer2Image({ x: rect.x1, y: rect.y1 }).y,
x2: Convertor.posContainer2Image({ x: rect.x2, y: rect.y2 }).x,
y2: Convertor.posContainer2Image({ x: rect.x2, y: rect.y2 }).y
};
}
}), [state.imageScale, state.imagePosition]);
const renderAnnotation = (annotations?: Annotation[], props?: Optional<RectBoxProps>) => {
if (!annotations) return null;
return annotations.map((rect) => (
<RectBox
key={rect.id}
rect={Convertor.rectImage2Container(rect.data)}
rectTip={rect._tip}
{...props}
/>
));
};
const addAnnotation = (annotation: Annotation) => {
console.log('Add annotation: ', annotation);
onAnnotationChanged?.({
currentTool: Tool.Rect,
type: 'add',
annotationType: 'rect',
annotation: annotation
});
};
const updateAnnotation = (type: AnnotationType, annotation: Annotation) => {
console.log('Update rect: ', annotation);
onAnnotationChanged?.({
currentTool: Tool.Rect,
type: 'update',
annotationType: type,
annotation: annotation
});
};
const queryAnnotation = (id: string) => {
let ret = null;
if (annotations) {
ret = annotations.find(annotation => annotation.id === id);
}
return ret;
};
const toolHandlerProps: ToolHandlerProps = {
editorState: [state, updateState],
editorProps: {
...props,
imageSize
},
containerRef,
addAnnotation,
updateAnnotation,
renderAnnotation,
queryAnnotation,
Convertor,
};
// 处理鼠标滚轮缩放
const handleWheel = useLatestCallback((e: WheelEvent) => {
e.preventDefault();
const delta = -e.deltaY;
const scaleMode = props.scaleMode || 'wheel';
// 判断缩放还是滚动
const shouldScale =
(scaleMode === 'wheel' && !e.ctrlKey && !e.shiftKey) ||
(scaleMode === 'ctrlWheel' && e.ctrlKey);
if (shouldScale) {
const scaleChange = delta > 0 ? 1.1 : 0.9;
// 获取鼠标相对于容器的坐标
const container = containerRef.current;
if (!container) return;
const rect = container.getBoundingClientRect();
const mouseX = e.clientX - rect.left;
const mouseY = e.clientY - rect.top;
// 计算鼠标位置在图片上的相对位置
const mouseImageX = (mouseX - state.imagePosition.x) / state.imageScale;
const mouseImageY = (mouseY - state.imagePosition.y) / state.imageScale;
updateState(draft => {
draft.imageScale = Math.max(0.1, Math.min(10, draft.imageScale * scaleChange));
// 计算新的图片位置,保持鼠标指向的图片位置不变
draft.imagePosition.x = mouseX - mouseImageX * draft.imageScale;
draft.imagePosition.y = mouseY - mouseImageY * draft.imageScale;
});
} else {
const moveX = e.shiftKey ? delta : 0;
const moveY = !e.shiftKey ? delta : 0;
updateState(draft => {
draft.imagePosition.x += moveX;
draft.imagePosition.y += moveY;
});
}
});
// 暴露组件方法
React.useImperativeHandle(ref, () => ({
reset: () => {
updateState(draft => {
draft.imageScale = initialScale;
draft.imagePosition = { x: 0, y: 0 };
});
},
setScale: (newScale: number) => {
updateState(draft => {
draft.imageScale = newScale;
});
},
getScale: () => state.imageScale
}));
return (
<EditorContainer
ref={containerRef}
isDragging={state.isDragging}
style={{ cursor: tool === Tool.Rect ? 'crosshair' : undefined }}
onNativeKeyDown={props.onNativeKeyDown}
onNativeMouseWheel={handleWheel}
>
{imageReady && (
<EditorImage
ref={imageRef}
src={image}
scale={state.imageScale}
x={state.imagePosition.x}
y={state.imagePosition.y}
onLoad={handleImageLoad}
/>
)}
{showCrosshair && tool === Tool.Rect && (
<>
<CrosshairLine isVertical position={state.mousePosition.x} />
<CrosshairLine position={state.mousePosition.y} />
</>
)}
{tool === Tool.Rect && <RectTool {...toolHandlerProps} />}
{tool === Tool.Drag && <DragTool {...toolHandlerProps} />}
</EditorContainer>
);
});
ImageEditor.displayName = 'ImageEditor';
export default ImageEditor;