合并主分支
合并资源卡依赖关系主线,保留钱包单一生命周期系列修改。
This commit is contained in:
@@ -57,9 +57,10 @@ export function PlatformAuthModalShell({
|
||||
closeOnEscape={false}
|
||||
size={size}
|
||||
showHeader={showHeader}
|
||||
portalTheme={platformTheme}
|
||||
zIndexClassName={zIndexClassName}
|
||||
overlayClassName={joinClassNames(
|
||||
`platform-theme platform-theme--${platformTheme} text-[var(--platform-text-strong)]`,
|
||||
'text-[var(--platform-text-strong)]',
|
||||
overlaySpacing === 'default' && '!px-3 !py-4 sm:!p-4',
|
||||
overlayClassName,
|
||||
)}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -289,6 +289,7 @@ test('creative image input panel confirms before removing uploaded image', () =>
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '移除拼图图片' }));
|
||||
const dialog = screen.getByRole('dialog', { name: '移除拼图图片?' });
|
||||
expect(dialog.parentElement?.className).toContain('platform-theme--light');
|
||||
expect(within(dialog).getByText('移除后需要重新上传图片。')).toBeTruthy();
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: '移除' }));
|
||||
expect(onMainImageRemove).toHaveBeenCalledTimes(1);
|
||||
|
||||
@@ -88,9 +88,7 @@ export function LegalDocumentModal({
|
||||
size="md"
|
||||
closeLabel="关闭法律信息"
|
||||
zIndexClassName={zIndexClassName ?? 'z-[150]'}
|
||||
overlayClassName={`platform-theme ${
|
||||
platformTheme ? `platform-theme--${platformTheme}` : ''
|
||||
}`}
|
||||
portalTheme={platformTheme ?? 'auto'}
|
||||
panelClassName="platform-remap-surface rounded-t-[1.4rem] sm:rounded-[1.4rem]"
|
||||
headerClassName="items-center"
|
||||
bodyClassName="px-4 py-0 sm:px-5"
|
||||
|
||||
@@ -24,6 +24,10 @@ test('renders a standard danger confirmation with cancel and confirm actions', (
|
||||
|
||||
const dialog = screen.getByRole('dialog', { name: '删除作品' });
|
||||
|
||||
expect(dialog.parentElement?.className).toContain('platform-theme--light');
|
||||
expect(dialog.parentElement?.className).toContain('z-[140]');
|
||||
expect(dialog.className).toContain('platform-remap-surface');
|
||||
expect(dialog.className).toContain('shadow-[0_24px_80px_rgba(0,0,0,0.55)]');
|
||||
expect(within(dialog).getByText('确认删除《潮雾列岛》吗?')).toBeTruthy();
|
||||
expect(within(dialog).getByText('删除后不可恢复。')).toBeTruthy();
|
||||
|
||||
|
||||
@@ -21,8 +21,13 @@ type PlatformDangerConfirmDialogProps = {
|
||||
panelClassName?: string;
|
||||
footerClassName?: string;
|
||||
confirmClassName?: string;
|
||||
zIndexClassName?: string;
|
||||
};
|
||||
|
||||
function joinClassNames(...classNames: Array<string | undefined>) {
|
||||
return classNames.filter(Boolean).join(' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* 平台危险确认弹窗。
|
||||
* 统一承接需要“确认 / 取消 + 危险主动作”语义的标准弹窗壳层。
|
||||
@@ -46,6 +51,7 @@ export function PlatformDangerConfirmDialog({
|
||||
panelClassName,
|
||||
footerClassName,
|
||||
confirmClassName,
|
||||
zIndexClassName = 'z-[140]',
|
||||
}: PlatformDangerConfirmDialogProps) {
|
||||
return (
|
||||
<UnifiedConfirmDialog
|
||||
@@ -65,9 +71,13 @@ export function PlatformDangerConfirmDialog({
|
||||
portal={portal}
|
||||
size={size}
|
||||
overlayClassName={overlayClassName}
|
||||
panelClassName={panelClassName}
|
||||
panelClassName={joinClassNames(
|
||||
'platform-remap-surface shadow-[0_24px_80px_rgba(0,0,0,0.55)]',
|
||||
panelClassName,
|
||||
)}
|
||||
footerClassName={footerClassName}
|
||||
confirmClassName={confirmClassName}
|
||||
zIndexClassName={zIndexClassName}
|
||||
>
|
||||
{children}
|
||||
</UnifiedConfirmDialog>
|
||||
|
||||
@@ -103,6 +103,7 @@ test('renders full-screen image preview with zoom controls and dark backdrop', (
|
||||
expect(
|
||||
dialog.parentElement?.className.includes('!bg-black'),
|
||||
).toBe(true);
|
||||
expect(dialog.parentElement?.className).not.toContain('platform-theme--');
|
||||
expect(screen.getByRole('button', { name: '放大图片' })).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: '缩小图片' })).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: '重置图片缩放' })).toBeTruthy();
|
||||
|
||||
@@ -243,6 +243,8 @@ export function PlatformImagePreviewModal({
|
||||
showHeader={false}
|
||||
showCloseButton={false}
|
||||
size="fullscreen"
|
||||
// 全黑自绘查看器不需要平台变量;避免 light / dark remap 改写黑底、白字和暗色控件。
|
||||
portalTheme="none"
|
||||
zIndexClassName={zIndexClassName}
|
||||
overlayClassName="!items-stretch !justify-stretch !bg-black !p-0 !backdrop-blur-none"
|
||||
panelClassName="platform-image-preview-modal !h-[100dvh] !max-h-none !max-w-none !rounded-none border-0 bg-black text-white shadow-none"
|
||||
|
||||
@@ -10,6 +10,10 @@ vi.mock('../../services/clipboard', () => ({
|
||||
copyTextToClipboard: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../auth/AuthUiContext', () => ({
|
||||
useAuthUi: () => ({ platformTheme: 'dark' }),
|
||||
}));
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
@@ -31,6 +35,8 @@ test('renders report fields and copies the joined report lines', async () => {
|
||||
);
|
||||
|
||||
const dialog = screen.getByRole('dialog', { name: '统一报告' });
|
||||
expect(dialog.parentElement?.className).toContain('platform-theme--light');
|
||||
expect(dialog.parentElement?.className).not.toContain('platform-theme--dark');
|
||||
expect(within(dialog).getByText('拼图草稿 puzzle-session-1')).toBeTruthy();
|
||||
expect(within(dialog).getByText('已完成')).toBeTruthy();
|
||||
|
||||
|
||||
@@ -54,6 +54,8 @@ export function PlatformReportDialog({
|
||||
open={open}
|
||||
title={title}
|
||||
onClose={onClose}
|
||||
// 报告中的二维码 / 扫码区固定使用浅色背景,保证对比度和识别率。
|
||||
platformTheme="light"
|
||||
overlayClassName={overlayClassName}
|
||||
panelClassName={panelClassName}
|
||||
bodyClassName="space-y-3"
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { useAuthUi } from '../auth/AuthUiContext';
|
||||
import { UnifiedModal } from './UnifiedModal';
|
||||
|
||||
type PlatformToolModalShellProps = {
|
||||
@@ -50,9 +49,6 @@ export function PlatformToolModalShell({
|
||||
bodyClassName,
|
||||
footerClassName,
|
||||
}: PlatformToolModalShellProps) {
|
||||
const resolvedPlatformTheme =
|
||||
useAuthUi()?.platformTheme ?? 'light';
|
||||
|
||||
return (
|
||||
<UnifiedModal
|
||||
open={open}
|
||||
@@ -68,7 +64,6 @@ export function PlatformToolModalShell({
|
||||
closeOnBackdrop={closeOnBackdrop}
|
||||
closeOnEscape={closeOnEscape}
|
||||
zIndexClassName={zIndexClassName}
|
||||
overlayClassName={`platform-theme platform-theme--${resolvedPlatformTheme}`}
|
||||
panelClassName={joinClassNames(
|
||||
'platform-remap-surface shadow-[0_24px_80px_rgba(0,0,0,0.55)]',
|
||||
panelClassName,
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render, screen, within } from '@testing-library/react';
|
||||
import { expect, test } from 'vitest';
|
||||
import { expect, test, vi } from 'vitest';
|
||||
|
||||
import { PlatformUtilityInfoModal } from './PlatformUtilityInfoModal';
|
||||
|
||||
vi.mock('../auth/AuthUiContext', () => ({
|
||||
useAuthUi: () => ({ platformTheme: 'dark' }),
|
||||
}));
|
||||
|
||||
test('renders platform utility info modal shell with default platform styling', () => {
|
||||
render(
|
||||
<PlatformUtilityInfoModal
|
||||
@@ -19,7 +23,7 @@ test('renders platform utility info modal shell with default platform styling',
|
||||
);
|
||||
|
||||
const dialog = screen.getByRole('dialog', { name: '工具信息' });
|
||||
expect(dialog.parentElement?.className).toContain('platform-theme--light');
|
||||
expect(dialog.parentElement?.className).toContain('platform-theme--dark');
|
||||
expect(dialog.parentElement?.className).toContain('!items-center');
|
||||
expect(dialog.className).toContain('platform-remap-surface');
|
||||
expect(dialog.className).toContain('rounded-[1.5rem]');
|
||||
|
||||
@@ -29,7 +29,7 @@ export function PlatformUtilityInfoModal({
|
||||
onClose,
|
||||
children,
|
||||
footer,
|
||||
platformTheme = 'light',
|
||||
platformTheme,
|
||||
overlayClassName,
|
||||
panelClassName,
|
||||
bodyClassName,
|
||||
@@ -41,11 +41,8 @@ export function PlatformUtilityInfoModal({
|
||||
title={title}
|
||||
onClose={onClose}
|
||||
size="sm"
|
||||
overlayClassName={joinClassNames(
|
||||
`platform-theme platform-theme--${platformTheme}`,
|
||||
'!items-center',
|
||||
overlayClassName,
|
||||
)}
|
||||
portalTheme={platformTheme}
|
||||
overlayClassName={joinClassNames('!items-center', overlayClassName)}
|
||||
panelClassName={joinClassNames('platform-remap-surface', panelClassName)}
|
||||
bodyClassName={joinClassNames(
|
||||
'space-y-4 px-4 py-4 sm:px-5 sm:py-5',
|
||||
|
||||
@@ -29,6 +29,10 @@ vi.mock('../../services/clipboard', () => ({
|
||||
copyTextToClipboard: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../auth/AuthUiContext', () => ({
|
||||
useAuthUi: () => ({ platformTheme: 'dark' }),
|
||||
}));
|
||||
|
||||
const payload: PublishShareModalPayload = {
|
||||
title: '暖灯猫街',
|
||||
publicWorkCode: 'PZ-00000001',
|
||||
@@ -119,7 +123,7 @@ describe('PublishShareModal', () => {
|
||||
|
||||
const dialog = screen.getByRole('dialog', { name: '分享给朋友' });
|
||||
expect(dialog.parentElement?.className).toContain('!items-center');
|
||||
expect(dialog.parentElement?.className).toContain('platform-theme--light');
|
||||
expect(dialog.parentElement?.className).toContain('platform-theme--dark');
|
||||
expect(dialog.className).toContain('platform-modal-shell');
|
||||
expect(dialog.className).toContain('rounded-[1.75rem]');
|
||||
expect(dialog.getAttribute('style')).toBeNull();
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
openHostShare,
|
||||
openHostShareGrid,
|
||||
} from '../../services/host-bridge/hostBridge';
|
||||
import { useAuthUi } from '../auth/AuthUiContext';
|
||||
import { ResolvedAssetImage } from '../ResolvedAssetImage';
|
||||
import { PlatformUtilityInfoModal } from './PlatformUtilityInfoModal';
|
||||
import { downloadPublishShareCardImage } from './publishShareCardImage';
|
||||
@@ -77,7 +76,6 @@ export function PublishShareModal({
|
||||
payload,
|
||||
onClose,
|
||||
}: PublishShareModalProps) {
|
||||
const platformTheme = useAuthUi()?.platformTheme ?? 'light';
|
||||
const [copyState, setCopyState] = useState<ActionState>('idle');
|
||||
const [downloadState, setDownloadState] = useState<ActionState>('idle');
|
||||
const [gridState, setGridState] = useState<ActionState>('idle');
|
||||
@@ -233,7 +231,6 @@ export function PublishShareModal({
|
||||
open={open && Boolean(payload)}
|
||||
title="分享给朋友"
|
||||
onClose={onClose}
|
||||
platformTheme={platformTheme}
|
||||
panelClassName="rounded-[1.75rem]"
|
||||
footerClassName="border-t-0 px-4 pb-5 pt-0 sm:px-5"
|
||||
footer={
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { PlatformActionButton } from './PlatformActionButton';
|
||||
import { UnifiedModal } from './UnifiedModal';
|
||||
import { UnifiedModal, type UnifiedModalPortalTheme } from './UnifiedModal';
|
||||
|
||||
type UnifiedConfirmDialogTone = 'primary' | 'danger';
|
||||
|
||||
@@ -24,6 +24,7 @@ type UnifiedConfirmDialogProps = {
|
||||
closeOnBackdrop?: boolean;
|
||||
showCloseButton?: boolean;
|
||||
portal?: boolean;
|
||||
portalTheme?: UnifiedModalPortalTheme;
|
||||
size?: 'sm' | 'md';
|
||||
overlayClassName?: string;
|
||||
panelClassName?: string;
|
||||
@@ -54,6 +55,7 @@ export function UnifiedConfirmDialog({
|
||||
closeOnBackdrop = true,
|
||||
showCloseButton = true,
|
||||
portal = true,
|
||||
portalTheme = 'auto',
|
||||
size = 'sm',
|
||||
overlayClassName,
|
||||
panelClassName,
|
||||
@@ -74,6 +76,7 @@ export function UnifiedConfirmDialog({
|
||||
closeOnBackdrop={closeOnBackdrop && !busy}
|
||||
showCloseButton={showCloseButton}
|
||||
portal={portal}
|
||||
portalTheme={portalTheme}
|
||||
size={size}
|
||||
overlayClassName={overlayClassName}
|
||||
panelClassName={panelClassName}
|
||||
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
} from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import type { PlatformTheme } from '../../../packages/shared/src/contracts/runtime';
|
||||
import { useAuthUi } from '../auth/AuthUiContext';
|
||||
import { PlatformModalCloseButton } from './PlatformModalCloseButton';
|
||||
|
||||
type UnifiedModalSize = 'sm' | 'md' | 'lg' | 'xl' | 'fullscreen';
|
||||
@@ -18,6 +20,8 @@ type UnifiedModalCloseIcon = ComponentProps<
|
||||
typeof PlatformModalCloseButton
|
||||
>['icon'];
|
||||
|
||||
export type UnifiedModalPortalTheme = PlatformTheme | 'auto' | 'none';
|
||||
|
||||
type UnifiedModalProps = {
|
||||
open: boolean;
|
||||
title: string;
|
||||
@@ -37,6 +41,7 @@ type UnifiedModalProps = {
|
||||
closeVariant?: UnifiedModalCloseVariant;
|
||||
closeIcon?: UnifiedModalCloseIcon;
|
||||
portal?: boolean;
|
||||
portalTheme?: UnifiedModalPortalTheme;
|
||||
zIndexClassName?: string;
|
||||
overlayClassName?: string;
|
||||
overlayStyle?: CSSProperties;
|
||||
@@ -63,6 +68,44 @@ function joinClassNames(
|
||||
return classNames.filter(Boolean).join(' ');
|
||||
}
|
||||
|
||||
function resolvePortalOverlayClassName({
|
||||
overlayClassName,
|
||||
portalTheme,
|
||||
contextualTheme,
|
||||
}: {
|
||||
overlayClassName?: string;
|
||||
portalTheme: UnifiedModalPortalTheme;
|
||||
contextualTheme: PlatformTheme;
|
||||
}) {
|
||||
if (portalTheme === 'none') {
|
||||
return overlayClassName;
|
||||
}
|
||||
|
||||
const classNames = overlayClassName?.split(/\s+/u).filter(Boolean) ?? [];
|
||||
const explicitTheme = classNames.find(
|
||||
(className): className is `platform-theme--${PlatformTheme}` =>
|
||||
className === 'platform-theme--light' ||
|
||||
className === 'platform-theme--dark',
|
||||
);
|
||||
const resolvedTheme =
|
||||
portalTheme === 'auto'
|
||||
? ((explicitTheme?.replace('platform-theme--', '') as
|
||||
PlatformTheme | undefined) ?? contextualTheme)
|
||||
: portalTheme;
|
||||
const remainingClassNames = classNames.filter(
|
||||
(className) =>
|
||||
className !== 'platform-theme' &&
|
||||
className !== 'platform-theme--light' &&
|
||||
className !== 'platform-theme--dark',
|
||||
);
|
||||
|
||||
return joinClassNames(
|
||||
'platform-theme',
|
||||
`platform-theme--${resolvedTheme}`,
|
||||
...remainingClassNames,
|
||||
);
|
||||
}
|
||||
|
||||
function UnifiedModalContent({
|
||||
open,
|
||||
title,
|
||||
@@ -91,7 +134,7 @@ function UnifiedModalContent({
|
||||
bodyClassName,
|
||||
footerClassName,
|
||||
panelStyle,
|
||||
}: Omit<UnifiedModalProps, 'portal'>) {
|
||||
}: Omit<UnifiedModalProps, 'portal' | 'portalTheme'>) {
|
||||
const generatedTitleId = useId();
|
||||
const descriptionId = useId();
|
||||
const titleId = titleIdProp ?? generatedTitleId;
|
||||
@@ -231,10 +274,30 @@ function UnifiedModalContent({
|
||||
* 统一模态窗口外壳。
|
||||
* 业务组件只传入标题、内容和操作区;遮罩、无障碍属性、Escape 与移动端布局在这里收口。
|
||||
*/
|
||||
export function UnifiedModal({ portal = true, ...props }: UnifiedModalProps) {
|
||||
export function UnifiedModal({
|
||||
portal = true,
|
||||
portalTheme = 'auto',
|
||||
overlayClassName,
|
||||
...props
|
||||
}: UnifiedModalProps) {
|
||||
const contextualTheme = useAuthUi()?.platformTheme ?? 'light';
|
||||
const resolvedProps = {
|
||||
...props,
|
||||
overlayClassName: portal
|
||||
? resolvePortalOverlayClassName({
|
||||
overlayClassName,
|
||||
portalTheme,
|
||||
contextualTheme,
|
||||
})
|
||||
: overlayClassName,
|
||||
};
|
||||
|
||||
if (!portal || typeof document === 'undefined') {
|
||||
return <UnifiedModalContent {...props} />;
|
||||
return <UnifiedModalContent {...resolvedProps} />;
|
||||
}
|
||||
|
||||
return createPortal(<UnifiedModalContent {...props} />, document.body);
|
||||
return createPortal(
|
||||
<UnifiedModalContent {...resolvedProps} />,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -569,6 +569,10 @@ describe('CreationLandingView', () => {
|
||||
);
|
||||
|
||||
const dialog = await screen.findByRole('dialog', { name: '角色英雄' });
|
||||
expect(dialog.parentElement?.className).not.toContain('platform-theme--');
|
||||
expect(dialog.parentElement?.className).toContain(
|
||||
'creation-landing__showcase-modal-overlay',
|
||||
);
|
||||
expect(within(dialog).getByText('赞')).toBeTruthy();
|
||||
expect(within(dialog).getByText('3')).toBeTruthy();
|
||||
expect(within(dialog).queryByRole('button', { name: /点赞/ })).toBeNull();
|
||||
|
||||
@@ -409,6 +409,8 @@ function CreationShowcaseModal({
|
||||
showHeader={false}
|
||||
showCloseButton={false}
|
||||
size="fullscreen"
|
||||
// 素材舞台是独立黑底视觉;避免平台主题 remap 把它改成普通白底工具弹窗。
|
||||
portalTheme="none"
|
||||
zIndexClassName="z-[120]"
|
||||
overlayClassName="creation-landing__showcase-modal-overlay"
|
||||
panelClassName="creation-landing__showcase-modal-panel"
|
||||
@@ -1174,7 +1176,8 @@ export function CreationLandingView({
|
||||
showHeader={false}
|
||||
showCloseButton={false}
|
||||
size="sm"
|
||||
overlayClassName="platform-theme platform-theme--light platform-mobile-home-welcome-overlay !items-center !p-4"
|
||||
portalTheme="light"
|
||||
overlayClassName="platform-mobile-home-welcome-overlay !items-center !p-4"
|
||||
panelClassName="platform-remap-surface platform-mobile-home-welcome-dialog"
|
||||
bodyClassName="platform-mobile-home-welcome-dialog__body"
|
||||
footerClassName="platform-mobile-home-welcome-dialog__footer"
|
||||
|
||||
+12
-6
@@ -338,9 +338,7 @@ describe('EditorAgentConversationPanelView', () => {
|
||||
expect(
|
||||
screen.getByText('一二三四五六七八九十甲乙丙丁戊己庚辛壬癸子丑寅卯'),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
screen.getByRole('option', { name: '角色参考' }),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByRole('option', { name: '角色参考' })).toBeTruthy();
|
||||
expect(screen.getByRole('option', { name: '新对话' })).toBeTruthy();
|
||||
|
||||
fireEvent.change(screen.getByLabelText('发送给画布 Agent'), {
|
||||
@@ -530,9 +528,9 @@ describe('EditorAgentConversationPanelView', () => {
|
||||
(screen.getByLabelText('发送给画布 Agent') as HTMLTextAreaElement).value,
|
||||
).toBe('需要保留的草稿');
|
||||
expect(screen.getByText('已经看到画布内容')).toBeTruthy();
|
||||
expect(
|
||||
(screen.getByLabelText('当前对话') as HTMLSelectElement).value,
|
||||
).toBe('conversation-1');
|
||||
expect((screen.getByLabelText('当前对话') as HTMLSelectElement).value).toBe(
|
||||
'conversation-1',
|
||||
);
|
||||
});
|
||||
|
||||
it('preserves newer draft edits while creating a conversation', async () => {
|
||||
@@ -1989,6 +1987,14 @@ describe('EditorAgentConversationPanelView', () => {
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '删除当前对话' }));
|
||||
const confirmDialog = screen.getByRole('dialog', { name: '删除对话' });
|
||||
expect(confirmDialog.parentElement?.className).toContain(
|
||||
'platform-theme--light',
|
||||
);
|
||||
expect(confirmDialog.parentElement?.className).toContain('z-[140]');
|
||||
expect(confirmDialog.className).toContain('platform-remap-surface');
|
||||
expect(
|
||||
within(confirmDialog).getByText('确认删除这个对话吗?'),
|
||||
).toBeTruthy();
|
||||
fireEvent.click(
|
||||
within(confirmDialog).getByRole('button', { name: '确认删除' }),
|
||||
);
|
||||
|
||||
+3
-1
@@ -391,7 +391,9 @@ export function EditorAgentConversationPanelView({
|
||||
setDeleteConfirmOpen(false),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
>
|
||||
确认删除这个对话吗?
|
||||
</PlatformDangerConfirmDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
+17
-89
@@ -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}
|
||||
|
||||
@@ -118,9 +118,11 @@ describe('MessageBubble', () => {
|
||||
clientX: 30,
|
||||
clientY: 40,
|
||||
});
|
||||
const menu = screen.getByRole('menu', { name: '消息右键菜单' });
|
||||
expect(menu.style.zIndex).toBe('60');
|
||||
expect(
|
||||
screen.getByRole('menu', { name: '消息右键菜单' }).style.zIndex,
|
||||
).toBe('60');
|
||||
menu.closest('.image-canvas-editor__portal-theme')?.className,
|
||||
).toContain('platform-theme--light');
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '复制文本' }));
|
||||
|
||||
await waitFor(() =>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user