feat(devtool): ScriptRecorder 页面
实现了一个脚本录制器页面,可以方便地截图 -> 标注 -> 保存 -> 生成代码。
This commit is contained in:
parent
9b7ecd9884
commit
5a200f81d0
|
@ -1,7 +1,11 @@
|
|||
.vite
|
||||
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
|
|
|
@ -11,11 +11,14 @@
|
|||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.0",
|
||||
"@types/node": "^22.12.0",
|
||||
"ace-builds": "^1.37.5",
|
||||
"ace-code": "^1.37.5",
|
||||
"bootstrap": "^5.3.3",
|
||||
"bootstrap-icons": "^1.11.3",
|
||||
"fabric": "^6.5.4",
|
||||
"immer": "^10.1.1",
|
||||
"react": "^18.3.1",
|
||||
"react-ace": "^13.0.0",
|
||||
"react-bootstrap": "^2.10.8",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-icons": "^5.4.0",
|
||||
|
@ -1840,6 +1843,19 @@
|
|||
"integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/ace-builds": {
|
||||
"version": "1.37.5",
|
||||
"resolved": "https://registry.npmjs.org/ace-builds/-/ace-builds-1.37.5.tgz",
|
||||
"integrity": "sha512-VMJ4Cnhq6L9dwvOCyuyyvQuiVTSwdZC7zDKJBBBJJax0wGQ7MvzQZFoi0gMmCm2I4Zuv/ZbtwU/dlglIhCNLhw=="
|
||||
},
|
||||
"node_modules/ace-code": {
|
||||
"version": "1.37.5",
|
||||
"resolved": "https://registry.npmjs.org/ace-code/-/ace-code-1.37.5.tgz",
|
||||
"integrity": "sha512-32IX6aoINIwx0eVnnfJ2lrRJFVZ7YotYNkyt4mAfhxLBKo73iCpi+jpy2FsMJPHpd7CMJeYwx7bQ+XYKXKKgxA==",
|
||||
"engines": {
|
||||
"node": ">= 0.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/acorn": {
|
||||
"version": "8.14.0",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz",
|
||||
|
@ -2363,6 +2379,11 @@
|
|||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/diff-match-patch": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/diff-match-patch/-/diff-match-patch-1.0.5.tgz",
|
||||
"integrity": "sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw=="
|
||||
},
|
||||
"node_modules/dom-helpers": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
|
||||
|
@ -3343,6 +3364,18 @@
|
|||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/lodash.get": {
|
||||
"version": "4.4.2",
|
||||
"resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz",
|
||||
"integrity": "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==",
|
||||
"deprecated": "This package is deprecated. Use the optional chaining (?.) operator instead."
|
||||
},
|
||||
"node_modules/lodash.isequal": {
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz",
|
||||
"integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==",
|
||||
"deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead."
|
||||
},
|
||||
"node_modules/lodash.merge": {
|
||||
"version": "4.6.2",
|
||||
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
|
||||
|
@ -3898,6 +3931,22 @@
|
|||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-ace": {
|
||||
"version": "13.0.0",
|
||||
"resolved": "https://registry.npmjs.org/react-ace/-/react-ace-13.0.0.tgz",
|
||||
"integrity": "sha512-PPk2O/ArHzDtbnK82QImfHYXwuiitRgHJf5AxwMQh9zciojbWsPmKJm1tMgWOYLCtGEz8/Dh3MxRxrXe7QcstQ==",
|
||||
"dependencies": {
|
||||
"ace-builds": "^1.36.3",
|
||||
"diff-match-patch": "^1.0.5",
|
||||
"lodash.get": "^4.4.2",
|
||||
"lodash.isequal": "^4.5.0",
|
||||
"prop-types": "^15.8.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^0.13.0 || ^0.14.0 || ^15.0.1 || ^16.0.0 || ^17.0.0 || ^18.0.0",
|
||||
"react-dom": "^0.13.0 || ^0.14.0 || ^15.0.1 || ^16.0.0 || ^17.0.0 || ^18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-bootstrap": {
|
||||
"version": "2.10.8",
|
||||
"resolved": "https://registry.npmjs.org/react-bootstrap/-/react-bootstrap-2.10.8.tgz",
|
||||
|
|
|
@ -13,11 +13,14 @@
|
|||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.0",
|
||||
"@types/node": "^22.12.0",
|
||||
"ace-builds": "^1.37.5",
|
||||
"ace-code": "^1.37.5",
|
||||
"bootstrap": "^5.3.3",
|
||||
"bootstrap-icons": "^1.11.3",
|
||||
"fabric": "^6.5.4",
|
||||
"immer": "^10.1.1",
|
||||
"react": "^18.3.1",
|
||||
"react-ace": "^13.0.0",
|
||||
"react-bootstrap": "^2.10.8",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-icons": "^5.4.0",
|
||||
|
|
|
@ -4,6 +4,16 @@ import { useEffect, useRef, ReactNode } from 'react';
|
|||
import NativeDiv from '../NativeDiv';
|
||||
type RectMode = 'move' | 'resize' | 'none';
|
||||
|
||||
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 Handle = styled(NativeDiv)<{ $mode: RectMode }>`
|
||||
position: absolute;
|
||||
width: 12px;
|
||||
|
@ -40,6 +50,7 @@ const ResizeArea = styled(NativeDiv)<{ position: string }>`
|
|||
const RectBoxContainer = styled(NativeDiv)<{ rect: RectPoints; mode: RectMode; $lineColor: string }>`
|
||||
position: absolute;
|
||||
border: 3px solid ${props => props.$lineColor};
|
||||
outline: 1px solid #eee;
|
||||
transition: border 0.1s ease-in;
|
||||
background: transparent;
|
||||
left: ${props => props.rect.x1}px;
|
||||
|
@ -251,7 +262,7 @@ function RectBox(props: RectBoxProps) {
|
|||
>
|
||||
{showRectTip && rectTip && (
|
||||
<RectTipContainer>
|
||||
{rectTip}
|
||||
{typeof rectTip === 'string' ? <Tip>{rectTip}</Tip> : rectTip}
|
||||
</RectTipContainer>
|
||||
)}
|
||||
<DragArea
|
||||
|
|
|
@ -1,58 +1,88 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, Children } from 'react';
|
||||
import styled from '@emotion/styled';
|
||||
import { useResizePanel } from '../hooks/useResizePanel';
|
||||
|
||||
interface SplitableProps {
|
||||
left: React.ReactNode;
|
||||
right: React.ReactNode;
|
||||
/** 默认是否折叠右侧面板 */
|
||||
children: React.ReactNode;
|
||||
/** 是否为垂直分割,默认为水平分割 */
|
||||
vertical?: boolean;
|
||||
/** 默认是否折叠最后一个面板 */
|
||||
defaultCollapsed?: boolean;
|
||||
/** 折叠状态改变时的回调 */
|
||||
onCollapsedChange?: (collapsed: boolean) => void;
|
||||
}
|
||||
|
||||
const Container = styled.div`
|
||||
const Container = styled.div<{ $vertical?: boolean }>`
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: ${props => props.$vertical ? 'column' : 'row'};
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
const LeftPanel = styled.div`
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
`;
|
||||
|
||||
const RightPanel = styled.div<{ $width: number; $collapsed: boolean }>`
|
||||
const Panel = styled.div<{
|
||||
$isLast: boolean,
|
||||
$width: number,
|
||||
$collapsed: boolean,
|
||||
$vertical?: boolean,
|
||||
$isResizable?: boolean
|
||||
}>`
|
||||
position: relative;
|
||||
width: ${props => props.$collapsed ? '0' : `${props.$width}px`};
|
||||
height: 100%;
|
||||
${props => props.$isResizable ? `
|
||||
${props.$vertical ? 'height' : 'width'}: ${props.$collapsed ? '0' : `${props.$width}px`};
|
||||
` : `
|
||||
flex: 1;
|
||||
min-${props.$vertical ? 'height' : 'width'}: 0;
|
||||
`}
|
||||
${props => props.$vertical ? 'width: 100%;' : 'height: 100%;'}
|
||||
background-color: #fff;
|
||||
border-left: 1px solid #dee2e6;
|
||||
${props => !props.$vertical && 'border-left: 1px solid #dee2e6;'}
|
||||
${props => props.$vertical && 'border-top: 1px solid #dee2e6;'}
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
const ResizeHandle = styled.div<{ $isResizing: boolean }>`
|
||||
const ResizeHandle = styled.div<{
|
||||
$isResizing: boolean,
|
||||
$vertical?: boolean
|
||||
}>`
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 4px;
|
||||
${props => props.$vertical ? `
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 0;
|
||||
height: 4px;
|
||||
cursor: row-resize;
|
||||
` : `
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 4px;
|
||||
cursor: col-resize;
|
||||
`}
|
||||
background-color: ${props => props.$isResizing ? '#0d6efd' : 'transparent'};
|
||||
transition: background-color 0.2s ease;
|
||||
cursor: col-resize;
|
||||
z-index: 1;
|
||||
z-index: 10;
|
||||
|
||||
&:hover {
|
||||
background-color: #0d6efd;
|
||||
}
|
||||
`;
|
||||
|
||||
const ToggleButton = styled.button<{ $collapsed: boolean }>`
|
||||
const ToggleButton = styled.button<{
|
||||
$collapsed: boolean,
|
||||
$vertical?: boolean
|
||||
}>`
|
||||
position: absolute;
|
||||
right: 1rem;
|
||||
top: 1rem;
|
||||
${props => props.$vertical ? `
|
||||
bottom: 1rem;
|
||||
left: 50%;
|
||||
transform: translateX(-50%) rotate(${props.$collapsed ? 90 : -90}deg);
|
||||
` : `
|
||||
right: 1rem;
|
||||
top: 1rem;
|
||||
transform: rotate(${props.$collapsed ? 180 : 0}deg);
|
||||
`}
|
||||
z-index: 2;
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
|
@ -63,7 +93,6 @@ const ToggleButton = styled.button<{ $collapsed: boolean }>`
|
|||
color: #6c757d;
|
||||
cursor: pointer;
|
||||
transition: transform 0.3s ease;
|
||||
transform: rotate(${props => props.$collapsed ? 180 : 0}deg);
|
||||
|
||||
&:hover {
|
||||
background-color: #6c757d;
|
||||
|
@ -72,31 +101,46 @@ const ToggleButton = styled.button<{ $collapsed: boolean }>`
|
|||
`;
|
||||
|
||||
export const Splitable: React.FC<SplitableProps> = ({
|
||||
left,
|
||||
right,
|
||||
children,
|
||||
vertical = false,
|
||||
defaultCollapsed = false,
|
||||
onCollapsedChange
|
||||
}) => {
|
||||
const {
|
||||
width,
|
||||
isResizing,
|
||||
handleResizeStart,
|
||||
handleResizeMove,
|
||||
handleResizeEnd,
|
||||
} = useResizePanel();
|
||||
|
||||
const childrenArray = Children.toArray(children);
|
||||
const [collapsed, setCollapsed] = useState(defaultCollapsed);
|
||||
const [panelSizes, setPanelSizes] = useState<number[]>(
|
||||
Array(childrenArray.length).fill(400)
|
||||
);
|
||||
|
||||
// 为每个可调整大小的面板创建一个 useResizePanel 实例
|
||||
const resizePanels = childrenArray.map((_, index) => {
|
||||
if (index === 0) return null; // 第一个面板不需要调整大小
|
||||
return useResizePanel({
|
||||
vertical,
|
||||
defaultWidth: 400,
|
||||
minWidth: 100,
|
||||
maxWidth: 800,
|
||||
onWidthChange: (width) => {
|
||||
setPanelSizes(prev => {
|
||||
const newSizes = [...prev];
|
||||
newSizes[index] = width;
|
||||
return newSizes;
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
handleResizeMove(e);
|
||||
resizePanels.forEach(panel => panel?.handleResizeMove(e));
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
handleResizeEnd();
|
||||
resizePanels.forEach(panel => panel?.handleResizeEnd());
|
||||
};
|
||||
|
||||
if (isResizing) {
|
||||
const isAnyPanelResizing = resizePanels.some(panel => panel?.isResizing);
|
||||
if (isAnyPanelResizing) {
|
||||
window.addEventListener('mousemove', handleMouseMove);
|
||||
window.addEventListener('mouseup', handleMouseUp);
|
||||
}
|
||||
|
@ -105,7 +149,7 @@ export const Splitable: React.FC<SplitableProps> = ({
|
|||
window.removeEventListener('mousemove', handleMouseMove);
|
||||
window.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
}, [isResizing, handleResizeMove, handleResizeEnd]);
|
||||
}, [resizePanels]);
|
||||
|
||||
const handleToggle = () => {
|
||||
setCollapsed(prev => {
|
||||
|
@ -116,23 +160,35 @@ export const Splitable: React.FC<SplitableProps> = ({
|
|||
};
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<ToggleButton
|
||||
onClick={handleToggle}
|
||||
$collapsed={collapsed}
|
||||
>
|
||||
<i className="bi bi-chevron-right" />
|
||||
</ToggleButton>
|
||||
<LeftPanel>
|
||||
{left}
|
||||
</LeftPanel>
|
||||
<RightPanel $width={width} $collapsed={collapsed}>
|
||||
<ResizeHandle
|
||||
onMouseDown={handleResizeStart}
|
||||
$isResizing={isResizing}
|
||||
/>
|
||||
{right}
|
||||
</RightPanel>
|
||||
<Container $vertical={vertical}>
|
||||
{childrenArray.map((child, index) => (
|
||||
<Panel
|
||||
key={index}
|
||||
$isLast={index === childrenArray.length - 1}
|
||||
$width={panelSizes[index]}
|
||||
$collapsed={index === childrenArray.length - 1 ? collapsed : false}
|
||||
$vertical={vertical}
|
||||
$isResizable={index !== 0}
|
||||
>
|
||||
{index > 0 && (
|
||||
<ResizeHandle
|
||||
onMouseDown={resizePanels[index]?.handleResizeStart}
|
||||
$isResizing={resizePanels[index]?.isResizing || false}
|
||||
$vertical={vertical}
|
||||
/>
|
||||
)}
|
||||
{index === childrenArray.length - 1 && (
|
||||
<ToggleButton
|
||||
onClick={handleToggle}
|
||||
$collapsed={collapsed}
|
||||
$vertical={vertical}
|
||||
>
|
||||
<i className="bi bi-chevron-right" />
|
||||
</ToggleButton>
|
||||
)}
|
||||
{child}
|
||||
</Panel>
|
||||
))}
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -0,0 +1,139 @@
|
|||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
const DropdownContainer = styled.div`
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
`;
|
||||
|
||||
const DropdownButton = styled.button<{ $isOpen: boolean }>`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 8px;
|
||||
background-color: white;
|
||||
border: 1px solid #e7e7e7;
|
||||
border-radius: 2px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
color: #424242;
|
||||
min-width: 100px;
|
||||
justify-content: space-between;
|
||||
|
||||
&:hover {
|
||||
background-color: #e0e0e0;
|
||||
}
|
||||
|
||||
&:active {
|
||||
background-color: #d0d0d0;
|
||||
}
|
||||
`;
|
||||
|
||||
const DropdownArrow = styled.span<{ $isOpen: boolean }>`
|
||||
border-style: solid;
|
||||
border-width: 4px 4px 0 4px;
|
||||
border-color: #424242 transparent transparent transparent;
|
||||
margin-left: 4px;
|
||||
transform: ${props => props.$isOpen ? 'rotate(180deg)' : 'rotate(0)'};
|
||||
transition: transform 0.2s ease;
|
||||
`;
|
||||
|
||||
const DropdownMenu = styled.div<{ $isOpen: boolean }>`
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
background-color: #ffffff;
|
||||
border: 1px solid #e7e7e7;
|
||||
border-radius: 2px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
z-index: 1000;
|
||||
display: ${props => props.$isOpen ? 'block' : 'none'};
|
||||
margin-top: 2px;
|
||||
`;
|
||||
|
||||
const MenuItem = styled.div<{ $selected?: boolean }>`
|
||||
padding: 6px 8px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
color: #424242;
|
||||
background-color: ${props => props.$selected ? '#e8e8e8' : 'transparent'};
|
||||
|
||||
&:hover {
|
||||
background-color: #f0f0f0;
|
||||
}
|
||||
`;
|
||||
|
||||
export interface DropdownOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface VSDropdownProps {
|
||||
options: DropdownOption[];
|
||||
value?: string;
|
||||
onChange?: (value: string) => void;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
export const VSDropdown: React.FC<VSDropdownProps> = ({
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
placeholder = '请选择...',
|
||||
className,
|
||||
style
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const selectedOption = options.find(opt => opt.value === value);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleSelect = (option: DropdownOption) => {
|
||||
onChange?.(option.value);
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<DropdownContainer ref={containerRef} className={className} style={style}>
|
||||
<DropdownButton
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
$isOpen={isOpen}
|
||||
>
|
||||
<span>{selectedOption?.label || placeholder}</span>
|
||||
<DropdownArrow $isOpen={isOpen} />
|
||||
</DropdownButton>
|
||||
<DropdownMenu $isOpen={isOpen}>
|
||||
{options.map((option) => (
|
||||
<MenuItem
|
||||
key={option.value}
|
||||
onClick={() => handleSelect(option)}
|
||||
$selected={option.value === value}
|
||||
>
|
||||
{option.label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</DropdownMenu>
|
||||
</DropdownContainer>
|
||||
);
|
||||
};
|
||||
|
||||
// 导出命名空间对象
|
||||
const Dropdown = Object.assign(VSDropdown, {});
|
||||
|
||||
export default Dropdown;
|
|
@ -0,0 +1,379 @@
|
|||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
// 基础工具接口
|
||||
interface BaseToolProps {
|
||||
id: string;
|
||||
title?: string;
|
||||
disabled?: boolean;
|
||||
selected?: boolean;
|
||||
}
|
||||
|
||||
// 按钮类型工具
|
||||
export interface ButtonTool extends BaseToolProps {
|
||||
type: 'button';
|
||||
icon: React.ReactNode;
|
||||
label?: string;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
// Checkbox类型工具
|
||||
export interface CheckboxTool extends BaseToolProps {
|
||||
type: 'checkbox';
|
||||
label: React.ReactNode;
|
||||
checked: boolean;
|
||||
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
}
|
||||
|
||||
// 分隔符类型
|
||||
export type SeparatorTool = 'separator';
|
||||
|
||||
// 添加 Dropdown 相关组件
|
||||
const DropdownContainer = styled.div`
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
`;
|
||||
|
||||
const DropdownButton = styled.button<{ $isOpen: boolean }>`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 8px;
|
||||
background-color: #f3f3f3;
|
||||
border: 1px solid #e7e7e7;
|
||||
border-radius: 2px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
color: #424242;
|
||||
min-width: 100px;
|
||||
justify-content: space-between;
|
||||
|
||||
&:hover {
|
||||
background-color: #e0e0e0;
|
||||
}
|
||||
|
||||
&:active {
|
||||
background-color: #d0d0d0;
|
||||
}
|
||||
`;
|
||||
|
||||
const DropdownArrow = styled.span<{ $isOpen: boolean }>`
|
||||
border-style: solid;
|
||||
border-width: 4px 4px 0 4px;
|
||||
border-color: #424242 transparent transparent transparent;
|
||||
margin-left: 4px;
|
||||
transform: ${props => props.$isOpen ? 'rotate(180deg)' : 'rotate(0)'};
|
||||
transition: transform 0.2s ease;
|
||||
`;
|
||||
|
||||
const DropdownMenu = styled.div<{ $isOpen: boolean }>`
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
background-color: #ffffff;
|
||||
border: 1px solid #e7e7e7;
|
||||
border-radius: 2px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
z-index: 1000;
|
||||
display: ${props => props.$isOpen ? 'block' : 'none'};
|
||||
margin-top: 2px;
|
||||
`;
|
||||
|
||||
const MenuItem = styled.div<{ $selected?: boolean }>`
|
||||
padding: 6px 8px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
color: #424242;
|
||||
background-color: ${props => props.$selected ? '#e8e8e8' : 'transparent'};
|
||||
|
||||
&:hover {
|
||||
background-color: #f0f0f0;
|
||||
}
|
||||
`;
|
||||
|
||||
export interface DropdownOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface ToolDropdownProps extends BaseToolProps {
|
||||
type: 'dropdown';
|
||||
options: DropdownOption[];
|
||||
value?: string;
|
||||
onChange?: (value: string) => void;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
// 合并所有工具类型
|
||||
export type Tool = ButtonTool | CheckboxTool | ToolDropdownProps;
|
||||
export type ToolBarItem = Tool | SeparatorTool | React.ReactNode;
|
||||
|
||||
interface VSToolBarProps {
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
align?: 'left' | 'center' | 'right';
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
// 样式组件定义
|
||||
const ToolBarContainer = styled.div<{ $align?: 'left' | 'center' | 'right' }>`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
background-color: #f3f3f3;
|
||||
border-bottom: 1px solid #e7e7e7;
|
||||
padding: 4px;
|
||||
gap: 2px;
|
||||
align-items: center;
|
||||
justify-content: ${props => {
|
||||
switch (props.$align) {
|
||||
case 'center':
|
||||
return 'center';
|
||||
case 'right':
|
||||
return 'flex-end';
|
||||
default:
|
||||
return 'flex-start';
|
||||
}
|
||||
}};
|
||||
`;
|
||||
|
||||
const StyledToolButton = styled.button<{ hasLabel?: boolean; $selected?: boolean; $disabled?: boolean }>`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 32px;
|
||||
border: none;
|
||||
background-color: ${props => props.$selected ? '#d0d0d0' : 'transparent'};
|
||||
cursor: ${props => props.$disabled ? 'not-allowed' : 'pointer'};
|
||||
position: relative;
|
||||
padding: ${props => props.hasLabel ? '0 8px' : '0'};
|
||||
min-width: ${props => props.hasLabel ? '0' : '32px'};
|
||||
color: ${props => props.$disabled ? '#a0a0a0' : '#424242'};
|
||||
font-size: 16px;
|
||||
gap: 4px;
|
||||
opacity: ${props => props.$disabled ? 0.6 : 1};
|
||||
|
||||
&:hover {
|
||||
background-color: ${props => props.$disabled ? 'transparent' : props.$selected ? '#c0c0c0' : '#e0e0e0'};
|
||||
}
|
||||
|
||||
&:active {
|
||||
background-color: ${props => props.$disabled ? 'transparent' : props.$selected ? '#b0b0b0' : '#d0d0d0'};
|
||||
}
|
||||
|
||||
&[data-tooltip]:hover::after {
|
||||
content: attr(data-tooltip);
|
||||
position: absolute;
|
||||
bottom: -24px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background-color: #333;
|
||||
color: white;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
z-index: 1000;
|
||||
}
|
||||
`;
|
||||
|
||||
const ButtonLabel = styled.span`
|
||||
font-size: 13px;
|
||||
`;
|
||||
|
||||
const CheckboxContainer = styled.label`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 32px;
|
||||
padding: 0 8px;
|
||||
cursor: pointer;
|
||||
color: #424242;
|
||||
gap: 6px;
|
||||
user-select: none;
|
||||
|
||||
&:hover {
|
||||
background-color: #e0e0e0;
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledCheckbox = styled.input`
|
||||
appearance: none;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 1px solid #a0a0a0;
|
||||
border-radius: 2px;
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
background: white;
|
||||
|
||||
&:checked {
|
||||
background: white;
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 4px;
|
||||
top: 1px;
|
||||
width: 6px;
|
||||
height: 10px;
|
||||
border: solid #000;
|
||||
border-width: 0 2px 2px 0;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: #808080;
|
||||
}
|
||||
`;
|
||||
|
||||
const Separator = styled.div`
|
||||
width: 1px;
|
||||
height: 24px;
|
||||
margin: 4px 4px;
|
||||
background-color: #e0e0e0;
|
||||
`;
|
||||
|
||||
// 组件接口定义
|
||||
interface ToolButtonProps {
|
||||
id: string;
|
||||
icon?: React.ReactNode;
|
||||
label?: React.ReactNode;
|
||||
title?: string;
|
||||
onClick?: () => void;
|
||||
selected?: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface ToolCheckboxProps {
|
||||
id: string;
|
||||
label: React.ReactNode;
|
||||
checked: boolean;
|
||||
title?: string;
|
||||
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
}
|
||||
|
||||
// 子组件定义
|
||||
export const ToolButton: React.FC<ToolButtonProps> = ({
|
||||
id,
|
||||
icon,
|
||||
label,
|
||||
title,
|
||||
onClick,
|
||||
selected,
|
||||
disabled
|
||||
}) => {
|
||||
return (
|
||||
<StyledToolButton
|
||||
hasLabel={!!label}
|
||||
onClick={disabled ? undefined : onClick}
|
||||
data-tooltip={label ? undefined : title}
|
||||
$selected={selected}
|
||||
$disabled={disabled}
|
||||
disabled={disabled}
|
||||
>
|
||||
{icon}
|
||||
{label && <ButtonLabel>{label}</ButtonLabel>}
|
||||
</StyledToolButton>
|
||||
);
|
||||
};
|
||||
|
||||
export const ToolCheckbox: React.FC<ToolCheckboxProps> = ({
|
||||
id,
|
||||
label,
|
||||
checked,
|
||||
onChange
|
||||
}) => {
|
||||
return (
|
||||
<CheckboxContainer>
|
||||
<StyledCheckbox
|
||||
type="checkbox"
|
||||
id={id}
|
||||
checked={checked}
|
||||
onChange={onChange}
|
||||
/>
|
||||
<ButtonLabel>{label}</ButtonLabel>
|
||||
</CheckboxContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export const ToolSeparator: React.FC = () => <Separator />;
|
||||
|
||||
// 添加 ToolDropdown 组件
|
||||
export const ToolDropdown: React.FC<ToolDropdownProps> = ({
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
placeholder = '请选择...',
|
||||
title
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const selectedOption = options.find(opt => opt.value === value);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleSelect = (option: DropdownOption) => {
|
||||
onChange?.(option.value);
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<DropdownContainer ref={containerRef} data-tooltip={title}>
|
||||
<DropdownButton
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
$isOpen={isOpen}
|
||||
>
|
||||
<span>{selectedOption?.label || placeholder}</span>
|
||||
<DropdownArrow $isOpen={isOpen} />
|
||||
</DropdownButton>
|
||||
<DropdownMenu $isOpen={isOpen}>
|
||||
{options.map((option) => (
|
||||
<MenuItem
|
||||
key={option.value}
|
||||
onClick={() => handleSelect(option)}
|
||||
$selected={option.value === value}
|
||||
>
|
||||
{option.label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</DropdownMenu>
|
||||
</DropdownContainer>
|
||||
);
|
||||
};
|
||||
|
||||
// 主组件
|
||||
export const VSToolBar: React.FC<VSToolBarProps> = ({
|
||||
children,
|
||||
className,
|
||||
align = 'left',
|
||||
style
|
||||
}) => {
|
||||
return (
|
||||
<ToolBarContainer className={className} $align={align} style={style}>
|
||||
{children}
|
||||
</ToolBarContainer>
|
||||
);
|
||||
};
|
||||
|
||||
// 导出命名空间对象
|
||||
const ToolBar = Object.assign(VSToolBar, {
|
||||
Button: ToolButton,
|
||||
Checkbox: ToolCheckbox,
|
||||
Separator: ToolSeparator,
|
||||
Dropdown: ToolDropdown
|
||||
});
|
||||
|
||||
export default ToolBar;
|
|
@ -0,0 +1,58 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
|
||||
export type ThemeMode = 'dark' | 'light' | 'system';
|
||||
|
||||
interface UseDarkModeOptions<T> {
|
||||
whenDark: T;
|
||||
whenLight: T;
|
||||
}
|
||||
|
||||
interface UseDarkModeResult<T> {
|
||||
theme: T;
|
||||
isDark: boolean;
|
||||
themeMode: ThemeMode;
|
||||
setThemeMode: (mode: ThemeMode) => void;
|
||||
toggleTheme: () => void;
|
||||
}
|
||||
|
||||
export function useDarkMode<T>(options: UseDarkModeOptions<T>): UseDarkModeResult<T> {
|
||||
const { whenDark: darkTheme, whenLight: lightTheme } = options;
|
||||
|
||||
const [themeMode, setThemeMode] = useState<ThemeMode>('system');
|
||||
const [systemIsDark, setSystemIsDark] = useState(() =>
|
||||
window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
);
|
||||
|
||||
// 切换主题
|
||||
const toggleTheme = () => {
|
||||
setThemeMode(themeMode === 'dark' ? 'light' : 'dark');
|
||||
};
|
||||
|
||||
// 监听系统主题变化
|
||||
useEffect(() => {
|
||||
const darkModeMediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
|
||||
const handleThemeChange = (e: MediaQueryListEvent | MediaQueryList) => {
|
||||
setSystemIsDark(e.matches);
|
||||
};
|
||||
|
||||
handleThemeChange(darkModeMediaQuery);
|
||||
darkModeMediaQuery.addEventListener('change', handleThemeChange);
|
||||
|
||||
return () => {
|
||||
darkModeMediaQuery.removeEventListener('change', handleThemeChange);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 根据当前模式和系统主题计算实际的主题状态
|
||||
const isDark = themeMode === 'system' ? systemIsDark : themeMode === 'dark';
|
||||
const theme = isDark ? darkTheme : lightTheme;
|
||||
|
||||
return {
|
||||
theme,
|
||||
isDark,
|
||||
themeMode,
|
||||
setThemeMode,
|
||||
toggleTheme,
|
||||
};
|
||||
}
|
|
@ -0,0 +1,186 @@
|
|||
import { useState, useCallback } from 'react';
|
||||
import Modal from 'react-bootstrap/Modal';
|
||||
import Button from 'react-bootstrap/Button';
|
||||
import Form from 'react-bootstrap/Form';
|
||||
|
||||
export interface FormField {
|
||||
type: 'text' | 'number' | 'password' | 'email' | 'textarea';
|
||||
label: string;
|
||||
name: string;
|
||||
required?: boolean;
|
||||
defaultValue?: string;
|
||||
validator?: (value: string) => boolean | string;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
interface FormModalProps {
|
||||
show: boolean;
|
||||
onHide: () => void;
|
||||
onSubmit: (formData: Record<string, string>) => void;
|
||||
title: string;
|
||||
fields: FormField[];
|
||||
}
|
||||
|
||||
function FormModal({ show, onHide, onSubmit, title, fields }: FormModalProps) {
|
||||
const [formData, setFormData] = useState<Record<string, string>>(() => {
|
||||
const initialData: Record<string, string> = {};
|
||||
fields.forEach(field => {
|
||||
initialData[field.name] = field.defaultValue || '';
|
||||
});
|
||||
return initialData;
|
||||
});
|
||||
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
|
||||
// 添加重置表单的函数
|
||||
const resetForm = useCallback(() => {
|
||||
const initialData: Record<string, string> = {};
|
||||
fields.forEach(field => {
|
||||
initialData[field.name] = field.defaultValue || '';
|
||||
});
|
||||
setFormData(initialData);
|
||||
setErrors({});
|
||||
}, [fields]);
|
||||
|
||||
// 在 Modal 隐藏时重置表单
|
||||
const handleHide = () => {
|
||||
resetForm();
|
||||
onHide();
|
||||
};
|
||||
|
||||
const handleChange = (name: string, value: string) => {
|
||||
setFormData(prev => ({ ...prev, [name]: value }));
|
||||
// 清除错误
|
||||
if (errors[name]) {
|
||||
setErrors(prev => {
|
||||
const newErrors = { ...prev };
|
||||
delete newErrors[name];
|
||||
return newErrors;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
// 验证表单
|
||||
const newErrors: Record<string, string> = {};
|
||||
|
||||
fields.forEach(field => {
|
||||
const value = formData[field.name];
|
||||
|
||||
// 必填项验证
|
||||
if (field.required && !value) {
|
||||
newErrors[field.name] = '此字段为必填项';
|
||||
return;
|
||||
}
|
||||
|
||||
// 自定义验证
|
||||
if (field.validator && value) {
|
||||
const validationResult = field.validator(value);
|
||||
if (typeof validationResult === 'string') {
|
||||
newErrors[field.name] = validationResult;
|
||||
} else if (!validationResult) {
|
||||
newErrors[field.name] = '输入值无效';
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (Object.keys(newErrors).length > 0) {
|
||||
setErrors(newErrors);
|
||||
return;
|
||||
}
|
||||
|
||||
onSubmit(formData);
|
||||
resetForm(); // 提交成功后也重置表单
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal show={show} onHide={handleHide}>
|
||||
<Form onSubmit={handleSubmit}>
|
||||
<Modal.Header closeButton>
|
||||
<Modal.Title>{title}</Modal.Title>
|
||||
</Modal.Header>
|
||||
<Modal.Body>
|
||||
{fields.map(field => (
|
||||
<Form.Group key={field.name} className="mb-3">
|
||||
<Form.Label>{field.label}</Form.Label>
|
||||
{field.type === 'textarea' ? (
|
||||
<Form.Control
|
||||
as="textarea"
|
||||
rows={3}
|
||||
value={formData[field.name]}
|
||||
onChange={e => handleChange(field.name, e.target.value)}
|
||||
isInvalid={!!errors[field.name]}
|
||||
placeholder={field.placeholder}
|
||||
/>
|
||||
) : (
|
||||
<Form.Control
|
||||
type={field.type}
|
||||
value={formData[field.name]}
|
||||
onChange={e => handleChange(field.name, e.target.value)}
|
||||
isInvalid={!!errors[field.name]}
|
||||
placeholder={field.placeholder}
|
||||
/>
|
||||
)}
|
||||
<Form.Control.Feedback type="invalid">
|
||||
{errors[field.name]}
|
||||
</Form.Control.Feedback>
|
||||
</Form.Group>
|
||||
))}
|
||||
</Modal.Body>
|
||||
<Modal.Footer>
|
||||
<Button variant="secondary" onClick={handleHide}>
|
||||
取消
|
||||
</Button>
|
||||
<Button variant="primary" type="submit">
|
||||
确定
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export function useFormModal(fields: FormField[]) {
|
||||
const [show, setShow] = useState(false);
|
||||
const [resolveRef, setResolveRef] = useState<((value: Record<string, string> | null) => void) | null>(null);
|
||||
|
||||
const handleHide = useCallback(() => {
|
||||
setShow(false);
|
||||
if (resolveRef) {
|
||||
resolveRef(null);
|
||||
setResolveRef(null);
|
||||
}
|
||||
}, [resolveRef]);
|
||||
|
||||
const handleSubmit = useCallback((formData: Record<string, string>) => {
|
||||
setShow(false);
|
||||
if (resolveRef) {
|
||||
resolveRef(formData);
|
||||
setResolveRef(null);
|
||||
}
|
||||
}, [resolveRef]);
|
||||
|
||||
const showModal = useCallback(async (title: string = '表单'): Promise<Record<string, string> | null> => {
|
||||
return new Promise(resolve => {
|
||||
setResolveRef(() => resolve);
|
||||
setShow(true);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const modal = (
|
||||
<FormModal
|
||||
show={show}
|
||||
onHide={handleHide}
|
||||
onSubmit={handleSubmit}
|
||||
title="表单"
|
||||
fields={fields}
|
||||
/>
|
||||
);
|
||||
|
||||
return {
|
||||
modal,
|
||||
show: showModal
|
||||
};
|
||||
}
|
|
@ -0,0 +1,78 @@
|
|||
import { useCallback, useEffect } from 'react';
|
||||
|
||||
export interface HotkeyConfig {
|
||||
key: string;
|
||||
ctrl?: boolean;
|
||||
shift?: boolean;
|
||||
alt?: boolean;
|
||||
/** 是否是单按键(不需要修饰键) */
|
||||
single?: boolean;
|
||||
/** 是否在输入框中也触发 */
|
||||
triggerInInput?: boolean;
|
||||
callback: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 热键 Hook
|
||||
* @param hotkeys 热键配置数组
|
||||
* @example
|
||||
* ```tsx
|
||||
* // 使用示例
|
||||
* useHotkey([
|
||||
* // Ctrl + S
|
||||
* { key: 's', ctrl: true, callback: handleSave },
|
||||
* // 单按 V 键
|
||||
* { key: 'v', single: true, callback: () => setTool('select') },
|
||||
* ]);
|
||||
* ```
|
||||
*/
|
||||
const useHotkey = (hotkeys: HotkeyConfig[]) => {
|
||||
const handleKeyDown = useCallback((e: KeyboardEvent) => {
|
||||
// 获取当前按键的小写形式
|
||||
const key = e.key.toLowerCase();
|
||||
|
||||
// 检查是否在输入框中
|
||||
const isInInput = e.target instanceof HTMLInputElement ||
|
||||
e.target instanceof HTMLTextAreaElement;
|
||||
|
||||
// 遍历所有热键配置
|
||||
for (const hotkey of hotkeys) {
|
||||
// 如果在输入框中且不允许在输入框触发,则跳过
|
||||
if (isInInput && !hotkey.triggerInInput) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 检查按键是否匹配
|
||||
if (key !== hotkey.key.toLowerCase()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 对于单按键,检查是否有修饰键被按下
|
||||
if (hotkey.single) {
|
||||
if (e.ctrlKey || e.shiftKey || e.altKey) {
|
||||
continue;
|
||||
}
|
||||
hotkey.callback();
|
||||
return;
|
||||
}
|
||||
|
||||
// 对于组合键,检查修饰键是否匹配
|
||||
if (e.ctrlKey === !!hotkey.ctrl &&
|
||||
e.shiftKey === !!hotkey.shift &&
|
||||
e.altKey === !!hotkey.alt) {
|
||||
e.preventDefault(); // 阻止默认行为
|
||||
hotkey.callback();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}, [hotkeys]);
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [handleKeyDown]);
|
||||
};
|
||||
|
||||
export default useHotkey;
|
|
@ -0,0 +1,123 @@
|
|||
import { useState } from "react";
|
||||
import { Annotation } from "../components/ImageEditor/types";
|
||||
import { useImmer } from "use-immer";
|
||||
|
||||
export type DefinitionType = 'template' | 'ocr' | 'color' | 'hint-box';
|
||||
|
||||
export interface Definition {
|
||||
/** 最终出现在 R.py 中的名称 */
|
||||
name: string;
|
||||
/** 显示在调试器与调试输出中的名称 */
|
||||
displayName: string;
|
||||
type: DefinitionType;
|
||||
/** 标注 ID */
|
||||
annotationId: string;
|
||||
}
|
||||
|
||||
|
||||
export interface TemplateDefinition extends Definition {
|
||||
type: 'template';
|
||||
/**
|
||||
* 是否将这个模板的矩形范围作为运行时
|
||||
* 执行模板寻找函数时的提示范围。
|
||||
*
|
||||
* 若为 true,则运行时会先在这个范围内寻找,
|
||||
* 如果没找到,再在整张截图中寻找。
|
||||
*/
|
||||
useHintRect: boolean
|
||||
}
|
||||
|
||||
|
||||
export type Definitions = Record<string, Definition>;
|
||||
|
||||
export interface ImageMetaData {
|
||||
definitions: Definitions;
|
||||
annotations: Annotation[];
|
||||
}
|
||||
|
||||
function useImageMetaData(data?: ImageMetaData) {
|
||||
const [imageMetaData, updateImageMetaData] = useImmer<ImageMetaData>(data || {
|
||||
definitions: {},
|
||||
annotations: [],
|
||||
});
|
||||
|
||||
const Definitions = {
|
||||
get: (annotationId: string) => {
|
||||
return imageMetaData.definitions[annotationId];
|
||||
},
|
||||
add: (definition: Definition) => {
|
||||
updateImageMetaData(draft => {
|
||||
draft.definitions[definition.annotationId] = definition;
|
||||
});
|
||||
},
|
||||
update: (definition: Partial<Definition> & { annotationId: string }) => {
|
||||
updateImageMetaData(draft => {
|
||||
Object.assign(draft.definitions[definition.annotationId], definition);
|
||||
});
|
||||
},
|
||||
remove: (annotationId: string) => {
|
||||
updateImageMetaData(draft => {
|
||||
delete draft.definitions[annotationId];
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
const Annotations = {
|
||||
get: (id: string) => {
|
||||
return imageMetaData.annotations.find(a => a.id === id);
|
||||
},
|
||||
add: (annotation: Annotation) => {
|
||||
updateImageMetaData(draft => {
|
||||
draft.annotations.push(annotation);
|
||||
|
||||
});
|
||||
},
|
||||
update: (annotation: Partial<Annotation> & { id: string }) => {
|
||||
updateImageMetaData(draft => {
|
||||
const oldAnnotation = draft.annotations.find(a => a.id === annotation.id);
|
||||
if (oldAnnotation) {
|
||||
Object.assign(oldAnnotation, annotation);
|
||||
}
|
||||
});
|
||||
},
|
||||
remove: (id: string) => {
|
||||
updateImageMetaData(draft => {
|
||||
draft.annotations = draft.annotations.filter(a => a.id !== id);
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
const clear = () => {
|
||||
updateImageMetaData(draft => {
|
||||
draft.definitions = {};
|
||||
draft.annotations = [];
|
||||
});
|
||||
};
|
||||
|
||||
const load = (data: Partial<ImageMetaData>) => {
|
||||
updateImageMetaData(draft => {
|
||||
if (data.definitions) {
|
||||
draft.definitions = { ...data.definitions };
|
||||
}
|
||||
if (data.annotations) {
|
||||
draft.annotations = [...data.annotations];
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
imageMetaData,
|
||||
/** 对图像定义数据的操作对象 */
|
||||
Definitions,
|
||||
/** 对图像标注数据的操作对象 */
|
||||
Annotations,
|
||||
/** 清空图像元数据 */
|
||||
clear,
|
||||
/** 载入图像元数据 */
|
||||
load,
|
||||
/** 检查是否没有任何标注和定义 */
|
||||
isEmpty: () => imageMetaData.annotations.length === 0 && Object.keys(imageMetaData.definitions).length === 0,
|
||||
};
|
||||
}
|
||||
|
||||
export default useImageMetaData;
|
|
@ -4,6 +4,7 @@ interface UseResizePanelProps {
|
|||
minWidth?: number;
|
||||
maxWidth?: number;
|
||||
defaultWidth?: number;
|
||||
vertical?: boolean;
|
||||
onWidthChange?: (width: number) => void;
|
||||
}
|
||||
|
||||
|
@ -11,31 +12,33 @@ export const useResizePanel = ({
|
|||
minWidth = 200,
|
||||
maxWidth = 800,
|
||||
defaultWidth = 400,
|
||||
vertical = false,
|
||||
onWidthChange
|
||||
}: UseResizePanelProps = {}) => {
|
||||
const [width, setWidth] = useState(defaultWidth);
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
const startXRef = useRef<number>(0);
|
||||
const startPosRef = useRef<number>(0);
|
||||
const startWidthRef = useRef<number>(0);
|
||||
|
||||
const handleResizeStart = useCallback((event: React.MouseEvent) => {
|
||||
event.preventDefault();
|
||||
setIsResizing(true);
|
||||
startXRef.current = event.clientX;
|
||||
startPosRef.current = vertical ? event.clientY : event.clientX;
|
||||
startWidthRef.current = width;
|
||||
document.body.style.cursor = 'col-resize';
|
||||
document.body.style.cursor = vertical ? 'row-resize' : 'col-resize';
|
||||
document.body.style.userSelect = 'none';
|
||||
}, [width]);
|
||||
}, [width, vertical]);
|
||||
|
||||
const handleResizeMove = useCallback((event: MouseEvent) => {
|
||||
if (!isResizing) return;
|
||||
|
||||
const dx = startXRef.current - event.clientX;
|
||||
const currentPos = vertical ? event.clientY : event.clientX;
|
||||
const dx = startPosRef.current - currentPos;
|
||||
const newWidth = Math.min(Math.max(startWidthRef.current + dx, minWidth), maxWidth);
|
||||
|
||||
setWidth(newWidth);
|
||||
onWidthChange?.(newWidth);
|
||||
}, [isResizing, minWidth, maxWidth, onWidthChange]);
|
||||
}, [isResizing, minWidth, maxWidth, onWidthChange, vertical]);
|
||||
|
||||
const handleResizeEnd = useCallback(() => {
|
||||
setIsResizing(false);
|
||||
|
|
|
@ -12,10 +12,14 @@ 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 { SideToolBar } from '../components/SideToolBar';
|
||||
import type { Tool as SideBarTool } from '../components/SideToolBar';
|
||||
import PropertyGrid, { Property, PropertyCategory } from '../components/PropertyGrid';
|
||||
import ImageViewerModal, { useImageViewerModal } from '../components/ImageViewerModal';
|
||||
import { useToast } from '../components/ToastMessage';
|
||||
import VSToolBar, { Tool, ToolBarItem, DropdownOption } from '../components/VSToolBar';
|
||||
import { Splitable } from '../components/Splitable';
|
||||
import { useFormModal } from '../hooks/useFormModal';
|
||||
|
||||
// 布局相关的样式组件
|
||||
const DemoContainer = styled.div`
|
||||
|
@ -29,6 +33,8 @@ const Sidebar = styled.div`
|
|||
background-color: #f5f5f5;
|
||||
padding: 20px;
|
||||
border-right: 1px solid #ddd;
|
||||
overflow-y: auto;
|
||||
height: 100vh;
|
||||
`;
|
||||
|
||||
const Content = styled.div`
|
||||
|
@ -930,7 +936,7 @@ function SideToolBarDemo(): JSX.Element {
|
|||
setSelectedToolId(id);
|
||||
};
|
||||
|
||||
const tools: Array<Tool | 'separator'> = [
|
||||
const tools: Array<SideBarTool | 'separator'> = [
|
||||
{
|
||||
id: 'select',
|
||||
icon: <i className="bi bi-hand-index"></i>,
|
||||
|
@ -1277,6 +1283,404 @@ function ImageViewerModalDemo(): JSX.Element {
|
|||
);
|
||||
}
|
||||
|
||||
// VSToolBar 演示组件
|
||||
function VSToolBarDemo(): JSX.Element {
|
||||
const [count, setCount] = useState(0);
|
||||
const [currentAlign, setCurrentAlign] = useState<'left' | 'center' | 'right'>('left');
|
||||
const [autoSave, setAutoSave] = useState(false);
|
||||
const [selectedCPU, setSelectedCPU] = useState<string>('any');
|
||||
const [selectedOS, setSelectedOS] = useState<string>('win');
|
||||
const [selectedTool, setSelectedTool] = useState<string>('select');
|
||||
|
||||
const cpuOptions: DropdownOption[] = [
|
||||
{ value: 'any', label: 'Any CPU' },
|
||||
{ value: 'x86', label: 'x86' },
|
||||
{ value: 'x64', label: 'x64' },
|
||||
{ value: 'arm', label: 'ARM' },
|
||||
{ value: 'arm64', label: 'ARM64' }
|
||||
];
|
||||
|
||||
const osOptions: DropdownOption[] = [
|
||||
{ value: 'win', label: 'Windows' },
|
||||
{ value: 'mac', label: 'macOS' },
|
||||
{ value: 'linux', label: 'Linux' }
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2>VS工具栏演示</h2>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '20px', marginTop: '20px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<select
|
||||
value={currentAlign}
|
||||
onChange={(e) => setCurrentAlign(e.target.value as 'left' | 'center' | 'right')}
|
||||
style={{
|
||||
padding: '6px 12px',
|
||||
borderRadius: '4px',
|
||||
border: '1px solid #ccc',
|
||||
fontSize: '14px'
|
||||
}}
|
||||
>
|
||||
<option value="left">左对齐</option>
|
||||
<option value="center">居中对齐</option>
|
||||
<option value="right">右对齐</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<VSToolBar align={currentAlign}>
|
||||
<VSToolBar.Button
|
||||
id="select"
|
||||
icon={<i className="bi bi-cursor" />}
|
||||
label="选择"
|
||||
selected={selectedTool === 'select'}
|
||||
onClick={() => setSelectedTool('select')}
|
||||
/>
|
||||
<VSToolBar.Button
|
||||
id="move"
|
||||
icon={<i className="bi bi-arrows-move" />}
|
||||
label="移动"
|
||||
selected={selectedTool === 'move'}
|
||||
onClick={() => setSelectedTool('move')}
|
||||
/>
|
||||
<VSToolBar.Button
|
||||
id="rotate"
|
||||
icon={<i className="bi bi-arrow-clockwise" />}
|
||||
label="旋转"
|
||||
selected={selectedTool === 'rotate'}
|
||||
onClick={() => setSelectedTool('rotate')}
|
||||
/>
|
||||
<VSToolBar.Separator />
|
||||
<VSToolBar.Button
|
||||
id="debug"
|
||||
icon={<i className="bi bi-bug" />}
|
||||
label="调试"
|
||||
disabled={count === 0}
|
||||
onClick={() => alert('开始调试')}
|
||||
/>
|
||||
<VSToolBar.Button
|
||||
id="run"
|
||||
icon={<i className="bi bi-play-fill" />}
|
||||
label="运行"
|
||||
disabled={count === 0}
|
||||
onClick={() => alert('开始运行')}
|
||||
/>
|
||||
<VSToolBar.Separator />
|
||||
<VSToolBar.Dropdown
|
||||
id="cpu"
|
||||
type="dropdown"
|
||||
options={cpuOptions}
|
||||
value={selectedCPU}
|
||||
onChange={setSelectedCPU}
|
||||
title="选择CPU架构"
|
||||
/>
|
||||
<VSToolBar.Dropdown
|
||||
id="os"
|
||||
type="dropdown"
|
||||
options={osOptions}
|
||||
value={selectedOS}
|
||||
onChange={setSelectedOS}
|
||||
title="选择操作系统"
|
||||
/>
|
||||
<VSToolBar.Separator />
|
||||
<VSToolBar.Button
|
||||
id="add"
|
||||
icon={<i className="bi bi-plus-lg" />}
|
||||
title="增加计数"
|
||||
onClick={() => setCount(prev => prev + 1)}
|
||||
/>
|
||||
<VSToolBar.Button
|
||||
id="subtract"
|
||||
icon={<i className="bi bi-dash-lg" />}
|
||||
title="减少计数"
|
||||
disabled={count === 0}
|
||||
onClick={() => setCount(prev => prev - 1)}
|
||||
/>
|
||||
<VSToolBar.Separator />
|
||||
<VSToolBar.Button
|
||||
id="reset"
|
||||
icon={<i className="bi bi-arrow-clockwise" />}
|
||||
label="重置计数"
|
||||
disabled={count === 0}
|
||||
onClick={() => setCount(0)}
|
||||
/>
|
||||
<div style={{
|
||||
marginLeft: '8px',
|
||||
fontSize: '13px',
|
||||
color: '#666'
|
||||
}}>
|
||||
计数: {count}
|
||||
</div>
|
||||
<VSToolBar.Separator />
|
||||
<VSToolBar.Checkbox
|
||||
id="auto-save"
|
||||
label="自动保存"
|
||||
checked={autoSave}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setAutoSave(e.target.checked)}
|
||||
/>
|
||||
</VSToolBar>
|
||||
|
||||
<div style={{
|
||||
marginTop: '20px',
|
||||
padding: '15px',
|
||||
backgroundColor: '#f5f5f5',
|
||||
borderRadius: '4px'
|
||||
}}>
|
||||
<h4 style={{ margin: '0 0 10px 0' }}>当前选择:</h4>
|
||||
<div>当前工具: {selectedTool}</div>
|
||||
<div>CPU架构: {cpuOptions.find(opt => opt.value === selectedCPU)?.label}</div>
|
||||
<div>操作系统: {osOptions.find(opt => opt.value === selectedOS)?.label}</div>
|
||||
<div>计数器: {count}</div>
|
||||
<div>自动保存: {autoSave ? '开启' : '关闭'}</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: '20px' }}>
|
||||
<h3>使用说明:</h3>
|
||||
<ul>
|
||||
<li>工具栏支持三种对齐方式:</li>
|
||||
<ul>
|
||||
<li>左对齐(默认):工具栏项目靠左排列</li>
|
||||
<li>居中对齐:工具栏项目居中排列</li>
|
||||
<li>右对齐:工具栏项目靠右排列</li>
|
||||
</ul>
|
||||
<li>工具栏项目包括:</li>
|
||||
<ul>
|
||||
<li>可选择的工具按钮(选择、移动、旋转)- 点击后会保持选中状态</li>
|
||||
<li>禁用状态的按钮(当计数为0时,调试和运行按钮被禁用)</li>
|
||||
<li>带图标和文字的按钮</li>
|
||||
<li>只带图标的按钮(如"增加"、"减少")- 悬停时显示提示文本</li>
|
||||
<li>下拉菜单(如"CPU架构"、"操作系统")</li>
|
||||
<li>分隔符用于分组相关功能</li>
|
||||
<li>复选框用于开关类选项</li>
|
||||
<li>自定义内容(如计数器显示)</li>
|
||||
</ul>
|
||||
<li>按钮状态:</li>
|
||||
<ul>
|
||||
<li>选中状态:背景色变深,hover时颜色更深</li>
|
||||
<li>禁用状态:文字变灰,透明度降低,不可点击</li>
|
||||
<li>普通状态:透明背景,hover时背景色变浅</li>
|
||||
</ul>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 添加 Splitable 演示组件
|
||||
function SplitableDemo(): JSX.Element {
|
||||
const [direction, setDirection] = useState<'horizontal' | 'vertical'>('horizontal');
|
||||
const [panelCount, setPanelCount] = useState(2);
|
||||
|
||||
const panels = Array.from({ length: panelCount }, (_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
height: '100%',
|
||||
padding: '1rem',
|
||||
backgroundColor: i % 2 === 0 ? '#f8f9fa' : '#e9ecef',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '1.25rem',
|
||||
color: '#495057'
|
||||
}}
|
||||
>
|
||||
面板 {i + 1}
|
||||
</div>
|
||||
));
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2>可分割面板演示</h2>
|
||||
<ControlPanel>
|
||||
<Button onClick={() => setDirection(d => d === 'horizontal' ? 'vertical' : 'horizontal')}>
|
||||
切换方向 ({direction === 'horizontal' ? '水平' : '垂直'})
|
||||
</Button>
|
||||
<Button onClick={() => setPanelCount(c => Math.min(c + 1, 5))}>
|
||||
添加面板
|
||||
</Button>
|
||||
<Button onClick={() => setPanelCount(c => Math.max(c - 1, 2))}>
|
||||
移除面板
|
||||
</Button>
|
||||
</ControlPanel>
|
||||
<div style={{
|
||||
height: '500px',
|
||||
border: '1px solid #dee2e6',
|
||||
borderRadius: '4px',
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
<Splitable vertical={direction === 'vertical'}>
|
||||
{panels}
|
||||
</Splitable>
|
||||
</div>
|
||||
<div>
|
||||
<h3>使用说明:</h3>
|
||||
<ul>
|
||||
<li>可以通过按钮切换面板的分割方向:</li>
|
||||
<ul>
|
||||
<li>水平方向:面板左右排列</li>
|
||||
<li>垂直方向:面板上下排列</li>
|
||||
</ul>
|
||||
<li>可以动态添加或移除面板(2-5个)</li>
|
||||
<li>每个面板之间都有一个可拖动的分隔条</li>
|
||||
<li>最后一个面板可以通过右上角(或底部)的按钮折叠/展开</li>
|
||||
<li>拖动分隔条时会显示蓝色的指示器</li>
|
||||
<li>面板内容会自动适应容器大小</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 添加 FormModal 演示组件
|
||||
function FormModalDemo(): JSX.Element {
|
||||
const [results, setResults] = useState<Array<{ time: string; data: Record<string, string> | null }>>([]);
|
||||
|
||||
const simpleForm = useFormModal([
|
||||
{
|
||||
type: 'text',
|
||||
label: '用户名',
|
||||
name: 'username',
|
||||
required: true,
|
||||
placeholder: '请输入用户名'
|
||||
},
|
||||
{
|
||||
type: 'password',
|
||||
label: '密码',
|
||||
name: 'password',
|
||||
required: true,
|
||||
validator: (value) => value.length >= 6 || '密码长度至少为6位',
|
||||
placeholder: '请输入密码'
|
||||
}
|
||||
]);
|
||||
|
||||
const advancedForm = useFormModal([
|
||||
{
|
||||
type: 'text',
|
||||
label: '姓名',
|
||||
name: 'name',
|
||||
required: true,
|
||||
placeholder: '请输入姓名'
|
||||
},
|
||||
{
|
||||
type: 'email',
|
||||
label: '邮箱',
|
||||
name: 'email',
|
||||
required: true,
|
||||
validator: (value) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value) || '请输入有效的邮箱地址',
|
||||
placeholder: 'example@domain.com'
|
||||
},
|
||||
{
|
||||
type: 'number',
|
||||
label: '年龄',
|
||||
name: 'age',
|
||||
validator: (value) => {
|
||||
const age = parseInt(value);
|
||||
return (age >= 18 && age <= 100) || '年龄必须在18-100之间';
|
||||
},
|
||||
placeholder: '请输入年龄'
|
||||
},
|
||||
{
|
||||
type: 'textarea',
|
||||
label: '简介',
|
||||
name: 'bio',
|
||||
placeholder: '请输入个人简介'
|
||||
}
|
||||
]);
|
||||
|
||||
const handleShowSimpleForm = async () => {
|
||||
const result = await simpleForm.show('登录');
|
||||
setResults(prev => [{
|
||||
time: new Date().toLocaleTimeString(),
|
||||
data: result
|
||||
}, ...prev]);
|
||||
};
|
||||
|
||||
const handleShowAdvancedForm = async () => {
|
||||
const result = await advancedForm.show('用户注册');
|
||||
setResults(prev => [{
|
||||
time: new Date().toLocaleTimeString(),
|
||||
data: result
|
||||
}, ...prev]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2>表单对话框演示</h2>
|
||||
<ControlPanel>
|
||||
<Button onClick={handleShowSimpleForm}>显示简单表单</Button>
|
||||
<Button onClick={handleShowAdvancedForm}>显示高级表单</Button>
|
||||
</ControlPanel>
|
||||
{simpleForm.modal}
|
||||
{advancedForm.modal}
|
||||
|
||||
<div style={{ marginTop: '20px' }}>
|
||||
<h3>表单提交历史:</h3>
|
||||
{results.length === 0 ? (
|
||||
<div style={{ color: '#666', padding: '10px' }}>
|
||||
暂无提交记录
|
||||
</div>
|
||||
) : (
|
||||
<div style={{
|
||||
maxHeight: '300px',
|
||||
overflowY: 'auto',
|
||||
border: '1px solid #dee2e6',
|
||||
borderRadius: '4px'
|
||||
}}>
|
||||
{results.map((result, index) => (
|
||||
<div key={index} style={{
|
||||
padding: '10px',
|
||||
borderBottom: index < results.length - 1 ? '1px solid #dee2e6' : 'none',
|
||||
backgroundColor: index % 2 === 0 ? '#f8f9fa' : 'white'
|
||||
}}>
|
||||
<div style={{ marginBottom: '5px', color: '#666' }}>
|
||||
提交时间:{result.time}
|
||||
</div>
|
||||
{result.data === null ? (
|
||||
<div style={{ color: '#dc3545' }}>用户取消了操作</div>
|
||||
) : (
|
||||
<pre style={{ margin: 0, fontSize: '0.9em' }}>
|
||||
{JSON.stringify(result.data, null, 2)}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3>使用说明:</h3>
|
||||
<ul>
|
||||
<li>简单表单演示:</li>
|
||||
<ul>
|
||||
<li>包含基本的用户名和密码字段</li>
|
||||
<li>用户名为必填项</li>
|
||||
<li>密码必须至少6位</li>
|
||||
</ul>
|
||||
<li>高级表单演示:</li>
|
||||
<ul>
|
||||
<li>包含更多字段类型:文本、邮箱、数字、文本区域</li>
|
||||
<li>演示了不同类型的验证:</li>
|
||||
<ul>
|
||||
<li>必填项验证</li>
|
||||
<li>邮箱格式验证</li>
|
||||
<li>数字范围验证</li>
|
||||
</ul>
|
||||
</ul>
|
||||
<li>表单特性:</li>
|
||||
<ul>
|
||||
<li>支持字段验证和错误提示</li>
|
||||
<li>可以通过点击取消按钮或关闭图标来取消操作</li>
|
||||
<li>提交的数据会显示在下方的历史记录中</li>
|
||||
<li>支持异步/Promise方式获取表单结果</li>
|
||||
</ul>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const GlobalStyle = styled.div`
|
||||
.modal-90w {
|
||||
width: 90vw;
|
||||
|
@ -1294,6 +1698,7 @@ function Demo() {
|
|||
{ id: 'messageBox', name: '消息框', component: MessageBoxDemo },
|
||||
{ id: 'spinner', name: '加载动画', component: SpinnerDemo },
|
||||
{ id: 'toast', name: 'Toast 消息', component: ToastMessageDemo },
|
||||
{ id: 'formModal', name: '表单对话框', component: FormModalDemo },
|
||||
{ id: 'singleViewer', name: '单图片查看器', component: SingleImageViewerDemo },
|
||||
{ id: 'multipleViewer', name: '多图片查看器', component: MultipleImagesViewerDemo },
|
||||
{ id: 'imageViewerModal', name: '图片查看器模态框', component: ImageViewerModalDemo },
|
||||
|
@ -1302,7 +1707,9 @@ function Demo() {
|
|||
{ id: 'imageEditor', name: '图片标注器', component: ImageEditorDemo },
|
||||
{ id: 'nativeDiv', name: '原生 Div', component: NativeDivDemo },
|
||||
{ id: 'sideToolBar', name: '工具栏', component: SideToolBarDemo },
|
||||
{ id: 'propertyGrid', name: '属性网格', component: PropertyGridDemo }
|
||||
{ id: 'propertyGrid', name: '属性网格', component: PropertyGridDemo },
|
||||
{ id: 'vsToolBar', name: 'VS工具栏', component: VSToolBarDemo },
|
||||
{ id: 'splitable', name: '可分割面板', component: SplitableDemo }
|
||||
];
|
||||
|
||||
return (
|
||||
|
|
|
@ -246,35 +246,31 @@ export const MainLayout: React.FC = () => {
|
|||
</ToolbarButton>
|
||||
</ToolbarContainer>
|
||||
<MainContent>
|
||||
<Splitable
|
||||
left={
|
||||
<ViewerContainer>
|
||||
<MultipleImagesViewer
|
||||
currentGroup={{
|
||||
mainIndex: 0,
|
||||
images: !isLocalMode ?
|
||||
(records[index]?.image.value.map(item => 'http://' + host + '/api/read_memory?key=' + item))
|
||||
: (records[index]?.image.value.map(item => localImageMap?.get(item) ?? 'error')),
|
||||
}}
|
||||
groupIndex={index}
|
||||
imageIndex={imageIndex}
|
||||
groupCount={records.length}
|
||||
onGotoGroup={(i) => {
|
||||
setIndex(i);
|
||||
setImageIndex(0);
|
||||
}}
|
||||
onGotoImage={setImageIndex}
|
||||
/>
|
||||
</ViewerContainer>
|
||||
}
|
||||
right={
|
||||
<InfoPanel
|
||||
name={records[index]?.name}
|
||||
details={records[index]?.details}
|
||||
imagesMap={isLocalMode ? localImageMap : undefined}
|
||||
<Splitable>
|
||||
<ViewerContainer>
|
||||
<MultipleImagesViewer
|
||||
currentGroup={{
|
||||
mainIndex: 0,
|
||||
images: !isLocalMode ?
|
||||
(records[index]?.image.value.map(item => 'http://' + host + '/api/read_memory?key=' + item))
|
||||
: (records[index]?.image.value.map(item => localImageMap?.get(item) ?? 'error')),
|
||||
}}
|
||||
groupIndex={index}
|
||||
imageIndex={imageIndex}
|
||||
groupCount={records.length}
|
||||
onGotoGroup={(i) => {
|
||||
setIndex(i);
|
||||
setImageIndex(0);
|
||||
}}
|
||||
onGotoImage={setImageIndex}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</ViewerContainer>
|
||||
<InfoPanel
|
||||
name={records[index]?.name}
|
||||
details={records[index]?.details}
|
||||
imagesMap={isLocalMode ? localImageMap : undefined}
|
||||
/>
|
||||
</Splitable>
|
||||
</MainContent>
|
||||
</LayoutContainer>
|
||||
);
|
||||
|
|
|
@ -5,12 +5,11 @@ import PropertyGrid, { Property, PropertyCategory } from '../../components/Prope
|
|||
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 useImageMetaData, { Definition, DefinitionType, ImageMetaData, TemplateDefinition, Definitions } from '../../hooks/useImageMetaData';
|
||||
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';
|
||||
|
||||
|
@ -167,8 +166,12 @@ const usePropertyGridData = (
|
|||
}
|
||||
|
||||
const definition = definitions[selectedAnnotation.id];
|
||||
if (!definition) {
|
||||
return [];
|
||||
}
|
||||
const { x1, y1, x2, y2 } = selectedAnnotation.data;
|
||||
|
||||
|
||||
const generalProperties: Array<PropertyCategory | Property> = [
|
||||
{
|
||||
render: () => {
|
||||
|
@ -278,10 +281,7 @@ const usePropertyGridData = (
|
|||
|
||||
const ImageAnnotation: React.FC = () => {
|
||||
const [currentTool, setCurrentTool] = useState<EditorTool>(EditorTool.Drag);
|
||||
const [imageMetaData, updateImageMetaData] = useImmer<ImageMetaData>({
|
||||
definitions: {},
|
||||
annotations: [],
|
||||
});
|
||||
const { imageMetaData, Definitions, Annotations, clear, load } = useImageMetaData();
|
||||
const [selectedAnnotation, setSelectedAnnotation] = useState<Annotation | null>(null);
|
||||
const [isDirty, setIsDirty] = useState(false);
|
||||
const [image, setImage] = useState<HTMLImageElement | null>(null);
|
||||
|
@ -306,14 +306,11 @@ const ImageAnnotation: React.FC = () => {
|
|||
setImageUrl(newImageUrl);
|
||||
if (shouldClearMetaData) {
|
||||
// 只有在不是同时加载 meta 数据时才清空标注
|
||||
updateImageMetaData(draft => {
|
||||
draft.annotations = [];
|
||||
draft.definitions = {};
|
||||
});
|
||||
clear();
|
||||
setSelectedAnnotation(null);
|
||||
setIsDirty(false);
|
||||
}
|
||||
}, []);
|
||||
}, [clear]);
|
||||
|
||||
const handleAnnotationChange = (e: AnnotationChangedEvent) => {
|
||||
if (e.type === 'add') {
|
||||
|
@ -325,40 +322,26 @@ const ImageAnnotation: React.FC = () => {
|
|||
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;
|
||||
});
|
||||
Annotations.add(e.annotation);
|
||||
Definitions.add({
|
||||
name: '',
|
||||
displayName: '',
|
||||
type: type,
|
||||
annotationId: e.annotation.id,
|
||||
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);
|
||||
});
|
||||
Annotations.update(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);
|
||||
}
|
||||
});
|
||||
Annotations.remove(e.annotation.id);
|
||||
if (selectedAnnotation?.id === e.annotation.id) {
|
||||
setSelectedAnnotation(null);
|
||||
updateImageMetaData(draft => {
|
||||
delete draft.definitions[e.annotation.id];
|
||||
});
|
||||
Definitions.remove(e.annotation.id);
|
||||
}
|
||||
setIsDirty(true);
|
||||
}
|
||||
|
@ -396,11 +379,9 @@ const ImageAnnotation: React.FC = () => {
|
|||
if (jsonFile) {
|
||||
currentFileResult.current = jsonFile;
|
||||
try {
|
||||
const metaData = await readFileAsJSON(jsonFile.file);
|
||||
updateImageMetaData(draft => {
|
||||
draft.annotations = metaData?.annotations || [];
|
||||
draft.definitions = metaData?.definitions || {};
|
||||
});
|
||||
const metaData = await readFileAsJSON(jsonFile.file) as ImageMetaData;
|
||||
// 使用统一的 load 方法载入数据
|
||||
load(metaData);
|
||||
} catch (error) {
|
||||
console.error('Failed to parse JSON file:', error);
|
||||
throw new Error('JSON文件格式错误,无法加载。');
|
||||
|
@ -421,8 +402,9 @@ const ImageAnnotation: React.FC = () => {
|
|||
});
|
||||
showToast('danger', '加载失败', '无法加载文件');
|
||||
}
|
||||
}, [handleImageLoad, isDirty, yesNo, showToast, updateImageMetaData]);
|
||||
}, [handleImageLoad, isDirty, yesNo, showToast, load]);
|
||||
|
||||
console.log(imageMetaData);
|
||||
const handleUpload = useCallback(async () => {
|
||||
await handleOpen(false);
|
||||
}, [handleOpen]);
|
||||
|
@ -483,16 +465,21 @@ const ImageAnnotation: React.FC = () => {
|
|||
};
|
||||
|
||||
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>;
|
||||
}
|
||||
Definitions.update({
|
||||
...changes,
|
||||
annotationId: id
|
||||
});
|
||||
|
||||
// 更新标注的提示文本
|
||||
const definition = imageMetaData.definitions[id];
|
||||
const displayName = changes.displayName || definition.displayName;
|
||||
const name = changes.name || definition.name;
|
||||
if (definition) {
|
||||
Annotations.update({
|
||||
id,
|
||||
tip: <Tip>{displayName} ({name})</Tip>
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = useCallback((e: KeyboardEvent) => {
|
||||
|
|
|
@ -1,34 +0,0 @@
|
|||
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[];
|
||||
}
|
|
@ -0,0 +1,570 @@
|
|||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import styled from '@emotion/styled';
|
||||
import VSToolBar from '../../components/VSToolBar';
|
||||
import ImageEditor, { AnnotationChangedEvent } from '../../components/ImageEditor/ImageEditor';
|
||||
import { MdDevices, MdPlayArrow, MdSearch, MdTouchApp, MdTextFields, MdTextSnippet, MdCropFree, MdRefresh, MdPause, MdBackHand, MdCheck, MdClose, MdEdit, MdContentCopy, MdContentCut, MdFolder } from 'react-icons/md';
|
||||
import AceEditor from 'react-ace';
|
||||
import { Splitable } from '../../components/Splitable';
|
||||
import { Tool, Annotation } from '../../components/ImageEditor/types';
|
||||
import { create } from 'zustand';
|
||||
|
||||
// 引入 ace 编辑器的主题和语言模式
|
||||
import 'ace-builds/src-noconflict/mode-python';
|
||||
import 'ace-builds/src-noconflict/theme-monokai';
|
||||
import 'ace-builds/src-noconflict/theme-chrome';
|
||||
import 'ace-builds/src-noconflict/ext-language_tools';
|
||||
import { css } from '@emotion/react';
|
||||
import useImageMetaData, { Definition, DefinitionType, ImageMetaData, TemplateDefinition } from '../../hooks/useImageMetaData';
|
||||
import { useDarkMode } from '../../hooks/useDarkMode';
|
||||
import { useDebugClient } from '../../store/debugStore';
|
||||
import useLatestCallback from '../../hooks/useLatestCallback';
|
||||
import useHotkey from '../../hooks/useHotkey';
|
||||
import { useFormModal } from '../../hooks/useFormModal';
|
||||
import { openDirectory } from '../../utils/fileUtils';
|
||||
|
||||
const Container = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
`;
|
||||
|
||||
const ImageViewerWrapper = styled.div`
|
||||
height: 100%;
|
||||
`;
|
||||
|
||||
const CodeEditorWrapper = styled.div`
|
||||
height: 100%;
|
||||
background-color: #1e1e1e;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
`;
|
||||
|
||||
type ScriptRecorderTool = 'drag' | 'template' | 'template-click' | 'ocr' | 'ocr-click' | 'hint-box';
|
||||
|
||||
interface ScriptRecorderState {
|
||||
code: string;
|
||||
tool: ScriptRecorderTool;
|
||||
autoScreenshot: boolean;
|
||||
connected: boolean;
|
||||
imageUrl: string;
|
||||
inEditMode: boolean;
|
||||
directoryHandle: FileSystemDirectoryHandle | null;
|
||||
|
||||
imageMetaDataObject: ReturnType<typeof useImageMetaData> | null;
|
||||
setImageMetaDataObject: (imageMetaData: ReturnType<typeof useImageMetaData>) => void;
|
||||
|
||||
setCode: (code: string) => void;
|
||||
setTool: (tool: ScriptRecorderTool) => void;
|
||||
setAutoScreenshot: (auto: boolean) => void;
|
||||
setConnected: (connected: boolean) => void;
|
||||
setImageUrl: (url: string) => void;
|
||||
|
||||
setDirectoryHandle: (handle: FileSystemDirectoryHandle | null) => void;
|
||||
enterEditMode: () => void;
|
||||
exitEditMode: () => void;
|
||||
}
|
||||
|
||||
|
||||
const useScriptRecorderStore = create<ScriptRecorderState>((set) => ({
|
||||
code: '',
|
||||
tool: 'drag',
|
||||
autoScreenshot: true,
|
||||
connected: false,
|
||||
imageUrl: '',
|
||||
inEditMode: false,
|
||||
directoryHandle: null,
|
||||
|
||||
imageMetaDataObject: null,
|
||||
setImageMetaDataObject: (imageMetaData) => set({ imageMetaDataObject: imageMetaData }),
|
||||
|
||||
setCode: (code) => set({ code }),
|
||||
setTool: (tool) => set({ tool }),
|
||||
setAutoScreenshot: (auto) => set({ autoScreenshot: auto }),
|
||||
setConnected: (connected) => set({ connected }),
|
||||
setImageUrl: (url) => set({ imageUrl: url }),
|
||||
setDirectoryHandle: (handle) => set({ directoryHandle: handle }),
|
||||
enterEditMode: () => set({ inEditMode: true, autoScreenshot: false }),
|
||||
exitEditMode: () => set({ inEditMode: false }),
|
||||
}));
|
||||
|
||||
|
||||
interface ToolConfigItem {
|
||||
code?: (d: Definition, a: Annotation) => string;
|
||||
}
|
||||
|
||||
const ToolConfig: Record<ScriptRecorderTool, ToolConfigItem> = {
|
||||
'drag': {
|
||||
},
|
||||
'template': {
|
||||
code: (d: Definition) => `image.find(R.${d.name})`,
|
||||
|
||||
},
|
||||
'template-click': {
|
||||
code: (d: Definition) =>
|
||||
`if image.find(R.${d.name}):\n\tdevice.click()`,
|
||||
},
|
||||
'ocr': {
|
||||
code: (d: Definition) => `ocr.ocr(R.${d.name})`,
|
||||
},
|
||||
'ocr-click': {
|
||||
code: (d: Definition) =>
|
||||
`if ocr.ocr(R.${d.name}):\n\tdevice.click()`,
|
||||
},
|
||||
'hint-box': {
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
|
||||
interface ViewToolBarProps {
|
||||
onOpenDirectory: () => void;
|
||||
}
|
||||
|
||||
const ViewToolBar: React.FC<ViewToolBarProps> = ({
|
||||
onOpenDirectory,
|
||||
}) => {
|
||||
const { connected, autoScreenshot, setAutoScreenshot, directoryHandle, enterEditMode } = useScriptRecorderStore();
|
||||
|
||||
return (
|
||||
<VSToolBar align='center'>
|
||||
<VSToolBar.Button
|
||||
id="open-directory"
|
||||
icon={<MdFolder />}
|
||||
label="打开文件夹"
|
||||
onClick={onOpenDirectory}
|
||||
/>
|
||||
<VSToolBar.Button
|
||||
id="device-status"
|
||||
icon={<MdDevices style={{ color: connected ? undefined : '#ff4444' }} />}
|
||||
label={<span style={{ color: connected ? undefined : '#ff4444' }}>{connected ? "设备已连接" : "设备未连接"}</span>}
|
||||
onClick={() => { }}
|
||||
/>
|
||||
<VSToolBar.Button
|
||||
icon={autoScreenshot ? <MdPause /> : <MdPlayArrow />}
|
||||
id="auto-update"
|
||||
label={autoScreenshot ? "自动截图 ON" : "自动截图 OFF"}
|
||||
onClick={() => setAutoScreenshot(!autoScreenshot)}
|
||||
/>
|
||||
<VSToolBar.Separator />
|
||||
<VSToolBar.Button
|
||||
id="enter-edit"
|
||||
icon={<MdEdit />}
|
||||
label="进入编辑"
|
||||
onClick={enterEditMode}
|
||||
disabled={!directoryHandle}
|
||||
/>
|
||||
</VSToolBar>
|
||||
);
|
||||
};
|
||||
|
||||
interface EditToolBarProps {
|
||||
onClear: () => void;
|
||||
}
|
||||
|
||||
const EditToolBar: React.FC<EditToolBarProps> = ({
|
||||
onClear,
|
||||
}) => {
|
||||
const { tool, setTool, exitEditMode, imageUrl, directoryHandle, imageMetaDataObject } = useScriptRecorderStore();
|
||||
const { modal, show: showFormModal } = useFormModal([
|
||||
{
|
||||
type: 'text',
|
||||
label: '名称',
|
||||
name: 'name',
|
||||
required: true,
|
||||
placeholder: '请输入文件名'
|
||||
}
|
||||
]);
|
||||
|
||||
const handleToolChange = (newTool: ScriptRecorderTool) => {
|
||||
if (newTool === tool)
|
||||
setTool('drag');
|
||||
else
|
||||
setTool(newTool);
|
||||
};
|
||||
|
||||
const handleConfirm = async () => {
|
||||
if (!directoryHandle) return;
|
||||
|
||||
const result = await showFormModal('保存文件');
|
||||
if (!result) return;
|
||||
|
||||
const name = result.name;
|
||||
|
||||
try {
|
||||
// 保存图片
|
||||
const imageResponse = await fetch(imageUrl);
|
||||
const imageBlob = await imageResponse.blob();
|
||||
const imageFile = await directoryHandle.getFileHandle(`${name}.png`, { create: true });
|
||||
const imageWritable = await imageFile.createWritable();
|
||||
await imageWritable.write(imageBlob);
|
||||
await imageWritable.close();
|
||||
|
||||
// 保存元数据
|
||||
const metaFile = await directoryHandle.getFileHandle(`${name}.png.json`, { create: true });
|
||||
const metaWritable = await metaFile.createWritable();
|
||||
await metaWritable.write(JSON.stringify(imageMetaDataObject?.imageMetaData, null, 2));
|
||||
await metaWritable.close();
|
||||
|
||||
// 清理并退出
|
||||
imageMetaDataObject?.clear();
|
||||
exitEditMode();
|
||||
} catch (error) {
|
||||
console.error('保存文件失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<VSToolBar align='center'>
|
||||
{modal}
|
||||
<VSToolBar.Button
|
||||
id="drag"
|
||||
icon={<MdBackHand />}
|
||||
label="拖动 (V)"
|
||||
selected={tool === 'drag'}
|
||||
onClick={() => handleToolChange('drag')}
|
||||
/>
|
||||
<VSToolBar.Button
|
||||
id="templ-find"
|
||||
icon={<MdSearch />}
|
||||
label="找图 (T)"
|
||||
selected={tool === 'template'}
|
||||
onClick={() => handleToolChange('template')}
|
||||
/>
|
||||
<VSToolBar.Button
|
||||
id="templ-click"
|
||||
icon={<MdTouchApp />}
|
||||
label="找图并点击 (R)"
|
||||
selected={tool === 'template-click'}
|
||||
onClick={() => handleToolChange('template-click')}
|
||||
/>
|
||||
<VSToolBar.Button
|
||||
id="ocr-find"
|
||||
icon={<MdTextFields />}
|
||||
label="OCR (S)"
|
||||
selected={tool === 'ocr'}
|
||||
onClick={() => handleToolChange('ocr')}
|
||||
/>
|
||||
<VSToolBar.Button
|
||||
id="ocr-click"
|
||||
icon={<MdTextSnippet />}
|
||||
label="OCR 并点击 (A)"
|
||||
selected={tool === 'ocr-click'}
|
||||
onClick={() => handleToolChange('ocr-click')}
|
||||
/>
|
||||
<VSToolBar.Button
|
||||
id="hint-box"
|
||||
icon={<MdCropFree />}
|
||||
label="HintBox (B)"
|
||||
selected={tool === 'hint-box'}
|
||||
onClick={() => handleToolChange('hint-box')}
|
||||
/>
|
||||
<VSToolBar.Separator />
|
||||
<VSToolBar.Button
|
||||
id="confirm"
|
||||
icon={<MdCheck />}
|
||||
label="完成"
|
||||
onClick={handleConfirm}
|
||||
/>
|
||||
<VSToolBar.Button
|
||||
id="cancel"
|
||||
icon={<MdClose />}
|
||||
label="取消"
|
||||
onClick={() => {
|
||||
onClear();
|
||||
setTool('drag');
|
||||
exitEditMode();
|
||||
}}
|
||||
/>
|
||||
</VSToolBar>
|
||||
);
|
||||
};
|
||||
|
||||
interface CodeEditorToolBarProps {
|
||||
onCopyAll: () => void;
|
||||
onCutAll: () => void;
|
||||
}
|
||||
|
||||
const CodeEditorToolBar: React.FC<CodeEditorToolBarProps> = ({
|
||||
onCopyAll,
|
||||
onCutAll,
|
||||
}) => {
|
||||
return (
|
||||
<VSToolBar align='center'>
|
||||
<VSToolBar.Button
|
||||
id="run"
|
||||
icon={<MdPlayArrow />}
|
||||
label="运行"
|
||||
disabled={true}
|
||||
/>
|
||||
<VSToolBar.Separator />
|
||||
<VSToolBar.Button
|
||||
id="copy-all"
|
||||
icon={<MdContentCopy />}
|
||||
label="复制全部"
|
||||
onClick={onCopyAll}
|
||||
/>
|
||||
<VSToolBar.Button
|
||||
id="cut-all"
|
||||
icon={<MdContentCut />}
|
||||
label="剪切全部"
|
||||
onClick={onCutAll}
|
||||
/>
|
||||
</VSToolBar>
|
||||
);
|
||||
};
|
||||
|
||||
function useStoreImageMetaData() {
|
||||
const setImageMetaDataObject = useScriptRecorderStore((state) => state.setImageMetaDataObject);
|
||||
const ret = useImageMetaData();
|
||||
setImageMetaDataObject(ret);
|
||||
return ret;
|
||||
}
|
||||
|
||||
const ScriptRecorder: React.FC = () => {
|
||||
const client = useDebugClient();
|
||||
const editorRef = useRef<any>(null);
|
||||
const { imageMetaData, Definitions, Annotations, clear } = useStoreImageMetaData();
|
||||
const code = useScriptRecorderStore((s) => s.code);
|
||||
const tool = useScriptRecorderStore((s) => s.tool);
|
||||
const autoScreenshot = useScriptRecorderStore((s) => s.autoScreenshot);
|
||||
const imageUrl = useScriptRecorderStore((s) => s.imageUrl);
|
||||
const inEditMode = useScriptRecorderStore((s) => s.inEditMode);
|
||||
const setCode = useScriptRecorderStore((s) => s.setCode);
|
||||
const setTool = useScriptRecorderStore((s) => s.setTool);
|
||||
const setConnected = useScriptRecorderStore((s) => s.setConnected);
|
||||
const setImageUrl = useScriptRecorderStore((s) => s.setImageUrl);
|
||||
const setDirectoryHandle = useScriptRecorderStore((s) => s.setDirectoryHandle);
|
||||
|
||||
|
||||
|
||||
const { theme: editorTheme } = useDarkMode({
|
||||
whenDark: 'monokai',
|
||||
whenLight: 'chrome'
|
||||
});
|
||||
|
||||
const { modal: formModal, show: showFormModal } = useFormModal([
|
||||
{
|
||||
type: 'text',
|
||||
label: '名称',
|
||||
name: 'name',
|
||||
required: true,
|
||||
placeholder: '请输入名称'
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
label: '显示名称',
|
||||
name: 'displayName',
|
||||
required: true,
|
||||
placeholder: '请输入显示名称'
|
||||
}
|
||||
]);
|
||||
|
||||
const updateScreenshot = useLatestCallback(async () => {
|
||||
window.setTimeout(async () => {
|
||||
if (!autoScreenshot)
|
||||
return;
|
||||
const url = await client.screenshot();
|
||||
if (!autoScreenshot)
|
||||
return;
|
||||
setImageUrl(url);
|
||||
if (!autoScreenshot)
|
||||
return;
|
||||
window.setTimeout(updateScreenshot, 10);
|
||||
}, 10);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (autoScreenshot)
|
||||
updateScreenshot();
|
||||
}, [autoScreenshot, updateScreenshot]);
|
||||
|
||||
useEffect(() => {
|
||||
client.addEventListener('connectionStatus', (e) => {
|
||||
if (e.connected) {
|
||||
setImageUrl(`http://${client.host}/api/screenshot?t=${Date.now()}`);
|
||||
}
|
||||
setConnected(e.connected);
|
||||
});
|
||||
}, [client]);
|
||||
|
||||
const handleAnnotationChange = async (e: AnnotationChangedEvent) => {
|
||||
if (e.type === 'add') {
|
||||
|
||||
let type: DefinitionType | undefined;
|
||||
if (tool === 'template' || tool === 'template-click')
|
||||
type = 'template';
|
||||
else if (tool === 'ocr' || tool === 'ocr-click')
|
||||
type = 'ocr';
|
||||
else if (tool === 'hint-box')
|
||||
type = 'hint-box';
|
||||
if (!type) {
|
||||
return;
|
||||
}
|
||||
|
||||
Annotations.add(e.annotation);
|
||||
const definition = {
|
||||
name: '',
|
||||
displayName: '',
|
||||
type: type,
|
||||
annotationId: e.annotation.id,
|
||||
useHintRect: false,
|
||||
} as TemplateDefinition;
|
||||
Definitions.add(definition);
|
||||
|
||||
const formResult = await showFormModal('编辑标注');
|
||||
if (formResult) {
|
||||
Definitions.update({
|
||||
...definition,
|
||||
name: formResult.name,
|
||||
displayName: formResult.displayName
|
||||
});
|
||||
Annotations.update({
|
||||
id: e.annotation.id,
|
||||
tip: formResult.displayName
|
||||
});
|
||||
|
||||
// 根据工具类型插入相应的代码
|
||||
if (tool in ToolConfig) {
|
||||
const codeTemplate = ToolConfig[tool as keyof typeof ToolConfig].code?.(
|
||||
{ ...definition, name: formResult.name },
|
||||
e.annotation
|
||||
);
|
||||
|
||||
const editor = editorRef.current?.editor;
|
||||
if (editor && codeTemplate) {
|
||||
const session = editor.getSession();
|
||||
const position = editor.getCursorPosition();
|
||||
const currentLine = session.getLine(position.row);
|
||||
|
||||
// 如果当前行不为空且不是以换行符结尾,先添加换行符
|
||||
const insertText = (currentLine && currentLine.trim() !== '' ? '\n' : '') + codeTemplate;
|
||||
|
||||
editor.insert(insertText);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Annotations.remove(e.annotation.id);
|
||||
Definitions.remove(e.annotation.id);
|
||||
}
|
||||
setTool('drag');
|
||||
} else if (e.type === 'update') {
|
||||
Annotations.update(e.annotation);
|
||||
} else if (e.type === 'remove') {
|
||||
Annotations.remove(e.annotation.id);
|
||||
Definitions.remove(e.annotation.id);
|
||||
}
|
||||
};
|
||||
|
||||
// 热键支持
|
||||
useHotkey([
|
||||
{
|
||||
key: 'v',
|
||||
single: true,
|
||||
callback: () => setTool('drag')
|
||||
},
|
||||
{
|
||||
key: 't',
|
||||
single: true,
|
||||
callback: () => setTool('template')
|
||||
},
|
||||
{
|
||||
key: 'r',
|
||||
single: true,
|
||||
callback: () => setTool('template-click')
|
||||
},
|
||||
{
|
||||
key: 's',
|
||||
single: true,
|
||||
callback: () => setTool('ocr')
|
||||
},
|
||||
{
|
||||
key: 'a',
|
||||
single: true,
|
||||
callback: () => setTool('ocr-click')
|
||||
},
|
||||
{
|
||||
key: 'b',
|
||||
single: true,
|
||||
callback: () => setTool('hint-box')
|
||||
}
|
||||
]);
|
||||
|
||||
const handleCopyAll = () => {
|
||||
const editor = editorRef.current?.editor;
|
||||
if (editor) {
|
||||
const text = editor.getValue();
|
||||
navigator.clipboard.writeText(text);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCutAll = () => {
|
||||
const editor = editorRef.current?.editor;
|
||||
if (editor) {
|
||||
const text = editor.getValue();
|
||||
navigator.clipboard.writeText(text);
|
||||
editor.setValue('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenDirectory = async () => {
|
||||
const handle = await openDirectory();
|
||||
setDirectoryHandle(handle);
|
||||
};
|
||||
|
||||
return (
|
||||
<Container>
|
||||
{formModal}
|
||||
{inEditMode ? (
|
||||
<EditToolBar
|
||||
onClear={clear}
|
||||
/>
|
||||
) : (
|
||||
<ViewToolBar
|
||||
onOpenDirectory={handleOpenDirectory}
|
||||
/>
|
||||
)}
|
||||
<div css={css`height: 100%; margin-top: 0;`}>
|
||||
<Splitable>
|
||||
<ImageViewerWrapper>
|
||||
<ImageEditor
|
||||
enableMask
|
||||
image={imageUrl}
|
||||
tool={tool === 'drag' ? Tool.Drag : Tool.Rect}
|
||||
annotations={imageMetaData.annotations}
|
||||
onAnnotationChanged={handleAnnotationChange}
|
||||
/>
|
||||
</ImageViewerWrapper>
|
||||
<CodeEditorWrapper>
|
||||
<CodeEditorToolBar
|
||||
onCopyAll={handleCopyAll}
|
||||
onCutAll={handleCutAll}
|
||||
/>
|
||||
<AceEditor
|
||||
ref={editorRef}
|
||||
mode="python"
|
||||
theme={editorTheme}
|
||||
value={code}
|
||||
onChange={setCode}
|
||||
name="script-editor"
|
||||
width="100%"
|
||||
height="100%"
|
||||
fontSize={14}
|
||||
showPrintMargin={false}
|
||||
showGutter={true}
|
||||
highlightActiveLine={true}
|
||||
setOptions={{
|
||||
enableBasicAutocompletion: true,
|
||||
enableLiveAutocompletion: true,
|
||||
enableSnippets: true,
|
||||
showLineNumbers: true,
|
||||
tabSize: 4,
|
||||
}}
|
||||
/>
|
||||
</CodeEditorWrapper>
|
||||
</Splitable>
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default ScriptRecorder;
|
|
@ -2,6 +2,7 @@ import { createBrowserRouter } from 'react-router-dom';
|
|||
import { Home } from './pages/Home';
|
||||
import Demo from './pages/Demo';
|
||||
import ImageAnnotation from './pages/ImageAnnotation/ImageAnnotation';
|
||||
import ScriptRecorder from './pages/ScriptRecorder/ScriptRecorder';
|
||||
|
||||
export const router = createBrowserRouter([
|
||||
{
|
||||
|
@ -16,4 +17,8 @@ export const router = createBrowserRouter([
|
|||
path: '/image-annotation',
|
||||
element: <ImageAnnotation />,
|
||||
},
|
||||
{
|
||||
path: '/script-recorder',
|
||||
element: <ScriptRecorder />,
|
||||
},
|
||||
]);
|
|
@ -68,14 +68,16 @@ export class KotoneDebugClient {
|
|||
#isReconnecting: boolean;
|
||||
/** WebSocket 服务器 URL */
|
||||
#serverUrl: string;
|
||||
#host: string;
|
||||
/** 服务器地址 */
|
||||
host: string;
|
||||
|
||||
|
||||
/**
|
||||
* 创建一个新的 Kotone 调试客户端实例
|
||||
* @param host - WebSocket 服务器的 IP 地址
|
||||
*/
|
||||
constructor(host: string) {
|
||||
this.#host = host;
|
||||
this.host = host;
|
||||
this.#serverUrl = `ws://${host}/ws`;
|
||||
this.#isReconnecting = false;
|
||||
this.#connect();
|
||||
|
@ -163,10 +165,11 @@ export class KotoneDebugClient {
|
|||
*/
|
||||
async #checkServerStatus(): Promise<void> {
|
||||
try {
|
||||
const response = await fetch(`http://${this.#host}/api/ping`);
|
||||
const response = await fetch(`http://${this.host}/api/ping`);
|
||||
if (response.ok) {
|
||||
this.#connect();
|
||||
} else {
|
||||
|
||||
setTimeout(() => this.#checkServerStatus(), 2000);
|
||||
}
|
||||
} catch {
|
||||
|
@ -184,4 +187,11 @@ export class KotoneDebugClient {
|
|||
const listeners = this.#eventListeners[event] as Array<(data: EventTypeMap<T>) => void>;
|
||||
listeners.forEach(callback => callback(data));
|
||||
}
|
||||
|
||||
async screenshot(): Promise<string> {
|
||||
const response = await fetch(`http://${this.host}/api/screenshot`);
|
||||
const blob = await response.blob();
|
||||
return URL.createObjectURL(blob);
|
||||
}
|
||||
|
||||
}
|
|
@ -284,3 +284,22 @@ export const saveImageWithHandle = async (handle: FileSystemFileHandle, imageDat
|
|||
}
|
||||
await saveFileWFS(handle, imageData);
|
||||
};
|
||||
|
||||
/**
|
||||
* 使用 Web File System API 打开文件夹
|
||||
* @returns 文件夹的 FileSystemDirectoryHandle,如果用户取消则返回 null
|
||||
*/
|
||||
export const openDirectory = async (): Promise<FileSystemDirectoryHandle | null> => {
|
||||
try {
|
||||
// @ts-ignore - FileSystemHandle API 可能在某些环境下不支持
|
||||
const handle = await window.showDirectoryPicker({
|
||||
mode: 'read'
|
||||
});
|
||||
return handle;
|
||||
} catch (error) {
|
||||
if ((error as Error).name === 'AbortError') {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
|
|
@ -693,4 +693,4 @@ def init_context(
|
|||
color._FORWARD_getter = lambda: _c.color # type: ignore
|
||||
vars._FORWARD_getter = lambda: _c.vars # type: ignore
|
||||
debug._FORWARD_getter = lambda: _c.debug # type: ignore
|
||||
config._FORWARD_getter = lambda: _c.config # type: ignore
|
||||
config._FORWARD_getter = lambda: _c.config # type: ignore
|
|
@ -9,13 +9,16 @@ import uvicorn
|
|||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.responses import FileResponse, Response
|
||||
from fastapi import FastAPI, WebSocket, HTTPException
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from . import vars
|
||||
|
||||
app = FastAPI()
|
||||
app.add_middleware(CORSMiddleware, allow_origins=["*"])
|
||||
|
||||
# 获取当前文件夹路径
|
||||
CURRENT_DIR = Path(__file__).parent
|
||||
|
||||
STATIC_DIR = CURRENT_DIR / "web"
|
||||
APP_DIR = Path.cwd()
|
||||
|
||||
|
@ -73,6 +76,13 @@ async def read_memory(key: str):
|
|||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@app.get("/api/screenshot")
|
||||
def screenshot():
|
||||
from ..context import device
|
||||
img = device.screenshot()
|
||||
buff = cv2.imencode('.png', img)[1].tobytes()
|
||||
return Response(buff, media_type="image/png")
|
||||
|
||||
@app.get("/api/ping")
|
||||
async def ping():
|
||||
return {"status": "ok"}
|
||||
|
|
Loading…
Reference in New Issue