合并主分支
Project CI / Repository checks (pull_request) Successful in 51s
Project CI / Backend tests (pull_request) Successful in 4m20s
Project CI / Frontend tests (pull_request) Successful in 3m20s
Project CI / Native shell tests (pull_request) Successful in 16m28s

解决合并冲突
This commit is contained in:
2026-08-08 15:50:45 +08:00
39 changed files with 1343 additions and 964 deletions
@@ -0,0 +1,47 @@
import { act } from '@testing-library/react';
import {
$createLineBreakNode,
$createParagraphNode,
$createTextNode,
$getRoot,
getNearestEditorFromDOMNode,
} from 'lexical';
export function setPlainTextEditorValue(
contentEditable: HTMLElement,
value: string,
) {
const editor = getNearestEditorFromDOMNode(contentEditable);
if (!editor) {
throw new Error('Expected a Lexical editor root');
}
act(() => {
editor.update(
() => {
const paragraph = $createParagraphNode();
value.split('\n').forEach((line, index, lines) => {
if (line) {
paragraph.append($createTextNode(line));
}
if (index < lines.length - 1) {
paragraph.append($createLineBreakNode());
}
});
$getRoot().clear().append(paragraph);
paragraph.selectEnd();
},
{ discrete: true },
);
});
}
export function getPlainTextEditorHost(contentEditable: HTMLElement) {
const host = contentEditable.closest<HTMLElement>(
'.auto-grow-text-area',
);
if (!host) {
throw new Error('Expected an auto-grow plain-text editor host');
}
return host;
}
+145 -258
View File
@@ -1,288 +1,175 @@
/* @vitest-environment jsdom */
import { act, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { useState } from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { AutoGrowTextArea } from './AutoGrowTextArea';
afterEach(() => {
vi.unstubAllGlobals();
beforeEach(() => {
Range.prototype.getBoundingClientRect = vi.fn(() => new DOMRect());
Range.prototype.getClientRects = vi.fn(() => [] as unknown as DOMRectList);
vi.stubGlobal(
'ResizeObserver',
class ResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
},
);
vi.stubGlobal(
'ClipboardEvent',
class ClipboardEvent extends Event {
clipboardData: DataTransfer;
constructor(
type: string,
init: EventInit & { clipboardData: DataTransfer },
) {
super(type, init);
this.clipboardData = init.clipboardData;
}
},
);
});
function pastePlainText(editor: HTMLElement, value: string) {
const clipboardData = {
files: [],
getData: (type: string) => (type === 'text/plain' ? value : ''),
items: [],
types: ['text/plain'],
} as unknown as DataTransfer;
fireEvent(
editor,
new ClipboardEvent('paste', {
bubbles: true,
cancelable: true,
clipboardData,
}),
);
}
function ControlledEditor({
initialValue = '',
maxLength,
}: {
initialValue?: string;
maxLength?: number;
}) {
const [value, setValue] = useState(initialValue);
return (
<>
<AutoGrowTextArea
aria-label="受控编辑器"
value={value}
maxLength={maxLength}
onValueChange={setValue}
/>
<output aria-label="React 文本状态">{value}</output>
<button type="button" onClick={() => setValue('外部\n更新')}>
</button>
</>
);
}
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);
it('keeps React state authoritative and preserves exact line breaks', async () => {
const user = userEvent.setup();
render(<ControlledEditor initialValue={'第一行\n第二行'} />);
render(
<AutoGrowTextArea
aria-label="自动增长输入框"
value=""
onChange={onChange}
/>,
const editor = screen.getByRole('textbox', { name: '受控编辑器' });
expect(screen.getByLabelText('React 文本状态').textContent).toBe(
'第一行\n第二行',
);
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);
await user.click(screen.getByRole('button', { name: '外部更新' }));
expect(screen.getByLabelText('React 文本状态').textContent).toBe(
'外部\n更新',
);
});
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,
};
}),
it('publishes edits through onValueChange and enforces maxLength', async () => {
const user = userEvent.setup();
render(<ControlledEditor maxLength={4} />);
const editor = screen.getByRole('textbox', { name: '受控编辑器' });
await user.click(editor);
pastePlainText(editor, '12345');
await waitFor(() =>
expect(screen.getByLabelText('React 文本状态').textContent).toBe('1234'),
);
expect(editor.textContent).toBe('1234');
});
it('supports undo and redo while keeping React state synchronized', async () => {
const user = userEvent.setup();
render(<ControlledEditor />);
const editor = screen.getByRole('textbox', { name: '受控编辑器' });
await user.click(editor);
pastePlainText(editor, '草稿');
await waitFor(() =>
expect(screen.getByLabelText('React 文本状态').textContent).toBe('草稿'),
);
const { rerender, unmount } = render(
await user.keyboard('{Control>}z{/Control}');
expect(screen.getByLabelText('React 文本状态').textContent).toBe('');
await user.keyboard('{Control>}{Shift>}z{/Shift}{/Control}');
expect(screen.getByLabelText('React 文本状态').textContent).toBe('草稿');
});
it('exposes read-only and disabled accessibility without form controls', () => {
const { container } = render(
<AutoGrowTextArea
aria-label="自动增长输入框"
value=""
style={{
boxSizing: 'border-box',
minHeight: '80px',
maxHeight: '128px',
}}
onChange={vi.fn()}
aria-label="只读编辑器"
value="只读内容"
disabled
readOnly
onValueChange={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(
const editor = screen.getByRole('textbox', { name: '只读编辑器' });
expect(editor.getAttribute('aria-disabled')).toBe('true');
expect(editor.getAttribute('aria-readonly')).toBe('true');
expect(editor.getAttribute('tabindex')).toBe('-1');
expect(container.querySelector('textarea')).toBeNull();
expect(container.querySelector('form')).toBeNull();
});
it('uses a distinct internal viewport for overflow content', async () => {
const { container } = render(
<AutoGrowTextArea
aria-label="自动增长输入框"
value="第一行\n第二行\n第三行\n第四行\n第五行"
style={{
boxSizing: 'border-box',
minHeight: '80px',
maxHeight: '128px',
}}
onChange={vi.fn()}
aria-label="滚动编辑器"
value={Array.from({ length: 20 }, (_, index) => `${index + 1}`).join(
'\n',
)}
onValueChange={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,
const viewport = await waitFor(() => {
const element = container.querySelector<HTMLElement>(
'[data-overlayscrollbars-viewport]',
);
expect(element).not.toBeNull();
return element!;
});
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()}
/>,
const content = container.querySelector<HTMLElement>(
'[data-overlayscrollbars-content]',
);
expect(input.style.height).toBe('80px');
expect(input.style.overflowY).toBe('hidden');
unmount();
expect(disconnect).toHaveBeenCalledTimes(1);
expect(viewport).not.toBe(content);
expect(
viewport.contains(
screen.getByRole('textbox', { name: '滚动编辑器' }),
),
).toBe(true);
});
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');
});
});
File diff suppressed because it is too large Load Diff
@@ -16,6 +16,7 @@ import type {
EditorAgentMessage,
EditorAgentMessageResponse,
} from '@/packages/shared/src/contracts';
import { setPlainTextEditorValue } from '@/src/components/common/AutoGrowTextArea.test-utils.ts';
import type { EditorAgentConversationClient } from '@/src/components/image-editor/EditorAgentConversation/useEditorAgentConversation.ts';
import { EDITOR_AGENT_PATIENCE_NOTICE_DELAY_MS } from '@/src/components/image-editor/EditorAgentConversation/useEditorAgentConversation.ts';
import { ImageCanvasActionsProvider } from '@/src/components/image-editor/ImageCanvasActionsProvider.tsx';
@@ -135,9 +136,10 @@ function createClient(): EditorAgentConversationClient {
}
function enterAttachmentPrompt() {
fireEvent.change(screen.getByLabelText('发送给画布 Agent'), {
target: { value: ATTACHMENT_PROMPT },
});
setPlainTextEditorValue(
screen.getByLabelText('发送给画布 Agent'),
ATTACHMENT_PROMPT,
);
}
afterEach(() => {
@@ -294,9 +296,10 @@ describe('EditorAgentConversationPanelView', () => {
name: '新建对话',
}) as HTMLButtonElement;
expect(newConversationButton.disabled).toBe(false);
fireEvent.change(screen.getByLabelText('发送给画布 Agent'), {
target: { value: '应保留到新会话的草稿' },
});
setPlainTextEditorValue(
screen.getByLabelText('发送给画布 Agent'),
'应保留到新会话的草稿',
);
fireEvent.click(screen.getByRole('button', { name: '添加附件' }));
const attachmentDialog = screen.getByRole('dialog', {
name: '选择图片附件',
@@ -332,25 +335,27 @@ describe('EditorAgentConversationPanelView', () => {
).toBe('conversation-2');
expect(newConversationButton.disabled).toBe(true);
});
expect(
(screen.getByLabelText('发送给画布 Agent') as HTMLTextAreaElement).value,
).toBe('应保留到新会话的草稿');
expect(screen.getByLabelText('发送给画布 Agent').textContent).toBe(
'应保留到新会话的草稿',
);
expect(
screen.getByText('一二三四五六七八九十甲乙丙丁戊己庚辛壬癸子丑寅卯'),
).toBeTruthy();
expect(screen.getByRole('option', { name: '角色参考' })).toBeTruthy();
expect(screen.getByRole('option', { name: '新对话' })).toBeTruthy();
fireEvent.change(screen.getByLabelText('发送给画布 Agent'), {
target: { value: '尚未发送的草稿' },
});
setPlainTextEditorValue(
screen.getByLabelText('发送给画布 Agent'),
'尚未发送的草稿',
);
expect(newConversationButton.disabled).toBe(true);
fireEvent.click(newConversationButton);
expect(client.createConversation).toHaveBeenCalledTimes(1);
fireEvent.change(screen.getByLabelText('发送给画布 Agent'), {
target: { value: '参考附件做像素风' },
});
setPlainTextEditorValue(
screen.getByLabelText('发送给画布 Agent'),
'参考附件做像素风',
);
fireEvent.click(screen.getByRole('button', { name: '发送' }));
await waitFor(() => {
@@ -491,9 +496,10 @@ describe('EditorAgentConversationPanelView', () => {
}) as HTMLButtonElement;
expect(newConversationButton.disabled).toBe(true);
fireEvent.change(screen.getByLabelText('发送给画布 Agent'), {
target: { value: '发送内容' },
});
setPlainTextEditorValue(
screen.getByLabelText('发送给画布 Agent'),
'未发送内容',
);
expect(newConversationButton.disabled).toBe(true);
fireEvent.click(newConversationButton);
expect(client.createConversation).not.toHaveBeenCalled();
@@ -516,17 +522,18 @@ describe('EditorAgentConversationPanelView', () => {
await waitFor(() => {
expect(screen.getByText('已经看到画布内容')).toBeTruthy();
});
fireEvent.change(screen.getByLabelText('发送给画布 Agent'), {
target: { value: '需要保留的草稿' },
});
setPlainTextEditorValue(
screen.getByLabelText('发送给画布 Agent'),
'需要保留的草稿',
);
fireEvent.click(screen.getByRole('button', { name: '新建对话' }));
await waitFor(() => {
expect(screen.getByText('创建新会话失败')).toBeTruthy();
});
expect(
(screen.getByLabelText('发送给画布 Agent') as HTMLTextAreaElement).value,
).toBe('需要保留的草稿');
expect(screen.getByLabelText('发送给画布 Agent').textContent).toBe(
'需要保留的草稿',
);
expect(screen.getByText('已经看到画布内容')).toBeTruthy();
expect((screen.getByLabelText('当前对话') as HTMLSelectElement).value).toBe(
'conversation-1',
@@ -556,15 +563,13 @@ describe('EditorAgentConversationPanelView', () => {
});
const draftInput = screen.getByLabelText(
'发送给画布 Agent',
) as HTMLTextAreaElement;
fireEvent.change(draftInput, {
target: { value: '旧项目草稿' },
});
) as HTMLElement;
setPlainTextEditorValue(draftInput, '旧项目草稿');
fireEvent.click(screen.getByRole('button', { name: '新建对话' }));
await waitFor(() => {
expect(client.createConversation).toHaveBeenCalledWith('project-1', {});
expect(draftInput.disabled).toBe(false);
expect(draftInput.getAttribute('aria-disabled')).toBeNull();
});
const conversationSelect = screen.getByLabelText(
'当前对话',
@@ -578,12 +583,10 @@ describe('EditorAgentConversationPanelView', () => {
(screen.getByRole('button', { name: '添加附件' }) as HTMLButtonElement)
.disabled,
).toBe(false);
fireEvent.change(draftInput, {
target: { value: '创建期间更新的草稿' },
});
fireEvent.submit(draftInput.closest('form') as HTMLFormElement);
setPlainTextEditorValue(draftInput, '创建期间更新的草稿');
fireEvent.click(sendButton);
expect(client.sendMessage).not.toHaveBeenCalled();
expect(draftInput.value).toBe('创建期间更新的草稿');
expect(draftInput.textContent).toBe('创建期间更新的草稿');
await act(async () => {
resolveCreate({
@@ -596,7 +599,7 @@ describe('EditorAgentConversationPanelView', () => {
});
await Promise.resolve();
});
expect(draftInput.value).toBe('创建期间更新的草稿');
expect(draftInput.textContent).toBe('创建期间更新的草稿');
expect(conversationSelect.value).toBe('conversation-2');
expect(conversationSelect.disabled).toBe(false);
expect(sendButton.disabled).toBe(false);
@@ -659,13 +662,11 @@ describe('EditorAgentConversationPanelView', () => {
});
const draftInput = screen.getByLabelText(
'发送给画布 Agent',
) as HTMLTextAreaElement;
) as HTMLElement;
const conversationSelect = screen.getByLabelText(
'当前对话',
) as HTMLSelectElement;
fireEvent.change(draftInput, {
target: { value: '切换期间必须保留的草稿' },
});
setPlainTextEditorValue(draftInput, '切换期间必须保留的草稿');
fireEvent.change(conversationSelect, {
target: { value: 'conversation-2' },
});
@@ -677,9 +678,9 @@ describe('EditorAgentConversationPanelView', () => {
expect(client.getConversation).toHaveBeenCalledWith('conversation-2');
expect(sendButton.disabled).toBe(true);
});
fireEvent.submit(draftInput.closest('form') as HTMLFormElement);
fireEvent.click(sendButton);
expect(client.sendMessage).not.toHaveBeenCalled();
expect(draftInput.value).toBe('切换期间必须保留的草稿');
expect(draftInput.textContent).toBe('切换期间必须保留的草稿');
await act(async () => {
resolveConversationLoad({
@@ -702,7 +703,7 @@ describe('EditorAgentConversationPanelView', () => {
await Promise.resolve();
});
expect(await screen.findByText('背景会话内容')).toBeTruthy();
expect(draftInput.value).toBe('切换期间必须保留的草稿');
expect(draftInput.textContent).toBe('切换期间必须保留的草稿');
expect(sendButton.disabled).toBe(false);
});
@@ -729,9 +730,10 @@ describe('EditorAgentConversationPanelView', () => {
});
vi.useFakeTimers();
fireEvent.change(screen.getByLabelText('发送给画布 Agent'), {
target: { value: '继续规划' },
});
setPlainTextEditorValue(
screen.getByLabelText('发送给画布 Agent'),
'继续规划',
);
fireEvent.click(screen.getByRole('button', { name: '发送' }));
await act(async () => {
@@ -1316,7 +1318,7 @@ describe('EditorAgentConversationPanelView', () => {
}) as HTMLButtonElement;
expect(sendButton.disabled).toBe(true);
fireEvent.submit(sendButton.closest('form')!);
fireEvent.click(sendButton);
expect(client.sendMessage).not.toHaveBeenCalled();
expect(screen.getByText('角色图层')).toBeTruthy();
@@ -1368,15 +1370,18 @@ describe('EditorAgentConversationPanelView', () => {
fireEvent.click(
within(attachmentDialog).getByRole('button', { name: '应用' }),
);
fireEvent.change(screen.getByLabelText('发送给画布 Agent'), {
target: { value: '失败后恢复这条草稿' },
});
setPlainTextEditorValue(
screen.getByLabelText('发送给画布 Agent'),
'失败后恢复这条草稿',
);
fireEvent.click(screen.getByRole('button', { name: '发送' }));
expect(await screen.findByText('Network error')).toBeTruthy();
expect(
(screen.getByLabelText('发送给画布 Agent') as HTMLTextAreaElement).value,
).toBe('失败后恢复这条草稿');
await waitFor(() =>
expect(screen.getByLabelText('发送给画布 Agent').textContent).toBe(
'失败后恢复这条草稿',
),
);
expect(screen.getByText('角色图层')).toBeTruthy();
expect(
within(
@@ -1760,9 +1765,10 @@ describe('EditorAgentConversationPanelView', () => {
expect(screen.getByText('已经看到画布内容')).toBeTruthy();
});
fireEvent.change(screen.getByLabelText('发送给画布 Agent'), {
target: { value: '生成一张图片' },
});
setPlainTextEditorValue(
screen.getByLabelText('发送给画布 Agent'),
'生成一张图片',
);
fireEvent.click(screen.getByRole('button', { name: '发送' }));
await waitFor(() => {
@@ -10,7 +10,6 @@ import {
X,
} from 'lucide-react';
import {
type FormEvent,
useEffect,
useState,
type WheelEvent as ReactWheelEvent,
@@ -139,8 +138,7 @@ export function EditorAgentConversationPanelView({
void createConversation().catch(() => undefined);
};
const submitMessage = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const submitMessage = () => {
if (isMessageSubmissionBlocked) {
return;
}
@@ -328,10 +326,7 @@ export function EditorAgentConversationPanelView({
{attachmentError}
</div>
) : null}
<form
className="border-t border-slate-200 bg-white/95 p-3"
onSubmit={submitMessage}
>
<div className="border-t border-slate-200 bg-white/95 p-3">
{attachments.length ? (
<div className="mb-2 flex flex-wrap gap-1.5">
{attachments.map((attachment) => (
@@ -354,19 +349,21 @@ export function EditorAgentConversationPanelView({
</button>
<EditorAgentDraftTextarea
value={draftText}
onChange={setDraftText}
onValueChange={setDraftText}
onPaste={handleInputPaste}
onSubmitRequest={submitMessage}
/>
<button
type="submit"
type="button"
className="inline-flex h-10 min-w-16 shrink-0 items-center justify-center gap-1.5 rounded-full bg-slate-900 px-3 text-sm font-semibold text-white disabled:opacity-45"
disabled={isMessageSubmissionBlocked}
onClick={submitMessage}
>
<Send className="h-3.5 w-3.5" aria-hidden="true" />
</button>
</div>
</form>
</div>
</aside>
<AttachmentPicker
open={attachmentPickerOpen}
@@ -1,60 +1,86 @@
/* @vitest-environment jsdom */
import { fireEvent, render, screen } from '@testing-library/react';
import type { FormEvent } from 'react';
import { describe, expect, it, vi } from 'vitest';
import userEvent from '@testing-library/user-event';
import { useState } from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { EditorAgentDraftTextarea } from './EditorAgentDraftTextarea.tsx';
beforeEach(() => {
vi.stubGlobal(
'ResizeObserver',
class ResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
},
);
});
function DraftHarness({
onPaste,
onSubmitRequest,
}: {
onPaste?: (event: ClipboardEvent) => void;
onSubmitRequest: () => void;
}) {
const [value, setValue] = useState('');
return (
<EditorAgentDraftTextarea
value={value}
onValueChange={setValue}
onPaste={onPaste}
onSubmitRequest={onSubmitRequest}
/>
);
}
describe('EditorAgentDraftTextarea', () => {
it('forwards draft changes and paste events through the Agent wrapper', () => {
const onChange = vi.fn();
const onPaste = vi.fn();
it('forwards controlled draft edits and paste events', async () => {
const user = userEvent.setup();
const onPaste = vi.fn((event: ClipboardEvent) => event.preventDefault());
render(<DraftHarness onPaste={onPaste} onSubmitRequest={vi.fn()} />);
render(
<EditorAgentDraftTextarea
value=""
onChange={onChange}
onPaste={onPaste}
/>,
);
const input = screen.getByLabelText(
'发送给画布 Agent',
) as HTMLTextAreaElement;
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('新草稿');
fireEvent.paste(input);
const editor = screen.getByRole('textbox', {
name: '发送给画布 Agent',
});
await user.click(editor);
fireEvent.paste(editor, {
clipboardData: {
files: [],
getData: () => '',
items: [],
types: [],
},
});
expect(onPaste).toHaveBeenCalledTimes(1);
});
it('submits on Enter and keeps line breaks or IME confirmation from submitting', () => {
const onSubmit = vi.fn((event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
});
render(
<form onSubmit={onSubmit}>
<EditorAgentDraftTextarea value="草稿" onChange={vi.fn()} />
</form>,
it('submits on Enter but preserves Shift+Enter and IME confirmation', async () => {
const user = userEvent.setup();
const onSubmitRequest = vi.fn();
const { container } = render(
<DraftHarness onSubmitRequest={onSubmitRequest} />,
);
const input = screen.getByLabelText('发送给画布 Agent');
fireEvent.keyDown(input, { key: 'Enter', shiftKey: true });
expect(onSubmit).not.toHaveBeenCalled();
const editor = screen.getByRole('textbox', {
name: '发送给画布 Agent',
});
await user.click(editor);
fireEvent.keyDown(editor, { key: 'Enter', shiftKey: true });
expect(onSubmitRequest).not.toHaveBeenCalled();
fireEvent.keyDown(input, {
fireEvent.keyDown(editor, {
key: 'Enter',
isComposing: false,
isComposing: true,
keyCode: 229,
});
expect(onSubmit).not.toHaveBeenCalled();
expect(onSubmitRequest).not.toHaveBeenCalled();
fireEvent.keyDown(input, { key: 'Enter' });
expect(onSubmit).toHaveBeenCalledTimes(1);
fireEvent.keyDown(editor, { key: 'Enter' });
expect(onSubmitRequest).toHaveBeenCalledTimes(1);
expect(container.querySelector('form')).toBeNull();
expect(container.querySelector('textarea')).toBeNull();
});
});
@@ -1,39 +1,24 @@
import {
type ClipboardEventHandler,
type KeyboardEvent,
} from 'react';
import { AutoGrowTextArea } from '@/src/components/common/AutoGrowTextArea';
export function EditorAgentDraftTextarea({
value,
onChange,
onValueChange,
onPaste,
onSubmitRequest,
}: {
value: string;
onChange: (value: string) => void;
onPaste?: ClipboardEventHandler<HTMLTextAreaElement>;
onValueChange: (value: string) => void;
onPaste?: (event: ClipboardEvent) => void;
onSubmitRequest: () => void;
}) {
const handleKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
if (
event.key === 'Enter' &&
!event.shiftKey &&
!event.nativeEvent.isComposing &&
event.nativeEvent.keyCode !== 229
) {
event.preventDefault();
event.currentTarget.form?.requestSubmit();
}
};
return (
<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"
className="editor-agent-conversation__draft-input min-w-0 flex-1 border border-slate-200 bg-slate-50 text-sm text-slate-800 outline-none focus-within:border-slate-400"
aria-label="发送给画布 Agent"
value={value}
onChange={(event) => onChange(event.currentTarget.value)}
onValueChange={onValueChange}
onPaste={onPaste}
onKeyDown={handleKeyDown}
onSubmitRequest={onSubmitRequest}
/>
);
}
@@ -1,5 +1,4 @@
import {
type ClipboardEvent as ReactClipboardEvent,
useCallback,
useMemo,
useRef,
@@ -305,7 +304,7 @@ export function useConversationAttachments({
);
const handleInputPaste = useCallback(
(event: ReactClipboardEvent<HTMLTextAreaElement>) => {
(event: ClipboardEvent) => {
const imageFiles = extractClipboardImageFiles(event.clipboardData);
if (!imageFiles.length || isPastingAttachmentRef.current) {
return;
@@ -4,6 +4,7 @@ import { fireEvent, render, screen, within } from '@testing-library/react';
import { createRef, useState } from 'react';
import { describe, expect, it, vi } from 'vitest';
import { setPlainTextEditorValue } from '../common/AutoGrowTextArea.test-utils';
import { ImageCanvasBasicGenerationComposerView } from './ImageCanvasBasicGenerationComposerView';
import type {
GenerateDialogState,
@@ -83,9 +84,7 @@ describe('ImageCanvasBasicGenerationComposerView', () => {
/>,
);
fireEvent.change(screen.getByLabelText('生成提示词'), {
target: { value: '新的提示' },
});
setPlainTextEditorValue(screen.getByLabelText('生成提示词'), '新的提示');
const panel = screen.getByRole('dialog', { name: '生成图片' });
expect(
within(panel).queryByRole('textbox', { name: '资源名称' }),
@@ -216,7 +215,9 @@ describe('ImageCanvasBasicGenerationComposerView', () => {
name: '快速编辑提示词',
});
expect(prompt.className).toContain('auto-grow-text-area');
expect(prompt.closest('.auto-grow-text-area')?.className).toContain(
'auto-grow-text-area',
);
expect(prompt.className).not.toContain('platform-text-field');
fireEvent.keyDown(prompt, { key: 'Enter' });
expect(submitQuickEdit).not.toHaveBeenCalled();
@@ -84,7 +84,7 @@ type ImageCanvasBasicGenerationComposerViewProps = {
referenceAriaLabelFormatter?: ReferenceLabelFormatter;
referenceRemoveLabelFormatter?: ReferenceLabelFormatter;
showReferenceAddButton?: boolean;
formClassName?: string;
panelClassName?: string;
footerClassName?: string;
promptClassName?: string;
referenceSlotClassName?: string;
@@ -139,7 +139,7 @@ export function ImageCanvasBasicGenerationComposerView({
referenceAriaLabelFormatter,
referenceRemoveLabelFormatter,
showReferenceAddButton = true,
formClassName,
panelClassName,
footerClassName,
promptClassName = 'image-canvas-editor__generation-prompt',
referenceSlotClassName,
@@ -176,8 +176,8 @@ export function ImageCanvasBasicGenerationComposerView({
? '修改中'
: submittingStatusLabel;
const hasReferenceMenu = shouldIncludeReferences && onRequestUpload;
const finalFormClassName =
formClassName ??
const finalPanelClassName =
panelClassName ??
'image-canvas-editor__generation-composer image-canvas-editor__generation-composer--image';
const finalFooterClassName =
footerClassName ?? 'image-canvas-editor__generation-composer-footer';
@@ -206,18 +206,12 @@ export function ImageCanvasBasicGenerationComposerView({
return (
<>
<form
className={finalFormClassName}
<div
className={finalPanelClassName}
style={style}
role="dialog"
aria-label={resolvedDialogLabel}
onPointerDown={(event) => event.stopPropagation()}
onSubmit={(event) => {
event.preventDefault();
if (dialog.status !== 'generating') {
onSubmit(dialog);
}
}}
>
{shouldIncludeReferences ? (
<div className="image-canvas-editor__reference-strip">
@@ -285,12 +279,12 @@ export function ImageCanvasBasicGenerationComposerView({
disabled={dialog.status === 'generating'}
placeholder={resolvedPromptPlaceholder}
className={promptClassName}
onChange={(event) =>
onValueChange={(value) =>
setGenerateDialog((currentDialog) =>
currentDialog
? {
...resetFailedDialogStatus(currentDialog),
prompt: event.target.value,
prompt: value,
}
: currentDialog,
)
@@ -319,12 +313,12 @@ export function ImageCanvasBasicGenerationComposerView({
disabled={dialog.status === 'generating'}
placeholder="填写自定义画风,例如:90 年代复古像素风"
className={`${promptClassName} image-canvas-editor__scene-custom-style`}
onChange={(event) =>
onValueChange={(value) =>
setGenerateDialog((currentDialog) =>
currentDialog
? {
...resetFailedDialogStatus(currentDialog),
sceneCustomStyle: event.target.value,
sceneCustomStyle: value,
}
: currentDialog,
)
@@ -358,6 +352,11 @@ export function ImageCanvasBasicGenerationComposerView({
submitLabel={resolvedSubmitLabel}
submitAriaLabel={resolvedSubmitAriaLabel}
submitButtonClassName={submitButtonClassName}
onSubmitRequest={() => {
if (dialog.status !== 'generating') {
onSubmit(dialog);
}
}}
styleControl={styleControl}
renderEditorPortal={renderEditorPortal}
buildPortalMenuStyle={buildPortalMenuStyle}
@@ -385,7 +384,7 @@ export function ImageCanvasBasicGenerationComposerView({
{dialog.errorMessage}
</PlatformStatusMessage>
) : null}
</form>
</div>
{hasReferenceMenu &&
isGenerationReferenceMenuOpen &&
generationReferenceButtonRef
@@ -4,9 +4,12 @@ import { fireEvent, render, screen } from '@testing-library/react';
import { useState } from 'react';
import { describe, expect, it, vi } from 'vitest';
import { setPlainTextEditorValue } from '../common/AutoGrowTextArea.test-utils';
import { ImageCanvasCharacterAnimationPanelView } from './ImageCanvasCharacterAnimationPanelView';
import type { CanvasLayer, CharacterAnimationPanelState } from './ImageCanvasEditorTypes';
import type {
CanvasLayer,
CharacterAnimationPanelState,
} from './ImageCanvasEditorTypes';
function createSourceLayer(): CanvasLayer {
return {
@@ -61,14 +64,11 @@ function CharacterAnimationPanelHarness({
currentPanel
? {
...currentPanel,
frameCount:
frameCount === 48 ? 48 : frameCount === 40 ? 40 : 32,
frameCount: frameCount === 48 ? 48 : frameCount === 40 ? 40 : 32,
durationSeconds:
frameCount === 48 ? 6 : frameCount === 40 ? 5 : 4,
status:
currentPanel.status === 'failed'
? 'idle'
: currentPanel.status,
currentPanel.status === 'failed' ? 'idle' : currentPanel.status,
errorMessage:
currentPanel.status === 'failed'
? undefined
@@ -114,12 +114,12 @@ describe('ImageCanvasCharacterAnimationPanelView', () => {
);
const description = screen.getByLabelText('动画描述');
expect(description.className).toContain('auto-grow-text-area');
expect(description.closest('.auto-grow-text-area')?.className).toContain(
'auto-grow-text-area',
);
expect(description.className).not.toContain('platform-text-field');
fireEvent.change(description, {
target: { value: `${'a'.repeat(4001)}` },
});
setPlainTextEditorValue(description, 'a'.repeat(4001));
fireEvent.click(
screen.getByRole('button', { name: '动画参数 同图尺寸 · 4秒 · 480p' }),
);
@@ -128,7 +128,9 @@ describe('ImageCanvasCharacterAnimationPanelView', () => {
fireEvent.click(screen.getByRole('button', { name: '比例 16:9' }));
expect(screen.getByRole('menu', { name: '动画参数选项' })).toBeTruthy();
expect(screen.getByLabelText('当前动画描述').textContent).toHaveLength(4000);
expect(screen.getByLabelText('当前动画描述').textContent).toHaveLength(
4000,
);
expect(screen.getByLabelText('当前分辨率').textContent).toBe('720p');
expect(screen.getByLabelText('当前比例').textContent).toBe('16:9');
expect(menu).toBeTruthy();
@@ -142,9 +144,7 @@ describe('ImageCanvasCharacterAnimationPanelView', () => {
});
it('clicks the parent animation panel to collapse the parameter menu', () => {
render(
<CharacterAnimationPanelHarness initialPanel={createPanel()} />,
);
render(<CharacterAnimationPanelHarness initialPanel={createPanel()} />);
fireEvent.click(
screen.getByRole('button', { name: '动画参数 同图尺寸 · 4秒 · 480p' }),
@@ -211,15 +211,17 @@ describe('ImageCanvasCharacterAnimationPanelView', () => {
fireEvent.click(screen.getByRole('button', { name: '生成中' }));
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,
);
const description = screen.getByLabelText('动画描述');
expect(description.getAttribute('aria-disabled')).toBe('true');
expect(
description
.closest('.auto-grow-text-area')
?.getAttribute('data-disabled'),
).toBe('true');
expect(
(screen.getByRole('button', { name: '待机' }) as HTMLButtonElement)
.disabled,
).toBe(true);
expect(submitCharacterAnimation).not.toHaveBeenCalled();
});
@@ -161,18 +161,12 @@ export function ImageCanvasCharacterAnimationPanelView({
return (
<>
<form
<div
className="image-canvas-editor__character-animation-panel image-canvas-editor__generation-composer image-canvas-editor__generation-composer--image image-canvas-editor__generation-composer--character-animation"
style={style}
role="dialog"
aria-label="角色动画生成面板"
onPointerDown={(event) => event.stopPropagation()}
onSubmit={(event) => {
event.preventDefault();
if (!isGenerating) {
onSubmit();
}
}}
>
<div className="image-canvas-editor__reference-strip">
<ImageCanvasReferenceSlot
@@ -201,9 +195,7 @@ export function ImageCanvasCharacterAnimationPanelView({
disabled={isGenerating}
placeholder="你希望角色做什么动作?"
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) })
}
onValueChange={(value) => updatePanel({ promptText: value })}
/>
<div className="image-canvas-editor__character-animation-presets">
{CHARACTER_ANIMATION_ACTION_PROMPTS.map((preset) => (
@@ -244,12 +236,17 @@ export function ImageCanvasCharacterAnimationPanelView({
</PlatformInlineOptionButton>
</div>
<PlatformActionButton
type="submit"
type="button"
tone="secondary"
size="xs"
shape="pill"
className="image-canvas-editor__character-animation-submit"
disabled={isGenerating}
onClick={() => {
if (!isGenerating) {
onSubmit();
}
}}
>
{isGenerating ? '生成中' : `生成${price}泥点`}
</PlatformActionButton>
@@ -281,7 +278,7 @@ export function ImageCanvasCharacterAnimationPanelView({
{panel.errorMessage}
</PlatformStatusMessage>
) : null}
</form>
</div>
{isParameterMenuOpen
? renderPanelPortal(
<PlatformFloatingMenu
@@ -4,6 +4,10 @@ import { fireEvent, render, screen } from '@testing-library/react';
import { createRef, useState } from 'react';
import { describe, expect, it, vi } from 'vitest';
import {
getPlainTextEditorHost,
setPlainTextEditorValue,
} from '../common/AutoGrowTextArea.test-utils';
import { ImageCanvasCharacterGenerationComposerView } from './ImageCanvasCharacterGenerationComposerView';
import type {
GenerateDialogState,
@@ -94,21 +98,26 @@ function CharacterGenerationHarness({
describe('ImageCanvasCharacterGenerationComposerView', () => {
it('keeps the prompt as a bordered single text input with a question placeholder', () => {
render(<CharacterGenerationHarness />);
render(
<CharacterGenerationHarness initialDialog={createDialog({ prompt: '' })} />,
);
const panel = screen.getByRole('dialog', { name: '生成角色形象' });
const prompt = screen.getByRole('textbox', { name: '角色设定' });
const promptHost = getPlainTextEditorHost(prompt);
expect(
Array.from(panel.querySelectorAll('.image-canvas-editor__field-title'))
.map((node) => node.textContent)
.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(
expect(screen.getByText('你希望角色如何设计?')).toBeTruthy();
expect(promptHost.className).toContain('auto-grow-text-area');
expect(promptHost.className).not.toContain('platform-text-field');
expect(promptHost.className).toContain(
'image-canvas-editor__generation-prompt',
);
expect(promptHost.className).not.toContain(
'image-canvas-editor__generation-prompt--borderless',
);
});
@@ -125,9 +134,10 @@ describe('ImageCanvasCharacterGenerationComposerView', () => {
/>,
);
fireEvent.change(screen.getByLabelText('角色设定'), {
target: { value: '新的角色设定' },
});
setPlainTextEditorValue(
screen.getByLabelText('角色设定'),
'新的角色设定',
);
fireEvent.click(screen.getByRole('button', { name: '生成' }));
expect(screen.getByLabelText('当前角色设定').textContent).toBe(
@@ -111,18 +111,12 @@ export function ImageCanvasCharacterGenerationComposerView({
});
return (
<form
<div
className="image-canvas-editor__character-composer"
style={style}
role="dialog"
aria-label="生成角色形象"
onPointerDown={(event) => event.stopPropagation()}
onSubmit={(event) => {
event.preventDefault();
if (dialog.status !== 'generating') {
onSubmit(dialog);
}
}}
>
<div className="image-canvas-editor__reference-strip">
<ImageCanvasReferenceSlot
@@ -255,25 +249,25 @@ export function ImageCanvasCharacterGenerationComposerView({
)
: null}
</div>
<label className="image-canvas-editor__field-block image-canvas-editor__field-block--single">
<div className="image-canvas-editor__field-block image-canvas-editor__field-block--single">
<AutoGrowTextArea
aria-label="角色设定"
value={dialog.prompt}
disabled={dialog.status === 'generating'}
placeholder="你希望角色如何设计?"
className="image-canvas-editor__generation-prompt"
onChange={(event) =>
onValueChange={(value) =>
setGenerateDialog((currentDialog) =>
currentDialog?.mode === 'character'
? {
...resetFailedDialogStatus(currentDialog),
prompt: event.target.value,
prompt: value,
}
: currentDialog,
)
}
/>
</label>
</div>
{dialog.status === 'failed' ? (
<PlatformStatusMessage
tone="error"
@@ -300,10 +294,15 @@ export function ImageCanvasCharacterGenerationComposerView({
})}
submitLabel="生成"
submitAriaLabel="生成"
onSubmitRequest={() => {
if (dialog.status !== 'generating') {
onSubmit(dialog);
}
}}
renderEditorPortal={renderEditorPortal}
buildPortalMenuStyle={buildPortalMenuStyle}
/>
</div>
</form>
</div>
);
}
@@ -41,18 +41,12 @@ export function ImageCanvasCropExpandPanelView({
onSubmit,
}: ImageCanvasCropExpandPanelViewProps) {
return (
<form
<div
className="image-canvas-editor__crop-expand-panel"
style={style}
role="dialog"
aria-label="裁扩图片"
onPointerDown={(event) => event.stopPropagation()}
onSubmit={(event) => {
event.preventDefault();
if (panel.status !== 'processing') {
onSubmit();
}
}}
>
<div className="image-canvas-editor__crop-expand-head">
<strong></strong>
@@ -107,14 +101,19 @@ export function ImageCanvasCropExpandPanelView({
</PlatformActionButton>
<PlatformActionButton
type="submit"
type="button"
tone="primary"
size="sm"
disabled={panel.status === 'processing'}
onClick={() => {
if (panel.status !== 'processing') {
onSubmit();
}
}}
>
{panel.status === 'processing' ? '处理中' : '完成'}
</PlatformActionButton>
</div>
</form>
</div>
);
}
@@ -5,6 +5,7 @@ import { type ComponentProps, type ReactNode, useState } from 'react';
import { describe, expect, it, vi } from 'vitest';
import { AuthUiContext } from '../auth/AuthUiContext';
import { setPlainTextEditorValue } from '../common/AutoGrowTextArea.test-utils';
import { ImageCanvasEditGenerationModalView } from './ImageCanvasEditGenerationModalView';
import type { GenerateDialogState } from './ImageCanvasEditorTypes';
@@ -72,9 +73,7 @@ describe('ImageCanvasEditGenerationModalView', () => {
expect(modal?.parentElement?.className).not.toContain(
'platform-theme--dark',
);
fireEvent.change(screen.getByLabelText('生成提示词'), {
target: { value: '新的修改提示' },
});
setPlainTextEditorValue(screen.getByLabelText('生成提示词'), '新的修改提示');
fireEvent.click(screen.getByRole('button', { name: '修改3泥点' }));
expect(screen.getByLabelText('当前提示词').textContent).toBe(
@@ -60,7 +60,7 @@ export function ImageCanvasEditGenerationModalView({
submitLabel="修改"
submitAriaLabel={`修改${editPrice}泥点`}
submittingStatusLabel="修改中"
formClassName="image-canvas-editor__generate-form"
panelClassName="image-canvas-editor__generate-form"
footerClassName="image-canvas-editor__generate-body"
promptClassName="image-canvas-editor__generate-prompt"
submitButtonClassName="image-canvas-editor__generate-submit"
@@ -12,6 +12,7 @@ import userEvent from '@testing-library/user-event';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { ExternalGenerationTaskRecord } from '../../../packages/shared/src/contracts/externalGeneration';
import { setPlainTextEditorValue } from '../common/AutoGrowTextArea.test-utils';
import {
ApiClientError,
defaultEditorProjectLayers,
@@ -554,9 +555,9 @@ describe('ImageCanvasEditorView generation integration', () => {
expect(screen.queryByLabelText('图像生成占位图')).toBeNull();
const generateDialog = screen.getByRole('dialog', { name: '生成图片' });
expect(generateDialog).toBeTruthy();
expect(
(screen.getByLabelText('生成提示词') as HTMLTextAreaElement).value,
).toBe('刷新后恢复生成器');
expect(screen.getByLabelText('生成提示词').textContent).toBe(
'刷新后恢复生成器',
);
});
it('restores generated image composer from layer metadata when the dialog snapshot is missing', async () => {
@@ -609,9 +610,9 @@ describe('ImageCanvasEditorView generation integration', () => {
expect(
await screen.findByRole('dialog', { name: '生成图片' }),
).toBeTruthy();
expect(
(screen.getByLabelText('生成提示词') as HTMLTextAreaElement).value,
).toBe('元数据恢复生成器');
expect(screen.getByLabelText('生成提示词').textContent).toBe(
'元数据恢复生成器',
);
});
it('does not open a generator on direct click for tagged uploaded images', async () => {
@@ -781,11 +782,9 @@ describe('ImageCanvasEditorView generation integration', () => {
).value,
).toBe('非对称对抗');
expect(
(
within(publicationDialog).getByRole('textbox', {
name: '运营海报一句话描述游戏',
}) as HTMLTextAreaElement
).value,
within(publicationDialog).getByRole('textbox', {
name: '运营海报一句话描述游戏',
}).textContent,
).toBe('找到钥匙,开门逃离马戏团');
expect(within(publicationDialog).getByLabelText('首图参考')).toBeTruthy();
expect(screen.getByLabelText('宣发素材生成占位图')).toBeTruthy();
@@ -838,9 +837,11 @@ describe('ImageCanvasEditorView generation integration', () => {
.className,
).toContain('image-canvas-editor__reference-chip');
const generatePrompt = screen.getByLabelText('生成提示词');
expect(generatePrompt.className).toContain('auto-grow-text-area');
expect(generatePrompt.closest('.auto-grow-text-area')?.className).toContain(
'auto-grow-text-area',
);
expect(generatePrompt.className).not.toContain('platform-text-field');
expect(generatePrompt.className).toContain(
expect(generatePrompt.closest('.auto-grow-text-area')?.className).toContain(
'image-canvas-editor__generation-prompt',
);
expect(
@@ -865,9 +866,10 @@ describe('ImageCanvasEditorView generation integration', () => {
).toContain('image-canvas-editor__generation-submit');
expect(screen.getByRole('toolbar', { name: 'AI画布工具栏' })).toBeTruthy();
fireEvent.change(screen.getByLabelText('生成提示词'), {
target: { value: '一张明亮的拼图主视觉' },
});
setPlainTextEditorValue(
screen.getByLabelText('生成提示词'),
'一张明亮的拼图主视觉',
);
fireEvent.click(pixelArtToggle);
fireEvent.click(
within(generateDialog).getByRole('button', { name: '生成' }),
@@ -890,9 +892,7 @@ describe('ImageCanvasEditorView generation integration', () => {
});
const submittedGenerationInputs =
generateEditorImageMock.mock.calls[0]?.[0]?.generationInputs;
expect(JSON.stringify(submittedGenerationInputs)).not.toContain(
'pixelArt',
);
expect(JSON.stringify(submittedGenerationInputs)).not.toContain('pixelArt');
await waitFor(() => {
expect(screen.getByAltText(/画布图片:生成图片/)).toBeTruthy();
@@ -979,9 +979,10 @@ describe('ImageCanvasEditorView generation integration', () => {
const draggedFrameCenterY =
Number.parseFloat(draggedFrame.style.top) +
Number.parseFloat(draggedFrame.style.height) / 2;
fireEvent.change(screen.getByLabelText('生成提示词'), {
target: { value: '拖拽后的生成图' },
});
setPlainTextEditorValue(
screen.getByLabelText('生成提示词'),
'拖拽后的生成图',
);
fireEvent.click(screen.getByRole('button', { name: '生成' }));
await waitFor(() => {
@@ -1019,9 +1020,10 @@ describe('ImageCanvasEditorView generation integration', () => {
fireEvent.click(screen.getByRole('button', { name: '生成图片' }));
const generateDialog = screen.getByRole('dialog', { name: '生成图片' });
fireEvent.change(screen.getByLabelText('生成提示词'), {
target: { value: '生成中继续拖动的图片' },
});
setPlainTextEditorValue(
screen.getByLabelText('生成提示词'),
'生成中继续拖动的图片',
);
fireEvent.click(
within(generateDialog).getByRole('button', { name: '生成' }),
);
@@ -1098,9 +1100,10 @@ describe('ImageCanvasEditorView generation integration', () => {
});
fireEvent.click(screen.getByRole('button', { name: '生成图片' }));
fireEvent.change(screen.getByLabelText('生成提示词'), {
target: { value: '准备删除的生成中图片' },
});
setPlainTextEditorValue(
screen.getByLabelText('生成提示词'),
'准备删除的生成中图片',
);
fireEvent.click(screen.getByRole('button', { name: '生成' }));
const frame = screen.getByLabelText('图像生成占位图');
@@ -1213,9 +1216,10 @@ describe('ImageCanvasEditorView generation integration', () => {
render(<ImageCanvasEditorView />);
fireEvent.click(screen.getByRole('button', { name: '生成图片' }));
fireEvent.change(screen.getByLabelText('生成提示词'), {
target: { value: '一张真实生成失败的图' },
});
setPlainTextEditorValue(
screen.getByLabelText('生成提示词'),
'一张真实生成失败的图',
);
fireEvent.click(screen.getByRole('button', { name: '生成' }));
expect(screen.getByRole('status').textContent).toContain('生成中');
@@ -1240,9 +1244,10 @@ describe('ImageCanvasEditorView generation integration', () => {
render(<ImageCanvasEditorView />);
fireEvent.click(screen.getByRole('button', { name: '生成图片' }));
fireEvent.change(screen.getByLabelText('生成提示词'), {
target: { value: '一张需要登录生成的图' },
});
setPlainTextEditorValue(
screen.getByLabelText('生成提示词'),
'一张需要登录生成的图',
);
fireEvent.click(screen.getByRole('button', { name: '生成' }));
await waitFor(() => {
@@ -1263,9 +1268,10 @@ describe('ImageCanvasEditorView generation integration', () => {
'1:1',
'2K',
);
fireEvent.change(screen.getByLabelText('生成提示词'), {
target: { value: '生成中的普通图片' },
});
setPlainTextEditorValue(
screen.getByLabelText('生成提示词'),
'生成中的普通图片',
);
fireEvent.click(screen.getByRole('button', { name: '生成' }));
},
dialogName: '生成图片',
@@ -1284,9 +1290,10 @@ describe('ImageCanvasEditorView generation integration', () => {
screen.getByRole('menu', { name: '生成规范类型' }),
).getByRole('menuitem', { name: '自定义规范' }),
);
fireEvent.change(screen.getByLabelText('自定义规范提示词'), {
target: { value: '生成中的自定义规范图' },
});
setPlainTextEditorValue(
screen.getByLabelText('自定义规范提示词'),
'生成中的自定义规范图',
);
fireEvent.click(
within(screen.getByRole('dialog', { name: '生成规范' })).getByRole(
'button',
@@ -1306,9 +1313,10 @@ describe('ImageCanvasEditorView generation integration', () => {
'2:3',
'2K',
);
fireEvent.change(screen.getByLabelText('角色设定'), {
target: { value: '生成中的角色形象' },
});
setPlainTextEditorValue(
screen.getByLabelText('角色设定'),
'生成中的角色形象',
);
fireEvent.click(screen.getByRole('button', { name: '生成' }));
},
dialogName: '生成角色形象',
@@ -1397,12 +1405,12 @@ describe('ImageCanvasEditorView generation integration', () => {
clientY: 120,
},
);
fireEvent.change(
setPlainTextEditorValue(
within(screen.getByRole('dialog', { name: '生成图标素材' })).getByRole(
'textbox',
{ name: '素材描述' },
),
{ target: { value: '返回按钮' } },
'返回按钮',
);
fireEvent.click(
within(screen.getByRole('dialog', { name: '生成图标素材' })).getByRole(
@@ -1412,9 +1420,7 @@ describe('ImageCanvasEditorView generation integration', () => {
);
await waitFor(() => {
expect(
screen.queryByRole('dialog', { name: '生成图标素材' }),
).toBeNull();
expect(screen.queryByRole('dialog', { name: '生成图标素材' })).toBeNull();
});
const frame = screen.getByLabelText('图标素材生成占位图');
expect(frame.className).toContain(
@@ -1497,7 +1503,9 @@ describe('ImageCanvasEditorView generation integration', () => {
fireEvent.change(screen.getByLabelText('角色视角'), {
target: { value: '左向三分之二侧身站姿' },
});
fireEvent.submit(specDialog);
fireEvent.click(
within(specDialog).getByRole('button', { name: '提交生成规范' }),
);
await waitFor(() => {
expect(generateEditorImageMock).toHaveBeenCalledWith(
@@ -1689,9 +1697,10 @@ describe('ImageCanvasEditorView generation integration', () => {
const characterPanel = screen.getByRole('dialog', {
name: '生成角色形象',
});
fireEvent.change(within(characterPanel).getByLabelText('角色设定'), {
target: { value: '高个子游侠' },
});
setPlainTextEditorValue(
within(characterPanel).getByLabelText('角色设定'),
'高个子游侠',
);
fireEvent.click(
within(characterPanel).getByRole('button', { name: '生成' }),
);
@@ -1784,9 +1793,10 @@ describe('ImageCanvasEditorView generation integration', () => {
});
selectGenerationModel(characterPanel, 'gpt-image-2');
selectGenerationDimensions(characterPanel, '2:3', '2K');
fireEvent.change(within(characterPanel).getByLabelText('角色设定'), {
target: { value: '蓝衣剑士' },
});
setPlainTextEditorValue(
within(characterPanel).getByLabelText('角色设定'),
'蓝衣剑士',
);
fireEvent.click(
within(characterPanel).getByRole('button', { name: '生成' }),
);
@@ -1829,12 +1839,12 @@ describe('ImageCanvasEditorView generation integration', () => {
screen.getByRole('dialog', { name: '生成图标素材' }),
'图标规范 2',
);
fireEvent.change(
setPlainTextEditorValue(
within(screen.getByRole('dialog', { name: '生成图标素材' })).getByRole(
'textbox',
{ name: '素材描述' },
),
{ target: { value: '返回按钮' } },
'返回按钮',
);
fireEvent.click(
within(screen.getByRole('dialog', { name: '生成图标素材' })).getByRole(
@@ -1910,9 +1920,10 @@ describe('ImageCanvasEditorView generation integration', () => {
});
fireEvent.click(screen.getByRole('button', { name: '生成图片' }));
fireEvent.change(screen.getByLabelText('生成提示词'), {
target: { value: '生成中切换后仍保留位置' },
});
setPlainTextEditorValue(
screen.getByLabelText('生成提示词'),
'生成中切换后仍保留位置',
);
fireEvent.click(screen.getByRole('button', { name: '生成' }));
const originalFrame = screen.getByLabelText('图像生成占位图');
@@ -2078,16 +2089,16 @@ describe('ImageCanvasEditorView generation integration', () => {
iconPanel.querySelector('.image-canvas-editor__icon-spec-card'),
).toBeTruthy();
fireEvent.change(
setPlainTextEditorValue(
within(iconPanel).getByRole('textbox', { name: '素材描述' }),
{ target: { value: '返回按钮\n设置按钮\n提示按钮' } },
'返回按钮\n设置按钮\n提示按钮',
);
const iconPrompt = within(iconPanel).getByRole('textbox', {
name: '素材描述',
});
expect(iconPrompt.tagName).toBe('TEXTAREA');
expect(iconPrompt.className).toContain(
expect(iconPrompt.getAttribute('contenteditable')).toBe('true');
expect(iconPrompt.closest('.auto-grow-text-area')?.className).toContain(
'image-canvas-editor__generation-prompt',
);
expect(iconPrompt.className).not.toContain(
@@ -2118,9 +2129,10 @@ describe('ImageCanvasEditorView generation integration', () => {
render(<ImageCanvasEditorView />);
fireEvent.click(screen.getByRole('button', { name: '生成图片' }));
fireEvent.change(screen.getByLabelText('生成提示词'), {
target: { value: '发光蘑菇角色' },
});
setPlainTextEditorValue(
screen.getByLabelText('生成提示词'),
'发光蘑菇角色',
);
fireEvent.click(screen.getByRole('button', { name: '生成' }));
const generatedImage = await screen.findByAltText(//u);
@@ -2198,7 +2210,12 @@ describe('ImageCanvasEditorView generation integration', () => {
fireEvent.change(screen.getByLabelText('美术风格'), {
target: { value: '糖果玻璃拟物' },
});
fireEvent.submit(screen.getByRole('dialog', { name: '生成规范' }));
fireEvent.click(
within(screen.getByRole('dialog', { name: '生成规范' })).getByRole(
'button',
{ name: '提交生成规范' },
),
);
await waitFor(() => {
expect(generateEditorImageMock).toHaveBeenCalledWith(
@@ -2248,10 +2265,16 @@ describe('ImageCanvasEditorView generation integration', () => {
),
);
fireEvent.click(screen.getByRole('menuitem', { name: '自定义规范' }));
fireEvent.change(screen.getByLabelText('自定义规范提示词'), {
target: { value: ' 生成一张武器图标规范展板 ' },
});
fireEvent.submit(screen.getByRole('dialog', { name: '生成规范' }));
setPlainTextEditorValue(
screen.getByLabelText('自定义规范提示词'),
' 生成一张武器图标规范展板 ',
);
fireEvent.click(
within(screen.getByRole('dialog', { name: '生成规范' })).getByRole(
'button',
{ name: '提交生成规范' },
),
);
await waitFor(() => {
expect(generateEditorImageMock).toHaveBeenCalledWith(
@@ -2480,9 +2503,10 @@ describe('ImageCanvasEditorView generation integration', () => {
const readyCharacterPanel = screen.getByRole('dialog', {
name: '生成角色形象',
});
fireEvent.change(within(readyCharacterPanel).getByLabelText('角色设定'), {
target: { value: '银发游侠,蓝色披风,弓箭手,适合像素风战棋。' },
});
setPlainTextEditorValue(
within(readyCharacterPanel).getByLabelText('角色设定'),
'银发游侠,蓝色披风,弓箭手,适合像素风战棋。',
);
fireEvent.click(
within(readyCharacterPanel).getByRole('button', { name: '生成' }),
);
@@ -2495,10 +2519,7 @@ describe('ImageCanvasEditorView generation integration', () => {
model: 'gemini-3.1-flash-image-preview',
aspectRatio: '1:1',
imageSize: '1K',
referenceImageSrcs: [
'resource-character-spec',
'resource-big-fish',
],
referenceImageSrcs: ['resource-character-spec', 'resource-big-fish'],
projectId: 'editor-project-default',
assetFolderId: 'project',
assetKind: 'character',
@@ -2633,11 +2654,9 @@ describe('ImageCanvasEditorView generation integration', () => {
within(iconPanel).getByRole('button', { name: '图标规范' }),
).toBeTruthy();
expect(
(
within(iconPanel).getByRole('textbox', {
name: '素材描述',
}) as HTMLTextAreaElement
).value,
within(iconPanel).getByRole('textbox', {
name: '素材描述',
}).textContent,
).toBe('');
fireEvent.click(
@@ -2677,11 +2696,9 @@ describe('ImageCanvasEditorView generation integration', () => {
screen.queryByText('请选择画布中的图标规范,按 Esc 退出'),
).toBeNull();
fireEvent.change(
setPlainTextEditorValue(
within(iconPanel).getByRole('textbox', { name: '素材描述' }),
{
target: { value: '返回按钮\n设置按钮' },
},
'返回按钮\n设置按钮',
);
fireEvent.click(within(iconPanel).getByRole('button', { name: '生成' }));
@@ -2854,11 +2871,9 @@ describe('ImageCanvasEditorView generation integration', () => {
clientY: 100,
},
);
fireEvent.change(
setPlainTextEditorValue(
within(iconPanel).getByRole('textbox', { name: '素材描述' }),
{
target: { value: '返回按钮' },
},
'返回按钮',
);
fireEvent.click(within(iconPanel).getByRole('button', { name: '生成' }));
@@ -3139,11 +3154,9 @@ describe('ImageCanvasEditorView generation integration', () => {
screen.queryByText('请选择画布中的图标规范,按 Esc 退出'),
).toBeNull();
fireEvent.change(
setPlainTextEditorValue(
within(iconPanel).getByRole('textbox', { name: '素材描述' }),
{
target: { value: '背包按钮' },
},
'背包按钮',
);
fireEvent.click(within(iconPanel).getByRole('button', { name: '生成' }));
@@ -3344,21 +3357,23 @@ describe('ImageCanvasEditorView generation integration', () => {
).toBeTruthy();
}
fireEvent.click(within(panel).getByRole('button', { name: '待机' }));
expect(
(within(panel).getByLabelText('动画描述') as HTMLTextAreaElement).value,
).toContain('待机');
expect(within(panel).getByLabelText('动画描述').textContent).toContain(
'待机',
);
const longPrompt = '走'.repeat(4100);
fireEvent.change(within(panel).getByLabelText('动画描述'), {
target: { value: longPrompt },
});
expect(
(within(panel).getByLabelText('动画描述') as HTMLTextAreaElement).value,
).toHaveLength(4000);
setPlainTextEditorValue(
within(panel).getByLabelText('动画描述'),
longPrompt,
);
expect(within(panel).getByLabelText('动画描述').textContent).toHaveLength(
4000,
);
const precisePrompt =
'The elderly market woman gently shifts weight while the basket sways.';
fireEvent.change(within(panel).getByLabelText('动画描述'), {
target: { value: precisePrompt },
});
setPlainTextEditorValue(
within(panel).getByLabelText('动画描述'),
precisePrompt,
);
expect(
within(panel).getByLabelText(`生成文本:${precisePrompt}`),
).toBeTruthy();
@@ -3428,10 +3443,9 @@ describe('ImageCanvasEditorView generation integration', () => {
const remodelPanel = screen.getByRole('dialog', {
name: '角色动画生成面板',
});
expect(
(within(remodelPanel).getByLabelText('动画描述') as HTMLTextAreaElement)
.value,
).toBe(precisePrompt);
expect(within(remodelPanel).getByLabelText('动画描述').textContent).toBe(
precisePrompt,
);
});
it('extracts UI design assets from the floating toolbar and keeps the spritesheet on canvas', async () => {
@@ -3687,9 +3701,10 @@ describe('ImageCanvasEditorView generation integration', () => {
render(<ImageCanvasEditorView />);
fireEvent.click(screen.getByRole('button', { name: '生成图片' }));
fireEvent.change(screen.getByLabelText('生成提示词'), {
target: { value: '一张可修改的生成图' },
});
setPlainTextEditorValue(
screen.getByLabelText('生成提示词'),
'一张可修改的生成图',
);
fireEvent.click(screen.getByRole('button', { name: '生成' }));
await waitFor(() => {
@@ -13,6 +13,7 @@ import JSZip from 'jszip';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { usePlatformWalletStore } from '../../stores/usePlatformWalletStore';
import { setPlainTextEditorValue } from '../common/AutoGrowTextArea.test-utils';
import type { EditorAgentConversationClient } from './EditorAgentConversation/useEditorAgentConversation';
import {
ApiClientError,
@@ -895,11 +896,7 @@ describe('ImageCanvasEditorView', () => {
});
expect(
(
within(quickEditDialog).getByLabelText(
'快速编辑提示词',
) as HTMLTextAreaElement
).value,
within(quickEditDialog).getByLabelText('快速编辑提示词').textContent,
).toContain('对1号红色圈选框里的内容做以下修改');
dispatchPointerEvent(selectionCanvas, 'pointerdown', {
@@ -977,11 +974,11 @@ describe('ImageCanvasEditorView', () => {
clientY: 110,
});
const resumedPrompt = screen.getByLabelText(
'快速编辑提示词',
) as HTMLTextAreaElement;
expect(resumedPrompt.value).toContain('对1号红色圈选框里的内容做以下修改');
expect(resumedPrompt.value).not.toContain('2号红色圈选框');
const resumedPrompt = screen.getByLabelText('快速编辑提示词');
expect(resumedPrompt.textContent).toContain(
'对1号红色圈选框里的内容做以下修改',
);
expect(resumedPrompt.textContent).not.toContain('2号红色圈选框');
});
it('locks quick edit selection tools after the edit task starts', async () => {
@@ -1002,9 +999,10 @@ describe('ImageCanvasEditorView', () => {
const quickEditDialog = screen.getByRole('dialog', {
name: '快速编辑图片',
});
fireEvent.change(within(quickEditDialog).getByLabelText('快速编辑提示词'), {
target: { value: '快速修图' },
});
setPlainTextEditorValue(
within(quickEditDialog).getByLabelText('快速编辑提示词'),
'快速修图',
);
fireEvent.click(
within(
screen.getByRole('toolbar', { name: '快速编辑框选工具' }),
@@ -1253,9 +1251,7 @@ describe('ImageCanvasEditorView', () => {
await waitFor(() => {
expect(exportedBlob).toBeTruthy();
});
expect(downloadName).toMatch(
/^--\d{8}-\d{6}\.zip$/u,
);
expect(downloadName).toMatch(/^--\d{8}-\d{6}\.zip$/u);
const zip = await JSZip.loadAsync(exportedBlob!);
expect(zip.file('导出项目-画布素材/images/001-素材 A.png')).toBeTruthy();
@@ -1613,7 +1609,9 @@ describe('ImageCanvasEditorView', () => {
fireEvent.click(screen.getByRole('button', { name: '改造' }));
const panel = screen.getByRole('dialog', { name: '生成游戏音效' });
expect(within(panel).getByDisplayValue('金币跳出音')).toBeTruthy();
expect(
within(panel).getByRole('textbox', { name: 'prompt' }).textContent,
).toBe('金币跳出音');
expect(
within(panel).getByRole('button', { name: '音效时长 8秒' }),
).toBeTruthy();
@@ -10,6 +10,7 @@ import {
} from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { setPlainTextEditorValue } from '../common/AutoGrowTextArea.test-utils';
import { BACKGROUND_MUSIC_PROMPT_PRESETS } from './ImageCanvasBackgroundMusicPresetModel';
import type { BackgroundMusicGenerationDialogState } from './ImageCanvasBackgroundMusicPromptModel';
import type {
@@ -242,7 +243,7 @@ function getBackgroundMusicPanel() {
function getBackgroundMusicPromptTextarea() {
return within(getBackgroundMusicPanel()).getByRole('textbox', {
name: 'gpt_description_prompt',
}) as HTMLTextAreaElement;
});
}
function getBackgroundMusicActionButton(name: string) {
@@ -373,8 +374,6 @@ describe('ImageCanvasGenerationComposerView', () => {
expect(within(panel).queryByText('不得重新生成')).toBeNull();
expect(within(panel).queryByText('gpt-image-2')).toBeNull();
fireEvent.submit(panel);
expect(onRetryPerfectPixelOperation).not.toHaveBeenCalled();
expect(onSubmitImageGeneration).not.toHaveBeenCalled();
});
@@ -506,9 +505,11 @@ describe('ImageCanvasGenerationComposerView', () => {
const customStyle = within(panel).getByRole('textbox', {
name: '自定义画风',
});
expect(customStyle.tagName).toBe('TEXTAREA');
expect(customStyle.className).toContain('auto-grow-text-area');
expect(customStyle.className).toContain(
expect(customStyle.getAttribute('contenteditable')).toBe('true');
expect(customStyle.closest('.auto-grow-text-area')?.className).toContain(
'auto-grow-text-area',
);
expect(customStyle.closest('.auto-grow-text-area')?.className).toContain(
'image-canvas-editor__generation-prompt',
);
expect(customStyle.className).not.toContain('platform-text-field');
@@ -541,16 +542,14 @@ describe('ImageCanvasGenerationComposerView', () => {
const error = within(panel).getByRole('alert');
expect(error.textContent).toBe('请填写画面内容');
expect(error.className).toContain(
'image-canvas-editor__scene-field-error',
);
expect(error.className).toContain('image-canvas-editor__scene-field-error');
expect(sceneContent.getAttribute('aria-invalid')).toBe('true');
expect(sceneContent.getAttribute('aria-describedby')).toBe(error.id);
expect(
panel.querySelector('.image-canvas-editor__generate-status'),
).toBeNull();
fireEvent.change(sceneContent, { target: { value: '雨夜中的海边车站' } });
setPlainTextEditorValue(sceneContent, '雨夜中的海边车站');
expect(within(panel).queryByRole('alert')).toBeNull();
expect(sceneContent.getAttribute('aria-invalid')).toBeNull();
@@ -584,16 +583,14 @@ describe('ImageCanvasGenerationComposerView', () => {
const error = within(panel).getByRole('alert');
expect(error.textContent).toBe('请填写自定义画风');
expect(error.className).toContain(
'image-canvas-editor__scene-field-error',
);
expect(error.className).toContain('image-canvas-editor__scene-field-error');
expect(customStyle.getAttribute('aria-invalid')).toBe('true');
expect(customStyle.getAttribute('aria-describedby')).toBe(error.id);
expect(
panel.querySelector('.image-canvas-editor__generate-status'),
).toBeNull();
fireEvent.change(customStyle, { target: { value: '90 年代复古像素风' } });
setPlainTextEditorValue(customStyle, '90 年代复古像素风');
expect(within(panel).queryByRole('alert')).toBeNull();
expect(customStyle.getAttribute('aria-invalid')).toBeNull();
@@ -637,16 +634,16 @@ describe('ImageCanvasGenerationComposerView', () => {
within(panel).getByRole('button', { name: 'UI设计图标规范' }),
).toBeTruthy();
expect(
within(panel).getByRole('textbox', { name: 'UI设计要求' }).className,
within(panel)
.getByRole('textbox', { name: 'UI设计要求' })
.closest('.auto-grow-text-area')?.className,
).toContain('image-canvas-editor__generation-prompt');
expect(
panel.querySelector('.image-canvas-editor__generation-composer-footer'),
).toBeTruthy();
fireEvent.change(
setPlainTextEditorValue(
within(panel).getByRole('textbox', { name: 'UI设计要求' }),
{
target: { value: '主界面和结算弹窗' },
},
'主界面和结算弹窗',
);
expect(setGenerateDialog).toHaveBeenCalled();
const menu = screen.getByRole('menu', { name: '参考图来源' });
@@ -814,11 +811,9 @@ describe('ImageCanvasGenerationComposerView', () => {
).value,
).toBe('非对称对抗');
expect(
(
within(panel).getByRole('textbox', {
name: '运营海报一句话描述游戏',
}) as HTMLTextAreaElement
).value,
within(panel).getByRole('textbox', {
name: '运营海报一句话描述游戏',
}).textContent,
).toBe('找到钥匙,开门逃离马戏团');
expect(within(panel).getByLabelText('首图参考')).toBeTruthy();
expect(
@@ -1181,7 +1176,9 @@ describe('ImageCanvasGenerationComposerView', () => {
expect(screen.getByLabelText('当前音效时长').textContent).toBe('8');
expect(screen.getByRole('button', { name: '音效时长 8秒' })).toBeTruthy();
fireEvent.submit(panel);
fireEvent.click(
within(panel).getByRole('button', { name: '生成游戏音效' }),
);
expect(onSubmitImageGeneration).toHaveBeenCalledWith(
expect.objectContaining({
mode: 'audio-sound-effect',
@@ -1266,7 +1263,7 @@ describe('ImageCanvasGenerationComposerView', () => {
});
const textarea = getBackgroundMusicPromptTextarea();
expect(textarea.value).toBe(' 森林冒险背景音乐 ');
expect(textarea.textContent).toBe(' 森林冒险背景音乐 ');
expect(
within(getBackgroundMusicPanel()).getByText('8 / 200'),
).toBeTruthy();
@@ -1312,8 +1309,8 @@ describe('ImageCanvasGenerationComposerView', () => {
});
const textarea = getBackgroundMusicPromptTextarea();
expect(textarea.value).toBe(prompt);
expect(textarea.value).toHaveLength(2001);
expect(textarea.textContent).toBe(prompt);
expect(textarea.textContent).toHaveLength(2001);
expect(textarea.getAttribute('aria-invalid')).toBe('true');
expect(
within(getBackgroundMusicPanel()).getByText('2001 / 200'),
@@ -1419,7 +1416,7 @@ describe('ImageCanvasGenerationComposerView', () => {
const dialog = createBackgroundMusicDialog({ prompt: '森林冒险' });
renderBackgroundMusicComposer({ dialog, onSubmit });
fireEvent.submit(getBackgroundMusicPanel());
fireEvent.click(getBackgroundMusicActionButton('生成游戏背景音乐'));
expect(onSubmit).toHaveBeenCalledWith(dialog);
onSubmit.mockClear();
@@ -1427,8 +1424,8 @@ describe('ImageCanvasGenerationComposerView', () => {
dialog: createBackgroundMusicDialog({ prompt: ' ' }),
onSubmit,
});
fireEvent.submit(
within(blocked.container).getByRole('dialog', {
fireEvent.click(
within(blocked.container).getByRole('button', {
name: '生成游戏背景音乐',
}),
);
@@ -1451,7 +1448,9 @@ describe('ImageCanvasGenerationComposerView', () => {
const panel = getBackgroundMusicPanel();
expect(panel.getAttribute('aria-busy')).toBe('true');
expect(getBackgroundMusicPromptTextarea().readOnly).toBe(true);
expect(
getBackgroundMusicPromptTextarea().getAttribute('aria-readonly'),
).toBe('true');
expect(getBackgroundMusicActionButton('AI 补全').disabled).toBe(true);
expect(getBackgroundMusicActionButton('一键简化').disabled).toBe(true);
expect(getBackgroundMusicActionButton('撤销').disabled).toBe(true);
@@ -1470,7 +1469,9 @@ describe('ImageCanvasGenerationComposerView', () => {
});
expect(getBackgroundMusicPanel().getAttribute('aria-busy')).toBe('true');
expect(getBackgroundMusicPromptTextarea().readOnly).toBe(true);
expect(
getBackgroundMusicPromptTextarea().getAttribute('aria-readonly'),
).toBe('true');
expect(getBackgroundMusicActionButton('AI 补全').disabled).toBe(true);
expect(getBackgroundMusicActionButton('展开').disabled).toBe(true);
expect(
@@ -1714,12 +1715,10 @@ describe('ImageCanvasGenerationComposerView', () => {
name: '生成游戏音效',
});
expect(
(
within(soundEffectPanel).getByRole('textbox', {
name: 'prompt',
}) as HTMLTextAreaElement
).disabled,
).toBe(false);
within(soundEffectPanel)
.getByRole('textbox', { name: 'prompt' })
.getAttribute('aria-disabled'),
).toBeNull();
expect(
(
within(soundEffectPanel).getByRole('button', {
@@ -439,18 +439,12 @@ function ImageCanvasVideoGenerationComposerView({
return (
<>
<form
<div
className="image-canvas-editor__generation-composer image-canvas-editor__generation-composer--image image-canvas-editor__generation-composer--video"
style={style}
role="dialog"
aria-label="生成视频"
onPointerDown={(event) => event.stopPropagation()}
onSubmit={(event) => {
event.preventDefault();
if (!isGenerating) {
onSubmit(dialog);
}
}}
>
{supportsReferences ? (
<div className="image-canvas-editor__reference-strip">
@@ -486,9 +480,7 @@ function ImageCanvasVideoGenerationComposerView({
disabled={isGenerating}
placeholder="你希望生成什么视频?"
className="image-canvas-editor__generation-prompt"
onChange={(event) =>
updateVideoDialog({ prompt: event.target.value })
}
onValueChange={(value) => updateVideoDialog({ prompt: value })}
/>
<div className="image-canvas-editor__generation-composer-footer">
<div className="image-canvas-editor__option-popover-anchor image-canvas-editor__option-popover-anchor--dimensions">
@@ -698,13 +690,18 @@ function ImageCanvasVideoGenerationComposerView({
: null}
</div>
<PlatformActionButton
type="submit"
type="button"
tone="secondary"
size="xs"
shape="pill"
className="image-canvas-editor__generation-submit"
disabled={isGenerating}
aria-label="生成视频"
onClick={() => {
if (!isGenerating) {
onSubmit(dialog);
}
}}
>
{isGenerating ? (
'生成中'
@@ -718,7 +715,7 @@ function ImageCanvasVideoGenerationComposerView({
)}
</PlatformActionButton>
</div>
</form>
</div>
{isGenerationReferenceMenuOpen && supportsReferences
? renderEditorPortal(
<PlatformFloatingMenu
@@ -903,20 +900,13 @@ function ImageCanvasAudioGenerationComposerView({
};
return (
<form
<div
className="image-canvas-editor__generation-composer image-canvas-editor__generation-composer--image image-canvas-editor__generation-composer--audio image-canvas-editor__generation-composer--background-music"
style={style}
role="dialog"
aria-label={BACKGROUND_MUSIC_COMPOSER_LABEL}
aria-busy={isLocked}
onPointerDown={(event) => event.stopPropagation()}
onSubmit={(event) => {
event.preventDefault();
if (!canGenerate) {
return;
}
onSubmit(backgroundMusicDialog);
}}
>
{/* 与生图 / 生视频 / 角色 / 图标同源:自动增高到 max-h-32 后内部滚动。 */}
<AutoGrowTextArea
@@ -927,7 +917,7 @@ function ImageCanvasAudioGenerationComposerView({
readOnly={isLocked}
placeholder="你希望生成什么音乐?"
className="image-canvas-editor__generation-prompt"
onChange={(event) => updateBackgroundMusicPrompt(event.target.value)}
onValueChange={updateBackgroundMusicPrompt}
/>
<ImageCanvasBackgroundMusicPresetMarquee
presets={BACKGROUND_MUSIC_PROMPT_PRESETS}
@@ -1060,13 +1050,18 @@ function ImageCanvasAudioGenerationComposerView({
</PlatformInlineOptionButton>
</div>
<PlatformActionButton
type="submit"
type="button"
tone="secondary"
size="xs"
shape="pill"
className="image-canvas-editor__generation-submit"
disabled={!canGenerate}
aria-label={BACKGROUND_MUSIC_COMPOSER_LABEL}
onClick={() => {
if (canGenerate) {
onSubmit(backgroundMusicDialog);
}
}}
>
{isGenerating ? (
'生成中'
@@ -1080,7 +1075,7 @@ function ImageCanvasAudioGenerationComposerView({
)}
</PlatformActionButton>
</div>
</form>
</div>
);
}
@@ -1114,18 +1109,12 @@ function ImageCanvasAudioGenerationComposerView({
};
return (
<form
<div
className="image-canvas-editor__generation-composer image-canvas-editor__generation-composer--image image-canvas-editor__generation-composer--audio"
style={style}
role="dialog"
aria-label={dialogLabel}
onPointerDown={(event) => event.stopPropagation()}
onSubmit={(event) => {
event.preventDefault();
if (!isGenerating) {
onSubmit(dialog);
}
}}
>
<AutoGrowTextArea
aria-label="prompt"
@@ -1133,7 +1122,7 @@ function ImageCanvasAudioGenerationComposerView({
disabled={isGenerating}
placeholder="你希望生成什么音效?"
className="image-canvas-editor__generation-prompt"
onChange={(event) => updateAudioDialog({ prompt: event.target.value })}
onValueChange={(value) => updateAudioDialog({ prompt: value })}
/>
{dialog.status === 'failed' ? (
<PlatformStatusMessage
@@ -1215,13 +1204,18 @@ function ImageCanvasAudioGenerationComposerView({
</PlatformInlineOptionButton>
</div>
<PlatformActionButton
type="submit"
type="button"
tone="secondary"
size="xs"
shape="pill"
className="image-canvas-editor__generation-submit"
disabled={isGenerating}
aria-label={dialogLabel}
onClick={() => {
if (!isGenerating) {
onSubmit(dialog);
}
}}
>
{isGenerating ? (
'生成中'
@@ -1235,7 +1229,7 @@ function ImageCanvasAudioGenerationComposerView({
)}
</PlatformActionButton>
</div>
</form>
</div>
);
}
@@ -44,6 +44,7 @@ function ImageOptionsHarness({
})}
submitLabel="生成"
submitAriaLabel="生成角色形象"
onSubmitRequest={() => undefined}
/>
<output aria-label="当前模型">{dialog.imageModel}</output>
<output aria-label="当前比例">{dialog.aspectRatio}</output>
@@ -41,6 +41,7 @@ type ImageCanvasGenerationImageOptionsViewProps = {
submitLabel?: string;
submitAriaLabel?: string;
submitButtonClassName?: string;
onSubmitRequest: () => void;
lockedModel?: string;
styleControl?: ReactNode;
renderEditorPortal?: (node: ReactNode) => ReactNode;
@@ -134,6 +135,7 @@ export function ImageCanvasGenerationImageOptionsView({
submitLabel = '生成',
submitAriaLabel = '生成',
submitButtonClassName = 'image-canvas-editor__generation-submit',
onSubmitRequest,
lockedModel,
styleControl,
renderEditorPortal = (node) => node,
@@ -422,13 +424,14 @@ export function ImageCanvasGenerationImageOptionsView({
) : null}
{typeof cost === 'number' ? (
<PlatformActionButton
type="submit"
type="button"
tone="secondary"
size="xs"
shape="pill"
className={submitButtonClassName}
disabled={isGenerating || hasPendingImageReferenceUploads}
aria-label={submitAriaLabel}
onClick={onSubmitRequest}
>
{isGenerating ? (
'生成中'
@@ -4,6 +4,10 @@ import { fireEvent, render, screen, within } from '@testing-library/react';
import { createRef, useState } from 'react';
import { describe, expect, it, vi } from 'vitest';
import {
getPlainTextEditorHost,
setPlainTextEditorValue,
} from '../common/AutoGrowTextArea.test-utils';
import type {
GenerateDialogState,
UploadTarget,
@@ -110,9 +114,13 @@ describe('ImageCanvasIconSpritesheetComposerView', () => {
/>,
);
const input = screen.getByLabelText('素材描述') as HTMLTextAreaElement;
expect(input.value).toBe('');
expect(input.placeholder).toBe('你需要哪些图标?');
const input = screen.getByLabelText('素材描述');
expect(input.textContent).toBe('');
expect(
getPlainTextEditorHost(input).querySelector(
'.auto-grow-text-area__placeholder',
)?.textContent,
).toBe('你需要哪些图标?');
});
it('keeps the spec reference as a single first-row card without extra source buttons', () => {
@@ -197,9 +205,7 @@ describe('ImageCanvasIconSpritesheetComposerView', () => {
expect(description.className).toContain('auto-grow-text-area');
expect(description.className).not.toContain('platform-text-field');
fireEvent.change(description, {
target: { value: '魔法剑\n圆盾' },
});
setPlainTextEditorValue(description, '魔法剑\n圆盾');
fireEvent.click(screen.getByRole('button', { name: '生成' }));
expect(updateIconDescriptionText).toHaveBeenCalledWith('魔法剑\n圆盾');
@@ -246,8 +252,8 @@ describe('ImageCanvasIconSpritesheetComposerView', () => {
fireEvent.click(screen.getByRole('button', { name: '生成' }));
expect(
(screen.getByLabelText('素材描述') as HTMLInputElement).disabled,
).toBe(true);
screen.getByLabelText('素材描述').getAttribute('aria-disabled'),
).toBe('true');
expect(submitIconGeneration).not.toHaveBeenCalled();
rerender(
@@ -86,18 +86,12 @@ export function ImageCanvasIconSpritesheetComposerView({
const references = dialog.generationReferences ?? [];
return (
<form
<div
className="image-canvas-editor__icon-composer"
style={style}
role="dialog"
aria-label="生成图标素材"
onPointerDown={(event) => event.stopPropagation()}
onSubmit={(event) => {
event.preventDefault();
if (dialog.status !== 'generating') {
onSubmit({ ...dialog, prompt: descriptionText });
}
}}
>
<div className="image-canvas-editor__reference-strip">
<ImageCanvasReferenceSlot
@@ -209,16 +203,16 @@ export function ImageCanvasIconSpritesheetComposerView({
onClick={() => onRequestUpload('generation-reference')}
/>
</div>
<label className="image-canvas-editor__field-block image-canvas-editor__field-block--single">
<div className="image-canvas-editor__field-block image-canvas-editor__field-block--single">
<AutoGrowTextArea
aria-label="素材描述"
value={descriptionText}
disabled={dialog.status === 'generating'}
placeholder="你需要哪些图标?"
className="image-canvas-editor__generation-prompt"
onChange={(event) => onUpdateIconDescriptionText(event.target.value)}
onValueChange={onUpdateIconDescriptionText}
/>
</label>
</div>
{dialog.status === 'failed' ? (
<PlatformStatusMessage
tone="error"
@@ -244,10 +238,15 @@ export function ImageCanvasIconSpritesheetComposerView({
)}
submitLabel="生成"
submitAriaLabel="生成"
onSubmitRequest={() => {
if (dialog.status !== 'generating') {
onSubmit({ ...dialog, prompt: descriptionText });
}
}}
renderEditorPortal={renderEditorPortal}
buildPortalMenuStyle={buildPortalMenuStyle}
/>
</div>
</form>
</div>
);
}

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