style: 画布编辑器多行输入框样式调整 (#130)
Project CI / Repository checks (push) Successful in 50s
Project CI / Backend tests (push) Successful in 3m30s
Project CI / Frontend tests (push) Successful in 3m42s
Project CI / Native shell tests (push) Successful in 11m34s

复用了画布agent的输入框:

* 高度自适应
* 滑动条样式变为浅色和位置不再出界

before
![shotmd-1785745933-compressed.webp](/attachments/95727f6d-63e1-48f7-b806-8203cd73d1eb)
after
![shotmd-1785745957-compressed.webp](/attachments/b5af077d-d3b9-4572-9135-39e288291e24)

Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/130
Co-authored-by: 王德宇 <kvtodev@outlook.com>
Co-committed-by: 王德宇 <kvtodev@outlook.com>
This commit was merged in pull request #130.
This commit is contained in:
2026-08-04 17:07:30 +08:00
committed by 段舒康
parent e6b3199490
commit 99d239c8bf
24 changed files with 571 additions and 228 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) =>
@@ -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',
);
@@ -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 })}
/>
@@ -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)}
/>
@@ -58,7 +58,15 @@ describe('ImageCanvasPublicationMaterialsDemoPanelView', () => {
const modelButton = screen.getByRole('button', {
name: '宣发素材模型 gpt-image-2',
}) as HTMLButtonElement;
const description = screen.getByRole('textbox', {
name: '游戏首图一句话描述游戏',
});
expect(description.className).toContain('auto-grow-text-area');
expect(description.className).not.toContain('platform-text-field');
expect(description.className).toContain(
'image-canvas-editor__generation-prompt',
);
expect(modelButton.disabled).toBe(true);
expect(modelButton.textContent).not.toContain('nanobanana2');
fireEvent.click(modelButton);
@@ -7,6 +7,7 @@ import {
type SetStateAction,
} from 'react';
import { AutoGrowTextArea } from '../common/AutoGrowTextArea';
import { PlatformFieldLabel } from '../common/PlatformFieldLabel';
import {
PlatformFloatingMenu,
@@ -264,15 +265,12 @@ export function ImageCanvasPublicationMaterialsDemoPanelView({
>
</PlatformFieldLabel>
<PlatformTextField
variant="textarea"
<AutoGrowTextArea
aria-label={`${workflow.label}一句话描述游戏`}
value={publicationGameInfo.gameDescription}
disabled={dialog.status === 'generating'}
placeholder="神奇数字马戏团里,帕姆尼失控了,找到钥匙,开门逃离马戏团,千万别被抓住了"
size="sm"
density="compact"
className="image-canvas-editor__publication-description"
className="image-canvas-editor__generation-prompt image-canvas-editor__publication-description"
onChange={(event) =>
setGenerateDialog((currentDialog) =>
updatePublicationGameInfoField(
@@ -138,6 +138,8 @@ describe('ImageCanvasSpecGenerationPanelView', () => {
).toBe(true);
expect(panel.textContent).not.toContain('UI设计要求');
expect(prompt.getAttribute('placeholder')).toBe('你希望这个 UI 长什么样?');
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',
@@ -276,6 +278,11 @@ describe('ImageCanvasSpecGenerationPanelView', () => {
expect(customPrompt.placeholder).toBe(
'写下你想要的任何内容,陶泥儿帮你形成新的约束',
);
expect(customPrompt.className).toContain('auto-grow-text-area');
expect(customPrompt.className).not.toContain('platform-text-field');
expect(customPrompt.className).toContain(
'image-canvas-editor__generation-prompt',
);
fireEvent.change(screen.getByLabelText('自定义规范提示词'), {
target: { value: '新的规范提示' },
@@ -7,6 +7,7 @@ import {
type SetStateAction,
} from 'react';
import { AutoGrowTextArea } from '../common/AutoGrowTextArea';
import { PlatformActionButton } from '../common/PlatformActionButton';
import { PlatformFieldLabel } from '../common/PlatformFieldLabel';
import {
@@ -249,14 +250,11 @@ export function ImageCanvasSpecGenerationPanelView({
<div className="image-canvas-editor__spec-fields">
{isUiDesignDialog ? (
<label className="image-canvas-editor__field-block image-canvas-editor__field-block--single">
<PlatformTextField
variant="textarea"
<AutoGrowTextArea
aria-label="UI设计要求"
value={dialog.prompt}
disabled={isGenerating}
placeholder="你希望这个 UI 长什么样?"
size="sm"
density="compact"
className="image-canvas-editor__generation-prompt"
onChange={(event) => {
const nextPrompt = event.target.value;
@@ -291,15 +289,12 @@ export function ImageCanvasSpecGenerationPanelView({
>
</PlatformFieldLabel>
<PlatformTextField
variant="textarea"
<AutoGrowTextArea
aria-label="自定义规范提示词"
value={dialog.specValues?.customPrompt ?? ''}
placeholder="写下你想要的任何内容,陶泥儿帮你形成新的约束"
disabled={isGenerating}
size="sm"
density="compact"
className="image-canvas-editor__spec-textarea"
className="image-canvas-editor__generation-prompt image-canvas-editor__spec-textarea"
onChange={(event) =>
onUpdateSpecFormValue('customPrompt', event.target.value)
}
@@ -0,0 +1,55 @@
import { readFileSync } from 'node:fs';
import { describe, expect, it } from 'vitest';
const stylesheet = readFileSync(
new URL('../../index.css', import.meta.url),
'utf8',
);
function readCssRule(selector: string) {
const ruleStart = stylesheet.indexOf(selector);
expect(ruleStart).toBeGreaterThanOrEqual(0);
const bodyStart = stylesheet.indexOf('{', ruleStart) + 1;
const bodyEnd = stylesheet.indexOf('}', bodyStart);
expect(bodyEnd).toBeGreaterThan(bodyStart);
return stylesheet.slice(bodyStart, bodyEnd);
}
describe('image canvas textarea chrome', () => {
it('keeps complete chrome in the canonical generation prompt styles', () => {
const generationRule = readCssRule(
'.image-canvas-editor__generation-prompt',
);
expect(generationRule).toContain('border: 1px solid');
expect(generationRule).toContain('border-radius: 1.05rem;');
expect(generationRule).toContain('background: #f8fafc;');
expect(generationRule).toContain('padding: 0.52rem 0.82rem;');
const generationFocusRule = readCssRule(
'.image-canvas-editor__generation-prompt:focus,',
);
expect(generationFocusRule).toContain(
'border-color: var(--image-canvas-brand-border-strong);',
);
expect(generationFocusRule).toContain(
'box-shadow: 0 0 0 3px var(--image-canvas-brand-focus-ring);',
);
const modalRule = readCssRule('.image-canvas-editor__generate-prompt');
expect(modalRule).toContain('border: 1px solid #d9dee8;');
expect(modalRule).toContain('border-radius: 0.45rem;');
expect(modalRule).toContain('background: #f8fafc;');
expect(modalRule).toContain('padding: 0.7rem;');
const modalFocusRule = readCssRule(
'.image-canvas-editor__generate-prompt:focus',
);
expect(modalFocusRule).toContain(
'border-color: var(--image-canvas-brand-border-strong);',
);
expect(modalFocusRule).toContain(
'box-shadow: 0 0 0 3px var(--image-canvas-brand-focus-ring);',
);
});
});
+6 -6
View File
@@ -8725,7 +8725,7 @@ button.image-canvas-editor__reference-chip:disabled {
.image-canvas-editor__generate-prompt {
min-height: 7rem;
resize: vertical;
resize: none;
border: 1px solid #d9dee8;
border-radius: 0.45rem;
background: #f8fafc;
@@ -16728,28 +16728,28 @@ button {
background: #3f3f46;
}
.editor-agent-conversation__draft-input {
.auto-grow-text-area {
scrollbar-color: #cbd5e1 transparent;
scrollbar-width: thin;
}
.editor-agent-conversation__draft-input::-webkit-scrollbar {
.auto-grow-text-area::-webkit-scrollbar {
width: 6px;
}
.editor-agent-conversation__draft-input::-webkit-scrollbar-track {
.auto-grow-text-area::-webkit-scrollbar-track {
margin-block: 10px;
background-color: transparent;
}
.editor-agent-conversation__draft-input::-webkit-scrollbar-thumb {
.auto-grow-text-area::-webkit-scrollbar-thumb {
border: 1px solid transparent;
border-radius: 999px;
background-color: #cbd5e1;
background-clip: padding-box;
}
.editor-agent-conversation__draft-input::-webkit-scrollbar-thumb:hover {
.auto-grow-text-area::-webkit-scrollbar-thumb:hover {
background-color: #94a3b8;
}