合并master并保留Spine序列帧修复
合并master当前工程、后端、前端和文档更新 按已确认方案解决Spine序列帧多模态、帧数和快速编辑冲突 保留当前分支底部工具栏宽度与隐藏滚动条样式
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
/* eslint-disable react-refresh/only-export-components */
|
||||
|
||||
import '@genarrative/image-canvas-react/styles.css';
|
||||
import './index.css';
|
||||
|
||||
import { StrictMode, Suspense } from 'react';
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
within,
|
||||
} from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import type { ContextType } from 'react';
|
||||
import { type ContextType, startTransition, Suspense } from 'react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { AuthUiContext } from '../auth/AuthUiContext';
|
||||
@@ -144,6 +144,13 @@ function triggerIntersection(observer: {
|
||||
});
|
||||
}
|
||||
|
||||
function SuspendAfterLandingRender({ active }: { active: boolean }) {
|
||||
if (active) {
|
||||
throw new Promise<never>(() => undefined);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
describe('CreationLandingView', () => {
|
||||
beforeEach(() => {
|
||||
listPublicEditorProjectResourcesMock.mockResolvedValue([]);
|
||||
@@ -679,6 +686,7 @@ describe('CreationLandingView', () => {
|
||||
toggleEditorShowcaseAssetLikeMock.mockResolvedValueOnce({
|
||||
showcaseId: 'showcase-character',
|
||||
likeCount: 4,
|
||||
viewerLiked: true,
|
||||
});
|
||||
|
||||
renderCreationLanding();
|
||||
@@ -712,6 +720,354 @@ describe('CreationLandingView', () => {
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it('restores the server-provided liked state after remount', async () => {
|
||||
const likedResource = {
|
||||
resourceId: 'resource-character',
|
||||
projectId: 'editor-showcase',
|
||||
showcaseId: 'showcase-character',
|
||||
label: '角色英雄',
|
||||
imageSrc: 'data:image/png;base64,character',
|
||||
width: 512,
|
||||
height: 512,
|
||||
sourceType: 'generated',
|
||||
prompt: 'character hero prompt',
|
||||
showcaseCategory: 'characters',
|
||||
likeCount: 4,
|
||||
viewerLiked: true,
|
||||
};
|
||||
listEditorProjectsMock.mockResolvedValue([]);
|
||||
listPublicEditorProjectResourcesMock.mockResolvedValue([likedResource]);
|
||||
|
||||
const firstRender = renderCreationLanding();
|
||||
expect(
|
||||
await screen.findByRole('button', { name: '取消点赞:角色英雄' }),
|
||||
).toBeTruthy();
|
||||
firstRender.unmount();
|
||||
|
||||
renderCreationLanding();
|
||||
expect(
|
||||
await screen.findByRole('button', { name: '取消点赞:角色英雄' }),
|
||||
).toBeTruthy();
|
||||
expect(listPublicEditorProjectResourcesMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('waits for the server before changing like state and count', async () => {
|
||||
const user = userEvent.setup();
|
||||
let resolveLike!: (value: {
|
||||
showcaseId: string;
|
||||
likeCount: number;
|
||||
viewerLiked: boolean;
|
||||
}) => void;
|
||||
const likeResult = new Promise<{
|
||||
showcaseId: string;
|
||||
likeCount: number;
|
||||
viewerLiked: boolean;
|
||||
}>((resolve) => {
|
||||
resolveLike = resolve;
|
||||
});
|
||||
listEditorProjectsMock.mockResolvedValueOnce([]);
|
||||
listPublicEditorProjectResourcesMock.mockResolvedValueOnce([
|
||||
{
|
||||
resourceId: 'resource-character',
|
||||
projectId: 'editor-showcase',
|
||||
showcaseId: 'showcase-character',
|
||||
label: '角色英雄',
|
||||
imageSrc: 'data:image/png;base64,character',
|
||||
width: 512,
|
||||
height: 512,
|
||||
sourceType: 'generated',
|
||||
prompt: 'character hero prompt',
|
||||
likeCount: 3,
|
||||
viewerLiked: false,
|
||||
},
|
||||
]);
|
||||
toggleEditorShowcaseAssetLikeMock.mockReturnValueOnce(likeResult);
|
||||
|
||||
renderCreationLanding();
|
||||
const likeButton = await screen.findByRole('button', {
|
||||
name: '点赞:角色英雄',
|
||||
});
|
||||
await user.click(likeButton);
|
||||
|
||||
expect((likeButton as HTMLButtonElement).disabled).toBe(true);
|
||||
expect(likeButton.getAttribute('aria-busy')).toBe('true');
|
||||
expect(screen.getByRole('button', { name: '点赞:角色英雄' })).toBeTruthy();
|
||||
expect(within(likeButton).getByText('3')).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
resolveLike({
|
||||
showcaseId: 'showcase-character',
|
||||
likeCount: 4,
|
||||
viewerLiked: true,
|
||||
});
|
||||
await likeResult;
|
||||
});
|
||||
expect(
|
||||
await screen.findByRole('button', { name: '取消点赞:角色英雄' }),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByText('4')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('preserves the prior like state and announces a write failure', async () => {
|
||||
const user = userEvent.setup();
|
||||
listEditorProjectsMock.mockResolvedValueOnce([]);
|
||||
listPublicEditorProjectResourcesMock.mockResolvedValueOnce([
|
||||
{
|
||||
resourceId: 'resource-character',
|
||||
projectId: 'editor-showcase',
|
||||
showcaseId: 'showcase-character',
|
||||
label: '角色英雄',
|
||||
imageSrc: 'data:image/png;base64,character',
|
||||
width: 512,
|
||||
height: 512,
|
||||
sourceType: 'generated',
|
||||
prompt: 'character hero prompt',
|
||||
likeCount: 3,
|
||||
viewerLiked: false,
|
||||
},
|
||||
]);
|
||||
toggleEditorShowcaseAssetLikeMock.mockRejectedValueOnce(
|
||||
new Error('write failed'),
|
||||
);
|
||||
|
||||
renderCreationLanding();
|
||||
await user.click(
|
||||
await screen.findByRole('button', { name: '点赞:角色英雄' }),
|
||||
);
|
||||
|
||||
expect((await screen.findByRole('alert')).textContent).toContain(
|
||||
'点赞失败,请重试',
|
||||
);
|
||||
expect(
|
||||
screen.getByRole('button', { name: '点赞:角色英雄' }),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByText('3')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('does not fall back to an anonymous showcase read while auth is recovering', async () => {
|
||||
listEditorProjectsMock.mockResolvedValue([]);
|
||||
const recoveringAuth = createAuthValue({ canAccessProtectedData: false });
|
||||
const readyAuth = createAuthValue({ canAccessProtectedData: true });
|
||||
const result = renderCreationLanding({ authValue: recoveringAuth });
|
||||
|
||||
await act(async () => Promise.resolve());
|
||||
expect(listPublicEditorProjectResourcesMock).not.toHaveBeenCalled();
|
||||
|
||||
result.rerender(
|
||||
<AuthUiContext.Provider value={readyAuth}>
|
||||
<CreationLandingView
|
||||
onOpenProject={vi.fn()}
|
||||
onOpenProjects={vi.fn()}
|
||||
/>
|
||||
</AuthUiContext.Provider>,
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(listPublicEditorProjectResourcesMock).toHaveBeenCalledWith({
|
||||
viewer: 'authenticated',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('ignores an old account showcase response after an account switch', async () => {
|
||||
let resolveOldPage!: (value: unknown[]) => void;
|
||||
const oldPage = new Promise<unknown[]>((resolve) => {
|
||||
resolveOldPage = resolve;
|
||||
});
|
||||
listEditorProjectsMock.mockResolvedValue([]);
|
||||
listPublicEditorProjectResourcesMock
|
||||
.mockReturnValueOnce(oldPage)
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
resourceId: 'new-account-resource',
|
||||
projectId: 'editor-showcase',
|
||||
label: '新账号素材',
|
||||
imageSrc: 'data:image/png;base64,new',
|
||||
width: 512,
|
||||
height: 512,
|
||||
sourceType: 'generated',
|
||||
prompt: 'new account',
|
||||
viewerLiked: false,
|
||||
},
|
||||
]);
|
||||
const result = renderCreationLanding();
|
||||
await waitFor(() =>
|
||||
expect(listPublicEditorProjectResourcesMock).toHaveBeenCalledTimes(1),
|
||||
);
|
||||
|
||||
const nextAuth = createAuthValue({
|
||||
user: {
|
||||
...createAuthValue().user!,
|
||||
id: 'user-2',
|
||||
publicUserCode: '100002',
|
||||
},
|
||||
});
|
||||
result.rerender(
|
||||
<AuthUiContext.Provider value={nextAuth}>
|
||||
<CreationLandingView
|
||||
onOpenProject={vi.fn()}
|
||||
onOpenProjects={vi.fn()}
|
||||
/>
|
||||
</AuthUiContext.Provider>,
|
||||
);
|
||||
expect(await screen.findByText('新账号素材')).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
resolveOldPage([
|
||||
{
|
||||
resourceId: 'old-account-resource',
|
||||
projectId: 'editor-showcase',
|
||||
label: '旧账号素材',
|
||||
imageSrc: 'data:image/png;base64,old',
|
||||
width: 512,
|
||||
height: 512,
|
||||
sourceType: 'generated',
|
||||
prompt: 'old account',
|
||||
viewerLiked: true,
|
||||
},
|
||||
]);
|
||||
await oldPage;
|
||||
});
|
||||
expect(screen.queryByText('旧账号素材')).toBeNull();
|
||||
expect(screen.getByText('新账号素材')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('keeps the committed showcase request valid when a viewer render is abandoned', async () => {
|
||||
let resolveCurrentPage!: (value: unknown[]) => void;
|
||||
const currentPage = new Promise<unknown[]>((resolve) => {
|
||||
resolveCurrentPage = resolve;
|
||||
});
|
||||
listEditorProjectsMock.mockResolvedValue([]);
|
||||
listPublicEditorProjectResourcesMock.mockReturnValueOnce(currentPage);
|
||||
const currentAuth = createAuthValue();
|
||||
const abandonedAuth = createAuthValue({
|
||||
user: {
|
||||
...createAuthValue().user!,
|
||||
id: 'user-abandoned',
|
||||
publicUserCode: '100099',
|
||||
},
|
||||
});
|
||||
const result = render(
|
||||
<Suspense fallback={<span>切换中</span>}>
|
||||
<AuthUiContext.Provider value={currentAuth}>
|
||||
<CreationLandingView
|
||||
onOpenProject={vi.fn()}
|
||||
onOpenProjects={vi.fn()}
|
||||
/>
|
||||
<SuspendAfterLandingRender active={false} />
|
||||
</AuthUiContext.Provider>
|
||||
</Suspense>,
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(listPublicEditorProjectResourcesMock).toHaveBeenCalledTimes(1),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
startTransition(() => {
|
||||
result.rerender(
|
||||
<Suspense fallback={<span>切换中</span>}>
|
||||
<AuthUiContext.Provider value={abandonedAuth}>
|
||||
<CreationLandingView
|
||||
onOpenProject={vi.fn()}
|
||||
onOpenProjects={vi.fn()}
|
||||
/>
|
||||
<SuspendAfterLandingRender active />
|
||||
</AuthUiContext.Provider>
|
||||
</Suspense>,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
resolveCurrentPage([
|
||||
{
|
||||
resourceId: 'current-account-resource',
|
||||
projectId: 'editor-showcase',
|
||||
label: '当前账号素材',
|
||||
imageSrc: 'data:image/png;base64,current',
|
||||
width: 512,
|
||||
height: 512,
|
||||
sourceType: 'generated',
|
||||
prompt: 'current account',
|
||||
viewerLiked: false,
|
||||
},
|
||||
]);
|
||||
await currentPage;
|
||||
});
|
||||
|
||||
expect(await screen.findByText('当前账号素材')).toBeTruthy();
|
||||
expect(screen.queryByText('切换中')).toBeNull();
|
||||
});
|
||||
|
||||
it('ignores an old account like response after an account switch', async () => {
|
||||
const user = userEvent.setup();
|
||||
let resolveOldLike!: (value: {
|
||||
showcaseId: string;
|
||||
likeCount: number;
|
||||
viewerLiked: boolean;
|
||||
}) => void;
|
||||
const oldLike = new Promise<{
|
||||
showcaseId: string;
|
||||
likeCount: number;
|
||||
viewerLiked: boolean;
|
||||
}>((resolve) => {
|
||||
resolveOldLike = resolve;
|
||||
});
|
||||
const resource = {
|
||||
resourceId: 'resource-character',
|
||||
projectId: 'editor-showcase',
|
||||
showcaseId: 'showcase-character',
|
||||
label: '角色英雄',
|
||||
imageSrc: 'data:image/png;base64,character',
|
||||
width: 512,
|
||||
height: 512,
|
||||
sourceType: 'generated',
|
||||
prompt: 'character hero prompt',
|
||||
viewerLiked: false,
|
||||
};
|
||||
listEditorProjectsMock.mockResolvedValue([]);
|
||||
listPublicEditorProjectResourcesMock
|
||||
.mockResolvedValueOnce([{ ...resource, likeCount: 3 }])
|
||||
.mockResolvedValueOnce([{ ...resource, likeCount: 10 }]);
|
||||
toggleEditorShowcaseAssetLikeMock.mockReturnValueOnce(oldLike);
|
||||
const result = renderCreationLanding();
|
||||
await user.click(
|
||||
await screen.findByRole('button', { name: '点赞:角色英雄' }),
|
||||
);
|
||||
|
||||
const nextAuth = createAuthValue({
|
||||
user: {
|
||||
...createAuthValue().user!,
|
||||
id: 'user-2',
|
||||
publicUserCode: '100002',
|
||||
},
|
||||
});
|
||||
result.rerender(
|
||||
<AuthUiContext.Provider value={nextAuth}>
|
||||
<CreationLandingView
|
||||
onOpenProject={vi.fn()}
|
||||
onOpenProjects={vi.fn()}
|
||||
/>
|
||||
</AuthUiContext.Provider>,
|
||||
);
|
||||
const newAccountLikeButton = await screen.findByRole('button', {
|
||||
name: '点赞:角色英雄',
|
||||
});
|
||||
expect(within(newAccountLikeButton).getByText('10')).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
resolveOldLike({
|
||||
showcaseId: 'showcase-character',
|
||||
likeCount: 4,
|
||||
viewerLiked: true,
|
||||
});
|
||||
await oldLike;
|
||||
});
|
||||
expect(
|
||||
screen.getByRole('button', { name: '点赞:角色英雄' }),
|
||||
).toBeTruthy();
|
||||
expect(within(newAccountLikeButton).getByText('10')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('does not show a like action in the featured asset preview modal', async () => {
|
||||
const user = userEvent.setup();
|
||||
listEditorProjectsMock.mockResolvedValueOnce(projectItems);
|
||||
@@ -1064,6 +1420,7 @@ describe('CreationLandingView', () => {
|
||||
await waitFor(() =>
|
||||
expect(listPublicEditorProjectResourcesMock).toHaveBeenLastCalledWith({
|
||||
cursor: 'cursor-page-2',
|
||||
viewer: 'authenticated',
|
||||
}),
|
||||
);
|
||||
expect(listPublicEditorProjectResourcesMock).toHaveBeenCalledTimes(2);
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
type EditorProjectResourceListPage,
|
||||
type EditorProjectResourceSnapshot,
|
||||
type EditorProjectSnapshot,
|
||||
type EditorPublicShowcaseResourceSnapshot,
|
||||
type EditorShowcaseCampaignSnapshot,
|
||||
listEditorProjects,
|
||||
listPublicEditorProjectResources,
|
||||
@@ -165,13 +166,22 @@ function normalizeShowcaseResourcePage(
|
||||
page: EditorProjectResourceListPage | EditorProjectResourceSnapshot[],
|
||||
): EditorProjectResourceListPage {
|
||||
return Array.isArray(page)
|
||||
? { resources: page, nextCursor: null, campaign: null }
|
||||
? {
|
||||
resources: page.map((resource) => ({
|
||||
...resource,
|
||||
viewerLiked:
|
||||
(resource as Partial<EditorPublicShowcaseResourceSnapshot>)
|
||||
.viewerLiked === true,
|
||||
})),
|
||||
nextCursor: null,
|
||||
campaign: null,
|
||||
}
|
||||
: page;
|
||||
}
|
||||
|
||||
function mergeShowcaseResources(
|
||||
current: EditorProjectResourceSnapshot[],
|
||||
incoming: EditorProjectResourceSnapshot[],
|
||||
current: EditorPublicShowcaseResourceSnapshot[],
|
||||
incoming: EditorPublicShowcaseResourceSnapshot[],
|
||||
) {
|
||||
const seenResourceIds = new Set(
|
||||
current.map((resource) => resource.resourceId),
|
||||
@@ -835,6 +845,13 @@ export function CreationLandingView({
|
||||
const prefersReducedMotion = usePrefersReducedMotion();
|
||||
const authUi = useAuthUi();
|
||||
const isAuthenticated = Boolean(authUi?.user);
|
||||
const showcaseViewerUserId = authUi?.user?.id.trim() || null;
|
||||
const canLoadShowcaseViewer =
|
||||
!showcaseViewerUserId || Boolean(authUi?.canAccessProtectedData);
|
||||
const showcaseViewerKey = showcaseViewerUserId
|
||||
? `user:${showcaseViewerUserId}`
|
||||
: 'anonymous';
|
||||
const showcaseRequestScope = `${showcaseViewerKey}:${canLoadShowcaseViewer ? 'ready' : 'recovering'}`;
|
||||
const normalizedSearchKeyword = searchKeyword.trim().toLocaleLowerCase();
|
||||
const [recentProjects, setRecentProjects] = useState<EditorProjectSnapshot[]>(
|
||||
[],
|
||||
@@ -845,7 +862,7 @@ export function CreationLandingView({
|
||||
const [isCreatingProject, setIsCreatingProject] = useState(false);
|
||||
const [projectError, setProjectError] = useState<string | null>(null);
|
||||
const [projectShowcaseResources, setProjectShowcaseResources] = useState<
|
||||
EditorProjectResourceSnapshot[]
|
||||
EditorPublicShowcaseResourceSnapshot[]
|
||||
>([]);
|
||||
const [showcaseCampaign, setShowcaseCampaign] =
|
||||
useState<EditorShowcaseCampaignSnapshot | null>(null);
|
||||
@@ -865,17 +882,21 @@ export function CreationLandingView({
|
||||
const [focusedShowcaseSequenceCardId, setFocusedShowcaseSequenceCardId] =
|
||||
useState<string | null>(null);
|
||||
const [selectedShowcaseIndex, setSelectedShowcaseIndex] = useState(0);
|
||||
const [likedShowcaseIds, setLikedShowcaseIds] = useState<Set<string>>(
|
||||
() => new Set(),
|
||||
);
|
||||
const [pendingLikeShowcaseIds, setPendingLikeShowcaseIds] = useState<
|
||||
Set<string>
|
||||
>(() => new Set());
|
||||
const [showcaseLikeError, setShowcaseLikeError] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [isPendingFeatureDialogOpen, setIsPendingFeatureDialogOpen] =
|
||||
useState(false);
|
||||
const showcaseLoadMoreRef = useRef<HTMLDivElement | null>(null);
|
||||
const showcaseLoadingMoreCursorRef = useRef<string | null>(null);
|
||||
const showcaseFailedLoadMoreCursorRef = useRef<string | null>(null);
|
||||
const showcaseRequestStateRef = useRef({
|
||||
scope: showcaseRequestScope,
|
||||
generation: 0,
|
||||
});
|
||||
|
||||
const refreshRecentProjects = useCallback(() => {
|
||||
if (!isAuthenticated) {
|
||||
@@ -910,21 +931,56 @@ export function CreationLandingView({
|
||||
refreshRecentProjects();
|
||||
}, [refreshRecentProjects]);
|
||||
|
||||
const refreshShowcaseResources = useCallback(() => {
|
||||
useEffect(() => {
|
||||
const requestState = {
|
||||
scope: showcaseRequestScope,
|
||||
generation: showcaseRequestStateRef.current.generation + 1,
|
||||
};
|
||||
showcaseRequestStateRef.current = requestState;
|
||||
const isCurrentRequest = () =>
|
||||
showcaseRequestStateRef.current.scope === requestState.scope &&
|
||||
showcaseRequestStateRef.current.generation === requestState.generation;
|
||||
const invalidateRequest = () => {
|
||||
if (isCurrentRequest()) {
|
||||
showcaseRequestStateRef.current = {
|
||||
scope: requestState.scope,
|
||||
generation: requestState.generation + 1,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
setIsLoadingShowcase(true);
|
||||
setIsLoadingMoreShowcase(false);
|
||||
showcaseLoadingMoreCursorRef.current = null;
|
||||
showcaseFailedLoadMoreCursorRef.current = null;
|
||||
setProjectShowcaseResources([]);
|
||||
setShowcaseCampaign(null);
|
||||
setShowcaseNextCursor(null);
|
||||
setAssetError(null);
|
||||
setShowcaseLoadMoreError(null);
|
||||
listPublicEditorProjectResources()
|
||||
setShowcaseLikeError(null);
|
||||
setPendingLikeShowcaseIds(new Set());
|
||||
setSelectedShowcaseItem(null);
|
||||
if (!canLoadShowcaseViewer) {
|
||||
return invalidateRequest;
|
||||
}
|
||||
|
||||
listPublicEditorProjectResources({
|
||||
viewer: showcaseViewerUserId ? 'authenticated' : 'anonymous',
|
||||
})
|
||||
.then((page) => {
|
||||
if (!isCurrentRequest()) {
|
||||
return;
|
||||
}
|
||||
const normalizedPage = normalizeShowcaseResourcePage(page);
|
||||
setProjectShowcaseResources(normalizedPage.resources);
|
||||
setShowcaseCampaign(normalizedPage.campaign ?? null);
|
||||
setShowcaseNextCursor(normalizedPage.nextCursor);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (!isCurrentRequest()) {
|
||||
return;
|
||||
}
|
||||
setProjectShowcaseResources([]);
|
||||
setShowcaseCampaign(null);
|
||||
setShowcaseNextCursor(null);
|
||||
@@ -933,12 +989,13 @@ export function CreationLandingView({
|
||||
error instanceof Error ? error.message : '读取陶泥儿精选失败',
|
||||
);
|
||||
})
|
||||
.finally(() => setIsLoadingShowcase(false));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
refreshShowcaseResources();
|
||||
}, [refreshShowcaseResources]);
|
||||
.finally(() => {
|
||||
if (isCurrentRequest()) {
|
||||
setIsLoadingShowcase(false);
|
||||
}
|
||||
});
|
||||
return invalidateRequest;
|
||||
}, [canLoadShowcaseViewer, showcaseRequestScope, showcaseViewerUserId]);
|
||||
|
||||
const loadMoreShowcaseResources = useCallback(
|
||||
(options: { force?: boolean } = {}) => {
|
||||
@@ -956,10 +1013,20 @@ export function CreationLandingView({
|
||||
return;
|
||||
}
|
||||
showcaseLoadingMoreCursorRef.current = cursor;
|
||||
const requestState = { ...showcaseRequestStateRef.current };
|
||||
const isCurrentRequest = () =>
|
||||
showcaseRequestStateRef.current.scope === requestState.scope &&
|
||||
showcaseRequestStateRef.current.generation === requestState.generation;
|
||||
setIsLoadingMoreShowcase(true);
|
||||
setShowcaseLoadMoreError(null);
|
||||
listPublicEditorProjectResources({ cursor })
|
||||
listPublicEditorProjectResources({
|
||||
cursor,
|
||||
viewer: showcaseViewerUserId ? 'authenticated' : 'anonymous',
|
||||
})
|
||||
.then((page) => {
|
||||
if (!isCurrentRequest()) {
|
||||
return;
|
||||
}
|
||||
const normalizedPage = normalizeShowcaseResourcePage(page);
|
||||
setProjectShowcaseResources((current) =>
|
||||
mergeShowcaseResources(current, normalizedPage.resources),
|
||||
@@ -969,19 +1036,30 @@ export function CreationLandingView({
|
||||
showcaseFailedLoadMoreCursorRef.current = null;
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (!isCurrentRequest()) {
|
||||
return;
|
||||
}
|
||||
showcaseFailedLoadMoreCursorRef.current = cursor;
|
||||
setShowcaseLoadMoreError(
|
||||
error instanceof Error ? error.message : '读取陶泥儿精选失败',
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!isCurrentRequest()) {
|
||||
return;
|
||||
}
|
||||
if (showcaseLoadingMoreCursorRef.current === cursor) {
|
||||
showcaseLoadingMoreCursorRef.current = null;
|
||||
}
|
||||
setIsLoadingMoreShowcase(false);
|
||||
});
|
||||
},
|
||||
[isLoadingMoreShowcase, isLoadingShowcase, showcaseNextCursor],
|
||||
[
|
||||
isLoadingMoreShowcase,
|
||||
isLoadingShowcase,
|
||||
showcaseNextCursor,
|
||||
showcaseViewerUserId,
|
||||
],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -1056,19 +1134,19 @@ export function CreationLandingView({
|
||||
[createProject],
|
||||
);
|
||||
|
||||
const updateShowcaseLikeCount = useCallback(
|
||||
(showcaseId: string, likeCount: number) => {
|
||||
const updateShowcaseViewerState = useCallback(
|
||||
(showcaseId: string, likeCount: number, viewerLiked: boolean) => {
|
||||
setProjectShowcaseResources((current) =>
|
||||
current.map((resource) =>
|
||||
resource.showcaseId === showcaseId ||
|
||||
resource.resourceId === showcaseId
|
||||
? { ...resource, likeCount }
|
||||
? { ...resource, likeCount, viewerLiked }
|
||||
: resource,
|
||||
),
|
||||
);
|
||||
setSelectedShowcaseItem((current) =>
|
||||
current?.showcaseId === showcaseId
|
||||
? { ...current, likeCount }
|
||||
? { ...current, likeCount, viewerLiked }
|
||||
: current,
|
||||
);
|
||||
},
|
||||
@@ -1089,44 +1167,38 @@ export function CreationLandingView({
|
||||
return;
|
||||
}
|
||||
|
||||
const nextLiked = !likedShowcaseIds.has(showcaseId);
|
||||
const previousLikeCount = item.likeCount ?? 0;
|
||||
const optimisticLikeCount = Math.max(
|
||||
0,
|
||||
previousLikeCount + (nextLiked ? 1 : -1),
|
||||
);
|
||||
const nextLiked = item.viewerLiked !== true;
|
||||
const requestState = { ...showcaseRequestStateRef.current };
|
||||
const requestViewerKey = showcaseViewerKey;
|
||||
const isCurrentRequest = () =>
|
||||
showcaseRequestStateRef.current.scope === requestState.scope &&
|
||||
showcaseRequestStateRef.current.generation === requestState.generation &&
|
||||
requestViewerKey === showcaseViewerKey;
|
||||
setShowcaseLikeError(null);
|
||||
setPendingLikeShowcaseIds((current) => new Set(current).add(showcaseId));
|
||||
setLikedShowcaseIds((current) => {
|
||||
const next = new Set(current);
|
||||
if (nextLiked) {
|
||||
next.add(showcaseId);
|
||||
} else {
|
||||
next.delete(showcaseId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
updateShowcaseLikeCount(showcaseId, optimisticLikeCount);
|
||||
|
||||
toggleEditorShowcaseAssetLike(showcaseId, nextLiked)
|
||||
.then((record) => {
|
||||
updateShowcaseLikeCount(
|
||||
if (!isCurrentRequest()) {
|
||||
return;
|
||||
}
|
||||
updateShowcaseViewerState(
|
||||
showcaseId,
|
||||
record.likeCount ?? optimisticLikeCount,
|
||||
record.likeCount ?? item.likeCount ?? 0,
|
||||
record.viewerLiked,
|
||||
);
|
||||
})
|
||||
.catch(() => {
|
||||
setLikedShowcaseIds((current) => {
|
||||
const next = new Set(current);
|
||||
if (nextLiked) {
|
||||
next.delete(showcaseId);
|
||||
} else {
|
||||
next.add(showcaseId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
updateShowcaseLikeCount(showcaseId, previousLikeCount);
|
||||
if (isCurrentRequest()) {
|
||||
setShowcaseLikeError(
|
||||
nextLiked ? '点赞失败,请重试' : '取消点赞失败,请重试',
|
||||
);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!isCurrentRequest()) {
|
||||
return;
|
||||
}
|
||||
setPendingLikeShowcaseIds((current) => {
|
||||
const next = new Set(current);
|
||||
next.delete(showcaseId);
|
||||
@@ -1134,7 +1206,12 @@ export function CreationLandingView({
|
||||
});
|
||||
});
|
||||
},
|
||||
[authUi, likedShowcaseIds, pendingLikeShowcaseIds, updateShowcaseLikeCount],
|
||||
[
|
||||
authUi,
|
||||
pendingLikeShowcaseIds,
|
||||
showcaseViewerKey,
|
||||
updateShowcaseViewerState,
|
||||
],
|
||||
);
|
||||
|
||||
const showcaseItems = useMemo(
|
||||
@@ -1227,6 +1304,11 @@ export function CreationLandingView({
|
||||
|
||||
return (
|
||||
<>
|
||||
{showcaseLikeError ? (
|
||||
<PlatformStatusMessage tone="error" surface="platform" role="alert">
|
||||
{showcaseLikeError}
|
||||
</PlatformStatusMessage>
|
||||
) : null}
|
||||
<div
|
||||
ref={showcaseMasonryRef}
|
||||
className="creation-landing__asset-waterfall"
|
||||
@@ -1235,9 +1317,7 @@ export function CreationLandingView({
|
||||
>
|
||||
{visibleShowcaseItems.map((item) => {
|
||||
const showcaseId = item.showcaseId?.trim();
|
||||
const isLiked = Boolean(
|
||||
showcaseId && likedShowcaseIds.has(showcaseId),
|
||||
);
|
||||
const isLiked = item.viewerLiked === true;
|
||||
const isLikePending = Boolean(
|
||||
showcaseId && pendingLikeShowcaseIds.has(showcaseId),
|
||||
);
|
||||
@@ -1302,6 +1382,7 @@ export function CreationLandingView({
|
||||
: 'creation-landing__asset-like-button'
|
||||
}
|
||||
aria-label={`${isLiked ? '取消点赞' : '点赞'}:${item.label}`}
|
||||
aria-busy={isLikePending}
|
||||
disabled={isLikePending}
|
||||
onClick={() => handleToggleShowcaseLike(item)}
|
||||
>
|
||||
|
||||
@@ -44,6 +44,7 @@ export type ShowcaseAssetItem = {
|
||||
author: string;
|
||||
cost: string;
|
||||
likeCount?: number | null;
|
||||
viewerLiked?: boolean;
|
||||
showcaseId?: string | null;
|
||||
campaign?: boolean;
|
||||
};
|
||||
@@ -983,6 +984,7 @@ function toLibraryShowcaseItem(
|
||||
author: resolveGroupAuthor(sortedAssets, fallbackAuthorName),
|
||||
cost: resolveGroupCost(sortedAssets),
|
||||
likeCount: normalizeNumber(assetRecord(primaryAsset).likeCount),
|
||||
viewerLiked: assetRecord(primaryAsset).viewerLiked === true,
|
||||
showcaseId: normalizeText(assetRecord(primaryAsset).showcaseId),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -67,6 +67,17 @@ describe('ImageCanvasBottomToolbarView', () => {
|
||||
const user = userEvent.setup();
|
||||
const { toolbar, switchTool } = renderToolbar();
|
||||
|
||||
expect(toolbar.className).toContain('genarrative-image-canvas__toolbar');
|
||||
expect(toolbar.className).toContain(
|
||||
'genarrative-image-canvas__toolbar--floating',
|
||||
);
|
||||
expect(
|
||||
toolbar.querySelectorAll('.genarrative-image-canvas__toolbar-group'),
|
||||
).toHaveLength(12);
|
||||
expect(
|
||||
toolbar.querySelectorAll('.genarrative-image-canvas__toolbar-divider'),
|
||||
).toHaveLength(2);
|
||||
|
||||
const toolExpectations = [
|
||||
['选择工具', 'select'],
|
||||
['抓手工具', 'hand'],
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
import {
|
||||
CanvasToolbar,
|
||||
CanvasToolbarDivider,
|
||||
CanvasToolbarGroup,
|
||||
} from '@genarrative/image-canvas-react';
|
||||
import {
|
||||
AppWindow,
|
||||
Clapperboard,
|
||||
@@ -85,27 +90,22 @@ export function ImageCanvasBottomToolbarView({
|
||||
onCloseToolOptions,
|
||||
}: ImageCanvasBottomToolbarViewProps) {
|
||||
return (
|
||||
<div
|
||||
<CanvasToolbar
|
||||
className={[
|
||||
'image-canvas-editor__bottom-toolbar',
|
||||
highlight ? 'image-canvas-editor__bottom-toolbar--guided' : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
role="toolbar"
|
||||
aria-label="AI画布工具栏"
|
||||
label="AI画布工具栏"
|
||||
surface="floating"
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
{canvasTools.map(({ id, label, icon: Icon, separatorBefore }) => {
|
||||
if (isToolbarOptionTool(id)) {
|
||||
return (
|
||||
<span key={id} className="image-canvas-editor__bottom-tool-group">
|
||||
{separatorBefore ? (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="image-canvas-editor__bottom-toolbar-divider"
|
||||
/>
|
||||
) : null}
|
||||
<CanvasToolbarGroup key={id}>
|
||||
{separatorBefore ? <CanvasToolbarDivider /> : null}
|
||||
<span
|
||||
ref={
|
||||
id === 'spec'
|
||||
@@ -138,18 +138,13 @@ export function ImageCanvasBottomToolbarView({
|
||||
onClick={() => onSwitchTool(id)}
|
||||
/>
|
||||
</span>
|
||||
</span>
|
||||
</CanvasToolbarGroup>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span key={id} className="image-canvas-editor__bottom-tool-group">
|
||||
{separatorBefore ? (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="image-canvas-editor__bottom-toolbar-divider"
|
||||
/>
|
||||
) : null}
|
||||
<CanvasToolbarGroup key={id}>
|
||||
{separatorBefore ? <CanvasToolbarDivider /> : null}
|
||||
<EditorIconButton
|
||||
className={
|
||||
id === 'character-animation'
|
||||
@@ -162,9 +157,9 @@ export function ImageCanvasBottomToolbarView({
|
||||
pressed={hasPersistentPressedState(id) && effectiveTool === id}
|
||||
onClick={() => onSwitchTool(id)}
|
||||
/>
|
||||
</span>
|
||||
</CanvasToolbarGroup>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CanvasToolbar>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import {
|
||||
cleanup,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
} from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { ImageCanvasContextMenusView } from './ImageCanvasContextMenusView';
|
||||
@@ -56,6 +62,7 @@ function renderContextMenus(
|
||||
onFitLayers: vi.fn(),
|
||||
onOpenQuickEditPanel: vi.fn(),
|
||||
onOpenLayerMetadata: vi.fn(),
|
||||
onOpenCharacterAnimationPanel: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
|
||||
@@ -158,7 +165,8 @@ describe('ImageCanvasContextMenusView', () => {
|
||||
fireEvent.click(
|
||||
screen.getByRole('menuitem', { name: 'Spine 导出(zip)' }),
|
||||
);
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '快速编辑' }));
|
||||
expect(screen.queryByRole('menuitem', { name: '快速编辑' })).toBeNull();
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '生成动画' }));
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '删除' }));
|
||||
|
||||
expect(props.onCopyContextLayers).toHaveBeenNthCalledWith(1);
|
||||
@@ -176,7 +184,8 @@ describe('ImageCanvasContextMenusView', () => {
|
||||
2,
|
||||
expect.objectContaining({ mode: 'spine-json' }),
|
||||
);
|
||||
expect(props.onOpenQuickEditPanel).toHaveBeenCalledWith(layer);
|
||||
expect(props.onOpenQuickEditPanel).not.toHaveBeenCalled();
|
||||
expect(props.onOpenCharacterAnimationPanel).toHaveBeenCalledWith(layer);
|
||||
expect(props.onCloseContextMenu).toHaveBeenCalledTimes(1);
|
||||
expect(props.onCloseImageContextMenu).toHaveBeenCalledTimes(1);
|
||||
expect(props.onDeleteContextLayers).toHaveBeenCalledTimes(1);
|
||||
@@ -316,6 +325,19 @@ describe('ImageCanvasContextMenusView', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('closes the standalone image menu after opening character animation', () => {
|
||||
const layer = createLayer({ assetKind: 'character' });
|
||||
const props = renderContextMenus({
|
||||
imageContextMenu: { layerId: layer.id, x: 20, y: 22 },
|
||||
imageContextMenuLayer: layer,
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '生成动画' }));
|
||||
|
||||
expect(props.onOpenCharacterAnimationPanel).toHaveBeenCalledWith(layer);
|
||||
expect(props.onCloseImageContextMenu).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('renders the image context menu when no canvas context menu is open', () => {
|
||||
const layer = createLayer({ assetKind: 'character' });
|
||||
const props = renderContextMenus({
|
||||
@@ -333,6 +355,46 @@ describe('ImageCanvasContextMenusView', () => {
|
||||
expect(props.onDeleteLayerById).toHaveBeenCalledWith(layer.id);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
title: '角色动作',
|
||||
mediaType: 'image-sequence' as const,
|
||||
assetKind: 'character-animation' as const,
|
||||
},
|
||||
{
|
||||
title: '点击音效',
|
||||
mediaType: 'audio' as const,
|
||||
assetKind: 'sound-effect' as const,
|
||||
},
|
||||
{
|
||||
title: '背景音乐',
|
||||
mediaType: 'audio' as const,
|
||||
assetKind: 'background-music' as const,
|
||||
},
|
||||
])('hides quick edit from both menus for $title', (overrides) => {
|
||||
const layer = createLayer(overrides);
|
||||
renderContextMenus({
|
||||
contextMenu: {
|
||||
kind: 'layer',
|
||||
x: 16,
|
||||
y: 18,
|
||||
layerId: layer.id,
|
||||
canvasPoint: { x: 40, y: 42 },
|
||||
},
|
||||
imageContextMenuLayer: layer,
|
||||
contextMenuLayer: layer,
|
||||
});
|
||||
|
||||
expect(screen.queryByRole('menuitem', { name: '快速编辑' })).toBeNull();
|
||||
|
||||
cleanup();
|
||||
renderContextMenus({
|
||||
imageContextMenu: { layerId: layer.id, x: 20, y: 22 },
|
||||
imageContextMenuLayer: layer,
|
||||
});
|
||||
expect(screen.queryByRole('menuitem', { name: '快速编辑' })).toBeNull();
|
||||
});
|
||||
|
||||
it('hides quick edit from the standalone menu for individual icons', () => {
|
||||
const layer = createLayer({ assetKind: 'icon' });
|
||||
renderContextMenus({
|
||||
|
||||
@@ -18,7 +18,7 @@ import type {
|
||||
CanvasViewport,
|
||||
ImageContextMenuState,
|
||||
} from './ImageCanvasEditorTypes';
|
||||
import { isQuickEditUnsupportedAssetKind } from './ImageCanvasGenerationModel';
|
||||
import { isQuickEditSupportedLayer } from './ImageCanvasGenerationModel';
|
||||
import type { CanvasLayerCopyBlockReason } from './ImageCanvasLayerCommandModel';
|
||||
|
||||
type ImageCanvasContextMenusViewProps = {
|
||||
@@ -53,6 +53,7 @@ type ImageCanvasContextMenusViewProps = {
|
||||
onFitLayers: () => void;
|
||||
onOpenQuickEditPanel: (layer: CanvasLayer) => void;
|
||||
onOpenLayerMetadata: (layer: CanvasLayer) => void;
|
||||
onOpenCharacterAnimationPanel: (layer: CanvasLayer) => void;
|
||||
};
|
||||
|
||||
type MeasuredMenuLayout = {
|
||||
@@ -93,6 +94,7 @@ export function ImageCanvasContextMenusView({
|
||||
onFitLayers,
|
||||
onOpenQuickEditPanel,
|
||||
onOpenLayerMetadata,
|
||||
onOpenCharacterAnimationPanel,
|
||||
}: ImageCanvasContextMenusViewProps) {
|
||||
const isImageSequenceLayer = contextMenuLayer?.mediaType === 'image-sequence';
|
||||
const layerCopyBlockedTitle =
|
||||
@@ -499,7 +501,7 @@ export function ImageCanvasContextMenusView({
|
||||
<hr />
|
||||
{imageContextMenuLayer ? (
|
||||
<>
|
||||
{!isQuickEditUnsupportedAssetKind(imageContextMenuLayer) ? (
|
||||
{isQuickEditSupportedLayer(imageContextMenuLayer) ? (
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
@@ -523,6 +525,19 @@ export function ImageCanvasContextMenusView({
|
||||
>
|
||||
查看图片信息
|
||||
</button>
|
||||
{imageContextMenuLayer.assetKind === 'character' ? (
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
onOpenCharacterAnimationPanel(imageContextMenuLayer);
|
||||
onCloseContextMenu();
|
||||
onCloseImageContextMenu();
|
||||
}}
|
||||
>
|
||||
生成动画
|
||||
</button>
|
||||
) : null}
|
||||
<hr />
|
||||
</>
|
||||
) : null}
|
||||
@@ -550,7 +565,7 @@ export function ImageCanvasContextMenusView({
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
<PlatformFloatingMenu label="图片功能面板" placement="bottom-start">
|
||||
{!isQuickEditUnsupportedAssetKind(imageContextMenuLayer) ? (
|
||||
{isQuickEditSupportedLayer(imageContextMenuLayer) ? (
|
||||
<PlatformFloatingMenuItem
|
||||
className="image-canvas-editor__context-menu-item"
|
||||
onClick={() => {
|
||||
@@ -570,6 +585,17 @@ export function ImageCanvasContextMenusView({
|
||||
>
|
||||
查看图片信息
|
||||
</PlatformFloatingMenuItem>
|
||||
{imageContextMenuLayer.assetKind === 'character' ? (
|
||||
<PlatformFloatingMenuItem
|
||||
className="image-canvas-editor__context-menu-item"
|
||||
onClick={() => {
|
||||
onOpenCharacterAnimationPanel(imageContextMenuLayer);
|
||||
onCloseImageContextMenu();
|
||||
}}
|
||||
>
|
||||
生成动画
|
||||
</PlatformFloatingMenuItem>
|
||||
) : null}
|
||||
<PlatformFloatingMenuItem
|
||||
className="image-canvas-editor__context-menu-item"
|
||||
onClick={() => onDeleteLayerById(imageContextMenuLayer.id)}
|
||||
|
||||
@@ -168,7 +168,7 @@ function createTestImageAsset({
|
||||
width: 320,
|
||||
height: 240,
|
||||
sourceType,
|
||||
assetKind: 'image' as const,
|
||||
assetKind: null,
|
||||
sourceResourceId,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
} from '../../services/image-editor/editorProjectClient';
|
||||
import {
|
||||
CANVAS_WORLD_ORIGIN,
|
||||
canvasAssetKindOrNull,
|
||||
canvasDisplayScaleToViewportScale,
|
||||
canvasDisplayViewportToViewport,
|
||||
collectExpiredInlineGenerationDialogIds,
|
||||
@@ -548,7 +549,7 @@ describe('ImageCanvasEditorModel', () => {
|
||||
width: 640,
|
||||
height: 640,
|
||||
sourceType: 'generated',
|
||||
assetKind: 'image',
|
||||
assetKind: null,
|
||||
sourceResourceId: 'resource-generated',
|
||||
},
|
||||
],
|
||||
@@ -557,6 +558,7 @@ describe('ImageCanvasEditorModel', () => {
|
||||
expect(library.assets[0]).toMatchObject({
|
||||
id: 'asset-generated',
|
||||
sourceResourceId: 'resource-generated',
|
||||
assetKind: null,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1118,7 +1120,7 @@ describe('ImageCanvasEditorModel', () => {
|
||||
assetKindOverride: 'character-animation',
|
||||
},
|
||||
new Map([
|
||||
['resource-image', { imageSrc: '/read/image.png', assetKind: 'image' }],
|
||||
['resource-image', { imageSrc: '/read/image.png', assetKind: null }],
|
||||
]),
|
||||
{ onAssetKindOverrideFallback },
|
||||
);
|
||||
@@ -1127,29 +1129,28 @@ describe('ImageCanvasEditorModel', () => {
|
||||
id: 'layer-image-with-action-label',
|
||||
src: '/read/image.png',
|
||||
mediaType: 'image',
|
||||
resourceAssetKind: 'image',
|
||||
resourceAssetKind: null,
|
||||
assetKindOverride: null,
|
||||
assetKind: 'image',
|
||||
assetKind: null,
|
||||
});
|
||||
expect(onAssetKindOverrideFallback).toHaveBeenCalledWith({
|
||||
layerId: 'layer-image-with-action-label',
|
||||
resourceId: 'resource-image',
|
||||
resourceAssetKind: 'image',
|
||||
resourceAssetKind: null,
|
||||
rejectedAssetKindOverride: 'character-animation',
|
||||
});
|
||||
});
|
||||
|
||||
it('uses the same media-family compatibility matrix for layer labels', () => {
|
||||
expect(isCanvasAssetKindOverrideCompatible('image', 'character')).toBe(
|
||||
true,
|
||||
);
|
||||
expect(canvasAssetKindOrNull('image')).toBeNull();
|
||||
expect(isCanvasAssetKindOverrideCompatible(null, 'character')).toBe(true);
|
||||
expect(isCanvasAssetKindOverrideCompatible(null, 'icon')).toBe(true);
|
||||
expect(
|
||||
isCanvasAssetKindOverrideCompatible('sound-effect', 'background-music'),
|
||||
).toBe(true);
|
||||
expect(isCanvasAssetKindOverrideCompatible('image', 'video')).toBe(false);
|
||||
expect(isCanvasAssetKindOverrideCompatible(null, 'video')).toBe(false);
|
||||
expect(
|
||||
isCanvasAssetKindOverrideCompatible('image', 'character-animation'),
|
||||
isCanvasAssetKindOverrideCompatible(null, 'character-animation'),
|
||||
).toBe(false);
|
||||
expect(isCanvasAssetKindOverrideCompatible('video', 'character')).toBe(
|
||||
false,
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
import {
|
||||
canvasDisplayScaleToViewportScale,
|
||||
clamp,
|
||||
resolveSnappedItemPosition,
|
||||
viewportScaleToCanvasDisplayScale,
|
||||
} from '@genarrative/image-canvas-core';
|
||||
|
||||
import {
|
||||
EDITOR_SOUND_EFFECT_MODEL,
|
||||
SOUND_EFFECT_DURATION_MAX_SECONDS,
|
||||
@@ -19,19 +26,41 @@ import type {
|
||||
CanvasGenerationInputs,
|
||||
CanvasLayer,
|
||||
CanvasMediaType,
|
||||
CanvasSnapItem,
|
||||
CanvasViewport,
|
||||
CharacterReferenceImage,
|
||||
EditorAsset,
|
||||
EditorAssetFolder,
|
||||
PerfectPixelOperationSnapshot,
|
||||
SnapCandidate,
|
||||
} from './ImageCanvasEditorTypes';
|
||||
import {
|
||||
hydrateCanvasGenerationInputs,
|
||||
hydrateCanvasGenerationInputsResult,
|
||||
} from './ImageCanvasGenerationInputsModel';
|
||||
|
||||
export {
|
||||
CANVAS_DISPLAY_SCALE_BASE,
|
||||
CANVAS_WORLD_ORIGIN,
|
||||
CANVAS_WORLD_SIZE,
|
||||
canvasDisplayScaleToViewportScale,
|
||||
clamp,
|
||||
DEFAULT_CANVAS_SIZE,
|
||||
findNearestSnap,
|
||||
FIT_VIEW_PADDING,
|
||||
formatCanvasDisplayScalePercent,
|
||||
formatPercent,
|
||||
getLayerBounds,
|
||||
MAX_HISTORY_STEPS,
|
||||
MAX_SCALE,
|
||||
MIN_SCALE,
|
||||
MINIMAP_DRAG_SENSITIVITY,
|
||||
MINIMAP_PADDING,
|
||||
MINIMAP_SIZE,
|
||||
resolveSnappedItemPosition,
|
||||
SNAP_DISTRIBUTION_OVERLAP_TOLERANCE,
|
||||
SNAP_THRESHOLD_SCREEN_PX,
|
||||
viewportScaleToCanvasDisplayScale,
|
||||
} from '@genarrative/image-canvas-core';
|
||||
|
||||
export const EDITOR_ASSET_FOLDERS: EditorAssetFolder[] = [
|
||||
{
|
||||
id: 'project',
|
||||
@@ -42,20 +71,7 @@ export const EDITOR_ASSET_FOLDERS: EditorAssetFolder[] = [
|
||||
},
|
||||
];
|
||||
|
||||
export const CANVAS_WORLD_SIZE = 12000;
|
||||
export const CANVAS_WORLD_ORIGIN = CANVAS_WORLD_SIZE / 2;
|
||||
export const MIN_SCALE = 0.025;
|
||||
export const MAX_SCALE = 3.2;
|
||||
export const CANVAS_DISPLAY_SCALE_BASE = 0.5;
|
||||
export const TOOLBAR_HALF_WIDTH = 132;
|
||||
export const DEFAULT_CANVAS_SIZE = { width: 900, height: 640 };
|
||||
export const SNAP_THRESHOLD_SCREEN_PX = 18;
|
||||
export const SNAP_DISTRIBUTION_OVERLAP_TOLERANCE = 1;
|
||||
const SNAP_DISTRIBUTION_PAIR_LOOKAHEAD = 3;
|
||||
export const FIT_VIEW_PADDING = 10;
|
||||
export const MINIMAP_SIZE = { width: 132, height: 84 };
|
||||
export const MINIMAP_PADDING = 8;
|
||||
export const MINIMAP_DRAG_SENSITIVITY = 0.3;
|
||||
export const ASSET_DRAG_MIME_TYPE = 'application/x-genarrative-editor-asset';
|
||||
const INTERNAL_EDITOR_PROCESSING_MODELS = new Set([
|
||||
'anime-seg',
|
||||
@@ -75,7 +91,6 @@ export function isEditorInternalProcessingModel(
|
||||
);
|
||||
}
|
||||
|
||||
export const MAX_HISTORY_STEPS = 60;
|
||||
export const CONTEXT_MENU_VIEWPORT_MARGIN = 8;
|
||||
export const CONTEXT_MENU_SIZE = {
|
||||
blank: { width: 188, height: 176 },
|
||||
@@ -106,29 +121,6 @@ export function normalizeCanvasBackgroundHex(value: string) {
|
||||
return `#${hexValue}`;
|
||||
}
|
||||
|
||||
export function clamp(value: number, min: number, max: number) {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
export function formatPercent(value: number) {
|
||||
return `${Math.round(value * 100)}%`;
|
||||
}
|
||||
|
||||
export function formatCanvasDisplayScalePercent(scale: number) {
|
||||
const safeScale = Number.isFinite(scale) ? scale : CANVAS_DISPLAY_SCALE_BASE;
|
||||
return formatPercent(safeScale / CANVAS_DISPLAY_SCALE_BASE);
|
||||
}
|
||||
|
||||
export function canvasDisplayScaleToViewportScale(displayScale: number) {
|
||||
const safeDisplayScale = Number.isFinite(displayScale) ? displayScale : 1;
|
||||
return safeDisplayScale * CANVAS_DISPLAY_SCALE_BASE;
|
||||
}
|
||||
|
||||
export function viewportScaleToCanvasDisplayScale(scale: number) {
|
||||
const safeScale = Number.isFinite(scale) ? scale : CANVAS_DISPLAY_SCALE_BASE;
|
||||
return safeScale / CANVAS_DISPLAY_SCALE_BASE;
|
||||
}
|
||||
|
||||
export function viewportToCanvasDisplayViewport(
|
||||
viewport: CanvasViewport,
|
||||
): CanvasViewport {
|
||||
@@ -1877,7 +1869,7 @@ function inferEditorAssetKind(
|
||||
objectKey?: string | null,
|
||||
assetKind?: string | null,
|
||||
mediaType?: CanvasMediaType,
|
||||
): CanvasAssetKind {
|
||||
): CanvasAssetKind | null {
|
||||
const normalizedAssetKind = canvasAssetKindOrNull(assetKind);
|
||||
if (normalizedAssetKind) {
|
||||
return normalizedAssetKind;
|
||||
@@ -1893,7 +1885,7 @@ function inferEditorAssetKind(
|
||||
if (inferredMediaType === 'audio') {
|
||||
return inferAudioAssetKindFromLabel(label);
|
||||
}
|
||||
return 'image';
|
||||
return null;
|
||||
}
|
||||
|
||||
function hasMediaFileExtension(
|
||||
@@ -2232,8 +2224,7 @@ export function generationInputsOrNull(
|
||||
}
|
||||
|
||||
export function canvasAssetKindOrNull(value: unknown): CanvasAssetKind | null {
|
||||
return value === 'image' ||
|
||||
value === 'audio' ||
|
||||
return value === 'audio' ||
|
||||
value === 'spec' ||
|
||||
value === 'character' ||
|
||||
value === 'character-animation' ||
|
||||
@@ -2467,27 +2458,6 @@ function hydrateGenerationPlaceholder(
|
||||
};
|
||||
}
|
||||
|
||||
export function getLayerBounds(targetLayers: CanvasLayer[]) {
|
||||
if (targetLayers.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return targetLayers.reduce(
|
||||
(current, layer) => ({
|
||||
minX: Math.min(current.minX, layer.x),
|
||||
minY: Math.min(current.minY, layer.y),
|
||||
maxX: Math.max(current.maxX, layer.x + layer.width),
|
||||
maxY: Math.max(current.maxY, layer.y + layer.height),
|
||||
}),
|
||||
{
|
||||
minX: Number.POSITIVE_INFINITY,
|
||||
minY: Number.POSITIVE_INFINITY,
|
||||
maxX: Number.NEGATIVE_INFINITY,
|
||||
maxY: Number.NEGATIVE_INFINITY,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveSnappedLayerPosition(
|
||||
movingLayer: CanvasLayer,
|
||||
proposedX: number,
|
||||
@@ -2503,268 +2473,3 @@ export function resolveSnappedLayerPosition(
|
||||
scale,
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveSnappedItemPosition(
|
||||
movingItem: CanvasSnapItem,
|
||||
proposedX: number,
|
||||
proposedY: number,
|
||||
items: CanvasSnapItem[],
|
||||
scale: number,
|
||||
) {
|
||||
const threshold = SNAP_THRESHOLD_SCREEN_PX / Math.max(scale, MIN_SCALE);
|
||||
const visibleItems = items.filter(
|
||||
(item) => item.id !== movingItem.id && !item.hidden,
|
||||
);
|
||||
const verticalTargets = [
|
||||
0,
|
||||
CANVAS_WORLD_ORIGIN,
|
||||
...visibleItems.flatMap((item) => [
|
||||
item.x,
|
||||
item.x + item.width / 2,
|
||||
item.x + item.width,
|
||||
]),
|
||||
];
|
||||
const horizontalTargets = [
|
||||
0,
|
||||
CANVAS_WORLD_ORIGIN,
|
||||
...visibleItems.flatMap((item) => [
|
||||
item.y,
|
||||
item.y + item.height / 2,
|
||||
item.y + item.height,
|
||||
]),
|
||||
];
|
||||
|
||||
const xAlignmentSnap = findNearestSnap(
|
||||
proposedX,
|
||||
[0, movingItem.width / 2, movingItem.width],
|
||||
verticalTargets,
|
||||
threshold,
|
||||
);
|
||||
const yAlignmentSnap = findNearestSnap(
|
||||
proposedY,
|
||||
[0, movingItem.height / 2, movingItem.height],
|
||||
horizontalTargets,
|
||||
threshold,
|
||||
);
|
||||
const xDistributionSnap = findNearestEqualSpacingSnap({
|
||||
axis: 'x',
|
||||
proposedStart: proposedX,
|
||||
proposedCrossStart: proposedY,
|
||||
movingSize: movingItem.width,
|
||||
movingCrossSize: movingItem.height,
|
||||
items: visibleItems,
|
||||
threshold,
|
||||
});
|
||||
const yDistributionSnap = findNearestEqualSpacingSnap({
|
||||
axis: 'y',
|
||||
proposedStart: proposedY,
|
||||
proposedCrossStart: proposedX,
|
||||
movingSize: movingItem.height,
|
||||
movingCrossSize: movingItem.width,
|
||||
items: visibleItems,
|
||||
threshold,
|
||||
});
|
||||
const xSnap = chooseNearestSnap(xAlignmentSnap, xDistributionSnap);
|
||||
const ySnap = chooseNearestSnap(yAlignmentSnap, yDistributionSnap);
|
||||
|
||||
return {
|
||||
x: xSnap ? xSnap.position : proposedX,
|
||||
y: ySnap ? ySnap.position : proposedY,
|
||||
guide:
|
||||
xSnap || ySnap
|
||||
? {
|
||||
vertical: xSnap?.guide,
|
||||
horizontal: ySnap?.guide,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
function chooseNearestSnap(
|
||||
first: SnapCandidate | null,
|
||||
second: SnapCandidate | null,
|
||||
) {
|
||||
if (!first) {
|
||||
return second;
|
||||
}
|
||||
if (!second) {
|
||||
return first;
|
||||
}
|
||||
return second.distance < first.distance ? second : first;
|
||||
}
|
||||
|
||||
function findNearestEqualSpacingSnap({
|
||||
axis,
|
||||
proposedStart,
|
||||
proposedCrossStart,
|
||||
movingSize,
|
||||
movingCrossSize,
|
||||
items,
|
||||
threshold,
|
||||
}: {
|
||||
axis: 'x' | 'y';
|
||||
proposedStart: number;
|
||||
proposedCrossStart: number;
|
||||
movingSize: number;
|
||||
movingCrossSize: number;
|
||||
items: CanvasSnapItem[];
|
||||
threshold: number;
|
||||
}): SnapCandidate | null {
|
||||
const orderedItems = items
|
||||
.filter((item) =>
|
||||
snapItemsOverlapOnCrossAxis(
|
||||
proposedCrossStart,
|
||||
movingCrossSize,
|
||||
item,
|
||||
axis,
|
||||
),
|
||||
)
|
||||
.sort(
|
||||
(firstItem, secondItem) =>
|
||||
getSnapItemStart(firstItem, axis) - getSnapItemStart(secondItem, axis),
|
||||
);
|
||||
let nearest: SnapCandidate | null = null;
|
||||
|
||||
for (let firstIndex = 0; firstIndex < orderedItems.length; firstIndex += 1) {
|
||||
const firstItem = orderedItems[firstIndex];
|
||||
if (!firstItem) {
|
||||
continue;
|
||||
}
|
||||
for (
|
||||
let secondIndex = firstIndex + 1;
|
||||
secondIndex < orderedItems.length &&
|
||||
secondIndex <= firstIndex + SNAP_DISTRIBUTION_PAIR_LOOKAHEAD;
|
||||
secondIndex += 1
|
||||
) {
|
||||
const secondItem = orderedItems[secondIndex];
|
||||
if (!secondItem) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const firstStart = getSnapItemStart(firstItem, axis);
|
||||
const firstEnd = getSnapItemEnd(firstItem, axis);
|
||||
const secondStart = getSnapItemStart(secondItem, axis);
|
||||
const secondEnd = getSnapItemEnd(secondItem, axis);
|
||||
const pairGap = secondStart - firstEnd;
|
||||
if (pairGap < 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
nearest = chooseNearestSnap(
|
||||
nearest,
|
||||
createDistributionSnapCandidate({
|
||||
position: firstStart - pairGap - movingSize,
|
||||
proposedStart,
|
||||
movingSize,
|
||||
threshold,
|
||||
}),
|
||||
);
|
||||
nearest = chooseNearestSnap(
|
||||
nearest,
|
||||
createDistributionSnapCandidate({
|
||||
position: secondEnd + pairGap,
|
||||
proposedStart,
|
||||
movingSize,
|
||||
threshold,
|
||||
}),
|
||||
);
|
||||
|
||||
const betweenGap = (pairGap - movingSize) / 2;
|
||||
if (betweenGap >= 0) {
|
||||
nearest = chooseNearestSnap(
|
||||
nearest,
|
||||
createDistributionSnapCandidate({
|
||||
position: firstEnd + betweenGap,
|
||||
proposedStart,
|
||||
movingSize,
|
||||
threshold,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nearest;
|
||||
}
|
||||
|
||||
function createDistributionSnapCandidate({
|
||||
position,
|
||||
proposedStart,
|
||||
movingSize,
|
||||
threshold,
|
||||
}: {
|
||||
position: number;
|
||||
proposedStart: number;
|
||||
movingSize: number;
|
||||
threshold: number;
|
||||
}): SnapCandidate | null {
|
||||
const distance = Math.abs(position - proposedStart);
|
||||
if (distance > threshold) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
position,
|
||||
guide: position + movingSize / 2,
|
||||
distance,
|
||||
};
|
||||
}
|
||||
|
||||
function getSnapItemStart(item: CanvasSnapItem, axis: 'x' | 'y') {
|
||||
return axis === 'x' ? item.x : item.y;
|
||||
}
|
||||
|
||||
function getSnapItemSize(item: CanvasSnapItem, axis: 'x' | 'y') {
|
||||
return axis === 'x' ? item.width : item.height;
|
||||
}
|
||||
|
||||
function getSnapItemEnd(item: CanvasSnapItem, axis: 'x' | 'y') {
|
||||
return getSnapItemStart(item, axis) + getSnapItemSize(item, axis);
|
||||
}
|
||||
|
||||
function getSnapItemCrossStart(item: CanvasSnapItem, axis: 'x' | 'y') {
|
||||
return axis === 'x' ? item.y : item.x;
|
||||
}
|
||||
|
||||
function getSnapItemCrossSize(item: CanvasSnapItem, axis: 'x' | 'y') {
|
||||
return axis === 'x' ? item.height : item.width;
|
||||
}
|
||||
|
||||
function snapItemsOverlapOnCrossAxis(
|
||||
proposedCrossStart: number,
|
||||
movingCrossSize: number,
|
||||
item: CanvasSnapItem,
|
||||
axis: 'x' | 'y',
|
||||
) {
|
||||
const movingCrossEnd = proposedCrossStart + movingCrossSize;
|
||||
const itemCrossStart = getSnapItemCrossStart(item, axis);
|
||||
const itemCrossEnd = itemCrossStart + getSnapItemCrossSize(item, axis);
|
||||
return (
|
||||
proposedCrossStart <= itemCrossEnd - SNAP_DISTRIBUTION_OVERLAP_TOLERANCE &&
|
||||
movingCrossEnd >= itemCrossStart + SNAP_DISTRIBUTION_OVERLAP_TOLERANCE
|
||||
);
|
||||
}
|
||||
|
||||
export function findNearestSnap(
|
||||
origin: number,
|
||||
offsets: number[],
|
||||
targets: number[],
|
||||
threshold: number,
|
||||
): SnapCandidate | null {
|
||||
let nearest: SnapCandidate | null = null;
|
||||
for (const offset of offsets) {
|
||||
for (const target of targets) {
|
||||
const distance = Math.abs(target - (origin + offset));
|
||||
if (distance > threshold) {
|
||||
continue;
|
||||
}
|
||||
if (!nearest || distance < nearest.distance) {
|
||||
nearest = {
|
||||
position: target - offset,
|
||||
guide: target,
|
||||
distance,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
return nearest;
|
||||
}
|
||||
|
||||
@@ -1,21 +1,16 @@
|
||||
import { CanvasPortal } from '@genarrative/image-canvas-react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import { useAuthUi } from '../auth/AuthUiContext';
|
||||
|
||||
export function ImageCanvasEditorPortal({ children }: { children: ReactNode }) {
|
||||
const platformTheme = useAuthUi()?.platformTheme ?? 'light';
|
||||
|
||||
if (typeof document === 'undefined') {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
return (
|
||||
<CanvasPortal
|
||||
className={`platform-theme platform-theme--${platformTheme} image-canvas-editor__portal-theme`}
|
||||
>
|
||||
{children}
|
||||
</div>,
|
||||
document.body,
|
||||
</CanvasPortal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
SidebarMediaItem,
|
||||
} from './ImageCanvasEditorPrimitives';
|
||||
|
||||
test('editor icon button delegates accessible icon chrome to the platform primitive', () => {
|
||||
test('editor icon button delegates accessible icon chrome to the shared canvas primitive', () => {
|
||||
render(
|
||||
<EditorIconButton
|
||||
label="素材选择模式"
|
||||
@@ -23,7 +23,7 @@ test('editor icon button delegates accessible icon chrome to the platform primit
|
||||
|
||||
const button = screen.getByRole('button', { name: '素材选择模式' });
|
||||
|
||||
expect(button.className).toContain('platform-icon-button');
|
||||
expect(button.className).toContain('genarrative-image-canvas__chrome-button');
|
||||
expect(button.className).toContain('image-canvas-editor__icon-button');
|
||||
expect(button.getAttribute('title')).toBe('选择');
|
||||
expect(button.getAttribute('aria-pressed')).toBe('true');
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { CanvasChromeButton } from '@genarrative/image-canvas-react';
|
||||
import type {
|
||||
ComponentType,
|
||||
DragEventHandler,
|
||||
@@ -39,6 +40,24 @@ export function EditorIconButton({
|
||||
variant,
|
||||
onClick,
|
||||
}: EditorIconButtonProps) {
|
||||
const iconNode = <Icon className="h-4 w-4" />;
|
||||
|
||||
if (variant === undefined || variant === 'platformIcon') {
|
||||
return (
|
||||
<CanvasChromeButton
|
||||
type={type}
|
||||
className={className}
|
||||
label={label}
|
||||
title={title}
|
||||
disabled={disabled}
|
||||
pressed={pressed}
|
||||
expanded={expanded}
|
||||
onClick={onClick}
|
||||
icon={iconNode}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PlatformIconButton
|
||||
variant={variant}
|
||||
@@ -50,7 +69,7 @@ export function EditorIconButton({
|
||||
aria-pressed={pressed}
|
||||
aria-expanded={expanded}
|
||||
onClick={onClick}
|
||||
icon={<Icon className="h-4 w-4" />}
|
||||
icon={iconNode}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -216,6 +216,7 @@ function createStageProps(): ImageCanvasStageViewProps {
|
||||
onCropExpandHandlePointerDown: vi.fn(),
|
||||
onOpenQuickEditPanel: vi.fn(),
|
||||
onOpenRedrawPanel: vi.fn(),
|
||||
onOpenCharacterAnimationPanel: vi.fn(),
|
||||
onOpenCropExpandPanel: vi.fn(),
|
||||
onRemoveBackground: vi.fn(),
|
||||
onPerfectPixel: vi.fn(),
|
||||
|
||||
@@ -23,7 +23,6 @@ import type {
|
||||
export type CanvasSourceType = 'uploaded' | 'generated' | 'mock_generated';
|
||||
|
||||
export type CanvasAssetKind =
|
||||
| 'image'
|
||||
| 'audio'
|
||||
| 'spec'
|
||||
| 'character'
|
||||
@@ -374,6 +373,7 @@ export type CanvasHistorySnapshot = {
|
||||
|
||||
export type CanvasHistoryActionType =
|
||||
| 'move-image'
|
||||
| 'resize-image'
|
||||
| 'move-generation-result'
|
||||
| 'delete-image'
|
||||
| 'delete-generation-result'
|
||||
|
||||
@@ -2312,7 +2312,9 @@ describe('ImageCanvasEditorView', () => {
|
||||
});
|
||||
|
||||
expect(screen.queryByRole('button', { name: '画布小地图' })).toBeNull();
|
||||
expect(backgroundButton.className).toContain('platform-icon-button');
|
||||
expect(backgroundButton.className).toContain(
|
||||
'genarrative-image-canvas__chrome-button',
|
||||
);
|
||||
const minimapToggle = within(panelToolbar).getByRole('button', {
|
||||
name: '切换小地图',
|
||||
});
|
||||
|
||||
@@ -2691,6 +2691,7 @@ export function ImageCanvasEditorView({
|
||||
onQuickEditSelectionPointerStart: startQuickEditSelectionPointer,
|
||||
onQuickEditSelectionPointerMove: moveQuickEditSelectionPointer,
|
||||
onQuickEditSelectionPointerEnd: endQuickEditSelectionPointer,
|
||||
onOpenCharacterAnimationPanel: openCharacterAnimationPanel,
|
||||
onDownloadLayer: exportLayerImage,
|
||||
onPasteCanvasClipboard: pasteCanvasOrSystemClipboard,
|
||||
onCopyContextLayers: copyContextLayers,
|
||||
|
||||
@@ -52,7 +52,7 @@ import {
|
||||
IMAGE_MODEL_GPT_IMAGE_2,
|
||||
IMAGE_MODEL_NANOBANANA2,
|
||||
isNormalizedCanvasGenerationInputs,
|
||||
isQuickEditUnsupportedAssetKind,
|
||||
isQuickEditSupportedLayer,
|
||||
resolveCharacterAnimationSourceImageSrc,
|
||||
resolveEditorCharacterAnimationFramePixelSize,
|
||||
resolveEditorImageGenerationPixelSize,
|
||||
@@ -670,16 +670,6 @@ describe('ImageCanvasGenerationModel', () => {
|
||||
},
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
canOpenRedrawPanel({
|
||||
...generatedLayer,
|
||||
assetKind: 'image',
|
||||
generationInputs: {
|
||||
fields: [{ title: '画面内容', value: '非场景展示字段' }],
|
||||
references: [],
|
||||
},
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
canOpenRedrawPanel({
|
||||
...generatedLayer,
|
||||
@@ -1071,17 +1061,52 @@ describe('ImageCanvasGenerationModel', () => {
|
||||
{ label: 'gpt-image-2', value: 'gpt-image-2' },
|
||||
]);
|
||||
expect(
|
||||
isQuickEditUnsupportedAssetKind({
|
||||
isQuickEditSupportedLayer({
|
||||
...buildSourceLayer(),
|
||||
assetKind: 'icon',
|
||||
mediaType: 'image',
|
||||
assetKind: null,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isQuickEditUnsupportedAssetKind({
|
||||
isQuickEditSupportedLayer({
|
||||
...buildSourceLayer(),
|
||||
assetKind: 'icon',
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isQuickEditSupportedLayer({
|
||||
...buildSourceLayer(),
|
||||
assetKind: 'icon-spritesheet',
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isQuickEditSupportedLayer({
|
||||
...buildSourceLayer(),
|
||||
assetKind: 'scene',
|
||||
resourceAssetKind: 'icon',
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isQuickEditSupportedLayer({
|
||||
...buildSourceLayer(),
|
||||
mediaType: 'image-sequence',
|
||||
assetKind: 'character-animation',
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isQuickEditSupportedLayer({
|
||||
...buildSourceLayer(),
|
||||
mediaType: 'audio',
|
||||
assetKind: 'sound-effect',
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isQuickEditSupportedLayer({
|
||||
...buildSourceLayer(),
|
||||
mediaType: 'video',
|
||||
assetKind: 'video',
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('adds reference image semantics and snapshots for spec generation references', () => {
|
||||
|
||||
@@ -820,9 +820,29 @@ export function buildQuickEditModelOptions(currentModel: string) {
|
||||
return options;
|
||||
}
|
||||
|
||||
export function isQuickEditUnsupportedAssetKind(layer: CanvasLayer) {
|
||||
const QUICK_EDIT_SUPPORTED_MEDIA_TYPES = new Set<CanvasLayer['mediaType']>([
|
||||
undefined,
|
||||
'image',
|
||||
'video',
|
||||
]);
|
||||
|
||||
const QUICK_EDIT_SUPPORTED_ASSET_KINDS = new Set<CanvasLayer['assetKind']>([
|
||||
undefined,
|
||||
null,
|
||||
'spec',
|
||||
'character',
|
||||
'icon-spritesheet',
|
||||
'icon-spec',
|
||||
'publication-material',
|
||||
'ui-design',
|
||||
'scene',
|
||||
'video',
|
||||
]);
|
||||
|
||||
export function isQuickEditSupportedLayer(layer: CanvasLayer) {
|
||||
return (
|
||||
layer.assetKind === 'icon' || layer.assetKind === 'character-animation'
|
||||
QUICK_EDIT_SUPPORTED_MEDIA_TYPES.has(layer.mediaType) &&
|
||||
QUICK_EDIT_SUPPORTED_ASSET_KINDS.has(layer.assetKind)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -971,11 +991,7 @@ export function buildCharacterAnimationGenerationInputs(
|
||||
: []),
|
||||
...(options.appearanceReferenceSrcs?.length
|
||||
? options.appearanceReferenceSrcs.flatMap((src) =>
|
||||
createGenerationInputField(
|
||||
'参考图',
|
||||
src,
|
||||
'appearanceReferenceSrc',
|
||||
),
|
||||
createGenerationInputField('参考图', src, 'appearanceReferenceSrc'),
|
||||
)
|
||||
: []),
|
||||
...createGenerationInputField('动作描述', promptText, 'prompt'),
|
||||
@@ -1017,11 +1033,9 @@ export function buildCharacterAnimationGenerationInputs(
|
||||
: []),
|
||||
...(options.appearanceReferences?.length
|
||||
? options.appearanceReferences.flatMap((appearanceReference) =>
|
||||
createGenerationInputReference(
|
||||
'参考图',
|
||||
appearanceReference,
|
||||
{ id: 'appearance' },
|
||||
),
|
||||
createGenerationInputReference('参考图', appearanceReference, {
|
||||
id: 'appearance',
|
||||
}),
|
||||
)
|
||||
: []),
|
||||
],
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
buildCharacterAnimationSubmissionPlan,
|
||||
buildIconSpritesheetGenerationSubmissionPlan,
|
||||
buildImageGenerationSubmissionPlan,
|
||||
resolveRegisteredEditorReferenceId,
|
||||
} from './ImageCanvasGenerationSubmissionModel';
|
||||
|
||||
function createLayer(overrides: Partial<CanvasLayer> = {}): CanvasLayer {
|
||||
@@ -31,6 +32,33 @@ function createLayer(overrides: Partial<CanvasLayer> = {}): CanvasLayer {
|
||||
}
|
||||
|
||||
describe('ImageCanvasGenerationSubmissionModel', () => {
|
||||
it('resolves registered image-edit sources by resource ID first', () => {
|
||||
expect(
|
||||
resolveRegisteredEditorReferenceId({
|
||||
resourceId: ' resource-source ',
|
||||
sourceAssetId: 'asset-source',
|
||||
}),
|
||||
).toBe('resource-source');
|
||||
});
|
||||
|
||||
it('falls back to a registered asset ID for image edits', () => {
|
||||
expect(
|
||||
resolveRegisteredEditorReferenceId({
|
||||
resourceId: 'local-resource-source',
|
||||
sourceAssetId: ' asset-source ',
|
||||
}),
|
||||
).toBe('asset-source');
|
||||
});
|
||||
|
||||
it('rejects unregistered image-edit sources', () => {
|
||||
expect(() =>
|
||||
resolveRegisteredEditorReferenceId({
|
||||
resourceId: 'generation-dialog:source',
|
||||
sourceAssetId: 'upload-source',
|
||||
}),
|
||||
).toThrow('原图尚未登记为项目资源或素材');
|
||||
});
|
||||
|
||||
it('builds normal image generation submission plans', () => {
|
||||
const plan = buildImageGenerationSubmissionPlan({
|
||||
dialog: {
|
||||
|
||||
@@ -135,10 +135,13 @@ export function resolveImageReferenceSubmissionSource(reference: {
|
||||
return reference.objectKey?.trim() || reference.src;
|
||||
}
|
||||
|
||||
export function resolveRegisteredEditorReferenceId(reference: {
|
||||
resourceId?: string | null;
|
||||
sourceAssetId?: string | null;
|
||||
}) {
|
||||
export function resolveRegisteredEditorReferenceId(
|
||||
reference: {
|
||||
resourceId?: string | null;
|
||||
sourceAssetId?: string | null;
|
||||
},
|
||||
referenceLabel: '原图' | '参考图' = '原图',
|
||||
) {
|
||||
const resourceId = reference.resourceId?.trim();
|
||||
if (
|
||||
resourceId &&
|
||||
@@ -148,10 +151,12 @@ export function resolveRegisteredEditorReferenceId(reference: {
|
||||
return resourceId;
|
||||
}
|
||||
const assetId = reference.sourceAssetId?.trim();
|
||||
if (assetId) {
|
||||
if (assetId && !assetId.startsWith('upload-')) {
|
||||
return assetId;
|
||||
}
|
||||
throw new Error('参考图尚未登记为项目资源或素材,请重新选择或上传后再试');
|
||||
throw new Error(
|
||||
`${referenceLabel}尚未登记为项目资源或素材,请重新选择或上传后再试`,
|
||||
);
|
||||
}
|
||||
|
||||
function buildSeedanceVideoReferenceInput(
|
||||
@@ -737,6 +742,7 @@ export function buildImageGenerationSubmissionPlan({
|
||||
? {
|
||||
referenceId: resolveRegisteredEditorReferenceId(
|
||||
dialog.specReference,
|
||||
'参考图',
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
@@ -1116,7 +1122,10 @@ export function buildIconSpritesheetGenerationSubmissionPlan(
|
||||
}
|
||||
let referenceId: string;
|
||||
try {
|
||||
referenceId = resolveRegisteredEditorReferenceId(dialog.iconSpecReference);
|
||||
referenceId = resolveRegisteredEditorReferenceId(
|
||||
dialog.iconSpecReference,
|
||||
'参考图',
|
||||
);
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
|
||||
@@ -199,6 +199,19 @@ describe('ImageCanvasHistoryModel', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('restores layer size only for layers targeted by a resize action', () => {
|
||||
const targetLayer = createLayer({ width: 320, height: 240 });
|
||||
const currentLayer = createLayer({ width: 640, height: 480 });
|
||||
|
||||
const merged = mergeCanvasHistorySnapshotForRestore({
|
||||
current: createSnapshot([currentLayer]),
|
||||
target: createSnapshot([targetLayer]),
|
||||
layerSizeLayerIds: new Set([targetLayer.id]),
|
||||
});
|
||||
|
||||
expect(merged.layers[0]).toMatchObject({ width: 320, height: 240 });
|
||||
});
|
||||
|
||||
it('restores the complete target layer after that layer was deleted', () => {
|
||||
const targetLayer = createLayer({
|
||||
resourceId: 'deleted-resource',
|
||||
|
||||
@@ -1,284 +1 @@
|
||||
import type {
|
||||
CanvasGenerationDialogState,
|
||||
CanvasHistoryAction,
|
||||
CanvasHistorySnapshot,
|
||||
CanvasLayer,
|
||||
GenerateDialogState,
|
||||
} from './ImageCanvasEditorTypes';
|
||||
|
||||
const CANVAS_HISTORY_ACTION_LABELS: Record<
|
||||
CanvasHistoryAction['type'],
|
||||
string
|
||||
> = {
|
||||
'move-image': '移动图片',
|
||||
'move-generation-result': '移动生成结果',
|
||||
'delete-image': '删除图片',
|
||||
'delete-generation-result': '删除生成结果',
|
||||
'cut-image': '剪切图片',
|
||||
'paste-image': '粘贴图片',
|
||||
'duplicate-image': '复制图片',
|
||||
'add-image': '添加图片',
|
||||
'upload-image': '上传图片',
|
||||
'generate-image': '生成图片',
|
||||
'expand-image': '扩展图片',
|
||||
'remove-background': '移除背景',
|
||||
'perfect-pixel': '完美像素',
|
||||
'split-atlas': '拆分图集',
|
||||
'replace-image': '替换图片',
|
||||
'show-image': '显示图片',
|
||||
'hide-image': '隐藏图片',
|
||||
'change-layer-order': '调整图片层级',
|
||||
'group-images': '组合图片',
|
||||
'ungroup-images': '取消组合',
|
||||
'lock-image': '锁定图片',
|
||||
'unlock-image': '解锁图片',
|
||||
'flip-image': '翻转图片',
|
||||
'change-asset-kind': '修改素材类型',
|
||||
'change-viewport': '调整画布视图',
|
||||
};
|
||||
|
||||
const PROTECTED_CANVAS_HISTORY_ACTION_TYPES = new Set<
|
||||
CanvasHistoryAction['type']
|
||||
>([
|
||||
'paste-image',
|
||||
'duplicate-image',
|
||||
'add-image',
|
||||
'upload-image',
|
||||
'generate-image',
|
||||
'expand-image',
|
||||
'remove-background',
|
||||
'perfect-pixel',
|
||||
'split-atlas',
|
||||
'replace-image',
|
||||
]);
|
||||
|
||||
export function formatCanvasHistoryAction(action: CanvasHistoryAction): string {
|
||||
return CANVAS_HISTORY_ACTION_LABELS[action.type];
|
||||
}
|
||||
|
||||
export function isProtectedCanvasHistoryAction(
|
||||
action: CanvasHistoryAction,
|
||||
): boolean {
|
||||
return PROTECTED_CANVAS_HISTORY_ACTION_TYPES.has(action.type);
|
||||
}
|
||||
|
||||
function hasSameStableMediaReference(
|
||||
current: {
|
||||
src: string;
|
||||
objectKey?: string | null;
|
||||
assetObjectId?: string | null;
|
||||
},
|
||||
target: {
|
||||
src: string;
|
||||
objectKey?: string | null;
|
||||
assetObjectId?: string | null;
|
||||
},
|
||||
): boolean {
|
||||
if (current.objectKey && target.objectKey) {
|
||||
return current.objectKey === target.objectKey;
|
||||
}
|
||||
if (current.assetObjectId && target.assetObjectId) {
|
||||
return current.assetObjectId === target.assetObjectId;
|
||||
}
|
||||
return current.src === target.src;
|
||||
}
|
||||
|
||||
function hasSameImageSequenceFrames(
|
||||
current: CanvasLayer,
|
||||
target: CanvasLayer,
|
||||
): boolean {
|
||||
const currentFrames = current.imageSequenceFrames ?? [];
|
||||
const targetFrames = target.imageSequenceFrames ?? [];
|
||||
if (currentFrames.length !== targetFrames.length) {
|
||||
return false;
|
||||
}
|
||||
return currentFrames.every((currentFrame, index) => {
|
||||
const targetFrame = targetFrames[index];
|
||||
return (
|
||||
targetFrame !== undefined &&
|
||||
hasSameStableMediaReference(
|
||||
{
|
||||
src: currentFrame.imageSrc,
|
||||
objectKey: currentFrame.objectKey,
|
||||
assetObjectId: currentFrame.assetObjectId,
|
||||
},
|
||||
{
|
||||
src: targetFrame.imageSrc,
|
||||
objectKey: targetFrame.objectKey,
|
||||
assetObjectId: targetFrame.assetObjectId,
|
||||
},
|
||||
)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function hasSameLayerContent(
|
||||
current: CanvasLayer,
|
||||
target: CanvasLayer,
|
||||
): boolean {
|
||||
return (
|
||||
(current.mediaType ?? 'image') === (target.mediaType ?? 'image') &&
|
||||
hasSameStableMediaReference(current, target) &&
|
||||
hasSameImageSequenceFrames(current, target) &&
|
||||
current.imageSequenceDurationMs === target.imageSequenceDurationMs
|
||||
);
|
||||
}
|
||||
|
||||
function cloneDialogPlaceholder(
|
||||
dialog: GenerateDialogState,
|
||||
): GenerateDialogState['placeholder'] {
|
||||
return dialog.placeholder ? { ...dialog.placeholder } : undefined;
|
||||
}
|
||||
|
||||
function mergeLayerForHistoryRestore(
|
||||
target: CanvasLayer,
|
||||
current: CanvasLayer | undefined,
|
||||
restoreAssetKind: boolean,
|
||||
): CanvasLayer {
|
||||
if (!current) {
|
||||
return { ...target };
|
||||
}
|
||||
const resourceAssetKind =
|
||||
current.resourceAssetKind ?? target.resourceAssetKind;
|
||||
const restoresLayerOverride =
|
||||
target.assetKindOverride !== undefined ||
|
||||
current.assetKindOverride !== undefined;
|
||||
const assetKindOverride = restoreAssetKind
|
||||
? (target.assetKindOverride ?? null)
|
||||
: current.assetKindOverride;
|
||||
return {
|
||||
...current,
|
||||
x: target.x,
|
||||
y: target.y,
|
||||
zIndex: target.zIndex,
|
||||
groupId: target.groupId,
|
||||
...(resourceAssetKind !== undefined ? { resourceAssetKind } : {}),
|
||||
...(restoresLayerOverride ? { assetKindOverride } : {}),
|
||||
assetKind: restoreAssetKind
|
||||
? restoresLayerOverride
|
||||
? (assetKindOverride ?? resourceAssetKind ?? null)
|
||||
: target.assetKind
|
||||
: current.assetKind,
|
||||
hidden: target.hidden,
|
||||
locked: target.locked,
|
||||
flipX: target.flipX,
|
||||
flipY: target.flipY,
|
||||
};
|
||||
}
|
||||
|
||||
function mergeDialogPlaceholderForHistoryRestore(
|
||||
target: GenerateDialogState,
|
||||
current: GenerateDialogState,
|
||||
): GenerateDialogState['placeholder'] {
|
||||
if (!current.placeholder) {
|
||||
return cloneDialogPlaceholder(target);
|
||||
}
|
||||
if (!target.placeholder) {
|
||||
return cloneDialogPlaceholder(current);
|
||||
}
|
||||
return {
|
||||
...current.placeholder,
|
||||
x: target.placeholder.x,
|
||||
y: target.placeholder.y,
|
||||
};
|
||||
}
|
||||
|
||||
function mergeDialogForHistoryRestore<
|
||||
TTarget extends GenerateDialogState,
|
||||
TCurrent extends GenerateDialogState,
|
||||
>(target: TTarget, current: TCurrent | undefined): TTarget | TCurrent {
|
||||
if (!current) {
|
||||
return {
|
||||
...target,
|
||||
placeholder: cloneDialogPlaceholder(target),
|
||||
};
|
||||
}
|
||||
return {
|
||||
...current,
|
||||
composerOpen: target.composerOpen,
|
||||
placeholder: mergeDialogPlaceholderForHistoryRestore(target, current),
|
||||
};
|
||||
}
|
||||
|
||||
export function mergeCanvasHistorySnapshotForRestore({
|
||||
current,
|
||||
target,
|
||||
assetKindLayerIds,
|
||||
}: {
|
||||
current: CanvasHistorySnapshot;
|
||||
target: CanvasHistorySnapshot;
|
||||
assetKindLayerIds?: ReadonlySet<string>;
|
||||
}): CanvasHistorySnapshot {
|
||||
const currentLayerById = new Map(
|
||||
current.layers.map((layer) => [layer.id, layer] as const),
|
||||
);
|
||||
const currentDialogById = new Map<string, GenerateDialogState>();
|
||||
for (const dialog of current.inactiveGenerateDialogs) {
|
||||
currentDialogById.set(dialog.id, dialog);
|
||||
}
|
||||
if (current.generateDialog?.id) {
|
||||
currentDialogById.set(current.generateDialog.id, current.generateDialog);
|
||||
}
|
||||
|
||||
const mergeTargetDialog = <T extends GenerateDialogState>(dialog: T): T =>
|
||||
mergeDialogForHistoryRestore(
|
||||
dialog,
|
||||
dialog.id ? currentDialogById.get(dialog.id) : undefined,
|
||||
) as T;
|
||||
|
||||
return {
|
||||
...target,
|
||||
layers: target.layers.map((layer) =>
|
||||
mergeLayerForHistoryRestore(
|
||||
layer,
|
||||
currentLayerById.get(layer.id),
|
||||
assetKindLayerIds?.has(layer.id) ?? true,
|
||||
),
|
||||
),
|
||||
generateDialog: target.generateDialog
|
||||
? mergeTargetDialog(target.generateDialog)
|
||||
: null,
|
||||
inactiveGenerateDialogs: target.inactiveGenerateDialogs.map(
|
||||
(dialog): CanvasGenerationDialogState => mergeTargetDialog(dialog),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function canRestoreCanvasHistorySnapshotWithoutContentLoss({
|
||||
current,
|
||||
target,
|
||||
}: {
|
||||
current: CanvasHistorySnapshot;
|
||||
target: CanvasHistorySnapshot;
|
||||
}): boolean {
|
||||
const targetLayerById = new Map(
|
||||
target.layers.map((layer) => [layer.id, layer] as const),
|
||||
);
|
||||
|
||||
const preservesLayers = current.layers.every((currentLayer) => {
|
||||
const targetLayer = targetLayerById.get(currentLayer.id);
|
||||
if (!targetLayer) {
|
||||
return false;
|
||||
}
|
||||
if (!currentLayer.hidden && targetLayer.hidden) {
|
||||
return false;
|
||||
}
|
||||
return hasSameLayerContent(currentLayer, targetLayer);
|
||||
});
|
||||
if (!preservesLayers) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const getDialogIds = (snapshot: CanvasHistorySnapshot): Set<string> =>
|
||||
new Set(
|
||||
[
|
||||
snapshot.generateDialog?.id,
|
||||
...snapshot.inactiveGenerateDialogs.map((dialog) => dialog.id),
|
||||
].filter((dialogId): dialogId is string => Boolean(dialogId)),
|
||||
);
|
||||
const targetDialogIds = getDialogIds(target);
|
||||
|
||||
return [...getDialogIds(current)].every((dialogId) =>
|
||||
targetDialogIds.has(dialogId),
|
||||
);
|
||||
}
|
||||
export * from '../../../packages/image-canvas-core/src/history';
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user