合并最新 master 并解决画布共享冲突
同步 origin/master 的场景、音效、运行时与后端原子提交改动 保留共享画布包导出并补齐场景、生成配方和历史动作类型 融合清单刷新测试、幂等请求、生成合同校验与项目记忆文档
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 3.6 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.3 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.5 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.1 MiB |
@@ -166,7 +166,14 @@ test('direct account entry does not render the settings shell as another dialog'
|
||||
renderAccountModal({ entryMode: 'account' });
|
||||
|
||||
const accountDialog = screen.getByRole('dialog', { name: '账号信息' });
|
||||
const accountBody = accountDialog.firstElementChild as HTMLElement | null;
|
||||
const accountWrapper = accountBody?.firstElementChild as HTMLElement | null;
|
||||
expect(accountDialog).toBeTruthy();
|
||||
expect(accountDialog.style.background).toBe('none');
|
||||
expect(accountDialog.style.borderWidth).toBe('0px');
|
||||
expect(accountDialog.className).toContain('!overflow-visible');
|
||||
expect(accountBody?.className).toContain('!overflow-visible');
|
||||
expect(accountWrapper?.className).toContain('overflow-visible');
|
||||
expect(screen.queryByRole('dialog', { name: '设置与账号安全' })).toBeNull();
|
||||
expect(screen.queryByText('设置与账号安全')).toBeNull();
|
||||
expect(
|
||||
|
||||
@@ -518,18 +518,26 @@ export function AccountModal({
|
||||
authCardClassName={isDirectAccountMode ? '' : undefined}
|
||||
panelClassName={
|
||||
isDirectAccountMode
|
||||
? 'relative !max-w-3xl !rounded-none !bg-transparent !shadow-none'
|
||||
? 'relative !max-w-3xl !overflow-visible !rounded-none !bg-transparent !shadow-none'
|
||||
: 'relative !h-[min(100%,calc(100vh-2rem))] !max-w-5xl !rounded-[28px] !p-5 sm:!p-6'
|
||||
}
|
||||
bodyClassName="!flex !min-h-0 !flex-1 !overflow-hidden !p-0"
|
||||
bodyClassName={`!flex !min-h-0 !flex-1 !p-0 ${
|
||||
isDirectAccountMode ? '!overflow-visible' : '!overflow-hidden'
|
||||
}`}
|
||||
panelStyle={{
|
||||
maxHeight: ACCOUNT_MODAL_MAX_HEIGHT,
|
||||
...(isDirectAccountMode
|
||||
? {
|
||||
background: 'none',
|
||||
borderWidth: 0,
|
||||
}
|
||||
: {}),
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
isDirectAccountMode
|
||||
? 'relative flex max-h-full w-full max-w-3xl min-h-0 flex-col overflow-hidden'
|
||||
? 'relative flex max-h-full w-full max-w-3xl min-h-0 flex-col overflow-visible'
|
||||
: 'relative flex h-full w-full min-h-0 flex-col overflow-hidden'
|
||||
}
|
||||
style={{ maxHeight: ACCOUNT_MODAL_MAX_HEIGHT }}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { act, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { StrictMode, useEffect, useState } from 'react';
|
||||
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import type { AuthSessionSummary, AuthUser } from '../../services/authService';
|
||||
@@ -16,6 +16,26 @@ import { useAuthUi } from './AuthUiContext';
|
||||
|
||||
const browserReloadMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
const walletLifecycleMocks = vi.hoisted(() => ({
|
||||
usePlatformWalletLifecycle: vi.fn(),
|
||||
}));
|
||||
|
||||
function createMemoryStorage(): Storage {
|
||||
const values = new Map<string, string>();
|
||||
return {
|
||||
get length() {
|
||||
return values.size;
|
||||
},
|
||||
clear: () => values.clear(),
|
||||
getItem: (key) => values.get(key) ?? null,
|
||||
key: (index) => Array.from(values.keys())[index] ?? null,
|
||||
removeItem: (key) => values.delete(key),
|
||||
setItem: (key, value) => values.set(key, String(value)),
|
||||
};
|
||||
}
|
||||
|
||||
const memoryLocalStorage = createMemoryStorage();
|
||||
|
||||
const authMocks = vi.hoisted(() => ({
|
||||
authEntry: vi.fn(),
|
||||
changePassword: vi.fn(),
|
||||
@@ -77,6 +97,8 @@ vi.mock('../../services/authService', () => ({
|
||||
startWechatLogin: authMocks.startWechatLogin,
|
||||
}));
|
||||
|
||||
vi.mock('../../stores/usePlatformWalletStore', () => walletLifecycleMocks);
|
||||
|
||||
const hostBridgeMocks = vi.hoisted(() => ({
|
||||
getHostRuntime: vi.fn(() => ({
|
||||
kind: 'browser',
|
||||
@@ -136,6 +158,10 @@ const mockUser: AuthUser = {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
Object.defineProperty(window, 'localStorage', {
|
||||
configurable: true,
|
||||
value: memoryLocalStorage,
|
||||
});
|
||||
window.localStorage.clear();
|
||||
window.history.replaceState(null, '', '/');
|
||||
setAuthGateReloadForTest(vi.fn());
|
||||
@@ -381,6 +407,32 @@ test('auth gate keeps a valid local token login when refresh rotation fails afte
|
||||
expect(authMocks.getCurrentAuthUser).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('auth root binds the single wallet lifecycle under StrictMode', async () => {
|
||||
authMocks.getStoredAccessToken.mockReturnValue('jwt-existing-token');
|
||||
authMocks.refreshStoredAccessToken.mockRejectedValue(
|
||||
new Error('refresh cookie 失效'),
|
||||
);
|
||||
authMocks.getCurrentAuthUser.mockResolvedValue({
|
||||
user: mockUser,
|
||||
availableLoginMethods: ['phone'],
|
||||
});
|
||||
|
||||
render(
|
||||
<StrictMode>
|
||||
<AuthGate>
|
||||
<LogoutStateProbe />
|
||||
</AuthGate>
|
||||
</StrictMode>,
|
||||
);
|
||||
|
||||
expect(await screen.findByText('当前用户:测试玩家')).toBeTruthy();
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
walletLifecycleMocks.usePlatformWalletLifecycle,
|
||||
).toHaveBeenCalledWith('user-1', true);
|
||||
});
|
||||
});
|
||||
|
||||
test('auth gate does not auto-create a guest account when dev guest switch is not explicitly enabled', async () => {
|
||||
authMocks.getAuthLoginOptions.mockResolvedValue({
|
||||
availableLoginMethods: [],
|
||||
|
||||
@@ -50,6 +50,7 @@ import {
|
||||
reloadHostWebView,
|
||||
requestHostLogin,
|
||||
} from '../../services/host-bridge/hostBridge';
|
||||
import { usePlatformWalletLifecycle } from '../../stores/usePlatformWalletStore';
|
||||
import { PlatformActionButton } from '../common/PlatformActionButton';
|
||||
import { AccountModal } from './AccountModal';
|
||||
import { AuthUiContext, type PlatformSettingsSection } from './AuthUiContext';
|
||||
@@ -117,10 +118,7 @@ function normalizeAvailableLoginMethods(
|
||||
|
||||
// 登录面板的核心入口必须稳定展示,login-options 只补充微信等环境相关入口。
|
||||
return Array.from(
|
||||
new Set<AuthLoginMethod>([
|
||||
...REQUIRED_LOGIN_METHODS,
|
||||
...normalizedMethods,
|
||||
]),
|
||||
new Set<AuthLoginMethod>([...REQUIRED_LOGIN_METHODS, ...normalizedMethods]),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -192,10 +190,7 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
}
|
||||
|
||||
const markAuthStateReloadIfChanged = useCallback(
|
||||
(
|
||||
nextUser: AuthUser | null,
|
||||
options: { reloadOnChange?: boolean } = {},
|
||||
) => {
|
||||
(nextUser: AuthUser | null, options: { reloadOnChange?: boolean } = {}) => {
|
||||
const nextHasUser = Boolean(nextUser);
|
||||
const previousHasUser = lastStableAuthPresenceRef.current;
|
||||
if (previousHasUser === null) {
|
||||
@@ -204,23 +199,23 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
}
|
||||
|
||||
lastStableAuthPresenceRef.current = nextHasUser;
|
||||
if (
|
||||
previousHasUser !== nextHasUser &&
|
||||
options.reloadOnChange !== false
|
||||
) {
|
||||
if (previousHasUser !== nextHasUser && options.reloadOnChange !== false) {
|
||||
pendingAuthStateReloadRef.current = true;
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const activateReadyUser = useCallback((nextUser: AuthUser) => {
|
||||
// 受保护业务 hook 只在 readyUser 暴露后启动,必须先保证请求层能带 Bearer token。
|
||||
authHydrateVersionRef.current += 1;
|
||||
markAuthStateReloadIfChanged(nextUser);
|
||||
setUser(nextUser);
|
||||
setStatus('ready');
|
||||
}, [markAuthStateReloadIfChanged]);
|
||||
const activateReadyUser = useCallback(
|
||||
(nextUser: AuthUser) => {
|
||||
// 受保护业务 hook 只在 readyUser 暴露后启动,必须先保证请求层能带 Bearer token。
|
||||
authHydrateVersionRef.current += 1;
|
||||
markAuthStateReloadIfChanged(nextUser);
|
||||
setUser(nextUser);
|
||||
setStatus('ready');
|
||||
},
|
||||
[markAuthStateReloadIfChanged],
|
||||
);
|
||||
|
||||
const clearLocalAuthenticatedState = useCallback(
|
||||
(options: { reloadOnChange?: boolean } = {}) => {
|
||||
@@ -687,6 +682,11 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
],
|
||||
);
|
||||
|
||||
usePlatformWalletLifecycle(
|
||||
readyUser?.id ?? null,
|
||||
status === 'ready' && Boolean(readyUser),
|
||||
);
|
||||
|
||||
if (status === 'checking' && !canKeepPlatformContentMounted) {
|
||||
return (
|
||||
<div
|
||||
@@ -969,7 +969,11 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
const registrationInviteCode =
|
||||
pendingInviteCode || readInviteCodeFromLocation();
|
||||
const response = registrationInviteCode
|
||||
? await loginWithPhoneCode(phone, code, registrationInviteCode)
|
||||
? await loginWithPhoneCode(
|
||||
phone,
|
||||
code,
|
||||
registrationInviteCode,
|
||||
)
|
||||
: await loginWithPhoneCode(phone, code);
|
||||
const autoRedeemedInvite = response.referral?.ok === true;
|
||||
setStoredLastLoginPhone(phone);
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { act } from '@testing-library/react';
|
||||
import {
|
||||
$createLineBreakNode,
|
||||
$createParagraphNode,
|
||||
$createTextNode,
|
||||
$getRoot,
|
||||
getNearestEditorFromDOMNode,
|
||||
} from 'lexical';
|
||||
|
||||
export function setPlainTextEditorValue(
|
||||
contentEditable: HTMLElement,
|
||||
value: string,
|
||||
) {
|
||||
const editor = getNearestEditorFromDOMNode(contentEditable);
|
||||
if (!editor) {
|
||||
throw new Error('Expected a Lexical editor root');
|
||||
}
|
||||
|
||||
act(() => {
|
||||
editor.update(
|
||||
() => {
|
||||
const paragraph = $createParagraphNode();
|
||||
value.split('\n').forEach((line, index, lines) => {
|
||||
if (line) {
|
||||
paragraph.append($createTextNode(line));
|
||||
}
|
||||
if (index < lines.length - 1) {
|
||||
paragraph.append($createLineBreakNode());
|
||||
}
|
||||
});
|
||||
$getRoot().clear().append(paragraph);
|
||||
paragraph.selectEnd();
|
||||
},
|
||||
{ discrete: true },
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export function getPlainTextEditorHost(contentEditable: HTMLElement) {
|
||||
const host = contentEditable.closest<HTMLElement>(
|
||||
'.auto-grow-text-area',
|
||||
);
|
||||
if (!host) {
|
||||
throw new Error('Expected an auto-grow plain-text editor host');
|
||||
}
|
||||
return host;
|
||||
}
|
||||
@@ -1,288 +1,175 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { useState } from 'react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { AutoGrowTextArea } from './AutoGrowTextArea';
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
beforeEach(() => {
|
||||
Range.prototype.getBoundingClientRect = vi.fn(() => new DOMRect());
|
||||
Range.prototype.getClientRects = vi.fn(() => [] as unknown as DOMRectList);
|
||||
vi.stubGlobal(
|
||||
'ResizeObserver',
|
||||
class ResizeObserver {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
},
|
||||
);
|
||||
vi.stubGlobal(
|
||||
'ClipboardEvent',
|
||||
class ClipboardEvent extends Event {
|
||||
clipboardData: DataTransfer;
|
||||
|
||||
constructor(
|
||||
type: string,
|
||||
init: EventInit & { clipboardData: DataTransfer },
|
||||
) {
|
||||
super(type, init);
|
||||
this.clipboardData = init.clipboardData;
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
function pastePlainText(editor: HTMLElement, value: string) {
|
||||
const clipboardData = {
|
||||
files: [],
|
||||
getData: (type: string) => (type === 'text/plain' ? value : ''),
|
||||
items: [],
|
||||
types: ['text/plain'],
|
||||
} as unknown as DataTransfer;
|
||||
fireEvent(
|
||||
editor,
|
||||
new ClipboardEvent('paste', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
clipboardData,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function ControlledEditor({
|
||||
initialValue = '',
|
||||
maxLength,
|
||||
}: {
|
||||
initialValue?: string;
|
||||
maxLength?: number;
|
||||
}) {
|
||||
const [value, setValue] = useState(initialValue);
|
||||
return (
|
||||
<>
|
||||
<AutoGrowTextArea
|
||||
aria-label="受控编辑器"
|
||||
value={value}
|
||||
maxLength={maxLength}
|
||||
onValueChange={setValue}
|
||||
/>
|
||||
<output aria-label="React 文本状态">{value}</output>
|
||||
<button type="button" onClick={() => setValue('外部\n更新')}>
|
||||
外部更新
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
describe('AutoGrowTextArea', () => {
|
||||
it('uses native field sizing when the browser supports it', () => {
|
||||
const supports = vi.fn().mockReturnValue(true);
|
||||
const resizeObserver = vi.fn();
|
||||
const onChange = vi.fn();
|
||||
vi.stubGlobal('CSS', { supports });
|
||||
vi.stubGlobal('ResizeObserver', resizeObserver);
|
||||
it('keeps React state authoritative and preserves exact line breaks', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ControlledEditor initialValue={'第一行\n第二行'} />);
|
||||
|
||||
render(
|
||||
<AutoGrowTextArea
|
||||
aria-label="自动增长输入框"
|
||||
value=""
|
||||
onChange={onChange}
|
||||
/>,
|
||||
const editor = screen.getByRole('textbox', { name: '受控编辑器' });
|
||||
expect(screen.getByLabelText('React 文本状态').textContent).toBe(
|
||||
'第一行\n第二行',
|
||||
);
|
||||
|
||||
const input = screen.getByLabelText(
|
||||
'自动增长输入框',
|
||||
) as HTMLTextAreaElement;
|
||||
expect(supports).toHaveBeenCalledWith('field-sizing', 'content');
|
||||
expect(resizeObserver).not.toHaveBeenCalled();
|
||||
expect(input.getAttribute('rows')).toBe('1');
|
||||
expect(input.className).toContain('auto-grow-text-area');
|
||||
expect(input.className).toContain('[field-sizing:content]');
|
||||
expect(input.className).toContain('min-h-10');
|
||||
expect(input.className).toContain('max-h-32');
|
||||
expect(input.className).toContain('overflow-y-auto');
|
||||
expect(input.className).toContain('disabled:cursor-not-allowed');
|
||||
expect(input.className).toContain('disabled:opacity-60');
|
||||
expect(input.style.height).toBe('');
|
||||
|
||||
fireEvent.change(input, { target: { value: '新内容' } });
|
||||
expect(onChange).toHaveBeenCalledTimes(1);
|
||||
await user.click(screen.getByRole('button', { name: '外部更新' }));
|
||||
expect(screen.getByLabelText('React 文本状态').textContent).toBe(
|
||||
'外部\n更新',
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to measured sizing, preserves css limits and remeasures width changes', () => {
|
||||
let resizeObserverCallback!: ResizeObserverCallback;
|
||||
const observe = vi.fn();
|
||||
const disconnect = vi.fn();
|
||||
vi.stubGlobal('CSS', { supports: vi.fn().mockReturnValue(false) });
|
||||
vi.stubGlobal(
|
||||
'ResizeObserver',
|
||||
vi.fn().mockImplementation((callback: ResizeObserverCallback) => {
|
||||
resizeObserverCallback = callback;
|
||||
return {
|
||||
observe,
|
||||
unobserve: vi.fn(),
|
||||
disconnect,
|
||||
};
|
||||
}),
|
||||
it('publishes edits through onValueChange and enforces maxLength', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ControlledEditor maxLength={4} />);
|
||||
|
||||
const editor = screen.getByRole('textbox', { name: '受控编辑器' });
|
||||
await user.click(editor);
|
||||
pastePlainText(editor, '12345');
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByLabelText('React 文本状态').textContent).toBe('1234'),
|
||||
);
|
||||
expect(editor.textContent).toBe('1234');
|
||||
});
|
||||
|
||||
it('supports undo and redo while keeping React state synchronized', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ControlledEditor />);
|
||||
|
||||
const editor = screen.getByRole('textbox', { name: '受控编辑器' });
|
||||
await user.click(editor);
|
||||
pastePlainText(editor, '草稿');
|
||||
await waitFor(() =>
|
||||
expect(screen.getByLabelText('React 文本状态').textContent).toBe('草稿'),
|
||||
);
|
||||
|
||||
const { rerender, unmount } = render(
|
||||
await user.keyboard('{Control>}z{/Control}');
|
||||
expect(screen.getByLabelText('React 文本状态').textContent).toBe('');
|
||||
|
||||
await user.keyboard('{Control>}{Shift>}z{/Shift}{/Control}');
|
||||
expect(screen.getByLabelText('React 文本状态').textContent).toBe('草稿');
|
||||
});
|
||||
|
||||
it('exposes read-only and disabled accessibility without form controls', () => {
|
||||
const { container } = render(
|
||||
<AutoGrowTextArea
|
||||
aria-label="自动增长输入框"
|
||||
value=""
|
||||
style={{
|
||||
boxSizing: 'border-box',
|
||||
minHeight: '80px',
|
||||
maxHeight: '128px',
|
||||
}}
|
||||
onChange={vi.fn()}
|
||||
aria-label="只读编辑器"
|
||||
value="只读内容"
|
||||
disabled
|
||||
readOnly
|
||||
onValueChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
const input = screen.getByLabelText(
|
||||
'自动增长输入框',
|
||||
) as HTMLTextAreaElement;
|
||||
expect(observe).toHaveBeenCalledWith(input);
|
||||
let contentHeight = 92;
|
||||
Object.defineProperty(input, 'offsetHeight', {
|
||||
configurable: true,
|
||||
get: () => {
|
||||
const styleHeight = Number.parseFloat(input.style.height);
|
||||
return Number.isNaN(styleHeight) ? 82 : styleHeight;
|
||||
},
|
||||
});
|
||||
Object.defineProperty(input, 'clientHeight', {
|
||||
configurable: true,
|
||||
get: () => input.offsetHeight - 2,
|
||||
});
|
||||
Object.defineProperty(input, 'scrollHeight', {
|
||||
configurable: true,
|
||||
get: () => contentHeight,
|
||||
});
|
||||
|
||||
rerender(
|
||||
const editor = screen.getByRole('textbox', { name: '只读编辑器' });
|
||||
expect(editor.getAttribute('aria-disabled')).toBe('true');
|
||||
expect(editor.getAttribute('aria-readonly')).toBe('true');
|
||||
expect(editor.getAttribute('tabindex')).toBe('-1');
|
||||
expect(container.querySelector('textarea')).toBeNull();
|
||||
expect(container.querySelector('form')).toBeNull();
|
||||
});
|
||||
|
||||
it('uses a distinct internal viewport for overflow content', async () => {
|
||||
const { container } = render(
|
||||
<AutoGrowTextArea
|
||||
aria-label="自动增长输入框"
|
||||
value="第一行\n第二行\n第三行\n第四行\n第五行"
|
||||
style={{
|
||||
boxSizing: 'border-box',
|
||||
minHeight: '80px',
|
||||
maxHeight: '128px',
|
||||
}}
|
||||
onChange={vi.fn()}
|
||||
aria-label="滚动编辑器"
|
||||
value={Array.from({ length: 20 }, (_, index) => `第 ${index + 1} 行`).join(
|
||||
'\n',
|
||||
)}
|
||||
onValueChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(input.style.height).toBe('94px');
|
||||
expect(input.style.overflowY).toBe('hidden');
|
||||
|
||||
contentHeight = 180;
|
||||
act(() => {
|
||||
resizeObserverCallback(
|
||||
[
|
||||
{
|
||||
target: input,
|
||||
contentRect: { width: 180 },
|
||||
} as unknown as ResizeObserverEntry,
|
||||
],
|
||||
{} as ResizeObserver,
|
||||
const viewport = await waitFor(() => {
|
||||
const element = container.querySelector<HTMLElement>(
|
||||
'[data-overlayscrollbars-viewport]',
|
||||
);
|
||||
expect(element).not.toBeNull();
|
||||
return element!;
|
||||
});
|
||||
expect(input.style.height).toBe('128px');
|
||||
expect(input.style.overflowY).toBe('auto');
|
||||
|
||||
contentHeight = 30;
|
||||
rerender(
|
||||
<AutoGrowTextArea
|
||||
aria-label="自动增长输入框"
|
||||
value="短内容"
|
||||
style={{
|
||||
boxSizing: 'border-box',
|
||||
minHeight: '80px',
|
||||
maxHeight: '128px',
|
||||
}}
|
||||
onChange={vi.fn()}
|
||||
/>,
|
||||
const content = container.querySelector<HTMLElement>(
|
||||
'[data-overlayscrollbars-content]',
|
||||
);
|
||||
expect(input.style.height).toBe('80px');
|
||||
expect(input.style.overflowY).toBe('hidden');
|
||||
|
||||
unmount();
|
||||
expect(disconnect).toHaveBeenCalledTimes(1);
|
||||
expect(viewport).not.toBe(content);
|
||||
expect(
|
||||
viewport.contains(
|
||||
screen.getByRole('textbox', { name: '滚动编辑器' }),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('remeasures uncontrolled input changes and forwards the consumer input handler', () => {
|
||||
const onInput = vi.fn();
|
||||
vi.stubGlobal('CSS', { supports: vi.fn().mockReturnValue(false) });
|
||||
vi.stubGlobal(
|
||||
'ResizeObserver',
|
||||
vi.fn().mockImplementation(() => ({
|
||||
observe: vi.fn(),
|
||||
unobserve: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
})),
|
||||
);
|
||||
|
||||
render(
|
||||
<AutoGrowTextArea
|
||||
aria-label="非受控自动增长输入框"
|
||||
defaultValue="初始内容"
|
||||
style={{
|
||||
boxSizing: 'border-box',
|
||||
minHeight: '40px',
|
||||
maxHeight: '128px',
|
||||
}}
|
||||
onInput={onInput}
|
||||
/>,
|
||||
);
|
||||
const input = screen.getByLabelText(
|
||||
'非受控自动增长输入框',
|
||||
) as HTMLTextAreaElement;
|
||||
let contentHeight = 90;
|
||||
Object.defineProperty(input, 'offsetHeight', {
|
||||
configurable: true,
|
||||
get: () => {
|
||||
const styleHeight = Number.parseFloat(input.style.height);
|
||||
return Number.isNaN(styleHeight) ? 42 : styleHeight;
|
||||
},
|
||||
});
|
||||
Object.defineProperty(input, 'clientHeight', {
|
||||
configurable: true,
|
||||
get: () => input.offsetHeight - 2,
|
||||
});
|
||||
Object.defineProperty(input, 'scrollHeight', {
|
||||
configurable: true,
|
||||
get: () => contentHeight,
|
||||
});
|
||||
|
||||
fireEvent.input(input, {
|
||||
target: { value: '第一行\n第二行\n第三行\n第四行' },
|
||||
});
|
||||
expect(input.style.height).toBe('92px');
|
||||
expect(input.style.overflowY).toBe('hidden');
|
||||
|
||||
contentHeight = 180;
|
||||
fireEvent.input(input, {
|
||||
target: { value: '很多行内容'.repeat(30) },
|
||||
});
|
||||
expect(input.style.height).toBe('128px');
|
||||
expect(input.style.overflowY).toBe('auto');
|
||||
|
||||
contentHeight = 20;
|
||||
fireEvent.input(input, { target: { value: '短内容' } });
|
||||
expect(input.style.height).toBe('40px');
|
||||
expect(input.style.overflowY).toBe('hidden');
|
||||
expect(onInput).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('measures content-box height without double-counting padding or borders', () => {
|
||||
vi.stubGlobal('CSS', { supports: vi.fn().mockReturnValue(false) });
|
||||
vi.stubGlobal(
|
||||
'ResizeObserver',
|
||||
vi.fn().mockImplementation(() => ({
|
||||
observe: vi.fn(),
|
||||
unobserve: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
})),
|
||||
);
|
||||
|
||||
const { rerender } = render(
|
||||
<AutoGrowTextArea
|
||||
aria-label="内容盒输入框"
|
||||
value=""
|
||||
style={{
|
||||
boxSizing: 'content-box',
|
||||
minHeight: '40px',
|
||||
maxHeight: '128px',
|
||||
paddingTop: '10px',
|
||||
paddingBottom: '6px',
|
||||
}}
|
||||
onChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
const input = screen.getByLabelText(
|
||||
'内容盒输入框',
|
||||
) as HTMLTextAreaElement;
|
||||
let contentHeight = 120;
|
||||
Object.defineProperty(input, 'offsetHeight', {
|
||||
configurable: true,
|
||||
get: () => 122,
|
||||
});
|
||||
Object.defineProperty(input, 'clientHeight', {
|
||||
configurable: true,
|
||||
get: () => 120,
|
||||
});
|
||||
Object.defineProperty(input, 'scrollHeight', {
|
||||
configurable: true,
|
||||
get: () => contentHeight,
|
||||
});
|
||||
|
||||
rerender(
|
||||
<AutoGrowTextArea
|
||||
aria-label="内容盒输入框"
|
||||
value="多行内容"
|
||||
style={{
|
||||
boxSizing: 'content-box',
|
||||
minHeight: '40px',
|
||||
maxHeight: '128px',
|
||||
paddingTop: '10px',
|
||||
paddingBottom: '6px',
|
||||
}}
|
||||
onChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(input.style.height).toBe('104px');
|
||||
expect(input.style.overflowY).toBe('hidden');
|
||||
|
||||
contentHeight = 160;
|
||||
rerender(
|
||||
<AutoGrowTextArea
|
||||
aria-label="内容盒输入框"
|
||||
value="更多多行内容"
|
||||
style={{
|
||||
boxSizing: 'content-box',
|
||||
minHeight: '40px',
|
||||
maxHeight: '128px',
|
||||
paddingTop: '10px',
|
||||
paddingBottom: '6px',
|
||||
}}
|
||||
onChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(input.style.height).toBe('128px');
|
||||
expect(input.style.overflowY).toBe('auto');
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -29,6 +29,28 @@ describe('PlatformFloatingMenu', () => {
|
||||
expect(onRename).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('drops menu semantics for the parameter options variant', () => {
|
||||
render(
|
||||
<PlatformFloatingMenu
|
||||
label="音效时长选项"
|
||||
variant="options"
|
||||
placement="top-start"
|
||||
>
|
||||
<label>
|
||||
<input type="checkbox" aria-label="自动时长" readOnly checked />
|
||||
自动时长
|
||||
</label>
|
||||
<input type="range" aria-label="手动音效时长" readOnly value={5} />
|
||||
</PlatformFloatingMenu>,
|
||||
);
|
||||
|
||||
// 复选框与滑块都不是 menu 的合法子角色,浮层必须退回普通分组语义。
|
||||
expect(screen.getByRole('group', { name: '音效时长选项' })).toBeTruthy();
|
||||
expect(screen.queryByRole('menu', { name: '音效时长选项' })).toBeNull();
|
||||
expect(screen.getByRole('checkbox', { name: '自动时长' })).toBeTruthy();
|
||||
expect(screen.getByRole('slider', { name: '手动音效时长' })).toBeTruthy();
|
||||
});
|
||||
|
||||
it('keeps pointer events from leaking to canvas-style parents', () => {
|
||||
const onParentPointerDown = vi.fn();
|
||||
const onMenuPointerDown = vi.fn();
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
import type { ButtonHTMLAttributes, CSSProperties, HTMLAttributes, ReactNode } from 'react';
|
||||
|
||||
/**
|
||||
* `menu` 承载纯动作集合,子节点必须全是 `PlatformFloatingMenuItem`;
|
||||
* `options` 承载参数控件(复选框、滑块、选项按钮),它们不是 menuitem,
|
||||
* 放进 `role="menu"` 既违反 ARIA 子角色约束,也会让读屏进入菜单模式后读不到滑块。
|
||||
*/
|
||||
type PlatformFloatingMenuVariant = 'menu' | 'options';
|
||||
|
||||
type PlatformFloatingMenuProps = HTMLAttributes<HTMLDivElement> & {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
label?: string;
|
||||
placement?: 'bottom-start' | 'bottom-end' | 'top-start' | 'top-end';
|
||||
style?: CSSProperties;
|
||||
variant?: PlatformFloatingMenuVariant;
|
||||
};
|
||||
|
||||
type PlatformFloatingMenuItemProps = Omit<
|
||||
@@ -26,6 +34,7 @@ export function PlatformFloatingMenu({
|
||||
label,
|
||||
placement = 'top-end',
|
||||
style,
|
||||
variant = 'menu',
|
||||
onPointerDown,
|
||||
...divProps
|
||||
}: PlatformFloatingMenuProps) {
|
||||
@@ -39,7 +48,7 @@ export function PlatformFloatingMenu({
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
role="menu"
|
||||
role={variant === 'options' ? 'group' : 'menu'}
|
||||
aria-label={label}
|
||||
style={style}
|
||||
onPointerDown={(event) => {
|
||||
|
||||
@@ -350,7 +350,7 @@ describe('CreationLandingView', () => {
|
||||
['游戏角色', 'character-spec'],
|
||||
['游戏UI', 'ui-design'],
|
||||
['游戏音乐', 'background-music'],
|
||||
['游戏场景', 'image'],
|
||||
['游戏场景', 'scene'],
|
||||
['游戏美宣', 'publication-cover'],
|
||||
])(
|
||||
'creates a new project from %s with its canvas tool intent',
|
||||
@@ -480,7 +480,11 @@ describe('CreationLandingView', () => {
|
||||
|
||||
it('plays a character action on card focus and in the preview dialog', async () => {
|
||||
const user = userEvent.setup();
|
||||
const setIntervalSpy = vi.spyOn(window, 'setInterval');
|
||||
const setIntervalSpy = vi
|
||||
.spyOn(window, 'setInterval')
|
||||
.mockImplementation(
|
||||
() => 1 as unknown as ReturnType<typeof window.setInterval>,
|
||||
);
|
||||
listEditorProjectsMock.mockResolvedValueOnce(projectItems);
|
||||
listPublicEditorProjectResourcesMock.mockResolvedValueOnce([
|
||||
{
|
||||
@@ -994,9 +998,11 @@ describe('CreationLandingView', () => {
|
||||
'creation-landing__asset-preview--campaign',
|
||||
);
|
||||
expect(campaignPreview?.style.aspectRatio).toBe('900 / 1200');
|
||||
const campaignImage = await screen.findByRole('img', {
|
||||
name: '活动精选',
|
||||
});
|
||||
const campaignImage = await screen.findByRole(
|
||||
'img',
|
||||
{ name: '活动精选' },
|
||||
{ timeout: 5_000 },
|
||||
);
|
||||
expect((campaignImage as HTMLImageElement).src).toBe(signedCampaignUrl);
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
`/api/assets/read-url?objectKey=${encodeURIComponent(campaignObjectKey)}`,
|
||||
|
||||
@@ -58,7 +58,8 @@ type CreationFeatureTool =
|
||||
| 'ui-design'
|
||||
| 'background-music'
|
||||
| 'image'
|
||||
| 'publication-cover';
|
||||
| 'publication-cover'
|
||||
| 'scene';
|
||||
|
||||
type CreationFeatureItem = {
|
||||
title: string;
|
||||
@@ -134,7 +135,7 @@ const CREATION_FEATURES: CreationFeatureItem[] = [
|
||||
title: '游戏场景',
|
||||
description: '轻松生成各类游戏场景素材',
|
||||
iconSrc: '/creation-home/feature-scene.png',
|
||||
action: { kind: 'tool', tool: 'image' },
|
||||
action: { kind: 'tool', tool: 'scene' },
|
||||
},
|
||||
{
|
||||
title: '游戏美宣',
|
||||
|
||||
@@ -573,6 +573,35 @@ describe('creationShowcaseModel', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('infers video cost from typed V2 duration metadata', () => {
|
||||
const items = buildCreationShowcaseItems({
|
||||
activeTab: 'all',
|
||||
projectResources: [
|
||||
createResource({
|
||||
resourceId: 'video-v2',
|
||||
label: 'V2 角色宣传片',
|
||||
assetKind: 'video',
|
||||
imageSrc: '/generated-editor-videos/video-v2.mp4',
|
||||
model: 'seedance2.0-fast',
|
||||
generationInputs: {
|
||||
version: 2,
|
||||
action: 'video.generate',
|
||||
fields: [
|
||||
{ id: 'resolution', title: '清晰度', value: '720p' },
|
||||
{ id: 'durationSeconds', title: '时长', value: 6 },
|
||||
],
|
||||
references: [],
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
expect(items).toHaveLength(1);
|
||||
expect(items[0]?.cost).toBe(
|
||||
`${calculateEditorVideoPrice('seedance2.0-fast', '720p', 6)}泥点`,
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps character animation videos out of the marketing tab', () => {
|
||||
const items = buildCreationShowcaseItems({
|
||||
activeTab: 'marketing',
|
||||
|
||||
@@ -21,10 +21,7 @@ import {
|
||||
export type ShowcaseTabId = 'all' | 'characters' | 'ui' | 'music' | 'marketing';
|
||||
|
||||
export type ShowcaseAssetMediaType =
|
||||
| 'image'
|
||||
| 'video'
|
||||
| 'audio'
|
||||
| 'image-sequence';
|
||||
'image' | 'video' | 'audio' | 'image-sequence';
|
||||
|
||||
export type ShowcaseAssetPreview = {
|
||||
id: string;
|
||||
@@ -657,7 +654,7 @@ function getGroupsForTab(assets: EditorAssetSnapshot[], tab: ShowcaseTabId) {
|
||||
function resolveUserGenerationPrompt(asset: EditorAssetSnapshot) {
|
||||
const fields = asset.generationInputs?.fields ?? [];
|
||||
const promptField = fields.find((field) => {
|
||||
const value = field.value.trim();
|
||||
const value = String(field.value).trim();
|
||||
return (
|
||||
value &&
|
||||
USER_PROMPT_INPUT_TITLES.has(
|
||||
@@ -666,7 +663,7 @@ function resolveUserGenerationPrompt(asset: EditorAssetSnapshot) {
|
||||
!isBuiltInPromptText(value)
|
||||
);
|
||||
});
|
||||
const promptValue = promptField?.value.trim();
|
||||
const promptValue = promptField ? String(promptField.value).trim() : '';
|
||||
if (promptValue) {
|
||||
return promptValue;
|
||||
}
|
||||
@@ -679,7 +676,7 @@ function resolveUserGenerationPrompt(asset: EditorAssetSnapshot) {
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
const value = field.value.trim();
|
||||
const value = String(field.value).trim();
|
||||
return value && !isBuiltInPromptText(value)
|
||||
? [`${field.title.trim()}:${value}`]
|
||||
: [];
|
||||
@@ -837,7 +834,14 @@ function inferMediaDurationSeconds(asset: EditorAssetSnapshot) {
|
||||
const title = field.title.trim().toLowerCase();
|
||||
return title === '时长' || title === 'duration';
|
||||
});
|
||||
const matchedValue = durationField?.value.match(/\d+(?:\.\d+)?/u)?.[0];
|
||||
const durationValue = durationField?.value;
|
||||
if (typeof durationValue === 'number') {
|
||||
return Number.isFinite(durationValue) ? durationValue : undefined;
|
||||
}
|
||||
if (typeof durationValue !== 'string') {
|
||||
return undefined;
|
||||
}
|
||||
const matchedValue = durationValue.match(/\d+(?:\.\d+)?/u)?.[0];
|
||||
return matchedValue ? Number(matchedValue) : undefined;
|
||||
}
|
||||
|
||||
@@ -849,7 +853,7 @@ function inferVideoResolution(
|
||||
const fields = asset.generationInputs?.fields ?? [];
|
||||
const fieldValue = fields
|
||||
.flatMap((field) => [field.title, field.value])
|
||||
.map((value) => value.trim())
|
||||
.map((value) => String(value).trim())
|
||||
.find((value) => /\b(?:480p|720p|1080p)\b/iu.test(value));
|
||||
const matchedValue = fieldValue?.match(/\b(480p|720p|1080p)\b/iu)?.[1];
|
||||
if (matchedValue && allowedValues.includes(matchedValue)) {
|
||||
@@ -957,9 +961,8 @@ function toLibraryShowcaseItem(
|
||||
const sortedAssets = sortGroupAssets(group.assets);
|
||||
const previews = sortedAssets
|
||||
.map(toAssetPreview)
|
||||
.filter(
|
||||
(preview): preview is ShowcaseAssetPreview =>
|
||||
Boolean(preview?.src.trim()),
|
||||
.filter((preview): preview is ShowcaseAssetPreview =>
|
||||
Boolean(preview?.src.trim()),
|
||||
);
|
||||
if (!previews.length) {
|
||||
return null;
|
||||
|
||||
+61
-55
@@ -16,6 +16,7 @@ import type {
|
||||
EditorAgentMessage,
|
||||
EditorAgentMessageResponse,
|
||||
} from '@/packages/shared/src/contracts';
|
||||
import { setPlainTextEditorValue } from '@/src/components/common/AutoGrowTextArea.test-utils.ts';
|
||||
import type { EditorAgentConversationClient } from '@/src/components/image-editor/EditorAgentConversation/useEditorAgentConversation.ts';
|
||||
import { EDITOR_AGENT_PATIENCE_NOTICE_DELAY_MS } from '@/src/components/image-editor/EditorAgentConversation/useEditorAgentConversation.ts';
|
||||
import { ImageCanvasActionsProvider } from '@/src/components/image-editor/ImageCanvasActionsProvider.tsx';
|
||||
@@ -135,9 +136,10 @@ function createClient(): EditorAgentConversationClient {
|
||||
}
|
||||
|
||||
function enterAttachmentPrompt() {
|
||||
fireEvent.change(screen.getByLabelText('发送给画布 Agent'), {
|
||||
target: { value: ATTACHMENT_PROMPT },
|
||||
});
|
||||
setPlainTextEditorValue(
|
||||
screen.getByLabelText('发送给画布 Agent'),
|
||||
ATTACHMENT_PROMPT,
|
||||
);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
@@ -294,9 +296,10 @@ describe('EditorAgentConversationPanelView', () => {
|
||||
name: '新建对话',
|
||||
}) as HTMLButtonElement;
|
||||
expect(newConversationButton.disabled).toBe(false);
|
||||
fireEvent.change(screen.getByLabelText('发送给画布 Agent'), {
|
||||
target: { value: '应保留到新会话的草稿' },
|
||||
});
|
||||
setPlainTextEditorValue(
|
||||
screen.getByLabelText('发送给画布 Agent'),
|
||||
'应保留到新会话的草稿',
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: '添加附件' }));
|
||||
const attachmentDialog = screen.getByRole('dialog', {
|
||||
name: '选择图片附件',
|
||||
@@ -332,25 +335,27 @@ describe('EditorAgentConversationPanelView', () => {
|
||||
).toBe('conversation-2');
|
||||
expect(newConversationButton.disabled).toBe(true);
|
||||
});
|
||||
expect(
|
||||
(screen.getByLabelText('发送给画布 Agent') as HTMLTextAreaElement).value,
|
||||
).toBe('应保留到新会话的草稿');
|
||||
expect(screen.getByLabelText('发送给画布 Agent').textContent).toBe(
|
||||
'应保留到新会话的草稿',
|
||||
);
|
||||
expect(
|
||||
screen.getByText('一二三四五六七八九十甲乙丙丁戊己庚辛壬癸子丑寅卯'),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByRole('option', { name: '角色参考' })).toBeTruthy();
|
||||
expect(screen.getByRole('option', { name: '新对话' })).toBeTruthy();
|
||||
|
||||
fireEvent.change(screen.getByLabelText('发送给画布 Agent'), {
|
||||
target: { value: '尚未发送的草稿' },
|
||||
});
|
||||
setPlainTextEditorValue(
|
||||
screen.getByLabelText('发送给画布 Agent'),
|
||||
'尚未发送的草稿',
|
||||
);
|
||||
expect(newConversationButton.disabled).toBe(true);
|
||||
fireEvent.click(newConversationButton);
|
||||
expect(client.createConversation).toHaveBeenCalledTimes(1);
|
||||
|
||||
fireEvent.change(screen.getByLabelText('发送给画布 Agent'), {
|
||||
target: { value: '参考附件做像素风' },
|
||||
});
|
||||
setPlainTextEditorValue(
|
||||
screen.getByLabelText('发送给画布 Agent'),
|
||||
'参考附件做像素风',
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: '发送' }));
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -491,9 +496,10 @@ describe('EditorAgentConversationPanelView', () => {
|
||||
}) as HTMLButtonElement;
|
||||
expect(newConversationButton.disabled).toBe(true);
|
||||
|
||||
fireEvent.change(screen.getByLabelText('发送给画布 Agent'), {
|
||||
target: { value: '未发送内容' },
|
||||
});
|
||||
setPlainTextEditorValue(
|
||||
screen.getByLabelText('发送给画布 Agent'),
|
||||
'未发送内容',
|
||||
);
|
||||
expect(newConversationButton.disabled).toBe(true);
|
||||
fireEvent.click(newConversationButton);
|
||||
expect(client.createConversation).not.toHaveBeenCalled();
|
||||
@@ -516,17 +522,18 @@ describe('EditorAgentConversationPanelView', () => {
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('已经看到画布内容')).toBeTruthy();
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText('发送给画布 Agent'), {
|
||||
target: { value: '需要保留的草稿' },
|
||||
});
|
||||
setPlainTextEditorValue(
|
||||
screen.getByLabelText('发送给画布 Agent'),
|
||||
'需要保留的草稿',
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: '新建对话' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('创建新会话失败')).toBeTruthy();
|
||||
});
|
||||
expect(
|
||||
(screen.getByLabelText('发送给画布 Agent') as HTMLTextAreaElement).value,
|
||||
).toBe('需要保留的草稿');
|
||||
expect(screen.getByLabelText('发送给画布 Agent').textContent).toBe(
|
||||
'需要保留的草稿',
|
||||
);
|
||||
expect(screen.getByText('已经看到画布内容')).toBeTruthy();
|
||||
expect((screen.getByLabelText('当前对话') as HTMLSelectElement).value).toBe(
|
||||
'conversation-1',
|
||||
@@ -556,15 +563,13 @@ describe('EditorAgentConversationPanelView', () => {
|
||||
});
|
||||
const draftInput = screen.getByLabelText(
|
||||
'发送给画布 Agent',
|
||||
) as HTMLTextAreaElement;
|
||||
fireEvent.change(draftInput, {
|
||||
target: { value: '旧项目草稿' },
|
||||
});
|
||||
) as HTMLElement;
|
||||
setPlainTextEditorValue(draftInput, '旧项目草稿');
|
||||
fireEvent.click(screen.getByRole('button', { name: '新建对话' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(client.createConversation).toHaveBeenCalledWith('project-1', {});
|
||||
expect(draftInput.disabled).toBe(false);
|
||||
expect(draftInput.getAttribute('aria-disabled')).toBeNull();
|
||||
});
|
||||
const conversationSelect = screen.getByLabelText(
|
||||
'当前对话',
|
||||
@@ -578,12 +583,10 @@ describe('EditorAgentConversationPanelView', () => {
|
||||
(screen.getByRole('button', { name: '添加附件' }) as HTMLButtonElement)
|
||||
.disabled,
|
||||
).toBe(false);
|
||||
fireEvent.change(draftInput, {
|
||||
target: { value: '创建期间更新的草稿' },
|
||||
});
|
||||
fireEvent.submit(draftInput.closest('form') as HTMLFormElement);
|
||||
setPlainTextEditorValue(draftInput, '创建期间更新的草稿');
|
||||
fireEvent.click(sendButton);
|
||||
expect(client.sendMessage).not.toHaveBeenCalled();
|
||||
expect(draftInput.value).toBe('创建期间更新的草稿');
|
||||
expect(draftInput.textContent).toBe('创建期间更新的草稿');
|
||||
|
||||
await act(async () => {
|
||||
resolveCreate({
|
||||
@@ -596,7 +599,7 @@ describe('EditorAgentConversationPanelView', () => {
|
||||
});
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(draftInput.value).toBe('创建期间更新的草稿');
|
||||
expect(draftInput.textContent).toBe('创建期间更新的草稿');
|
||||
expect(conversationSelect.value).toBe('conversation-2');
|
||||
expect(conversationSelect.disabled).toBe(false);
|
||||
expect(sendButton.disabled).toBe(false);
|
||||
@@ -659,13 +662,11 @@ describe('EditorAgentConversationPanelView', () => {
|
||||
});
|
||||
const draftInput = screen.getByLabelText(
|
||||
'发送给画布 Agent',
|
||||
) as HTMLTextAreaElement;
|
||||
) as HTMLElement;
|
||||
const conversationSelect = screen.getByLabelText(
|
||||
'当前对话',
|
||||
) as HTMLSelectElement;
|
||||
fireEvent.change(draftInput, {
|
||||
target: { value: '切换期间必须保留的草稿' },
|
||||
});
|
||||
setPlainTextEditorValue(draftInput, '切换期间必须保留的草稿');
|
||||
fireEvent.change(conversationSelect, {
|
||||
target: { value: 'conversation-2' },
|
||||
});
|
||||
@@ -677,9 +678,9 @@ describe('EditorAgentConversationPanelView', () => {
|
||||
expect(client.getConversation).toHaveBeenCalledWith('conversation-2');
|
||||
expect(sendButton.disabled).toBe(true);
|
||||
});
|
||||
fireEvent.submit(draftInput.closest('form') as HTMLFormElement);
|
||||
fireEvent.click(sendButton);
|
||||
expect(client.sendMessage).not.toHaveBeenCalled();
|
||||
expect(draftInput.value).toBe('切换期间必须保留的草稿');
|
||||
expect(draftInput.textContent).toBe('切换期间必须保留的草稿');
|
||||
|
||||
await act(async () => {
|
||||
resolveConversationLoad({
|
||||
@@ -702,7 +703,7 @@ describe('EditorAgentConversationPanelView', () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(await screen.findByText('背景会话内容')).toBeTruthy();
|
||||
expect(draftInput.value).toBe('切换期间必须保留的草稿');
|
||||
expect(draftInput.textContent).toBe('切换期间必须保留的草稿');
|
||||
expect(sendButton.disabled).toBe(false);
|
||||
});
|
||||
|
||||
@@ -729,9 +730,10 @@ describe('EditorAgentConversationPanelView', () => {
|
||||
});
|
||||
vi.useFakeTimers();
|
||||
|
||||
fireEvent.change(screen.getByLabelText('发送给画布 Agent'), {
|
||||
target: { value: '继续规划' },
|
||||
});
|
||||
setPlainTextEditorValue(
|
||||
screen.getByLabelText('发送给画布 Agent'),
|
||||
'继续规划',
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: '发送' }));
|
||||
|
||||
await act(async () => {
|
||||
@@ -1316,7 +1318,7 @@ describe('EditorAgentConversationPanelView', () => {
|
||||
}) as HTMLButtonElement;
|
||||
expect(sendButton.disabled).toBe(true);
|
||||
|
||||
fireEvent.submit(sendButton.closest('form')!);
|
||||
fireEvent.click(sendButton);
|
||||
|
||||
expect(client.sendMessage).not.toHaveBeenCalled();
|
||||
expect(screen.getByText('角色图层')).toBeTruthy();
|
||||
@@ -1368,15 +1370,18 @@ describe('EditorAgentConversationPanelView', () => {
|
||||
fireEvent.click(
|
||||
within(attachmentDialog).getByRole('button', { name: '应用' }),
|
||||
);
|
||||
fireEvent.change(screen.getByLabelText('发送给画布 Agent'), {
|
||||
target: { value: '失败后恢复这条草稿' },
|
||||
});
|
||||
setPlainTextEditorValue(
|
||||
screen.getByLabelText('发送给画布 Agent'),
|
||||
'失败后恢复这条草稿',
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: '发送' }));
|
||||
|
||||
expect(await screen.findByText('Network error')).toBeTruthy();
|
||||
expect(
|
||||
(screen.getByLabelText('发送给画布 Agent') as HTMLTextAreaElement).value,
|
||||
).toBe('失败后恢复这条草稿');
|
||||
await waitFor(() =>
|
||||
expect(screen.getByLabelText('发送给画布 Agent').textContent).toBe(
|
||||
'失败后恢复这条草稿',
|
||||
),
|
||||
);
|
||||
expect(screen.getByText('角色图层')).toBeTruthy();
|
||||
expect(
|
||||
within(
|
||||
@@ -1760,9 +1765,10 @@ describe('EditorAgentConversationPanelView', () => {
|
||||
expect(screen.getByText('已经看到画布内容')).toBeTruthy();
|
||||
});
|
||||
|
||||
fireEvent.change(screen.getByLabelText('发送给画布 Agent'), {
|
||||
target: { value: '生成一张图片' },
|
||||
});
|
||||
setPlainTextEditorValue(
|
||||
screen.getByLabelText('发送给画布 Agent'),
|
||||
'生成一张图片',
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: '发送' }));
|
||||
|
||||
await waitFor(() => {
|
||||
|
||||
+7
-10
@@ -10,7 +10,6 @@ import {
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
type FormEvent,
|
||||
useEffect,
|
||||
useState,
|
||||
type WheelEvent as ReactWheelEvent,
|
||||
@@ -139,8 +138,7 @@ export function EditorAgentConversationPanelView({
|
||||
void createConversation().catch(() => undefined);
|
||||
};
|
||||
|
||||
const submitMessage = (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
const submitMessage = () => {
|
||||
if (isMessageSubmissionBlocked) {
|
||||
return;
|
||||
}
|
||||
@@ -328,10 +326,7 @@ export function EditorAgentConversationPanelView({
|
||||
{attachmentError}
|
||||
</div>
|
||||
) : null}
|
||||
<form
|
||||
className="border-t border-slate-200 bg-white/95 p-3"
|
||||
onSubmit={submitMessage}
|
||||
>
|
||||
<div className="border-t border-slate-200 bg-white/95 p-3">
|
||||
{attachments.length ? (
|
||||
<div className="mb-2 flex flex-wrap gap-1.5">
|
||||
{attachments.map((attachment) => (
|
||||
@@ -354,19 +349,21 @@ export function EditorAgentConversationPanelView({
|
||||
</button>
|
||||
<EditorAgentDraftTextarea
|
||||
value={draftText}
|
||||
onChange={setDraftText}
|
||||
onValueChange={setDraftText}
|
||||
onPaste={handleInputPaste}
|
||||
onSubmitRequest={submitMessage}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
type="button"
|
||||
className="inline-flex h-10 min-w-16 shrink-0 items-center justify-center gap-1.5 rounded-full bg-slate-900 px-3 text-sm font-semibold text-white disabled:opacity-45"
|
||||
disabled={isMessageSubmissionBlocked}
|
||||
onClick={submitMessage}
|
||||
>
|
||||
<Send className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
发送
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</aside>
|
||||
<AttachmentPicker
|
||||
open={attachmentPickerOpen}
|
||||
|
||||
+66
-40
@@ -1,60 +1,86 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import type { FormEvent } from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { useState } from 'react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { EditorAgentDraftTextarea } from './EditorAgentDraftTextarea.tsx';
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal(
|
||||
'ResizeObserver',
|
||||
class ResizeObserver {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
function DraftHarness({
|
||||
onPaste,
|
||||
onSubmitRequest,
|
||||
}: {
|
||||
onPaste?: (event: ClipboardEvent) => void;
|
||||
onSubmitRequest: () => void;
|
||||
}) {
|
||||
const [value, setValue] = useState('');
|
||||
return (
|
||||
<EditorAgentDraftTextarea
|
||||
value={value}
|
||||
onValueChange={setValue}
|
||||
onPaste={onPaste}
|
||||
onSubmitRequest={onSubmitRequest}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
describe('EditorAgentDraftTextarea', () => {
|
||||
it('forwards draft changes and paste events through the Agent wrapper', () => {
|
||||
const onChange = vi.fn();
|
||||
const onPaste = vi.fn();
|
||||
it('forwards controlled draft edits and paste events', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onPaste = vi.fn((event: ClipboardEvent) => event.preventDefault());
|
||||
render(<DraftHarness onPaste={onPaste} onSubmitRequest={vi.fn()} />);
|
||||
|
||||
render(
|
||||
<EditorAgentDraftTextarea
|
||||
value=""
|
||||
onChange={onChange}
|
||||
onPaste={onPaste}
|
||||
/>,
|
||||
);
|
||||
|
||||
const input = screen.getByLabelText(
|
||||
'发送给画布 Agent',
|
||||
) as HTMLTextAreaElement;
|
||||
expect(input.className).toContain('auto-grow-text-area');
|
||||
expect(input.className).toContain(
|
||||
'editor-agent-conversation__draft-input',
|
||||
);
|
||||
|
||||
fireEvent.change(input, { target: { value: '新草稿' } });
|
||||
expect(onChange).toHaveBeenCalledWith('新草稿');
|
||||
fireEvent.paste(input);
|
||||
const editor = screen.getByRole('textbox', {
|
||||
name: '发送给画布 Agent',
|
||||
});
|
||||
await user.click(editor);
|
||||
fireEvent.paste(editor, {
|
||||
clipboardData: {
|
||||
files: [],
|
||||
getData: () => '',
|
||||
items: [],
|
||||
types: [],
|
||||
},
|
||||
});
|
||||
expect(onPaste).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('submits on Enter and keeps line breaks or IME confirmation from submitting', () => {
|
||||
const onSubmit = vi.fn((event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
});
|
||||
render(
|
||||
<form onSubmit={onSubmit}>
|
||||
<EditorAgentDraftTextarea value="草稿" onChange={vi.fn()} />
|
||||
</form>,
|
||||
it('submits on Enter but preserves Shift+Enter and IME confirmation', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSubmitRequest = vi.fn();
|
||||
const { container } = render(
|
||||
<DraftHarness onSubmitRequest={onSubmitRequest} />,
|
||||
);
|
||||
|
||||
const input = screen.getByLabelText('发送给画布 Agent');
|
||||
fireEvent.keyDown(input, { key: 'Enter', shiftKey: true });
|
||||
expect(onSubmit).not.toHaveBeenCalled();
|
||||
const editor = screen.getByRole('textbox', {
|
||||
name: '发送给画布 Agent',
|
||||
});
|
||||
await user.click(editor);
|
||||
fireEvent.keyDown(editor, { key: 'Enter', shiftKey: true });
|
||||
expect(onSubmitRequest).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.keyDown(input, {
|
||||
fireEvent.keyDown(editor, {
|
||||
key: 'Enter',
|
||||
isComposing: false,
|
||||
isComposing: true,
|
||||
keyCode: 229,
|
||||
});
|
||||
expect(onSubmit).not.toHaveBeenCalled();
|
||||
expect(onSubmitRequest).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.keyDown(input, { key: 'Enter' });
|
||||
expect(onSubmit).toHaveBeenCalledTimes(1);
|
||||
fireEvent.keyDown(editor, { key: 'Enter' });
|
||||
expect(onSubmitRequest).toHaveBeenCalledTimes(1);
|
||||
expect(container.querySelector('form')).toBeNull();
|
||||
expect(container.querySelector('textarea')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,39 +1,24 @@
|
||||
import {
|
||||
type ClipboardEventHandler,
|
||||
type KeyboardEvent,
|
||||
} from 'react';
|
||||
|
||||
import { AutoGrowTextArea } from '@/src/components/common/AutoGrowTextArea';
|
||||
|
||||
export function EditorAgentDraftTextarea({
|
||||
value,
|
||||
onChange,
|
||||
onValueChange,
|
||||
onPaste,
|
||||
onSubmitRequest,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
onPaste?: ClipboardEventHandler<HTMLTextAreaElement>;
|
||||
onValueChange: (value: string) => void;
|
||||
onPaste?: (event: ClipboardEvent) => void;
|
||||
onSubmitRequest: () => void;
|
||||
}) {
|
||||
const handleKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (
|
||||
event.key === 'Enter' &&
|
||||
!event.shiftKey &&
|
||||
!event.nativeEvent.isComposing &&
|
||||
event.nativeEvent.keyCode !== 229
|
||||
) {
|
||||
event.preventDefault();
|
||||
event.currentTarget.form?.requestSubmit();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AutoGrowTextArea
|
||||
className="editor-agent-conversation__draft-input min-w-0 flex-1 rounded-3xl border border-slate-200 bg-slate-50 px-3 py-2 text-sm text-slate-800 outline-none focus:border-slate-400"
|
||||
className="editor-agent-conversation__draft-input min-w-0 flex-1 border border-slate-200 bg-slate-50 text-sm text-slate-800 outline-none focus-within:border-slate-400"
|
||||
aria-label="发送给画布 Agent"
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.currentTarget.value)}
|
||||
onValueChange={onValueChange}
|
||||
onPaste={onPaste}
|
||||
onKeyDown={handleKeyDown}
|
||||
onSubmitRequest={onSubmitRequest}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import {
|
||||
type ClipboardEvent as ReactClipboardEvent,
|
||||
useCallback,
|
||||
useMemo,
|
||||
useRef,
|
||||
@@ -305,7 +304,7 @@ export function useConversationAttachments({
|
||||
);
|
||||
|
||||
const handleInputPaste = useCallback(
|
||||
(event: ReactClipboardEvent<HTMLTextAreaElement>) => {
|
||||
(event: ClipboardEvent) => {
|
||||
const imageFiles = extractClipboardImageFiles(event.clipboardData);
|
||||
if (!imageFiles.length || isPastingAttachmentRef.current) {
|
||||
return;
|
||||
|
||||
@@ -29,6 +29,7 @@ import type {
|
||||
EditorAssetFolder,
|
||||
UploadTarget,
|
||||
} from './ImageCanvasEditorTypes';
|
||||
import type { AssetSelectionInteraction } from './useImageCanvasAssetSelection';
|
||||
|
||||
type ImageCanvasAssetFolderSectionViewProps = {
|
||||
folder: GroupedEditorAssetFolder;
|
||||
@@ -36,6 +37,7 @@ type ImageCanvasAssetFolderSectionViewProps = {
|
||||
suppressAssetClickRef: { current: boolean };
|
||||
isAssetSelectionMode: boolean;
|
||||
selectedAssetIds: Set<string>;
|
||||
visibleAssetIds: string[];
|
||||
assetMoveDropFolderId: string | null;
|
||||
renamingFolder: { folderId: string; value: string } | null;
|
||||
renamingAsset: { assetId: string; value: string } | null;
|
||||
@@ -48,9 +50,11 @@ type ImageCanvasAssetFolderSectionViewProps = {
|
||||
setActiveUploadFolderId: Dispatch<SetStateAction<string>>;
|
||||
setUploadDropTarget: Dispatch<SetStateAction<'canvas' | 'assets' | null>>;
|
||||
setAssetPointerDrag: Dispatch<SetStateAction<AssetPointerDragState | null>>;
|
||||
setSelectedAssetIds: Dispatch<SetStateAction<Set<string>>>;
|
||||
updateAssetMoveDropFolder: (folderId: string | null) => void;
|
||||
addUploadedFiles: (files: FileList | File[], options?: UploadFilesOptions) => void;
|
||||
addUploadedFiles: (
|
||||
files: FileList | File[],
|
||||
options?: UploadFilesOptions,
|
||||
) => void;
|
||||
requestUpload: (target: UploadTarget, options?: UploadRequestOptions) => void;
|
||||
moveAssetToFolder: (assetId: string, folderId: string) => void;
|
||||
toggleAssetFolder: (folderId: string) => void;
|
||||
@@ -61,7 +65,10 @@ type ImageCanvasAssetFolderSectionViewProps = {
|
||||
commitAssetRename: (asset: EditorAsset) => void;
|
||||
deleteUploadedAsset: (asset: EditorAsset) => void;
|
||||
submitAssetToShowcase: (asset: EditorAsset) => void;
|
||||
toggleAssetSelected: (assetId: string) => void;
|
||||
toggleAssetSelected: (
|
||||
assetId: string,
|
||||
interaction: AssetSelectionInteraction,
|
||||
) => void;
|
||||
addAssetLayer: (asset: EditorAsset) => void;
|
||||
onDownloadAsset: (asset: EditorAsset) => void;
|
||||
};
|
||||
@@ -72,6 +79,7 @@ export function ImageCanvasAssetFolderSectionView({
|
||||
suppressAssetClickRef,
|
||||
isAssetSelectionMode,
|
||||
selectedAssetIds,
|
||||
visibleAssetIds,
|
||||
assetMoveDropFolderId,
|
||||
renamingFolder,
|
||||
renamingAsset,
|
||||
@@ -80,7 +88,6 @@ export function ImageCanvasAssetFolderSectionView({
|
||||
setActiveUploadFolderId,
|
||||
setUploadDropTarget,
|
||||
setAssetPointerDrag,
|
||||
setSelectedAssetIds,
|
||||
updateAssetMoveDropFolder,
|
||||
addUploadedFiles,
|
||||
requestUpload,
|
||||
@@ -110,6 +117,17 @@ export function ImageCanvasAssetFolderSectionView({
|
||||
aria-label={folder.label}
|
||||
data-asset-folder-id={folder.id}
|
||||
onDragOver={(event) => {
|
||||
if (isAssetSelectionMode) {
|
||||
if (
|
||||
hasDataTransferType(event.dataTransfer, ASSET_DRAG_MIME_TYPE) ||
|
||||
hasDataTransferType(event.dataTransfer, 'Files')
|
||||
) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
event.dataTransfer.dropEffect = 'none';
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (hasDataTransferType(event.dataTransfer, ASSET_DRAG_MIME_TYPE)) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
@@ -126,6 +144,18 @@ export function ImageCanvasAssetFolderSectionView({
|
||||
}
|
||||
}}
|
||||
onDrop={(event) => {
|
||||
if (isAssetSelectionMode) {
|
||||
if (
|
||||
getDraggedAssetId(event.dataTransfer) ||
|
||||
event.dataTransfer.files.length > 0
|
||||
) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setUploadDropTarget(null);
|
||||
updateAssetMoveDropFolder(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const movingAssetId = getDraggedAssetId(event.dataTransfer);
|
||||
if (movingAssetId) {
|
||||
event.preventDefault();
|
||||
@@ -241,11 +271,11 @@ export function ImageCanvasAssetFolderSectionView({
|
||||
suppressAssetClickRef={suppressAssetClickRef}
|
||||
isAssetSelectionMode={isAssetSelectionMode}
|
||||
selectedAssetIds={selectedAssetIds}
|
||||
visibleAssetIds={visibleAssetIds}
|
||||
renamingAsset={renamingAsset}
|
||||
setRenamingAsset={setRenamingAsset}
|
||||
setUploadDropTarget={setUploadDropTarget}
|
||||
setAssetPointerDrag={setAssetPointerDrag}
|
||||
setSelectedAssetIds={setSelectedAssetIds}
|
||||
updateAssetMoveDropFolder={updateAssetMoveDropFolder}
|
||||
addUploadedFiles={addUploadedFiles}
|
||||
moveAssetToFolder={moveAssetToFolder}
|
||||
|
||||
@@ -1,33 +1,25 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
areAllSelectableAssetsSelected,
|
||||
createAssetMarqueeFromPointer,
|
||||
createAssetMarqueeSelectionRect,
|
||||
createLocalAssetFolder,
|
||||
deleteAssetFolderLocally,
|
||||
filterGroupedAssetsBySearch,
|
||||
getSelectableAssets,
|
||||
groupAssetsByFolder,
|
||||
layerMatchesSearch,
|
||||
layerStartsWithSearch,
|
||||
moveAssetMarqueeToPointer,
|
||||
moveAssetToFolderLocally,
|
||||
removeAssetById,
|
||||
removeSelectedAssets,
|
||||
renameAssetById,
|
||||
renameAssetFolderById,
|
||||
replaceLocalAssetFolder,
|
||||
resolveAllAssetSelection,
|
||||
resolveAssetFolderIdFromPoint,
|
||||
resolveAssetShowcaseSubmissionState,
|
||||
resolveDefaultAssetFolder,
|
||||
resolvePinnedAssetMoveFolderId,
|
||||
resolvePreferredAssetUploadFolder,
|
||||
resolveUploadAssetFolder,
|
||||
selectUploadedAssetsInRect,
|
||||
toggleAssetFolderCollapsed,
|
||||
toggleAssetSelection,
|
||||
} from './ImageCanvasAssetLibraryModel';
|
||||
import type {
|
||||
CanvasLayer,
|
||||
@@ -105,16 +97,12 @@ describe('ImageCanvasAssetLibraryModel', () => {
|
||||
{ id: 'project', assets: [{ id: 'asset-a' }, { id: 'built-in' }] },
|
||||
{ id: 'folder-role', assets: [{ id: 'asset-b' }] },
|
||||
]);
|
||||
expect(getSelectableAssets(assets).map((asset) => asset.id)).toEqual([
|
||||
'asset-a',
|
||||
'asset-b',
|
||||
]);
|
||||
});
|
||||
|
||||
it('explains why assets cannot be submitted to showcase review', () => {
|
||||
expect(
|
||||
resolveAssetShowcaseSubmissionState(createAsset()).reason,
|
||||
).toBe('只有生成素材可以提交精选审核');
|
||||
expect(resolveAssetShowcaseSubmissionState(createAsset()).reason).toBe(
|
||||
'只有生成素材可以提交精选审核',
|
||||
);
|
||||
expect(
|
||||
resolveAssetShowcaseSubmissionState(
|
||||
createAsset({ sourceType: 'generated', persisted: false }),
|
||||
@@ -260,15 +248,15 @@ describe('ImageCanvasAssetLibraryModel', () => {
|
||||
});
|
||||
|
||||
it('renames assets, toggles folders and creates local folders', () => {
|
||||
expect(renameAssetById([createAsset()], 'asset-a', '新名字')[0]).toMatchObject(
|
||||
{ label: '新名字' },
|
||||
);
|
||||
expect(
|
||||
renameAssetById([createAsset()], 'asset-a', '新名字')[0],
|
||||
).toMatchObject({ label: '新名字' });
|
||||
expect(
|
||||
renameAssetFolderById([createFolder()], 'project', '默认素材')[0],
|
||||
).toMatchObject({ label: '默认素材' });
|
||||
expect(toggleAssetFolderCollapsed([createFolder()], 'project')[0]).toMatchObject(
|
||||
{ collapsed: true },
|
||||
);
|
||||
expect(
|
||||
toggleAssetFolderCollapsed([createFolder()], 'project')[0],
|
||||
).toMatchObject({ collapsed: true });
|
||||
expect(
|
||||
createLocalAssetFolder({
|
||||
folderId: 'folder-local',
|
||||
@@ -316,9 +304,9 @@ describe('ImageCanvasAssetLibraryModel', () => {
|
||||
createAsset({ id: 'asset-b', folderId: 'project' }),
|
||||
];
|
||||
|
||||
expect(removeAssetById(assets, 'asset-a').map((asset) => asset.id)).toEqual([
|
||||
'asset-b',
|
||||
]);
|
||||
expect(removeAssetById(assets, 'asset-a').map((asset) => asset.id)).toEqual(
|
||||
['asset-b'],
|
||||
);
|
||||
expect(resolveDefaultAssetFolder(folders)?.id).toBe('project');
|
||||
expect(
|
||||
resolveUploadAssetFolder([
|
||||
@@ -343,23 +331,11 @@ describe('ImageCanvasAssetLibraryModel', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('toggles selection, selects all uploaded assets and removes selected assets', () => {
|
||||
it('removes selected assets', () => {
|
||||
const assets = [
|
||||
createAsset({ id: 'asset-a' }),
|
||||
createAsset({ id: 'asset-b' }),
|
||||
];
|
||||
const selected = toggleAssetSelection(new Set<string>(), 'asset-a');
|
||||
|
||||
expect([...selected]).toEqual(['asset-a']);
|
||||
expect([...toggleAssetSelection(selected, 'asset-a')]).toEqual([]);
|
||||
expect(areAllSelectableAssetsSelected(assets, new Set(['asset-a']))).toBe(
|
||||
false,
|
||||
);
|
||||
const allSelected = resolveAllAssetSelection({
|
||||
allSelectableAssetsSelected: false,
|
||||
selectableAssets: assets,
|
||||
});
|
||||
expect([...allSelected]).toEqual(['asset-a', 'asset-b']);
|
||||
const removal = removeSelectedAssets(assets, new Set(['asset-b']));
|
||||
expect(removal.assets.map((asset) => asset.id)).toEqual(['asset-a']);
|
||||
expect(removal.deletedAssets.map((asset) => asset.id)).toEqual(['asset-b']);
|
||||
@@ -438,71 +414,4 @@ describe('ImageCanvasAssetLibraryModel', () => {
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('updates marquee geometry from pointer coordinates and normalizes reverse drags', () => {
|
||||
const marquee = createAssetMarqueeFromPointer({
|
||||
pointerId: 5,
|
||||
point: { clientX: 180, clientY: 220 },
|
||||
containerRect: { left: 100, top: 200 },
|
||||
});
|
||||
|
||||
expect(marquee).toEqual({
|
||||
pointerId: 5,
|
||||
startX: 80,
|
||||
startY: 20,
|
||||
currentX: 80,
|
||||
currentY: 20,
|
||||
});
|
||||
expect(
|
||||
moveAssetMarqueeToPointer({
|
||||
marquee,
|
||||
point: { clientX: 120, clientY: 205 },
|
||||
containerRect: { left: 100, top: 200 },
|
||||
}),
|
||||
).toMatchObject({ currentX: 20, currentY: 5 });
|
||||
expect(
|
||||
createAssetMarqueeSelectionRect({
|
||||
marquee,
|
||||
point: { clientX: 120, clientY: 205 },
|
||||
containerRect: { left: 100, top: 200 },
|
||||
}),
|
||||
).toEqual({
|
||||
left: 120,
|
||||
right: 180,
|
||||
top: 205,
|
||||
bottom: 220,
|
||||
});
|
||||
});
|
||||
|
||||
it('selects only uploaded assets intersecting the marquee rectangle', () => {
|
||||
const assets = [
|
||||
createAsset({ id: 'asset-a' }),
|
||||
createAsset({ id: 'asset-b' }),
|
||||
createAsset({ id: 'built-in', sourceKind: 'built-in' }),
|
||||
];
|
||||
const selectedIds = selectUploadedAssetsInRect({
|
||||
assets,
|
||||
assetTargets: [
|
||||
{
|
||||
assetId: 'asset-a',
|
||||
rect: { left: 10, right: 20, top: 10, bottom: 20 },
|
||||
},
|
||||
{
|
||||
assetId: 'asset-b',
|
||||
rect: { left: 30, right: 40, top: 30, bottom: 40 },
|
||||
},
|
||||
{
|
||||
assetId: 'built-in',
|
||||
rect: { left: 15, right: 25, top: 15, bottom: 25 },
|
||||
},
|
||||
{
|
||||
assetId: 'missing',
|
||||
rect: { left: 15, right: 25, top: 15, bottom: 25 },
|
||||
},
|
||||
],
|
||||
selectionRect: { left: 20, right: 35, top: 20, bottom: 35 },
|
||||
});
|
||||
|
||||
expect([...selectedIds]).toEqual(['asset-a', 'asset-b']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { isEditorInternalProcessingModel } from './ImageCanvasEditorModel';
|
||||
import type {
|
||||
AssetMarqueeState,
|
||||
CanvasLayer,
|
||||
EditorAsset,
|
||||
EditorAssetFolder,
|
||||
@@ -18,11 +17,6 @@ type AssetFolderHitTarget = {
|
||||
rect: ClientRectLike;
|
||||
};
|
||||
|
||||
type AssetHitTarget = {
|
||||
assetId: string;
|
||||
rect: ClientRectLike;
|
||||
};
|
||||
|
||||
export type GroupedAssetFolder = EditorAssetFolder & {
|
||||
assets: EditorAsset[];
|
||||
};
|
||||
@@ -38,15 +32,6 @@ function isPointInRect(point: ClientPoint, rect: ClientRectLike) {
|
||||
);
|
||||
}
|
||||
|
||||
function doRectsIntersect(rect: ClientRectLike, selectionRect: ClientRectLike) {
|
||||
return (
|
||||
rect.left <= selectionRect.right &&
|
||||
rect.right >= selectionRect.left &&
|
||||
rect.top <= selectionRect.bottom &&
|
||||
rect.bottom >= selectionRect.top
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveAssetFolderIdFromPoint({
|
||||
point,
|
||||
listRect,
|
||||
@@ -85,98 +70,6 @@ export function resolvePinnedAssetMoveFolderId({
|
||||
return folderId;
|
||||
}
|
||||
|
||||
export function shouldStartAssetMarquee({
|
||||
isAssetSelectionMode,
|
||||
button,
|
||||
isBlockedTarget,
|
||||
}: {
|
||||
isAssetSelectionMode: boolean;
|
||||
button: number;
|
||||
isBlockedTarget: boolean;
|
||||
}) {
|
||||
return isAssetSelectionMode && button === 0 && !isBlockedTarget;
|
||||
}
|
||||
|
||||
export function createAssetMarqueeFromPointer({
|
||||
pointerId,
|
||||
point,
|
||||
containerRect,
|
||||
}: {
|
||||
pointerId: number;
|
||||
point: ClientPoint;
|
||||
containerRect: Pick<DOMRect, 'left' | 'top'> | null | undefined;
|
||||
}): AssetMarqueeState {
|
||||
const startX = point.clientX - (containerRect?.left ?? 0);
|
||||
const startY = point.clientY - (containerRect?.top ?? 0);
|
||||
return {
|
||||
pointerId,
|
||||
startX,
|
||||
startY,
|
||||
currentX: startX,
|
||||
currentY: startY,
|
||||
};
|
||||
}
|
||||
|
||||
export function moveAssetMarqueeToPointer({
|
||||
marquee,
|
||||
point,
|
||||
containerRect,
|
||||
}: {
|
||||
marquee: AssetMarqueeState;
|
||||
point: ClientPoint;
|
||||
containerRect: Pick<DOMRect, 'left' | 'top'> | null | undefined;
|
||||
}) {
|
||||
return {
|
||||
...marquee,
|
||||
currentX: point.clientX - (containerRect?.left ?? 0),
|
||||
currentY: point.clientY - (containerRect?.top ?? 0),
|
||||
};
|
||||
}
|
||||
|
||||
export function createAssetMarqueeSelectionRect({
|
||||
marquee,
|
||||
point,
|
||||
containerRect,
|
||||
}: {
|
||||
marquee: AssetMarqueeState;
|
||||
point: ClientPoint;
|
||||
containerRect: Pick<DOMRect, 'left' | 'top'> | null | undefined;
|
||||
}): ClientRectLike {
|
||||
const startClientX = (containerRect?.left ?? 0) + marquee.startX;
|
||||
const startClientY = (containerRect?.top ?? 0) + marquee.startY;
|
||||
return {
|
||||
left: Math.min(startClientX, point.clientX),
|
||||
right: Math.max(startClientX, point.clientX),
|
||||
top: Math.min(startClientY, point.clientY),
|
||||
bottom: Math.max(startClientY, point.clientY),
|
||||
};
|
||||
}
|
||||
|
||||
export function selectUploadedAssetsInRect({
|
||||
assets,
|
||||
assetTargets,
|
||||
selectionRect,
|
||||
}: {
|
||||
assets: EditorAsset[];
|
||||
assetTargets: AssetHitTarget[];
|
||||
selectionRect: ClientRectLike;
|
||||
}) {
|
||||
const uploadedAssetIds = new Set(
|
||||
assets
|
||||
.filter((asset) => asset.sourceKind === 'uploaded')
|
||||
.map((asset) => asset.id),
|
||||
);
|
||||
return new Set(
|
||||
assetTargets
|
||||
.filter(
|
||||
(target) =>
|
||||
uploadedAssetIds.has(target.assetId) &&
|
||||
doRectsIntersect(target.rect, selectionRect),
|
||||
)
|
||||
.map((target) => target.assetId),
|
||||
);
|
||||
}
|
||||
|
||||
export function groupAssetsByFolder(
|
||||
assetFolders: EditorAssetFolder[],
|
||||
assets: EditorAsset[],
|
||||
@@ -191,14 +84,20 @@ function normalizeSearchQuery(query: string) {
|
||||
return query.trim().toLowerCase();
|
||||
}
|
||||
|
||||
function matchesSearchText(query: string, values: Array<string | null | undefined>) {
|
||||
function matchesSearchText(
|
||||
query: string,
|
||||
values: Array<string | null | undefined>,
|
||||
) {
|
||||
if (!query) {
|
||||
return true;
|
||||
}
|
||||
return values.some((value) => value?.toLowerCase().includes(query));
|
||||
}
|
||||
|
||||
function startsWithSearchText(query: string, values: Array<string | null | undefined>) {
|
||||
function startsWithSearchText(
|
||||
query: string,
|
||||
values: Array<string | null | undefined>,
|
||||
) {
|
||||
if (!query) {
|
||||
return true;
|
||||
}
|
||||
@@ -290,10 +189,6 @@ export function filterGroupedAssetsBySearch(
|
||||
.filter((folder) => folder.assets.length > 0);
|
||||
}
|
||||
|
||||
export function getSelectableAssets(assets: EditorAsset[]) {
|
||||
return assets.filter((asset) => asset.sourceKind === 'uploaded');
|
||||
}
|
||||
|
||||
export type AssetShowcaseSubmissionState = {
|
||||
canSubmit: boolean;
|
||||
label: string;
|
||||
@@ -370,16 +265,6 @@ export function resolveAssetShowcaseSubmissionState(
|
||||
};
|
||||
}
|
||||
|
||||
export function areAllSelectableAssetsSelected(
|
||||
selectableAssets: EditorAsset[],
|
||||
selectedAssetIds: Set<string>,
|
||||
) {
|
||||
return (
|
||||
selectableAssets.length > 0 &&
|
||||
selectableAssets.every((asset) => selectedAssetIds.has(asset.id))
|
||||
);
|
||||
}
|
||||
|
||||
export function renameAssetById(
|
||||
assets: EditorAsset[],
|
||||
assetId: string,
|
||||
@@ -485,7 +370,9 @@ export function renameAssetFolderById(
|
||||
|
||||
export function resolveDefaultAssetFolder(assetFolders: EditorAssetFolder[]) {
|
||||
return (
|
||||
assetFolders.find((folder) => folder.systemDefault) ?? assetFolders[0] ?? null
|
||||
assetFolders.find((folder) => folder.systemDefault) ??
|
||||
assetFolders[0] ??
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
@@ -500,7 +387,10 @@ export function resolveUploadAssetFolder(assetFolders: EditorAssetFolder[]) {
|
||||
export function resolvePreferredAssetUploadFolder(
|
||||
assetFolders: EditorAssetFolder[],
|
||||
) {
|
||||
return resolveUploadAssetFolder(assetFolders) ?? resolveDefaultAssetFolder(assetFolders);
|
||||
return (
|
||||
resolveUploadAssetFolder(assetFolders) ??
|
||||
resolveDefaultAssetFolder(assetFolders)
|
||||
);
|
||||
}
|
||||
|
||||
export function deleteAssetFolderLocally({
|
||||
@@ -527,36 +417,13 @@ export function deleteAssetFolderLocally({
|
||||
};
|
||||
}
|
||||
|
||||
export function toggleAssetSelection(
|
||||
selectedAssetIds: Set<string>,
|
||||
assetId: string,
|
||||
) {
|
||||
const nextIds = new Set(selectedAssetIds);
|
||||
if (nextIds.has(assetId)) {
|
||||
nextIds.delete(assetId);
|
||||
} else {
|
||||
nextIds.add(assetId);
|
||||
}
|
||||
return nextIds;
|
||||
}
|
||||
|
||||
export function resolveAllAssetSelection({
|
||||
allSelectableAssetsSelected,
|
||||
selectableAssets,
|
||||
}: {
|
||||
allSelectableAssetsSelected: boolean;
|
||||
selectableAssets: EditorAsset[];
|
||||
}) {
|
||||
return allSelectableAssetsSelected
|
||||
? new Set<string>()
|
||||
: new Set(selectableAssets.map((asset) => asset.id));
|
||||
}
|
||||
|
||||
export function removeSelectedAssets(
|
||||
assets: EditorAsset[],
|
||||
selectedAssetIds: Set<string>,
|
||||
) {
|
||||
const deletedAssets = assets.filter((asset) => selectedAssetIds.has(asset.id));
|
||||
const deletedAssets = assets.filter((asset) =>
|
||||
selectedAssetIds.has(asset.id),
|
||||
);
|
||||
return {
|
||||
assets: assets.filter((asset) => !selectedAssetIds.has(asset.id)),
|
||||
deletedAssets,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user