合并最新主分支并同步审核修复

合入画布副本持久化与素材类型覆盖变更
合入画布编辑器多行输入框样式调整
保留资源管理审核修复与项目记忆更新

# Conflicts:
#	docs/project-memory/shared-memory/decision-log.md
This commit is contained in:
2026-08-04 18:40:45 +08:00
52 changed files with 2565 additions and 1062 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,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,
@@ -247,14 +247,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) =>
@@ -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,
@@ -254,14 +254,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) =>
@@ -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,
@@ -871,37 +873,39 @@ describe('ImageCanvasEditorView asset library integration', () => {
it('saves the canvas layout after deleting an asset linked by source resource id', async () => {
const user = userEvent.setup();
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce(withEditorProjectCanvasRevision({
projectId: 'editor-project-default',
title: '生成资源项目',
viewport: { x: 0, y: 0, scale: 1 },
layers: [
{
layerId: 'layer-generated-a',
resourceId: 'resource-generated-a',
title: '生成资源A',
x: 100,
y: 120,
width: 320,
height: 240,
originalWidth: 320,
originalHeight: 240,
zIndex: 1,
sourceType: 'generated',
},
],
resources: [
{
resourceId: 'resource-generated-a',
projectId: 'editor-project-default',
imageSrc: '/editor-test/project/generated-a.png',
width: 320,
height: 240,
sourceType: 'generated',
},
],
updatedAt: '2026-06-12T00:00:00.000Z',
}));
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce(
withEditorProjectCanvasRevision({
projectId: 'editor-project-default',
title: '生成资源项目',
viewport: { x: 0, y: 0, scale: 1 },
layers: [
{
layerId: 'layer-generated-a',
resourceId: 'resource-generated-a',
title: '生成资源A',
x: 100,
y: 120,
width: 320,
height: 240,
originalWidth: 320,
originalHeight: 240,
zIndex: 1,
sourceType: 'generated',
},
],
resources: [
{
resourceId: 'resource-generated-a',
projectId: 'editor-project-default',
imageSrc: '/editor-test/project/generated-a.png',
width: 320,
height: 240,
sourceType: 'generated',
},
],
updatedAt: '2026-06-12T00:00:00.000Z',
}),
);
loadEditorAssetLibraryMock.mockResolvedValueOnce({
folders: [
{
@@ -947,14 +951,15 @@ describe('ImageCanvasEditorView asset library integration', () => {
expect(screen.queryByAltText('画布图片:生成资源A')).toBeNull();
});
await waitFor(() => {
const savedLayoutWithoutDeletedLayer = saveEditorProjectLayoutMock.mock.calls
.map((call) => call[1])
.find((layout) =>
layout.layers.every(
(item: { layerId?: unknown }) =>
item.layerId !== 'layer-generated-a',
),
);
const savedLayoutWithoutDeletedLayer =
saveEditorProjectLayoutMock.mock.calls
.map((call) => call[1])
.find((layout) =>
layout.layers.every(
(item: { layerId?: unknown }) =>
item.layerId !== 'layer-generated-a',
),
);
expect(savedLayoutWithoutDeletedLayer).toBeTruthy();
});
expect(deleteEditorAssetMock).toHaveBeenCalledWith('asset-generated-a');
@@ -965,21 +970,25 @@ describe('ImageCanvasEditorView asset library integration', () => {
createEditorProjectResourceMock.mockResolvedValueOnce({
resourceId: 'resource-added-asset-a',
projectId: 'editor-project-default',
imageSrc: '/generated-character-drafts/editor/asset-library/image/asset-a.png',
objectKey: 'generated-character-drafts/editor/asset-library/image/asset-a.png',
imageSrc:
'/generated-character-drafts/editor/asset-library/image/asset-a.png',
objectKey:
'generated-character-drafts/editor/asset-library/image/asset-a.png',
assetObjectId: 'assetobj-asset-a',
width: 320,
height: 240,
sourceType: 'uploaded',
});
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce(withEditorProjectCanvasRevision({
projectId: 'editor-project-default',
title: '空画布项目',
viewport: { x: 0, y: 0, scale: 1 },
layers: [],
resources: [],
updatedAt: '2026-06-12T00:00:00.000Z',
}));
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce(
withEditorProjectCanvasRevision({
projectId: 'editor-project-default',
title: '空画布项目',
viewport: { x: 0, y: 0, scale: 1 },
layers: [],
resources: [],
updatedAt: '2026-06-12T00:00:00.000Z',
}),
);
loadEditorAssetLibraryMock.mockResolvedValueOnce({
folders: [
{
@@ -1109,7 +1118,6 @@ describe('ImageCanvasEditorView asset library integration', () => {
expect(within(batchToolbar).getByText(/ 2/u)).toBeTruthy();
});
it('drops an image file on the canvas as a new canvas layer', async () => {
render(<ImageCanvasEditorView />);
await waitFor(() => {
@@ -1141,9 +1149,9 @@ describe('ImageCanvasEditorView asset library integration', () => {
);
expect(screen.getByRole('heading', { name: '素材' })).toBeTruthy();
expect(
screen.getByRole('button', { name: '打开素材' }).getAttribute(
'aria-pressed',
),
screen
.getByRole('button', { name: '打开素材' })
.getAttribute('aria-pressed'),
).toBe('true');
expect(
screen
@@ -1287,6 +1295,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 />);
@@ -1410,5 +1557,4 @@ describe('ImageCanvasEditorView asset library integration', () => {
});
expect(createEditorAssetMock).not.toHaveBeenCalled();
});
});
@@ -823,7 +823,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',
);
@@ -11,6 +11,7 @@ import {
hydrateLayer,
normalizeAssetLibrary,
normalizeCanvasBackgroundHex,
resolveLayerResourceAssetKind,
resolveSnappedLayerPosition,
serializeCanvasLayout,
serializeLayer,
@@ -25,6 +26,33 @@ import type {
} from './ImageCanvasEditorTypes';
describe('ImageCanvasEditorModel', () => {
it('keeps the resource default kind separate from a layer override', () => {
const layer = {
id: 'layer-shared',
resourceId: 'resource-shared',
title: '共享素材',
src: '/shared.png',
x: 0,
y: 0,
width: 100,
height: 100,
originalWidth: 100,
originalHeight: 100,
zIndex: 1,
sourceType: 'uploaded',
resourceAssetKind: 'character',
assetKindOverride: 'icon',
assetKind: 'icon',
} satisfies CanvasLayer;
expect(resolveLayerResourceAssetKind(layer)).toBe('character');
expect(serializeLayer(layer)).toMatchObject({
resourceId: 'resource-shared',
assetKindOverride: 'icon',
});
expect(serializeLayer(layer)).not.toHaveProperty('assetKind');
});
it('maps viewport scale to the user-facing canvas zoom scale', () => {
expect(canvasDisplayScaleToViewportScale(0.5)).toBe(0.25);
expect(canvasDisplayScaleToViewportScale(1)).toBe(0.5);
@@ -39,9 +67,9 @@ describe('ImageCanvasEditorModel', () => {
expect(
viewportToCanvasDisplayViewport({ x: 10, y: 20, scale: 0.25 }),
).toEqual({ x: 10, y: 20, scale: 0.5 });
expect(
canvasDisplayViewportToViewport({ x: 10, y: 20, scale: 2 }),
).toEqual({ x: 10, y: 20, scale: 1 });
expect(canvasDisplayViewportToViewport({ x: 10, y: 20, scale: 2 })).toEqual(
{ x: 10, y: 20, scale: 1 },
);
});
it('normalizes valid canvas background hex values and rejects invalid input', () => {
@@ -83,7 +111,9 @@ describe('ImageCanvasEditorModel', () => {
});
expect(layout).toEqual([]);
expect(splitCanvasLayoutItems(layout).canvasBackgroundColor).toBeUndefined();
expect(
splitCanvasLayoutItems(layout).canvasBackgroundColor,
).toBeUndefined();
expect(DEFAULT_CANVAS_BACKGROUND_COLOR).toBe('#f8fafc');
});
@@ -128,7 +158,8 @@ describe('ImageCanvasEditorModel', () => {
assetId: 'asset-audio',
folderId: 'project',
label: '胜利音效.mp3',
imageSrc: '/generated-character-drafts/editor/asset-library/audio/胜利音效.mp3',
imageSrc:
'/generated-character-drafts/editor/asset-library/audio/胜利音效.mp3',
width: 420,
height: 120,
sourceType: 'uploaded',
@@ -139,7 +170,8 @@ describe('ImageCanvasEditorModel', () => {
assetId: 'asset-video',
folderId: 'project',
label: '开场动画.mp4',
imageSrc: '/generated-character-drafts/editor/asset-library/video/开场动画.mp4',
imageSrc:
'/generated-character-drafts/editor/asset-library/video/开场动画.mp4',
thumbnailSrc:
'/generated-character-drafts/editor/asset-library/video/开场动画-cover.png',
width: 560,
@@ -252,7 +284,8 @@ describe('ImageCanvasEditorModel', () => {
assetId: 'asset-generated',
folderId: 'project',
label: '生成素材',
imageSrc: '/generated-character-drafts/editor/asset-library/generated.png',
imageSrc:
'/generated-character-drafts/editor/asset-library/generated.png',
width: 640,
height: 640,
sourceType: 'generated',
@@ -284,6 +317,8 @@ describe('ImageCanvasEditorModel', () => {
objectKey: 'generated/object.png',
model: 'birefnet',
sourceResourceId: 'resource-provider-source',
resourceAssetKind: 'character',
assetKindOverride: null,
assetKind: 'character',
generationInputs: {
fields: [{ title: '角色设定', value: '骑士' }],
@@ -295,6 +330,7 @@ describe('ImageCanvasEditorModel', () => {
const snapshot = serializeLayer(layer);
expect(snapshot).not.toHaveProperty('src');
expect(snapshot).not.toHaveProperty('assetKind');
expect(snapshot.assetKindOverride).toBeNull();
expect(snapshot).not.toHaveProperty('generationInputs');
const hydrated = hydrateLayer(
@@ -327,6 +363,8 @@ describe('ImageCanvasEditorModel', () => {
id: 'layer-generated',
src: '/read/generated.png',
sourceType: 'generated',
resourceAssetKind: 'character',
assetKindOverride: null,
assetKind: 'character',
objectKey: 'generated/object.png',
model: 'gpt-image-2',
@@ -335,6 +373,55 @@ describe('ImageCanvasEditorModel', () => {
expect(hydrated?.generationInputs?.fields[0]?.value).toBe('骑士');
});
it('distinguishes persisted self-contained sequences from unresolved local resources', () => {
const validSequence = {
layerId: 'layer-sequence',
resourceId: 'local-resource-sequence',
title: '历史动作',
src: '/generated/sequence/frame-1.png',
sourceType: 'generated',
mediaType: 'image-sequence',
imageSequenceFrames: [
{
frameIndex: 1,
imageSrc: '/generated/sequence/frame-1.png',
objectKey: 'generated/sequence/frame-1.png',
width: 320,
height: 240,
},
],
thumbnailSrc: '/generated/sequence/thumbnail.png',
previewVideoPath: '/generated/sequence/preview.mp4',
};
expect(hydrateLayer(validSequence, new Map())).toMatchObject({
resourcePersistenceState: 'self-contained-local',
});
expect(
hydrateLayer(
{
...validSequence,
imageSequenceFrames: [
{ ...validSequence.imageSequenceFrames[0], frameIndex: 0 },
],
},
new Map(),
),
).toMatchObject({ resourcePersistenceState: 'unresolved-local' });
expect(
hydrateLayer(
{
layerId: 'layer-unresolved',
resourceId: 'local-resource-unresolved',
title: '未登记图片',
src: '/generated/unresolved.png',
sourceType: 'uploaded',
},
new Map(),
),
).toMatchObject({ resourcePersistenceState: 'unresolved-local' });
});
it('recovers the normal source model after the owner payload removes an internal model', () => {
const hydrated = hydrateLayer(
{
@@ -391,8 +478,10 @@ describe('ImageCanvasEditorModel', () => {
[
'resource-uploaded',
{
imageSrc: '/generated-character-drafts/editor/asset-library/image.png',
objectKey: 'generated-character-drafts/editor/asset-library/image.png',
imageSrc:
'/generated-character-drafts/editor/asset-library/image.png',
objectKey:
'generated-character-drafts/editor/asset-library/image.png',
assetObjectId: 'asset-object-uploaded',
sourceResourceId: 'resource-source',
},
@@ -409,7 +498,7 @@ describe('ImageCanvasEditorModel', () => {
});
});
it('hydrates manually selected publication material tags from saved layout', () => {
it('hydrates a layer override ahead of the shared resource default', () => {
const hydrated = hydrateLayer(
{
layerId: 'layer-publication',
@@ -423,13 +512,20 @@ describe('ImageCanvasEditorModel', () => {
originalHeight: 1024,
zIndex: 5,
sourceType: 'generated',
assetKind: 'publication-material',
assetKindOverride: 'publication-material',
},
new Map([['resource-publication', { imageSrc: '/read/publication.png' }]]),
new Map([
[
'resource-publication',
{ imageSrc: '/read/publication.png', assetKind: 'ui-design' },
],
]),
);
expect(hydrated).toMatchObject({
id: 'layer-publication',
resourceAssetKind: 'ui-design',
assetKindOverride: 'publication-material',
assetKind: 'publication-material',
});
});
@@ -708,12 +804,16 @@ describe('ImageCanvasEditorModel', () => {
});
expect(
JSON.stringify(
layout.find((item) => item.resourceId === 'generation-dialog:generation-dialog-9'),
layout.find(
(item) => item.resourceId === 'generation-dialog:generation-dialog-9',
),
),
).not.toContain('data:image');
expect(
JSON.stringify(
layout.find((item) => item.resourceId === 'generation-dialog:generation-dialog-9'),
layout.find(
(item) => item.resourceId === 'generation-dialog:generation-dialog-9',
),
),
).toContain('ref:project-resource:resource-reference-1');
@@ -181,6 +181,7 @@ export function createLayerFromAsset(
return {
id: `layer-${asset.id}-${index}`,
resourceId: `local-resource-${asset.id}-${index}`,
resourcePersistenceState: 'pending',
title: asset.label,
src: asset.src,
mediaType: asset.mediaType,
@@ -203,7 +204,9 @@ export function createLayerFromAsset(
sourceResourceId: asset.sourceResourceId,
durationSeconds: asset.durationSeconds,
sourceAssetId: asset.id,
assetKind: asset.assetKind ?? assetKind,
resourceAssetKind: asset.assetKind ?? assetKind ?? null,
assetKindOverride: null,
assetKind: asset.assetKind ?? assetKind ?? null,
generationInputs: asset.generationInputs,
} satisfies CanvasLayer;
}
@@ -258,7 +261,10 @@ function serializeImageSequenceFrames(
return undefined;
}
const serializedFrames = frames.flatMap((frame) => {
const imageSrc = serializeOptionalMediaSource(frame.imageSrc, frame.objectKey);
const imageSrc = serializeOptionalMediaSource(
frame.imageSrc,
frame.objectKey,
);
return imageSrc
? [
{
@@ -271,9 +277,7 @@ function serializeImageSequenceFrames(
return serializedFrames.length ? serializedFrames : undefined;
}
export function serializeLayer(
layer: CanvasLayer,
): EditorProjectLayerSnapshot {
export function serializeLayer(layer: CanvasLayer): EditorProjectLayerSnapshot {
return {
layerId: layer.id,
resourceId: layer.resourceId,
@@ -291,7 +295,9 @@ export function serializeLayer(
layer.thumbnailSrc,
layer.objectKey,
),
imageSequenceFrames: serializeImageSequenceFrames(layer.imageSequenceFrames),
imageSequenceFrames: serializeImageSequenceFrames(
layer.imageSequenceFrames,
),
previewVideoPath: serializeOptionalMediaSource(
layer.previewVideoPath,
layer.objectKey,
@@ -307,6 +313,7 @@ export function serializeLayer(
sourceResourceId: layer.sourceResourceId,
sourceAssetId: layer.sourceAssetId,
groupId: layer.groupId,
assetKindOverride: layer.assetKindOverride ?? null,
hidden: layer.hidden,
locked: layer.locked,
flipX: layer.flipX,
@@ -332,8 +339,8 @@ function isPersistedReferenceResourceId(resourceId: string | null | undefined) {
const normalizedResourceId = resourceId?.trim();
return Boolean(
normalizedResourceId &&
!normalizedResourceId.startsWith('local-') &&
!normalizedResourceId.startsWith('generation-dialog:'),
!normalizedResourceId.startsWith('local-') &&
!normalizedResourceId.startsWith('generation-dialog:'),
);
}
@@ -487,9 +494,7 @@ export function isCanvasGenerationDialogLayoutItem(
return (
item.itemType === 'generation-dialog' &&
Boolean(
hydrateCanvasGenerationDialog(
(item as { dialog?: unknown }).dialog,
),
hydrateCanvasGenerationDialog((item as { dialog?: unknown }).dialog),
)
);
}
@@ -570,9 +575,7 @@ export function hydrateCanvasGenerationDialog(
prompt,
status: isGenerationStatus(snapshot.status) ? snapshot.status : 'idle',
composerOpen:
typeof snapshot.composerOpen === 'boolean'
? snapshot.composerOpen
: true,
typeof snapshot.composerOpen === 'boolean' ? snapshot.composerOpen : true,
sourceLayerId: stringOrUndefined(snapshot.sourceLayerId),
generatedLayerId: stringOrUndefined(snapshot.generatedLayerId),
specType: isSpecGenerationType(snapshot.specType)
@@ -629,9 +632,7 @@ export function hydrateCanvasGenerationDialog(
imageModel: stringOrUndefined(snapshot.imageModel),
style,
videoModel:
typeof snapshot.videoModel === 'string'
? snapshot.videoModel
: undefined,
typeof snapshot.videoModel === 'string' ? snapshot.videoModel : undefined,
videoAspectRatio: videoAspectRatioOrUndefined(snapshot.videoAspectRatio),
videoResolution:
snapshot.videoResolution === '480p' ||
@@ -719,10 +720,26 @@ export function hydrateLayer(
if (!resourceId || !layerId || !src) {
return null;
}
const resourceAssetKind = canvasAssetKindOrNull(resource?.assetKind);
const assetKindOverride = Object.prototype.hasOwnProperty.call(
snapshot,
'assetKindOverride',
)
? canvasAssetKindOrNull(snapshot.assetKindOverride)
: canvasAssetKindOrNull(snapshot.assetKind);
const isSelfContainedLocalResource =
!resource && isSelfContainedLegacyLocalImageSequence(snapshot);
return {
id: layerId,
resourceId,
resourcePersistenceState: resource
? 'registered'
: isSelfContainedLocalResource
? 'self-contained-local'
: resourceId.startsWith('local-')
? 'unresolved-local'
: undefined,
title,
src,
x: numberFromSnapshot(snapshot.x, 0),
@@ -751,13 +768,17 @@ export function hydrateLayer(
previewVideoPath: stringOrNull(snapshot.previewVideoPath),
prompt: stringOrNull(snapshot.prompt) ?? stringOrNull(resource?.prompt),
actualPrompt:
stringOrNull(snapshot.actualPrompt) ?? stringOrNull(resource?.actualPrompt),
stringOrNull(snapshot.actualPrompt) ??
stringOrNull(resource?.actualPrompt),
model: resolveHydratedLayerModel(snapshot, resource, resourcesById),
provider: stringOrNull(snapshot.provider) ?? stringOrNull(resource?.provider),
provider:
stringOrNull(snapshot.provider) ?? stringOrNull(resource?.provider),
taskId: stringOrNull(snapshot.taskId) ?? stringOrNull(resource?.taskId),
objectKey: stringOrNull(snapshot.objectKey) ?? stringOrNull(resource?.objectKey),
objectKey:
stringOrNull(snapshot.objectKey) ?? stringOrNull(resource?.objectKey),
assetObjectId:
stringOrNull(snapshot.assetObjectId) ?? stringOrNull(resource?.assetObjectId),
stringOrNull(snapshot.assetObjectId) ??
stringOrNull(resource?.assetObjectId),
durationSeconds:
audioDurationOrNull(snapshot.durationSeconds) ??
audioDurationOrNull(resource?.durationSeconds),
@@ -766,9 +787,9 @@ export function hydrateLayer(
stringOrNull(resource?.sourceResourceId),
sourceAssetId: stringOrNull(snapshot.sourceAssetId),
groupId: stringOrNull(snapshot.groupId),
assetKind:
canvasAssetKindOrNull(resource?.assetKind) ??
canvasAssetKindOrNull(snapshot.assetKind),
resourceAssetKind,
assetKindOverride,
assetKind: assetKindOverride ?? resourceAssetKind,
generationInputs:
generationInputsOrNull(resource?.generationInputs) ??
generationInputsOrNull(snapshot.generationInputs),
@@ -779,6 +800,120 @@ export function hydrateLayer(
};
}
function isSelfContainedLegacyLocalImageSequence(
snapshot: EditorProjectLayerSnapshot,
) {
if (
!snapshot.resourceId.startsWith('local-') ||
snapshot.sourceType !== 'generated' ||
snapshot.mediaType !== 'image-sequence'
) {
return false;
}
if (
!isEmptyLegacyLocalSequenceField(snapshot.imageSrc) ||
!isEmptyLegacyLocalSequenceField(snapshot.objectKey) ||
!isEmptyLegacyLocalSequenceField(snapshot.assetObjectId)
) {
return false;
}
if (
!Array.isArray(snapshot.imageSequenceFrames) ||
snapshot.imageSequenceFrames.length === 0
) {
return false;
}
let firstFrameSrc = '';
for (const [index, value] of snapshot.imageSequenceFrames.entries()) {
if (!value || typeof value !== 'object') {
return false;
}
const frame = value as Record<string, unknown>;
const imageSrc =
typeof frame.imageSrc === 'string' ? frame.imageSrc.trim() : '';
if (
frame.frameIndex !== index + 1 ||
typeof frame.width !== 'number' ||
!Number.isFinite(frame.width) ||
frame.width <= 0 ||
typeof frame.height !== 'number' ||
!Number.isFinite(frame.height) ||
frame.height <= 0 ||
!isStableLegacyCanvasObjectPath(imageSrc)
) {
return false;
}
if (index === 0) {
firstFrameSrc = imageSrc;
}
if (frame.objectKey !== undefined) {
if (
typeof frame.objectKey !== 'string' ||
!isStableLegacyCanvasObjectKey(frame.objectKey) ||
frame.objectKey.trim() !== imageSrc.replace(/^\/+/, '')
) {
return false;
}
}
}
const source = typeof snapshot.src === 'string' ? snapshot.src.trim() : '';
if (
source &&
(!isStableLegacyCanvasObjectPath(source) || source !== firstFrameSrc)
) {
return false;
}
return [snapshot.thumbnailSrc, snapshot.previewVideoPath].every(
(value) =>
value === undefined ||
value === null ||
(typeof value === 'string' && isStableLegacyCanvasObjectPath(value)),
);
}
function isEmptyLegacyLocalSequenceField(value: unknown) {
return (
value === undefined ||
value === null ||
(typeof value === 'string' && !value.trim())
);
}
function isStableLegacyCanvasObjectPath(value: string) {
const normalized = value.trim();
return (
normalized.startsWith('/') &&
!normalized.startsWith('//') &&
!normalized.includes('?') &&
!normalized.includes('#') &&
!normalized.includes('\\') &&
!normalized.split('/').includes('..') &&
!hasControlCharacter(normalized)
);
}
function isStableLegacyCanvasObjectKey(value: string) {
const normalized = value.trim();
return (
Boolean(normalized) &&
!normalized.startsWith('/') &&
!normalized.includes('?') &&
!normalized.includes('#') &&
!normalized.includes('\\') &&
!normalized.split('/').includes('..') &&
!hasControlCharacter(normalized)
);
}
function hasControlCharacter(value: string) {
return Array.from(value).some((character) => {
const codePoint = character.codePointAt(0) ?? 0;
return codePoint <= 0x1f || (codePoint >= 0x7f && codePoint <= 0x9f);
});
}
function resolveHydratedLayerModel(
snapshot: EditorProjectLayerSnapshot,
resource: CanvasLayerResourceMetadata | undefined,
@@ -806,9 +941,7 @@ function resolveHydratedLayerModel(
return persistedModel;
}
export function mapAssetLibrarySnapshot(
library: EditorAssetLibrarySnapshot,
): {
export function mapAssetLibrarySnapshot(library: EditorAssetLibrarySnapshot): {
folders: EditorAssetFolder[];
assets: EditorAsset[];
} {
@@ -929,6 +1062,17 @@ export type CanvasLayerResourceMetadata = {
updatedAt?: string | null;
};
export function resolveLayerResourceAssetKind(
layer: CanvasLayer,
): CanvasAssetKind | null {
if (layer.resourceAssetKind !== undefined) {
return layer.resourceAssetKind;
}
return layer.assetKindOverride === undefined
? (layer.assetKind ?? null)
: null;
}
export function normalizeAssetLibrary(library: EditorAssetLibrarySnapshot) {
const mapped = mapAssetLibrarySnapshot(library);
let hasDefaultFolder = false;
@@ -992,7 +1136,8 @@ export function soundDurationOrDefault(
value: unknown,
fallbackValue?: unknown,
) {
const resolvedValue = audioDurationOrNull(value) ?? audioDurationOrNull(fallbackValue);
const resolvedValue =
audioDurationOrNull(value) ?? audioDurationOrNull(fallbackValue);
if (!resolvedValue) {
return undefined;
}
@@ -1103,10 +1248,7 @@ export function resolveContextMenuPosition(
};
}
export function hasDataTransferType(
dataTransfer: DataTransfer,
type: string,
) {
export function hasDataTransferType(dataTransfer: DataTransfer, type: string) {
return Array.from(dataTransfer.types).includes(type);
}
@@ -1136,10 +1278,12 @@ export function isLayerLinkedToAsset(layer: CanvasLayer, asset: EditorAsset) {
layer.sourceAssetId === asset.id ||
Boolean(
assetSourceResourceId &&
(layer.resourceId === assetSourceResourceId ||
layer.sourceResourceId === assetSourceResourceId),
(layer.resourceId === assetSourceResourceId ||
layer.sourceResourceId === assetSourceResourceId),
) ||
Boolean(
asset.assetObjectId && layer.assetObjectId === asset.assetObjectId,
) ||
Boolean(asset.assetObjectId && layer.assetObjectId === asset.assetObjectId) ||
Boolean(assetObjectKey && layerObjectKey === assetObjectKey) ||
Boolean(assetSrc && layerSrc === assetSrc)
);
@@ -1295,9 +1439,7 @@ function hydrateSpecFormValues(
artStyle: typeof snapshot.artStyle === 'string' ? snapshot.artStyle : '',
bodyRatio: typeof snapshot.bodyRatio === 'string' ? snapshot.bodyRatio : '',
characterView:
typeof snapshot.characterView === 'string'
? snapshot.characterView
: '',
typeof snapshot.characterView === 'string' ? snapshot.characterView : '',
customPrompt:
typeof snapshot.customPrompt === 'string' ? snapshot.customPrompt : '',
};
@@ -1355,7 +1497,8 @@ function hydrateCharacterReference(
return null;
}
const objectKey =
stringOrUndefined(snapshot.objectKey) ?? stringOrUndefined(resource?.objectKey);
stringOrUndefined(snapshot.objectKey) ??
stringOrUndefined(resource?.objectKey);
const assetObjectId =
stringOrUndefined(snapshot.assetObjectId) ??
stringOrUndefined(resource?.assetObjectId);
@@ -1683,8 +1826,7 @@ function snapItemsOverlapOnCrossAxis(
const itemCrossStart = getSnapItemCrossStart(item, axis);
const itemCrossEnd = itemCrossStart + getSnapItemCrossSize(item, axis);
return (
proposedCrossStart <=
itemCrossEnd - SNAP_DISTRIBUTION_OVERLAP_TOLERANCE &&
proposedCrossStart <= itemCrossEnd - SNAP_DISTRIBUTION_OVERLAP_TOLERANCE &&
movingCrossEnd >= itemCrossStart + SNAP_DISTRIBUTION_OVERLAP_TOLERANCE
);
}
@@ -180,6 +180,8 @@ function createStageProps(): ImageCanvasStageViewProps {
contextMenuLayer: null,
contextShouldShowLayer: false,
contextShouldUnlockLayer: false,
canCopyContextLayers: true,
isCanvasClipboardCopyBlocked: false,
canUndo: false,
canRedo: false,
historyFeedback: null,
@@ -84,6 +84,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,6 +109,8 @@ 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;
@@ -390,12 +394,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'
@@ -2032,42 +2032,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 />);
@@ -2085,80 +2074,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(
@@ -2166,45 +2123,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(
@@ -2212,249 +2137,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 () => {
@@ -2580,10 +2268,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);
@@ -39,6 +39,7 @@ import {
DEFAULT_CANVAS_BACKGROUND_COLOR,
generationInputsOrNull,
isInlineEditorMediaSource,
resolveLayerResourceAssetKind,
resolveContextMenuPosition,
} from './ImageCanvasEditorModel';
import { ImageCanvasEditorShellView } from './ImageCanvasEditorShellView';
@@ -66,6 +67,7 @@ import {
} from './ImageCanvasGenerationModel';
import { formatCanvasHistoryAction } from './ImageCanvasHistoryModel';
import { fitViewportToBounds } from './ImageCanvasInteractionModel';
import type { CanvasLayerCopyBlockReason } from './ImageCanvasLayerCommandModel';
import {
isCanvasGenerationComposerVisible,
resolveQuickEditFocusViewport,
@@ -319,15 +321,7 @@ export function ImageCanvasEditorView({
const discardCanvasHistoryEntriesContainingLayerRef = useRef<
(matchesLayer: (layer: CanvasLayer) => boolean) => void
>(() => {});
const persistRestoredAssetKindRef = useRef<
(layer: CanvasLayer, historyEntryId?: number) => void
>(() => {});
const layerResourcePersistenceVersionRef = useRef(new Map<string, number>());
const assetKindRollbackLayerByIdRef = useRef(new Map<string, CanvasLayer>());
const assetKindHistoryEntryIdsByLayerIdRef = useRef(
new Map<string, Set<number>>(),
);
const persistingAssetKindLayerIdsRef = useRef(new Set<string>());
const resetCanvasInteractionStateRef = useRef<() => void>(() => {});
const closeGenerationTransientStateRef = useRef<() => void>(() => {});
const specToolWrapRef = useRef<HTMLSpanElement | null>(null);
@@ -795,35 +789,24 @@ export function ImageCanvasEditorView({
}),
[],
);
const canvasHistoryEffects = useMemo(
() => ({
persistRestoredAssetKind: (layer: CanvasLayer, historyEntryId?: number) =>
persistRestoredAssetKindRef.current(layer, historyEntryId),
}),
[],
);
const {
canUndo,
canRedo,
getCanvasHistorySnapshot,
captureCanvasHistory,
discardCanvasHistoryEntriesContainingLayer,
discardCanvasHistoryEntriesById,
undoCanvasChange,
redoCanvasChange,
} = useCanvasHistory({
refs: canvasHistoryRefs,
setters: canvasHistorySetters,
resetters: canvasHistoryResetters,
effects: canvasHistoryEffects,
});
captureCanvasHistoryRef.current = captureCanvasHistory;
discardCanvasHistoryEntriesContainingLayerRef.current =
discardCanvasHistoryEntriesContainingLayer;
const [historyFeedback, setHistoryFeedback] =
useState<CanvasHistoryFeedback | null>(null);
const [persistingAssetKindLayerIds, setPersistingAssetKindLayerIds] =
useState<ReadonlySet<string>>(() => new Set());
useEffect(() => {
if (!historyFeedback) {
return;
@@ -831,6 +814,21 @@ export function ImageCanvasEditorView({
const timer = window.setTimeout(() => setHistoryFeedback(null), 3000);
return () => window.clearTimeout(timer);
}, [historyFeedback]);
const showCanvasLayerCopyWarning = useCallback(
(reason: CanvasLayerCopyBlockReason) => {
setHistoryFeedback((currentFeedback) => ({
id: (currentFeedback?.id ?? 0) + 1,
tone: 'warning',
text:
reason === 'self-contained-local-unsupported'
? '历史序列帧素材暂不支持复制'
: reason === 'unresolved-resource'
? '素材资源尚未登记,暂不能复制'
: '素材仍在保存,请稍后重试',
}));
},
[],
);
const handleUndoCanvasChange = useCallback(() => {
const result = undoCanvasChange();
if (result.status === 'empty') {
@@ -1237,105 +1235,19 @@ export function ImageCanvasEditorView({
.then(applyGeneratedProjectSnapshot)
.catch(() => undefined);
}, [applyGeneratedProjectSnapshot, projectId]);
const setLayerAssetKindPersistencePending = useCallback(
(layerId: string, isPending: boolean) => {
const nextLayerIds = new Set(persistingAssetKindLayerIdsRef.current);
if (isPending) {
nextLayerIds.add(layerId);
} else {
nextLayerIds.delete(layerId);
}
persistingAssetKindLayerIdsRef.current = nextLayerIds;
setPersistingAssetKindLayerIds(nextLayerIds);
},
[],
);
const persistUpdatedLayerResource = useCallback(
(
layer: CanvasLayer,
rollbackLayer?: CanvasLayer,
historyEntryId?: number,
) => {
(layer: CanvasLayer) => {
const requestVersion =
(layerResourcePersistenceVersionRef.current.get(layer.id) ?? 0) + 1;
layerResourcePersistenceVersionRef.current.set(layer.id, requestVersion);
const confirmedRollbackLayer = rollbackLayer
? (assetKindRollbackLayerByIdRef.current.get(layer.id) ?? rollbackLayer)
: undefined;
if (confirmedRollbackLayer) {
assetKindRollbackLayerByIdRef.current.set(
layer.id,
confirmedRollbackLayer,
);
if (historyEntryId !== undefined) {
const historyEntryIds = new Set(
assetKindHistoryEntryIdsByLayerIdRef.current.get(layer.id),
);
historyEntryIds.add(historyEntryId);
assetKindHistoryEntryIdsByLayerIdRef.current.set(
layer.id,
historyEntryIds,
);
}
setLayerAssetKindPersistencePending(layer.id, true);
}
const rollbackLatestRequest = (error: unknown) => {
if (
layerResourcePersistenceVersionRef.current.get(layer.id) !==
requestVersion ||
!confirmedRollbackLayer
) {
return;
}
setLayers((currentLayers) => {
const nextLayers = currentLayers.map((currentLayer) =>
currentLayer.id === layer.id
? {
...currentLayer,
assetKind: confirmedRollbackLayer.assetKind,
resourceId: confirmedRollbackLayer.resourceId,
}
: currentLayer,
);
layersRef.current = nextLayers;
return nextLayers;
});
setMetadataLayer((currentLayer) =>
currentLayer?.id === layer.id
? {
...currentLayer,
assetKind: confirmedRollbackLayer.assetKind,
resourceId: confirmedRollbackLayer.resourceId,
}
: currentLayer,
);
const historyEntryIds =
assetKindHistoryEntryIdsByLayerIdRef.current.get(layer.id);
if (historyEntryIds) {
discardCanvasHistoryEntriesById(historyEntryIds);
}
assetKindHistoryEntryIdsByLayerIdRef.current.delete(layer.id);
assetKindRollbackLayerByIdRef.current.delete(layer.id);
setLayerAssetKindPersistencePending(layer.id, false);
const reason =
error instanceof Error && error.message.trim()
? error.message.trim()
: '请稍后重试';
setHistoryFeedback((currentFeedback) => ({
id: (currentFeedback?.id ?? 0) + 1,
tone: 'warning',
text: `${reason}`,
}));
};
if (!projectId) {
rollbackLatestRequest(new Error('项目尚未就绪'));
return;
}
const imageSrc = resolveLayerProjectResourceImageSrc(layer);
if (!imageSrc) {
rollbackLatestRequest(new Error('素材图片尚未就绪'));
return;
}
const resourceAssetKind = resolveLayerResourceAssetKind(layer);
void createEditorProjectResource(projectId, {
imageSrc,
objectKey: layer.objectKey,
@@ -1350,7 +1262,7 @@ export function ImageCanvasEditorView({
taskId: layer.taskId,
durationSeconds: layer.durationSeconds,
sourceResourceId: layer.sourceResourceId,
assetKind: layer.assetKind,
assetKind: resourceAssetKind,
generationInputs: layer.generationInputs,
})
.then((resource) => {
@@ -1360,21 +1272,19 @@ export function ImageCanvasEditorView({
) {
return;
}
if (
confirmedRollbackLayer &&
(resource.assetKind !== layer.assetKind ||
!resource.resourceId.trim())
) {
rollbackLatestRequest(new Error('服务器未确认新的素材类型'));
return;
}
const confirmedResourceAssetKind =
canvasAssetKindOrNull(resource.assetKind) ?? resourceAssetKind;
setLayers((currentLayers) => {
const nextLayers = currentLayers.map((currentLayer) =>
currentLayer.id === layer.id
? {
...currentLayer,
assetKind: layer.assetKind,
resourceId: resource.resourceId,
resourcePersistenceState: 'registered' as const,
resourceAssetKind: confirmedResourceAssetKind,
assetKind:
currentLayer.assetKindOverride ??
confirmedResourceAssetKind,
}
: currentLayer,
);
@@ -1385,31 +1295,20 @@ export function ImageCanvasEditorView({
currentLayer?.id === layer.id
? {
...currentLayer,
assetKind: layer.assetKind,
resourceId: resource.resourceId,
resourcePersistenceState: 'registered' as const,
resourceAssetKind: confirmedResourceAssetKind,
assetKind:
currentLayer.assetKindOverride ??
confirmedResourceAssetKind,
}
: currentLayer,
);
if (confirmedRollbackLayer) {
assetKindHistoryEntryIdsByLayerIdRef.current.delete(layer.id);
assetKindRollbackLayerByIdRef.current.delete(layer.id);
setLayerAssetKindPersistencePending(layer.id, false);
}
})
.catch(rollbackLatestRequest);
.catch(() => undefined);
},
[
discardCanvasHistoryEntriesById,
projectId,
setLayerAssetKindPersistencePending,
],
[projectId],
);
persistRestoredAssetKindRef.current = (layer, historyEntryId) => {
const rollbackLayer = layersRef.current.find(
(currentLayer) => currentLayer.id === layer.id,
);
persistUpdatedLayerResource(layer, rollbackLayer ?? layer, historyEntryId);
};
const {
assetExportStatus,
isExportingAssets,
@@ -1517,11 +1416,7 @@ export function ImageCanvasEditorView({
}
refreshEditorWalletState();
},
[
projectId,
refreshEditorWalletState,
showGenerationWarning,
],
[projectId, refreshEditorWalletState, showGenerationWarning],
);
const effectiveIsAgentConversationOpen =
isAgentConversationEnabled && isAgentConversationOpen;
@@ -1778,6 +1673,9 @@ export function ImageCanvasEditorView({
: null;
const {
canvasClipboard,
canCopyContextLayers,
contextLayerCopyBlockReason,
isCanvasClipboardCopyBlocked,
pasteCanvasClipboard,
copyContextLayers,
copySelectedLayers,
@@ -1802,6 +1700,7 @@ export function ImageCanvasEditorView({
selectedLayerIds,
canvasGenerationDialogs,
setLayers,
appendCanvasLayersWithResources,
setSelectedLayerId,
setSelectedLayerIds,
setHoveredLayerId,
@@ -1814,6 +1713,7 @@ export function ImageCanvasEditorView({
onDeleteLayerSideEffects: clearDeletedLayerGenerationState,
onDeleteGenerationDialogSideEffects: removeCanvasGenerationDialogById,
exportLayerImage,
onCanvasLayerCopyBlocked: showCanvasLayerCopyWarning,
});
const {
canvasMarquee,
@@ -1958,18 +1858,23 @@ export function ImageCanvasEditorView({
if (!targetLayer || targetLayer.assetKind === assetKind) {
return;
}
const historyEntryId = captureCanvasHistory({
captureCanvasHistory({
type: 'change-asset-kind',
count: 1,
layerIds: [layerId],
});
const resourceAssetKind = resolveLayerResourceAssetKind(targetLayer);
const assetKindOverride =
assetKind === resourceAssetKind ? null : assetKind;
const nextLayer = {
...targetLayer,
assetKind,
resourceAssetKind,
assetKindOverride,
assetKind: assetKindOverride ?? resourceAssetKind,
};
setLayers((currentLayers) => {
const nextLayers = currentLayers.map((layer) =>
layer.id === layerId ? { ...layer, assetKind } : layer,
layer.id === layerId ? nextLayer : layer,
);
layersRef.current = nextLayers;
return nextLayers;
@@ -1977,11 +1882,10 @@ export function ImageCanvasEditorView({
setMetadataLayer((currentLayer) =>
currentLayer?.id === layerId ? nextLayer : currentLayer,
);
persistUpdatedLayerResource(nextLayer, targetLayer, historyEntryId);
setImageContextMenu(null);
setContextMenu(null);
},
[captureCanvasHistory, persistUpdatedLayerResource],
[captureCanvasHistory],
);
const selectAllCanvasObjects = useCallback(() => {
const layerSelectionIds = layersRef.current
@@ -2384,7 +2288,6 @@ export function ImageCanvasEditorView({
generationComposerStyle,
selectedToolbarStyle,
splittingIconSpritesheetLayerIds,
persistingAssetKindLayerIds,
uploadDropTarget,
contextMenu,
canvasClipboard,
@@ -2392,6 +2295,9 @@ export function ImageCanvasEditorView({
imageContextMenuLayer,
contextShouldShowLayer,
contextShouldUnlockLayer,
canCopyContextLayers,
contextLayerCopyBlockReason,
isCanvasClipboardCopyBlocked,
canUndo,
canRedo,
historyFeedback,
@@ -2435,10 +2341,9 @@ export function ImageCanvasEditorView({
onOpenCropExpandPanel: openCropExpandPanel,
onRemoveBackground: removeSelectedLayerBackground,
onSplitIconSpritesheet: (layer: CanvasLayer) => {
if (persistingAssetKindLayerIdsRef.current.has(layer.id)) {
return;
}
void splitSelectedIconSpritesheet(layer);
void flushProjectPersistence().then(() =>
splitSelectedIconSpritesheet(layer),
);
},
onExtractUiDesignAssets: extractUiDesignAssets,
onUiAssetExtractionToolChange: changeUiAssetExtractionTool,
@@ -534,6 +534,9 @@ describe('ImageCanvasGenerationComposerView', () => {
render(<VideoHarness />);
const panel = screen.getByRole('dialog', { name: '生成视频' });
const prompt = within(panel).getByRole('textbox', { name: '视频描述' });
expect(prompt.className).toContain('auto-grow-text-area');
expect(prompt.className).not.toContain('platform-text-field');
expect(
within(panel).getByRole('button', {
name: '视频参数 16:9 · 4秒 · 480p',
@@ -658,7 +661,9 @@ describe('ImageCanvasGenerationComposerView', () => {
expect(footer?.children[2]?.className).toContain(
'image-canvas-editor__generation-submit',
);
expect(within(panel).getByRole('textbox', { name: 'prompt' })).toBeTruthy();
const prompt = within(panel).getByRole('textbox', { name: 'prompt' });
expect(prompt.className).toContain('auto-grow-text-area');
expect(prompt.className).not.toContain('platform-text-field');
expect(screen.getByLabelText('当前音效模型').textContent).toBe('audio1.0');
const soundModelButton = within(panel).getByRole('button', {
name: '音效模型 Vidu',
@@ -10,6 +10,7 @@ import {
useState,
} from 'react';
import { AutoGrowTextArea } from '../common/AutoGrowTextArea';
import { PlatformActionButton } from '../common/PlatformActionButton';
import {
PlatformFloatingMenu,
@@ -17,7 +18,6 @@ import {
} from '../common/PlatformFloatingMenu';
import { PlatformInlineOptionButton } from '../common/PlatformInlineOptionButton';
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
import { PlatformTextField } from '../common/PlatformTextField';
import { ImageCanvasBasicGenerationComposerView } from './ImageCanvasBasicGenerationComposerView';
import { ImageCanvasCharacterAnimationPanelView } from './ImageCanvasCharacterAnimationPanelView';
import { ImageCanvasCharacterGenerationComposerView } from './ImageCanvasCharacterGenerationComposerView';
@@ -384,14 +384,11 @@ function ImageCanvasVideoGenerationComposerView({
/>
</div>
) : null}
<PlatformTextField
variant="textarea"
<AutoGrowTextArea
aria-label="视频描述"
value={dialog.prompt}
disabled={isGenerating}
placeholder="你希望生成什么视频?"
size="sm"
density="compact"
className="image-canvas-editor__generation-prompt"
onChange={(event) =>
updateVideoDialog({ prompt: event.target.value })
@@ -768,8 +765,7 @@ function ImageCanvasAudioGenerationComposerView({
}
}}
>
<PlatformTextField
variant="textarea"
<AutoGrowTextArea
aria-label={isSoundEffect ? 'prompt' : 'gpt_description_prompt'}
value={dialog.prompt}
disabled={isGenerating}
@@ -778,8 +774,6 @@ function ImageCanvasAudioGenerationComposerView({
? '你希望生成什么音效?'
: '你希望生成什么音乐?'
}
size="sm"
density="compact"
className="image-canvas-editor__generation-prompt"
onChange={(event) => updateAudioDialog({ prompt: event.target.value })}
/>
@@ -136,13 +136,27 @@ function mergeLayerForHistoryRestore(
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,
assetKind: restoreAssetKind ? target.assetKind : current.assetKind,
...(resourceAssetKind !== undefined ? { resourceAssetKind } : {}),
...(restoresLayerOverride ? { assetKindOverride } : {}),
assetKind: restoreAssetKind
? restoresLayerOverride
? (assetKindOverride ?? resourceAssetKind ?? null)
: target.assetKind
: current.assetKind,
hidden: target.hidden,
locked: target.locked,
flipX: target.flipX,
@@ -193,7 +193,11 @@ describe('ImageCanvasIconSpritesheetComposerView', () => {
/>,
);
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: '魔法剑\n圆盾' },
});
fireEvent.click(screen.getByRole('button', { name: '生成' }));
@@ -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 {
GenerateDialogState,
SpecGenerationType,
@@ -210,14 +210,11 @@ export function ImageCanvasIconSpritesheetComposerView({
/>
</div>
<label className="image-canvas-editor__field-block image-canvas-editor__field-block--single">
<PlatformTextField
variant="textarea"
<AutoGrowTextArea
aria-label="素材描述"
value={descriptionText}
disabled={dialog.status === 'generating'}
placeholder="你需要哪些图标?"
size="sm"
density="compact"
className="image-canvas-editor__generation-prompt"
onChange={(event) => onUpdateIconDescriptionText(event.target.value)}
/>

Some files were not shown because too many files have changed in this diff Show More