diff --git a/src/components/image-editor/ImageCanvasBasicGenerationComposerView.tsx b/src/components/image-editor/ImageCanvasBasicGenerationComposerView.tsx index 2a58554a1..fad81478e 100644 --- a/src/components/image-editor/ImageCanvasBasicGenerationComposerView.tsx +++ b/src/components/image-editor/ImageCanvasBasicGenerationComposerView.tsx @@ -60,6 +60,11 @@ type ImageCanvasBasicGenerationComposerViewProps = { ) => CSSProperties; onRequestUpload: (target: UploadTarget) => void; onPickReferenceFromCanvas?: () => void; + /** + * 从项目素材库选择参考图。传入时参考图来源菜单多一项, + * 点击由宿主打开独立的参考图选择弹窗。 + */ + onRequestProjectAssetPicker?: () => void; onToggleReferenceMenu?: () => void; onRememberImageModel?: (model: string) => void; hasPendingImageReferenceUploads?: boolean; @@ -115,6 +120,7 @@ export function ImageCanvasBasicGenerationComposerView({ buildPortalMenuStyle = () => ({}), onRequestUpload, onPickReferenceFromCanvas, + onRequestProjectAssetPicker, onToggleReferenceMenu, onRememberImageModel = () => {}, hasPendingImageReferenceUploads = false, @@ -419,6 +425,17 @@ export function ImageCanvasBasicGenerationComposerView({ > 上传图片 + {onRequestProjectAssetPicker ? ( + { + setIsGenerationReferenceMenuOpen?.(false); + setIsPickingGenerationReferenceFromCanvas?.(false); + onRequestProjectAssetPicker(); + }} + > + 从项目素材中选择 + + ) : null} , ) : null} diff --git a/src/components/image-editor/ImageCanvasEditorView.tsx b/src/components/image-editor/ImageCanvasEditorView.tsx index 5abf3d6cf..8aca4173e 100644 --- a/src/components/image-editor/ImageCanvasEditorView.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.tsx @@ -1465,6 +1465,7 @@ export function ImageCanvasEditorView({ setMetadataLayer, setImageContextMenu, requestUpload, + projectAssets: assets, persistGeneratedAsset, persistUpdatedLayerResource, projectId, diff --git a/src/components/image-editor/ImageCanvasGenerationComposerView.tsx b/src/components/image-editor/ImageCanvasGenerationComposerView.tsx index e0a5406ae..883a8499f 100644 --- a/src/components/image-editor/ImageCanvasGenerationComposerView.tsx +++ b/src/components/image-editor/ImageCanvasGenerationComposerView.tsx @@ -173,6 +173,8 @@ type ImageCanvasGenerationComposerViewProps = { setIsPickingUiDesignSpecFromCanvas: Dispatch>; onOpenSpecDialog: (specType: SpecGenerationType) => void; onRequestUpload: (target: UploadTarget) => void; + /** 参考图来源菜单里的「从项目素材中选择」;不传则不显示该项。 */ + onRequestProjectAssetPicker?: () => void; onSubmitImageGeneration: (dialog: GenerateDialogState) => void; onSubmitIconSpritesheetGeneration: (dialog: GenerateDialogState) => void; onSubmitQuickEdit: () => void; @@ -1489,6 +1491,7 @@ export function ImageCanvasGenerationComposerView({ hasPendingImageReferenceUploads = false, onOpenSpecDialog, onRequestUpload, + onRequestProjectAssetPicker, onSubmitImageGeneration, onSubmitIconSpritesheetGeneration, onSubmitQuickEdit, @@ -1563,6 +1566,7 @@ export function ImageCanvasGenerationComposerView({ renderEditorPortal={renderEditorPortal} buildPortalMenuStyle={buildPortalMenuStyle} onRequestUpload={onRequestUpload} + onRequestProjectAssetPicker={onRequestProjectAssetPicker} onToggleReferenceMenu={() => setIsGenerationReferenceMenuOpen((open) => !open) } diff --git a/src/components/image-editor/ImageCanvasGenerationDialogModel.ts b/src/components/image-editor/ImageCanvasGenerationDialogModel.ts index b59c7187b..6c6a5fb0f 100644 --- a/src/components/image-editor/ImageCanvasGenerationDialogModel.ts +++ b/src/components/image-editor/ImageCanvasGenerationDialogModel.ts @@ -13,6 +13,7 @@ import type { CanvasLayer, CanvasViewport, CharacterAnimationPanelState, + CharacterReferenceImage, GenerateDialogState, PublicationMaterialsWorkflowId, QuickEditPanelState, @@ -56,6 +57,7 @@ import { SPEC_FRAME_ORIGINAL_SIZE, } from './ImageCanvasGenerationModel'; import { getPublicationMaterialsWorkflow } from './ImageCanvasPublicationMaterialsModel'; +import { isProjectAssetPickerReference } from './projectAssetReferencePickerModel'; type CanvasSize = { width: number; height: number }; type SourceGenerationDialogDraftContext = { @@ -2208,6 +2210,38 @@ export function appendGenerationReference( : dialog; } +/** + * 参考图弹窗确认:把「从项目素材中选择」进来的参考图整组替换, + * 画布点选与上传得到的参考图保持原样。 + * 素材 id 通过 `sourceAssetId` 随引用进入提交数据,即本次任务的参考图 ID 快照。 + */ +export function replaceProjectAssetPickerReferences( + dialog: GenerateDialogState | null, + references: CharacterReferenceImage[], +): GenerateDialogState | null { + if ( + !dialog || + (dialog.mode !== 'generate' && + dialog.mode !== 'scene' && + dialog.mode !== 'quick-edit' && + dialog.mode !== 'icon' && + dialog.mode !== 'ui-design') + ) { + return dialog; + } + return { + ...resetFailedGenerationDialog(dialog), + generationReferences: appendLimitedImageReferences( + (dialog.generationReferences ?? []).filter( + (reference) => !isProjectAssetPickerReference(reference), + ), + references, + resolveDialogExtraImageReferenceLimit(dialog), + ), + composerOpen: true, + }; +} + export function appendPublicationReference( dialog: GenerateDialogState | null, layer: CanvasLayer, diff --git a/src/components/image-editor/ImageCanvasProjectAssetPickerDialog.test.tsx b/src/components/image-editor/ImageCanvasProjectAssetPickerDialog.test.tsx new file mode 100644 index 000000000..89dca93c4 --- /dev/null +++ b/src/components/image-editor/ImageCanvasProjectAssetPickerDialog.test.tsx @@ -0,0 +1,189 @@ +/* @vitest-environment jsdom */ + +import { fireEvent, render, screen, within } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import type { EditorAsset } from './ImageCanvasEditorTypes'; +import { ImageCanvasProjectAssetPickerDialog } from './ImageCanvasProjectAssetPickerDialog'; + +function createAsset(overrides: Partial): EditorAsset { + return { + id: 'asset-1', + label: '素材 1', + src: 'https://example.test/asset-1.png', + mediaType: 'image', + width: 64, + height: 64, + folderId: 'folder-1', + sourceKind: 'uploaded', + sourceType: 'uploaded', + persisted: true, + ...overrides, + }; +} + +const ASSETS: EditorAsset[] = [ + createAsset({ id: 'asset-hero', label: '主角立绘' }), + createAsset({ + id: 'asset-town', + label: '小镇背景', + mediaType: 'video', + src: 'https://example.test/town.mp4', + }), + createAsset({ + id: 'asset-bgm', + label: '背景音乐', + mediaType: 'audio', + src: 'https://example.test/bgm.mp3', + }), +]; + +describe('ImageCanvasProjectAssetPickerDialog', () => { + it('selects several assets, shows the count and returns the id snapshot', () => { + const onConfirm = vi.fn(); + render( + {}} + onConfirm={onConfirm} + />, + ); + + fireEvent.click(screen.getByRole('option', { name: '选择参考图主角立绘' })); + fireEvent.click(screen.getByRole('option', { name: '选择参考图小镇背景' })); + + expect(screen.getByText('已选 2 个')).toBeTruthy(); + expect( + screen + .getByRole('option', { name: '选择参考图主角立绘' }) + .getAttribute('aria-selected'), + ).toBe('true'); + + fireEvent.click(screen.getByRole('button', { name: '确认选择参考图' })); + expect(onConfirm).toHaveBeenCalledWith(['asset-hero', 'asset-town']); + }); + + it('clears only the current selection and keeps cancel free of side effects', () => { + const onCancel = vi.fn(); + const onConfirm = vi.fn(); + render( + , + ); + + expect(screen.getByText('已选 1 个')).toBeTruthy(); + fireEvent.click(screen.getByRole('button', { name: '清空' })); + expect(screen.getByText('已选 0 个')).toBeTruthy(); + expect(onConfirm).not.toHaveBeenCalled(); + + fireEvent.click(screen.getByRole('button', { name: '取消' })); + expect(onCancel).toHaveBeenCalledTimes(1); + expect(onConfirm).not.toHaveBeenCalled(); + }); + + it('deselects one asset from the selected chips', () => { + render( + {}} + onConfirm={() => {}} + />, + ); + + const selectedRow = screen.getByLabelText('已选参考图'); + fireEvent.click( + within(selectedRow).getByRole('button', { name: '取消参考图主角立绘' }), + ); + + expect(screen.getByText('已选 1 个')).toBeTruthy(); + }); + + it('filters by category and search text with dialog-local state', () => { + render( + {}} + onConfirm={() => {}} + />, + ); + + fireEvent.click(screen.getByRole('button', { name: '视频' })); + expect( + screen.queryByRole('option', { name: '选择参考图主角立绘' }), + ).toBeNull(); + expect( + screen.getByRole('option', { name: '选择参考图小镇背景' }), + ).toBeTruthy(); + + fireEvent.click(screen.getByRole('button', { name: '全部' })); + fireEvent.change(screen.getByLabelText('搜索参考图素材'), { + target: { value: '音乐' }, + }); + expect( + screen.getByRole('option', { name: '选择参考图背景音乐' }), + ).toBeTruthy(); + expect( + screen.queryByRole('option', { name: '选择参考图小镇背景' }), + ).toBeNull(); + }); + + it('restarts from the caller-provided selection each time it opens', () => { + const { rerender } = render( + {}} + onConfirm={() => {}} + />, + ); + fireEvent.click(screen.getByRole('button', { name: '清空' })); + expect(screen.getByText('已选 0 个')).toBeTruthy(); + + rerender( + {}} + onConfirm={() => {}} + />, + ); + rerender( + {}} + onConfirm={() => {}} + />, + ); + + expect(screen.getByText('已选 1 个')).toBeTruthy(); + }); + + it('shows an empty state when the project has no registered assets', () => { + render( + {}} + onConfirm={() => {}} + />, + ); + + expect(screen.getByText('当前项目还没有已登记素材')).toBeTruthy(); + }); +}); diff --git a/src/components/image-editor/ImageCanvasProjectAssetPickerDialog.tsx b/src/components/image-editor/ImageCanvasProjectAssetPickerDialog.tsx new file mode 100644 index 000000000..b49a1a872 --- /dev/null +++ b/src/components/image-editor/ImageCanvasProjectAssetPickerDialog.tsx @@ -0,0 +1,221 @@ +import { Check, ImageIcon, Music, Search, Video } from 'lucide-react'; +import { useEffect, useMemo, useState } from 'react'; + +import { PlatformActionButton } from '../../../packages/shared/src/components/PlatformActionButton'; +import { PlatformResourceFilterBar } from '../../../packages/shared/src/components/PlatformResourceFilterBar'; +import { UnifiedModal } from '../common/UnifiedModal'; +import type { EditorAsset } from './ImageCanvasEditorTypes'; +import { + PROJECT_ASSET_PICKER_CATEGORY_OPTIONS, + projectAssetMatchesPickerFilter, + type ProjectAssetPickerCategory, + projectAssetPickerCategory, +} from './projectAssetReferencePickerModel'; + +type ImageCanvasProjectAssetPickerDialogProps = { + open: boolean; + /** 项目已登记素材;弹窗只负责选择,不改素材本身。 */ + assets: readonly EditorAsset[]; + /** 打开时的初始选择,取消时原选择保持不变。 */ + selectedAssetIds: readonly string[]; + onCancel: () => void; + onConfirm: (assetIds: string[]) => void; +}; + +function assetIcon(asset: EditorAsset) { + const category = projectAssetPickerCategory(asset); + if (category === 'video') { + return