合并最新 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
@@ -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,
@@ -1,6 +1,7 @@
import {
Check,
CheckSquare,
Download,
Folder,
Square,
Trash2,
@@ -12,9 +13,11 @@ import type {
RefObject,
SetStateAction,
} from 'react';
import { useEffect, useState } from 'react';
import { PlatformActionButton } from '../common/PlatformActionButton';
import { PlatformBatchActionToolbar } from '../common/PlatformBatchActionToolbar';
import { PlatformDangerConfirmDialog } from '../common/PlatformDangerConfirmDialog';
import { PlatformTextField } from '../common/PlatformTextField';
import { ImageCanvasAssetFolderSectionView } from './ImageCanvasAssetFolderSectionView';
import { EditorIconButton } from './ImageCanvasEditorPrimitives';
@@ -25,6 +28,10 @@ import type {
EditorAssetFolder,
UploadTarget,
} from './ImageCanvasEditorTypes';
import {
type AssetSelectionInteraction,
countHiddenSelectedAssets,
} from './useImageCanvasAssetSelection';
export type GroupedEditorAssetFolder = EditorAssetFolder & {
assets: EditorAsset[];
@@ -49,13 +56,15 @@ export type ImageCanvasAssetLibraryPanelViewProps = {
assetFolders: EditorAssetFolder[];
isAssetSelectionMode: boolean;
selectedAssetIds: Set<string>;
visibleAssetIds: string[];
assetMoveDropFolderId: string | null;
pinnedAssetMoveFolderId: string | null;
creatingFolder: boolean;
newFolderName: string;
renamingFolder: { folderId: string; value: string } | null;
renamingAsset: { assetId: string; value: string } | null;
allSelectableAssetsSelected: boolean;
allVisibleAssetsSelected: boolean;
isExportingSelectedAssets: boolean;
assetMarquee: AssetMarqueeState | null;
setCreatingFolder: Dispatch<SetStateAction<boolean>>;
setNewFolderName: Dispatch<SetStateAction<string>>;
@@ -68,18 +77,20 @@ export type ImageCanvasAssetLibraryPanelViewProps = {
setActiveUploadFolderId: Dispatch<SetStateAction<string>>;
setUploadDropTarget: Dispatch<SetStateAction<'canvas' | 'assets' | null>>;
setAssetPointerDrag: Dispatch<SetStateAction<AssetPointerDragState | null>>;
setSelectedAssetIds: Dispatch<SetStateAction<Set<string>>>;
onAssetMarqueePointerDown: (
onAssetMarqueePointerDown: (event: ReactPointerEvent<HTMLDivElement>) => void;
onAssetMarqueePointerMove: (event: ReactPointerEvent<HTMLDivElement>) => void;
onAssetMarqueePointerUp: (event: ReactPointerEvent<HTMLDivElement>) => void;
onAssetMarqueePointerCancel: (
event: ReactPointerEvent<HTMLDivElement>,
) => void;
onAssetMarqueePointerMove: (
event: ReactPointerEvent<HTMLDivElement>,
) => void;
onAssetMarqueePointerUp: (
onAssetMarqueeLostPointerCapture: (
event: ReactPointerEvent<HTMLDivElement>,
) => void;
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;
commitNewAssetFolder: () => void | Promise<void>;
@@ -91,11 +102,15 @@ export type ImageCanvasAssetLibraryPanelViewProps = {
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;
toggleAllAssetsSelected: () => void;
deleteSelectedAssets: () => void;
toggleAllAssetsSelected: (visibleAssetIds: string[]) => void;
downloadSelectedAssets: () => void | Promise<void>;
deleteSelectedAssets: (assetIds?: ReadonlySet<string>) => void;
closeAssetSelectionMode: () => void;
};
@@ -107,13 +122,15 @@ export function ImageCanvasAssetLibraryPanelView({
assetFolders,
isAssetSelectionMode,
selectedAssetIds,
visibleAssetIds,
assetMoveDropFolderId,
pinnedAssetMoveFolderId,
creatingFolder,
newFolderName,
renamingFolder,
renamingAsset,
allSelectableAssetsSelected,
allVisibleAssetsSelected,
isExportingSelectedAssets,
assetMarquee,
setCreatingFolder,
setNewFolderName,
@@ -122,10 +139,11 @@ export function ImageCanvasAssetLibraryPanelView({
setActiveUploadFolderId,
setUploadDropTarget,
setAssetPointerDrag,
setSelectedAssetIds,
onAssetMarqueePointerDown,
onAssetMarqueePointerMove,
onAssetMarqueePointerUp,
onAssetMarqueePointerCancel,
onAssetMarqueeLostPointerCapture,
updateAssetMoveDropFolder,
addUploadedFiles,
requestUpload,
@@ -143,148 +161,246 @@ export function ImageCanvasAssetLibraryPanelView({
addAssetLayer,
onDownloadAsset,
toggleAllAssetsSelected,
downloadSelectedAssets,
deleteSelectedAssets,
closeAssetSelectionMode,
}: ImageCanvasAssetLibraryPanelViewProps) {
const [hiddenSelectionDeleteSnapshot, setHiddenSelectionDeleteSnapshot] =
useState<{
assetIds: Set<string>;
visibleAssetIds: string[];
hiddenAssetCount: number;
} | null>(null);
let selectionToggleActionText = '全选';
if (allVisibleAssetsSelected) {
selectionToggleActionText = '取消全选';
}
let selectionToggleLabel = selectionToggleActionText;
if (selectedAssetIds.size > 0) {
selectionToggleLabel = `${selectionToggleActionText} · 已选 ${selectedAssetIds.size}`;
}
const hiddenSelectedAssetCount = countHiddenSelectedAssets(
selectedAssetIds,
visibleAssetIds,
);
useEffect(() => {
if (!hiddenSelectionDeleteSnapshot) {
return;
}
const selectedIdsUnchanged =
hiddenSelectionDeleteSnapshot.assetIds.size === selectedAssetIds.size &&
[...hiddenSelectionDeleteSnapshot.assetIds].every((assetId) =>
selectedAssetIds.has(assetId),
);
const visibleIdsUnchanged =
hiddenSelectionDeleteSnapshot.visibleAssetIds.length ===
visibleAssetIds.length &&
hiddenSelectionDeleteSnapshot.visibleAssetIds.every(
(assetId, index) => assetId === visibleAssetIds[index],
);
if (
!isAssetSelectionMode ||
!selectedIdsUnchanged ||
!visibleIdsUnchanged
) {
setHiddenSelectionDeleteSnapshot(null);
}
}, [
hiddenSelectionDeleteSnapshot,
isAssetSelectionMode,
selectedAssetIds,
visibleAssetIds,
]);
const requestDeleteSelectedAssets = () => {
if (hiddenSelectedAssetCount > 0) {
setHiddenSelectionDeleteSnapshot({
assetIds: new Set(selectedAssetIds),
visibleAssetIds: [...visibleAssetIds],
hiddenAssetCount: hiddenSelectedAssetCount,
});
return;
}
deleteSelectedAssets();
};
return (
<div
ref={assetListRef}
className="image-canvas-editor__asset-list"
onPointerDown={onAssetMarqueePointerDown}
onPointerMove={onAssetMarqueePointerMove}
onPointerUp={onAssetMarqueePointerUp}
onPointerCancel={onAssetMarqueePointerUp}
>
{pinnedAssetMoveFolderId ? (
<div
className="image-canvas-editor__asset-folder-sticky-target"
aria-hidden="true"
>
<Folder className="h-4 w-4" />
<span>
{assetFolders.find((folder) => folder.id === pinnedAssetMoveFolderId)
?.label ?? '目标文件夹'}
</span>
</div>
) : null}
{creatingFolder ? (
<form
className="image-canvas-editor__folder-create"
onSubmit={(event) => {
event.preventDefault();
void commitNewAssetFolder();
}}
>
<PlatformTextField
aria-label="素材文件夹名称"
value={newFolderName}
autoFocus
size="xs"
density="compact"
className="image-canvas-editor__folder-create-input"
onChange={(event) => setNewFolderName(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Escape') {
event.preventDefault();
<div className="image-canvas-editor__asset-library">
<div
ref={assetListRef}
className="image-canvas-editor__asset-list"
onPointerDown={onAssetMarqueePointerDown}
onPointerMove={onAssetMarqueePointerMove}
onPointerUp={onAssetMarqueePointerUp}
onPointerCancel={onAssetMarqueePointerCancel}
onLostPointerCapture={onAssetMarqueeLostPointerCapture}
>
{pinnedAssetMoveFolderId ? (
<div
className="image-canvas-editor__asset-folder-sticky-target"
aria-hidden="true"
>
<Folder className="h-4 w-4" />
<span>
{assetFolders.find(
(folder) => folder.id === pinnedAssetMoveFolderId,
)?.label ?? '目标文件夹'}
</span>
</div>
) : null}
{creatingFolder ? (
<form
className="image-canvas-editor__folder-create"
onSubmit={(event) => {
event.preventDefault();
void commitNewAssetFolder();
}}
>
<PlatformTextField
aria-label="素材文件夹名称"
value={newFolderName}
autoFocus
size="xs"
density="compact"
className="image-canvas-editor__folder-create-input"
onChange={(event) => setNewFolderName(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Escape') {
event.preventDefault();
setCreatingFolder(false);
setNewFolderName('');
}
}}
/>
<EditorIconButton
type="submit"
label="保存素材文件夹"
icon={Check}
/>
<EditorIconButton
label="取消新建素材文件夹"
icon={X}
onClick={() => {
setCreatingFolder(false);
setNewFolderName('');
}
}}
/>
</form>
) : null}
{groupedAssets.map((folder) => (
<ImageCanvasAssetFolderSectionView
key={folder.id}
folder={folder}
assetPointerDragRef={assetPointerDragRef}
suppressAssetClickRef={suppressAssetClickRef}
isAssetSelectionMode={isAssetSelectionMode}
selectedAssetIds={selectedAssetIds}
visibleAssetIds={visibleAssetIds}
assetMoveDropFolderId={assetMoveDropFolderId}
renamingFolder={renamingFolder}
renamingAsset={renamingAsset}
setRenamingFolder={setRenamingFolder}
setRenamingAsset={setRenamingAsset}
setActiveUploadFolderId={setActiveUploadFolderId}
setUploadDropTarget={setUploadDropTarget}
setAssetPointerDrag={setAssetPointerDrag}
updateAssetMoveDropFolder={updateAssetMoveDropFolder}
addUploadedFiles={addUploadedFiles}
requestUpload={requestUpload}
moveAssetToFolder={moveAssetToFolder}
toggleAssetFolder={toggleAssetFolder}
startRenamingFolder={startRenamingFolder}
commitFolderRename={commitFolderRename}
deleteAssetFolder={deleteAssetFolder}
startRenamingAsset={startRenamingAsset}
commitAssetRename={commitAssetRename}
deleteUploadedAsset={deleteUploadedAsset}
submitAssetToShowcase={submitAssetToShowcase}
toggleAssetSelected={toggleAssetSelected}
addAssetLayer={addAssetLayer}
onDownloadAsset={onDownloadAsset}
/>
))}
{assetMarquee ? (
<div
className="image-canvas-editor__asset-marquee"
aria-hidden="true"
style={{
left: Math.min(assetMarquee.startX, assetMarquee.currentX),
top: Math.min(assetMarquee.startY, assetMarquee.currentY),
width: Math.abs(assetMarquee.currentX - assetMarquee.startX),
height: Math.abs(assetMarquee.currentY - assetMarquee.startY),
}}
/>
<EditorIconButton type="submit" label="保存素材文件夹" icon={Check} />
<EditorIconButton
label="取消新建素材文件夹"
icon={X}
onClick={() => {
setCreatingFolder(false);
setNewFolderName('');
}}
/>
</form>
) : null}
{groupedAssets.map((folder) => (
<ImageCanvasAssetFolderSectionView
key={folder.id}
folder={folder}
assetPointerDragRef={assetPointerDragRef}
suppressAssetClickRef={suppressAssetClickRef}
isAssetSelectionMode={isAssetSelectionMode}
selectedAssetIds={selectedAssetIds}
assetMoveDropFolderId={assetMoveDropFolderId}
renamingFolder={renamingFolder}
renamingAsset={renamingAsset}
setRenamingFolder={setRenamingFolder}
setRenamingAsset={setRenamingAsset}
setActiveUploadFolderId={setActiveUploadFolderId}
setUploadDropTarget={setUploadDropTarget}
setAssetPointerDrag={setAssetPointerDrag}
setSelectedAssetIds={setSelectedAssetIds}
updateAssetMoveDropFolder={updateAssetMoveDropFolder}
addUploadedFiles={addUploadedFiles}
requestUpload={requestUpload}
moveAssetToFolder={moveAssetToFolder}
toggleAssetFolder={toggleAssetFolder}
startRenamingFolder={startRenamingFolder}
commitFolderRename={commitFolderRename}
deleteAssetFolder={deleteAssetFolder}
startRenamingAsset={startRenamingAsset}
commitAssetRename={commitAssetRename}
deleteUploadedAsset={deleteUploadedAsset}
submitAssetToShowcase={submitAssetToShowcase}
toggleAssetSelected={toggleAssetSelected}
addAssetLayer={addAssetLayer}
onDownloadAsset={onDownloadAsset}
/>
))}
) : null}
</div>
{isAssetSelectionMode ? (
<PlatformBatchActionToolbar
className="image-canvas-editor__asset-batch-toolbar"
className="static! left-auto! bottom-auto! grid! w-full! max-w-none! transform-none! grid-cols-3! gap-1.5! overflow-hidden! rounded-none! border-x-0! border-b-0! px-2.5! pt-2.5! pb-[calc(0.55rem+env(safe-area-inset-bottom,0px))]! shadow-[0_-8px_22px_rgba(15,23,42,0.08)]!"
label="素材批量操作"
>
<PlatformActionButton
className="image-canvas-editor__asset-batch-action col-span-3"
tone="secondary"
size="sm"
onClick={toggleAllAssetsSelected}
disabled={visibleAssetIds.length === 0 || isExportingSelectedAssets}
onClick={() => toggleAllAssetsSelected(visibleAssetIds)}
>
{allSelectableAssetsSelected ? (
{allVisibleAssetsSelected ? (
<CheckSquare className="h-4 w-4" />
) : (
<Square className="h-4 w-4" />
)}
{selectedAssetIds.size > 0
? `${allSelectableAssetsSelected ? '取消全选' : '全选'} · 已选 ${selectedAssetIds.size}`
: '全选'}
<span className="min-w-0 truncate">{selectionToggleLabel}</span>
</PlatformActionButton>
<PlatformActionButton
className="image-canvas-editor__asset-batch-action"
tone="secondary"
size="sm"
disabled={selectedAssetIds.size === 0 || isExportingSelectedAssets}
onClick={() => void downloadSelectedAssets()}
>
<Download className="h-4 w-4" />
{isExportingSelectedAssets ? '下载中' : '下载'}
</PlatformActionButton>
<PlatformActionButton
className="image-canvas-editor__asset-batch-action"
tone="warning"
size="sm"
disabled={selectedAssetIds.size === 0}
onClick={deleteSelectedAssets}
disabled={selectedAssetIds.size === 0 || isExportingSelectedAssets}
onClick={requestDeleteSelectedAssets}
>
<Trash2 className="h-4 w-4" />
</PlatformActionButton>
<PlatformActionButton
className="image-canvas-editor__asset-batch-action"
tone="secondary"
size="sm"
onClick={closeAssetSelectionMode}
>
<X className="h-4 w-4" />
</PlatformActionButton>
</PlatformBatchActionToolbar>
) : null}
{assetMarquee ? (
<div
className="image-canvas-editor__asset-marquee"
aria-hidden="true"
style={{
left: Math.min(assetMarquee.startX, assetMarquee.currentX),
top: Math.min(assetMarquee.startY, assetMarquee.currentY),
width: Math.abs(assetMarquee.currentX - assetMarquee.startX),
height: Math.abs(assetMarquee.currentY - assetMarquee.startY),
}}
/>
) : null}
<PlatformDangerConfirmDialog
open={isAssetSelectionMode && hiddenSelectionDeleteSnapshot !== null}
title="删除全部已选素材"
description={`将删除 ${hiddenSelectionDeleteSnapshot?.assetIds.size ?? 0} 个已选素材,其中 ${hiddenSelectionDeleteSnapshot?.hiddenAssetCount ?? 0} 个当前未显示。`}
confirmLabel="确认删除全部已选素材"
onClose={() => setHiddenSelectionDeleteSnapshot(null)}
onConfirm={() => {
const targetAssetIds = hiddenSelectionDeleteSnapshot?.assetIds;
setHiddenSelectionDeleteSnapshot(null);
if (targetAssetIds) {
deleteSelectedAssets(targetAssetIds);
}
}}
>
</PlatformDangerConfirmDialog>
</div>
);
}
@@ -4,7 +4,10 @@ import { fireEvent, render, screen } from '@testing-library/react';
import type { ReactNode } from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { ImageCanvasAssetRowView } from './ImageCanvasAssetRowView';
import {
ImageCanvasAssetRowView,
type ImageCanvasAssetRowViewProps,
} from './ImageCanvasAssetRowView';
import type { EditorAsset } from './ImageCanvasEditorTypes';
const useResolvedAssetReadUrlMock = vi.hoisted(() => vi.fn());
@@ -94,7 +97,7 @@ function renderAssetRow({
renamingAsset?: { assetId: string; value: string } | null;
suppressAssetClick?: boolean;
addAssetLayer?: (asset: EditorAsset) => void;
toggleAssetSelected?: (assetId: string) => void;
toggleAssetSelected?: ImageCanvasAssetRowViewProps['toggleAssetSelected'];
commitAssetRename?: (asset: EditorAsset) => void;
deleteUploadedAsset?: (asset: EditorAsset) => void;
submitAssetToShowcase?: (asset: EditorAsset) => void;
@@ -106,11 +109,11 @@ function renderAssetRow({
suppressAssetClickRef: { current: suppressAssetClick },
isAssetSelectionMode,
selectedAssetIds,
visibleAssetIds: [asset.id],
renamingAsset,
setRenamingAsset: vi.fn(),
setUploadDropTarget: vi.fn(),
setAssetPointerDrag: vi.fn(),
setSelectedAssetIds: vi.fn(),
updateAssetMoveDropFolder: vi.fn(),
addUploadedFiles: vi.fn(),
moveAssetToFolder: vi.fn(),
@@ -123,8 +126,10 @@ function renderAssetRow({
onDownloadAsset,
};
render(<ImageCanvasAssetRowView {...props} />);
return props;
return {
props,
...render(<ImageCanvasAssetRowView {...props} />),
};
}
describe('ImageCanvasAssetRowView', () => {
@@ -182,6 +187,25 @@ describe('ImageCanvasAssetRowView', () => {
expect(screen.queryByRole('menu')).toBeNull();
});
it('closes the right click menu when selection mode becomes active', () => {
const { props, rerender } = renderAssetRow();
fireEvent.contextMenu(
screen.getByRole('button', { name: '添加账号素材A' }),
{
clientX: 120,
clientY: 80,
},
);
expect(screen.getByRole('menu')).toBeTruthy();
rerender(
<ImageCanvasAssetRowView {...props} isAssetSelectionMode={true} />,
);
expect(screen.queryByRole('menu')).toBeNull();
});
it('selects the asset instead of adding it in selection mode', () => {
const addAssetLayer = vi.fn();
const toggleAssetSelected = vi.fn();
@@ -193,8 +217,76 @@ describe('ImageCanvasAssetRowView', () => {
fireEvent.click(screen.getByRole('button', { name: '选择素材账号素材A' }));
expect(toggleAssetSelected).toHaveBeenCalledWith('asset-1');
expect(toggleAssetSelected).toHaveBeenCalledWith('asset-1', {
visibleAssetIds: ['asset-1'],
shiftKey: false,
});
expect(addAssetLayer).not.toHaveBeenCalled();
expect(
screen.queryByRole('button', { name: '下载素材账号素材A' }),
).toBeNull();
});
it('disables rows that are outside the shared selectable-asset boundary', () => {
const toggleAssetSelected = vi.fn();
renderAssetRow({
asset: createAsset({ sourceKind: 'built-in' }),
isAssetSelectionMode: true,
toggleAssetSelected,
});
const button = screen.getByRole('button', {
name: '素材账号素材A不可选择',
}) as HTMLButtonElement;
expect(button.disabled).toBe(true);
fireEvent.click(screen.getByText('账号素材A'));
expect(toggleAssetSelected).not.toHaveBeenCalled();
});
it('forwards only the shift modifier used by selection semantics', () => {
const toggleAssetSelected = vi.fn();
renderAssetRow({ isAssetSelectionMode: true, toggleAssetSelected });
fireEvent.click(screen.getByText('账号素材A'), {
ctrlKey: true,
shiftKey: true,
});
expect(toggleAssetSelected).toHaveBeenCalledWith('asset-1', {
visibleAssetIds: ['asset-1'],
shiftKey: true,
});
});
it('blocks internal moves and external file drops in selection mode', () => {
const { container, props } = renderAssetRow({
isAssetSelectionMode: true,
});
const row = container.querySelector<HTMLElement>(
'[data-asset-id="asset-1"]',
);
if (!row) {
throw new Error('asset row should exist');
}
const fileTransfer = {
types: ['Files'],
files: [new File(['image'], '新增素材.png', { type: 'image/png' })],
dropEffect: 'copy',
getData: vi.fn(() => ''),
};
fireEvent.dragOver(row, { dataTransfer: fileTransfer });
fireEvent.drop(row, { dataTransfer: fileTransfer });
const assetTransfer = {
types: ['application/x-genarrative-editor-asset'],
files: [],
dropEffect: 'move',
getData: vi.fn(() => 'asset-other'),
};
fireEvent.dragOver(row, { dataTransfer: assetTransfer });
fireEvent.drop(row, { dataTransfer: assetTransfer });
expect(props.addUploadedFiles).not.toHaveBeenCalled();
expect(props.moveAssetToFolder).not.toHaveBeenCalled();
});
it('renders rename input and commits with Enter', () => {
@@ -1,5 +1,10 @@
import { Check, Download, Pencil, Video, X } from 'lucide-react';
import type { Dispatch, SetStateAction } from 'react';
import type {
Dispatch,
MouseEvent as ReactMouseEvent,
ReactNode,
SetStateAction,
} from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useResolvedAssetReadUrl } from '../../hooks/useResolvedAssetReadUrl';
@@ -24,6 +29,10 @@ import {
getCanvasMediaPreviewClassName,
getCanvasMediaPreviewMarker,
} from './ImageCanvasMediaModel';
import {
type AssetSelectionInteraction,
isSelectableAsset,
} from './useImageCanvasAssetSelection';
const AUDIO_ASSET_COVER_SRC = '/creation-home/audio-asset-cover.png';
@@ -33,13 +42,13 @@ export type ImageCanvasAssetRowViewProps = {
suppressAssetClickRef: { current: boolean };
isAssetSelectionMode: boolean;
selectedAssetIds: Set<string>;
visibleAssetIds: string[];
renamingAsset: { assetId: string; value: string } | null;
setRenamingAsset: Dispatch<
SetStateAction<{ assetId: string; value: string } | null>
>;
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[],
@@ -50,7 +59,10 @@ export type ImageCanvasAssetRowViewProps = {
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;
};
@@ -61,11 +73,11 @@ export function ImageCanvasAssetRowView({
suppressAssetClickRef,
isAssetSelectionMode,
selectedAssetIds,
visibleAssetIds,
renamingAsset,
setRenamingAsset,
setUploadDropTarget,
setAssetPointerDrag,
setSelectedAssetIds,
updateAssetMoveDropFolder,
addUploadedFiles,
moveAssetToFolder,
@@ -85,6 +97,7 @@ export function ImageCanvasAssetRowView({
const isRenaming = renamingAsset?.assetId === asset.id;
const isUploadingAsset = asset.uploadStatus === 'uploading';
const isFailedUpload = asset.uploadStatus === 'failed';
const canSelectAsset = isSelectableAsset(asset);
const showcaseReviewStatus = asset.showcaseReviewStatus ?? null;
const showcaseSubmissionState = resolveAssetShowcaseSubmissionState(asset);
const canSubmitShowcaseAsset = showcaseSubmissionState.canSubmit;
@@ -95,6 +108,38 @@ export function ImageCanvasAssetRowView({
setMenuPosition(null);
}, []);
useEffect(() => {
if (isAssetSelectionMode) {
closeMenu();
}
}, [closeMenu, isAssetSelectionMode]);
const selectAssetFromEvent = useCallback(
(event: ReactMouseEvent<HTMLElement>) => {
if (
!isAssetSelectionMode ||
!canSelectAsset ||
suppressAssetClickRef.current
) {
return;
}
closeMenu();
toggleAssetSelected(asset.id, {
visibleAssetIds,
shiftKey: event.shiftKey,
});
},
[
asset.id,
canSelectAsset,
closeMenu,
isAssetSelectionMode,
suppressAssetClickRef,
toggleAssetSelected,
visibleAssetIds,
],
);
useEffect(() => {
if (!menuPosition) {
return undefined;
@@ -158,6 +203,16 @@ export function ImageCanvasAssetRowView({
<Video className="h-5 w-5" />
</div>
) : undefined;
let primaryLabel = `添加${asset.label}`;
if (isUploadingAsset) {
primaryLabel = `上传中${asset.label}`;
} else if (isFailedUpload) {
primaryLabel = `上传失败${asset.label}`;
} else if (isAssetSelectionMode) {
primaryLabel = canSelectAsset
? `选择素材${asset.label}`
: `素材${asset.label}不可选择`;
}
const titleNode = isRenaming ? (
<PlatformTextField
aria-label={`重命名素材${asset.label}`}
@@ -184,54 +239,69 @@ export function ImageCanvasAssetRowView({
}}
/>
) : undefined;
const actions = isUploadingAsset ? (
<div className="image-canvas-editor__asset-upload-status">
<span>{asset.uploadMessage ?? '上传中'}</span>
<strong>{Math.round(uploadProgress)}%</strong>
</div>
) : isRenaming ? (
<div className="image-canvas-editor__asset-actions">
<EditorIconButton
label={`保存素材${asset.label}名称`}
title="保存"
icon={Check}
onClick={() => commitAssetRename(asset)}
/>
<EditorIconButton
label={`取消重命名素材${asset.label}`}
title="取消"
icon={X}
onClick={() => setRenamingAsset(null)}
/>
</div>
) : (
<div className="image-canvas-editor__asset-actions">
<EditorIconButton
label={`下载素材${asset.label}`}
title="下载"
icon={Download}
onClick={() => {
closeMenu();
onDownloadAsset(asset);
}}
/>
<EditorIconButton
label={`重命名素材${asset.label}`}
title="重命名"
icon={Pencil}
onClick={() => {
closeMenu();
startRenamingAsset(asset);
}}
/>
</div>
);
let actions: ReactNode;
if (isAssetSelectionMode) {
actions = undefined;
} else if (isUploadingAsset) {
actions = (
<div className="image-canvas-editor__asset-upload-status">
<span>{asset.uploadMessage ?? '上传中'}</span>
<strong>{Math.round(uploadProgress)}%</strong>
</div>
);
} else if (isRenaming) {
actions = (
<div className="image-canvas-editor__asset-actions">
<EditorIconButton
label={`保存素材${asset.label}名称`}
title="保存"
icon={Check}
onClick={() => commitAssetRename(asset)}
/>
<EditorIconButton
label={`取消重命名素材${asset.label}`}
title="取消"
icon={X}
onClick={() => setRenamingAsset(null)}
/>
</div>
);
} else {
actions = (
<div className="image-canvas-editor__asset-actions">
<EditorIconButton
label={`下载素材${asset.label}`}
title="下载"
icon={Download}
onClick={() => {
closeMenu();
onDownloadAsset(asset);
}}
/>
<EditorIconButton
label={`重命名素材${asset.label}`}
title="重命名"
icon={Pencil}
onClick={() => {
closeMenu();
startRenamingAsset(asset);
}}
/>
</div>
);
}
return (
<div
ref={rowRef}
data-asset-id={asset.id}
onClick={selectAssetFromEvent}
onContextMenu={(event) => {
if (isAssetSelectionMode) {
event.preventDefault();
event.stopPropagation();
return;
}
if (isUploadingAsset || isFailedUpload || isRenaming) {
return;
}
@@ -244,15 +314,8 @@ export function ImageCanvasAssetRowView({
title={asset.label}
detail={mediaDetail}
imageAlt={`素材:${asset.label}`}
primaryLabel={
isUploadingAsset
? `上传中${asset.label}`
: isFailedUpload
? `上传失败${asset.label}`
: isAssetSelectionMode
? `选择素材${asset.label}`
: `添加${asset.label}`
}
primaryLabel={primaryLabel}
primaryDisabled={isAssetSelectionMode && !canSelectAsset}
onPrimaryClick={() => {
if (isUploadingAsset || isFailedUpload) {
return;
@@ -262,12 +325,11 @@ export function ImageCanvasAssetRowView({
return;
}
if (isAssetSelectionMode) {
toggleAssetSelected(asset.id);
return;
}
addAssetLayer(asset);
}}
selected={selectedAssetIds.has(asset.id)}
selected={isAssetSelectionMode && selectedAssetIds.has(asset.id)}
rowClassName={[
'image-canvas-editor__asset-row',
isUploadingAsset ? 'image-canvas-editor__asset-row--uploading' : '',
@@ -295,7 +357,12 @@ export function ImageCanvasAssetRowView({
)
}
actions={actions}
draggable={!isRenaming && !isUploadingAsset && !isFailedUpload}
draggable={
!isAssetSelectionMode &&
!isRenaming &&
!isUploadingAsset &&
!isFailedUpload
}
previewOverlay={
isUploadingAsset ? (
<div className="image-canvas-editor__asset-upload-overlay">
@@ -325,7 +392,12 @@ export function ImageCanvasAssetRowView({
) : undefined
}
onDragStart={(event) => {
if (isRenaming || isUploadingAsset || isFailedUpload) {
if (
isAssetSelectionMode ||
isRenaming ||
isUploadingAsset ||
isFailedUpload
) {
event.preventDefault();
return;
}
@@ -344,16 +416,13 @@ export function ImageCanvasAssetRowView({
updateAssetMoveDropFolder(null);
}}
onPointerDown={(event) => {
if (isAssetSelectionMode) {
return;
}
if (event.button !== 0) {
return;
}
const target = event.target as HTMLElement;
if (isAssetSelectionMode) {
if (target.closest('button')) {
return;
}
return;
}
if (
isRenaming ||
isUploadingAsset ||
@@ -390,16 +459,18 @@ export function ImageCanvasAssetRowView({
assetPointerDragRef.current = nextDrag;
setAssetPointerDrag(nextDrag);
}}
onPointerEnter={(event) => {
if (isAssetSelectionMode && event.buttons === 1) {
setSelectedAssetIds((currentIds) => {
const nextIds = new Set(currentIds);
nextIds.add(asset.id);
return nextIds;
});
}
}}
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();
@@ -416,6 +487,18 @@ export function ImageCanvasAssetRowView({
}
}}
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();
@@ -0,0 +1,495 @@
import {
ChevronDown,
ChevronLeft,
ChevronRight,
ChevronUp,
} from 'lucide-react';
import {
type PointerEvent as ReactPointerEvent,
type ReactNode,
useCallback,
useEffect,
useId,
useRef,
useState,
} from 'react';
import { PlatformActionButton } from '../common/PlatformActionButton';
export type AudioPromptPreset = {
id: string;
group: string;
label: string;
};
/** 多份等宽队列拼接实现无缝循环:首尾之间不跳回也不留白。 */
const PRESET_QUEUE_COPIES = 3;
/** 初始 scrollLeft 正对中间队列,因此它同时承担唯一的键盘与屏幕阅读器入口。 */
const PRESET_ACCESSIBLE_QUEUE_INDEX = 1;
/** 默认缓慢循环与 hover 加速的速度,单位 px/s。 */
const PRESET_BASE_VELOCITY = 26;
/**
* 加速档按真机实测下调到原值 340 的 70%。左右 15% 控制区与左右箭头共用同一个速度:
* 需求要求“箭头和滚动栏两边都有加速滚动”,两者相邻,拆成两个速度会让指针在箭头与
* 控制区之间移动时出现速度突变。
*/
const PRESET_FAST_VELOCITY = 238;
/** 左右控制区各占 viewport 宽度的 15%,中间 70% 为暂停区。 */
const PRESET_SIDE_ZONE_RATIO = 0.15;
/** 箭头点击的一次离散滚动量,按 viewport 宽度比例计算。 */
const PRESET_ARROW_STEP_RATIO = 0.6;
/** 单帧最大步进:标签页被挂起后回到前台时不产生一次巨大跳变。 */
const PRESET_MAX_FRAME_MS = 100;
/**
* 触摸抬手后判定“惯性已经停下”的空闲窗口。原生惯性可能持续一两秒,固定等待再恢复
* rAF 一定会打断它,所以这里等的是滚动事件停止,而不是一个固定时长。
*/
const PRESET_TOUCH_IDLE_MS = 160;
const PRESET_REDUCED_MOTION_QUERY = '(prefers-reduced-motion: reduce)';
/** 桌面 hover 控制区只在真正支持悬停的精确指针设备上启用。 */
const PRESET_HOVER_CONTROL_QUERY = '(hover: hover) and (pointer: fine)';
type AudioPresetZone = 'left' | 'center' | 'right' | null;
function readsMediaQueryMatches(query: string) {
if (
typeof window === 'undefined' ||
typeof window.matchMedia !== 'function'
) {
return false;
}
return window.matchMedia(query).matches;
}
function useMediaQueryMatches(query: string) {
const [matches, setMatches] = useState(() => readsMediaQueryMatches(query));
useEffect(() => {
if (
typeof window === 'undefined' ||
typeof window.matchMedia !== 'function'
) {
return undefined;
}
const mediaQuery = window.matchMedia(query);
// 运行中切换(系统偏好变化、外接鼠标插拔)也要立即生效,不能只读初值。
setMatches(mediaQuery.matches);
const handleChange = (event: MediaQueryListEvent) => {
setMatches(event.matches);
};
mediaQuery.addEventListener('change', handleChange);
return () => {
mediaQuery.removeEventListener('change', handleChange);
};
}, [query]);
return matches;
}
export function ImageCanvasAudioPresetMarquee<
TPreset extends AudioPromptPreset,
>({
presets,
isLocked,
onSelectPreset,
headerTrailing,
getPresetClassName,
}: {
presets: readonly TPreset[];
isLocked: boolean;
onSelectPreset: (preset: TPreset) => void;
/** 标题行右端的展示位。字数计数要和「预设」同排,但它属于 composer 的状态。 */
headerTrailing?: ReactNode;
getPresetClassName: (preset: TPreset) => string;
}) {
const trackId = useId();
const [isExpanded, setIsExpanded] = useState(false);
const [zone, setZone] = useState<AudioPresetZone>(null);
const [isKeyboardFocused, setIsKeyboardFocused] = useState(false);
const [isPageVisible, setIsPageVisible] = useState(
() =>
typeof document === 'undefined' || document.visibilityState !== 'hidden',
);
const prefersReducedMotion = useMediaQueryMatches(
PRESET_REDUCED_MOTION_QUERY,
);
const supportsHoverControls = useMediaQueryMatches(
PRESET_HOVER_CONTROL_QUERY,
);
const [isTouchScrolling, setIsTouchScrolling] = useState(false);
const viewportRef = useRef<HTMLDivElement | null>(null);
const queueRef = useRef<HTMLDivElement | null>(null);
const queueWidthRef = useRef(0);
const frameRef = useRef<number | null>(null);
const frameTimestampRef = useRef<number | null>(null);
const touchSettleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(
null,
);
// 归位只按实测的单份队列宽度取模,不硬编码 chip 或轨道宽度。
const normalizeScrollPosition = useCallback(() => {
const viewport = viewportRef.current;
const queueWidth = queueWidthRef.current;
if (!viewport || queueWidth <= 0) {
return;
}
if (viewport.scrollLeft < queueWidth * 0.5) {
viewport.scrollLeft += queueWidth;
return;
}
if (viewport.scrollLeft > queueWidth * 1.5) {
viewport.scrollLeft -= queueWidth;
}
}, []);
const velocity = (() => {
if (prefersReducedMotion || isLocked || !isExpanded || !isPageVisible) {
return 0;
}
if (isTouchScrolling || isKeyboardFocused || zone === 'center') {
return 0;
}
if (zone === 'left') {
return -PRESET_FAST_VELOCITY;
}
if (zone === 'right') {
return PRESET_FAST_VELOCITY;
}
return PRESET_BASE_VELOCITY;
})();
useEffect(() => {
if (velocity === 0) {
return undefined;
}
const step = (timestamp: number) => {
const previousTimestamp = frameTimestampRef.current ?? timestamp;
const deltaMs = Math.min(
Math.max(timestamp - previousTimestamp, 0),
PRESET_MAX_FRAME_MS,
);
frameTimestampRef.current = timestamp;
const viewport = viewportRef.current;
if (viewport) {
viewport.scrollLeft += (velocity * deltaMs) / 1000;
normalizeScrollPosition();
}
frameRef.current = requestAnimationFrame(step);
};
frameRef.current = requestAnimationFrame(step);
return () => {
frameTimestampRef.current = null;
if (frameRef.current !== null) {
cancelAnimationFrame(frameRef.current);
frameRef.current = null;
}
};
}, [normalizeScrollPosition, velocity]);
useEffect(() => {
if (typeof document === 'undefined') {
return undefined;
}
const handleVisibilityChange = () => {
setIsPageVisible(document.visibilityState !== 'hidden');
};
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => {
document.removeEventListener('visibilitychange', handleVisibilityChange);
};
}, []);
useEffect(() => {
const queue = queueRef.current;
if (!queue || typeof ResizeObserver === 'undefined') {
return undefined;
}
const observer = new ResizeObserver(() => {
const nextQueueWidth = queue.getBoundingClientRect().width;
if (nextQueueWidth <= 0) {
return;
}
const previousQueueWidth = queueWidthRef.current;
queueWidthRef.current = nextQueueWidth;
const viewport = viewportRef.current;
if (!viewport) {
return;
}
// 尺寸变化后按新宽度重新建立基准,避免停在旧队列的相位上。
if (previousQueueWidth <= 0) {
viewport.scrollLeft = nextQueueWidth;
return;
}
normalizeScrollPosition();
});
observer.observe(queue);
return () => {
observer.disconnect();
};
}, [isExpanded, normalizeScrollPosition]);
useEffect(
() => () => {
if (touchSettleTimerRef.current !== null) {
clearTimeout(touchSettleTimerRef.current);
}
},
[],
);
useEffect(() => {
if (isExpanded && !isLocked && supportsHoverControls) {
return;
}
// 收起、锁定或失去 hover 能力时回到默认状态。最后一种尤其重要:没有 hover 的
// 设备不会再有 pointerleave,残留的 `center` 会让循环永久停住。
setZone(null);
setIsKeyboardFocused(false);
}, [isExpanded, isLocked, supportsHoverControls]);
const handleViewportPointerMove = (
event: ReactPointerEvent<HTMLDivElement>,
) => {
// 只在真正支持悬停的精确指针设备上启用;触摸指针再挡一道。控制区按 viewport
// 相对 X 计算,不在 chip 上方覆盖三个透明 div。
if (!supportsHoverControls || event.pointerType === 'touch' || isLocked) {
return;
}
const bounds = event.currentTarget.getBoundingClientRect();
if (bounds.width <= 0) {
return;
}
const ratio = (event.clientX - bounds.left) / bounds.width;
// 位置算不出来时保持当前区域:默认落到“中间暂停”会让指针一进入轨道就停住循环。
if (!Number.isFinite(ratio)) {
return;
}
if (ratio < PRESET_SIDE_ZONE_RATIO) {
setZone('left');
return;
}
if (ratio > 1 - PRESET_SIDE_ZONE_RATIO) {
setZone('right');
return;
}
setZone('center');
};
const handleViewportPointerLeave = (
event: ReactPointerEvent<HTMLDivElement>,
) => {
if (event.pointerType === 'touch') {
return;
}
setZone(null);
};
const armTouchSettleWatch = () => {
if (touchSettleTimerRef.current !== null) {
clearTimeout(touchSettleTimerRef.current);
}
touchSettleTimerRef.current = setTimeout(() => {
touchSettleTimerRef.current = null;
setIsTouchScrolling(false);
}, PRESET_TOUCH_IDLE_MS);
};
const beginTouchScroll = (event: ReactPointerEvent<HTMLDivElement>) => {
if (event.pointerType !== 'touch') {
return;
}
if (touchSettleTimerRef.current !== null) {
clearTimeout(touchSettleTimerRef.current);
touchSettleTimerRef.current = null;
}
setIsTouchScrolling(true);
};
const endTouchScroll = (event: ReactPointerEvent<HTMLDivElement>) => {
if (event.pointerType !== 'touch') {
return;
}
// 抬手只是开始等待:原生惯性可能还要跑一两秒,真正的恢复条件是滚动停止。
armTouchSettleWatch();
};
const handleViewportScroll = () => {
// 只有在等待惯性稳定的窗口内才顺延;否则收到的是 rAF 自己写 scrollLeft 触发的
// scroll,会把恢复条件永远推后。
if (touchSettleTimerRef.current === null) {
return;
}
armTouchSettleWatch();
};
const scrollByArrow = (direction: -1 | 1) => {
const viewport = viewportRef.current;
if (!viewport) {
return;
}
const step =
viewport.getBoundingClientRect().width * PRESET_ARROW_STEP_RATIO;
// 离散滚动不使用平滑动画,供触摸和键盘使用。
viewport.scrollLeft += direction * (step || PRESET_FAST_VELOCITY);
normalizeScrollPosition();
};
return (
<section className="image-canvas-editor__background-music-presets">
<div className="image-canvas-editor__background-music-presets-header">
<span className="image-canvas-editor__background-music-presets-title">
</span>
<PlatformActionButton
type="button"
tone="secondary"
size="xs"
shape="pill"
className="image-canvas-editor__background-music-presets-toggle"
// 图标化后文案不再进入可访问名,必须由 aria-label 承担。
aria-label={isExpanded ? '收起' : '展开'}
aria-expanded={isExpanded}
aria-controls={trackId}
disabled={isLocked}
onClick={() => setIsExpanded((expanded) => !expanded)}
>
{isExpanded ? (
<ChevronUp aria-hidden="true" />
) : (
<ChevronDown aria-hidden="true" />
)}
</PlatformActionButton>
{headerTrailing}
</div>
{isExpanded ? (
<div
id={trackId}
className="image-canvas-editor__background-music-presets-track"
>
<button
type="button"
className="image-canvas-editor__background-music-presets-arrow image-canvas-editor__background-music-presets-arrow--left"
aria-label="预设向左滚动"
disabled={isLocked}
onPointerEnter={(event) => {
if (!supportsHoverControls || event.pointerType === 'touch') {
return;
}
setZone('left');
}}
onPointerLeave={(event) => {
if (event.pointerType === 'touch') {
return;
}
setZone(null);
}}
onClick={() => scrollByArrow(-1)}
>
<ChevronLeft aria-hidden="true" />
</button>
<div
ref={viewportRef}
// 锁定要停的是整条轨道的滚动,不只是自动循环:轨道本身仍是可滚动容器,
// 不收紧就还能被触摸、触控板和 Shift+滚轮横向拖动。
className={`image-canvas-editor__background-music-presets-viewport${
isLocked
? ' image-canvas-editor__background-music-presets-viewport--locked'
: ''
}`}
onPointerMove={handleViewportPointerMove}
onPointerLeave={handleViewportPointerLeave}
onPointerDown={beginTouchScroll}
onPointerUp={endTouchScroll}
onPointerCancel={endTouchScroll}
onScroll={handleViewportScroll}
onFocus={() => setIsKeyboardFocused(true)}
onBlur={() => setIsKeyboardFocused(false)}
>
{Array.from({ length: PRESET_QUEUE_COPIES }, (_, copyIndex) => {
const isAccessibleQueue =
copyIndex === PRESET_ACCESSIBLE_QUEUE_INDEX;
return (
<div
key={copyIndex}
ref={isAccessibleQueue ? queueRef : undefined}
className="image-canvas-editor__background-music-presets-queue"
// 中间队列是唯一真实控件队列;首尾只负责无缝循环视觉,不能持有焦点。
aria-hidden={isAccessibleQueue ? undefined : true}
>
{presets.map((preset) => {
const className = getPresetClassName(preset);
if (isAccessibleQueue) {
return (
<button
key={preset.id}
type="button"
className={className}
disabled={isLocked}
onClick={() => onSelectPreset(preset)}
>
{preset.label}
</button>
);
}
return (
<span
key={preset.id}
className={`${className}${
isLocked
? ' image-canvas-editor__background-music-preset--disabled'
: ''
}`}
onClick={() => {
if (!isLocked) {
onSelectPreset(preset);
}
}}
>
{preset.label}
</span>
);
})}
</div>
);
})}
</div>
<button
type="button"
className="image-canvas-editor__background-music-presets-arrow image-canvas-editor__background-music-presets-arrow--right"
aria-label="预设向右滚动"
disabled={isLocked}
onPointerEnter={(event) => {
if (!supportsHoverControls || event.pointerType === 'touch') {
return;
}
setZone('right');
}}
onPointerLeave={(event) => {
if (event.pointerType === 'touch') {
return;
}
setZone(null);
}}
onClick={() => scrollByArrow(1)}
>
<ChevronRight aria-hidden="true" />
</button>
<div
className={
zone
? `image-canvas-editor__background-music-presets-hairline image-canvas-editor__background-music-presets-hairline--${zone}`
: 'image-canvas-editor__background-music-presets-hairline'
}
data-zone={zone ?? 'none'}
aria-hidden="true"
/>
</div>
) : null}
</section>
);
}

Some files were not shown because too many files have changed in this diff Show More