合并最新 master 并解决画布共享冲突

同步 origin/master 的场景、音效、运行时与后端原子提交改动

保留共享画布包导出并补齐场景、生成配方和历史动作类型

融合清单刷新测试、幂等请求、生成合同校验与项目记忆文档
This commit is contained in:
2026-08-10 11:34:38 +08:00
358 changed files with 51496 additions and 9219 deletions
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(
+11 -3
View File
@@ -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 }}
+53 -1
View File
@@ -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: [],
+24 -20
View File
@@ -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;
}
+145 -258
View File
@@ -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();
+10 -1
View File
@@ -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;
@@ -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(() => {
@@ -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}
@@ -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