合并主线并保留素材多选导出修复

合并主线角色动作序列帧、完美像素、外部编辑器与 AI 游戏创作 Runtime 最新改动
保留 objectKey-only 私有素材多选、批量导出与统一时间戳语义
统一素材删除门禁、参考图上传锁和侧栏选择状态清理
同步图片画布技术方案与角色动作下载集成测试
This commit is contained in:
2026-08-06 17:58:15 +08:00
348 changed files with 89427 additions and 6928 deletions
@@ -0,0 +1,288 @@
/* @vitest-environment jsdom */
import { act, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { AutoGrowTextArea } from './AutoGrowTextArea';
afterEach(() => {
vi.unstubAllGlobals();
});
describe('AutoGrowTextArea', () => {
it('uses native field sizing when the browser supports it', () => {
const supports = vi.fn().mockReturnValue(true);
const resizeObserver = vi.fn();
const onChange = vi.fn();
vi.stubGlobal('CSS', { supports });
vi.stubGlobal('ResizeObserver', resizeObserver);
render(
<AutoGrowTextArea
aria-label="自动增长输入框"
value=""
onChange={onChange}
/>,
);
const input = screen.getByLabelText(
'自动增长输入框',
) as HTMLTextAreaElement;
expect(supports).toHaveBeenCalledWith('field-sizing', 'content');
expect(resizeObserver).not.toHaveBeenCalled();
expect(input.getAttribute('rows')).toBe('1');
expect(input.className).toContain('auto-grow-text-area');
expect(input.className).toContain('[field-sizing:content]');
expect(input.className).toContain('min-h-10');
expect(input.className).toContain('max-h-32');
expect(input.className).toContain('overflow-y-auto');
expect(input.className).toContain('disabled:cursor-not-allowed');
expect(input.className).toContain('disabled:opacity-60');
expect(input.style.height).toBe('');
fireEvent.change(input, { target: { value: '新内容' } });
expect(onChange).toHaveBeenCalledTimes(1);
});
it('falls back to measured sizing, preserves css limits and remeasures width changes', () => {
let resizeObserverCallback!: ResizeObserverCallback;
const observe = vi.fn();
const disconnect = vi.fn();
vi.stubGlobal('CSS', { supports: vi.fn().mockReturnValue(false) });
vi.stubGlobal(
'ResizeObserver',
vi.fn().mockImplementation((callback: ResizeObserverCallback) => {
resizeObserverCallback = callback;
return {
observe,
unobserve: vi.fn(),
disconnect,
};
}),
);
const { rerender, unmount } = render(
<AutoGrowTextArea
aria-label="自动增长输入框"
value=""
style={{
boxSizing: 'border-box',
minHeight: '80px',
maxHeight: '128px',
}}
onChange={vi.fn()}
/>,
);
const input = screen.getByLabelText(
'自动增长输入框',
) as HTMLTextAreaElement;
expect(observe).toHaveBeenCalledWith(input);
let contentHeight = 92;
Object.defineProperty(input, 'offsetHeight', {
configurable: true,
get: () => {
const styleHeight = Number.parseFloat(input.style.height);
return Number.isNaN(styleHeight) ? 82 : styleHeight;
},
});
Object.defineProperty(input, 'clientHeight', {
configurable: true,
get: () => input.offsetHeight - 2,
});
Object.defineProperty(input, 'scrollHeight', {
configurable: true,
get: () => contentHeight,
});
rerender(
<AutoGrowTextArea
aria-label="自动增长输入框"
value="第一行\n第二行\n第三行\n第四行\n第五行"
style={{
boxSizing: 'border-box',
minHeight: '80px',
maxHeight: '128px',
}}
onChange={vi.fn()}
/>,
);
expect(input.style.height).toBe('94px');
expect(input.style.overflowY).toBe('hidden');
contentHeight = 180;
act(() => {
resizeObserverCallback(
[
{
target: input,
contentRect: { width: 180 },
} as unknown as ResizeObserverEntry,
],
{} as ResizeObserver,
);
});
expect(input.style.height).toBe('128px');
expect(input.style.overflowY).toBe('auto');
contentHeight = 30;
rerender(
<AutoGrowTextArea
aria-label="自动增长输入框"
value="短内容"
style={{
boxSizing: 'border-box',
minHeight: '80px',
maxHeight: '128px',
}}
onChange={vi.fn()}
/>,
);
expect(input.style.height).toBe('80px');
expect(input.style.overflowY).toBe('hidden');
unmount();
expect(disconnect).toHaveBeenCalledTimes(1);
});
it('remeasures uncontrolled input changes and forwards the consumer input handler', () => {
const onInput = vi.fn();
vi.stubGlobal('CSS', { supports: vi.fn().mockReturnValue(false) });
vi.stubGlobal(
'ResizeObserver',
vi.fn().mockImplementation(() => ({
observe: vi.fn(),
unobserve: vi.fn(),
disconnect: vi.fn(),
})),
);
render(
<AutoGrowTextArea
aria-label="非受控自动增长输入框"
defaultValue="初始内容"
style={{
boxSizing: 'border-box',
minHeight: '40px',
maxHeight: '128px',
}}
onInput={onInput}
/>,
);
const input = screen.getByLabelText(
'非受控自动增长输入框',
) as HTMLTextAreaElement;
let contentHeight = 90;
Object.defineProperty(input, 'offsetHeight', {
configurable: true,
get: () => {
const styleHeight = Number.parseFloat(input.style.height);
return Number.isNaN(styleHeight) ? 42 : styleHeight;
},
});
Object.defineProperty(input, 'clientHeight', {
configurable: true,
get: () => input.offsetHeight - 2,
});
Object.defineProperty(input, 'scrollHeight', {
configurable: true,
get: () => contentHeight,
});
fireEvent.input(input, {
target: { value: '第一行\n第二行\n第三行\n第四行' },
});
expect(input.style.height).toBe('92px');
expect(input.style.overflowY).toBe('hidden');
contentHeight = 180;
fireEvent.input(input, {
target: { value: '很多行内容'.repeat(30) },
});
expect(input.style.height).toBe('128px');
expect(input.style.overflowY).toBe('auto');
contentHeight = 20;
fireEvent.input(input, { target: { value: '短内容' } });
expect(input.style.height).toBe('40px');
expect(input.style.overflowY).toBe('hidden');
expect(onInput).toHaveBeenCalledTimes(3);
});
it('measures content-box height without double-counting padding or borders', () => {
vi.stubGlobal('CSS', { supports: vi.fn().mockReturnValue(false) });
vi.stubGlobal(
'ResizeObserver',
vi.fn().mockImplementation(() => ({
observe: vi.fn(),
unobserve: vi.fn(),
disconnect: vi.fn(),
})),
);
const { rerender } = render(
<AutoGrowTextArea
aria-label="内容盒输入框"
value=""
style={{
boxSizing: 'content-box',
minHeight: '40px',
maxHeight: '128px',
paddingTop: '10px',
paddingBottom: '6px',
}}
onChange={vi.fn()}
/>,
);
const input = screen.getByLabelText(
'内容盒输入框',
) as HTMLTextAreaElement;
let contentHeight = 120;
Object.defineProperty(input, 'offsetHeight', {
configurable: true,
get: () => 122,
});
Object.defineProperty(input, 'clientHeight', {
configurable: true,
get: () => 120,
});
Object.defineProperty(input, 'scrollHeight', {
configurable: true,
get: () => contentHeight,
});
rerender(
<AutoGrowTextArea
aria-label="内容盒输入框"
value="多行内容"
style={{
boxSizing: 'content-box',
minHeight: '40px',
maxHeight: '128px',
paddingTop: '10px',
paddingBottom: '6px',
}}
onChange={vi.fn()}
/>,
);
expect(input.style.height).toBe('104px');
expect(input.style.overflowY).toBe('hidden');
contentHeight = 160;
rerender(
<AutoGrowTextArea
aria-label="内容盒输入框"
value="更多多行内容"
style={{
boxSizing: 'content-box',
minHeight: '40px',
maxHeight: '128px',
paddingTop: '10px',
paddingBottom: '6px',
}}
onChange={vi.fn()}
/>,
);
expect(input.style.height).toBe('128px');
expect(input.style.overflowY).toBe('auto');
});
});
+130
View File
@@ -0,0 +1,130 @@
import {
type InputEventHandler,
type TextareaHTMLAttributes,
useLayoutEffect,
useRef,
} from 'react';
const DEFAULT_MIN_HEIGHT_PX = 40;
const DEFAULT_MAX_HEIGHT_PX = 128;
function supportsNativeFieldSizing() {
return (
typeof CSS !== 'undefined' &&
typeof CSS.supports === 'function' &&
CSS.supports('field-sizing', 'content')
);
}
function parseComputedPixelValue(value: string, fallback: number) {
const parsedValue = Number.parseFloat(value);
return Number.isFinite(parsedValue) ? parsedValue : fallback;
}
function resizeFallbackTextArea(textarea: HTMLTextAreaElement) {
textarea.style.height = 'auto';
const computedStyle = window.getComputedStyle(textarea);
const minHeight = parseComputedPixelValue(
computedStyle.minHeight,
DEFAULT_MIN_HEIGHT_PX,
);
const maxHeight = Math.max(
minHeight,
parseComputedPixelValue(computedStyle.maxHeight, DEFAULT_MAX_HEIGHT_PX),
);
const contentHeight = textarea.scrollHeight;
const borderHeight = Math.max(
0,
textarea.offsetHeight - textarea.clientHeight,
);
const isBorderBox = computedStyle.boxSizing === 'border-box';
const paddingHeight =
parseComputedPixelValue(computedStyle.paddingTop, 0) +
parseComputedPixelValue(computedStyle.paddingBottom, 0);
const measuredHeight = isBorderBox
? contentHeight + borderHeight
: Math.max(0, contentHeight - paddingHeight);
const nextHeight = Math.min(
Math.max(measuredHeight, minHeight),
maxHeight,
);
textarea.style.height = `${nextHeight}px`;
textarea.style.overflowY = measuredHeight > maxHeight ? 'auto' : 'hidden';
}
export function AutoGrowTextArea({
className,
onInput,
rows = 1,
value,
...textareaProps
}: TextareaHTMLAttributes<HTMLTextAreaElement>) {
const textareaRef = useRef<HTMLTextAreaElement>(null);
useLayoutEffect(() => {
const textarea = textareaRef.current;
if (!textarea || supportsNativeFieldSizing()) {
return;
}
resizeFallbackTextArea(textarea);
}, [value]);
useLayoutEffect(() => {
const textarea = textareaRef.current;
if (!textarea || supportsNativeFieldSizing()) {
return undefined;
}
let previousWidth = textarea.getBoundingClientRect().width;
const resizeWhenWidthChanges = (nextWidth: number) => {
if (Math.abs(nextWidth - previousWidth) < 0.5) {
return;
}
previousWidth = nextWidth;
resizeFallbackTextArea(textarea);
};
if (typeof ResizeObserver !== 'undefined') {
const observer = new ResizeObserver((entries) => {
const entry = entries[0];
if (entry) {
resizeWhenWidthChanges(entry.contentRect.width);
}
});
observer.observe(textarea);
return () => observer.disconnect();
}
const handleWindowResize = () => {
resizeWhenWidthChanges(textarea.getBoundingClientRect().width);
};
window.addEventListener('resize', handleWindowResize);
return () => window.removeEventListener('resize', handleWindowResize);
}, []);
const resolvedClassName = [
'auto-grow-text-area max-h-32 min-h-10 resize-none overflow-x-hidden overflow-y-auto overscroll-contain [field-sizing:content] disabled:cursor-not-allowed disabled:opacity-60',
className,
]
.filter(Boolean)
.join(' ');
const handleInput: InputEventHandler<HTMLTextAreaElement> = (event) => {
if (!supportsNativeFieldSizing()) {
resizeFallbackTextArea(event.currentTarget);
}
onInput?.(event);
};
return (
<textarea
{...textareaProps}
ref={textareaRef}
className={resolvedClassName}
onInput={handleInput}
rows={rows}
value={value}
/>
);
}
@@ -1,6 +1,13 @@
/* @vitest-environment jsdom */
import { act, render, screen, waitFor, within } from '@testing-library/react';
import {
act,
fireEvent,
render,
screen,
waitFor,
within,
} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import type { ContextType } from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
@@ -147,6 +154,7 @@ describe('CreationLandingView', () => {
listPublicEditorProjectResourcesMock.mockReset();
createEditorProjectMock.mockReset();
toggleEditorShowcaseAssetLikeMock.mockReset();
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
@@ -470,6 +478,177 @@ describe('CreationLandingView', () => {
expect(within(card as HTMLElement).getByText('20泥点')).toBeTruthy();
});
it('plays a character action on card focus and in the preview dialog', async () => {
const user = userEvent.setup();
const setIntervalSpy = vi.spyOn(window, 'setInterval');
listEditorProjectsMock.mockResolvedValueOnce(projectItems);
listPublicEditorProjectResourcesMock.mockResolvedValueOnce([
{
resourceId: 'resource-action',
projectId: 'editor-showcase',
showcaseId: 'showcase-action',
label: '待机动作',
imageSrc: '/public/action/frame-01.png',
objectKey: null,
width: 192,
height: 256,
sourceType: 'generated',
assetKind: 'character-animation',
generationCostMudPoints: 103,
imageSequenceFrames: [
{
imageSrc: '/public/action/frame-01.png',
width: 192,
height: 256,
},
{
imageSrc: '/public/action/frame-02.png',
width: 192,
height: 256,
},
{
imageSrc: '/public/action/frame-03.png',
width: 192,
height: 256,
},
{
imageSrc: '/public/action/frame-04.png',
width: 192,
height: 256,
},
{
imageSrc: '/public/action/frame-05.png',
width: 192,
height: 256,
},
],
imageSequenceDurationMs: 250,
},
]);
renderCreationLanding();
const title = await screen.findByText('待机动作');
const card = title.closest('.creation-landing__asset-card') as HTMLElement;
const openButton = card.querySelector(
'.creation-landing__asset-card-open',
) as HTMLButtonElement;
expect(
card.querySelectorAll('.creation-landing__image-sequence-frame'),
).toHaveLength(0);
fireEvent.focus(openButton);
await waitFor(() => {
expect(
card.querySelectorAll('.creation-landing__image-sequence-frame'),
).toHaveLength(3);
});
expect(setIntervalSpy.mock.calls.some(([, delay]) => delay === 50)).toBe(
true,
);
fireEvent.pointerEnter(openButton);
fireEvent.blur(openButton);
expect(
card.querySelectorAll('.creation-landing__image-sequence-frame'),
).toHaveLength(3);
fireEvent.pointerLeave(openButton);
await waitFor(() => {
expect(
card.querySelectorAll('.creation-landing__image-sequence-frame'),
).toHaveLength(0);
});
await user.click(openButton);
expect(
await screen.findByRole('button', { name: '暂停角色动作' }),
).toBeTruthy();
expect(
document.querySelectorAll(
'.creation-landing__showcase-current .creation-landing__image-sequence-frame',
),
).toHaveLength(3);
const failedModalFrame = document.querySelector(
'.creation-landing__showcase-current .creation-landing__image-sequence-frame',
) as HTMLImageElement;
await user.click(screen.getByRole('button', { name: '暂停角色动作' }));
fireEvent.error(failedModalFrame);
expect(await screen.findByText('1 帧加载失败')).toBeTruthy();
expect(await screen.findByText('2/5')).toBeTruthy();
await user.click(screen.getByRole('button', { name: '重试失败帧' }));
await waitFor(() => {
expect(screen.queryByText('1 帧加载失败')).toBeNull();
});
await user.click(screen.getByRole('button', { name: '暂停角色动作' }));
expect(screen.getByRole('button', { name: '播放角色动作' })).toBeTruthy();
setIntervalSpy.mockRestore();
});
it('keeps character action previews paused when reduced motion is requested', async () => {
const user = userEvent.setup();
vi.stubGlobal(
'matchMedia',
vi.fn().mockReturnValue({
matches: true,
media: '(prefers-reduced-motion: reduce)',
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
dispatchEvent: vi.fn(),
}),
);
listEditorProjectsMock.mockResolvedValueOnce(projectItems);
listPublicEditorProjectResourcesMock.mockResolvedValueOnce([
{
resourceId: 'resource-action-reduced-motion',
projectId: 'editor-showcase',
showcaseId: 'showcase-action-reduced-motion',
label: '缓慢待机动作',
imageSrc: '/public/action/frame-01.png',
objectKey: null,
width: 192,
height: 256,
sourceType: 'generated',
assetKind: 'character-animation',
generationCostMudPoints: 103,
imageSequenceFrames: [
{
imageSrc: '/public/action/frame-01.png',
width: 192,
height: 256,
},
{
imageSrc: '/public/action/frame-02.png',
width: 192,
height: 256,
},
],
imageSequenceDurationMs: 250,
},
]);
renderCreationLanding();
const title = await screen.findByText('缓慢待机动作');
const card = title.closest('.creation-landing__asset-card') as HTMLElement;
const openButton = card.querySelector(
'.creation-landing__asset-card-open',
) as HTMLButtonElement;
fireEvent.focus(openButton);
expect(
card.querySelectorAll('.creation-landing__image-sequence-frame'),
).toHaveLength(0);
await user.click(openButton);
expect(
await screen.findByRole('button', { name: '播放角色动作' }),
).toBeTruthy();
});
it('likes a featured asset from the list card action', async () => {
const user = userEvent.setup();
listEditorProjectsMock.mockResolvedValueOnce(projectItems);
@@ -615,6 +794,19 @@ describe('CreationLandingView', () => {
taskId: 'task-2',
assetKind: 'character-animation',
priceMudPoints: 40,
imageSequenceFrames: [
{
imageSrc: 'data:image/png;base64,animation-1',
width: 512,
height: 512,
},
{
imageSrc: 'data:image/png;base64,animation-2',
width: 512,
height: 512,
},
],
imageSequenceDurationMs: 4_000,
generationInputs: {
fields: [{ title: '动作描述', value: '待机动作' }],
references: [
@@ -1,4 +1,4 @@
import { Film, Image as ImageIcon, Music2 } from 'lucide-react';
import { Film, Image as ImageIcon, Music2, Pause, Play } from 'lucide-react';
import {
useCallback,
useEffect,
@@ -74,6 +74,30 @@ const CREATION_HOME_ASSETS = {
} as const;
const SHOWCASE_LIKE_ICON_SRC = '/creation-home/showcase-like-thumb.png';
const REDUCED_MOTION_MEDIA_QUERY = '(prefers-reduced-motion: reduce)';
function usePrefersReducedMotion() {
const [prefersReducedMotion, setPrefersReducedMotion] = useState(() =>
typeof window.matchMedia === 'function'
? window.matchMedia(REDUCED_MOTION_MEDIA_QUERY).matches
: false,
);
useEffect(() => {
if (typeof window.matchMedia !== 'function') {
return undefined;
}
const mediaQuery = window.matchMedia(REDUCED_MOTION_MEDIA_QUERY);
const handleChange = (event: MediaQueryListEvent) => {
setPrefersReducedMotion(event.matches);
};
setPrefersReducedMotion(mediaQuery.matches);
mediaQuery.addEventListener('change', handleChange);
return () => mediaQuery.removeEventListener('change', handleChange);
}, []);
return prefersReducedMotion;
}
const CREATION_FEATURES: CreationFeatureItem[] = [
{
@@ -239,7 +263,7 @@ function getPreviewIcon(preview: ShowcaseAssetPreview) {
if (preview.mediaType === 'audio') {
return <Music2 aria-hidden="true" />;
}
if (preview.mediaType === 'video') {
if (preview.mediaType === 'video' || preview.mediaType === 'image-sequence') {
return <Film aria-hidden="true" />;
}
return <ImageIcon aria-hidden="true" />;
@@ -276,9 +300,11 @@ function ShowcaseAudioPreview({
function ShowcasePreview({
preview,
variant = 'card',
sequencePlaying = false,
}: {
preview: ShowcaseAssetPreview;
variant?: 'card' | 'modal' | 'thumb';
sequencePlaying?: boolean;
}) {
if (preview.mediaType === 'audio') {
return (
@@ -294,9 +320,308 @@ function ShowcasePreview({
/>
);
}
if (preview.mediaType === 'image-sequence') {
return (
<ShowcaseImageSequencePreview
preview={preview}
variant={variant}
playing={sequencePlaying}
/>
);
}
return <ShowcaseImagePreview preview={preview} variant={variant} />;
}
function ShowcaseImageSequenceFrame({
preview,
frame,
frameKey,
visible,
loaded,
retryGeneration,
onReady,
onFailed,
}: {
preview: ShowcaseAssetPreview;
frame: NonNullable<ShowcaseAssetPreview['imageSequenceFrames']>[number];
frameKey: string;
visible: boolean;
loaded: boolean;
retryGeneration: number;
onReady: (frameKey: string) => void;
onFailed: (frameKey: string) => void;
}) {
const failureReportedRef = useRef(false);
const { resolvedUrl, isResolving, shouldResolve } = useResolvedAssetReadUrl(
frame.imageSrc,
{
objectKey: frame.objectKey,
refreshKey: `${preview.id}:${frameKey}:${retryGeneration}`,
},
);
const reportFailure = useCallback(() => {
if (failureReportedRef.current) {
return;
}
failureReportedRef.current = true;
onFailed(frameKey);
}, [frameKey, onFailed]);
useEffect(() => {
if (shouldResolve && !isResolving && !resolvedUrl) {
reportFailure();
}
}, [isResolving, reportFailure, resolvedUrl, shouldResolve]);
if (!resolvedUrl) {
return null;
}
return (
<img
src={resolvedUrl}
alt={visible ? `角色动作预览:${preview.label}` : ''}
aria-hidden={visible ? undefined : true}
className="creation-landing__image-sequence-frame"
decoding="async"
style={{
opacity: visible && loaded ? 1 : 0,
transition: 'none',
}}
onLoad={() => onReady(frameKey)}
onError={reportFailure}
/>
);
}
function ShowcaseImageSequencePreview({
preview,
variant,
playing,
}: {
preview: ShowcaseAssetPreview;
variant: 'card' | 'modal' | 'thumb';
playing: boolean;
}) {
const frames = useMemo(
() => preview.imageSequenceFrames ?? [],
[preview.imageSequenceFrames],
);
const durationMs = preview.imageSequenceDurationMs ?? 0;
const shouldMountSequence = variant === 'modal' || playing;
const frameItems = useMemo(
() =>
frames.map((frame, index) => ({
frame,
index,
key: [preview.id, frame.objectKey ?? '', frame.imageSrc, index].join(
':',
),
})),
[frames, preview.id],
);
const frameSequenceKey = frameItems.map((item) => item.key).join('|');
const firstFrameKey = frameItems[0]?.key ?? '';
const [frameIndex, setFrameIndex] = useState(0);
const [isPlaying, setIsPlaying] = useState(playing);
const [loadedFrameKeys, setLoadedFrameKeys] = useState<ReadonlySet<string>>(
() => new Set(),
);
const [failedFrameKeys, setFailedFrameKeys] = useState<ReadonlySet<string>>(
() => new Set(),
);
const [retryGeneration, setRetryGeneration] = useState(0);
const [visibleFrameKey, setVisibleFrameKey] = useState(firstFrameKey);
const currentFrameItem =
frameItems[Math.min(frameIndex, frameItems.length - 1)];
const currentFrameKey = currentFrameItem?.key ?? '';
const mountedFrameKeys = new Set<string>();
if (visibleFrameKey && !failedFrameKeys.has(visibleFrameKey)) {
mountedFrameKeys.add(visibleFrameKey);
}
for (
let offset = 0;
offset < frameItems.length && mountedFrameKeys.size < 3;
offset += 1
) {
const item = frameItems[(frameIndex + offset) % frameItems.length];
if (item && !failedFrameKeys.has(item.key)) {
mountedFrameKeys.add(item.key);
}
}
const availableFrameCount = frameItems.length - failedFrameKeys.size;
const handleFrameReady = useCallback((frameKey: string) => {
setLoadedFrameKeys((currentKeys) => {
if (currentKeys.has(frameKey)) {
return currentKeys;
}
const nextKeys = new Set(currentKeys);
nextKeys.add(frameKey);
return nextKeys;
});
}, []);
const handleFrameFailed = useCallback((frameKey: string) => {
setFailedFrameKeys((currentKeys) => {
if (currentKeys.has(frameKey)) {
return currentKeys;
}
const nextKeys = new Set(currentKeys);
nextKeys.add(frameKey);
return nextKeys;
});
}, []);
const retryFailedFrames = useCallback(() => {
setFrameIndex(0);
setIsPlaying(playing);
setLoadedFrameKeys(new Set());
setFailedFrameKeys(new Set());
setVisibleFrameKey(firstFrameKey);
setRetryGeneration((current) => current + 1);
}, [firstFrameKey, playing]);
useEffect(() => {
setFrameIndex(0);
setLoadedFrameKeys(new Set());
setFailedFrameKeys(new Set());
setVisibleFrameKey(firstFrameKey);
setIsPlaying(playing);
}, [firstFrameKey, frameSequenceKey, playing]);
useEffect(() => {
if (!currentFrameKey || !failedFrameKeys.has(currentFrameKey)) {
return;
}
const nextIndex = findNextShowcaseFrameIndex(
frameItems,
failedFrameKeys,
frameIndex,
);
if (nextIndex === null) {
setVisibleFrameKey('');
setIsPlaying(false);
return;
}
setFrameIndex(nextIndex);
}, [currentFrameKey, failedFrameKeys, frameIndex, frameItems]);
useEffect(() => {
if (!currentFrameKey) {
setVisibleFrameKey('');
return;
}
if (!loadedFrameKeys.has(currentFrameKey) && visibleFrameKey) {
return;
}
setVisibleFrameKey(currentFrameKey);
}, [currentFrameKey, loadedFrameKeys, visibleFrameKey]);
useEffect(() => {
if (!isPlaying || availableFrameCount < 2 || durationMs <= 0) {
return undefined;
}
const frameIntervalMs = Math.min(2_147_483_647, durationMs / frames.length);
const timer = window.setInterval(() => {
setFrameIndex(
(currentIndex) =>
findNextShowcaseFrameIndex(
frameItems,
failedFrameKeys,
currentIndex,
) ?? currentIndex,
);
}, frameIntervalMs);
return () => window.clearInterval(timer);
}, [
availableFrameCount,
durationMs,
failedFrameKeys,
frameItems,
frames.length,
isPlaying,
]);
if (!shouldMountSequence) {
return <ShowcaseImagePreview preview={preview} variant={variant} />;
}
const mediaClassName =
variant === 'modal'
? 'creation-landing__showcase-media'
: 'creation-landing__asset-preview-media';
return (
<div
aria-label={`角色动作预览:${preview.label}`}
className={`${mediaClassName} creation-landing__image-sequence`}
>
{frameItems
.filter((item) => mountedFrameKeys.has(item.key))
.map((item) => (
<ShowcaseImageSequenceFrame
key={`${item.key}:${retryGeneration}`}
preview={preview}
frame={item.frame}
frameKey={item.key}
visible={item.key === visibleFrameKey}
loaded={loadedFrameKeys.has(item.key)}
retryGeneration={retryGeneration}
onReady={handleFrameReady}
onFailed={handleFrameFailed}
/>
))}
{variant === 'modal' ? (
<div className="creation-landing__image-sequence-controls">
<button
type="button"
aria-label={isPlaying ? '暂停角色动作' : '播放角色动作'}
onClick={() => setIsPlaying((current) => !current)}
>
{isPlaying ? (
<Pause size={16} aria-hidden="true" />
) : (
<Play size={16} aria-hidden="true" />
)}
</button>
<span>{`${currentFrameItem ? currentFrameItem.index + 1 : 0}/${frames.length}`}</span>
{failedFrameKeys.size ? (
<>
<span aria-live="polite">{`${failedFrameKeys.size} 帧加载失败`}</span>
<button type="button" onClick={retryFailedFrames}>
重试失败帧
</button>
</>
) : null}
</div>
) : availableFrameCount === 0 ? (
<span
aria-live="polite"
className="creation-landing__image-sequence-error"
>
角色动作加载失败,打开预览可重试
</span>
) : null}
</div>
);
}
function findNextShowcaseFrameIndex(
frameItems: ReadonlyArray<{ key: string }>,
failedFrameKeys: ReadonlySet<string>,
currentIndex: number,
) {
for (let offset = 1; offset <= frameItems.length; offset += 1) {
const nextIndex = (currentIndex + offset) % frameItems.length;
const item = frameItems[nextIndex];
if (item && !failedFrameKeys.has(item.key)) {
return nextIndex;
}
}
return null;
}
function ShowcaseImagePreview({
preview,
variant,
@@ -344,7 +669,13 @@ function ShowcaseVideoPreview({
);
}
function ShowcaseBundlePreview({ item }: { item: ShowcaseAssetItem }) {
function ShowcaseBundlePreview({
item,
playSequences,
}: {
item: ShowcaseAssetItem;
playSequences: boolean;
}) {
const visiblePreviews = item.previews.slice(0, 4);
const primaryPreview = visiblePreviews[0];
const previewClassName = [
@@ -372,7 +703,7 @@ function ShowcaseBundlePreview({ item }: { item: ShowcaseAssetItem }) {
className="creation-landing__asset-preview-tile"
aria-label={preview.label}
>
<ShowcasePreview preview={preview} />
<ShowcasePreview preview={preview} sequencePlaying={playSequences} />
</span>
))}
{item.previews.length > visiblePreviews.length ? (
@@ -387,11 +718,13 @@ function ShowcaseBundlePreview({ item }: { item: ShowcaseAssetItem }) {
function CreationShowcaseModal({
item,
activeIndex,
playSequences,
onSelectIndex,
onClose,
}: {
item: ShowcaseAssetItem | null;
activeIndex: number;
playSequences: boolean;
onSelectIndex: (index: number) => void;
onClose: () => void;
}) {
@@ -427,7 +760,11 @@ function CreationShowcaseModal({
/>
<div className="creation-landing__showcase-modal-stage">
<div className="creation-landing__showcase-current">
<ShowcasePreview preview={activePreview} variant="modal" />
<ShowcasePreview
preview={activePreview}
variant="modal"
sequencePlaying={playSequences}
/>
</div>
</div>
<div className="creation-landing__showcase-dock">
@@ -470,7 +807,8 @@ function CreationShowcaseModal({
>
<span className="creation-landing__showcase-thumb-media">
{preview.mediaType === 'image' ||
preview.mediaType === 'audio' ? (
preview.mediaType === 'audio' ||
preview.mediaType === 'image-sequence' ? (
<ShowcasePreview preview={preview} variant="thumb" />
) : (
getPreviewIcon(preview)
@@ -493,6 +831,7 @@ export function CreationLandingView({
onOpenCommunity,
searchKeyword = '',
}: CreationLandingViewProps) {
const prefersReducedMotion = usePrefersReducedMotion();
const authUi = useAuthUi();
const isAuthenticated = Boolean(authUi?.user);
const normalizedSearchKeyword = searchKeyword.trim().toLocaleLowerCase();
@@ -520,6 +859,10 @@ export function CreationLandingView({
useState<ShowcaseTabId>('all');
const [selectedShowcaseItem, setSelectedShowcaseItem] =
useState<ShowcaseAssetItem | null>(null);
const [hoveredShowcaseSequenceCardId, setHoveredShowcaseSequenceCardId] =
useState<string | null>(null);
const [focusedShowcaseSequenceCardId, setFocusedShowcaseSequenceCardId] =
useState<string | null>(null);
const [selectedShowcaseIndex, setSelectedShowcaseIndex] = useState(0);
const [likedShowcaseIds, setLikedShowcaseIds] = useState<Set<string>>(
() => new Set(),
@@ -906,12 +1249,34 @@ export function CreationLandingView({
<button
type="button"
className="creation-landing__asset-card-open"
onPointerEnter={() =>
setHoveredShowcaseSequenceCardId(item.id)
}
onPointerLeave={() =>
setHoveredShowcaseSequenceCardId((current) =>
current === item.id ? null : current,
)
}
onFocus={() => setFocusedShowcaseSequenceCardId(item.id)}
onBlur={() =>
setFocusedShowcaseSequenceCardId((current) =>
current === item.id ? null : current,
)
}
onClick={() => {
setSelectedShowcaseItem(item);
setSelectedShowcaseIndex(0);
}}
>
<ShowcaseBundlePreview item={item} />
<ShowcaseBundlePreview
item={item}
playSequences={
!prefersReducedMotion &&
(hoveredShowcaseSequenceCardId === item.id ||
focusedShowcaseSequenceCardId === item.id) &&
!selectedShowcaseItem
}
/>
<div className="creation-landing__asset-meta">
<h3>{item.label}</h3>
<p>{item.prompt}</p>
@@ -1166,6 +1531,7 @@ export function CreationLandingView({
<CreationShowcaseModal
item={selectedShowcaseItem}
activeIndex={selectedShowcaseIndex}
playSequences={!prefersReducedMotion}
onSelectIndex={setSelectedShowcaseIndex}
onClose={() => setSelectedShowcaseItem(null)}
/>
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest';
import type { EditorProjectResourceSnapshot } from '../../services/image-editor/editorProjectClient';
import { calculateEditorVideoPrice } from '../image-editor/ImageCanvasGenerationModel';
import { buildCreationShowcaseItems } from './creationShowcaseModel';
function createResource(
@@ -375,6 +376,19 @@ describe('creationShowcaseModel', () => {
label: '待机动画',
assetKind: 'character-animation',
imageSrc: 'data:image/png;base64,animation',
imageSequenceFrames: [
{
imageSrc: 'data:image/png;base64,animation-1',
width: 512,
height: 512,
},
{
imageSrc: 'data:image/png;base64,animation-2',
width: 512,
height: 512,
},
],
imageSequenceDurationMs: 4_000,
generationInputs: {
fields: [{ title: '动作描述', value: '待机动作' }],
references: [
@@ -531,6 +545,34 @@ describe('creationShowcaseModel', () => {
expect(items[0]?.cost).toBe('5泥点');
});
it('infers video cost from the duration field instead of legacy top-level metadata', () => {
const items = buildCreationShowcaseItems({
activeTab: 'all',
projectResources: [
createResource({
resourceId: 'video-1',
label: '角色宣传片',
assetKind: 'video',
imageSrc: '/generated-editor-videos/video.mp4',
model: 'seedance2.0-fast',
generationInputs: {
fields: [
{ title: '清晰度', value: '720p' },
{ title: '时长', value: '6秒' },
],
references: [],
durationSeconds: 4,
},
}),
],
});
expect(items).toHaveLength(1);
expect(items[0]?.cost).toBe(
`${calculateEditorVideoPrice('seedance2.0-fast', '720p', 6)}泥点`,
);
});
it('keeps character animation videos out of the marketing tab', () => {
const items = buildCreationShowcaseItems({
activeTab: 'marketing',
@@ -555,46 +597,118 @@ describe('creationShowcaseModel', () => {
expect(items[0]?.previews[0]?.mediaType).toBe('video');
});
it('infers costs from generated asset kinds when stored price is absent', () => {
it.each([
{ frameCount: 32, durationMs: 4_000 },
{ frameCount: 40, durationMs: 5_000 },
{ frameCount: 48, durationMs: 6_000 },
])(
'carries a $frameCount-frame/$durationMs ms character action into the sequence preview',
({ frameCount, durationMs }) => {
const frames = Array.from({ length: frameCount }, (_, index) => ({
imageSrc: `/generated/action/frame-${index + 1}.png`,
objectKey: `generated/action/frame-${index + 1}.png`,
assetObjectId: `asset-object-frame-${index + 1}`,
width: 192,
height: 256,
}));
const items = buildCreationShowcaseItems({
activeTab: 'all',
projectResources: [
createResource({
resourceId: 'animation-1',
label: '待机动作',
assetKind: 'character-animation',
imageSrc: frames[0]?.imageSrc,
objectKey: frames[0]?.objectKey,
imageSequenceFrames: frames,
imageSequenceDurationMs: durationMs,
}),
],
});
expect(items).toHaveLength(1);
expect(items[0]?.previews[0]).toMatchObject({
mediaType: 'image-sequence',
imageSequenceDurationMs: durationMs,
});
expect(items[0]?.previews[0]?.imageSequenceFrames).toHaveLength(
frameCount,
);
},
);
it('omits a damaged character action instead of falling back to its first frame', () => {
const items = buildCreationShowcaseItems({
activeTab: 'characters',
activeTab: 'all',
projectResources: [
createResource({
resourceId: 'character-1',
label: '游戏角色图',
imageSrc: '/generated-editor-assets/character.png',
objectKey: 'generated-editor-assets/character.png',
assetKind: 'character',
model: 'gpt-image-2',
prompt: '角色提示词',
}),
createResource({
resourceId: 'animation-1',
label: '待机动画',
resourceId: 'animation-broken',
label: '损坏动作',
assetKind: 'character-animation',
imageSrc: '/generated-editor-videos/animation.mp4',
objectKey: 'generated-editor-videos/animation.mp4',
model: 'seedance2.0-fast',
durationSeconds: 6,
generationInputs: {
fields: [{ title: '清晰度', value: '720p' }],
references: [
{
title: '角色图片',
label: '游戏角色图',
refType: 'project-resource',
refId: 'character-1',
},
],
},
imageSrc: '/generated/action/frame-01.png',
objectKey: 'generated/action/frame-01.png',
imageSequenceFrames: [
{
imageSrc: '/generated/action/frame-01.png',
objectKey: 'generated/action/frame-01.png',
width: 192,
height: 256,
},
],
imageSequenceDurationMs: 5_000,
}),
],
});
expect(items).toHaveLength(1);
expect(items[0]?.cost).toBe('123泥点');
expect(items[0]?.previews[0]?.objectKey).toBe(
'generated-editor-assets/character.png',
);
expect(items).toEqual([]);
});
it.each([
{ durationMs: 5_000, expectedCost: '103泥点' },
{ durationMs: 6_000, expectedCost: '123泥点' },
])(
'infers costs from $durationMs ms character actions when stored price is absent',
({ durationMs, expectedCost }) => {
const items = buildCreationShowcaseItems({
activeTab: 'characters',
projectResources: [
createResource({
resourceId: 'character-1',
label: '游戏角色图',
imageSrc: '/generated-editor-assets/character.png',
objectKey: 'generated-editor-assets/character.png',
assetKind: 'character',
model: 'gpt-image-2',
prompt: '角色提示词',
}),
createResource({
resourceId: 'animation-1',
label: '待机动画',
assetKind: 'character-animation',
imageSrc: '/generated-editor-videos/animation.mp4',
objectKey: 'generated-editor-videos/animation.mp4',
model: 'seedance2.0-fast',
imageSequenceDurationMs: durationMs,
generationInputs: {
fields: [{ title: '清晰度', value: '720p' }],
references: [
{
title: '角色图片',
label: '游戏角色图',
refType: 'project-resource',
refId: 'character-1',
},
],
},
}),
],
});
expect(items).toHaveLength(1);
expect(items[0]?.cost).toBe(expectedCost);
expect(items[0]?.previews[0]?.objectKey).toBe(
'generated-editor-assets/character.png',
);
},
);
});
@@ -1,6 +1,7 @@
import type {
EditorAssetGenerationInputReference,
EditorAssetSnapshot,
EditorImageSequenceFrameResult,
EditorProjectResourceSnapshot,
EditorShowcaseCampaignSnapshot,
} from '../../services/image-editor/editorProjectClient';
@@ -19,7 +20,11 @@ import {
export type ShowcaseTabId = 'all' | 'characters' | 'ui' | 'music' | 'marketing';
export type ShowcaseAssetMediaType = 'image' | 'video' | 'audio';
export type ShowcaseAssetMediaType =
| 'image'
| 'video'
| 'audio'
| 'image-sequence';
export type ShowcaseAssetPreview = {
id: string;
@@ -30,6 +35,8 @@ export type ShowcaseAssetPreview = {
mediaType: ShowcaseAssetMediaType;
width?: number;
height?: number;
imageSequenceFrames?: EditorImageSequenceFrameResult[];
imageSequenceDurationMs?: number;
};
export type ShowcaseAssetItem = {
@@ -246,7 +253,8 @@ function projectResourceToShowcaseAsset(
actualPrompt: resource.actualPrompt ?? null,
model: resource.model ?? null,
taskId: resource.taskId ?? null,
durationSeconds: resource.durationSeconds,
imageSequenceFrames: resource.imageSequenceFrames,
imageSequenceDurationMs: resource.imageSequenceDurationMs,
sourceResourceId: resource.sourceResourceId,
assetKind: resource.assetKind,
showcaseCategory: normalizeShowcaseCategory(
@@ -394,6 +402,9 @@ function isVideoAsset(asset: EditorAssetSnapshot) {
function resolveAssetMediaType(
asset: EditorAssetSnapshot,
): ShowcaseAssetMediaType {
if (isCharacterAnimationAsset(asset)) {
return 'image-sequence';
}
if (isAudioAsset(asset)) {
return 'audio';
}
@@ -792,18 +803,23 @@ function inferAssetCost(asset: EditorAssetSnapshot) {
return calculateCharacterAnimationPrice(
model ?? CHARACTER_ANIMATION_MODEL,
inferVideoResolution(asset, ['480p', '720p'], '480p') as '480p' | '720p',
normalizeInt(asset.durationSeconds, 4, 4, 6),
normalizeInt(
asset.imageSequenceDurationMs
? asset.imageSequenceDurationMs / 1_000
: undefined,
4,
4,
6,
),
);
}
if (kind === 'video' || isVideoAsset(asset)) {
return calculateEditorVideoPrice(
model ?? DEFAULT_VIDEO_MODEL,
inferVideoResolution(asset, ['480p', '720p', '1080p'], '480p') as
| '480p'
| '720p'
| '1080p',
'480p' | '720p' | '1080p',
normalizeInt(
asset.durationSeconds,
inferMediaDurationSeconds(asset),
DEFAULT_VIDEO_DURATION_SECONDS,
4,
15,
@@ -816,6 +832,15 @@ function inferAssetCost(asset: EditorAssetSnapshot) {
return null;
}
function inferMediaDurationSeconds(asset: EditorAssetSnapshot) {
const durationField = asset.generationInputs?.fields.find((field) => {
const title = field.title.trim().toLowerCase();
return title === '时长' || title === 'duration';
});
const matchedValue = durationField?.value.match(/\d+(?:\.\d+)?/u)?.[0];
return matchedValue ? Number(matchedValue) : undefined;
}
function inferVideoResolution(
asset: EditorAssetSnapshot,
allowedValues: string[],
@@ -849,8 +874,70 @@ function resolveGroupCost(assets: EditorAssetSnapshot[]) {
return `${totalCost}泥点`;
}
function toAssetPreview(asset: EditorAssetSnapshot): ShowcaseAssetPreview {
function normalizeShowcaseImageSequence(asset: EditorAssetSnapshot) {
const frames = asset.imageSequenceFrames;
const durationMs = asset.imageSequenceDurationMs;
if (
!Array.isArray(frames) ||
frames.length < 2 ||
typeof durationMs !== 'number' ||
!Number.isFinite(durationMs) ||
durationMs <= 0
) {
return null;
}
const normalizedFrames = frames.flatMap((frame) => {
const imageSrc = frame.imageSrc?.trim() ?? '';
if (
!imageSrc ||
!Number.isFinite(frame.width) ||
frame.width <= 0 ||
!Number.isFinite(frame.height) ||
frame.height <= 0
) {
return [];
}
return [
{
...frame,
imageSrc,
objectKey: frame.objectKey?.trim() || null,
assetObjectId: frame.assetObjectId?.trim() || null,
},
];
});
if (normalizedFrames.length !== frames.length) {
return null;
}
return { frames: normalizedFrames, durationMs };
}
function toAssetPreview(
asset: EditorAssetSnapshot,
): ShowcaseAssetPreview | null {
const mediaType = resolveAssetMediaType(asset);
if (mediaType === 'image-sequence') {
const sequence = normalizeShowcaseImageSequence(asset);
if (!sequence) {
return null;
}
const firstFrame = sequence.frames[0];
if (!firstFrame) {
return null;
}
return {
id: asset.assetId,
label: asset.label,
src: firstFrame.imageSrc,
coverSrc: null,
objectKey: firstFrame.objectKey,
mediaType,
width: firstFrame.width,
height: firstFrame.height,
imageSequenceFrames: sequence.frames,
imageSequenceDurationMs: sequence.durationMs,
};
}
return {
id: asset.assetId,
label: asset.label,
@@ -870,7 +957,10 @@ function toLibraryShowcaseItem(
const sortedAssets = sortGroupAssets(group.assets);
const previews = sortedAssets
.map(toAssetPreview)
.filter((preview) => preview.src.trim());
.filter(
(preview): preview is ShowcaseAssetPreview =>
Boolean(preview?.src.trim()),
);
if (!previews.length) {
return null;
}
@@ -1,108 +1,36 @@
/* @vitest-environment jsdom */
import { act, fireEvent, render, screen } from '@testing-library/react';
import { fireEvent, render, screen } from '@testing-library/react';
import type { FormEvent } from 'react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { describe, expect, it, vi } from 'vitest';
import { EditorAgentDraftTextarea } from './EditorAgentDraftTextarea.tsx';
afterEach(() => {
vi.unstubAllGlobals();
});
describe('EditorAgentDraftTextarea', () => {
it('uses native field sizing when the browser supports it', () => {
const supports = vi.fn().mockReturnValue(true);
const resizeObserver = vi.fn();
it('forwards draft changes and paste events through the Agent wrapper', () => {
const onChange = vi.fn();
vi.stubGlobal('CSS', { supports });
vi.stubGlobal('ResizeObserver', resizeObserver);
const onPaste = vi.fn();
render(<EditorAgentDraftTextarea value="" onChange={onChange} />);
render(
<EditorAgentDraftTextarea
value=""
onChange={onChange}
onPaste={onPaste}
/>,
);
const input = screen.getByLabelText(
'发送给画布 Agent',
) as HTMLTextAreaElement;
expect(supports).toHaveBeenCalledWith('field-sizing', 'content');
expect(resizeObserver).not.toHaveBeenCalled();
expect(input.className).toContain('[field-sizing:content]');
expect(input.className).toContain('min-h-10');
expect(input.className).toContain('max-h-32');
expect(input.className).toContain('overflow-y-auto');
expect(input.style.height).toBe('');
expect(input.className).toContain('auto-grow-text-area');
expect(input.className).toContain(
'editor-agent-conversation__draft-input',
);
fireEvent.change(input, { target: { value: '新草稿' } });
expect(onChange).toHaveBeenCalledWith('新草稿');
});
it('falls back to measured sizing and remeasures when width changes', () => {
let resizeObserverCallback!: ResizeObserverCallback;
const observe = vi.fn();
const disconnect = vi.fn();
vi.stubGlobal('CSS', { supports: vi.fn().mockReturnValue(false) });
vi.stubGlobal(
'ResizeObserver',
vi.fn().mockImplementation((callback: ResizeObserverCallback) => {
resizeObserverCallback = callback;
return {
observe,
unobserve: vi.fn(),
disconnect,
};
}),
);
const { rerender, unmount } = render(
<EditorAgentDraftTextarea value="" onChange={vi.fn()} />,
);
const input = screen.getByLabelText(
'发送给画布 Agent',
) as HTMLTextAreaElement;
expect(observe).toHaveBeenCalledWith(input);
let contentHeight = 92;
Object.defineProperty(input, 'offsetHeight', {
configurable: true,
get: () => {
const styleHeight = Number.parseFloat(input.style.height);
return Number.isNaN(styleHeight) ? 42 : styleHeight;
},
});
Object.defineProperty(input, 'clientHeight', {
configurable: true,
get: () => input.offsetHeight - 2,
});
Object.defineProperty(input, 'scrollHeight', {
configurable: true,
get: () => contentHeight,
});
rerender(
<EditorAgentDraftTextarea
value="第一行\n第二行\n第三行\n第四行\n第五行"
onChange={vi.fn()}
/>,
);
expect(input.style.height).toBe('94px');
expect(input.style.overflowY).toBe('hidden');
expect(input.scrollHeight).toBeLessThanOrEqual(input.clientHeight);
contentHeight = 180;
act(() => {
resizeObserverCallback(
[
{
target: input,
contentRect: { width: 180 },
} as unknown as ResizeObserverEntry,
],
{} as ResizeObserver,
);
});
expect(input.style.height).toBe('128px');
expect(input.style.overflowY).toBe('auto');
unmount();
expect(disconnect).toHaveBeenCalledTimes(1);
fireEvent.paste(input);
expect(onPaste).toHaveBeenCalledTimes(1);
});
it('submits on Enter and keeps line breaks or IME confirmation from submitting', () => {
@@ -1,38 +1,9 @@
import {
type ClipboardEventHandler,
type KeyboardEvent,
useLayoutEffect,
useRef,
} from 'react';
const DRAFT_MIN_HEIGHT_PX = 40;
const DRAFT_MAX_HEIGHT_PX = 128;
// for fallback on some browser
function supportsNativeFieldSizing() {
return (
typeof CSS !== 'undefined' &&
typeof CSS.supports === 'function' &&
CSS.supports('field-sizing', 'content')
);
}
function resizeFallbackTextarea(textarea: HTMLTextAreaElement) {
textarea.style.height = 'auto';
const contentHeight = textarea.scrollHeight;
const borderHeight = Math.max(
0,
textarea.offsetHeight - textarea.clientHeight,
);
const borderBoxContentHeight = contentHeight + borderHeight;
const nextHeight = Math.min(
Math.max(borderBoxContentHeight, DRAFT_MIN_HEIGHT_PX),
DRAFT_MAX_HEIGHT_PX,
);
textarea.style.height = `${nextHeight}px`;
textarea.style.overflowY =
borderBoxContentHeight > DRAFT_MAX_HEIGHT_PX ? 'auto' : 'hidden';
}
import { AutoGrowTextArea } from '@/src/components/common/AutoGrowTextArea';
export function EditorAgentDraftTextarea({
value,
@@ -43,49 +14,6 @@ export function EditorAgentDraftTextarea({
onChange: (value: string) => void;
onPaste?: ClipboardEventHandler<HTMLTextAreaElement>;
}) {
const textareaRef = useRef<HTMLTextAreaElement>(null);
useLayoutEffect(() => {
const textarea = textareaRef.current;
if (!textarea || supportsNativeFieldSizing()) {
return;
}
resizeFallbackTextarea(textarea);
}, [value]);
useLayoutEffect(() => {
const textarea = textareaRef.current;
if (!textarea || supportsNativeFieldSizing()) {
return undefined;
}
let previousWidth = textarea.getBoundingClientRect().width;
const resizeWhenWidthChanges = (nextWidth: number) => {
if (Math.abs(nextWidth - previousWidth) < 0.5) {
return;
}
previousWidth = nextWidth;
resizeFallbackTextarea(textarea);
};
if (typeof ResizeObserver !== 'undefined') {
const observer = new ResizeObserver((entries) => {
const entry = entries[0];
if (entry) {
resizeWhenWidthChanges(entry.contentRect.width);
}
});
observer.observe(textarea);
return () => observer.disconnect();
}
const handleWindowResize = () => {
resizeWhenWidthChanges(textarea.getBoundingClientRect().width);
};
window.addEventListener('resize', handleWindowResize);
return () => window.removeEventListener('resize', handleWindowResize);
}, []);
const handleKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
if (
event.key === 'Enter' &&
@@ -99,12 +27,10 @@ export function EditorAgentDraftTextarea({
};
return (
<textarea
ref={textareaRef}
className="editor-agent-conversation__draft-input max-h-32 min-h-10 min-w-0 flex-1 resize-none overflow-x-hidden overflow-y-auto overscroll-contain rounded-3xl border border-slate-200 bg-slate-50 px-3 py-2 text-sm text-slate-800 outline-none [field-sizing:content] focus:border-slate-400"
<AutoGrowTextArea
className="editor-agent-conversation__draft-input min-w-0 flex-1 rounded-3xl border border-slate-200 bg-slate-50 px-3 py-2 text-sm text-slate-800 outline-none focus:border-slate-400"
aria-label="发送给画布 Agent"
value={value}
rows={1}
onChange={(event) => onChange(event.currentTarget.value)}
onPaste={onPaste}
onKeyDown={handleKeyDown}
@@ -190,6 +190,7 @@ describe('ImageCanvasBasicGenerationComposerView', () => {
});
it('keeps quick edit to one prompt box with image parameters', () => {
const submitQuickEdit = vi.fn();
render(
<BasicGenerationHarness
initialDialog={createDialog({
@@ -206,14 +207,19 @@ describe('ImageCanvasBasicGenerationComposerView', () => {
aspectRatio: '1:1',
imageSize: '1K',
})}
onSubmit={submitQuickEdit}
/>,
);
const panel = screen.getByRole('dialog', { name: '快速编辑图片' });
const prompt = within(panel).getByRole('textbox', {
name: '快速编辑提示词',
});
expect(
within(panel).getByRole('textbox', { name: '快速编辑提示词' }),
).toBeTruthy();
expect(prompt.className).toContain('auto-grow-text-area');
expect(prompt.className).not.toContain('platform-text-field');
fireEvent.keyDown(prompt, { key: 'Enter' });
expect(submitQuickEdit).not.toHaveBeenCalled();
expect(
within(panel).queryByRole('button', { name: '添加参考图' }),
).toBeNull();
@@ -7,12 +7,12 @@ import {
type SetStateAction,
} from 'react';
import { AutoGrowTextArea } from '../common/AutoGrowTextArea';
import {
PlatformFloatingMenu,
PlatformFloatingMenuItem,
} from '../common/PlatformFloatingMenu';
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
import { PlatformTextField } from '../common/PlatformTextField';
import type {
CharacterReferenceImage,
GenerateDialogState,
@@ -54,6 +54,7 @@ type ImageCanvasBasicGenerationComposerViewProps = {
onPickReferenceFromCanvas?: () => void;
onToggleReferenceMenu?: () => void;
onRememberImageModel?: (model: string) => void;
hasPendingImageReferenceUploads?: boolean;
onSubmit: (dialog: GenerateDialogState) => void;
dialogLabel?: string;
includeDimensions?: boolean;
@@ -107,6 +108,7 @@ export function ImageCanvasBasicGenerationComposerView({
onPickReferenceFromCanvas,
onToggleReferenceMenu,
onRememberImageModel = () => {},
hasPendingImageReferenceUploads = false,
onSubmit,
dialogLabel,
includeDimensions = true,
@@ -247,14 +249,11 @@ export function ImageCanvasBasicGenerationComposerView({
) : null}
</div>
) : null}
<PlatformTextField
variant="textarea"
<AutoGrowTextArea
aria-label={resolvedPromptLabel}
value={dialog.prompt}
disabled={dialog.status === 'generating'}
placeholder={resolvedPromptPlaceholder}
size="sm"
density="compact"
className={promptClassName}
onChange={(event) =>
setGenerateDialog((currentDialog) =>
@@ -274,6 +273,7 @@ export function ImageCanvasBasicGenerationComposerView({
includeDimensions={shouldIncludeDimensions}
includeModel={includeModel}
onRememberImageModel={onRememberImageModel}
hasPendingImageReferenceUploads={hasPendingImageReferenceUploads}
dimensionRatioAriaLabelPrefix={dimensionRatioAriaLabelPrefix}
dimensionSizeAriaLabelPrefix={dimensionSizeAriaLabelPrefix}
optionLabelPrefix={resolvedOptionLabelPrefix}
@@ -113,7 +113,11 @@ describe('ImageCanvasCharacterAnimationPanelView', () => {
/>,
);
fireEvent.change(screen.getByLabelText('动画描述'), {
const description = screen.getByLabelText('动画描述');
expect(description.className).toContain('auto-grow-text-area');
expect(description.className).not.toContain('platform-text-field');
fireEvent.change(description, {
target: { value: `${'a'.repeat(4001)}` },
});
fireEvent.click(
@@ -207,9 +211,12 @@ describe('ImageCanvasCharacterAnimationPanelView', () => {
fireEvent.click(screen.getByRole('button', { name: '生成中' }));
expect((screen.getByLabelText('动画描述') as HTMLTextAreaElement).disabled).toBe(
true,
);
const description = screen.getByLabelText(
'动画描述',
) as HTMLTextAreaElement;
expect(description.disabled).toBe(true);
expect(description.className).toContain('disabled:cursor-not-allowed');
expect(description.className).toContain('disabled:opacity-60');
expect((screen.getByRole('button', { name: '待机' }) as HTMLButtonElement).disabled).toBe(
true,
);
@@ -13,11 +13,11 @@ import type {
EditorCharacterAnimationRatio,
EditorCharacterAnimationResolution,
} from '../../services/image-editor/editorProjectClient';
import { AutoGrowTextArea } from '../common/AutoGrowTextArea';
import { PlatformActionButton } from '../common/PlatformActionButton';
import { PlatformFloatingMenu } from '../common/PlatformFloatingMenu';
import { PlatformInlineOptionButton } from '../common/PlatformInlineOptionButton';
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
import { PlatformTextField } from '../common/PlatformTextField';
import { ImageCanvasEditorPortal } from './ImageCanvasEditorPortal';
import { EditorIconButton } from './ImageCanvasEditorPrimitives';
import type {
@@ -194,16 +194,13 @@ export function ImageCanvasCharacterAnimationPanelView({
disabled={isGenerating}
onClick={closePanel}
/>
<PlatformTextField
variant="textarea"
<AutoGrowTextArea
aria-label="动画描述"
value={panel.promptText}
maxLength={4000}
disabled={isGenerating}
placeholder="你希望角色做什么动作?"
size="sm"
density="compact"
className="image-canvas-editor__generation-prompt image-canvas-editor__character-animation-textarea"
className="max-h-64 image-canvas-editor__generation-prompt image-canvas-editor__character-animation-textarea"
onChange={(event) =>
updatePanel({ promptText: event.target.value.slice(0, 4000) })
}
@@ -105,6 +105,8 @@ describe('ImageCanvasCharacterGenerationComposerView', () => {
.join(''),
).not.toContain('角色设定');
expect(prompt.getAttribute('placeholder')).toBe('你希望角色如何设计?');
expect(prompt.className).toContain('auto-grow-text-area');
expect(prompt.className).not.toContain('platform-text-field');
expect(prompt.className).toContain('image-canvas-editor__generation-prompt');
expect(prompt.className).not.toContain(
'image-canvas-editor__generation-prompt--borderless',
@@ -7,12 +7,12 @@ import {
type SetStateAction,
} from 'react';
import { AutoGrowTextArea } from '../common/AutoGrowTextArea';
import {
PlatformFloatingMenu,
PlatformFloatingMenuItem,
} from '../common/PlatformFloatingMenu';
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
import { PlatformTextField } from '../common/PlatformTextField';
import type {
CharacterReferenceImage,
GenerateDialogState,
@@ -44,6 +44,7 @@ type ImageCanvasCharacterGenerationComposerViewProps = {
onOpenSpecDialog: (specType: SpecGenerationType) => void;
onRequestUpload: (target: UploadTarget) => void;
onRememberImageModel: (model: string) => void;
hasPendingImageReferenceUploads?: boolean;
onSubmit: (dialog: GenerateDialogState) => void;
};
@@ -97,6 +98,7 @@ export function ImageCanvasCharacterGenerationComposerView({
onOpenSpecDialog,
onRequestUpload,
onRememberImageModel,
hasPendingImageReferenceUploads = false,
onSubmit,
}: ImageCanvasCharacterGenerationComposerViewProps) {
useImageCanvasFloatingOptionDismiss({
@@ -254,14 +256,11 @@ export function ImageCanvasCharacterGenerationComposerView({
: null}
</div>
<label className="image-canvas-editor__field-block image-canvas-editor__field-block--single">
<PlatformTextField
variant="textarea"
<AutoGrowTextArea
aria-label="角色设定"
value={dialog.prompt}
disabled={dialog.status === 'generating'}
placeholder="你希望角色如何设计?"
size="sm"
density="compact"
className="image-canvas-editor__generation-prompt"
onChange={(event) =>
setGenerateDialog((currentDialog) =>
@@ -292,6 +291,7 @@ export function ImageCanvasCharacterGenerationComposerView({
setGenerateDialog={setGenerateDialog}
includeDimensions
onRememberImageModel={onRememberImageModel}
hasPendingImageReferenceUploads={hasPendingImageReferenceUploads}
optionLabelPrefix="生成图片"
cost={calculateEditorImageGenerationPrice({
kind: 'character',
@@ -36,6 +36,8 @@ function renderContextMenus(
contextMenuLayer: null,
contextShouldShowLayer: false,
contextShouldUnlockLayer: false,
canCopyContextLayers: true,
isCanvasClipboardCopyBlocked: false,
onPasteCanvasClipboard: vi.fn(),
onCopyContextLayers: vi.fn(),
onDuplicateContextLayers: vi.fn(),
@@ -183,6 +185,87 @@ describe('ImageCanvasContextMenusView', () => {
expect(props.onDeleteContextLayers).toHaveBeenCalledTimes(1);
});
it('disables copy actions while the target resource is still being saved', () => {
const layer = createLayer({ resourceId: 'local-resource-pending' });
const props = renderContextMenus({
contextMenu: {
kind: 'layer',
x: 16,
y: 18,
layerId: layer.id,
canvasPoint: { x: 40, y: 42 },
},
contextMenuLayer: layer,
canCopyContextLayers: false,
contextLayerCopyBlockReason: 'pending-resource',
});
expect(
(screen.getByRole('menuitem', { name: '复制' }) as HTMLButtonElement)
.disabled,
).toBe(true);
expect(
(screen.getByRole('menuitem', { name: '剪切' }) as HTMLButtonElement)
.disabled,
).toBe(true);
expect(
(screen.getByRole('menuitem', { name: '创建副本' }) as HTMLButtonElement)
.disabled,
).toBe(true);
expect(props.onCopyContextLayers).not.toHaveBeenCalled();
expect(props.onDuplicateContextLayers).not.toHaveBeenCalled();
expect(
screen.getByRole('menuitem', { name: '复制' }).getAttribute('title'),
).toBe('素材仍在保存,请稍后重试');
});
it('explains that persisted historical sequences cannot be copied', () => {
const layer = createLayer({
resourceId: 'local-resource-historical-sequence',
resourcePersistenceState: 'self-contained-local',
mediaType: 'image-sequence',
});
renderContextMenus({
contextMenu: {
kind: 'layer',
x: 16,
y: 18,
layerId: layer.id,
canvasPoint: { x: 40, y: 42 },
},
contextMenuLayer: layer,
canCopyContextLayers: false,
contextLayerCopyBlockReason: 'self-contained-local-unsupported',
});
expect(
screen.getByRole('menuitem', { name: '复制' }).getAttribute('title'),
).toBe('历史序列帧素材暂不支持复制');
});
it('explains that unresolved local resources are not registered', () => {
const layer = createLayer({
resourceId: 'local-resource-unresolved',
resourcePersistenceState: 'unresolved-local',
});
renderContextMenus({
contextMenu: {
kind: 'layer',
x: 16,
y: 18,
layerId: layer.id,
canvasPoint: { x: 40, y: 42 },
},
contextMenuLayer: layer,
canCopyContextLayers: false,
contextLayerCopyBlockReason: 'unresolved-resource',
});
expect(
screen.getByRole('menuitem', { name: '复制' }).getAttribute('title'),
).toBe('素材资源尚未登记,暂不能复制');
});
it('keeps measured layer menus and submenus inside the viewport', async () => {
const originalInnerWidth = window.innerWidth;
const originalInnerHeight = window.innerHeight;
@@ -274,9 +357,7 @@ describe('ImageCanvasContextMenusView', () => {
});
expect(screen.queryByRole('menuitem', { name: '快速编辑' })).toBeNull();
expect(
screen.getByRole('menuitem', { name: '查看图片信息' }),
).toBeTruthy();
expect(screen.getByRole('menuitem', { name: '查看图片信息' })).toBeTruthy();
});
it('keeps quick edit in the standalone menu for icon spritesheets', () => {
@@ -19,6 +19,7 @@ import type {
ImageContextMenuState,
} from './ImageCanvasEditorTypes';
import { isQuickEditUnsupportedAssetKind } from './ImageCanvasGenerationModel';
import type { CanvasLayerCopyBlockReason } from './ImageCanvasLayerCommandModel';
type ImageCanvasContextMenusViewProps = {
viewport: CanvasViewport;
@@ -29,6 +30,9 @@ type ImageCanvasContextMenusViewProps = {
contextMenuLayer: CanvasLayer | null;
contextShouldShowLayer: boolean;
contextShouldUnlockLayer: boolean;
canCopyContextLayers: boolean;
contextLayerCopyBlockReason?: CanvasLayerCopyBlockReason | null;
isCanvasClipboardCopyBlocked: boolean;
onPasteCanvasClipboard: (canvasPoint?: { x: number; y: number }) => void;
onCopyContextLayers: (options?: { cut?: boolean }) => void;
onDuplicateContextLayers: () => void;
@@ -69,6 +73,9 @@ export function ImageCanvasContextMenusView({
contextMenuLayer,
contextShouldShowLayer,
contextShouldUnlockLayer,
canCopyContextLayers,
contextLayerCopyBlockReason = null,
isCanvasClipboardCopyBlocked,
onPasteCanvasClipboard,
onCopyContextLayers,
onDuplicateContextLayers,
@@ -90,6 +97,14 @@ export function ImageCanvasContextMenusView({
onOpenCharacterAnimationPanel,
}: ImageCanvasContextMenusViewProps) {
const isImageSequenceLayer = contextMenuLayer?.mediaType === 'image-sequence';
const layerCopyBlockedTitle =
contextLayerCopyBlockReason === 'self-contained-local-unsupported'
? '历史序列帧素材暂不支持复制'
: contextLayerCopyBlockReason === 'pending-resource'
? '素材仍在保存,请稍后重试'
: contextLayerCopyBlockReason === 'unresolved-resource'
? '素材资源尚未登记,暂不能复制'
: undefined;
const menuRef = useRef<HTMLDivElement | null>(null);
const [measuredMenuLayout, setMeasuredMenuLayout] =
useState<MeasuredMenuLayout | null>(null);
@@ -282,6 +297,7 @@ export function ImageCanvasContextMenusView({
<button
type="button"
role="menuitem"
disabled={isCanvasClipboardCopyBlocked}
onClick={() => onPasteCanvasClipboard(contextMenu.canvasPoint)}
>
粘贴
@@ -331,6 +347,8 @@ export function ImageCanvasContextMenusView({
<button
type="button"
role="menuitem"
disabled={!canCopyContextLayers}
title={layerCopyBlockedTitle}
onClick={() => onCopyContextLayers()}
>
复制
@@ -338,6 +356,8 @@ export function ImageCanvasContextMenusView({
<button
type="button"
role="menuitem"
disabled={!canCopyContextLayers}
title={layerCopyBlockedTitle}
onClick={() => onCopyContextLayers({ cut: true })}
>
剪切
@@ -345,6 +365,7 @@ export function ImageCanvasContextMenusView({
<button
type="button"
role="menuitem"
disabled={isCanvasClipboardCopyBlocked}
onClick={() => onPasteCanvasClipboard(contextMenu.canvasPoint)}
>
粘贴
@@ -352,6 +373,8 @@ export function ImageCanvasContextMenusView({
<button
type="button"
role="menuitem"
disabled={!canCopyContextLayers}
title={layerCopyBlockedTitle}
onClick={onDuplicateContextLayers}
>
创建副本
@@ -17,6 +17,8 @@ import {
createAuthValue,
createDataTransferStub,
createDeferred,
defaultEditorProjectLayers,
defaultEditorProjectResources,
dispatchPointerEvent,
ImageCanvasEditorView,
setupImageCanvasEditorViewTestLifecycle,
@@ -166,6 +168,7 @@ function createTestImageAsset({
width: 320,
height: 240,
sourceType,
assetKind: 'image' as const,
sourceResourceId,
};
}
@@ -345,6 +348,86 @@ describe('ImageCanvasEditorView asset library integration', () => {
}
});
it('downloads a character action from the asset library as a sequence zip', async () => {
loadEditorAssetLibraryMock.mockResolvedValueOnce({
folders: [
{
folderId: 'project',
label: '项目素材',
sortOrder: 0,
collapsed: false,
systemDefault: true,
},
],
assets: [
{
assetId: 'asset-action-download',
folderId: 'project',
label: '角色挥手',
imageSrc: '/generated/action/frame01.png',
width: 192,
height: 256,
sourceType: 'generated',
assetKind: 'character-animation',
imageSequenceFrames: [
{
imageSrc: '/generated/action/frame01.png',
width: 192,
height: 256,
},
{
imageSrc: '/generated/action/frame02.png',
width: 192,
height: 256,
},
],
imageSequenceDurationMs: 4_000,
},
],
});
const originalFetch = globalThis.fetch;
const fetchMock = vi.fn(
async (..._args: Parameters<typeof fetch>) =>
new Response(new Blob(['frame'], { type: 'image/png' })),
);
globalThis.fetch = fetchMock as typeof fetch;
let downloadName = '';
const downloadCapture: { blob: Blob | null } = { blob: null };
Object.defineProperty(URL, 'createObjectURL', {
configurable: true,
value: vi.fn((blob: Blob) => {
downloadCapture.blob = blob;
return 'blob:character-action-zip';
}),
});
Object.defineProperty(URL, 'revokeObjectURL', {
configurable: true,
value: vi.fn(),
});
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(
function click(this: HTMLAnchorElement) {
downloadName = this.download;
},
);
try {
render(<ImageCanvasEditorView />);
fireEvent.click(
await screen.findByRole('button', { name: '下载素材角色挥手' }),
);
await waitFor(() => {
expect(downloadName).toMatch(/^角色挥手-SpineJSON-\d{8}-\d{6}\.zip$/u);
});
const fetchedUrls = fetchMock.mock.calls.map(([url]) => url);
expect(fetchedUrls).toContain('/generated/action/frame01.png');
expect(fetchedUrls).toContain('/generated/action/frame02.png');
expect(downloadCapture.blob?.type).toBe('application/zip');
} finally {
globalThis.fetch = originalFetch;
}
});
it('collapses folders, creates upload folders, and deletes uploaded materials', async () => {
const user = userEvent.setup();
const createObjectUrlSpy = vi.fn(() => 'blob:folder-uploaded-image');
@@ -1454,6 +1537,145 @@ describe('ImageCanvasEditorView asset library integration', () => {
);
});
it('restores a copied layout that references the same project resource after re-entering', async () => {
const firstRender = render(<ImageCanvasEditorView />);
await waitFor(() => {
expect(loadOrCreateRecentEditorProjectMock).toHaveBeenCalled();
});
saveEditorProjectLayoutMock.mockClear();
createEditorProjectResourceMock.mockClear();
fireEvent.contextMenu(
screen.getByAltText('画布图片:拼图素材').closest('button')!,
{
clientX: 510,
clientY: 330,
},
);
fireEvent.click(screen.getByRole('menuitem', { name: '复制' }));
fireEvent.contextMenu(screen.getByLabelText('画布工作区'), {
clientX: 360,
clientY: 240,
});
fireEvent.click(screen.getByRole('menuitem', { name: '粘贴' }));
await waitFor(() => {
expect(saveEditorProjectLayoutMock).toHaveBeenCalled();
});
const savedLayers = saveEditorProjectLayoutMock.mock.calls.at(-1)?.[1]
?.layers as Array<{ layerId: string; resourceId: string }>;
const copiedPuzzleLayouts = savedLayers.filter(
(layer) => layer.resourceId === 'resource-puzzle',
);
expect(copiedPuzzleLayouts).toHaveLength(2);
expect(
new Set(copiedPuzzleLayouts.map((layer) => layer.layerId)).size,
).toBe(2);
expect(createEditorProjectResourceMock).not.toHaveBeenCalled();
firstRender.unmount();
window.sessionStorage.clear();
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce(
withEditorProjectCanvasRevision(
{
projectId: 'editor-project-default',
title: '默认项目',
viewport: { x: 0, y: 0, scale: 1 },
layers: savedLayers,
resources: defaultEditorProjectResources,
updatedAt: '2026-08-03T00:00:00.000Z',
},
1,
),
);
render(<ImageCanvasEditorView />);
await waitFor(() => {
expect(screen.getAllByAltText(/画布图片:拼图素材/u)).toHaveLength(2);
});
});
it('restores different layer overrides for one shared resource after re-entering', async () => {
const sharedResourceProject = withEditorProjectCanvasRevision(
{
projectId: 'editor-project-default',
title: '共享资源标签项目',
viewport: { x: 0, y: 0, scale: 1 },
layers: [
{
...defaultEditorProjectLayers[0],
layerId: 'layer-shared-default',
title: '共享默认角色',
resourceId: 'resource-shared-kind',
assetKindOverride: null,
},
{
...defaultEditorProjectLayers[0],
layerId: 'layer-shared-override',
title: '共享图标覆盖',
resourceId: 'resource-shared-kind',
assetKindOverride: 'icon',
x: 1140,
},
],
resources: [
{
resourceId: 'resource-shared-kind',
projectId: 'editor-project-default',
imageSrc: '/creation-type-references/puzzle.webp',
width: 640,
height: 640,
sourceType: 'uploaded',
assetKind: 'character',
},
],
updatedAt: '2026-08-04T00:00:00.000Z',
},
1,
);
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce(
sharedResourceProject,
);
const firstRender = render(<ImageCanvasEditorView />);
const defaultLayer = await screen.findByRole('button', {
name: '选择共享默认角色',
});
const overrideLayer = await screen.findByRole('button', {
name: '选择共享图标覆盖',
});
expect(
within(defaultLayer).getByRole('button', { name: '素材标签:角色' }),
).toBeTruthy();
expect(
within(overrideLayer).getByRole('button', { name: '素材标签:图标' }),
).toBeTruthy();
firstRender.unmount();
window.sessionStorage.clear();
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce(
sharedResourceProject,
);
render(<ImageCanvasEditorView />);
const restoredDefaultLayer = await screen.findByRole('button', {
name: '选择共享默认角色',
});
const restoredOverrideLayer = await screen.findByRole('button', {
name: '选择共享图标覆盖',
});
expect(
within(restoredDefaultLayer).getByRole('button', {
name: '素材标签:角色',
}),
).toBeTruthy();
expect(
within(restoredOverrideLayer).getByRole('button', {
name: '素材标签:图标',
}),
).toBeTruthy();
});
it('drops files into the asset panel only once without creating canvas layers', async () => {
render(<ImageCanvasEditorView />);
@@ -1520,6 +1742,92 @@ describe('ImageCanvasEditorView asset library integration', () => {
expect(createEditorAssetMock).not.toHaveBeenCalled();
});
it('drags a character action onto the canvas as a saved playable sequence', async () => {
const frames = [
{
imageSrc: '/generated/action/run-frame01.png',
width: 192,
height: 256,
},
{
imageSrc: '/generated/action/run-frame02.png',
width: 192,
height: 256,
},
];
loadEditorAssetLibraryMock.mockResolvedValueOnce({
folders: [
{
folderId: 'project',
label: '项目素材',
sortOrder: 0,
collapsed: false,
systemDefault: true,
},
],
assets: [
{
assetId: 'asset-action-run',
folderId: 'project',
label: '角色奔跑',
imageSrc: frames[0]!.imageSrc,
width: 192,
height: 256,
sourceType: 'generated',
assetKind: 'character-animation',
imageSequenceFrames: frames,
imageSequenceDurationMs: 4_000,
},
],
});
render(<ImageCanvasEditorView />);
const sourceAsset = await screen.findByRole('button', {
name: '添加角色奔跑',
});
const sourceAssetRow = sourceAsset.closest(
'.image-canvas-editor__asset-row',
);
const viewport = screen.getByLabelText('画布工作区');
const dataTransfer = createDataTransferStub();
if (!sourceAssetRow) {
throw new Error('asset row should exist');
}
fireEvent.dragStart(sourceAssetRow, { dataTransfer });
fireEvent.drop(viewport, {
clientX: 520,
clientY: 300,
dataTransfer,
});
expect(await screen.findByAltText('画布序列帧:角色奔跑')).toBeTruthy();
expect(
screen.getByRole('button', { name: '暂停角色奔跑序列帧播放' }),
).toBeTruthy();
await waitFor(() => {
expect(createEditorProjectResourceMock).toHaveBeenCalledWith(
'editor-project-default',
expect.objectContaining({
assetKind: 'character-animation',
imageSequenceFrames: frames,
imageSequenceDurationMs: 4_000,
}),
);
});
await waitFor(() => {
expect(saveEditorProjectLayoutMock).toHaveBeenCalledWith(
'editor-project-default',
expect.objectContaining({
layers: expect.arrayContaining([
expect.objectContaining({
title: '角色奔跑',
}),
]),
}),
);
});
});
it('keeps the source resource id when dragging a generated asset onto the viewport', async () => {
loadEditorAssetLibraryMock.mockResolvedValueOnce({
folders: [
@@ -40,6 +40,7 @@ const loadOrCreateRecentEditorProjectMock = vi.hoisted(() => vi.fn());
const renameEditorProjectMock = vi.hoisted(() => vi.fn());
const saveEditorProjectLayoutMock = vi.hoisted(() => vi.fn());
const uploadEditorMediaAssetFileMock = vi.hoisted(() => vi.fn());
const uploadEditorMediaAssetObjectFileMock = vi.hoisted(() => vi.fn());
vi.mock('../../services/image-editor/editorProjectClient', async () => {
const actual = await vi.importActual<
@@ -74,7 +75,10 @@ vi.mock('./ImageCanvasUiAssetExtractionRasterModel', () => ({
vi.mock('../../services/image-editor/editorMediaAssetUploadClient', () => ({
uploadEditorMediaAssetFile: uploadEditorMediaAssetFileMock,
uploadEditorMediaAssetObjectFile: vi.fn(),
// 中文注释:inline 源图上传走的是 object-only 版本,不是带签名 URL 的那个。留成裸 vi.fn()
// 会返回 undefined,取 objectKey 抛的 TypeError 被 extractUiDesignAssets 的 catch 吞成
// window.alert,最终只表现为「提取接口一次都没调」,报错里看不到任何线索。
uploadEditorMediaAssetObjectFile: uploadEditorMediaAssetObjectFileMock,
}));
vi.mock('./ImageCanvasProjectCoverSnapshotRenderer', () => ({
@@ -113,6 +117,17 @@ describe('ImageCanvasEditorView generation integration', () => {
objectKey:
'generated-character-drafts/editor/generation-references/marked-ui-design.png',
assetObjectId: 'asset-object-marked-ui-design',
legacyPublicPath:
'/generated-character-drafts/editor/generation-references/marked-ui-design.png',
src: 'https://oss.example.com/marked-ui-design.png',
});
uploadEditorMediaAssetObjectFileMock.mockReset();
uploadEditorMediaAssetObjectFileMock.mockResolvedValue({
objectKey:
'generated-character-drafts/editor/generation-references/marked-ui-design.png',
assetObjectId: 'asset-object-marked-ui-design',
legacyPublicPath:
'/generated-character-drafts/editor/generation-references/marked-ui-design.png',
});
});
@@ -823,7 +838,8 @@ describe('ImageCanvasEditorView generation integration', () => {
.className,
).toContain('image-canvas-editor__reference-chip');
const generatePrompt = screen.getByLabelText('生成提示词');
expect(generatePrompt.className).toContain('platform-text-field');
expect(generatePrompt.className).toContain('auto-grow-text-area');
expect(generatePrompt.className).not.toContain('platform-text-field');
expect(generatePrompt.className).toContain(
'image-canvas-editor__generation-prompt',
);
@@ -3214,20 +3230,32 @@ describe('ImageCanvasEditorView generation integration', () => {
resources: [],
updatedAt: '2026-06-15T00:00:00.000Z',
});
const animationFrames = Array.from({ length: 48 }, (_, index) => ({
imageSrc: `/generated-character-drafts/editor/frame${index + 1}.png`,
width: 1024,
height: 1024,
}));
generateEditorCharacterAnimationMock.mockResolvedValueOnce({
taskId: 'character-animation-task-1',
model: 'seedance2.0-fast',
prompt: '生成游戏角色动画\n动作描述:\n待机',
previewVideoPath: '/generated-character-drafts/editor/preview.mp4',
frames: Array.from({ length: 48 }, (_, index) => ({
frameIndex: index + 1,
imageSrc: `/generated-character-drafts/editor/frame${index + 1}.png`,
width: 1024,
height: 1024,
})),
frames: animationFrames,
frameCount: 48,
durationSeconds: 6,
fps: 8,
resource: {
resourceId: 'resource-character-animation-final',
projectId: 'editor-project-character-animation',
imageSrc: animationFrames[0]!.imageSrc,
width: 1024,
height: 1024,
sourceType: 'generated',
sourceResourceId: 'resource-character-animation-preview',
assetKind: 'character-animation',
imageSequenceFrames: animationFrames,
imageSequenceDurationMs: 6_000,
},
});
vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
const requestUrl =
@@ -3386,7 +3414,11 @@ describe('ImageCanvasEditorView generation integration', () => {
'https://assets.test/generated-character-drafts/editor/frame1.png',
);
});
expect(screen.getByText('1/48')).toBeTruthy();
// 中文注释:分子不能钉死。序列帧图层默认自动播放(frames.length > 1 即 isPlaying),
// 48 帧 6 秒算出 125ms 的真实 setInterval,而这里用的是真实定时器下的 waitFor——
// 上一条 waitFor 在 CI 上多轮询一次就已经越过 125ms,计数器变成 2/48。要断言的是
// 「按序列帧播放器渲染、总帧数 48」(下一行排除视频元素),分母才是这条断言的意义。
expect(screen.getByText(/^\d+\/48$/u)).toBeTruthy();
expect(screen.queryByLabelText('画布视频:角色动作')).toBeNull();
expect(screen.getByText('动作')).toBeTruthy();
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -181,6 +181,8 @@ function createStageProps(): ImageCanvasStageViewProps {
contextMenuLayer: null,
contextShouldShowLayer: false,
contextShouldUnlockLayer: false,
canCopyContextLayers: true,
isCanvasClipboardCopyBlocked: false,
canUndo: false,
canRedo: false,
historyFeedback: null,
@@ -215,6 +217,7 @@ function createStageProps(): ImageCanvasStageViewProps {
onOpenRedrawPanel: vi.fn(),
onOpenCropExpandPanel: vi.fn(),
onRemoveBackground: vi.fn(),
onPerfectPixel: vi.fn(),
onSplitIconSpritesheet: vi.fn(),
onExtractUiDesignAssets: vi.fn(),
onUiAssetExtractionToolChange: vi.fn(),
@@ -1,11 +1,12 @@
import type {
EditorAssetSnapshot,
EditorCharacterAnimationFrameCount,
EditorCharacterAnimationFrameResult,
EditorCharacterAnimationGenerationResult,
EditorCharacterAnimationRatio,
EditorCharacterAnimationResolution,
EditorImageGenerationStyle,
EditorImageSequenceFrameResult,
EditorPixelArtSnapInput,
EditorVideoAspectRatio,
EditorVideoModel,
EditorVideoResolution,
@@ -15,6 +16,8 @@ import type {
export type CanvasSourceType = 'uploaded' | 'generated' | 'mock_generated';
export type CanvasAssetKind =
| 'image'
| 'audio'
| 'spec'
| 'character'
| 'character-animation'
@@ -58,7 +61,8 @@ export type EditorAsset = {
showcaseSubmitError?: string | null;
assetKind?: CanvasAssetKind | null;
generationInputs?: CanvasGenerationInputs | null;
durationSeconds?: number;
imageSequenceFrames?: EditorImageSequenceFrameResult[];
imageSequenceDurationMs?: number;
uploadStatus?: 'uploading' | 'failed';
uploadProgress?: number;
uploadMessage?: string;
@@ -84,6 +88,8 @@ export type CanvasGenerationInputs = {
export type CanvasLayer = {
id: string;
resourceId: string;
resourcePersistenceState?:
'pending' | 'registered' | 'self-contained-local' | 'unresolved-local';
title: string;
src: string;
mediaType?: CanvasMediaType;
@@ -107,10 +113,12 @@ export type CanvasLayer = {
sourceAssetId?: string | null;
generatedAssetSnapshot?: EditorAssetSnapshot | null;
groupId?: string | null;
resourceAssetKind?: CanvasAssetKind | null;
assetKindOverride?: CanvasAssetKind | null;
assetKind?: CanvasAssetKind | null;
generationInputs?: CanvasGenerationInputs | null;
durationSeconds?: number;
imageSequenceFrames?: EditorCharacterAnimationFrameResult[];
imageSequenceFrames?: EditorImageSequenceFrameResult[];
imageSequenceDurationMs?: number;
previewVideoPath?: string | null;
hidden?: boolean;
locked?: boolean;
@@ -182,6 +190,16 @@ export type PublicationMaterialsWorkflowId =
| 'publication-detail-gallery'
| 'publication-promo-poster';
export type PerfectPixelOperationSnapshot = {
version: 1;
kind: 'perfect-pixel';
operationId: string;
taskId: string;
request: EditorPixelArtSnapInput;
submittedAt: number;
reconcileUntil: number;
};
export type GenerateDialogState = {
id?: string;
mode:
@@ -199,7 +217,7 @@ export type GenerateDialogState = {
| 'audio-background-music';
prompt: string;
assetLabel?: string;
status: 'idle' | 'generating' | 'failed';
status: 'idle' | 'generating' | 'pending-confirmation' | 'failed';
composerOpen?: boolean;
sourceLayerId?: string;
generatedLayerId?: string;
@@ -237,6 +255,17 @@ export type GenerateDialogState = {
aspectRatio?: string;
imageSize?: string;
errorMessage?: string;
// 中文注释:标记该占位的收口只能由创建它的页面会话完成——链路是同步 HTTP、服务端没有
// durable job,进程一死就再没有任何东西会把它推向终态。队列型占位(去除背景恒队列、
// 图片生成默认队列)不得置位:它们的 job 在服务端继续跑,worker 会替换占位,刷新后
// 必须原样恢复。
requiresLiveSession?: boolean;
// 中文注释:请求账本只存在于本机(见 perfectPixelOperationStore),布局里只留这个 id
// 标记「该占位是一次完美像素操作」。换设备打开时标记还在、账本读不到,占位收口为可删除
// 的失败态——这是明确设计,不得据此阻断用户删除或重做。
perfectPixelOperationId?: string;
perfectPixelOperation?: PerfectPixelOperationSnapshot;
perfectPixelOperationInvalid?: boolean;
generationStartedAt?: number;
generationFinishedAt?: number;
placeholder?: {
@@ -289,6 +318,7 @@ export type CanvasHistoryActionType =
| 'generate-image'
| 'expand-image'
| 'remove-background'
| 'perfect-pixel'
| 'split-atlas'
| 'replace-image'
| 'show-image'
@@ -363,6 +393,7 @@ export type CanvasContextMenuState =
};
export type QuickEditPanelState = {
referenceUploadContextId?: string;
mode?: 'quick-edit' | 'redraw';
sourceLayerId: string;
prompt: string;
@@ -390,12 +421,7 @@ export type CropExpandPanelState = {
};
export type CropExpandRatioValue =
| 'free'
| '1:1'
| '4:3'
| '3:4'
| '16:9'
| '9:16';
'free' | '1:1' | '4:3' | '3:4' | '16:9' | '9:16';
export type CropExpandResizeHandle =
| 'left'
@@ -267,6 +267,16 @@ export function setupImageCanvasEditorViewTestLifecycle({
immediateAsync(withEditorProjectCanvasRevision({
projectId: 'editor-project-default',
title: '默认项目',
canvas: {
canvasId: 'editor-project-default:canvas:default',
projectId: 'editor-project-default',
title: '默认画布',
viewport: { x: 0, y: 0, scale: 1 },
layers: defaultEditorProjectLayers,
revision: 0,
layoutStorageVersion: 0,
updatedAt: '2026-06-12T00:00:00.000Z',
},
viewport: { x: 0, y: 0, scale: 1 },
layers: defaultEditorProjectLayers,
resources: defaultEditorProjectResources,
@@ -299,7 +309,8 @@ export function setupImageCanvasEditorViewTestLifecycle({
sourceType: input.sourceType,
assetKind: input.assetKind,
generationInputs: input.generationInputs,
durationSeconds: input.durationSeconds,
imageSequenceFrames: input.imageSequenceFrames,
imageSequenceDurationMs: input.imageSequenceDurationMs,
}));
createEditorAssetFolderMock.mockResolvedValue({
folderId: 'folder-role-persisted',
@@ -353,12 +364,20 @@ export function setupImageCanvasEditorViewTestLifecycle({
width: input.width,
height: input.height,
sourceType: input.sourceType,
durationSeconds: input.durationSeconds,
assetKind: input.assetKind,
generationInputs: input.generationInputs,
imageSequenceFrames: input.imageSequenceFrames,
imageSequenceDurationMs: input.imageSequenceDurationMs,
}),
);
saveEditorProjectLayoutMock.mockImplementation(
async (projectId, input) => ({
projectId,
canvasId: `${projectId}:canvas:default`,
revision: input.expectedRevision + 1,
updatedAt: '2026-06-12T00:00:01.000Z',
}),
);
saveEditorProjectLayoutMock.mockResolvedValue({});
});
afterEach(() => {
@@ -12,7 +12,6 @@ import userEvent from '@testing-library/user-event';
import JSZip from 'jszip';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { EditorProjectResourceSnapshot } from '../../services/image-editor/editorProjectClient';
import type { EditorAgentConversationClient } from './EditorAgentConversation/useEditorAgentConversation';
import {
ApiClientError,
@@ -1454,7 +1453,7 @@ describe('ImageCanvasEditorView', () => {
fields: [
{ title: '音效提示词', value: '金币跳出音' },
{ title: 'model', value: 'audio1.0' },
{ title: 'duration', value: '8秒' },
{ title: '时长', value: '8秒' },
],
references: [
{
@@ -2034,42 +2033,31 @@ describe('ImageCanvasEditorView', () => {
await screen.findByRole('button', { name: '素材标签:角色' });
await waitFor(() => {
expect(createEditorProjectResourceMock).toHaveBeenCalledWith(
'editor-project-default',
expect.objectContaining({
imageSrc: '/creation-type-references/puzzle.webp',
assetKind: 'character',
}),
);
});
await waitFor(() => {
const savedLayout = saveEditorProjectLayoutMock.mock.calls
.map((call) => call[1])
.find((layout) =>
layout.layers.some(
(layer: { resourceId?: unknown; title?: unknown }) =>
(layer: {
resourceId?: unknown;
title?: unknown;
assetKindOverride?: unknown;
}) =>
layer.title === '拼图素材' &&
layer.resourceId === 'resource-editor-project-default-640',
layer.resourceId === 'resource-puzzle' &&
layer.assetKindOverride === 'character',
),
);
expect(savedLayout).toBeTruthy();
expect(savedLayout.layers).toEqual(
expect.arrayContaining([
expect.not.objectContaining({
title: '拼图素材',
assetKind: expect.anything(),
}),
]),
);
});
expect(createEditorProjectResourceMock).not.toHaveBeenCalled();
});
it('keeps atlas splitting busy until the new asset kind resource is confirmed', async () => {
const persistedResource = createDeferred<EditorProjectResourceSnapshot>();
createEditorProjectResourceMock.mockImplementationOnce(
() => persistedResource.promise,
it('flushes the layer override before splitting without replacing its resource', async () => {
const savedLayout = createDeferred<unknown>();
saveEditorProjectLayoutMock.mockImplementationOnce(
() => savedLayout.promise,
);
render(<ImageCanvasEditorView />);
@@ -2087,80 +2075,48 @@ describe('ImageCanvasEditorView', () => {
),
);
const pendingSplitButton = await screen.findByRole('button', {
name: '素材类型保存中',
});
expect((pendingSplitButton as HTMLButtonElement).disabled).toBe(true);
expect(pendingSplitButton.getAttribute('aria-busy')).toBe('true');
fireEvent.click(pendingSplitButton);
const splitButton = await screen.findByRole('button', { name: '拆分图集' });
fireEvent.click(splitButton);
expect(splitEditorIconSpritesheetMock).not.toHaveBeenCalled();
await act(async () => {
persistedResource.resolve({
resourceId: 'resource-confirmed-atlas',
projectId: 'editor-project-default',
imageSrc: '/creation-type-references/puzzle.webp',
width: 640,
height: 640,
sourceType: 'uploaded',
assetKind: 'icon-spritesheet',
});
await persistedResource.promise;
savedLayout.resolve({});
await savedLayout.promise;
});
const splitButton = await screen.findByRole('button', {
name: '拆分图集',
});
expect((splitButton as HTMLButtonElement).disabled).toBe(false);
fireEvent.click(splitButton);
await waitFor(() => {
expect(splitEditorIconSpritesheetMock).toHaveBeenCalledWith(
expect.objectContaining({
projectId: 'editor-project-default',
sourceResourceId: 'resource-confirmed-atlas',
sourceLayerId: 'layer-puzzle',
sourceResourceId: 'resource-puzzle',
}),
);
});
expect(createEditorProjectResourceMock).not.toHaveBeenCalled();
});
it('rolls back the asset kind and resource when persistence fails', async () => {
const persistedResource = createDeferred<EditorProjectResourceSnapshot>();
createEditorProjectResourceMock.mockImplementationOnce(
() => persistedResource.promise,
);
it('undoes and redoes only the layer override while keeping the resource stable', async () => {
render(<ImageCanvasEditorView />);
const layerButton = await screen.findByRole('button', {
name: '选择拼图素材',
});
fireEvent.click(layerButton);
fireEvent.click(
within(layerButton).getByRole('button', { name: '素材标签:未知' }),
);
fireEvent.click(
within(screen.getByRole('menu', { name: '选择素材标签' })).getByRole(
'menuitem',
{ name: '图集' },
{ name: '角色' },
),
);
await screen.findByRole('button', { name: '素材类型保存中' });
await act(async () => {
persistedResource.reject(new Error('资源类型写入失败'));
try {
await persistedResource.promise;
} catch {
// The component consumes the rejection and restores the prior layer.
}
});
await within(layerButton).findByRole('button', { name: '素材标签:角色' });
fireEvent.click(screen.getByRole('button', { name: '撤销' }));
await within(layerButton).findByRole('button', {
name: '素材标签:未知',
});
expect(
screen.getByText('素材类型保存失败,已恢复原标签:资源类型写入失败'),
).toBeTruthy();
expect(screen.queryByRole('button', { name: '拆分图集' })).toBeNull();
await waitFor(() => {
const latestLayout = saveEditorProjectLayoutMock.mock.calls.at(-1)?.[1];
expect(latestLayout.layers).toEqual(
@@ -2168,45 +2124,13 @@ describe('ImageCanvasEditorView', () => {
expect.objectContaining({
layerId: 'layer-puzzle',
resourceId: 'resource-puzzle',
assetKindOverride: null,
}),
]),
);
});
});
it('rolls back when the resource response keeps the old asset kind', async () => {
createEditorProjectResourceMock.mockResolvedValueOnce({
resourceId: 'resource-stale-icon-spec',
projectId: 'editor-project-default',
imageSrc: '/creation-type-references/puzzle.webp',
width: 640,
height: 640,
sourceType: 'uploaded',
assetKind: 'icon-spec',
});
render(<ImageCanvasEditorView />);
const layerButton = await screen.findByRole('button', {
name: '选择拼图素材',
});
fireEvent.click(layerButton);
fireEvent.click(
within(layerButton).getByRole('button', { name: '素材标签:未知' }),
);
fireEvent.click(
within(screen.getByRole('menu', { name: '选择素材标签' })).getByRole(
'menuitem',
{ name: '图集' },
),
);
await within(layerButton).findByRole('button', { name: '素材标签:未知' });
expect(
screen.getByText(
'素材类型保存失败,已恢复原标签:服务器未确认新的素材类型',
),
).toBeTruthy();
expect(screen.queryByRole('button', { name: '拆分图集' })).toBeNull();
fireEvent.click(screen.getByRole('button', { name: '恢复' }));
await within(layerButton).findByRole('button', { name: '素材标签:角色' });
await waitFor(() => {
const latestLayout = saveEditorProjectLayoutMock.mock.calls.at(-1)?.[1];
expect(latestLayout.layers).toEqual(
@@ -2214,249 +2138,12 @@ describe('ImageCanvasEditorView', () => {
expect.objectContaining({
layerId: 'layer-puzzle',
resourceId: 'resource-puzzle',
assetKindOverride: 'character',
}),
]),
);
});
});
it('persists an undone asset kind and ignores the older pending response', async () => {
const characterResource = createDeferred<EditorProjectResourceSnapshot>();
const restoredResource = createDeferred<EditorProjectResourceSnapshot>();
createEditorProjectResourceMock
.mockImplementationOnce(() => characterResource.promise)
.mockImplementationOnce(() => restoredResource.promise);
render(<ImageCanvasEditorView />);
const layerButton = await screen.findByRole('button', {
name: '选择拼图素材',
});
fireEvent.click(
within(layerButton).getByRole('button', { name: '素材标签:未知' }),
);
fireEvent.click(
within(screen.getByRole('menu', { name: '选择素材标签' })).getByRole(
'menuitem',
{ name: '角色' },
),
);
await screen.findByRole('button', { name: '素材标签:角色' });
fireEvent.click(screen.getByRole('button', { name: '撤销' }));
await within(layerButton).findByRole('button', { name: '素材标签:未知' });
await waitFor(() => {
expect(createEditorProjectResourceMock).toHaveBeenCalledTimes(2);
});
expect(createEditorProjectResourceMock).toHaveBeenNthCalledWith(
2,
'editor-project-default',
expect.objectContaining({ assetKind: null }),
);
await act(async () => {
restoredResource.resolve({
resourceId: 'resource-restored-kind',
projectId: 'editor-project-default',
imageSrc: '/creation-type-references/puzzle.webp',
width: 640,
height: 640,
sourceType: 'uploaded',
assetKind: null,
});
await restoredResource.promise;
});
await waitFor(() => {
expect(
saveEditorProjectLayoutMock.mock.calls.some((call) =>
call[1].layers.some(
(layer: { resourceId?: unknown }) =>
layer.resourceId === 'resource-restored-kind',
),
),
).toBe(true);
});
await act(async () => {
characterResource.resolve({
resourceId: 'resource-stale-character',
projectId: 'editor-project-default',
imageSrc: '/creation-type-references/puzzle.webp',
width: 640,
height: 640,
sourceType: 'uploaded',
assetKind: 'character',
});
await characterResource.promise;
});
expect(
saveEditorProjectLayoutMock.mock.calls.some((call) =>
call[1].layers.some(
(layer: { resourceId?: unknown }) =>
layer.resourceId === 'resource-stale-character',
),
),
).toBe(false);
});
it('rolls back an overlapping undo failure to the last confirmed resource', async () => {
const staleCharacterResource =
createDeferred<EditorProjectResourceSnapshot>();
const failedUndoResource = createDeferred<EditorProjectResourceSnapshot>();
createEditorProjectResourceMock
.mockImplementationOnce(() => staleCharacterResource.promise)
.mockImplementationOnce(() => failedUndoResource.promise);
render(<ImageCanvasEditorView />);
const layerButton = await screen.findByRole('button', {
name: '选择拼图素材',
});
fireEvent.click(
within(layerButton).getByRole('button', { name: '素材标签:未知' }),
);
fireEvent.click(
within(screen.getByRole('menu', { name: '选择素材标签' })).getByRole(
'menuitem',
{ name: '角色' },
),
);
await within(layerButton).findByRole('button', {
name: '素材标签:角色',
});
fireEvent.click(screen.getByRole('button', { name: '撤销' }));
await within(layerButton).findByRole('button', { name: '素材标签:未知' });
await waitFor(() => {
expect(createEditorProjectResourceMock).toHaveBeenCalledTimes(2);
});
await act(async () => {
failedUndoResource.reject(new Error('撤销后资源写入失败'));
try {
await failedUndoResource.promise;
} catch {
// The latest failure must restore the server-confirmed original layer.
}
});
await within(layerButton).findByRole('button', { name: '素材标签:未知' });
expect(
screen.getByText('素材类型保存失败,已恢复原标签:撤销后资源写入失败'),
).toBeTruthy();
await waitFor(() => {
const latestLayout = saveEditorProjectLayoutMock.mock.calls.at(-1)?.[1];
expect(latestLayout.layers).toEqual(
expect.arrayContaining([
expect.objectContaining({
layerId: 'layer-puzzle',
resourceId: 'resource-puzzle',
}),
]),
);
});
const undoButton = screen.getByRole('button', { name: '撤销' });
expect((undoButton as HTMLButtonElement).disabled).toBe(true);
fireEvent.click(undoButton);
expect(createEditorProjectResourceMock).toHaveBeenCalledTimes(2);
await act(async () => {
staleCharacterResource.resolve({
resourceId: 'resource-stale-character-after-failed-undo',
projectId: 'editor-project-default',
imageSrc: '/creation-type-references/puzzle.webp',
width: 640,
height: 640,
sourceType: 'uploaded',
assetKind: 'character',
});
await staleCharacterResource.promise;
});
expect(
within(layerButton).getByRole('button', { name: '素材标签:未知' }),
).toBeTruthy();
expect(
saveEditorProjectLayoutMock.mock.calls.some((call) =>
call[1].layers.some(
(layer: { resourceId?: unknown }) =>
layer.resourceId === 'resource-stale-character-after-failed-undo',
),
),
).toBe(false);
});
it('keeps the newer asset kind pending when an older request fails', async () => {
const staleCharacterResource =
createDeferred<EditorProjectResourceSnapshot>();
const currentAtlasResource =
createDeferred<EditorProjectResourceSnapshot>();
createEditorProjectResourceMock
.mockImplementationOnce(() => staleCharacterResource.promise)
.mockImplementationOnce(() => currentAtlasResource.promise);
render(<ImageCanvasEditorView />);
const layerButton = await screen.findByRole('button', {
name: '选择拼图素材',
});
fireEvent.click(layerButton);
fireEvent.click(
within(layerButton).getByRole('button', { name: '素材标签:未知' }),
);
fireEvent.click(
within(screen.getByRole('menu', { name: '选择素材标签' })).getByRole(
'menuitem',
{ name: '角色' },
),
);
await within(layerButton).findByRole('button', {
name: '素材标签:角色',
});
fireEvent.click(
within(layerButton).getByRole('button', { name: '素材标签:角色' }),
);
fireEvent.click(
within(screen.getByRole('menu', { name: '选择素材标签' })).getByRole(
'menuitem',
{ name: '图集' },
),
);
await screen.findByRole('button', { name: '素材类型保存中' });
await act(async () => {
staleCharacterResource.reject(new Error('旧请求失败'));
try {
await staleCharacterResource.promise;
} catch {
// The stale rejection must not affect the newer request state.
}
});
expect(
within(layerButton).getByRole('button', { name: '素材标签:图集' }),
).toBeTruthy();
const stillPendingButton = screen.getByRole('button', {
name: '素材类型保存中',
});
expect((stillPendingButton as HTMLButtonElement).disabled).toBe(true);
expect(screen.queryByText(/素材类型保存失败/u)).toBeNull();
await act(async () => {
currentAtlasResource.resolve({
resourceId: 'resource-current-atlas',
projectId: 'editor-project-default',
imageSrc: '/creation-type-references/puzzle.webp',
width: 640,
height: 640,
sourceType: 'uploaded',
assetKind: 'icon-spritesheet',
});
await currentAtlasResource.promise;
});
expect(
await screen.findByRole('button', { name: '拆分图集' }),
).toBeTruthy();
expect(createEditorProjectResourceMock).not.toHaveBeenCalled();
});
it('offers Lovart-style zoom menu commands', async () => {
@@ -2582,10 +2269,10 @@ describe('ImageCanvasEditorView', () => {
expect(
bigFishLayer.classList.contains('image-canvas-editor__layer--selected'),
).toBe(false);
fireEvent.contextMenu(
within(messageLog).getByRole('presentation'),
{ clientX: 30, clientY: 40 },
);
fireEvent.contextMenu(within(messageLog).getByRole('presentation'), {
clientX: 30,
clientY: 40,
});
fireEvent.click(screen.getByRole('menuitem', { name: '在画布中定位' }));
expect(world.style.transform).toBe(initialTransform);
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