合并master并保留Spine序列帧修复

合并master当前工程、后端、前端和文档更新

按已确认方案解决Spine序列帧多模态、帧数和快速编辑冲突

保留当前分支底部工具栏宽度与隐藏滚动条样式
This commit is contained in:
2026-08-14 21:33:26 +08:00
338 changed files with 69729 additions and 8828 deletions
+1
View File
@@ -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