7edc452c6a
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust smoke (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Has been cancelled
主菜单保留前五项,其余操作通过向上展开的更多菜单访问 类型与信息入口下沉资源卡片,并复用共享画布角标 补充菜单键盘、滚轮归属、换选及资源操作链路回归 同步方案A规范与待人工验收记录
385 lines
13 KiB
TypeScript
385 lines
13 KiB
TypeScript
import { CanvasChromeButton } from '@genarrative/image-canvas-react';
|
|
import { OverflowActions } from '@genarrative/shared/components';
|
|
import {
|
|
Crop,
|
|
Download,
|
|
Grid2X2,
|
|
ImageOff,
|
|
Loader2,
|
|
PersonStanding,
|
|
Scissors,
|
|
Sparkles,
|
|
WandSparkles,
|
|
} from 'lucide-react';
|
|
import {
|
|
Children,
|
|
type CSSProperties,
|
|
Fragment,
|
|
isValidElement,
|
|
type ReactNode,
|
|
} from 'react';
|
|
|
|
import { EditorIconButton } from './ImageCanvasEditorPrimitives';
|
|
import type { CanvasLayer } from './ImageCanvasEditorTypes';
|
|
import {
|
|
canOpenRedrawPanel,
|
|
isQuickEditSupportedLayer,
|
|
} from './ImageCanvasGenerationModel';
|
|
|
|
/** 选中工具条上的动作标识,供宿主声明自己实际可用的动作集合。 */
|
|
export type ImageCanvasSelectedToolbarAction =
|
|
| 'quick-edit'
|
|
| 'redraw'
|
|
| 'crop-expand'
|
|
| 'remove-background'
|
|
| 'perfect-pixel'
|
|
| 'split-icon-spritesheet'
|
|
| 'extract-ui-design'
|
|
| 'character-animation'
|
|
| 'download';
|
|
|
|
type ImageCanvasSelectedLayerToolbarViewProps = {
|
|
maxVisibleActions?: number;
|
|
/**
|
|
* 宿主显式声明的可用动作集合。
|
|
*
|
|
* 传 `null`(默认)时完全沿用美术画布内置判定;传入集合时以集合为准,
|
|
* 让没有对应后端链路的画布宿主不必把「点了没反应」的按钮渲染出来。
|
|
*/
|
|
supportedActions?: ReadonlySet<ImageCanvasSelectedToolbarAction> | null;
|
|
/** 宿主在工具条上追加的常规动作,渲染在下载按钮之前。 */
|
|
extraActions?: ReactNode;
|
|
/** 工具栏末组动作,紧随下载按钮,与下载共用一个分组。 */
|
|
endActions?: ReactNode;
|
|
/** 提供文字时显示带文字的导出入口;缺省保持网页画布的图标按钮。 */
|
|
downloadLabel?: string;
|
|
selectedLayer: CanvasLayer | null;
|
|
selectedToolbarStyle: CSSProperties | null;
|
|
onOpenQuickEditPanel: (layer: CanvasLayer) => void;
|
|
onOpenRedrawPanel: (layer: CanvasLayer) => void;
|
|
onOpenCropExpandPanel: (layer: CanvasLayer) => void;
|
|
onRemoveBackground: (layer: CanvasLayer) => void;
|
|
onPerfectPixel: (layer: CanvasLayer) => void;
|
|
isPerfectPixelProcessing: boolean;
|
|
isPerfectPixelPendingConfirmation: boolean;
|
|
isSplittingIconSpritesheet: boolean;
|
|
isPersistingAssetKind: boolean;
|
|
onSplitIconSpritesheet: (layer: CanvasLayer) => void;
|
|
onExtractUiDesignAssets: (layer: CanvasLayer) => void;
|
|
onOpenCharacterAnimationPanel: (layer: CanvasLayer) => void;
|
|
onDownloadLayer: (layer: CanvasLayer) => void;
|
|
};
|
|
|
|
/** 条件动作通常放在 Fragment 中;空 Fragment 不能产生一个空分组。 */
|
|
function hasToolbarActions(actions: ReactNode): boolean {
|
|
return Children.toArray(actions).some((action) =>
|
|
isValidElement<{ children?: ReactNode }>(action) && action.type === Fragment
|
|
? hasToolbarActions(action.props.children)
|
|
: action !== '',
|
|
);
|
|
}
|
|
|
|
export function ImageCanvasSelectedLayerToolbarView({
|
|
maxVisibleActions,
|
|
supportedActions = null,
|
|
extraActions,
|
|
endActions,
|
|
downloadLabel,
|
|
selectedLayer,
|
|
selectedToolbarStyle,
|
|
onOpenQuickEditPanel,
|
|
onOpenRedrawPanel,
|
|
onOpenCropExpandPanel,
|
|
onRemoveBackground,
|
|
onPerfectPixel,
|
|
isPerfectPixelProcessing,
|
|
isPerfectPixelPendingConfirmation,
|
|
isSplittingIconSpritesheet,
|
|
isPersistingAssetKind,
|
|
onSplitIconSpritesheet,
|
|
onExtractUiDesignAssets,
|
|
onOpenCharacterAnimationPanel,
|
|
onDownloadLayer,
|
|
}: ImageCanvasSelectedLayerToolbarViewProps) {
|
|
if (!selectedLayer || !selectedToolbarStyle) {
|
|
return null;
|
|
}
|
|
const isActionSupported = (
|
|
action: ImageCanvasSelectedToolbarAction,
|
|
builtInAvailability: boolean,
|
|
) => (supportedActions ? supportedActions.has(action) : builtInAvailability);
|
|
const canRedraw = isActionSupported(
|
|
'redraw',
|
|
canOpenRedrawPanel(selectedLayer),
|
|
);
|
|
const showDownload = isActionSupported('download', true);
|
|
const downloadAction = showDownload ? (
|
|
downloadLabel ? (
|
|
<CanvasChromeButton
|
|
className="image-canvas-editor__floating-toolbar-text-button"
|
|
label={downloadLabel}
|
|
title={downloadLabel}
|
|
icon={<Download className="h-4 w-4" />}
|
|
onClick={() => onDownloadLayer(selectedLayer)}
|
|
>
|
|
<span>{downloadLabel}</span>
|
|
</CanvasChromeButton>
|
|
) : (
|
|
<EditorIconButton
|
|
label="下载按钮"
|
|
title="下载按钮"
|
|
icon={Download}
|
|
onClick={() => onDownloadLayer(selectedLayer)}
|
|
/>
|
|
)
|
|
) : null;
|
|
const hasExtraActions = hasToolbarActions(extraActions);
|
|
const hasEndActions = showDownload || hasToolbarActions(endActions);
|
|
const divider = (
|
|
<span
|
|
aria-hidden="true"
|
|
className="image-canvas-editor__floating-toolbar-divider"
|
|
/>
|
|
);
|
|
|
|
if (selectedLayer.mediaType === 'audio') {
|
|
return (
|
|
<div
|
|
className="image-canvas-editor__floating-toolbar"
|
|
style={selectedToolbarStyle}
|
|
role="toolbar"
|
|
aria-label="素材工具栏"
|
|
onPointerDown={(event) => event.stopPropagation()}
|
|
>
|
|
<OverflowActions key={selectedLayer.id} maxVisible={maxVisibleActions}>
|
|
{canRedraw ? (
|
|
<CanvasChromeButton
|
|
className="image-canvas-editor__floating-toolbar-text-button"
|
|
label="改造"
|
|
title="改造"
|
|
icon={<WandSparkles className="h-4 w-4" />}
|
|
onClick={() => onOpenRedrawPanel(selectedLayer)}
|
|
>
|
|
<span>改造</span>
|
|
</CanvasChromeButton>
|
|
) : null}
|
|
{canRedraw && hasExtraActions ? divider : null}
|
|
{extraActions}
|
|
{(canRedraw || hasExtraActions) && hasEndActions ? divider : null}
|
|
{downloadAction}
|
|
{endActions}
|
|
</OverflowActions>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const canRasterEdit =
|
|
selectedLayer.mediaType !== 'video' &&
|
|
selectedLayer.mediaType !== 'image-sequence' &&
|
|
selectedLayer.assetKind !== 'character-animation';
|
|
const showQuickEdit = isActionSupported(
|
|
'quick-edit',
|
|
isQuickEditSupportedLayer(selectedLayer),
|
|
);
|
|
const showCharacterAnimation = isActionSupported(
|
|
'character-animation',
|
|
selectedLayer.assetKind === 'character',
|
|
);
|
|
const showCropExpand =
|
|
canRasterEdit && isActionSupported('crop-expand', true);
|
|
const showRemoveBackground =
|
|
canRasterEdit && isActionSupported('remove-background', true);
|
|
const showPerfectPixel =
|
|
canRasterEdit && isActionSupported('perfect-pixel', true);
|
|
const showSplitIconSpritesheet =
|
|
selectedLayer.assetKind === 'icon-spritesheet' &&
|
|
isActionSupported('split-icon-spritesheet', true);
|
|
const showExtractUiDesign =
|
|
selectedLayer.assetKind === 'ui-design' &&
|
|
isActionSupported('extract-ui-design', true);
|
|
const hasEditingActions =
|
|
showCropExpand ||
|
|
showRemoveBackground ||
|
|
showPerfectPixel ||
|
|
showSplitIconSpritesheet ||
|
|
showExtractUiDesign ||
|
|
showCharacterAnimation;
|
|
|
|
return (
|
|
<div
|
|
className="image-canvas-editor__floating-toolbar"
|
|
style={selectedToolbarStyle}
|
|
role="toolbar"
|
|
aria-label="图片工具栏"
|
|
onPointerDown={(event) => event.stopPropagation()}
|
|
>
|
|
<OverflowActions key={selectedLayer.id} maxVisible={maxVisibleActions}>
|
|
{showQuickEdit ? (
|
|
<CanvasChromeButton
|
|
className="image-canvas-editor__floating-toolbar-text-button"
|
|
label="快速编辑"
|
|
title="快速编辑"
|
|
icon={<Sparkles className="h-4 w-4" />}
|
|
onClick={() => onOpenQuickEditPanel(selectedLayer)}
|
|
>
|
|
<span>快速编辑</span>
|
|
</CanvasChromeButton>
|
|
) : null}
|
|
{showQuickEdit && hasEditingActions ? divider : null}
|
|
{showCropExpand ? (
|
|
<EditorIconButton
|
|
label="裁扩按钮"
|
|
title="裁扩按钮"
|
|
icon={Crop}
|
|
onClick={() => onOpenCropExpandPanel(selectedLayer)}
|
|
/>
|
|
) : null}
|
|
{showRemoveBackground ? (
|
|
<EditorIconButton
|
|
label="去除背景按钮"
|
|
title="去除背景按钮"
|
|
icon={ImageOff}
|
|
onClick={() => onRemoveBackground(selectedLayer)}
|
|
/>
|
|
) : null}
|
|
{showPerfectPixel ? (
|
|
<CanvasChromeButton
|
|
className="image-canvas-editor__floating-toolbar-text-button"
|
|
// 中文注释:保存态名称不能直接用「素材类型保存中」——icon-spritesheet 图层上
|
|
// 拆分图集按钮同时显示该文案,两个控件会撞同一个无障碍名称。
|
|
label={
|
|
isPersistingAssetKind
|
|
? '完美像素等待素材类型保存'
|
|
: isPerfectPixelProcessing
|
|
? '完美像素处理中'
|
|
: isPerfectPixelPendingConfirmation
|
|
? '完美像素结果待确认'
|
|
: '完美像素'
|
|
}
|
|
title={
|
|
isPersistingAssetKind
|
|
? '完美像素等待素材类型保存'
|
|
: isPerfectPixelProcessing
|
|
? '完美像素处理中'
|
|
: isPerfectPixelPendingConfirmation
|
|
? '结果待确认,双击原占位继续核对或重试'
|
|
: '自动识别并规整像素网格'
|
|
}
|
|
icon={
|
|
isPersistingAssetKind || isPerfectPixelProcessing ? (
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
) : (
|
|
<Grid2X2 className="h-4 w-4" />
|
|
)
|
|
}
|
|
// 中文注释:素材类型保存在途时必须一并禁用。请求同时带 assetKind 和
|
|
// sourceResourceId,本地类型已改但资源尚未落库时两者不一致,后端
|
|
// resolve_editor_pixel_art_snap_asset_kind 会直接 400,只留下失败占位。
|
|
// 与相邻的拆分图集按钮保持同一套门禁。
|
|
disabled={
|
|
isPersistingAssetKind ||
|
|
isPerfectPixelProcessing ||
|
|
isPerfectPixelPendingConfirmation
|
|
}
|
|
aria-busy={isPersistingAssetKind || isPerfectPixelProcessing}
|
|
onClick={() => onPerfectPixel(selectedLayer)}
|
|
>
|
|
<span>
|
|
{isPersistingAssetKind
|
|
? '保存中'
|
|
: isPerfectPixelProcessing
|
|
? '处理中'
|
|
: isPerfectPixelPendingConfirmation
|
|
? '待确认'
|
|
: '完美像素'}
|
|
</span>
|
|
</CanvasChromeButton>
|
|
) : null}
|
|
{showSplitIconSpritesheet ? (
|
|
<CanvasChromeButton
|
|
className="image-canvas-editor__floating-toolbar-text-button"
|
|
label={
|
|
isPersistingAssetKind
|
|
? '素材类型保存中'
|
|
: isSplittingIconSpritesheet
|
|
? '拆图中'
|
|
: '拆分图集'
|
|
}
|
|
title={
|
|
isPersistingAssetKind
|
|
? '素材类型保存中'
|
|
: isSplittingIconSpritesheet
|
|
? '拆图中'
|
|
: '拆分图集'
|
|
}
|
|
icon={
|
|
isPersistingAssetKind || isSplittingIconSpritesheet ? (
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
) : (
|
|
<Scissors className="h-4 w-4" />
|
|
)
|
|
}
|
|
disabled={isPersistingAssetKind || isSplittingIconSpritesheet}
|
|
aria-busy={isPersistingAssetKind || isSplittingIconSpritesheet}
|
|
onClick={() => onSplitIconSpritesheet(selectedLayer)}
|
|
>
|
|
<span>
|
|
{isPersistingAssetKind
|
|
? '保存中'
|
|
: isSplittingIconSpritesheet
|
|
? '拆图中'
|
|
: '拆分图集'}
|
|
</span>
|
|
</CanvasChromeButton>
|
|
) : null}
|
|
{showExtractUiDesign ? (
|
|
<CanvasChromeButton
|
|
className="image-canvas-editor__floating-toolbar-text-button"
|
|
label="提取素材"
|
|
title="提取素材"
|
|
icon={<Scissors className="h-4 w-4" />}
|
|
onClick={() => onExtractUiDesignAssets(selectedLayer)}
|
|
>
|
|
<span>提取素材</span>
|
|
</CanvasChromeButton>
|
|
) : null}
|
|
{showCharacterAnimation ? (
|
|
<CanvasChromeButton
|
|
className="image-canvas-editor__floating-toolbar-text-button"
|
|
label="生成动画"
|
|
title="生成动画"
|
|
icon={<PersonStanding className="h-4 w-4" />}
|
|
onClick={() => onOpenCharacterAnimationPanel(selectedLayer)}
|
|
>
|
|
<span>生成动画</span>
|
|
</CanvasChromeButton>
|
|
) : null}
|
|
{canRedraw ? (
|
|
<>
|
|
{showQuickEdit || hasEditingActions ? divider : null}
|
|
<CanvasChromeButton
|
|
className="image-canvas-editor__floating-toolbar-text-button"
|
|
label="改造"
|
|
title="改造"
|
|
icon={<WandSparkles className="h-4 w-4" />}
|
|
onClick={() => onOpenRedrawPanel(selectedLayer)}
|
|
>
|
|
<span>改造</span>
|
|
</CanvasChromeButton>
|
|
</>
|
|
) : null}
|
|
{(showQuickEdit || hasEditingActions || canRedraw) && hasExtraActions
|
|
? divider
|
|
: null}
|
|
{extraActions}
|
|
{(showQuickEdit || hasEditingActions || canRedraw || hasExtraActions) &&
|
|
hasEndActions
|
|
? divider
|
|
: null}
|
|
{downloadAction}
|
|
{endActions}
|
|
</OverflowActions>
|
|
</div>
|
|
);
|
|
}
|