合并编辑器素材库分支
同步编辑器素材库最新资源公开展示能力 保留master已有回合入提交并整合当前分支改动 # Conflicts: # docs/project-memory/shared-memory/pitfalls.md # src/components/image-editor/ImageCanvasEditorAssetsIntegration.test.tsx # src/components/image-editor/ImageCanvasEditorModel.ts # src/components/image-editor/ImageCanvasEditorTypes.ts
This commit is contained in:
@@ -3,12 +3,13 @@
|
||||
import { render, screen, waitFor, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import type { ContextType } from 'react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { AuthUiContext } from '../auth/AuthUiContext';
|
||||
import { CreationLandingView } from './CreationLandingView';
|
||||
|
||||
const listEditorProjectsMock = vi.hoisted(() => vi.fn());
|
||||
const listPublicEditorProjectResourcesMock = vi.hoisted(() => vi.fn());
|
||||
const createEditorProjectMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
type AuthValue = NonNullable<ContextType<typeof AuthUiContext>>;
|
||||
@@ -45,6 +46,7 @@ function createAuthValue(overrides: Partial<AuthValue> = {}): AuthValue {
|
||||
|
||||
vi.mock('../../services/image-editor/editorProjectClient', () => ({
|
||||
listEditorProjects: listEditorProjectsMock,
|
||||
listPublicEditorProjectResources: listPublicEditorProjectResourcesMock,
|
||||
createEditorProject: createEditorProjectMock,
|
||||
}));
|
||||
|
||||
@@ -88,8 +90,13 @@ function renderCreationLanding({
|
||||
}
|
||||
|
||||
describe('CreationLandingView', () => {
|
||||
beforeEach(() => {
|
||||
listPublicEditorProjectResourcesMock.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
listEditorProjectsMock.mockReset();
|
||||
listPublicEditorProjectResourcesMock.mockReset();
|
||||
createEditorProjectMock.mockReset();
|
||||
});
|
||||
|
||||
@@ -212,28 +219,24 @@ describe('CreationLandingView', () => {
|
||||
|
||||
it('renders generated project resources in showcase tabs without mock rows', async () => {
|
||||
const user = userEvent.setup();
|
||||
listEditorProjectsMock.mockResolvedValueOnce([
|
||||
listEditorProjectsMock.mockResolvedValueOnce(projectItems);
|
||||
listPublicEditorProjectResourcesMock.mockResolvedValueOnce([
|
||||
{
|
||||
...projectItems[0]!,
|
||||
resources: [
|
||||
{
|
||||
resourceId: 'resource-character',
|
||||
projectId: 'project-newest',
|
||||
label: '角色英雄',
|
||||
imageSrc: 'data:image/png;base64,character',
|
||||
width: 512,
|
||||
height: 512,
|
||||
sourceType: 'generated',
|
||||
prompt: 'character hero prompt',
|
||||
actualPrompt: null,
|
||||
model: 'character-model',
|
||||
provider: 'provider',
|
||||
taskId: 'task-1',
|
||||
assetKind: 'character',
|
||||
authorName: '创作者A',
|
||||
priceMudPoints: 20,
|
||||
},
|
||||
],
|
||||
resourceId: 'resource-character',
|
||||
projectId: 'project-newest',
|
||||
label: '角色英雄',
|
||||
imageSrc: 'data:image/png;base64,character',
|
||||
width: 512,
|
||||
height: 512,
|
||||
sourceType: 'generated',
|
||||
prompt: 'character hero prompt',
|
||||
actualPrompt: null,
|
||||
model: 'character-model',
|
||||
provider: 'provider',
|
||||
taskId: 'task-1',
|
||||
assetKind: 'character',
|
||||
authorName: '创作者A',
|
||||
priceMudPoints: 20,
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -251,55 +254,51 @@ describe('CreationLandingView', () => {
|
||||
|
||||
it('opens a fullscreen asset preview modal with thumbnail navigation', async () => {
|
||||
const user = userEvent.setup();
|
||||
listEditorProjectsMock.mockResolvedValueOnce([
|
||||
listEditorProjectsMock.mockResolvedValueOnce(projectItems);
|
||||
listPublicEditorProjectResourcesMock.mockResolvedValueOnce([
|
||||
{
|
||||
...projectItems[0]!,
|
||||
resources: [
|
||||
{
|
||||
resourceId: 'resource-character',
|
||||
projectId: 'project-newest',
|
||||
label: '角色英雄',
|
||||
imageSrc: 'data:image/png;base64,character',
|
||||
width: 512,
|
||||
height: 512,
|
||||
sourceType: 'generated',
|
||||
prompt: 'character hero prompt',
|
||||
actualPrompt: null,
|
||||
model: 'character-model',
|
||||
provider: 'provider',
|
||||
taskId: 'task-1',
|
||||
assetKind: 'character',
|
||||
authorName: '创作者A',
|
||||
priceMudPoints: 20,
|
||||
},
|
||||
{
|
||||
resourceId: 'resource-animation',
|
||||
projectId: 'project-newest',
|
||||
label: '待机动画',
|
||||
imageSrc: 'data:image/png;base64,animation',
|
||||
width: 512,
|
||||
height: 512,
|
||||
sourceType: 'generated',
|
||||
prompt: 'idle animation prompt',
|
||||
actualPrompt: null,
|
||||
model: 'seedance2.0-fast',
|
||||
provider: 'ark',
|
||||
taskId: 'task-2',
|
||||
assetKind: 'character-animation',
|
||||
priceMudPoints: 40,
|
||||
generationInputs: {
|
||||
fields: [{ title: '动作描述', value: '待机动作' }],
|
||||
references: [
|
||||
{
|
||||
title: '角色图片',
|
||||
label: '角色英雄',
|
||||
refType: 'project-resource',
|
||||
refId: 'resource-character',
|
||||
},
|
||||
],
|
||||
resourceId: 'resource-character',
|
||||
projectId: 'project-newest',
|
||||
label: '角色英雄',
|
||||
imageSrc: 'data:image/png;base64,character',
|
||||
width: 512,
|
||||
height: 512,
|
||||
sourceType: 'generated',
|
||||
prompt: 'character hero prompt',
|
||||
actualPrompt: null,
|
||||
model: 'character-model',
|
||||
provider: 'provider',
|
||||
taskId: 'task-1',
|
||||
assetKind: 'character',
|
||||
authorName: '创作者A',
|
||||
priceMudPoints: 20,
|
||||
},
|
||||
{
|
||||
resourceId: 'resource-animation',
|
||||
projectId: 'project-newest',
|
||||
label: '待机动画',
|
||||
imageSrc: 'data:image/png;base64,animation',
|
||||
width: 512,
|
||||
height: 512,
|
||||
sourceType: 'generated',
|
||||
prompt: 'idle animation prompt',
|
||||
actualPrompt: null,
|
||||
model: 'seedance2.0-fast',
|
||||
provider: 'ark',
|
||||
taskId: 'task-2',
|
||||
assetKind: 'character-animation',
|
||||
priceMudPoints: 40,
|
||||
generationInputs: {
|
||||
fields: [{ title: '动作描述', value: '待机动作' }],
|
||||
references: [
|
||||
{
|
||||
title: '角色图片',
|
||||
label: '角色英雄',
|
||||
refType: 'project-resource',
|
||||
refId: 'resource-character',
|
||||
},
|
||||
},
|
||||
],
|
||||
],
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -325,58 +324,49 @@ describe('CreationLandingView', () => {
|
||||
});
|
||||
|
||||
it('uses Taonier featured as the generated project resource waterfall instead of creation entries', async () => {
|
||||
listEditorProjectsMock.mockResolvedValueOnce([
|
||||
listEditorProjectsMock.mockResolvedValueOnce(projectItems);
|
||||
listPublicEditorProjectResourcesMock.mockResolvedValueOnce([
|
||||
{
|
||||
...projectItems[0]!,
|
||||
resources: [
|
||||
{
|
||||
resourceId: 'resource-pack-a',
|
||||
projectId: 'project-newest',
|
||||
label: '素材包 A',
|
||||
imageSrc: 'data:image/png;base64,a',
|
||||
width: 512,
|
||||
height: 640,
|
||||
sourceType: 'generated',
|
||||
prompt: '素材包提示词',
|
||||
actualPrompt: null,
|
||||
model: null,
|
||||
provider: null,
|
||||
taskId: null,
|
||||
},
|
||||
{
|
||||
resourceId: 'resource-uploaded',
|
||||
projectId: 'project-newest',
|
||||
label: '上传素材',
|
||||
imageSrc: 'data:image/png;base64,uploaded',
|
||||
width: 512,
|
||||
height: 512,
|
||||
sourceType: 'uploaded',
|
||||
prompt: null,
|
||||
actualPrompt: null,
|
||||
model: null,
|
||||
provider: null,
|
||||
taskId: null,
|
||||
},
|
||||
],
|
||||
resourceId: 'resource-pack-a',
|
||||
projectId: 'project-newest',
|
||||
label: '素材包 A',
|
||||
imageSrc: 'data:image/png;base64,a',
|
||||
width: 512,
|
||||
height: 640,
|
||||
sourceType: 'generated',
|
||||
prompt: '素材包提示词',
|
||||
actualPrompt: null,
|
||||
model: null,
|
||||
provider: null,
|
||||
taskId: null,
|
||||
},
|
||||
{
|
||||
...projectItems[1]!,
|
||||
resources: [
|
||||
{
|
||||
resourceId: 'resource-pack-b',
|
||||
projectId: 'project-old',
|
||||
label: '素材包 B',
|
||||
imageSrc: 'data:image/png;base64,b',
|
||||
width: 512,
|
||||
height: 512,
|
||||
sourceType: 'generated',
|
||||
prompt: '第二组素材',
|
||||
actualPrompt: null,
|
||||
model: null,
|
||||
provider: null,
|
||||
taskId: null,
|
||||
},
|
||||
],
|
||||
resourceId: 'resource-uploaded',
|
||||
projectId: 'project-newest',
|
||||
label: '上传素材',
|
||||
imageSrc: 'data:image/png;base64,uploaded',
|
||||
width: 512,
|
||||
height: 512,
|
||||
sourceType: 'uploaded',
|
||||
prompt: null,
|
||||
actualPrompt: null,
|
||||
model: null,
|
||||
provider: null,
|
||||
taskId: null,
|
||||
},
|
||||
{
|
||||
resourceId: 'resource-pack-b',
|
||||
projectId: 'project-old',
|
||||
label: '素材包 B',
|
||||
imageSrc: 'data:image/png;base64,b',
|
||||
width: 512,
|
||||
height: 512,
|
||||
sourceType: 'generated',
|
||||
prompt: '第二组素材',
|
||||
actualPrompt: null,
|
||||
model: null,
|
||||
provider: null,
|
||||
taskId: null,
|
||||
},
|
||||
]);
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import {
|
||||
createEditorProject,
|
||||
listPublicEditorProjectResources,
|
||||
listEditorProjects,
|
||||
type EditorProjectResourceSnapshot,
|
||||
type EditorProjectSnapshot,
|
||||
@@ -364,6 +365,7 @@ export function CreationLandingView({
|
||||
[],
|
||||
);
|
||||
const [isLoadingProjects, setIsLoadingProjects] = useState(false);
|
||||
const [isLoadingShowcase, setIsLoadingShowcase] = useState(false);
|
||||
const [isCreatingProject, setIsCreatingProject] = useState(false);
|
||||
const [projectError, setProjectError] = useState<string | null>(null);
|
||||
const [projectShowcaseResources, setProjectShowcaseResources] = useState<
|
||||
@@ -379,14 +381,11 @@ export function CreationLandingView({
|
||||
const refreshRecentProjects = useCallback(() => {
|
||||
if (!isAuthenticated) {
|
||||
setRecentProjects([]);
|
||||
setProjectShowcaseResources([]);
|
||||
setProjectError(null);
|
||||
setAssetError(null);
|
||||
return;
|
||||
}
|
||||
setIsLoadingProjects(true);
|
||||
setProjectError(null);
|
||||
setAssetError(null);
|
||||
listEditorProjects()
|
||||
.then((projects) => {
|
||||
const sortedProjects = [...projects].sort(
|
||||
@@ -395,9 +394,6 @@ export function CreationLandingView({
|
||||
new Date(left.updatedAt).getTime(),
|
||||
);
|
||||
setRecentProjects(sortedProjects.slice(0, 4));
|
||||
setProjectShowcaseResources(
|
||||
sortedProjects.flatMap((project) => project.resources ?? []),
|
||||
);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (isUnauthorizedError(error)) {
|
||||
@@ -409,7 +405,6 @@ export function CreationLandingView({
|
||||
setProjectError(
|
||||
message,
|
||||
);
|
||||
setAssetError(message);
|
||||
})
|
||||
.finally(() => setIsLoadingProjects(false));
|
||||
}, [authUi, isAuthenticated]);
|
||||
@@ -418,6 +413,24 @@ export function CreationLandingView({
|
||||
refreshRecentProjects();
|
||||
}, [refreshRecentProjects]);
|
||||
|
||||
const refreshShowcaseResources = useCallback(() => {
|
||||
setIsLoadingShowcase(true);
|
||||
setAssetError(null);
|
||||
listPublicEditorProjectResources()
|
||||
.then((resources) => setProjectShowcaseResources(resources))
|
||||
.catch((error: unknown) => {
|
||||
setProjectShowcaseResources([]);
|
||||
setAssetError(
|
||||
error instanceof Error ? error.message : '读取陶泥儿精选失败',
|
||||
);
|
||||
})
|
||||
.finally(() => setIsLoadingShowcase(false));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
refreshShowcaseResources();
|
||||
}, [refreshShowcaseResources]);
|
||||
|
||||
const createProject = useCallback(() => {
|
||||
if (authUi && !authUi.user) {
|
||||
authUi.openLoginModal(createProject);
|
||||
@@ -444,12 +457,12 @@ export function CreationLandingView({
|
||||
buildCreationShowcaseItems({
|
||||
activeTab: activeShowcaseTab,
|
||||
projectResources: projectShowcaseResources,
|
||||
fallbackAuthorName: authUi?.user?.displayName,
|
||||
fallbackAuthorName: null,
|
||||
}),
|
||||
[activeShowcaseTab, projectShowcaseResources, authUi?.user?.displayName],
|
||||
[activeShowcaseTab, projectShowcaseResources],
|
||||
);
|
||||
|
||||
const isShowcaseLoading = isAuthenticated && isLoadingProjects;
|
||||
const isShowcaseLoading = isLoadingShowcase;
|
||||
const showcaseEmptyText = '暂无素材';
|
||||
|
||||
const renderAssetShowcase = () => {
|
||||
|
||||
@@ -53,6 +53,63 @@ describe('creationShowcaseModel', () => {
|
||||
expect(items[0]?.label).toBe('画布生成素材');
|
||||
});
|
||||
|
||||
it('filters out generated resources that are not public showcase enabled', () => {
|
||||
const items = buildCreationShowcaseItems({
|
||||
activeTab: 'packs',
|
||||
projectResources: [
|
||||
createResource({
|
||||
resourceId: 'public-generated',
|
||||
label: '公开素材',
|
||||
sourceType: 'generated',
|
||||
publicShowcaseEnabled: true,
|
||||
}),
|
||||
createResource({
|
||||
resourceId: 'private-generated',
|
||||
label: '不公开素材',
|
||||
sourceType: 'generated',
|
||||
publicShowcaseEnabled: false,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
expect(items).toHaveLength(1);
|
||||
expect(items[0]?.label).toBe('公开素材');
|
||||
});
|
||||
|
||||
it('deduplicates canvas copies that point back to the same generated resource', () => {
|
||||
const items = buildCreationShowcaseItems({
|
||||
activeTab: 'characters',
|
||||
projectResources: [
|
||||
createResource({
|
||||
resourceId: 'resource-original',
|
||||
label: '原始角色图',
|
||||
imageSrc: '/generated/character.png',
|
||||
objectKey: 'generated/character.png',
|
||||
assetObjectId: 'asset-object-character',
|
||||
assetKind: 'character',
|
||||
sourceType: 'generated',
|
||||
publicShowcaseEnabled: true,
|
||||
}),
|
||||
createResource({
|
||||
resourceId: 'resource-copy',
|
||||
label: '拖拽副本角色图',
|
||||
imageSrc:
|
||||
'https://oss.example.test/generated/character.png?x-oss-signature=1',
|
||||
objectKey: '/generated/character.png',
|
||||
assetObjectId: 'asset-object-character',
|
||||
assetKind: 'character',
|
||||
sourceType: 'generated',
|
||||
sourceResourceId: 'resource-original',
|
||||
publicShowcaseEnabled: true,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
expect(items).toHaveLength(1);
|
||||
expect(items[0]?.previews).toHaveLength(1);
|
||||
expect(items[0]?.previews[0]?.id).toBe('resource-original');
|
||||
});
|
||||
|
||||
it('groups pack items by the same referenced spec image and sums known cost', () => {
|
||||
const items = buildCreationShowcaseItems({
|
||||
activeTab: 'packs',
|
||||
|
||||
@@ -210,15 +210,70 @@ function projectResourceToShowcaseAsset(
|
||||
} as EditorAssetSnapshot;
|
||||
}
|
||||
|
||||
function normalizeShowcaseMediaRef(value: string | null | undefined) {
|
||||
const trimmedValue = value?.trim().split(/[?#]/u)[0]?.trim() ?? '';
|
||||
if (!trimmedValue) {
|
||||
return '';
|
||||
}
|
||||
const pathValue = /^https?:\/\//iu.test(trimmedValue)
|
||||
? (() => {
|
||||
try {
|
||||
return new URL(trimmedValue).pathname;
|
||||
} catch {
|
||||
return trimmedValue;
|
||||
}
|
||||
})()
|
||||
: trimmedValue;
|
||||
return pathValue.replace(/^\/+/u, '');
|
||||
}
|
||||
|
||||
function getProjectResourceMediaKey(resource: EditorProjectResourceSnapshot) {
|
||||
const assetObjectId = resource.assetObjectId?.trim();
|
||||
if (assetObjectId) {
|
||||
return `asset-object:${assetObjectId}`;
|
||||
}
|
||||
const objectKey = normalizeShowcaseMediaRef(resource.objectKey);
|
||||
if (objectKey) {
|
||||
return `object:${objectKey}`;
|
||||
}
|
||||
const imageSrc = normalizeShowcaseMediaRef(resource.imageSrc);
|
||||
return imageSrc ? `src:${imageSrc}` : '';
|
||||
}
|
||||
|
||||
function isCopiedProjectResource(
|
||||
resource: EditorProjectResourceSnapshot,
|
||||
resourceById: Map<string, EditorProjectResourceSnapshot>,
|
||||
) {
|
||||
const sourceResourceId = resource.sourceResourceId?.trim();
|
||||
if (!sourceResourceId) {
|
||||
return false;
|
||||
}
|
||||
const sourceResource = resourceById.get(sourceResourceId);
|
||||
if (!sourceResource) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
getProjectResourceMediaKey(sourceResource) ===
|
||||
getProjectResourceMediaKey(resource)
|
||||
);
|
||||
}
|
||||
|
||||
function buildGeneratedProjectResourceAssets(
|
||||
resources: EditorProjectResourceSnapshot[],
|
||||
) {
|
||||
const seenResourceIds = new Set<string>();
|
||||
const resourceById = new Map(
|
||||
resources
|
||||
.filter((resource) => resource.resourceId.trim())
|
||||
.map((resource) => [resource.resourceId, resource]),
|
||||
);
|
||||
|
||||
return resources
|
||||
.filter(
|
||||
(resource) =>
|
||||
resource.sourceType === 'generated' && resource.imageSrc.trim(),
|
||||
resource.publicShowcaseEnabled !== false &&
|
||||
resource.sourceType === 'generated' &&
|
||||
resource.imageSrc.trim(),
|
||||
)
|
||||
.filter((resource) => {
|
||||
if (seenResourceIds.has(resource.resourceId)) {
|
||||
@@ -227,6 +282,7 @@ function buildGeneratedProjectResourceAssets(
|
||||
seenResourceIds.add(resource.resourceId);
|
||||
return true;
|
||||
})
|
||||
.filter((resource) => !isCopiedProjectResource(resource, resourceById))
|
||||
.map(projectResourceToShowcaseAsset);
|
||||
}
|
||||
|
||||
|
||||
@@ -60,6 +60,10 @@ type ImageCanvasAssetFolderSectionViewProps = {
|
||||
startRenamingAsset: (asset: EditorAsset) => void;
|
||||
commitAssetRename: (asset: EditorAsset) => void;
|
||||
deleteUploadedAsset: (asset: EditorAsset) => void;
|
||||
setAssetPublicShowcaseEnabled: (
|
||||
asset: EditorAsset,
|
||||
publicShowcaseEnabled: boolean,
|
||||
) => void;
|
||||
toggleAssetSelected: (assetId: string) => void;
|
||||
addAssetLayer: (asset: EditorAsset) => void;
|
||||
};
|
||||
@@ -90,6 +94,7 @@ export function ImageCanvasAssetFolderSectionView({
|
||||
startRenamingAsset,
|
||||
commitAssetRename,
|
||||
deleteUploadedAsset,
|
||||
setAssetPublicShowcaseEnabled,
|
||||
toggleAssetSelected,
|
||||
addAssetLayer,
|
||||
}: ImageCanvasAssetFolderSectionViewProps) {
|
||||
@@ -248,6 +253,7 @@ export function ImageCanvasAssetFolderSectionView({
|
||||
startRenamingAsset={startRenamingAsset}
|
||||
commitAssetRename={commitAssetRename}
|
||||
deleteUploadedAsset={deleteUploadedAsset}
|
||||
setAssetPublicShowcaseEnabled={setAssetPublicShowcaseEnabled}
|
||||
toggleAssetSelected={toggleAssetSelected}
|
||||
addAssetLayer={addAssetLayer}
|
||||
/>
|
||||
|
||||
@@ -90,6 +90,10 @@ export type ImageCanvasAssetLibraryPanelViewProps = {
|
||||
startRenamingAsset: (asset: EditorAsset) => void;
|
||||
commitAssetRename: (asset: EditorAsset) => void;
|
||||
deleteUploadedAsset: (asset: EditorAsset) => void;
|
||||
setAssetPublicShowcaseEnabled: (
|
||||
asset: EditorAsset,
|
||||
publicShowcaseEnabled: boolean,
|
||||
) => void;
|
||||
toggleAssetSelected: (assetId: string) => void;
|
||||
addAssetLayer: (asset: EditorAsset) => void;
|
||||
toggleAllAssetsSelected: () => void;
|
||||
@@ -136,6 +140,7 @@ export function ImageCanvasAssetLibraryPanelView({
|
||||
startRenamingAsset,
|
||||
commitAssetRename,
|
||||
deleteUploadedAsset,
|
||||
setAssetPublicShowcaseEnabled,
|
||||
toggleAssetSelected,
|
||||
addAssetLayer,
|
||||
toggleAllAssetsSelected,
|
||||
@@ -226,6 +231,7 @@ export function ImageCanvasAssetLibraryPanelView({
|
||||
startRenamingAsset={startRenamingAsset}
|
||||
commitAssetRename={commitAssetRename}
|
||||
deleteUploadedAsset={deleteUploadedAsset}
|
||||
setAssetPublicShowcaseEnabled={setAssetPublicShowcaseEnabled}
|
||||
toggleAssetSelected={toggleAssetSelected}
|
||||
addAssetLayer={addAssetLayer}
|
||||
/>
|
||||
|
||||
@@ -56,6 +56,8 @@ function renderAssetRow({
|
||||
addAssetLayer = vi.fn(),
|
||||
toggleAssetSelected = vi.fn(),
|
||||
commitAssetRename = vi.fn(),
|
||||
deleteUploadedAsset = vi.fn(),
|
||||
setAssetPublicShowcaseEnabled = vi.fn(),
|
||||
}: {
|
||||
asset?: EditorAsset;
|
||||
isAssetSelectionMode?: boolean;
|
||||
@@ -65,6 +67,11 @@ function renderAssetRow({
|
||||
addAssetLayer?: (asset: EditorAsset) => void;
|
||||
toggleAssetSelected?: (assetId: string) => void;
|
||||
commitAssetRename?: (asset: EditorAsset) => void;
|
||||
deleteUploadedAsset?: (asset: EditorAsset) => void;
|
||||
setAssetPublicShowcaseEnabled?: (
|
||||
asset: EditorAsset,
|
||||
publicShowcaseEnabled: boolean,
|
||||
) => void;
|
||||
} = {}) {
|
||||
const props = {
|
||||
asset,
|
||||
@@ -82,7 +89,8 @@ function renderAssetRow({
|
||||
moveAssetToFolder: vi.fn(),
|
||||
startRenamingAsset: vi.fn(),
|
||||
commitAssetRename,
|
||||
deleteUploadedAsset: vi.fn(),
|
||||
deleteUploadedAsset,
|
||||
setAssetPublicShowcaseEnabled,
|
||||
toggleAssetSelected,
|
||||
addAssetLayer,
|
||||
};
|
||||
@@ -207,4 +215,84 @@ describe('ImageCanvasAssetRowView', () => {
|
||||
'generated-character-drafts/editor/spec.png',
|
||||
);
|
||||
});
|
||||
|
||||
it('opens asset actions from the right click menu', () => {
|
||||
const asset = createAsset({
|
||||
sourceResourceId: 'resource-asset-1',
|
||||
publicShowcaseEnabled: true,
|
||||
});
|
||||
const deleteUploadedAsset = vi.fn();
|
||||
const setAssetPublicShowcaseEnabled = vi.fn();
|
||||
renderAssetRow({
|
||||
asset,
|
||||
deleteUploadedAsset,
|
||||
setAssetPublicShowcaseEnabled,
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: '删除素材账号素材A' }),
|
||||
).toBeNull();
|
||||
|
||||
fireEvent.contextMenu(screen.getByRole('button', { name: '添加账号素材A' }), {
|
||||
clientX: 120,
|
||||
clientY: 80,
|
||||
});
|
||||
|
||||
const publicMenuItem = screen.getByRole('menuitemcheckbox', {
|
||||
name: /公开展示该素材/u,
|
||||
});
|
||||
expect(publicMenuItem.getAttribute('aria-checked')).toBe('true');
|
||||
|
||||
fireEvent.click(publicMenuItem);
|
||||
expect(setAssetPublicShowcaseEnabled).toHaveBeenCalledWith(asset, false);
|
||||
|
||||
fireEvent.contextMenu(screen.getByRole('button', { name: '添加账号素材A' }), {
|
||||
clientX: 120,
|
||||
clientY: 80,
|
||||
});
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '删除' }));
|
||||
expect(deleteUploadedAsset).toHaveBeenCalledWith(asset);
|
||||
});
|
||||
|
||||
it('disables public showcase toggle when the asset is not linked to a project resource', () => {
|
||||
const setAssetPublicShowcaseEnabled = vi.fn();
|
||||
renderAssetRow({ setAssetPublicShowcaseEnabled });
|
||||
|
||||
fireEvent.contextMenu(screen.getByRole('button', { name: '添加账号素材A' }), {
|
||||
clientX: 120,
|
||||
clientY: 80,
|
||||
});
|
||||
|
||||
const publicMenuItem = screen.getByRole('menuitemcheckbox', {
|
||||
name: /公开展示该素材/u,
|
||||
});
|
||||
expect((publicMenuItem as HTMLButtonElement).disabled).toBe(true);
|
||||
|
||||
fireEvent.click(publicMenuItem);
|
||||
expect(setAssetPublicShowcaseEnabled).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps public showcase toggle enabled for legacy generated assets once a source resource is inferred', () => {
|
||||
const asset = createAsset({
|
||||
sourceType: 'generated',
|
||||
sourceResourceId: 'resource-inferred-from-media',
|
||||
publicShowcaseEnabled: null,
|
||||
});
|
||||
const setAssetPublicShowcaseEnabled = vi.fn();
|
||||
renderAssetRow({ asset, setAssetPublicShowcaseEnabled });
|
||||
|
||||
fireEvent.contextMenu(screen.getByRole('button', { name: '添加账号素材A' }), {
|
||||
clientX: 120,
|
||||
clientY: 80,
|
||||
});
|
||||
|
||||
const publicMenuItem = screen.getByRole('menuitemcheckbox', {
|
||||
name: /公开展示该素材/u,
|
||||
});
|
||||
expect((publicMenuItem as HTMLButtonElement).disabled).toBe(false);
|
||||
expect(publicMenuItem.getAttribute('aria-checked')).toBe('true');
|
||||
|
||||
fireEvent.click(publicMenuItem);
|
||||
expect(setAssetPublicShowcaseEnabled).toHaveBeenCalledWith(asset, false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Check, Music2, Pencil, Trash2, Video, X } from 'lucide-react';
|
||||
import { Check, Music2, Pencil, Video, X } from 'lucide-react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import type { Dispatch, SetStateAction } from 'react';
|
||||
|
||||
import { PlatformTextField } from '../common/PlatformTextField';
|
||||
@@ -41,6 +42,10 @@ export type ImageCanvasAssetRowViewProps = {
|
||||
startRenamingAsset: (asset: EditorAsset) => void;
|
||||
commitAssetRename: (asset: EditorAsset) => void;
|
||||
deleteUploadedAsset: (asset: EditorAsset) => void;
|
||||
setAssetPublicShowcaseEnabled: (
|
||||
asset: EditorAsset,
|
||||
publicShowcaseEnabled: boolean,
|
||||
) => void;
|
||||
toggleAssetSelected: (assetId: string) => void;
|
||||
addAssetLayer: (asset: EditorAsset) => void;
|
||||
};
|
||||
@@ -62,12 +67,51 @@ export function ImageCanvasAssetRowView({
|
||||
startRenamingAsset,
|
||||
commitAssetRename,
|
||||
deleteUploadedAsset,
|
||||
setAssetPublicShowcaseEnabled,
|
||||
toggleAssetSelected,
|
||||
addAssetLayer,
|
||||
}: ImageCanvasAssetRowViewProps) {
|
||||
const rowRef = useRef<HTMLDivElement | null>(null);
|
||||
const [menuPosition, setMenuPosition] = useState<{
|
||||
x: number;
|
||||
y: number;
|
||||
} | null>(null);
|
||||
const isRenaming = renamingAsset?.assetId === asset.id;
|
||||
const isUploadingAsset = asset.uploadStatus === 'uploading';
|
||||
const isFailedUpload = asset.uploadStatus === 'failed';
|
||||
const canShowcaseAsset = Boolean(asset.sourceResourceId);
|
||||
const publicShowcaseEnabled = asset.publicShowcaseEnabled ?? true;
|
||||
|
||||
const closeMenu = useCallback(() => {
|
||||
setMenuPosition(null);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!menuPosition) {
|
||||
return undefined;
|
||||
}
|
||||
const handlePointerDown = (event: PointerEvent) => {
|
||||
if (rowRef.current?.contains(event.target as Node)) {
|
||||
return;
|
||||
}
|
||||
closeMenu();
|
||||
};
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
closeMenu();
|
||||
}
|
||||
};
|
||||
window.addEventListener('pointerdown', handlePointerDown);
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
window.addEventListener('scroll', closeMenu, true);
|
||||
window.addEventListener('resize', closeMenu);
|
||||
return () => {
|
||||
window.removeEventListener('pointerdown', handlePointerDown);
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
window.removeEventListener('scroll', closeMenu, true);
|
||||
window.removeEventListener('resize', closeMenu);
|
||||
};
|
||||
}, [closeMenu, menuPosition]);
|
||||
const uploadProgress = clamp(asset.uploadProgress ?? 0, 0, 100);
|
||||
const mediaPreviewMarker = getCanvasMediaPreviewMarker(asset);
|
||||
const mediaPreviewClassName =
|
||||
@@ -146,19 +190,22 @@ export function ImageCanvasAssetRowView({
|
||||
icon={Pencil}
|
||||
onClick={() => startRenamingAsset(asset)}
|
||||
/>
|
||||
{asset.sourceKind === 'uploaded' ? (
|
||||
<EditorIconButton
|
||||
label={`删除素材${asset.label}`}
|
||||
title="删除"
|
||||
icon={Trash2}
|
||||
onClick={() => deleteUploadedAsset(asset)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div data-asset-id={asset.id}>
|
||||
<div
|
||||
ref={rowRef}
|
||||
data-asset-id={asset.id}
|
||||
onContextMenu={(event) => {
|
||||
if (isUploadingAsset || isFailedUpload || isRenaming) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setMenuPosition({ x: event.clientX, y: event.clientY });
|
||||
}}
|
||||
>
|
||||
<SidebarMediaItem
|
||||
title={asset.label}
|
||||
detail={mediaDetail}
|
||||
@@ -350,6 +397,46 @@ export function ImageCanvasAssetRowView({
|
||||
});
|
||||
}}
|
||||
/>
|
||||
{menuPosition ? (
|
||||
<div
|
||||
className="image-canvas-editor__context-menu image-canvas-editor__asset-context-menu"
|
||||
role="menu"
|
||||
style={{
|
||||
left: menuPosition.x,
|
||||
top: menuPosition.y,
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitemcheckbox"
|
||||
aria-checked={publicShowcaseEnabled}
|
||||
disabled={!canShowcaseAsset}
|
||||
onClick={() => {
|
||||
if (!canShowcaseAsset) {
|
||||
return;
|
||||
}
|
||||
setAssetPublicShowcaseEnabled(asset, !publicShowcaseEnabled);
|
||||
closeMenu();
|
||||
}}
|
||||
>
|
||||
<span aria-hidden="true">{publicShowcaseEnabled ? '✓' : ''}</span>
|
||||
公开展示该素材
|
||||
</button>
|
||||
{asset.sourceKind === 'uploaded' ? (
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className="image-canvas-editor__context-menu-danger"
|
||||
onClick={() => {
|
||||
deleteUploadedAsset(asset);
|
||||
closeMenu();
|
||||
}}
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -327,18 +327,22 @@ describe('ImageCanvasEditorView asset library integration', () => {
|
||||
expect(
|
||||
within(customFolder).getByRole('button', { name: '添加角色草图.png' }),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
within(customFolder).getByRole('button', {
|
||||
name: '删除素材角色草图.png',
|
||||
}),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
await user.click(
|
||||
within(customFolder).getByRole('button', {
|
||||
name: '删除素材角色草图.png',
|
||||
}),
|
||||
const uploadedAssetButton = within(customFolder).getByRole('button', {
|
||||
name: '添加角色草图.png',
|
||||
});
|
||||
const uploadedAssetRow = uploadedAssetButton.closest(
|
||||
'[data-asset-id="persisted-角色草图.png"]',
|
||||
);
|
||||
if (!uploadedAssetRow) {
|
||||
throw new Error('uploaded asset row should exist');
|
||||
}
|
||||
fireEvent.contextMenu(uploadedAssetRow, {
|
||||
clientX: 120,
|
||||
clientY: 180,
|
||||
});
|
||||
await user.click(screen.getByRole('menuitem', { name: '删除' }));
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: '添加角色草图.png' }),
|
||||
@@ -867,7 +871,20 @@ describe('ImageCanvasEditorView asset library integration', () => {
|
||||
|
||||
expect(await screen.findByAltText('画布图片:生成资源A')).toBeTruthy();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '删除素材生成资源A' }));
|
||||
const generatedAssetButton = screen.getByRole('button', {
|
||||
name: '添加生成资源A',
|
||||
});
|
||||
const generatedAssetRow = generatedAssetButton.closest(
|
||||
'[data-asset-id="asset-generated-a"]',
|
||||
);
|
||||
if (!generatedAssetRow) {
|
||||
throw new Error('generated asset row should exist');
|
||||
}
|
||||
fireEvent.contextMenu(generatedAssetRow, {
|
||||
clientX: 120,
|
||||
clientY: 180,
|
||||
});
|
||||
await user.click(screen.getByRole('menuitem', { name: '删除' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByAltText('画布图片:生成资源A')).toBeNull();
|
||||
@@ -1144,4 +1161,62 @@ describe('ImageCanvasEditorView asset library integration', () => {
|
||||
expect(createEditorAssetMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps the source resource id when dragging a generated asset onto the viewport', async () => {
|
||||
loadEditorAssetLibraryMock.mockResolvedValueOnce({
|
||||
folders: [
|
||||
{
|
||||
folderId: 'project',
|
||||
label: '项目素材',
|
||||
sortOrder: 0,
|
||||
collapsed: false,
|
||||
systemDefault: true,
|
||||
},
|
||||
],
|
||||
assets: [
|
||||
createTestImageAsset({
|
||||
assetId: 'asset-generated-character',
|
||||
label: '生成角色素材',
|
||||
imageSrc: '/generated/character.png',
|
||||
objectKey: 'generated/character.png',
|
||||
assetObjectId: 'asset-object-character',
|
||||
sourceType: 'generated',
|
||||
sourceResourceId: 'resource-original-character',
|
||||
}),
|
||||
],
|
||||
});
|
||||
render(<ImageCanvasEditorView />);
|
||||
|
||||
const sourceAsset = await screen.findByRole('button', {
|
||||
name: '添加生成角色素材',
|
||||
});
|
||||
const sourceAssetRow = sourceAsset.closest(
|
||||
'.image-canvas-editor__asset-row',
|
||||
);
|
||||
const viewport = screen.getByLabelText('画布工作区');
|
||||
const dataTransfer = createDataTransferStub();
|
||||
|
||||
if (!sourceAssetRow) {
|
||||
throw new Error('asset row should exist');
|
||||
}
|
||||
fireEvent.dragStart(sourceAssetRow, { dataTransfer });
|
||||
fireEvent.drop(viewport, {
|
||||
clientX: 520,
|
||||
clientY: 300,
|
||||
dataTransfer,
|
||||
});
|
||||
|
||||
expect(await screen.findByAltText('画布图片:生成角色素材')).toBeTruthy();
|
||||
await waitFor(() => {
|
||||
expect(createEditorProjectResourceMock).toHaveBeenCalledWith(
|
||||
'editor-project-default',
|
||||
expect.objectContaining({
|
||||
imageSrc: '/generated/character.png',
|
||||
sourceType: 'generated',
|
||||
sourceResourceId: 'resource-original-character',
|
||||
}),
|
||||
);
|
||||
});
|
||||
expect(createEditorAssetMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -711,6 +711,7 @@ export function mapAssetLibrarySnapshot(
|
||||
objectKey: asset.objectKey ?? undefined,
|
||||
assetObjectId: asset.assetObjectId ?? undefined,
|
||||
sourceResourceId: asset.sourceResourceId ?? undefined,
|
||||
publicShowcaseEnabled: asset.publicShowcaseEnabled ?? null,
|
||||
assetKind: canvasAssetKindOrNull(asset.assetKind),
|
||||
generationInputs: generationInputsOrNull(asset.generationInputs),
|
||||
durationSeconds: asset.durationSeconds ?? undefined,
|
||||
|
||||
@@ -77,6 +77,7 @@ function createSidebarProps(): ImageCanvasSidebarViewProps {
|
||||
startRenamingAsset: vi.fn(),
|
||||
commitAssetRename: vi.fn(),
|
||||
deleteUploadedAsset: vi.fn(),
|
||||
setAssetPublicShowcaseEnabled: vi.fn(),
|
||||
toggleAssetSelected: vi.fn(),
|
||||
addAssetLayer: vi.fn(),
|
||||
toggleAllAssetsSelected: vi.fn(),
|
||||
|
||||
@@ -48,6 +48,7 @@ export type EditorAsset = {
|
||||
objectKey?: string;
|
||||
assetObjectId?: string;
|
||||
sourceResourceId?: string | null;
|
||||
publicShowcaseEnabled?: boolean | null;
|
||||
assetKind?: CanvasAssetKind | null;
|
||||
generationInputs?: CanvasGenerationInputs | null;
|
||||
durationSeconds?: number;
|
||||
|
||||
@@ -382,6 +382,7 @@ export function ImageCanvasEditorView() {
|
||||
toggleAssetFolder,
|
||||
commitNewAssetFolder,
|
||||
deleteUploadedAsset,
|
||||
setAssetPublicShowcaseEnabled,
|
||||
startRenamingFolder,
|
||||
commitFolderRename,
|
||||
deleteAssetFolder,
|
||||
@@ -1317,6 +1318,7 @@ export function ImageCanvasEditorView() {
|
||||
startRenamingAsset,
|
||||
commitAssetRename,
|
||||
deleteUploadedAsset,
|
||||
setAssetPublicShowcaseEnabled,
|
||||
toggleAssetSelected,
|
||||
addAssetLayer,
|
||||
toggleAllAssetsSelected,
|
||||
|
||||
@@ -779,8 +779,8 @@ function ImageCanvasAudioGenerationComposerView({
|
||||
disabled={isGenerating}
|
||||
placeholder={
|
||||
isSoundEffect
|
||||
? '描述需要生成的游戏音效'
|
||||
: '描述需要生成的游戏背景音乐'
|
||||
? '你希望生成什么音效?'
|
||||
: '你希望生成什么音乐?'
|
||||
}
|
||||
size="sm"
|
||||
density="compact"
|
||||
|
||||
@@ -74,6 +74,28 @@ describe('ImageCanvasGenerationDialogModel', () => {
|
||||
originalHeight: 1024,
|
||||
},
|
||||
});
|
||||
expect(
|
||||
createSpecDialogDraft({ canvasSize, viewport, specType: 'character' }),
|
||||
).toMatchObject({
|
||||
mode: 'spec',
|
||||
specType: 'character',
|
||||
specValues: {
|
||||
playSetting: '',
|
||||
artStyle: '',
|
||||
bodyRatio: '3',
|
||||
},
|
||||
});
|
||||
expect(
|
||||
createSpecDialogDraft({ canvasSize, viewport, specType: 'ui' }),
|
||||
).toMatchObject({
|
||||
mode: 'spec',
|
||||
specType: 'ui',
|
||||
specValues: {
|
||||
playSetting: '',
|
||||
artStyle: '',
|
||||
bodyRatio: '3',
|
||||
},
|
||||
});
|
||||
expect(
|
||||
createSpecDialogDraft({ canvasSize, viewport, specType: 'icon' }),
|
||||
).toMatchObject({
|
||||
@@ -153,16 +175,9 @@ describe('ImageCanvasGenerationDialogModel', () => {
|
||||
imageModel: 'unknown-model',
|
||||
aspectRatio: '1:1',
|
||||
imageSize: '1K',
|
||||
prompt: '返回按钮\n设置按钮\n下一关按钮\n提示按钮\n原图按钮\n冻结按钮',
|
||||
prompt: '',
|
||||
iconSpecReference: null,
|
||||
iconDescriptions: [
|
||||
'返回按钮',
|
||||
'设置按钮',
|
||||
'下一关按钮',
|
||||
'提示按钮',
|
||||
'原图按钮',
|
||||
'冻结按钮',
|
||||
],
|
||||
iconDescriptions: [],
|
||||
placeholder: {
|
||||
x: 300,
|
||||
y: 180,
|
||||
@@ -228,8 +243,7 @@ describe('ImageCanvasGenerationDialogModel', () => {
|
||||
expect(createSoundEffectGenerationDialogDraft({ canvasSize, viewport }))
|
||||
.toMatchObject({
|
||||
mode: 'audio-sound-effect',
|
||||
prompt:
|
||||
'短促清脆的游戏交互音效,带轻微闪光质感,干净收尾,适合按钮确认或收集奖励。',
|
||||
prompt: '',
|
||||
status: 'idle',
|
||||
composerOpen: true,
|
||||
soundModel: 'audio1.0',
|
||||
@@ -246,8 +260,7 @@ describe('ImageCanvasGenerationDialogModel', () => {
|
||||
expect(createBackgroundMusicGenerationDialogDraft({ canvasSize, viewport }))
|
||||
.toMatchObject({
|
||||
mode: 'audio-background-music',
|
||||
prompt:
|
||||
'适合轻松冒险游戏的循环背景音乐,温暖明亮,旋律简洁,不包含人声。',
|
||||
prompt: '',
|
||||
status: 'idle',
|
||||
composerOpen: true,
|
||||
makeInstrumental: true,
|
||||
|
||||
@@ -50,11 +50,6 @@ const VIDEO_REFERENCE_LIMITS = {
|
||||
video: 3,
|
||||
audio: 3,
|
||||
} as const;
|
||||
const DEFAULT_SOUND_EFFECT_PROMPT =
|
||||
'短促清脆的游戏交互音效,带轻微闪光质感,干净收尾,适合按钮确认或收集奖励。';
|
||||
const DEFAULT_BACKGROUND_MUSIC_PROMPT =
|
||||
'适合轻松冒险游戏的循环背景音乐,温暖明亮,旋律简洁,不包含人声。';
|
||||
|
||||
function getViewportWorldCenter({
|
||||
canvasSize,
|
||||
viewport,
|
||||
@@ -194,6 +189,10 @@ export function createGenerateDialogDraft({
|
||||
};
|
||||
}
|
||||
|
||||
function shouldUseSpecPlaceholderValues(specType: SpecGenerationType) {
|
||||
return specType === 'character' || specType === 'ui';
|
||||
}
|
||||
|
||||
export function createSpecDialogDraft({
|
||||
canvasSize,
|
||||
viewport,
|
||||
@@ -210,7 +209,12 @@ export function createSpecDialogDraft({
|
||||
status: 'idle',
|
||||
composerOpen: true,
|
||||
specType,
|
||||
specValues: { ...DEFAULT_SPEC_FORM_VALUES[specType] },
|
||||
specValues: {
|
||||
...DEFAULT_SPEC_FORM_VALUES[specType],
|
||||
...(shouldUseSpecPlaceholderValues(specType)
|
||||
? { playSetting: '', artStyle: '' }
|
||||
: {}),
|
||||
},
|
||||
placeholder: {
|
||||
x: worldCenter.x - SPEC_FRAME_ORIGINAL_SIZE.width / 2,
|
||||
y: worldCenter.y - SPEC_FRAME_ORIGINAL_SIZE.height / 2,
|
||||
@@ -274,11 +278,11 @@ export function createIconGenerationDialogDraft({
|
||||
const dimensionDefaults = resolveImageDimensionDefaults(normalizedImageModel);
|
||||
return {
|
||||
mode: 'icon',
|
||||
prompt: formatIconDescriptionsText(DEFAULT_ICON_DESCRIPTIONS),
|
||||
prompt: '',
|
||||
status: 'idle',
|
||||
composerOpen: true,
|
||||
iconSpecReference: null,
|
||||
iconDescriptions: [...DEFAULT_ICON_DESCRIPTIONS],
|
||||
iconDescriptions: [],
|
||||
imageModel: normalizedImageModel,
|
||||
aspectRatio: dimensionDefaults.aspectRatio,
|
||||
imageSize: dimensionDefaults.imageSize,
|
||||
@@ -373,7 +377,7 @@ export function createSoundEffectGenerationDialogDraft({
|
||||
const worldCenter = getViewportWorldCenter({ canvasSize, viewport });
|
||||
return {
|
||||
mode: 'audio-sound-effect',
|
||||
prompt: DEFAULT_SOUND_EFFECT_PROMPT,
|
||||
prompt: '',
|
||||
status: 'idle',
|
||||
composerOpen: true,
|
||||
soundModel: DEFAULT_SOUND_EFFECT_MODEL,
|
||||
@@ -399,7 +403,7 @@ export function createBackgroundMusicGenerationDialogDraft({
|
||||
const worldCenter = getViewportWorldCenter({ canvasSize, viewport });
|
||||
return {
|
||||
mode: 'audio-background-music',
|
||||
prompt: DEFAULT_BACKGROUND_MUSIC_PROMPT,
|
||||
prompt: '',
|
||||
status: 'idle',
|
||||
composerOpen: true,
|
||||
makeInstrumental: true,
|
||||
|
||||
@@ -103,6 +103,18 @@ describe('ImageCanvasIconSpritesheetComposerView', () => {
|
||||
expect(requestUpload).toHaveBeenCalledWith('icon-spec');
|
||||
});
|
||||
|
||||
it('uses a grey hint instead of default icon text for new icon drafts', () => {
|
||||
render(
|
||||
<IconComposerHarness
|
||||
initialDialog={createIconDialog({ iconDescriptions: [] })}
|
||||
/>,
|
||||
);
|
||||
|
||||
const input = screen.getByLabelText('素材描述') as HTMLTextAreaElement;
|
||||
expect(input.value).toBe('');
|
||||
expect(input.placeholder).toBe('你需要哪些图标?');
|
||||
});
|
||||
|
||||
it('keeps the spec reference as a single first-row card without extra source buttons', () => {
|
||||
render(<IconComposerHarness initialDialog={createIconDialog()} />);
|
||||
|
||||
|
||||
@@ -63,8 +63,7 @@ export function ImageCanvasIconSpritesheetComposerView({
|
||||
onSubmit,
|
||||
}: ImageCanvasIconSpritesheetComposerViewProps) {
|
||||
const descriptionText =
|
||||
dialog.prompt ||
|
||||
(dialog.iconDescriptions ?? DEFAULT_ICON_DESCRIPTIONS).join('\n');
|
||||
dialog.prompt || (dialog.iconDescriptions ?? []).join('\n');
|
||||
useImageCanvasFloatingOptionDismiss({
|
||||
isOpen: isIconSpecMenuOpen,
|
||||
boundaryRefs: [iconSpecButtonRef],
|
||||
@@ -176,7 +175,7 @@ export function ImageCanvasIconSpritesheetComposerView({
|
||||
aria-label="素材描述"
|
||||
value={descriptionText}
|
||||
disabled={dialog.status === 'generating'}
|
||||
placeholder="描述要生成的多个素材,每行一个。"
|
||||
placeholder="你需要哪些图标?"
|
||||
size="sm"
|
||||
density="compact"
|
||||
className="image-canvas-editor__generation-prompt image-canvas-editor__generation-prompt--borderless"
|
||||
|
||||
@@ -115,6 +115,7 @@ function createSidebarProps(
|
||||
startRenamingAsset: vi.fn(),
|
||||
commitAssetRename: vi.fn(),
|
||||
deleteUploadedAsset: vi.fn(),
|
||||
setAssetPublicShowcaseEnabled: vi.fn(),
|
||||
toggleAssetSelected: vi.fn(),
|
||||
addAssetLayer: vi.fn(),
|
||||
toggleAllAssetsSelected: vi.fn(),
|
||||
|
||||
@@ -96,6 +96,10 @@ export type ImageCanvasSidebarViewProps = {
|
||||
startRenamingAsset: (asset: EditorAsset) => void;
|
||||
commitAssetRename: (asset: EditorAsset) => void;
|
||||
deleteUploadedAsset: (asset: EditorAsset) => void;
|
||||
setAssetPublicShowcaseEnabled: (
|
||||
asset: EditorAsset,
|
||||
publicShowcaseEnabled: boolean,
|
||||
) => void;
|
||||
toggleAssetSelected: (assetId: string) => void;
|
||||
addAssetLayer: (asset: EditorAsset) => void;
|
||||
toggleAllAssetsSelected: () => void;
|
||||
@@ -160,6 +164,7 @@ export function ImageCanvasSidebarView({
|
||||
startRenamingAsset,
|
||||
commitAssetRename,
|
||||
deleteUploadedAsset,
|
||||
setAssetPublicShowcaseEnabled,
|
||||
toggleAssetSelected,
|
||||
addAssetLayer,
|
||||
toggleAllAssetsSelected,
|
||||
@@ -313,6 +318,7 @@ export function ImageCanvasSidebarView({
|
||||
startRenamingAsset={startRenamingAsset}
|
||||
commitAssetRename={commitAssetRename}
|
||||
deleteUploadedAsset={deleteUploadedAsset}
|
||||
setAssetPublicShowcaseEnabled={setAssetPublicShowcaseEnabled}
|
||||
toggleAssetSelected={toggleAssetSelected}
|
||||
addAssetLayer={addAssetLayer}
|
||||
toggleAllAssetsSelected={toggleAllAssetsSelected}
|
||||
|
||||
@@ -161,6 +161,13 @@ describe('ImageCanvasSpecGenerationPanelView', () => {
|
||||
onUpdateSpecFormValue: updateSpecFormValue,
|
||||
});
|
||||
|
||||
expect(
|
||||
(screen.getByLabelText('玩法设定') as HTMLInputElement).placeholder,
|
||||
).toBe('这是什么类型的游戏?');
|
||||
expect(
|
||||
(screen.getByLabelText('美术风格') as HTMLInputElement).placeholder,
|
||||
).toBe('游戏的画风是怎样的?');
|
||||
|
||||
fireEvent.change(screen.getByLabelText('玩法设定'), {
|
||||
target: { value: '战棋玩法' },
|
||||
});
|
||||
@@ -183,6 +190,34 @@ describe('ImageCanvasSpecGenerationPanelView', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('uses gameplay and art style hints for icon specs', () => {
|
||||
renderPanel({
|
||||
dialog: createSpecDialog({
|
||||
specType: 'ui',
|
||||
specValues: {
|
||||
playSetting: '',
|
||||
artStyle: '',
|
||||
bodyRatio: '3',
|
||||
characterView: '右向斜侧身站姿',
|
||||
customPrompt: '',
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
expect((screen.getByLabelText('玩法设定') as HTMLInputElement).value).toBe(
|
||||
'',
|
||||
);
|
||||
expect(
|
||||
(screen.getByLabelText('玩法设定') as HTMLInputElement).placeholder,
|
||||
).toBe('这是什么类型的游戏?');
|
||||
expect((screen.getByLabelText('美术风格') as HTMLInputElement).value).toBe(
|
||||
'',
|
||||
);
|
||||
expect(
|
||||
(screen.getByLabelText('美术风格') as HTMLInputElement).placeholder,
|
||||
).toBe('游戏的画风是怎样的?');
|
||||
});
|
||||
|
||||
it('renders custom prompt and hides reference upload for icon specs', () => {
|
||||
const updateSpecFormValue = vi.fn();
|
||||
renderPanel({
|
||||
@@ -199,6 +234,13 @@ describe('ImageCanvasSpecGenerationPanelView', () => {
|
||||
onUpdateSpecFormValue: updateSpecFormValue,
|
||||
});
|
||||
|
||||
const customPrompt = screen.getByLabelText(
|
||||
'自定义规范提示词',
|
||||
) as HTMLTextAreaElement;
|
||||
expect(customPrompt.placeholder).toBe(
|
||||
'写下你想要的任何内容,陶泥儿帮你形成新的约束',
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByLabelText('自定义规范提示词'), {
|
||||
target: { value: '新的规范提示' },
|
||||
});
|
||||
|
||||
@@ -60,6 +60,10 @@ type ImageCanvasSpecGenerationPanelViewProps = {
|
||||
onSubmit: (dialog: GenerateDialogState) => void;
|
||||
};
|
||||
|
||||
function shouldShowSpecInputPlaceholders(dialog: GenerateDialogState) {
|
||||
return dialog.specType === 'character' || dialog.specType === 'ui';
|
||||
}
|
||||
|
||||
export function ImageCanvasSpecGenerationPanelView({
|
||||
dialog,
|
||||
style,
|
||||
@@ -77,6 +81,7 @@ export function ImageCanvasSpecGenerationPanelView({
|
||||
onSubmit,
|
||||
}: ImageCanvasSpecGenerationPanelViewProps) {
|
||||
const isUiDesignDialog = dialog.mode === 'ui-design';
|
||||
const showSpecInputPlaceholders = shouldShowSpecInputPlaceholders(dialog);
|
||||
const specSizeLabel = resolveEditorImageSizeLabel({
|
||||
aspectRatio: SPEC_GENERATION_ASPECT_RATIO,
|
||||
imageSize: SPEC_GENERATION_IMAGE_SIZE,
|
||||
@@ -237,6 +242,7 @@ export function ImageCanvasSpecGenerationPanelView({
|
||||
variant="textarea"
|
||||
aria-label="自定义规范提示词"
|
||||
value={dialog.specValues?.customPrompt ?? ''}
|
||||
placeholder="写下你想要的任何内容,陶泥儿帮你形成新的约束"
|
||||
disabled={isGenerating}
|
||||
size="sm"
|
||||
density="compact"
|
||||
@@ -258,6 +264,11 @@ export function ImageCanvasSpecGenerationPanelView({
|
||||
<PlatformTextField
|
||||
aria-label="玩法设定"
|
||||
value={dialog.specValues?.playSetting ?? ''}
|
||||
placeholder={
|
||||
showSpecInputPlaceholders
|
||||
? '这是什么类型的游戏?'
|
||||
: undefined
|
||||
}
|
||||
disabled={isGenerating}
|
||||
size="sm"
|
||||
density="compact"
|
||||
@@ -277,6 +288,11 @@ export function ImageCanvasSpecGenerationPanelView({
|
||||
<PlatformTextField
|
||||
aria-label="美术风格"
|
||||
value={dialog.specValues?.artStyle ?? ''}
|
||||
placeholder={
|
||||
showSpecInputPlaceholders
|
||||
? '游戏的画风是怎样的?'
|
||||
: undefined
|
||||
}
|
||||
disabled={isGenerating}
|
||||
size="sm"
|
||||
density="compact"
|
||||
|
||||
@@ -15,6 +15,7 @@ const deleteEditorAssetFolderMock = vi.hoisted(() => vi.fn());
|
||||
const loadEditorAssetLibraryMock = vi.hoisted(() => vi.fn());
|
||||
const updateEditorAssetMock = vi.hoisted(() => vi.fn());
|
||||
const updateEditorAssetFolderMock = vi.hoisted(() => vi.fn());
|
||||
const updateEditorProjectResourceShowcaseMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
const defaultOpenEditorLoginModal = () => {};
|
||||
const defaultOnDeleteAssets = () => {};
|
||||
@@ -31,6 +32,7 @@ vi.mock('../../services/image-editor/editorProjectClient', async () => {
|
||||
loadEditorAssetLibrary: loadEditorAssetLibraryMock,
|
||||
updateEditorAsset: updateEditorAssetMock,
|
||||
updateEditorAssetFolder: updateEditorAssetFolderMock,
|
||||
updateEditorProjectResourceShowcase: updateEditorProjectResourceShowcaseMock,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -107,7 +109,12 @@ function AssetLibraryHarness({
|
||||
</span>
|
||||
<span data-testid="assets">
|
||||
{assetLibrary.assets
|
||||
.map((asset) => `${asset.id}:${asset.label}:${asset.folderId}`)
|
||||
.map(
|
||||
(asset) =>
|
||||
`${asset.id}:${asset.label}:${asset.folderId}:${
|
||||
asset.publicShowcaseEnabled ?? 'unset'
|
||||
}`,
|
||||
)
|
||||
.join('|')}
|
||||
</span>
|
||||
<span data-testid="active-upload-folder">
|
||||
@@ -154,6 +161,19 @@ function AssetLibraryHarness({
|
||||
>
|
||||
delete asset
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const target = assetLibrary.assets.find(
|
||||
(asset) => asset.id === 'asset-a',
|
||||
);
|
||||
if (target) {
|
||||
assetLibrary.setAssetPublicShowcaseEnabled(target, false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
hide asset from showcase
|
||||
</button>
|
||||
<button type="button" onClick={assetLibrary.toggleAllAssetsSelected}>
|
||||
toggle all
|
||||
</button>
|
||||
@@ -186,6 +206,15 @@ describe('useImageCanvasAssetLibrary', () => {
|
||||
systemDefault: false,
|
||||
});
|
||||
updateEditorAssetMock.mockResolvedValue(createAssetSnapshot());
|
||||
updateEditorProjectResourceShowcaseMock.mockResolvedValue({
|
||||
resourceId: 'resource-a',
|
||||
projectId: 'project-1',
|
||||
imageSrc: 'data:image/png;base64,YQ==',
|
||||
width: 320,
|
||||
height: 240,
|
||||
sourceType: 'generated',
|
||||
publicShowcaseEnabled: false,
|
||||
});
|
||||
deleteEditorAssetMock.mockResolvedValue(createAssetSnapshot());
|
||||
deleteEditorAssetFolderMock.mockResolvedValue({
|
||||
folders: [
|
||||
@@ -236,7 +265,7 @@ describe('useImageCanvasAssetLibrary', () => {
|
||||
);
|
||||
});
|
||||
expect(screen.getByTestId('assets').textContent).toBe(
|
||||
'asset-a:素材A:project',
|
||||
'asset-a:素材A:project:unset',
|
||||
);
|
||||
expect(screen.getByTestId('active-upload-folder').textContent).toBe(
|
||||
'project',
|
||||
@@ -342,7 +371,7 @@ describe('useImageCanvasAssetLibrary', () => {
|
||||
act(() => screen.getByRole('button', { name: 'move asset' }).click());
|
||||
|
||||
expect(screen.getByTestId('assets').textContent).toBe(
|
||||
'asset-a:素材A:folder-role',
|
||||
'asset-a:素材A:folder-role:unset',
|
||||
);
|
||||
expect(updateEditorAssetMock).toHaveBeenCalledWith('asset-a', {
|
||||
folderId: 'folder-role',
|
||||
@@ -393,7 +422,7 @@ describe('useImageCanvasAssetLibrary', () => {
|
||||
act(() => screen.getByRole('button', { name: 'delete selected' }).click());
|
||||
|
||||
expect(screen.getByTestId('assets').textContent).toBe(
|
||||
'asset-b:素材B:project',
|
||||
'asset-b:素材B:project:unset',
|
||||
);
|
||||
expect(onDeleteAssets).toHaveBeenCalledWith([
|
||||
expect.objectContaining({ id: 'asset-a' }),
|
||||
@@ -401,4 +430,112 @@ describe('useImageCanvasAssetLibrary', () => {
|
||||
expect(deleteEditorAssetMock).toHaveBeenCalledWith('asset-a');
|
||||
expect(deleteEditorAssetMock).not.toHaveBeenCalledWith('asset-b');
|
||||
});
|
||||
|
||||
it('updates public showcase state through the linked project resource', async () => {
|
||||
loadEditorAssetLibraryMock.mockResolvedValueOnce({
|
||||
folders: [
|
||||
{
|
||||
folderId: 'project',
|
||||
label: '项目素材',
|
||||
sortOrder: 0,
|
||||
collapsed: false,
|
||||
systemDefault: true,
|
||||
},
|
||||
],
|
||||
assets: [
|
||||
createAssetSnapshot({
|
||||
assetId: 'asset-a',
|
||||
sourceType: 'generated',
|
||||
sourceResourceId: 'resource-a',
|
||||
publicShowcaseEnabled: true,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
render(<AssetLibraryHarness />);
|
||||
|
||||
await screen.findByText('素材A');
|
||||
act(() =>
|
||||
screen.getByRole('button', { name: 'hide asset from showcase' }).click(),
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('assets').textContent).toBe(
|
||||
'asset-a:素材A:project:false',
|
||||
);
|
||||
expect(updateEditorProjectResourceShowcaseMock).toHaveBeenCalledWith(
|
||||
'resource-a',
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('updates legacy generated asset showcase state when backend infers source resource id', async () => {
|
||||
loadEditorAssetLibraryMock.mockResolvedValueOnce({
|
||||
folders: [
|
||||
{
|
||||
folderId: 'project',
|
||||
label: '项目素材',
|
||||
sortOrder: 0,
|
||||
collapsed: false,
|
||||
systemDefault: true,
|
||||
},
|
||||
],
|
||||
assets: [
|
||||
createAssetSnapshot({
|
||||
assetId: 'asset-a',
|
||||
sourceType: 'generated',
|
||||
sourceResourceId: 'resource-inferred',
|
||||
publicShowcaseEnabled: null,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
render(<AssetLibraryHarness />);
|
||||
|
||||
await screen.findByText('素材A');
|
||||
act(() =>
|
||||
screen.getByRole('button', { name: 'hide asset from showcase' }).click(),
|
||||
);
|
||||
|
||||
expect(updateEditorProjectResourceShowcaseMock).toHaveBeenCalledWith(
|
||||
'resource-inferred',
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('rolls back public showcase state when update fails', async () => {
|
||||
updateEditorProjectResourceShowcaseMock.mockRejectedValueOnce(
|
||||
new Error('network'),
|
||||
);
|
||||
loadEditorAssetLibraryMock.mockResolvedValueOnce({
|
||||
folders: [
|
||||
{
|
||||
folderId: 'project',
|
||||
label: '项目素材',
|
||||
sortOrder: 0,
|
||||
collapsed: false,
|
||||
systemDefault: true,
|
||||
},
|
||||
],
|
||||
assets: [
|
||||
createAssetSnapshot({
|
||||
assetId: 'asset-a',
|
||||
sourceType: 'generated',
|
||||
sourceResourceId: 'resource-a',
|
||||
publicShowcaseEnabled: true,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
render(<AssetLibraryHarness />);
|
||||
|
||||
await screen.findByText('素材A');
|
||||
act(() =>
|
||||
screen.getByRole('button', { name: 'hide asset from showcase' }).click(),
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('assets').textContent).toBe(
|
||||
'asset-a:素材A:project:true',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
loadEditorAssetLibrary,
|
||||
updateEditorAsset,
|
||||
updateEditorAssetFolder,
|
||||
updateEditorProjectResourceShowcase,
|
||||
} from '../../services/image-editor/editorProjectClient';
|
||||
import {
|
||||
EDITOR_ASSET_FOLDERS,
|
||||
@@ -320,6 +321,38 @@ export function useImageCanvasAssetLibrary({
|
||||
[onDeleteAssets],
|
||||
);
|
||||
|
||||
const setAssetPublicShowcaseEnabled = useCallback(
|
||||
(asset: EditorAsset, publicShowcaseEnabled: boolean) => {
|
||||
if (!asset.sourceResourceId) {
|
||||
return;
|
||||
}
|
||||
const previousValue = asset.publicShowcaseEnabled ?? true;
|
||||
setAssets((currentAssets) =>
|
||||
currentAssets.map((currentAsset) =>
|
||||
currentAsset.id === asset.id
|
||||
? { ...currentAsset, publicShowcaseEnabled }
|
||||
: currentAsset,
|
||||
),
|
||||
);
|
||||
updateEditorProjectResourceShowcase(
|
||||
asset.sourceResourceId,
|
||||
publicShowcaseEnabled,
|
||||
).catch(() => {
|
||||
setAssets((currentAssets) =>
|
||||
currentAssets.map((currentAsset) =>
|
||||
currentAsset.id === asset.id
|
||||
? {
|
||||
...currentAsset,
|
||||
publicShowcaseEnabled: previousValue,
|
||||
}
|
||||
: currentAsset,
|
||||
),
|
||||
);
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const startRenamingFolder = useCallback((folder: EditorAssetFolder) => {
|
||||
setRenamingFolder({
|
||||
folderId: folder.id,
|
||||
@@ -558,6 +591,7 @@ export function useImageCanvasAssetLibrary({
|
||||
toggleAssetFolder,
|
||||
commitNewAssetFolder,
|
||||
deleteUploadedAsset,
|
||||
setAssetPublicShowcaseEnabled,
|
||||
startRenamingFolder,
|
||||
commitFolderRename,
|
||||
deleteAssetFolder,
|
||||
|
||||
@@ -15437,11 +15437,6 @@ export function PlatformEntryFlowShellImpl({
|
||||
<Suspense fallback={<LazyPanelFallback label="正在加载项目..." />}>
|
||||
<ProjectGalleryView
|
||||
onOpenProject={openEditorProject}
|
||||
onReturnToMainSite={() => {
|
||||
platformBootstrap.setPlatformTab('home');
|
||||
setSelectionStage('platform');
|
||||
pushAppHistoryPath('/');
|
||||
}}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user