feat(devtool): ScriptRecorder 页面

实现了一个脚本录制器页面,可以方便地截图 -> 标注 -> 保存 -> 生成代码。
This commit is contained in:
XcantloadX 2025-02-02 18:24:21 +08:00
parent 9b7ecd9884
commit 5a200f81d0
22 changed files with 2242 additions and 183 deletions

View File

@ -1,7 +1,11 @@
.vite
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*

View File

@ -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",

View File

@ -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",

View File

@ -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

View File

@ -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>
);
};

View File

@ -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;

View File

@ -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;

View File

@ -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,
};
}

View File

@ -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
};
}

View File

@ -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;

View File

@ -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;

View File

@ -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);

View File

@ -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 (

View File

@ -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>
);

View File

@ -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) => {

View File

@ -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[];
}

View File

@ -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;

View File

@ -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 />,
},
]);

View File

@ -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);
}
}

View File

@ -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;
}
};

View File

@ -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

View File

@ -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"}