diff --git a/kotonebot-devtool/package-lock.json b/kotonebot-devtool/package-lock.json index 8d38d8f..8fe7b6e 100644 --- a/kotonebot-devtool/package-lock.json +++ b/kotonebot-devtool/package-lock.json @@ -18,6 +18,7 @@ "react": "^18.3.1", "react-bootstrap": "^2.10.8", "react-dom": "^18.3.1", + "react-icons": "^5.4.0", "react-router-dom": "^7.1.3", "use-immer": "^0.11.0", "uuid": "^11.0.5", @@ -3939,6 +3940,14 @@ "react": "^18.3.1" } }, + "node_modules/react-icons": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.4.0.tgz", + "integrity": "sha512-7eltJxgVt7X64oHh6wSWNwwbKTCtMfK35hcjvJS0yxEAhPM8oUKdS3+kqaW1vicIltw+kR2unHaa12S9pPALoQ==", + "peerDependencies": { + "react": "*" + } + }, "node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", diff --git a/kotonebot-devtool/package.json b/kotonebot-devtool/package.json index 43d9946..57933e1 100644 --- a/kotonebot-devtool/package.json +++ b/kotonebot-devtool/package.json @@ -20,6 +20,7 @@ "react": "^18.3.1", "react-bootstrap": "^2.10.8", "react-dom": "^18.3.1", + "react-icons": "^5.4.0", "react-router-dom": "^7.1.3", "use-immer": "^0.11.0", "uuid": "^11.0.5", diff --git a/kotonebot-devtool/src/components/ImageEditor/DragTool.tsx b/kotonebot-devtool/src/components/ImageEditor/DragTool.tsx index 627f249..ab35c63 100644 --- a/kotonebot-devtool/src/components/ImageEditor/DragTool.tsx +++ b/kotonebot-devtool/src/components/ImageEditor/DragTool.tsx @@ -22,8 +22,8 @@ function DragTool(props: ToolHandlerProps) { updateState(draft => { draft.isDragging = true; draft.dragStart = { - x: e.clientX - draft.position.x, - y: e.clientY - draft.position.y, + x: e.clientX - draft.imagePosition.x, + y: e.clientY - draft.imagePosition.y, }; }); }); @@ -36,7 +36,7 @@ function DragTool(props: ToolHandlerProps) { console.log('DragTool: handleMouseMove', e); updateState(draft => { - draft.position = { + draft.imagePosition = { x: e.clientX - draft.dragStart.x, y: e.clientY - draft.dragStart.y, }; @@ -58,10 +58,16 @@ function DragTool(props: ToolHandlerProps) { // 处理矩形变换 const handleRectTransform = useLatestCallback((rectPoints: RectPoints, id: string) => { console.log('DragTool: handleRectTransform'); + const rect = Convertor.rectContainer2Image({ + x1: rectPoints.x1, + y1: rectPoints.y1, + x2: rectPoints.x2, + y2: rectPoints.y2, + }); updateAnnotation('rect', { id: id, type: 'rect', - data: rectPoints, + data: rect, }); }); @@ -97,6 +103,9 @@ function DragTool(props: ToolHandlerProps) { e.stopPropagation(); // 阻止事件冒泡到容器 console.log('DragTool: handleRectClick', 'selectedRectId=', selectedRectId); setSelectedRectId(id); + const anno = queryAnnotation(id); + if (anno) + editorProps.onAnnotationSelected?.(anno); }; // 点击容器,取消选中 @@ -104,6 +113,7 @@ function DragTool(props: ToolHandlerProps) { console.log('DragTool: handleContainerClick', 'selectedRectId=', selectedRectId, e); setSelectedRectId(null); setHoveredRectId(null); + editorProps.onAnnotationSelected?.(null); }; // 点击容器中的非矩形区域,取消选中当前矩形 useEffect(() => { @@ -133,6 +143,8 @@ function DragTool(props: ToolHandlerProps) { const shouldShowMask = () => { if (!editorProps.enableMask) return false; + if (editorProps.annotations.length === 0) + return false; if (selectedRectId === null && hoveredRectId === null) return true; if (selectedRectId === hoveredRectId) @@ -157,32 +169,56 @@ function DragTool(props: ToolHandlerProps) { onNativeMouseLeave={handleRectMouseLeave} onNativeClick={(e) => handleRectClick(anno.id, e)} onTransform={(points) => handleRectTransform(points, anno.id)} - rectTip="rect" + rectTip={anno.tip} showRectTip={hoveredRectId === anno.id && selectedRectId === null} /> )); }; - console.log('DragTool: render', editorProps.annotations); + let rectMask; + if (hoveredRectId !== null) { + // 当有矩形被悬停时,只显示该矩形的遮罩 + const rect = queryAnnotation(hoveredRectId); + if (rect) { + rectMask = ( + + ); + } + } else { + // 默认显示所有矩形的遮罩 + rectMask = ( + ({ + // 获取图像坐标 + ...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 ( <> - { - hoveredRectId !== null ? ( - // 当有矩形被悬停时,只显示该矩形的遮罩 - - ) : ( - // 默认显示所有矩形的遮罩 - Convertor.rectImage2Container(anno.data)) || []} - alpha={shouldShowMask() ? editorProps.maskAlpha : 0} - transition={true} - /> - ) - } + {rectMask} {renderAnnotationWithHover(editorProps.annotations)} > ); diff --git a/kotonebot-devtool/src/components/ImageEditor/ImageEditor.tsx b/kotonebot-devtool/src/components/ImageEditor/ImageEditor.tsx index 746356d..0650650 100644 --- a/kotonebot-devtool/src/components/ImageEditor/ImageEditor.tsx +++ b/kotonebot-devtool/src/components/ImageEditor/ImageEditor.tsx @@ -1,11 +1,12 @@ -import React, { useRef, useEffect } from 'react'; +import React, { useRef, useEffect, useState, useMemo } from 'react'; import styled from '@emotion/styled'; import { Updater, useImmer } from 'use-immer'; import RectTool from './RectTool'; import DragTool from './DragTool'; -import { Tool, Point, RectPoints, Annotation, AnnotationType } from './types'; -import RectBox from './RectBox'; +import { Tool, Point, RectPoints, Annotation, AnnotationType, Optional } from './types'; +import RectBox, { RectBoxProps } from './RectBox'; import NativeDiv from '../NativeDiv'; +import useLatestCallback from '../../hooks/useLatestCallback'; const EditorContainer = styled(NativeDiv)<{ isDragging: boolean }>` width: 100%; @@ -61,8 +62,21 @@ export interface ImageEditorProps { showCrosshair?: boolean; annotations: Annotation[]; onAnnotationChanged?: (e: AnnotationChangedEvent) => void; + onAnnotationSelected?: (annotation: Annotation | null) => void; enableMask?: boolean; maskAlpha?: number; + imageSize?: { + width: number; + height: number; + }; + /** + * 编辑器的缩放模式。默认为 `wheel`。 + * + * * `wheel` - 使用滚轮缩放,Ctrl + 滚轮上下移动,Shift + 滚轮左右移动 + * * `ctrlWheel` - 使用 Ctrl + 滚轮缩放,滚轮上下移动,Shift + 滚轮左右移动 + */ + scaleMode?: 'wheel' | 'ctrlWheel' + onNativeKeyDown?: (e: KeyboardEvent) => void; } export interface ImageEditorRef { @@ -72,8 +86,12 @@ export interface ImageEditorRef { } export interface EditorState { - scale: number; - position: Point; + /** 图片的缩放比例 */ + imageScale: number; + /** 图片相对于容器的偏移量/坐标 */ + imagePosition: Point; + /** 图片相对于视口的偏移量/坐标 */ + imageClientPosition: Point; isDragging: boolean; dragStart: Point; mousePosition: Point; @@ -90,10 +108,10 @@ export interface ToolHandlerProps { editorState: [EditorState, Updater]; editorProps: ImageEditorProps; containerRef: React.RefObject; - renderAnnotation: (annotations?: Annotation[]) => React.ReactNode; + renderAnnotation: (annotations?: Annotation[], props?: Optional) => React.ReactNode; addAnnotation: (annotation: Annotation) => void; updateAnnotation: (type: AnnotationType, annotation: Annotation) => void; - queryAnnotation: (id: string) => Annotation; + queryAnnotation: (id: string) => Annotation | null | undefined; Convertor: PostionConvertor; } @@ -108,17 +126,121 @@ const ImageEditor = React.forwardRef((props, r annotations = [], } = props; + const containerRef = useRef(null); + const imageRef = useRef(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({ - scale: initialScale, - position: { x: 0, y: 0 }, + imageScale: initialScale, + imagePosition: { x: 0, y: 0 }, + imageClientPosition: { x: 0, y: 0 }, isDragging: false, dragStart: { x: 0, y: 0 }, mousePosition: { x: 0, y: 0 } }); - const containerRef = useRef(null); + const [imageSize, setImageSize] = useState<{ width: number; height: number }>({ width: 0, height: 0 }); - const Convertor = { + // 监听图像加载完成事件(保留这个事件以防需要做其他处理) + const handleImageLoad = () => { + const img = imageRef.current; + if (img && !imageReady) { + const fitResult = calculateImageFit(); + if (fitResult) { + updateState(draft => { + draft.imageScale = fitResult.scale; + draft.imagePosition = fitResult.position; + draft.imageClientPosition = fitResult.clientPosition; + }); + } + setImageReady(true); + } + }; + + /** + * 计算图像在容器中的居中位置和缩放比例 + * @returns 返回图像的缩放比例和位置信息 + */ + const calculateImageFit = () => { + const img = imageRef.current; + const container = containerRef.current; + if (!img || !container) return null; + + const containerWidth = container.clientWidth; + const containerHeight = container.clientHeight; + const imageRatio = img.naturalWidth / img.naturalHeight; + const containerRatio = containerWidth / containerHeight; + + let scale = initialScale; + if (imageRatio > containerRatio) { + // 图片更宽,以容器宽度为基准 + scale = containerWidth / img.naturalWidth * 0.9; + } else { + // 图片更高,以容器高度为基准 + scale = containerHeight / img.naturalHeight * 0.9; + } + + // 计算居中位置 + const scaledWidth = img.naturalWidth * scale; + const scaledHeight = img.naturalHeight * scale; + const x = (containerWidth - scaledWidth) / 2; + const y = (containerHeight - scaledHeight) / 2; + + return { + scale, + position: { x, y }, + clientPosition: { + x: container.getBoundingClientRect().left + x, + y: container.getBoundingClientRect().top + y + } + }; + }; + + const Convertor = useMemo(() => ({ /** * 从容器坐标转换到图片坐标 * @param pos 容器坐标 @@ -126,8 +248,8 @@ const ImageEditor = React.forwardRef((props, r */ posContainer2Image: (pos: Point) => { return { - x: Math.round((pos.x - state.position.x) / state.scale), - y: Math.round((pos.y - state.position.y) / state.scale) + x: Math.round((pos.x - state.imagePosition.x) / state.imageScale), + y: Math.round((pos.y - state.imagePosition.y) / state.imageScale) }; }, /** @@ -137,8 +259,8 @@ const ImageEditor = React.forwardRef((props, r */ posImage2Container: (pos: Point) => { return { - x: pos.x * state.scale + state.position.x, - y: pos.y * state.scale + state.position.y + x: pos.x * state.imageScale + state.imagePosition.x, + y: pos.y * state.imageScale + state.imagePosition.y }; }, /** @@ -167,15 +289,16 @@ const ImageEditor = React.forwardRef((props, r y2: Convertor.posContainer2Image({ x: rect.x2, y: rect.y2 }).y }; } - }; + }), [state.imageScale, state.imagePosition]); - const renderAnnotation = (annotations?: Annotation[]) => { + const renderAnnotation = (annotations?: Annotation[], props?: Optional) => { if (!annotations) return null; return annotations.map((rect) => ( )); }; @@ -203,14 +326,15 @@ const ImageEditor = React.forwardRef((props, r if (annotations) { ret = annotations.find(annotation => annotation.id === id); } - if (!ret) - throw new Error(`Annotation not found: ${id}`); return ret; }; const toolHandlerProps: ToolHandlerProps = { editorState: [state, updateState], - editorProps: props, + editorProps: { + ...props, + imageSize + }, containerRef, addAnnotation, updateAnnotation, @@ -220,55 +344,82 @@ const ImageEditor = React.forwardRef((props, r }; // 处理鼠标滚轮缩放 - const handleWheel = (e: WheelEvent) => { + const handleWheel = useLatestCallback((e: WheelEvent) => { e.preventDefault(); const delta = -e.deltaY; - const scaleChange = delta > 0 ? 1.1 : 0.9; - updateState(draft => { - draft.scale = Math.max(0.1, Math.min(10, draft.scale * scaleChange)); - }); - }; + 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.scale = initialScale; - draft.position = { x: 0, y: 0 }; + draft.imageScale = initialScale; + draft.imagePosition = { x: 0, y: 0 }; }); }, setScale: (newScale: number) => { updateState(draft => { - draft.scale = newScale; + draft.imageScale = newScale; }); }, - getScale: () => state.scale + getScale: () => state.imageScale })); - // 添加和移除滚轮事件监听器 - useEffect(() => { - const container = containerRef.current; - if (container) { - container.addEventListener('wheel', handleWheel, { passive: false }); - return () => { - container.removeEventListener('wheel', handleWheel); - }; - } - }, []); - return ( - + {imageReady && ( + + )} {showCrosshair && tool === Tool.Rect && ( <> diff --git a/kotonebot-devtool/src/components/ImageEditor/RectBox.tsx b/kotonebot-devtool/src/components/ImageEditor/RectBox.tsx index fd985b0..1a7ba54 100644 --- a/kotonebot-devtool/src/components/ImageEditor/RectBox.tsx +++ b/kotonebot-devtool/src/components/ImageEditor/RectBox.tsx @@ -64,7 +64,7 @@ const RectTipContainer = styled(NativeDiv)` transform: translateY(-100%); `; -interface ObjectRectProps { +export interface RectBoxProps { rect: RectPoints; /** * 矩形模式。默认为 resize。 @@ -86,6 +86,13 @@ interface ObjectRectProps { * 是否显示提示内容(`rectTip`) */ showRectTip?: boolean; + /** + * 矩形框变换事件。 + * + * 变换后的坐标是在原坐标的基础上加上拖拽时鼠标的位移差得到的。 + * 也就是说,原来传入的是什么坐标系的坐标,变换后的坐标也是什么坐标系的坐标。 + * @param points 变换后的矩形框 + */ onTransform?: (points: RectPoints) => void; onNativeMouseEnter?: (e: MouseEvent) => void; onNativeMouseMove?: (e: MouseEvent) => void; @@ -110,7 +117,7 @@ function useLatestProps(props: T) { /** * 可调整位置、大小的矩形框组件。 */ -function RectBox(props: ObjectRectProps) { +function RectBox(props: RectBoxProps) { const { rect, mode = 'resize', diff --git a/kotonebot-devtool/src/components/ImageEditor/RectMask.tsx b/kotonebot-devtool/src/components/ImageEditor/RectMask.tsx index f84c4da..874b7f8 100644 --- a/kotonebot-devtool/src/components/ImageEditor/RectMask.tsx +++ b/kotonebot-devtool/src/components/ImageEditor/RectMask.tsx @@ -1,24 +1,30 @@ import { RectPoints } from "./types"; import styled from '@emotion/styled'; -const MaskContainer = styled.div` +const MaskContainer = styled.div<{ scale: number; transform: { x: number; y: number; width: number; height: number } }>` position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; + transform: translate(${props => props.transform.x}px, ${props => props.transform.y}px) scale(${props => props.scale}); + transform-origin: left top; + pointer-events: none; `; interface RectMaskProps { rects: RectPoints[]; alpha?: number; transition?: boolean; + scale: number; + transform: { + x: number; + y: number; + width: number; + height: number; + }; } -function RectMask({ rects, alpha = 0.5, transition = false }: RectMaskProps) { +function RectMask({ rects, alpha = 0.5, transition = false, scale, transform }: RectMaskProps) { return ( - - + + {/* 首先创建一个黑色背景(完全遮罩) */} @@ -33,7 +39,6 @@ function RectMask({ rects, alpha = 0.5, transition = false }: RectMaskProps) { width={rect.x2 - rect.x1} height={rect.y2 - rect.y1} fill="black" - // style={transition ? { transition: 'all 0.1s ease-in-out' } : undefined} /> ))} diff --git a/kotonebot-devtool/src/components/ImageEditor/RectTool.tsx b/kotonebot-devtool/src/components/ImageEditor/RectTool.tsx index 58e0d41..bf598da 100644 --- a/kotonebot-devtool/src/components/ImageEditor/RectTool.tsx +++ b/kotonebot-devtool/src/components/ImageEditor/RectTool.tsx @@ -1,15 +1,17 @@ import { ToolHandlerProps } from "./ImageEditor"; -import { useEffect, useState } from "react"; +import { useEffect, useState, useRef } from "react"; import RectBox from "./RectBox"; -import { Point } from "./types"; +import { Annotation, Point } from "./types"; import useLatestCallback from "../../hooks/useLatestCallback"; import { v4 } from "uuid"; function RectTool(props: ToolHandlerProps) { const { containerRef, addAnnotation, renderAnnotation, Convertor, editorProps } = props; + // 下面两个都为容器坐标 const [rectStart, setRectStart] = useState(null); const [rectEnd, setRectEnd] = useState(null); + const drawingRef = useRef(false); // 处理拖拽开始 const handleMouseDown = useLatestCallback((e: MouseEvent) => { @@ -18,9 +20,11 @@ function RectTool(props: ToolHandlerProps) { if (rect) { const x = e.clientX - rect.left; const y = e.clientY - rect.top; + // const imagePos = Convertor.posContainer2Image({ x, y }); setRectStart({ x, y }); setRectEnd({ x, y }); } + drawingRef.current = true; console.log('RectTool: handleMouseDown'); }); @@ -41,17 +45,25 @@ function RectTool(props: ToolHandlerProps) { let y1 = Math.min(rectStart.y, rectEnd.y); let x2 = Math.max(rectStart.x, rectEnd.x); let y2 = Math.max(rectStart.y, rectEnd.y); - let newRect = { x1, y1, x2, y2 }; - // 转换到图片坐标 - newRect = Convertor.rectContainer2Image(newRect); - - addAnnotation({ - id: v4(), - type: 'rect', - data: newRect - }); + if (Math.abs(x1 - x2) < 10 || Math.abs(y1 - y2) < 10) { + console.log('RectTool: rect too small. skip add annotation'); + } + else { + let newRect = { x1, y1, x2, y2 }; + + newRect = Convertor.rectContainer2Image(newRect); + + const annotation: Annotation = { + id: v4(), + type: 'rect', + data: newRect + }; + addAnnotation(annotation); + editorProps.onAnnotationSelected?.(annotation); + } setRectStart(null); setRectEnd(null); + drawingRef.current = false; console.log('RectTool: handleMouseUp'); }); @@ -75,7 +87,10 @@ function RectTool(props: ToolHandlerProps) { {rectStart && rectEnd && ( )} - {renderAnnotation(editorProps.annotations)} + {renderAnnotation(editorProps.annotations, { + mode: 'move', + lineColor: drawingRef.current ? 'rgba(255, 255, 255, 0.3)' : 'white' + })} > ); } diff --git a/kotonebot-devtool/src/components/ImageEditor/types.ts b/kotonebot-devtool/src/components/ImageEditor/types.ts index 6f85005..c86e6e7 100644 --- a/kotonebot-devtool/src/components/ImageEditor/types.ts +++ b/kotonebot-devtool/src/components/ImageEditor/types.ts @@ -10,10 +10,12 @@ type AnnotationTypeMap = { rect: RectPoints, }; -export interface Annotation{ +export interface Annotation { id: string; type: AnnotationType; data: AnnotationTypeMap[AnnotationType]; + /** 提示信息。 */ + tip?: React.ReactNode; } export interface Point { x: number; @@ -25,4 +27,11 @@ export interface RectPoints { y1: number; x2: number; y2: number; -} \ No newline at end of file +} + +/** + * 使对象中的所有属性都变为可选。 + */ +export type Optional = { + [P in keyof T]?: T[P] | undefined; +}; diff --git a/kotonebot-devtool/src/components/ImageViewer.tsx b/kotonebot-devtool/src/components/ImageViewer.tsx index 644a57e..5e1ea38 100644 --- a/kotonebot-devtool/src/components/ImageViewer.tsx +++ b/kotonebot-devtool/src/components/ImageViewer.tsx @@ -4,6 +4,8 @@ import styled from '@emotion/styled'; interface ImageViewerProps { /** 图片地址 */ image: string; + /** 图片渲染模式 */ + imageRendering?: 'pixelated' | 'auto'; /** 是否可缩放 */ zoomable?: boolean; /** 是否可移动 */ @@ -47,6 +49,7 @@ const ImageContainer = styled.div` interface StyledImageProps { withAnimation?: boolean; + imageRendering?: 'pixelated' | 'auto'; } const StyledImage = styled.img` @@ -57,6 +60,7 @@ const StyledImage = styled.img` user-select: none; position: relative; transition: ${(props: StyledImageProps) => props.withAnimation ? 'transform 0.2s ease-out' : 'none'}; + image-rendering: ${props => props.imageRendering}; &.dragging { cursor: grabbing; @@ -67,6 +71,7 @@ const StyledImage = styled.img` const ImageViewer = forwardRef( ({ image, + imageRendering = 'auto', zoomable: scalable = true, movable = true, minZoomScale: minScale = 0.1, @@ -214,6 +219,7 @@ const ImageViewer = forwardRef( onDragStart={(e: React.DragEvent) => e.preventDefault()} className={isDragging ? 'dragging' : ''} withAnimation={useAnimation} + imageRendering={imageRendering} style={{ transform: `translate(${translateX}px, ${translateY}px) scale(${scale})` }} diff --git a/kotonebot-devtool/src/components/ImageViewerModal.tsx b/kotonebot-devtool/src/components/ImageViewerModal.tsx new file mode 100644 index 0000000..8acd029 --- /dev/null +++ b/kotonebot-devtool/src/components/ImageViewerModal.tsx @@ -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(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 && } + + + {title && {title}} + + + + + + + + + + + + + + + + + + + + + + > + ); +} + +interface ImageViewerModalOpenOptions { + imageRendering?: 'pixelated' | 'auto'; +} + +export function useImageViewerModal(title?: string, options?: ImageViewerModalOpenOptions) { + const [show, setShow] = useState(false); + const [image, setImage] = useState(''); + 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 = ( + + ); + + return { + modal, + openModal, + closeModal + }; +} + +export default ImageViewerModal; diff --git a/kotonebot-devtool/src/components/NativeDiv.tsx b/kotonebot-devtool/src/components/NativeDiv.tsx index ea9adfc..0094de9 100644 --- a/kotonebot-devtool/src/components/NativeDiv.tsx +++ b/kotonebot-devtool/src/components/NativeDiv.tsx @@ -6,6 +6,9 @@ interface NativeDivProps extends HTMLAttributes { onNativeMouseLeave?: (e: MouseEvent) => void; onNativeMouseDown?: (e: MouseEvent) => void; onNativeClick?: (e: MouseEvent) => void; + onNativeKeyDown?: (e: KeyboardEvent) => void; + onNativeKeyUp?: (e: KeyboardEvent) => void; + onNativeMouseWheel?: (e: WheelEvent) => void; } /** @@ -17,6 +20,9 @@ interface NativeDivProps extends HTMLAttributes { * * onNativeMouseLeave * * onNativeMouseDown * * onNativeClick + * * onNativeKeyDown + * * onNativeKeyUp + * * onNativeMouseWheel * * 这些事件都是对原生 DOM 事件的封装。 */ @@ -27,6 +33,9 @@ const NativeDiv = forwardRef((props, ref) => { onNativeMouseLeave, onNativeMouseDown, onNativeClick, + onNativeKeyDown, + onNativeKeyUp, + onNativeMouseWheel, ...restProps } = props; @@ -53,6 +62,12 @@ const NativeDiv = forwardRef((props, ref) => { element.addEventListener('mousedown', onNativeMouseDown); if (onNativeClick) element.addEventListener('click', onNativeClick); + if (onNativeKeyDown) + element.addEventListener('keydown', onNativeKeyDown); + if (onNativeKeyUp) + element.addEventListener('keyup', onNativeKeyUp); + if (onNativeMouseWheel) + element.addEventListener('wheel', onNativeMouseWheel); return () => { if (onNativeMouseEnter) @@ -65,8 +80,14 @@ const NativeDiv = forwardRef((props, ref) => { element.removeEventListener('mousedown', onNativeMouseDown); if (onNativeClick) element.removeEventListener('click', onNativeClick); + if (onNativeKeyDown) + element.removeEventListener('keydown', onNativeKeyDown); + if (onNativeKeyUp) + element.removeEventListener('keyup', onNativeKeyUp); + if (onNativeMouseWheel) + element.removeEventListener('wheel', onNativeMouseWheel); }; - }, [onNativeMouseEnter, onNativeMouseMove, onNativeMouseLeave, onNativeClick]); + }, [onNativeMouseEnter, onNativeMouseMove, onNativeMouseLeave, onNativeClick, onNativeKeyDown, onNativeKeyUp, onNativeMouseWheel]); return ; }); diff --git a/kotonebot-devtool/src/components/PropertyGrid.tsx b/kotonebot-devtool/src/components/PropertyGrid.tsx new file mode 100644 index 0000000..ae3bfb8 --- /dev/null +++ b/kotonebot-devtool/src/components/PropertyGrid.tsx @@ -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; + 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 = ({ properties, titleColumnWidth = 'auto' }) => { + const [foldedCategories, setFoldedCategories] = useState>({}); + + 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 = propertyText.onChange(e.target.value)} />; + } else if (type === 'checkbox') { + const propertyCheckbox = property.render as PropertyRenderInputOptions['checkbox']; + field = propertyCheckbox.onChange(e.target.checked)} />; + } + } else { + console.error('Invalid property render type:', property.render); + } + if (property.title) { + return ( + <> + {property.title} + {field} + > + ); + } + return {field}; + }; + + /** + * 渲染属性分类 + * @param category - 分类配置 + * @param index - 分类索引 + */ + const makeCategory = (category: PropertyCategory, index: number) => { + return ( + <> + category.foldable && toggleCategory(category.title)} + > + {category.foldable && ( + + )} + {category.title} + + + {category.properties.map((subProperty, subIndex) => ( + + {makeProperty(subProperty)} + + ))} + + > + ); + }; + + return ( + + {properties.map((property, index) => ( + + {'properties' in property + ? makeCategory(property, index) + : makeProperty(property) + } + + ))} + + ); +}; + +export default PropertyGrid; diff --git a/kotonebot-devtool/src/components/SideToolBar.tsx b/kotonebot-devtool/src/components/SideToolBar.tsx new file mode 100644 index 0000000..d4f5eff --- /dev/null +++ b/kotonebot-devtool/src/components/SideToolBar.tsx @@ -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; + 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 = ({ + tools, + selectedToolId, + onSelectTool, + onClickTool +}) => { + return ( + + {tools.map((tool, index) => { + if (tool === 'separator') { + return ; + } + + const [showTooltip, setShowTooltip] = useState(false); + + const handleClick = () => { + tool.onClick?.(); + onClickTool?.(tool.id); + if (tool.selectable !== false && onSelectTool) { + onSelectTool(tool.id); + } + }; + + return ( + setShowTooltip(true)} + onMouseLeave={() => setShowTooltip(false)} + > + + {tool.icon} + + {tool.title} + + ); + })} + + ); +}; \ No newline at end of file diff --git a/kotonebot-devtool/src/components/ToastMessage.tsx b/kotonebot-devtool/src/components/ToastMessage.tsx new file mode 100644 index 0000000..4b9114b --- /dev/null +++ b/kotonebot-devtool/src/components/ToastMessage.tsx @@ -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([]); + + // 移除指定 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 = ( + + {toasts.map(toast => ( + removeToast(toast.id)} + show={true} + delay={toast.duration} + autohide + bg={toast.type} + className="mb-2" + > + + {toast.title} + + + {toast.message} + + + ))} + + ); + + return { + showToast, + ToastComponent + }; +}; diff --git a/kotonebot-devtool/src/components/useImageEditorTools.ts b/kotonebot-devtool/src/components/useImageEditorTools.ts deleted file mode 100644 index d66841a..0000000 --- a/kotonebot-devtool/src/components/useImageEditorTools.ts +++ /dev/null @@ -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 - }; -}; \ No newline at end of file diff --git a/kotonebot-devtool/src/pages/Demo.tsx b/kotonebot-devtool/src/pages/Demo.tsx index c9b4f09..646e82f 100644 --- a/kotonebot-devtool/src/pages/Demo.tsx +++ b/kotonebot-devtool/src/pages/Demo.tsx @@ -6,12 +6,17 @@ import MultipleImagesViewer from '../components/MutipleImagesViewer'; import { useMessageBox } from '../hooks/useMessageBox'; import { useFullscreenSpinner } from '../hooks/useFullscreenSpinner'; import ImageEditor, { ImageEditorRef } from '../components/ImageEditor/ImageEditor'; -import { Annotation, RectPoints, Tool } from '../components/ImageEditor/types'; +import { Annotation, RectPoints, Tool as EditorTool } from '../components/ImageEditor/types'; import RectBox from '../components/ImageEditor/RectBox'; import RectMask from '../components/ImageEditor/RectMask'; import FormRange from 'react-bootstrap/esm/FormRange'; import NativeDiv from '../components/NativeDiv'; import { AnnotationChangedEvent } from '../components/ImageEditor/ImageEditor'; +import { SideToolBar, Tool } from '../components/SideToolBar'; +import PropertyGrid, { Property, PropertyCategory } from '../components/PropertyGrid'; +import ImageViewerModal, { useImageViewerModal } from '../components/ImageViewerModal'; +import { useToast } from '../components/ToastMessage'; + // 布局相关的样式组件 const DemoContainer = styled.div` display: flex; @@ -239,6 +244,63 @@ function SpinnerDemo(): JSX.Element { ); } +// 添加 Toast 消息演示组件 +function ToastMessageDemo(): JSX.Element { + const { showToast, ToastComponent } = useToast(); + + const handleShowSuccess = () => { + showToast('success', '成功', '操作已成功完成!'); + }; + + const handleShowError = () => { + showToast('danger', '错误', '操作执行失败,请重试。'); + }; + + const handleShowInfo = () => { + showToast('info', '提示', '这是一条信息提示。'); + }; + + const handleShowWarning = () => { + showToast('warning', '警告', '请注意,这是一条警告消息。'); + }; + + const handleShowCustomDuration = () => { + showToast('info', '自定义时长', '这条消息会显示 10 秒钟。', 10000); + }; + + return ( + + Toast 消息演示 + + 显示成功消息 + 显示错误消息 + 显示信息提示 + 显示警告消息 + 显示长时消息 + + {ToastComponent} + + 使用说明: + + 点击不同的按钮可以显示不同类型的 Toast 消息 + 每种消息类型都有其独特的颜色样式: + + 成功消息 - 绿色 + 错误消息 - 红色 + 信息提示 - 蓝色 + 警告消息 - 黄色 + + 默认情况下,消息会在 3 秒后自动消失 + "显示长时消息"按钮会显示一个持续 10 秒的消息 + 可以通过点击消息右上角的关闭按钮手动关闭消息 + 多个消息会按顺序堆叠显示 + 消息会显示在屏幕右上角 + + + + ); +} + // 单图片查看器演示组件 function SingleImageViewerDemo(): JSX.Element { const viewerRef = useRef(null); @@ -370,10 +432,11 @@ function MultipleImagesViewerDemo(): JSX.Element { function ImageEditorDemo(): JSX.Element { const editorRef = useRef(null); const [showCrosshair, setShowCrosshair] = useState(false); - const [currentTool, setCurrentTool] = useState(Tool.Drag); + const [currentTool, setCurrentTool] = useState(EditorTool.Drag); const [annotations, setAnnotations] = useState([]); const [showMask, setShowMask] = useState(true); const [maskAlpha, setMaskAlpha] = useState(0.5); + const [scaleMode, setScaleMode] = useState<'wheel' | 'ctrlWheel'>('wheel'); const demoImage = 'https://picsum.photos/800/600'; const handleEditorReset = () => { @@ -438,6 +501,7 @@ function ImageEditorDemo(): JSX.Element { onAnnotationChanged={handleAnnotationChanged} enableMask={showMask} maskAlpha={maskAlpha} + scaleMode={scaleMode} /> @@ -447,13 +511,16 @@ function ImageEditorDemo(): JSX.Element { setShowCrosshair(!showCrosshair)}> {showCrosshair ? '隐藏准线' : '显示准线'} - setCurrentTool(currentTool === Tool.Drag ? Tool.Rect : Tool.Drag)}> - {currentTool === Tool.Drag ? '切换到矩形工具' : '切换到拖动工具'} + setCurrentTool(currentTool === EditorTool.Drag ? EditorTool.Rect : EditorTool.Drag)}> + {currentTool === EditorTool.Drag ? '切换到矩形工具' : '切换到拖动工具'} 清除标注 setShowMask(!showMask)}> {showMask ? '隐藏遮罩' : '显示遮罩'} + setScaleMode(mode => mode === 'wheel' ? 'ctrlWheel' : 'wheel')}> + {scaleMode === 'wheel' ? '切换到Ctrl+滚轮缩放' : '切换到滚轮缩放'} + 点击"清除标注"可以删除所有已绘制的矩形 点击"显示遮罩"可以显示/隐藏非标注区域的遮罩 使用滑块可以调整遮罩的透明度 + 点击"切换到Ctrl+滚轮缩放"可以切换缩放模式: + + 滚轮缩放模式:直接使用滚轮缩放,Ctrl+滚轮上下移动,Shift+滚轮左右移动 + Ctrl+滚轮缩放模式:使用Ctrl+滚轮缩放,直接滚轮上下移动,Shift+滚轮左右移动 + @@ -678,7 +750,13 @@ function RectMaskDemo(): JSX.Element { style={{ width: '100%', height: '100%', objectFit: 'cover' }} alt="Demo" /> - + 使用说明: @@ -698,35 +776,64 @@ function RectMaskDemo(): JSX.Element { // NativeDiv 演示组件 function NativeDivDemo(): JSX.Element { - const [events, setEvents] = useState>([]); + const [events, setEvents] = useState>([]); + const [wheelValue, setWheelValue] = useState(0); - const addEvent = (type: string, e: MouseEvent) => { + const addEvent = (type: string, details: string) => { const newEvent = { type, time: new Date().toLocaleTimeString(), - position: `(${e.clientX}, ${e.clientY})` + details }; - setEvents(prev => [newEvent, ...prev].slice(0, 5)); + setEvents(prev => [newEvent, ...prev].slice(0, 10)); }; const handleNativeMouseEnter = (e: MouseEvent) => { - addEvent('mouseenter', e); + addEvent('mouseenter', `位置:(${e.clientX}, ${e.clientY})`); }; const handleNativeMouseMove = (e: MouseEvent) => { - addEvent('mousemove', e); + addEvent('mousemove', `位置:(${e.clientX}, ${e.clientY})`); }; const handleNativeMouseLeave = (e: MouseEvent) => { - addEvent('mouseleave', e); + addEvent('mouseleave', `位置:(${e.clientX}, ${e.clientY})`); }; const handleNativeMouseDown = (e: MouseEvent) => { - addEvent('mousedown', e); + addEvent('mousedown', `位置:(${e.clientX}, ${e.clientY})`); }; const handleNativeClick = (e: MouseEvent) => { - addEvent('click', e); + addEvent('click', `位置:(${e.clientX}, ${e.clientY})`); + }; + + const handleNativeKeyDown = (e: KeyboardEvent) => { + addEvent('keydown', `按键:${e.key},键码:${e.keyCode},修饰键:${[ + e.ctrlKey && 'Ctrl', + e.shiftKey && 'Shift', + e.altKey && 'Alt', + e.metaKey && 'Meta' + ].filter(Boolean).join('+') || '无'}`); + }; + + const handleNativeKeyUp = (e: KeyboardEvent) => { + addEvent('keyup', `按键:${e.key},键码:${e.keyCode},修饰键:${[ + e.ctrlKey && 'Ctrl', + e.shiftKey && 'Shift', + e.altKey && 'Alt', + e.metaKey && 'Meta' + ].filter(Boolean).join('+') || '无'}`); + }; + + const handleNativeMouseWheel = (e: WheelEvent) => { + e.preventDefault(); + const delta = e.deltaY; + setWheelValue(prev => { + const newValue = prev + delta; + addEvent('mousewheel', `滚动值:${newValue},增量:${delta}`); + return newValue; + }); }; return ( @@ -734,21 +841,59 @@ function NativeDivDemo(): JSX.Element { 原生 Div 事件演示 - 与我互动! + + 与我互动! + + 点击获取焦点 + + 然后按键盘或滚动鼠标 + + + 滚动值:{wheelValue.toFixed(0)} + + 最近的事件: - + {events.map((event, index) => ( - - {event.type} - 时间:{event.time} - 位置:{event.position} + + {event.type} - {event.time} + + {event.details} ))} @@ -761,13 +906,384 @@ function NativeDivDemo(): JSX.Element { 在方块内移动鼠标来触发 mousemove 事件 将鼠标移出方块来触发 mouseleave 事件 点击方块来触发 click 事件 - 所有事件都会显示在下方的列表中,包括触发时间和鼠标位置 + 点击方块获取焦点后,按下键盘按键来触发 keydown 和 keyup 事件 + 在方块上滚动鼠标滚轮来旋转方块,并触发 mousewheel 事件 + 所有事件都会显示在下方的列表中,包括: + + 事件类型和触发时间 + 鼠标事件:鼠标位置坐标 + 键盘事件:按键名称、键码和修饰键状态 + 滚轮事件:滚动值和增量 + ); } +// 添加 SideToolBar 演示组件 +function SideToolBarDemo(): JSX.Element { + const [count, setCount] = useState(0); + const [selectedToolId, setSelectedToolId] = useState(undefined); + + const handleToolSelect = (id: string) => { + setSelectedToolId(id); + }; + + const tools: Array = [ + { + id: 'select', + icon: , + title: '选择工具', + selectable: true, + onClick: () => { + } + }, + { + id: 'crop', + icon: , + title: '裁剪工具', + selectable: true, + onClick: () => { + } + }, + 'separator', + { + id: 'add', + icon: , + title: '添加', + selectable: false, + onClick: () => { + setCount(prev => prev + 1); + } + }, + { + id: 'subtract', + icon: , + title: '减少', + selectable: false, + onClick: () => { + setCount(prev => prev - 1); + } + }, + 'separator', + { + id: 'reset', + icon: , + title: '重置', + selectable: false, + onClick: () => { + setCount(0); + } + } + ]; + + return ( + + 工具栏演示 + + + + 计数器: {count} + 使用加号和减号按钮来改变计数器的值 + 当前选中的工具ID: {selectedToolId || '无'} + 提示:只有选择工具和裁剪工具是可选择的,其他工具点击后不会保持选中状态 + + + + 使用说明: + + 工具栏包含了多个工具按钮和分隔符 + 鼠标悬停在按钮上会显示工具提示 + 点击按钮会触发相应的操作 + 只有 selectable=true 的工具(选择工具和裁剪工具)可以保持选中状态 + 其他工具(加号、减号、重置)点击后不会保持选中状态 + 分隔符用于分组相关的工具 + 工具栏支持垂直布局和自定义样式 + + + + ); +} + +// 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 = [ + // 无分类的单独属性 + { + title: '快速设置', + render: () => ( + + setChecked(!checked)}> + {checked ? '禁用' : '启用'} + + setNumber(prev => prev + 1)}> + 数值 +1 + + + ) + }, + // 无标题的全宽属性 + { + render: () => ( + + 提示:可以通过点击分类标题来展开或折叠属性组。 + + ) + }, + { + title: '连接设置', + properties: [ + { + title: '宠物名称', + render: () => ( + setPetName(e.target.value)} + style={{ width: '100%', padding: '4px' }} + /> + ) + } + ], + foldable: true + }, + // 又一个无分类的属性 + { + title: '状态', + render: () => ( + + {checked ? '已启用' : '已禁用'} + + ) + }, + { + title: '基本信息', + properties: [ + { + title: '喜欢的颜色', + render: () => ( + setFavoriteColor(e.target.value)} + style={{ width: '100%', padding: '4px' }} + /> + ) + }, + { + title: '名字', + render: () => ( + setFirstName(e.target.value)} + style={{ width: '100%', padding: '4px' }} + /> + ) + }, + // 分类中的无标题属性 + { + render: () => ( + + 这是基本信息分类中的一条说明文本。 + + ) + } + ], + foldable: true + }, + { + title: '其他设置', + properties: [ + { + title: '文本输入', + render: () => ( + setText(e.target.value)} + style={{ width: '100%', padding: '4px' }} + /> + ) + }, + { + title: '数字输入', + render: () => ( + setNumber(Number(e.target.value))} + style={{ width: '100px', padding: '4px' }} + /> + ) + }, + { + title: '颜色选择', + render: () => ( + + setColor(e.target.value)} + /> + {color} + + ) + }, + { + title: '复选框', + render: () => ( + setChecked(e.target.checked)} + /> + ) + }, + { + title: '超长文本', + render: () => ( + + {Array(100).fill("这是一个超长文本,用于测试PropertyGrid的折叠功能。").join("")} + + ) + } + ], + foldable: true + }, + // 最后添加一个备注输入框 + { + title: '备注', + render: () => ( + setNotes(e.target.value)} + style={{ + width: '100%', + padding: '4px', + minHeight: '60px', + resize: 'vertical' + }} + /> + ) + } + ]; + + return ( + + 属性网格演示 + + + + + 当前值: + +{JSON.stringify({ + petName, + favoriteColor, + firstName, + text, + number, + color, + checked, + selected, + notes +}, null, 2)} + + + + 使用说明: + + PropertyGrid 组件用于展示和编辑一组属性 + 属性可以按类别分组显示,也可以单独显示 + 属性可以有标题,也可以占据整行显示 + 每个类别有自己的标题栏 + 可折叠的类别可以通过点击标题栏来展开/折叠 + 支持在分类中和分类外混合显示属性 + 支持各种类型的输入控件:文本框、数字输入、颜色选择器等 + 可以通过 render 函数自定义属性的展示方式 + 所有的属性值改变都会实时反映在下方的当前值显示中 + + + + ); +} + +// ImageViewerModal 演示组件 +function ImageViewerModalDemo(): JSX.Element { + const { modal, openModal } = useImageViewerModal('示例图片'); + const demoImage = 'https://picsum.photos/1920/1080'; + + return ( + + 图片查看器模态框演示 + + openModal(demoImage)}>打开图片 + + {modal} + + 使用说明: + + 点击"打开图片"按钮显示图片查看器模态框 + 在模态框中可以使用鼠标拖动和滚轮缩放图片 + 使用底部工具栏的按钮可以: + + 放大/缩小图片 + 将图片缩放到适合容器大小 + 重置图片缩放 + + 点击右上角的 X 按钮或按 ESC 键关闭模态框 + 模态框支持设置标题,当前显示为"示例图片" + 模态框使用半透明背景,可以隐约看到背景内容 + 使用 useImageViewerModal Hook 可以更方便地集成此组件 + + + + ); +} + +const GlobalStyle = styled.div` + .modal-90w { + width: 90vw; + max-width: 90vw !important; + } +`; + // 主Demo组件 function Demo() { const navigate = useNavigate(); @@ -777,40 +1293,47 @@ function Demo() { const demos = [ { id: 'messageBox', name: '消息框', component: MessageBoxDemo }, { id: 'spinner', name: '加载动画', component: SpinnerDemo }, + { id: 'toast', name: 'Toast 消息', component: ToastMessageDemo }, { id: 'singleViewer', name: '单图片查看器', component: SingleImageViewerDemo }, { id: 'multipleViewer', name: '多图片查看器', component: MultipleImagesViewerDemo }, + { id: 'imageViewerModal', name: '图片查看器模态框', component: ImageViewerModalDemo }, { id: 'rectBox', name: '矩形框', component: RectBoxDemo }, { id: 'rectMask', name: '遮罩层', component: RectMaskDemo }, { id: 'imageEditor', name: '图片标注器', component: ImageEditorDemo }, - { id: 'nativeDiv', name: '原生 Div', component: NativeDivDemo } + { id: 'nativeDiv', name: '原生 Div', component: NativeDivDemo }, + { id: 'sideToolBar', name: '工具栏', component: SideToolBarDemo }, + { id: 'propertyGrid', name: '属性网格', component: PropertyGridDemo } ]; return ( - - - {demos.map(demo => ( - navigate(`/demo/${demo.id}`)} - > - {demo.name} - - ))} - - - - } /> + <> + + + {demos.map(demo => ( - } - /> + active={currentDemo === demo.id} + onClick={() => navigate(`/demo/${demo.id}`)} + > + {demo.name} + ))} - - - + + + + } /> + {demos.map(demo => ( + } + /> + ))} + + + + > ); } diff --git a/kotonebot-devtool/src/pages/ImageAnnotation/DragArea.tsx b/kotonebot-devtool/src/pages/ImageAnnotation/DragArea.tsx new file mode 100644 index 0000000..fd4d45b --- /dev/null +++ b/kotonebot-devtool/src/pages/ImageAnnotation/DragArea.tsx @@ -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 = ({ 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 ( + + {children} + + 拖放图片到此处 + + + ); +}; + +export default DragArea; diff --git a/kotonebot-devtool/src/pages/ImageAnnotation/ImageAnnotation.tsx b/kotonebot-devtool/src/pages/ImageAnnotation/ImageAnnotation.tsx new file mode 100644 index 0000000..a1c6286 --- /dev/null +++ b/kotonebot-devtool/src/pages/ImageAnnotation/ImageAnnotation.tsx @@ -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 = [ + { + id: 'open', + icon: , + title: '打开 (WebFileSystem)', + selectable: false, + }, + { + id: 'save', + icon: , + title: '保存 (WebFileSystem)', + selectable: false, + }, + { + id: 'upload', + icon: , + title: '上传 (Input)', + selectable: false, + }, + { + id: 'download', + icon: , + title: '下载 (Input)', + selectable: false, + }, + 'separator', + { + id: 'drag', + icon: , + title: '拖动工具 (V)', + selectable: true, + }, + { + id: 'rect', + icon: , + title: '矩形工具 (R)', + selectable: true, + }, +]; +const toolsMap: Record = { + 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) => void, + imageFileName?: string, + annotations?: Annotation[], + currentFileResult?: FileResult | null +) => { + const [croppedImageUrl, setCroppedImageUrl] = React.useState(''); + + 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 = [ + { + render: () => { + if (!image) return 图片加载中...; + if (!croppedImageUrl) return 裁剪中...; + return ( + + onImageClick(croppedImageUrl)} + /> + + ); + }, + }, + { + 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 = [ + { + 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 = []; + 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.Drag); + const [imageMetaData, updateImageMetaData] = useImmer({ + definitions: {}, + annotations: [], + }); + const [selectedAnnotation, setSelectedAnnotation] = useState(null); + const [isDirty, setIsDirty] = useState(false); + const [image, setImage] = useState(null); + const imageFileNameRef = useRef(''); + const { modal, openModal } = useImageViewerModal('裁剪预览'); + const [imageUrl, setImageUrl] = useState(SAMPLE_IMAGE_URL); + const { yesNo, MessageBoxComponent } = useMessageBox(); + const { showToast, ToastComponent } = useToast(); + const currentFileResult = useRef(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) => { + 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 = {displayName} ({name}); + } + }); + }; + + 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 ( + + + + + + + + + + + {modal} + {MessageBoxComponent} + {ToastComponent} + + ); +}; + +export default ImageAnnotation; diff --git a/kotonebot-devtool/src/pages/ImageAnnotation/types.ts b/kotonebot-devtool/src/pages/ImageAnnotation/types.ts new file mode 100644 index 0000000..b5d03dd --- /dev/null +++ b/kotonebot-devtool/src/pages/ImageAnnotation/types.ts @@ -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; + +export interface ImageMetaData { + definitions: Definitions; + annotations: Annotation[]; +} \ No newline at end of file diff --git a/kotonebot-devtool/src/routes.tsx b/kotonebot-devtool/src/routes.tsx index dfb4512..7c1cf9d 100644 --- a/kotonebot-devtool/src/routes.tsx +++ b/kotonebot-devtool/src/routes.tsx @@ -1,6 +1,7 @@ import { createBrowserRouter } from 'react-router-dom'; import { Home } from './pages/Home'; import Demo from './pages/Demo'; +import ImageAnnotation from './pages/ImageAnnotation/ImageAnnotation'; export const router = createBrowserRouter([ { @@ -11,4 +12,8 @@ export const router = createBrowserRouter([ path: '/demo/*', element: , }, + { + path: '/image-annotation', + element: , + }, ]); \ No newline at end of file diff --git a/kotonebot-devtool/src/utils/fileUtils.ts b/kotonebot-devtool/src/utils/fileUtils.ts index 6a47060..370ce64 100644 --- a/kotonebot-devtool/src/utils/fileUtils.ts +++ b/kotonebot-devtool/src/utils/fileUtils.ts @@ -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 => { - return new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onload = (event) => { - try { - const content = event.target?.result as string; - const data = JSON.parse(content); - if (Array.isArray(data) && data.every(item => - item.id && item.timestamp && item.message)) { - resolve(data as DebugRecord[]); - } else { - reject(new Error('文件格式不正确')); +export interface OpenFilesOptions { + accept?: string; + multiple?: boolean; +} + +export interface OpenFilesResult { + files: FileResult[]; +} + +/** + * 使用传统的 input 标签上传文件 + */ +export const openFileInput = async (options: OpenFilesOptions = {}): Promise => { + 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 => { + 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文件')); - } - }; - reader.onerror = () => reject(new Error('读取文件失败')); - reader.readAsText(file); - }); + + return { files: results }; + } catch (error) { + if ((error as Error).name === 'AbortError') { + return { files: [] }; + } + throw error; + } }; -export const formatTimestamp = (timestamp: number): string => { - const date = new Date(timestamp); - return date.toLocaleString('zh-CN', { - year: 'numeric', - month: '2-digit', - day: '2-digit', - hour: '2-digit', - minute: '2-digit', - second: '2-digit', - hour12: false - }); +/** + * 打开文件。优先使用 FileSystem API,失败时回退到上传方式 + */ +export const openFiles = async (options: OpenFilesOptions = {}): Promise => { + try { + // @ts-ignore - 检查是否支持 FileSystem API + if (window.showOpenFilePicker) { + return await openFileWFS(options); + } else { + return await openFileInput(options); + } + } catch (error) { + return await openFileInput(options); + } }; -export const generateUniqueId = (): string => { - return Date.now().toString(36) + Math.random().toString(36).substring(2); -}; \ No newline at end of file +/** + * 将文件读取为文本 + */ +export const readFileAsText = (file: File): Promise => { + 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 => { + 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 => { + 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 => { + 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 => { + 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 => { + if (typeof imageData === 'string' && imageData.startsWith('data:')) { + // 将 Base64 转换为 Blob + const response = await fetch(imageData); + imageData = await response.blob(); + } + await saveFileWFS(handle, imageData); +}; diff --git a/kotonebot-devtool/src/utils/imageUtils.ts b/kotonebot-devtool/src/utils/imageUtils.ts deleted file mode 100644 index 7d4e752..0000000 --- a/kotonebot-devtool/src/utils/imageUtils.ts +++ /dev/null @@ -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 => { - 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; - } -}; \ No newline at end of file
使用加号和减号按钮来改变计数器的值
当前选中的工具ID: {selectedToolId || '无'}
提示:只有选择工具和裁剪工具是可选择的,其他工具点击后不会保持选中状态
+{JSON.stringify({ + petName, + favoriteColor, + firstName, + text, + number, + color, + checked, + selected, + notes +}, null, 2)} +