style: 画布编辑器多行输入框样式调整 (#130)
复用了画布agent的输入框: * 高度自适应 * 滑动条样式变为浅色和位置不再出界 before  after  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:
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user