补充图集拆分中的前端表现
维护逐图层拆分状态并在请求结束后可靠清理 源图集显示扫描蒙层和拆图中状态胶囊 工具栏显示旋转图标并禁用重复提交 补充工作流、画布、工具栏测试及入口验收文档
This commit is contained in:
@@ -80,5 +80,5 @@
|
||||
- 图标素材生成请求必须带 `model`、`aspectRatio` 和 `imageSize`;`nanobanana2` 请求体必须包含 `generationConfig.imageConfig.aspectRatio/imageSize`,`gpt-image-2` 请求必须包含文档映射后的 `size`。
|
||||
- 图标素材生成可以上传普通参考图;提交时图标规范图仍走 `referenceImageSrc`,普通参考图走 `referenceImageSrcs`,上传参考图优先提交 `objectKey`,并写入 `generationInputs.references`。
|
||||
- 透明背景处理和自动拆分都成功后,画布同时出现透明 spritesheet 图集和按描述命名的独立图标图层;透明图集成功但拆分失败时只出现透明图集,透明背景处理最终失败时只出现 provider 原图。
|
||||
- 选中图集图层时显示 `拆分图集`,点击后不新增第二张图集,只在原图集右侧追加自动识别的独立素材,并同步写入素材库。
|
||||
- 选中图集图层时显示 `拆分图集`;点击后源图集显示扫描蒙层与 `拆图中` 状态,工具栏按钮同步切换为旋转图标和 `拆图中` 并禁用重复提交。完成后恢复工具栏,不新增第二张图集,只在原图集右侧追加自动识别的独立素材,并同步写入素材库。
|
||||
- 生成图标素材提交体包含按模型和尺寸计算的 `priceMudPoints`;`nanobanana2 1K` 应为 `12`,`gpt-image-2 1K` 应为 `3`,`gpt-image-2 2K` 应为 `5`。若前端传入与后端计费配置不一致的值,后端返回 `priceMudPoints` 校验错误,不继续调用上游生成。
|
||||
|
||||
@@ -1355,6 +1355,7 @@ export function ImageCanvasEditorView({
|
||||
openCropExpandPanel,
|
||||
removeSelectedLayerBackground,
|
||||
splitSelectedIconSpritesheet,
|
||||
splittingIconSpritesheetLayerIds,
|
||||
extractUiDesignAssets,
|
||||
pickCharacterSpecFromLayer,
|
||||
pickGenerationReferenceFromLayer,
|
||||
@@ -2119,6 +2120,7 @@ export function ImageCanvasEditorView({
|
||||
quickEditSelectionSourceLayer,
|
||||
generationComposerStyle,
|
||||
selectedToolbarStyle,
|
||||
splittingIconSpritesheetLayerIds,
|
||||
uploadDropTarget,
|
||||
contextMenu,
|
||||
canvasClipboard,
|
||||
|
||||
@@ -42,6 +42,7 @@ function renderSelectedToolbar(
|
||||
onOpenRedrawPanel: vi.fn(),
|
||||
onOpenCropExpandPanel: vi.fn(),
|
||||
onRemoveBackground: vi.fn(),
|
||||
isSplittingIconSpritesheet: false,
|
||||
onSplitIconSpritesheet: vi.fn(),
|
||||
onExtractUiDesignAssets: vi.fn(),
|
||||
onOpenCharacterAnimationPanel: vi.fn(),
|
||||
@@ -155,6 +156,33 @@ describe('ImageCanvasSelectedLayerToolbarView', () => {
|
||||
expect(props.onSplitIconSpritesheet).toHaveBeenCalledWith(layer);
|
||||
});
|
||||
|
||||
it('renders a disabled loading state while splitting an icon spritesheet', () => {
|
||||
const layer = createLayer({
|
||||
sourceType: 'generated',
|
||||
assetKind: 'icon-spritesheet',
|
||||
});
|
||||
const props = renderSelectedToolbar({
|
||||
selectedLayer: layer,
|
||||
isSplittingIconSpritesheet: true,
|
||||
});
|
||||
const splitButton = screen.getByRole('button', { name: '拆图中' });
|
||||
|
||||
expect(splitButton.getAttribute('title')).toBe('拆图中');
|
||||
expect(splitButton.getAttribute('aria-busy')).toBe('true');
|
||||
expect((splitButton as HTMLButtonElement).disabled).toBe(true);
|
||||
expect(splitButton.textContent).toContain('拆图中');
|
||||
expect(splitButton.querySelector('.lucide-loader-circle')).toBeTruthy();
|
||||
expect(
|
||||
splitButton.querySelector('.lucide-loader-circle.animate-spin'),
|
||||
).toBeTruthy();
|
||||
expect(splitButton.querySelector('.lucide-scissors')).toBeNull();
|
||||
|
||||
fireEvent.click(splitButton);
|
||||
fireEvent.click(splitButton);
|
||||
|
||||
expect(props.onSplitIconSpritesheet).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps only remodel and download for audio layers', () => {
|
||||
const layer = createLayer({
|
||||
title: '游戏音效',
|
||||
@@ -293,6 +321,7 @@ describe('ImageCanvasSelectedLayerToolbarView', () => {
|
||||
onOpenRedrawPanel={vi.fn()}
|
||||
onOpenCropExpandPanel={vi.fn()}
|
||||
onRemoveBackground={vi.fn()}
|
||||
isSplittingIconSpritesheet={false}
|
||||
onSplitIconSpritesheet={vi.fn()}
|
||||
onExtractUiDesignAssets={vi.fn()}
|
||||
onOpenCharacterAnimationPanel={vi.fn()}
|
||||
@@ -310,6 +339,7 @@ describe('ImageCanvasSelectedLayerToolbarView', () => {
|
||||
onOpenRedrawPanel={vi.fn()}
|
||||
onOpenCropExpandPanel={vi.fn()}
|
||||
onRemoveBackground={vi.fn()}
|
||||
isSplittingIconSpritesheet={false}
|
||||
onSplitIconSpritesheet={vi.fn()}
|
||||
onExtractUiDesignAssets={vi.fn()}
|
||||
onOpenCharacterAnimationPanel={vi.fn()}
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
Crop,
|
||||
Download,
|
||||
ImageOff,
|
||||
Loader2,
|
||||
PersonStanding,
|
||||
Scissors,
|
||||
Sparkles,
|
||||
@@ -21,6 +22,7 @@ type ImageCanvasSelectedLayerToolbarViewProps = {
|
||||
onOpenRedrawPanel: (layer: CanvasLayer) => void;
|
||||
onOpenCropExpandPanel: (layer: CanvasLayer) => void;
|
||||
onRemoveBackground: (layer: CanvasLayer) => void;
|
||||
isSplittingIconSpritesheet: boolean;
|
||||
onSplitIconSpritesheet: (layer: CanvasLayer) => void;
|
||||
onExtractUiDesignAssets: (layer: CanvasLayer) => void;
|
||||
onOpenCharacterAnimationPanel: (layer: CanvasLayer) => void;
|
||||
@@ -34,6 +36,7 @@ export function ImageCanvasSelectedLayerToolbarView({
|
||||
onOpenRedrawPanel,
|
||||
onOpenCropExpandPanel,
|
||||
onRemoveBackground,
|
||||
isSplittingIconSpritesheet,
|
||||
onSplitIconSpritesheet,
|
||||
onExtractUiDesignAssets,
|
||||
onOpenCharacterAnimationPanel,
|
||||
@@ -120,12 +123,20 @@ export function ImageCanvasSelectedLayerToolbarView({
|
||||
{selectedLayer.assetKind === 'icon-spritesheet' ? (
|
||||
<PlatformIconButton
|
||||
className="image-canvas-editor__floating-toolbar-text-button"
|
||||
label="拆分图集"
|
||||
title="拆分图集"
|
||||
icon={<Scissors className="h-4 w-4" />}
|
||||
label={isSplittingIconSpritesheet ? '拆图中' : '拆分图集'}
|
||||
title={isSplittingIconSpritesheet ? '拆图中' : '拆分图集'}
|
||||
icon={
|
||||
isSplittingIconSpritesheet ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Scissors className="h-4 w-4" />
|
||||
)
|
||||
}
|
||||
disabled={isSplittingIconSpritesheet}
|
||||
aria-busy={isSplittingIconSpritesheet}
|
||||
onClick={() => onSplitIconSpritesheet(selectedLayer)}
|
||||
>
|
||||
<span>拆分图集</span>
|
||||
<span>{isSplittingIconSpritesheet ? '拆图中' : '拆分图集'}</span>
|
||||
</PlatformIconButton>
|
||||
) : null}
|
||||
{selectedLayer.assetKind === 'ui-design' ? (
|
||||
|
||||
@@ -40,6 +40,8 @@ import type {
|
||||
import { ImageCanvasUiAssetExtractionOverlayView } from './ImageCanvasUiAssetExtractionOverlayView';
|
||||
import { ImageCanvasWorldView } from './ImageCanvasWorldView';
|
||||
|
||||
const EMPTY_LAYER_ID_SET: ReadonlySet<string> = new Set();
|
||||
|
||||
export type ImageCanvasStageViewProps = {
|
||||
canvasViewportRef: RefObject<HTMLDivElement | null>;
|
||||
specToolWrapRef: RefObject<HTMLSpanElement | null>;
|
||||
@@ -73,6 +75,7 @@ export type ImageCanvasStageViewProps = {
|
||||
quickEditSelectionSourceLayer: CanvasLayer | null;
|
||||
generationComposerStyle: CSSProperties | null;
|
||||
selectedToolbarStyle: CSSProperties | null;
|
||||
splittingIconSpritesheetLayerIds?: ReadonlySet<string>;
|
||||
uploadDropTarget: 'canvas' | 'assets' | null;
|
||||
contextMenu: CanvasContextMenuState | null;
|
||||
canvasClipboard: CanvasClipboard | null;
|
||||
@@ -225,6 +228,7 @@ export function ImageCanvasStageView({
|
||||
quickEditSelectionSourceLayer,
|
||||
generationComposerStyle,
|
||||
selectedToolbarStyle,
|
||||
splittingIconSpritesheetLayerIds = EMPTY_LAYER_ID_SET,
|
||||
uploadDropTarget,
|
||||
contextMenu,
|
||||
canvasClipboard,
|
||||
@@ -356,6 +360,7 @@ export function ImageCanvasStageView({
|
||||
cropExpandPanel={cropExpandPanel}
|
||||
cropExpandSourceLayer={cropExpandSourceLayer}
|
||||
generationComposerStyle={generationComposerStyle}
|
||||
splittingIconSpritesheetLayerIds={splittingIconSpritesheetLayerIds}
|
||||
onLayerPointerDown={onLayerPointerDown}
|
||||
onLayerClick={onLayerClick}
|
||||
onSelectLayer={onSelectLayer}
|
||||
@@ -377,6 +382,10 @@ export function ImageCanvasStageView({
|
||||
: selectedLayer
|
||||
}
|
||||
selectedToolbarStyle={selectedToolbarStyle}
|
||||
isSplittingIconSpritesheet={Boolean(
|
||||
selectedLayer &&
|
||||
splittingIconSpritesheetLayerIds.has(selectedLayer.id),
|
||||
)}
|
||||
onOpenQuickEditPanel={onOpenQuickEditPanel}
|
||||
onOpenRedrawPanel={onOpenRedrawPanel}
|
||||
onOpenCropExpandPanel={onOpenCropExpandPanel}
|
||||
|
||||
@@ -138,6 +138,22 @@ describe('ImageCanvasWorldView', () => {
|
||||
expect(screen.queryByRole('status', { name: '' })).toBeNull();
|
||||
});
|
||||
|
||||
it('shows the atlas splitting treatment on the source layer', () => {
|
||||
const layer = createLayer({ assetKind: 'icon-spritesheet' });
|
||||
renderWorldView({
|
||||
layers: [layer],
|
||||
splittingIconSpritesheetLayerIds: new Set([layer.id]),
|
||||
});
|
||||
|
||||
const sourceLayer = screen.getByRole('button', {
|
||||
name: `选择${layer.title}`,
|
||||
});
|
||||
expect(sourceLayer.className).toContain(
|
||||
'image-canvas-editor__layer--generating',
|
||||
);
|
||||
expect(within(sourceLayer).getByRole('status').textContent).toBe('拆图中');
|
||||
});
|
||||
|
||||
it('keeps the image layer frame visible while the image is loading', () => {
|
||||
useResolvedAssetReadUrlMock.mockImplementation(() => ({
|
||||
resolvedUrl: '',
|
||||
|
||||
@@ -250,6 +250,7 @@ export type ImageCanvasWorldViewProps = {
|
||||
cropExpandPanel: CropExpandPanelState | null;
|
||||
cropExpandSourceLayer: CanvasLayer | null;
|
||||
generationComposerStyle: CSSProperties | null;
|
||||
splittingIconSpritesheetLayerIds?: ReadonlySet<string>;
|
||||
onLayerPointerDown: (
|
||||
event: ReactPointerEvent<HTMLElement>,
|
||||
layer: CanvasLayer,
|
||||
@@ -285,6 +286,8 @@ export type ImageCanvasWorldViewProps = {
|
||||
) => void;
|
||||
};
|
||||
|
||||
const EMPTY_LAYER_ID_SET: ReadonlySet<string> = new Set();
|
||||
|
||||
const CROP_EXPAND_HANDLES: Array<{
|
||||
value: CropExpandResizeHandle;
|
||||
label: string;
|
||||
@@ -916,6 +919,7 @@ export function ImageCanvasWorldView({
|
||||
cropExpandPanel,
|
||||
cropExpandSourceLayer,
|
||||
generationComposerStyle,
|
||||
splittingIconSpritesheetLayerIds = EMPTY_LAYER_ID_SET,
|
||||
onLayerPointerDown,
|
||||
onLayerClick,
|
||||
onSelectLayer,
|
||||
@@ -1007,10 +1011,13 @@ export function ImageCanvasWorldView({
|
||||
layer.flipX || layer.flipY
|
||||
? `scale(${layer.flipX ? -1 : 1}, ${layer.flipY ? -1 : 1})`
|
||||
: undefined;
|
||||
const layerGeneratingLabel =
|
||||
generateDialog?.mode === 'edit' &&
|
||||
generateDialog.status === 'generating' &&
|
||||
generateDialog.sourceLayerId === layer.id
|
||||
const layerGeneratingLabel = splittingIconSpritesheetLayerIds.has(
|
||||
layer.id,
|
||||
)
|
||||
? '拆图中'
|
||||
: generateDialog?.mode === 'edit' &&
|
||||
generateDialog.status === 'generating' &&
|
||||
generateDialog.sourceLayerId === layer.id
|
||||
? '修改中'
|
||||
: null;
|
||||
const isMediaContainer = isCanvasMediaContainerLayer(layer);
|
||||
|
||||
@@ -535,6 +535,11 @@ function GenerationWorkflowHarness({
|
||||
>
|
||||
拆分图集
|
||||
</button>
|
||||
<output aria-label="拆图状态">
|
||||
{workflow.splittingIconSpritesheetLayerIds.has(layers[0]!.id)
|
||||
? '拆图中'
|
||||
: '空闲'}
|
||||
</output>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
@@ -1910,6 +1915,65 @@ describe('useImageCanvasGenerationWorkflow', () => {
|
||||
expect(screen.getByTestId('sidebar').textContent).toBe('layers');
|
||||
});
|
||||
|
||||
it('exposes a splitting state while the manual atlas request is pending', async () => {
|
||||
let resolveSplit:
|
||||
| ((value: { iconImageSrcs: never[]; project: unknown }) => void)
|
||||
| undefined;
|
||||
splitEditorIconSpritesheetMock.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolveSplit = resolve;
|
||||
}),
|
||||
);
|
||||
render(
|
||||
<GenerationWorkflowHarness
|
||||
projectId="project-1"
|
||||
applyProjectSnapshot={vi.fn()}
|
||||
initialLayers={[
|
||||
createLayer({
|
||||
assetKind: 'icon-spritesheet',
|
||||
originalWidth: 1024,
|
||||
originalHeight: 1024,
|
||||
}),
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '拆分图集' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '拆分图集' }));
|
||||
|
||||
expect(screen.getByRole('status', { name: '拆图状态' }).textContent).toBe(
|
||||
'拆图中',
|
||||
);
|
||||
expect(splitEditorIconSpritesheetMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
resolveSplit?.({
|
||||
iconImageSrcs: [],
|
||||
project: {
|
||||
projectId: 'project-1',
|
||||
title: '未命名画布',
|
||||
canvas: {
|
||||
canvasId: 'project-1:canvas:default',
|
||||
projectId: 'project-1',
|
||||
title: '默认画布',
|
||||
viewport: { x: 0, y: 0, scale: 1 },
|
||||
layers: [],
|
||||
updatedAt: '2026-07-18T00:00:00.000Z',
|
||||
},
|
||||
viewport: { x: 0, y: 0, scale: 1 },
|
||||
layers: [],
|
||||
resources: [],
|
||||
updatedAt: '2026-07-18T00:00:00.000Z',
|
||||
},
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByRole('status', { name: '拆图状态' }).textContent,
|
||||
).toBe('空闲');
|
||||
});
|
||||
});
|
||||
|
||||
it('queues background removal for private character images', async () => {
|
||||
resolveEditorImageReferenceDataUrlMock.mockResolvedValueOnce(
|
||||
'data:image/png;base64,resolved-character',
|
||||
|
||||
@@ -670,6 +670,8 @@ export function useImageCanvasGenerationWorkflow({
|
||||
}, []);
|
||||
const previousTaskCountRef = useRef(canvasGenerationDialogs.length);
|
||||
const splittingIconSpritesheetLayerIdsRef = useRef(new Set<string>());
|
||||
const [splittingIconSpritesheetLayerIds, setSplittingIconSpritesheetLayerIds] =
|
||||
useState<Set<string>>(() => new Set());
|
||||
const [isSpecMenuOpen, setIsSpecMenuOpen] = useState(false);
|
||||
const [isGenerationReferenceMenuOpen, setIsGenerationReferenceMenuOpen] =
|
||||
useState(false);
|
||||
@@ -1724,6 +1726,11 @@ export function useImageCanvasGenerationWorkflow({
|
||||
return;
|
||||
}
|
||||
splittingIconSpritesheetLayerIdsRef.current.add(sourceLayer.id);
|
||||
setSplittingIconSpritesheetLayerIds((currentLayerIds) => {
|
||||
const nextLayerIds = new Set(currentLayerIds);
|
||||
nextLayerIds.add(sourceLayer.id);
|
||||
return nextLayerIds;
|
||||
});
|
||||
closeGenerationTransientState();
|
||||
setImageContextMenu(null);
|
||||
setMetadataLayer(null);
|
||||
@@ -1757,6 +1764,14 @@ export function useImageCanvasGenerationWorkflow({
|
||||
);
|
||||
} finally {
|
||||
splittingIconSpritesheetLayerIdsRef.current.delete(sourceLayer.id);
|
||||
setSplittingIconSpritesheetLayerIds((currentLayerIds) => {
|
||||
if (!currentLayerIds.has(sourceLayer.id)) {
|
||||
return currentLayerIds;
|
||||
}
|
||||
const nextLayerIds = new Set(currentLayerIds);
|
||||
nextLayerIds.delete(sourceLayer.id);
|
||||
return nextLayerIds;
|
||||
});
|
||||
}
|
||||
},
|
||||
[
|
||||
@@ -2529,6 +2544,7 @@ export function useImageCanvasGenerationWorkflow({
|
||||
startCropExpandFrameResize,
|
||||
removeSelectedLayerBackground,
|
||||
splitSelectedIconSpritesheet,
|
||||
splittingIconSpritesheetLayerIds,
|
||||
taskListRefreshKey,
|
||||
refreshTaskList,
|
||||
isTaskSidebarOpen,
|
||||
@@ -2631,6 +2647,7 @@ export function useImageCanvasGenerationWorkflow({
|
||||
quickEditSourceLayer,
|
||||
removeSelectedLayerBackground,
|
||||
splitSelectedIconSpritesheet,
|
||||
splittingIconSpritesheetLayerIds,
|
||||
submitCharacterAnimation,
|
||||
submitCropExpand,
|
||||
submitIconSpritesheetGeneration,
|
||||
|
||||
Reference in New Issue
Block a user