抽取图片画布通用组件

新增图片画布图标按钮和侧栏媒体项 primitives

让素材文件夹按钮和生成关闭按钮复用通用图标按钮

移除主画布视图内重复的按钮与列表项实现
This commit is contained in:
2026-06-13 22:23:19 +08:00
parent 242860e2d3
commit b2122481ff
2 changed files with 114 additions and 118 deletions

View File

@@ -0,0 +1,96 @@
import type { ComponentType, ReactNode } from 'react';
type IconComponent = ComponentType<{ className?: string }>;
export type EditorIconButtonProps = {
label: string;
title?: string;
icon: IconComponent;
className?: string;
type?: 'button' | 'submit';
disabled?: boolean;
pressed?: boolean;
expanded?: boolean;
onClick?: () => void;
};
export function EditorIconButton({
label,
title = label,
icon: Icon,
className,
type = 'button',
disabled,
pressed,
expanded,
onClick,
}: EditorIconButtonProps) {
return (
<button
type={type}
className={className}
aria-label={label}
title={title}
disabled={disabled}
aria-pressed={pressed}
aria-expanded={expanded}
onClick={onClick}
>
<Icon className="h-4 w-4" />
</button>
);
}
export type SidebarMediaItemProps = {
title: string;
detail: string;
imageSrc: string;
imageAlt: string;
selected?: boolean;
primaryLabel: string;
onPrimaryClick: () => void;
thumbnailClassName: string;
metaClassName: string;
rowClassName: string;
primaryClassName?: string;
actions?: ReactNode;
titleNode?: ReactNode;
};
export function SidebarMediaItem({
title,
detail,
imageSrc,
imageAlt,
selected = false,
primaryLabel,
onPrimaryClick,
thumbnailClassName,
metaClassName,
rowClassName,
primaryClassName,
actions,
titleNode,
}: SidebarMediaItemProps) {
return (
<div
className={`${rowClassName} ${selected ? `${rowClassName}--selected` : ''}`}
>
<button
type="button"
className={primaryClassName}
onClick={onPrimaryClick}
aria-label={primaryLabel}
>
<span className={thumbnailClassName}>
<img src={imageSrc} alt={imageAlt} />
</span>
</button>
<div className={metaClassName}>
{titleNode ?? <span>{title}</span>}
<span>{detail}</span>
</div>
{actions}
</div>
);
}

View File

@@ -26,10 +26,8 @@ import {
X,
} from 'lucide-react';
import {
type ComponentType,
type KeyboardEvent as ReactKeyboardEvent,
type PointerEvent as ReactPointerEvent,
type ReactNode,
useCallback,
useEffect,
useMemo,
@@ -48,6 +46,10 @@ import {
saveEditorProjectLayout,
} from '../../services/image-editor/editorProjectClient';
import { ApiClientError } from '../../services/apiClient';
import {
EditorIconButton,
SidebarMediaItem,
} from './ImageCanvasEditorPrimitives';
type EditorAsset = {
id: string;
@@ -141,34 +143,6 @@ type SnapCandidate = {
distance: number;
};
type EditorIconButtonProps = {
label: string;
title?: string;
icon: ComponentType<{ className?: string }>;
className?: string;
type?: 'button' | 'submit';
disabled?: boolean;
pressed?: boolean;
expanded?: boolean;
onClick?: () => void;
};
type SidebarMediaItemProps = {
title: string;
detail: string;
imageSrc: string;
imageAlt: string;
selected?: boolean;
primaryLabel: string;
onPrimaryClick: () => void;
thumbnailClassName: string;
metaClassName: string;
rowClassName: string;
primaryClassName?: string;
actions?: ReactNode;
titleNode?: ReactNode;
};
type DragState =
| {
kind: 'pan';
@@ -304,71 +278,6 @@ const CANVAS_BACKGROUND_OPTIONS = [
{ label: '冷蓝', value: '#eef6ff' },
];
function EditorIconButton({
label,
title = label,
icon: Icon,
className,
type = 'button',
disabled,
pressed,
expanded,
onClick,
}: EditorIconButtonProps) {
return (
<button
type={type}
className={className}
aria-label={label}
title={title}
disabled={disabled}
aria-pressed={pressed}
aria-expanded={expanded}
onClick={onClick}
>
<Icon className="h-4 w-4" />
</button>
);
}
function SidebarMediaItem({
title,
detail,
imageSrc,
imageAlt,
selected = false,
primaryLabel,
onPrimaryClick,
thumbnailClassName,
metaClassName,
rowClassName,
primaryClassName,
actions,
titleNode,
}: SidebarMediaItemProps) {
return (
<div
className={`${rowClassName} ${selected ? `${rowClassName}--selected` : ''}`}
>
<button
type="button"
className={primaryClassName}
onClick={onPrimaryClick}
aria-label={primaryLabel}
>
<span className={thumbnailClassName}>
<img src={imageSrc} alt={imageAlt} />
</span>
</button>
<div className={metaClassName}>
{titleNode ?? <span>{title}</span>}
<span>{detail}</span>
</div>
{actions}
</div>
);
}
function clamp(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value));
}
@@ -1604,32 +1513,25 @@ export function ImageCanvasEditorView() {
aria-label={folder.label}
>
<div className="image-canvas-editor__asset-folder-header">
<button
type="button"
aria-label={`${folder.collapsed ? '展开' : '折叠'}${folder.label}`}
aria-expanded={!folder.collapsed}
<EditorIconButton
label={`${folder.collapsed ? '展开' : '折叠'}${folder.label}`}
title={folder.collapsed ? '展开' : '折叠'}
icon={folder.collapsed ? ChevronRight : ChevronDown}
expanded={!folder.collapsed}
onClick={() => toggleAssetFolder(folder.id)}
>
{folder.collapsed ? (
<ChevronRight className="h-4 w-4" />
) : (
<ChevronDown className="h-4 w-4" />
)}
</button>
/>
<Folder className="h-4 w-4" />
<span>{folder.label}</span>
<span>{folder.assets.length}</span>
<button
type="button"
aria-label={`上传到${folder.label}`}
<EditorIconButton
label={`上传到${folder.label}`}
title="上传"
icon={ImagePlus}
onClick={() => {
setActiveUploadFolderId(folder.id);
uploadInputRef.current?.click();
}}
>
<ImagePlus className="h-4 w-4" />
</button>
/>
</div>
<div
className="image-canvas-editor__asset-folder-list"
@@ -2192,18 +2094,16 @@ export function ImageCanvasEditorView() {
{generateDialog.errorMessage}
</div>
) : null}
<button
type="button"
<EditorIconButton
className="image-canvas-editor__generation-close"
aria-label="关闭生成图片"
label="关闭生成图片"
icon={X}
disabled={generateDialog.status === 'generating'}
onClick={() => {
setGenerateDialog(null);
setActiveTool('select');
}}
>
<X className="h-4 w-4" />
</button>
/>
</form>
) : null}
</div>