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
@@ -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}