合并远端master更新
融合移动端提示恢复与远端钱包刷新改动 # Conflicts: # src/components/platform-entry/PlatformEntryActiveFlowShell.test.tsx # src/components/platform-entry/PlatformEntryActiveFlowShell.tsx
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 3.6 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.3 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.5 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.1 MiB |
@@ -166,7 +166,14 @@ test('direct account entry does not render the settings shell as another dialog'
|
||||
renderAccountModal({ entryMode: 'account' });
|
||||
|
||||
const accountDialog = screen.getByRole('dialog', { name: '账号信息' });
|
||||
const accountBody = accountDialog.firstElementChild as HTMLElement | null;
|
||||
const accountWrapper = accountBody?.firstElementChild as HTMLElement | null;
|
||||
expect(accountDialog).toBeTruthy();
|
||||
expect(accountDialog.style.background).toBe('none');
|
||||
expect(accountDialog.style.borderWidth).toBe('0px');
|
||||
expect(accountDialog.className).toContain('!overflow-visible');
|
||||
expect(accountBody?.className).toContain('!overflow-visible');
|
||||
expect(accountWrapper?.className).toContain('overflow-visible');
|
||||
expect(screen.queryByRole('dialog', { name: '设置与账号安全' })).toBeNull();
|
||||
expect(screen.queryByText('设置与账号安全')).toBeNull();
|
||||
expect(
|
||||
|
||||
@@ -518,18 +518,26 @@ export function AccountModal({
|
||||
authCardClassName={isDirectAccountMode ? '' : undefined}
|
||||
panelClassName={
|
||||
isDirectAccountMode
|
||||
? 'relative !max-w-3xl !rounded-none !bg-transparent !shadow-none'
|
||||
? 'relative !max-w-3xl !overflow-visible !rounded-none !bg-transparent !shadow-none'
|
||||
: 'relative !h-[min(100%,calc(100vh-2rem))] !max-w-5xl !rounded-[28px] !p-5 sm:!p-6'
|
||||
}
|
||||
bodyClassName="!flex !min-h-0 !flex-1 !overflow-hidden !p-0"
|
||||
bodyClassName={`!flex !min-h-0 !flex-1 !p-0 ${
|
||||
isDirectAccountMode ? '!overflow-visible' : '!overflow-hidden'
|
||||
}`}
|
||||
panelStyle={{
|
||||
maxHeight: ACCOUNT_MODAL_MAX_HEIGHT,
|
||||
...(isDirectAccountMode
|
||||
? {
|
||||
background: 'none',
|
||||
borderWidth: 0,
|
||||
}
|
||||
: {}),
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
isDirectAccountMode
|
||||
? 'relative flex max-h-full w-full max-w-3xl min-h-0 flex-col overflow-hidden'
|
||||
? 'relative flex max-h-full w-full max-w-3xl min-h-0 flex-col overflow-visible'
|
||||
: 'relative flex h-full w-full min-h-0 flex-col overflow-hidden'
|
||||
}
|
||||
style={{ maxHeight: ACCOUNT_MODAL_MAX_HEIGHT }}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { act, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { StrictMode, useEffect, useState } from 'react';
|
||||
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import type { AuthSessionSummary, AuthUser } from '../../services/authService';
|
||||
@@ -16,6 +16,26 @@ import { useAuthUi } from './AuthUiContext';
|
||||
|
||||
const browserReloadMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
const walletLifecycleMocks = vi.hoisted(() => ({
|
||||
usePlatformWalletLifecycle: vi.fn(),
|
||||
}));
|
||||
|
||||
function createMemoryStorage(): Storage {
|
||||
const values = new Map<string, string>();
|
||||
return {
|
||||
get length() {
|
||||
return values.size;
|
||||
},
|
||||
clear: () => values.clear(),
|
||||
getItem: (key) => values.get(key) ?? null,
|
||||
key: (index) => Array.from(values.keys())[index] ?? null,
|
||||
removeItem: (key) => values.delete(key),
|
||||
setItem: (key, value) => values.set(key, String(value)),
|
||||
};
|
||||
}
|
||||
|
||||
const memoryLocalStorage = createMemoryStorage();
|
||||
|
||||
const authMocks = vi.hoisted(() => ({
|
||||
authEntry: vi.fn(),
|
||||
changePassword: vi.fn(),
|
||||
@@ -77,6 +97,8 @@ vi.mock('../../services/authService', () => ({
|
||||
startWechatLogin: authMocks.startWechatLogin,
|
||||
}));
|
||||
|
||||
vi.mock('../../stores/usePlatformWalletStore', () => walletLifecycleMocks);
|
||||
|
||||
const hostBridgeMocks = vi.hoisted(() => ({
|
||||
getHostRuntime: vi.fn(() => ({
|
||||
kind: 'browser',
|
||||
@@ -136,6 +158,10 @@ const mockUser: AuthUser = {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
Object.defineProperty(window, 'localStorage', {
|
||||
configurable: true,
|
||||
value: memoryLocalStorage,
|
||||
});
|
||||
window.localStorage.clear();
|
||||
window.history.replaceState(null, '', '/');
|
||||
setAuthGateReloadForTest(vi.fn());
|
||||
@@ -381,6 +407,32 @@ test('auth gate keeps a valid local token login when refresh rotation fails afte
|
||||
expect(authMocks.getCurrentAuthUser).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('auth root binds the single wallet lifecycle under StrictMode', async () => {
|
||||
authMocks.getStoredAccessToken.mockReturnValue('jwt-existing-token');
|
||||
authMocks.refreshStoredAccessToken.mockRejectedValue(
|
||||
new Error('refresh cookie 失效'),
|
||||
);
|
||||
authMocks.getCurrentAuthUser.mockResolvedValue({
|
||||
user: mockUser,
|
||||
availableLoginMethods: ['phone'],
|
||||
});
|
||||
|
||||
render(
|
||||
<StrictMode>
|
||||
<AuthGate>
|
||||
<LogoutStateProbe />
|
||||
</AuthGate>
|
||||
</StrictMode>,
|
||||
);
|
||||
|
||||
expect(await screen.findByText('当前用户:测试玩家')).toBeTruthy();
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
walletLifecycleMocks.usePlatformWalletLifecycle,
|
||||
).toHaveBeenCalledWith('user-1', true);
|
||||
});
|
||||
});
|
||||
|
||||
test('auth gate does not auto-create a guest account when dev guest switch is not explicitly enabled', async () => {
|
||||
authMocks.getAuthLoginOptions.mockResolvedValue({
|
||||
availableLoginMethods: [],
|
||||
|
||||
@@ -50,6 +50,7 @@ import {
|
||||
reloadHostWebView,
|
||||
requestHostLogin,
|
||||
} from '../../services/host-bridge/hostBridge';
|
||||
import { usePlatformWalletLifecycle } from '../../stores/usePlatformWalletStore';
|
||||
import { PlatformActionButton } from '../common/PlatformActionButton';
|
||||
import { AccountModal } from './AccountModal';
|
||||
import { AuthUiContext, type PlatformSettingsSection } from './AuthUiContext';
|
||||
@@ -117,10 +118,7 @@ function normalizeAvailableLoginMethods(
|
||||
|
||||
// 登录面板的核心入口必须稳定展示,login-options 只补充微信等环境相关入口。
|
||||
return Array.from(
|
||||
new Set<AuthLoginMethod>([
|
||||
...REQUIRED_LOGIN_METHODS,
|
||||
...normalizedMethods,
|
||||
]),
|
||||
new Set<AuthLoginMethod>([...REQUIRED_LOGIN_METHODS, ...normalizedMethods]),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -192,10 +190,7 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
}
|
||||
|
||||
const markAuthStateReloadIfChanged = useCallback(
|
||||
(
|
||||
nextUser: AuthUser | null,
|
||||
options: { reloadOnChange?: boolean } = {},
|
||||
) => {
|
||||
(nextUser: AuthUser | null, options: { reloadOnChange?: boolean } = {}) => {
|
||||
const nextHasUser = Boolean(nextUser);
|
||||
const previousHasUser = lastStableAuthPresenceRef.current;
|
||||
if (previousHasUser === null) {
|
||||
@@ -204,23 +199,23 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
}
|
||||
|
||||
lastStableAuthPresenceRef.current = nextHasUser;
|
||||
if (
|
||||
previousHasUser !== nextHasUser &&
|
||||
options.reloadOnChange !== false
|
||||
) {
|
||||
if (previousHasUser !== nextHasUser && options.reloadOnChange !== false) {
|
||||
pendingAuthStateReloadRef.current = true;
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const activateReadyUser = useCallback((nextUser: AuthUser) => {
|
||||
// 受保护业务 hook 只在 readyUser 暴露后启动,必须先保证请求层能带 Bearer token。
|
||||
authHydrateVersionRef.current += 1;
|
||||
markAuthStateReloadIfChanged(nextUser);
|
||||
setUser(nextUser);
|
||||
setStatus('ready');
|
||||
}, [markAuthStateReloadIfChanged]);
|
||||
const activateReadyUser = useCallback(
|
||||
(nextUser: AuthUser) => {
|
||||
// 受保护业务 hook 只在 readyUser 暴露后启动,必须先保证请求层能带 Bearer token。
|
||||
authHydrateVersionRef.current += 1;
|
||||
markAuthStateReloadIfChanged(nextUser);
|
||||
setUser(nextUser);
|
||||
setStatus('ready');
|
||||
},
|
||||
[markAuthStateReloadIfChanged],
|
||||
);
|
||||
|
||||
const clearLocalAuthenticatedState = useCallback(
|
||||
(options: { reloadOnChange?: boolean } = {}) => {
|
||||
@@ -687,6 +682,11 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
],
|
||||
);
|
||||
|
||||
usePlatformWalletLifecycle(
|
||||
readyUser?.id ?? null,
|
||||
status === 'ready' && Boolean(readyUser),
|
||||
);
|
||||
|
||||
if (status === 'checking' && !canKeepPlatformContentMounted) {
|
||||
return (
|
||||
<div
|
||||
@@ -969,7 +969,11 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
const registrationInviteCode =
|
||||
pendingInviteCode || readInviteCodeFromLocation();
|
||||
const response = registrationInviteCode
|
||||
? await loginWithPhoneCode(phone, code, registrationInviteCode)
|
||||
? await loginWithPhoneCode(
|
||||
phone,
|
||||
code,
|
||||
registrationInviteCode,
|
||||
)
|
||||
: await loginWithPhoneCode(phone, code);
|
||||
const autoRedeemedInvite = response.referral?.ok === true;
|
||||
setStoredLastLoginPhone(phone);
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { act } from '@testing-library/react';
|
||||
import {
|
||||
$createLineBreakNode,
|
||||
$createParagraphNode,
|
||||
$createTextNode,
|
||||
$getRoot,
|
||||
getNearestEditorFromDOMNode,
|
||||
} from 'lexical';
|
||||
|
||||
export function setPlainTextEditorValue(
|
||||
contentEditable: HTMLElement,
|
||||
value: string,
|
||||
) {
|
||||
const editor = getNearestEditorFromDOMNode(contentEditable);
|
||||
if (!editor) {
|
||||
throw new Error('Expected a Lexical editor root');
|
||||
}
|
||||
|
||||
act(() => {
|
||||
editor.update(
|
||||
() => {
|
||||
const paragraph = $createParagraphNode();
|
||||
value.split('\n').forEach((line, index, lines) => {
|
||||
if (line) {
|
||||
paragraph.append($createTextNode(line));
|
||||
}
|
||||
if (index < lines.length - 1) {
|
||||
paragraph.append($createLineBreakNode());
|
||||
}
|
||||
});
|
||||
$getRoot().clear().append(paragraph);
|
||||
paragraph.selectEnd();
|
||||
},
|
||||
{ discrete: true },
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export function getPlainTextEditorHost(contentEditable: HTMLElement) {
|
||||
const host = contentEditable.closest<HTMLElement>(
|
||||
'.auto-grow-text-area',
|
||||
);
|
||||
if (!host) {
|
||||
throw new Error('Expected an auto-grow plain-text editor host');
|
||||
}
|
||||
return host;
|
||||
}
|
||||
@@ -1,288 +1,175 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { useState } from 'react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { AutoGrowTextArea } from './AutoGrowTextArea';
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
beforeEach(() => {
|
||||
Range.prototype.getBoundingClientRect = vi.fn(() => new DOMRect());
|
||||
Range.prototype.getClientRects = vi.fn(() => [] as unknown as DOMRectList);
|
||||
vi.stubGlobal(
|
||||
'ResizeObserver',
|
||||
class ResizeObserver {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
},
|
||||
);
|
||||
vi.stubGlobal(
|
||||
'ClipboardEvent',
|
||||
class ClipboardEvent extends Event {
|
||||
clipboardData: DataTransfer;
|
||||
|
||||
constructor(
|
||||
type: string,
|
||||
init: EventInit & { clipboardData: DataTransfer },
|
||||
) {
|
||||
super(type, init);
|
||||
this.clipboardData = init.clipboardData;
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
function pastePlainText(editor: HTMLElement, value: string) {
|
||||
const clipboardData = {
|
||||
files: [],
|
||||
getData: (type: string) => (type === 'text/plain' ? value : ''),
|
||||
items: [],
|
||||
types: ['text/plain'],
|
||||
} as unknown as DataTransfer;
|
||||
fireEvent(
|
||||
editor,
|
||||
new ClipboardEvent('paste', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
clipboardData,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function ControlledEditor({
|
||||
initialValue = '',
|
||||
maxLength,
|
||||
}: {
|
||||
initialValue?: string;
|
||||
maxLength?: number;
|
||||
}) {
|
||||
const [value, setValue] = useState(initialValue);
|
||||
return (
|
||||
<>
|
||||
<AutoGrowTextArea
|
||||
aria-label="受控编辑器"
|
||||
value={value}
|
||||
maxLength={maxLength}
|
||||
onValueChange={setValue}
|
||||
/>
|
||||
<output aria-label="React 文本状态">{value}</output>
|
||||
<button type="button" onClick={() => setValue('外部\n更新')}>
|
||||
外部更新
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
describe('AutoGrowTextArea', () => {
|
||||
it('uses native field sizing when the browser supports it', () => {
|
||||
const supports = vi.fn().mockReturnValue(true);
|
||||
const resizeObserver = vi.fn();
|
||||
const onChange = vi.fn();
|
||||
vi.stubGlobal('CSS', { supports });
|
||||
vi.stubGlobal('ResizeObserver', resizeObserver);
|
||||
it('keeps React state authoritative and preserves exact line breaks', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ControlledEditor initialValue={'第一行\n第二行'} />);
|
||||
|
||||
render(
|
||||
<AutoGrowTextArea
|
||||
aria-label="自动增长输入框"
|
||||
value=""
|
||||
onChange={onChange}
|
||||
/>,
|
||||
const editor = screen.getByRole('textbox', { name: '受控编辑器' });
|
||||
expect(screen.getByLabelText('React 文本状态').textContent).toBe(
|
||||
'第一行\n第二行',
|
||||
);
|
||||
|
||||
const input = screen.getByLabelText(
|
||||
'自动增长输入框',
|
||||
) as HTMLTextAreaElement;
|
||||
expect(supports).toHaveBeenCalledWith('field-sizing', 'content');
|
||||
expect(resizeObserver).not.toHaveBeenCalled();
|
||||
expect(input.getAttribute('rows')).toBe('1');
|
||||
expect(input.className).toContain('auto-grow-text-area');
|
||||
expect(input.className).toContain('[field-sizing:content]');
|
||||
expect(input.className).toContain('min-h-10');
|
||||
expect(input.className).toContain('max-h-32');
|
||||
expect(input.className).toContain('overflow-y-auto');
|
||||
expect(input.className).toContain('disabled:cursor-not-allowed');
|
||||
expect(input.className).toContain('disabled:opacity-60');
|
||||
expect(input.style.height).toBe('');
|
||||
|
||||
fireEvent.change(input, { target: { value: '新内容' } });
|
||||
expect(onChange).toHaveBeenCalledTimes(1);
|
||||
await user.click(screen.getByRole('button', { name: '外部更新' }));
|
||||
expect(screen.getByLabelText('React 文本状态').textContent).toBe(
|
||||
'外部\n更新',
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to measured sizing, preserves css limits and remeasures width changes', () => {
|
||||
let resizeObserverCallback!: ResizeObserverCallback;
|
||||
const observe = vi.fn();
|
||||
const disconnect = vi.fn();
|
||||
vi.stubGlobal('CSS', { supports: vi.fn().mockReturnValue(false) });
|
||||
vi.stubGlobal(
|
||||
'ResizeObserver',
|
||||
vi.fn().mockImplementation((callback: ResizeObserverCallback) => {
|
||||
resizeObserverCallback = callback;
|
||||
return {
|
||||
observe,
|
||||
unobserve: vi.fn(),
|
||||
disconnect,
|
||||
};
|
||||
}),
|
||||
it('publishes edits through onValueChange and enforces maxLength', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ControlledEditor maxLength={4} />);
|
||||
|
||||
const editor = screen.getByRole('textbox', { name: '受控编辑器' });
|
||||
await user.click(editor);
|
||||
pastePlainText(editor, '12345');
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByLabelText('React 文本状态').textContent).toBe('1234'),
|
||||
);
|
||||
expect(editor.textContent).toBe('1234');
|
||||
});
|
||||
|
||||
it('supports undo and redo while keeping React state synchronized', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ControlledEditor />);
|
||||
|
||||
const editor = screen.getByRole('textbox', { name: '受控编辑器' });
|
||||
await user.click(editor);
|
||||
pastePlainText(editor, '草稿');
|
||||
await waitFor(() =>
|
||||
expect(screen.getByLabelText('React 文本状态').textContent).toBe('草稿'),
|
||||
);
|
||||
|
||||
const { rerender, unmount } = render(
|
||||
await user.keyboard('{Control>}z{/Control}');
|
||||
expect(screen.getByLabelText('React 文本状态').textContent).toBe('');
|
||||
|
||||
await user.keyboard('{Control>}{Shift>}z{/Shift}{/Control}');
|
||||
expect(screen.getByLabelText('React 文本状态').textContent).toBe('草稿');
|
||||
});
|
||||
|
||||
it('exposes read-only and disabled accessibility without form controls', () => {
|
||||
const { container } = render(
|
||||
<AutoGrowTextArea
|
||||
aria-label="自动增长输入框"
|
||||
value=""
|
||||
style={{
|
||||
boxSizing: 'border-box',
|
||||
minHeight: '80px',
|
||||
maxHeight: '128px',
|
||||
}}
|
||||
onChange={vi.fn()}
|
||||
aria-label="只读编辑器"
|
||||
value="只读内容"
|
||||
disabled
|
||||
readOnly
|
||||
onValueChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
const input = screen.getByLabelText(
|
||||
'自动增长输入框',
|
||||
) as HTMLTextAreaElement;
|
||||
expect(observe).toHaveBeenCalledWith(input);
|
||||
let contentHeight = 92;
|
||||
Object.defineProperty(input, 'offsetHeight', {
|
||||
configurable: true,
|
||||
get: () => {
|
||||
const styleHeight = Number.parseFloat(input.style.height);
|
||||
return Number.isNaN(styleHeight) ? 82 : styleHeight;
|
||||
},
|
||||
});
|
||||
Object.defineProperty(input, 'clientHeight', {
|
||||
configurable: true,
|
||||
get: () => input.offsetHeight - 2,
|
||||
});
|
||||
Object.defineProperty(input, 'scrollHeight', {
|
||||
configurable: true,
|
||||
get: () => contentHeight,
|
||||
});
|
||||
|
||||
rerender(
|
||||
const editor = screen.getByRole('textbox', { name: '只读编辑器' });
|
||||
expect(editor.getAttribute('aria-disabled')).toBe('true');
|
||||
expect(editor.getAttribute('aria-readonly')).toBe('true');
|
||||
expect(editor.getAttribute('tabindex')).toBe('-1');
|
||||
expect(container.querySelector('textarea')).toBeNull();
|
||||
expect(container.querySelector('form')).toBeNull();
|
||||
});
|
||||
|
||||
it('uses a distinct internal viewport for overflow content', async () => {
|
||||
const { container } = render(
|
||||
<AutoGrowTextArea
|
||||
aria-label="自动增长输入框"
|
||||
value="第一行\n第二行\n第三行\n第四行\n第五行"
|
||||
style={{
|
||||
boxSizing: 'border-box',
|
||||
minHeight: '80px',
|
||||
maxHeight: '128px',
|
||||
}}
|
||||
onChange={vi.fn()}
|
||||
aria-label="滚动编辑器"
|
||||
value={Array.from({ length: 20 }, (_, index) => `第 ${index + 1} 行`).join(
|
||||
'\n',
|
||||
)}
|
||||
onValueChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(input.style.height).toBe('94px');
|
||||
expect(input.style.overflowY).toBe('hidden');
|
||||
|
||||
contentHeight = 180;
|
||||
act(() => {
|
||||
resizeObserverCallback(
|
||||
[
|
||||
{
|
||||
target: input,
|
||||
contentRect: { width: 180 },
|
||||
} as unknown as ResizeObserverEntry,
|
||||
],
|
||||
{} as ResizeObserver,
|
||||
const viewport = await waitFor(() => {
|
||||
const element = container.querySelector<HTMLElement>(
|
||||
'[data-overlayscrollbars-viewport]',
|
||||
);
|
||||
expect(element).not.toBeNull();
|
||||
return element!;
|
||||
});
|
||||
expect(input.style.height).toBe('128px');
|
||||
expect(input.style.overflowY).toBe('auto');
|
||||
|
||||
contentHeight = 30;
|
||||
rerender(
|
||||
<AutoGrowTextArea
|
||||
aria-label="自动增长输入框"
|
||||
value="短内容"
|
||||
style={{
|
||||
boxSizing: 'border-box',
|
||||
minHeight: '80px',
|
||||
maxHeight: '128px',
|
||||
}}
|
||||
onChange={vi.fn()}
|
||||
/>,
|
||||
const content = container.querySelector<HTMLElement>(
|
||||
'[data-overlayscrollbars-content]',
|
||||
);
|
||||
expect(input.style.height).toBe('80px');
|
||||
expect(input.style.overflowY).toBe('hidden');
|
||||
|
||||
unmount();
|
||||
expect(disconnect).toHaveBeenCalledTimes(1);
|
||||
expect(viewport).not.toBe(content);
|
||||
expect(
|
||||
viewport.contains(
|
||||
screen.getByRole('textbox', { name: '滚动编辑器' }),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('remeasures uncontrolled input changes and forwards the consumer input handler', () => {
|
||||
const onInput = vi.fn();
|
||||
vi.stubGlobal('CSS', { supports: vi.fn().mockReturnValue(false) });
|
||||
vi.stubGlobal(
|
||||
'ResizeObserver',
|
||||
vi.fn().mockImplementation(() => ({
|
||||
observe: vi.fn(),
|
||||
unobserve: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
})),
|
||||
);
|
||||
|
||||
render(
|
||||
<AutoGrowTextArea
|
||||
aria-label="非受控自动增长输入框"
|
||||
defaultValue="初始内容"
|
||||
style={{
|
||||
boxSizing: 'border-box',
|
||||
minHeight: '40px',
|
||||
maxHeight: '128px',
|
||||
}}
|
||||
onInput={onInput}
|
||||
/>,
|
||||
);
|
||||
const input = screen.getByLabelText(
|
||||
'非受控自动增长输入框',
|
||||
) as HTMLTextAreaElement;
|
||||
let contentHeight = 90;
|
||||
Object.defineProperty(input, 'offsetHeight', {
|
||||
configurable: true,
|
||||
get: () => {
|
||||
const styleHeight = Number.parseFloat(input.style.height);
|
||||
return Number.isNaN(styleHeight) ? 42 : styleHeight;
|
||||
},
|
||||
});
|
||||
Object.defineProperty(input, 'clientHeight', {
|
||||
configurable: true,
|
||||
get: () => input.offsetHeight - 2,
|
||||
});
|
||||
Object.defineProperty(input, 'scrollHeight', {
|
||||
configurable: true,
|
||||
get: () => contentHeight,
|
||||
});
|
||||
|
||||
fireEvent.input(input, {
|
||||
target: { value: '第一行\n第二行\n第三行\n第四行' },
|
||||
});
|
||||
expect(input.style.height).toBe('92px');
|
||||
expect(input.style.overflowY).toBe('hidden');
|
||||
|
||||
contentHeight = 180;
|
||||
fireEvent.input(input, {
|
||||
target: { value: '很多行内容'.repeat(30) },
|
||||
});
|
||||
expect(input.style.height).toBe('128px');
|
||||
expect(input.style.overflowY).toBe('auto');
|
||||
|
||||
contentHeight = 20;
|
||||
fireEvent.input(input, { target: { value: '短内容' } });
|
||||
expect(input.style.height).toBe('40px');
|
||||
expect(input.style.overflowY).toBe('hidden');
|
||||
expect(onInput).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('measures content-box height without double-counting padding or borders', () => {
|
||||
vi.stubGlobal('CSS', { supports: vi.fn().mockReturnValue(false) });
|
||||
vi.stubGlobal(
|
||||
'ResizeObserver',
|
||||
vi.fn().mockImplementation(() => ({
|
||||
observe: vi.fn(),
|
||||
unobserve: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
})),
|
||||
);
|
||||
|
||||
const { rerender } = render(
|
||||
<AutoGrowTextArea
|
||||
aria-label="内容盒输入框"
|
||||
value=""
|
||||
style={{
|
||||
boxSizing: 'content-box',
|
||||
minHeight: '40px',
|
||||
maxHeight: '128px',
|
||||
paddingTop: '10px',
|
||||
paddingBottom: '6px',
|
||||
}}
|
||||
onChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
const input = screen.getByLabelText(
|
||||
'内容盒输入框',
|
||||
) as HTMLTextAreaElement;
|
||||
let contentHeight = 120;
|
||||
Object.defineProperty(input, 'offsetHeight', {
|
||||
configurable: true,
|
||||
get: () => 122,
|
||||
});
|
||||
Object.defineProperty(input, 'clientHeight', {
|
||||
configurable: true,
|
||||
get: () => 120,
|
||||
});
|
||||
Object.defineProperty(input, 'scrollHeight', {
|
||||
configurable: true,
|
||||
get: () => contentHeight,
|
||||
});
|
||||
|
||||
rerender(
|
||||
<AutoGrowTextArea
|
||||
aria-label="内容盒输入框"
|
||||
value="多行内容"
|
||||
style={{
|
||||
boxSizing: 'content-box',
|
||||
minHeight: '40px',
|
||||
maxHeight: '128px',
|
||||
paddingTop: '10px',
|
||||
paddingBottom: '6px',
|
||||
}}
|
||||
onChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(input.style.height).toBe('104px');
|
||||
expect(input.style.overflowY).toBe('hidden');
|
||||
|
||||
contentHeight = 160;
|
||||
rerender(
|
||||
<AutoGrowTextArea
|
||||
aria-label="内容盒输入框"
|
||||
value="更多多行内容"
|
||||
style={{
|
||||
boxSizing: 'content-box',
|
||||
minHeight: '40px',
|
||||
maxHeight: '128px',
|
||||
paddingTop: '10px',
|
||||
paddingBottom: '6px',
|
||||
}}
|
||||
onChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(input.style.height).toBe('128px');
|
||||
expect(input.style.overflowY).toBe('auto');
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,13 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { act, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import {
|
||||
act,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
within,
|
||||
} from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import type { ContextType } from 'react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
@@ -147,6 +154,7 @@ describe('CreationLandingView', () => {
|
||||
listPublicEditorProjectResourcesMock.mockReset();
|
||||
createEditorProjectMock.mockReset();
|
||||
toggleEditorShowcaseAssetLikeMock.mockReset();
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
@@ -342,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',
|
||||
@@ -470,6 +478,181 @@ describe('CreationLandingView', () => {
|
||||
expect(within(card as HTMLElement).getByText('20泥点')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('plays a character action on card focus and in the preview dialog', async () => {
|
||||
const user = userEvent.setup();
|
||||
const setIntervalSpy = vi
|
||||
.spyOn(window, 'setInterval')
|
||||
.mockImplementation(
|
||||
() => 1 as unknown as ReturnType<typeof window.setInterval>,
|
||||
);
|
||||
listEditorProjectsMock.mockResolvedValueOnce(projectItems);
|
||||
listPublicEditorProjectResourcesMock.mockResolvedValueOnce([
|
||||
{
|
||||
resourceId: 'resource-action',
|
||||
projectId: 'editor-showcase',
|
||||
showcaseId: 'showcase-action',
|
||||
label: '待机动作',
|
||||
imageSrc: '/public/action/frame-01.png',
|
||||
objectKey: null,
|
||||
width: 192,
|
||||
height: 256,
|
||||
sourceType: 'generated',
|
||||
assetKind: 'character-animation',
|
||||
generationCostMudPoints: 103,
|
||||
imageSequenceFrames: [
|
||||
{
|
||||
imageSrc: '/public/action/frame-01.png',
|
||||
width: 192,
|
||||
height: 256,
|
||||
},
|
||||
{
|
||||
imageSrc: '/public/action/frame-02.png',
|
||||
width: 192,
|
||||
height: 256,
|
||||
},
|
||||
{
|
||||
imageSrc: '/public/action/frame-03.png',
|
||||
width: 192,
|
||||
height: 256,
|
||||
},
|
||||
{
|
||||
imageSrc: '/public/action/frame-04.png',
|
||||
width: 192,
|
||||
height: 256,
|
||||
},
|
||||
{
|
||||
imageSrc: '/public/action/frame-05.png',
|
||||
width: 192,
|
||||
height: 256,
|
||||
},
|
||||
],
|
||||
imageSequenceDurationMs: 250,
|
||||
},
|
||||
]);
|
||||
|
||||
renderCreationLanding();
|
||||
const title = await screen.findByText('待机动作');
|
||||
const card = title.closest('.creation-landing__asset-card') as HTMLElement;
|
||||
const openButton = card.querySelector(
|
||||
'.creation-landing__asset-card-open',
|
||||
) as HTMLButtonElement;
|
||||
|
||||
expect(
|
||||
card.querySelectorAll('.creation-landing__image-sequence-frame'),
|
||||
).toHaveLength(0);
|
||||
|
||||
fireEvent.focus(openButton);
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
card.querySelectorAll('.creation-landing__image-sequence-frame'),
|
||||
).toHaveLength(3);
|
||||
});
|
||||
expect(setIntervalSpy.mock.calls.some(([, delay]) => delay === 50)).toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
fireEvent.pointerEnter(openButton);
|
||||
fireEvent.blur(openButton);
|
||||
expect(
|
||||
card.querySelectorAll('.creation-landing__image-sequence-frame'),
|
||||
).toHaveLength(3);
|
||||
fireEvent.pointerLeave(openButton);
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
card.querySelectorAll('.creation-landing__image-sequence-frame'),
|
||||
).toHaveLength(0);
|
||||
});
|
||||
|
||||
await user.click(openButton);
|
||||
expect(
|
||||
await screen.findByRole('button', { name: '暂停角色动作' }),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
document.querySelectorAll(
|
||||
'.creation-landing__showcase-current .creation-landing__image-sequence-frame',
|
||||
),
|
||||
).toHaveLength(3);
|
||||
|
||||
const failedModalFrame = document.querySelector(
|
||||
'.creation-landing__showcase-current .creation-landing__image-sequence-frame',
|
||||
) as HTMLImageElement;
|
||||
await user.click(screen.getByRole('button', { name: '暂停角色动作' }));
|
||||
fireEvent.error(failedModalFrame);
|
||||
expect(await screen.findByText('1 帧加载失败')).toBeTruthy();
|
||||
expect(await screen.findByText('2/5')).toBeTruthy();
|
||||
await user.click(screen.getByRole('button', { name: '重试失败帧' }));
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('1 帧加载失败')).toBeNull();
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '暂停角色动作' }));
|
||||
expect(screen.getByRole('button', { name: '播放角色动作' })).toBeTruthy();
|
||||
setIntervalSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('keeps character action previews paused when reduced motion is requested', async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.stubGlobal(
|
||||
'matchMedia',
|
||||
vi.fn().mockReturnValue({
|
||||
matches: true,
|
||||
media: '(prefers-reduced-motion: reduce)',
|
||||
onchange: null,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
}),
|
||||
);
|
||||
listEditorProjectsMock.mockResolvedValueOnce(projectItems);
|
||||
listPublicEditorProjectResourcesMock.mockResolvedValueOnce([
|
||||
{
|
||||
resourceId: 'resource-action-reduced-motion',
|
||||
projectId: 'editor-showcase',
|
||||
showcaseId: 'showcase-action-reduced-motion',
|
||||
label: '缓慢待机动作',
|
||||
imageSrc: '/public/action/frame-01.png',
|
||||
objectKey: null,
|
||||
width: 192,
|
||||
height: 256,
|
||||
sourceType: 'generated',
|
||||
assetKind: 'character-animation',
|
||||
generationCostMudPoints: 103,
|
||||
imageSequenceFrames: [
|
||||
{
|
||||
imageSrc: '/public/action/frame-01.png',
|
||||
width: 192,
|
||||
height: 256,
|
||||
},
|
||||
{
|
||||
imageSrc: '/public/action/frame-02.png',
|
||||
width: 192,
|
||||
height: 256,
|
||||
},
|
||||
],
|
||||
imageSequenceDurationMs: 250,
|
||||
},
|
||||
]);
|
||||
|
||||
renderCreationLanding();
|
||||
const title = await screen.findByText('缓慢待机动作');
|
||||
const card = title.closest('.creation-landing__asset-card') as HTMLElement;
|
||||
const openButton = card.querySelector(
|
||||
'.creation-landing__asset-card-open',
|
||||
) as HTMLButtonElement;
|
||||
|
||||
fireEvent.focus(openButton);
|
||||
expect(
|
||||
card.querySelectorAll('.creation-landing__image-sequence-frame'),
|
||||
).toHaveLength(0);
|
||||
|
||||
await user.click(openButton);
|
||||
expect(
|
||||
await screen.findByRole('button', { name: '播放角色动作' }),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it('likes a featured asset from the list card action', async () => {
|
||||
const user = userEvent.setup();
|
||||
listEditorProjectsMock.mockResolvedValueOnce(projectItems);
|
||||
@@ -615,6 +798,19 @@ describe('CreationLandingView', () => {
|
||||
taskId: 'task-2',
|
||||
assetKind: 'character-animation',
|
||||
priceMudPoints: 40,
|
||||
imageSequenceFrames: [
|
||||
{
|
||||
imageSrc: 'data:image/png;base64,animation-1',
|
||||
width: 512,
|
||||
height: 512,
|
||||
},
|
||||
{
|
||||
imageSrc: 'data:image/png;base64,animation-2',
|
||||
width: 512,
|
||||
height: 512,
|
||||
},
|
||||
],
|
||||
imageSequenceDurationMs: 4_000,
|
||||
generationInputs: {
|
||||
fields: [{ title: '动作描述', value: '待机动作' }],
|
||||
references: [
|
||||
@@ -802,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)}`,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Film, Image as ImageIcon, Music2 } from 'lucide-react';
|
||||
import { Film, Image as ImageIcon, Music2, Pause, Play } from 'lucide-react';
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
@@ -58,7 +58,8 @@ type CreationFeatureTool =
|
||||
| 'ui-design'
|
||||
| 'background-music'
|
||||
| 'image'
|
||||
| 'publication-cover';
|
||||
| 'publication-cover'
|
||||
| 'scene';
|
||||
|
||||
type CreationFeatureItem = {
|
||||
title: string;
|
||||
@@ -74,6 +75,30 @@ const CREATION_HOME_ASSETS = {
|
||||
} as const;
|
||||
|
||||
const SHOWCASE_LIKE_ICON_SRC = '/creation-home/showcase-like-thumb.png';
|
||||
const REDUCED_MOTION_MEDIA_QUERY = '(prefers-reduced-motion: reduce)';
|
||||
|
||||
function usePrefersReducedMotion() {
|
||||
const [prefersReducedMotion, setPrefersReducedMotion] = useState(() =>
|
||||
typeof window.matchMedia === 'function'
|
||||
? window.matchMedia(REDUCED_MOTION_MEDIA_QUERY).matches
|
||||
: false,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window.matchMedia !== 'function') {
|
||||
return undefined;
|
||||
}
|
||||
const mediaQuery = window.matchMedia(REDUCED_MOTION_MEDIA_QUERY);
|
||||
const handleChange = (event: MediaQueryListEvent) => {
|
||||
setPrefersReducedMotion(event.matches);
|
||||
};
|
||||
setPrefersReducedMotion(mediaQuery.matches);
|
||||
mediaQuery.addEventListener('change', handleChange);
|
||||
return () => mediaQuery.removeEventListener('change', handleChange);
|
||||
}, []);
|
||||
|
||||
return prefersReducedMotion;
|
||||
}
|
||||
|
||||
const CREATION_FEATURES: CreationFeatureItem[] = [
|
||||
{
|
||||
@@ -110,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: '游戏美宣',
|
||||
@@ -239,7 +264,7 @@ function getPreviewIcon(preview: ShowcaseAssetPreview) {
|
||||
if (preview.mediaType === 'audio') {
|
||||
return <Music2 aria-hidden="true" />;
|
||||
}
|
||||
if (preview.mediaType === 'video') {
|
||||
if (preview.mediaType === 'video' || preview.mediaType === 'image-sequence') {
|
||||
return <Film aria-hidden="true" />;
|
||||
}
|
||||
return <ImageIcon aria-hidden="true" />;
|
||||
@@ -276,9 +301,11 @@ function ShowcaseAudioPreview({
|
||||
function ShowcasePreview({
|
||||
preview,
|
||||
variant = 'card',
|
||||
sequencePlaying = false,
|
||||
}: {
|
||||
preview: ShowcaseAssetPreview;
|
||||
variant?: 'card' | 'modal' | 'thumb';
|
||||
sequencePlaying?: boolean;
|
||||
}) {
|
||||
if (preview.mediaType === 'audio') {
|
||||
return (
|
||||
@@ -294,9 +321,308 @@ function ShowcasePreview({
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (preview.mediaType === 'image-sequence') {
|
||||
return (
|
||||
<ShowcaseImageSequencePreview
|
||||
preview={preview}
|
||||
variant={variant}
|
||||
playing={sequencePlaying}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <ShowcaseImagePreview preview={preview} variant={variant} />;
|
||||
}
|
||||
|
||||
function ShowcaseImageSequenceFrame({
|
||||
preview,
|
||||
frame,
|
||||
frameKey,
|
||||
visible,
|
||||
loaded,
|
||||
retryGeneration,
|
||||
onReady,
|
||||
onFailed,
|
||||
}: {
|
||||
preview: ShowcaseAssetPreview;
|
||||
frame: NonNullable<ShowcaseAssetPreview['imageSequenceFrames']>[number];
|
||||
frameKey: string;
|
||||
visible: boolean;
|
||||
loaded: boolean;
|
||||
retryGeneration: number;
|
||||
onReady: (frameKey: string) => void;
|
||||
onFailed: (frameKey: string) => void;
|
||||
}) {
|
||||
const failureReportedRef = useRef(false);
|
||||
const { resolvedUrl, isResolving, shouldResolve } = useResolvedAssetReadUrl(
|
||||
frame.imageSrc,
|
||||
{
|
||||
objectKey: frame.objectKey,
|
||||
refreshKey: `${preview.id}:${frameKey}:${retryGeneration}`,
|
||||
},
|
||||
);
|
||||
const reportFailure = useCallback(() => {
|
||||
if (failureReportedRef.current) {
|
||||
return;
|
||||
}
|
||||
failureReportedRef.current = true;
|
||||
onFailed(frameKey);
|
||||
}, [frameKey, onFailed]);
|
||||
|
||||
useEffect(() => {
|
||||
if (shouldResolve && !isResolving && !resolvedUrl) {
|
||||
reportFailure();
|
||||
}
|
||||
}, [isResolving, reportFailure, resolvedUrl, shouldResolve]);
|
||||
|
||||
if (!resolvedUrl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<img
|
||||
src={resolvedUrl}
|
||||
alt={visible ? `角色动作预览:${preview.label}` : ''}
|
||||
aria-hidden={visible ? undefined : true}
|
||||
className="creation-landing__image-sequence-frame"
|
||||
decoding="async"
|
||||
style={{
|
||||
opacity: visible && loaded ? 1 : 0,
|
||||
transition: 'none',
|
||||
}}
|
||||
onLoad={() => onReady(frameKey)}
|
||||
onError={reportFailure}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ShowcaseImageSequencePreview({
|
||||
preview,
|
||||
variant,
|
||||
playing,
|
||||
}: {
|
||||
preview: ShowcaseAssetPreview;
|
||||
variant: 'card' | 'modal' | 'thumb';
|
||||
playing: boolean;
|
||||
}) {
|
||||
const frames = useMemo(
|
||||
() => preview.imageSequenceFrames ?? [],
|
||||
[preview.imageSequenceFrames],
|
||||
);
|
||||
const durationMs = preview.imageSequenceDurationMs ?? 0;
|
||||
const shouldMountSequence = variant === 'modal' || playing;
|
||||
const frameItems = useMemo(
|
||||
() =>
|
||||
frames.map((frame, index) => ({
|
||||
frame,
|
||||
index,
|
||||
key: [preview.id, frame.objectKey ?? '', frame.imageSrc, index].join(
|
||||
':',
|
||||
),
|
||||
})),
|
||||
[frames, preview.id],
|
||||
);
|
||||
const frameSequenceKey = frameItems.map((item) => item.key).join('|');
|
||||
const firstFrameKey = frameItems[0]?.key ?? '';
|
||||
const [frameIndex, setFrameIndex] = useState(0);
|
||||
const [isPlaying, setIsPlaying] = useState(playing);
|
||||
const [loadedFrameKeys, setLoadedFrameKeys] = useState<ReadonlySet<string>>(
|
||||
() => new Set(),
|
||||
);
|
||||
const [failedFrameKeys, setFailedFrameKeys] = useState<ReadonlySet<string>>(
|
||||
() => new Set(),
|
||||
);
|
||||
const [retryGeneration, setRetryGeneration] = useState(0);
|
||||
const [visibleFrameKey, setVisibleFrameKey] = useState(firstFrameKey);
|
||||
const currentFrameItem =
|
||||
frameItems[Math.min(frameIndex, frameItems.length - 1)];
|
||||
const currentFrameKey = currentFrameItem?.key ?? '';
|
||||
const mountedFrameKeys = new Set<string>();
|
||||
if (visibleFrameKey && !failedFrameKeys.has(visibleFrameKey)) {
|
||||
mountedFrameKeys.add(visibleFrameKey);
|
||||
}
|
||||
for (
|
||||
let offset = 0;
|
||||
offset < frameItems.length && mountedFrameKeys.size < 3;
|
||||
offset += 1
|
||||
) {
|
||||
const item = frameItems[(frameIndex + offset) % frameItems.length];
|
||||
if (item && !failedFrameKeys.has(item.key)) {
|
||||
mountedFrameKeys.add(item.key);
|
||||
}
|
||||
}
|
||||
const availableFrameCount = frameItems.length - failedFrameKeys.size;
|
||||
|
||||
const handleFrameReady = useCallback((frameKey: string) => {
|
||||
setLoadedFrameKeys((currentKeys) => {
|
||||
if (currentKeys.has(frameKey)) {
|
||||
return currentKeys;
|
||||
}
|
||||
const nextKeys = new Set(currentKeys);
|
||||
nextKeys.add(frameKey);
|
||||
return nextKeys;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleFrameFailed = useCallback((frameKey: string) => {
|
||||
setFailedFrameKeys((currentKeys) => {
|
||||
if (currentKeys.has(frameKey)) {
|
||||
return currentKeys;
|
||||
}
|
||||
const nextKeys = new Set(currentKeys);
|
||||
nextKeys.add(frameKey);
|
||||
return nextKeys;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const retryFailedFrames = useCallback(() => {
|
||||
setFrameIndex(0);
|
||||
setIsPlaying(playing);
|
||||
setLoadedFrameKeys(new Set());
|
||||
setFailedFrameKeys(new Set());
|
||||
setVisibleFrameKey(firstFrameKey);
|
||||
setRetryGeneration((current) => current + 1);
|
||||
}, [firstFrameKey, playing]);
|
||||
|
||||
useEffect(() => {
|
||||
setFrameIndex(0);
|
||||
setLoadedFrameKeys(new Set());
|
||||
setFailedFrameKeys(new Set());
|
||||
setVisibleFrameKey(firstFrameKey);
|
||||
setIsPlaying(playing);
|
||||
}, [firstFrameKey, frameSequenceKey, playing]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentFrameKey || !failedFrameKeys.has(currentFrameKey)) {
|
||||
return;
|
||||
}
|
||||
const nextIndex = findNextShowcaseFrameIndex(
|
||||
frameItems,
|
||||
failedFrameKeys,
|
||||
frameIndex,
|
||||
);
|
||||
if (nextIndex === null) {
|
||||
setVisibleFrameKey('');
|
||||
setIsPlaying(false);
|
||||
return;
|
||||
}
|
||||
setFrameIndex(nextIndex);
|
||||
}, [currentFrameKey, failedFrameKeys, frameIndex, frameItems]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentFrameKey) {
|
||||
setVisibleFrameKey('');
|
||||
return;
|
||||
}
|
||||
if (!loadedFrameKeys.has(currentFrameKey) && visibleFrameKey) {
|
||||
return;
|
||||
}
|
||||
setVisibleFrameKey(currentFrameKey);
|
||||
}, [currentFrameKey, loadedFrameKeys, visibleFrameKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPlaying || availableFrameCount < 2 || durationMs <= 0) {
|
||||
return undefined;
|
||||
}
|
||||
const frameIntervalMs = Math.min(2_147_483_647, durationMs / frames.length);
|
||||
const timer = window.setInterval(() => {
|
||||
setFrameIndex(
|
||||
(currentIndex) =>
|
||||
findNextShowcaseFrameIndex(
|
||||
frameItems,
|
||||
failedFrameKeys,
|
||||
currentIndex,
|
||||
) ?? currentIndex,
|
||||
);
|
||||
}, frameIntervalMs);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [
|
||||
availableFrameCount,
|
||||
durationMs,
|
||||
failedFrameKeys,
|
||||
frameItems,
|
||||
frames.length,
|
||||
isPlaying,
|
||||
]);
|
||||
|
||||
if (!shouldMountSequence) {
|
||||
return <ShowcaseImagePreview preview={preview} variant={variant} />;
|
||||
}
|
||||
|
||||
const mediaClassName =
|
||||
variant === 'modal'
|
||||
? 'creation-landing__showcase-media'
|
||||
: 'creation-landing__asset-preview-media';
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-label={`角色动作预览:${preview.label}`}
|
||||
className={`${mediaClassName} creation-landing__image-sequence`}
|
||||
>
|
||||
{frameItems
|
||||
.filter((item) => mountedFrameKeys.has(item.key))
|
||||
.map((item) => (
|
||||
<ShowcaseImageSequenceFrame
|
||||
key={`${item.key}:${retryGeneration}`}
|
||||
preview={preview}
|
||||
frame={item.frame}
|
||||
frameKey={item.key}
|
||||
visible={item.key === visibleFrameKey}
|
||||
loaded={loadedFrameKeys.has(item.key)}
|
||||
retryGeneration={retryGeneration}
|
||||
onReady={handleFrameReady}
|
||||
onFailed={handleFrameFailed}
|
||||
/>
|
||||
))}
|
||||
{variant === 'modal' ? (
|
||||
<div className="creation-landing__image-sequence-controls">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={isPlaying ? '暂停角色动作' : '播放角色动作'}
|
||||
onClick={() => setIsPlaying((current) => !current)}
|
||||
>
|
||||
{isPlaying ? (
|
||||
<Pause size={16} aria-hidden="true" />
|
||||
) : (
|
||||
<Play size={16} aria-hidden="true" />
|
||||
)}
|
||||
</button>
|
||||
<span>{`${currentFrameItem ? currentFrameItem.index + 1 : 0}/${frames.length}`}</span>
|
||||
{failedFrameKeys.size ? (
|
||||
<>
|
||||
<span aria-live="polite">{`${failedFrameKeys.size} 帧加载失败`}</span>
|
||||
<button type="button" onClick={retryFailedFrames}>
|
||||
重试失败帧
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
) : availableFrameCount === 0 ? (
|
||||
<span
|
||||
aria-live="polite"
|
||||
className="creation-landing__image-sequence-error"
|
||||
>
|
||||
角色动作加载失败,打开预览可重试
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function findNextShowcaseFrameIndex(
|
||||
frameItems: ReadonlyArray<{ key: string }>,
|
||||
failedFrameKeys: ReadonlySet<string>,
|
||||
currentIndex: number,
|
||||
) {
|
||||
for (let offset = 1; offset <= frameItems.length; offset += 1) {
|
||||
const nextIndex = (currentIndex + offset) % frameItems.length;
|
||||
const item = frameItems[nextIndex];
|
||||
if (item && !failedFrameKeys.has(item.key)) {
|
||||
return nextIndex;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function ShowcaseImagePreview({
|
||||
preview,
|
||||
variant,
|
||||
@@ -344,7 +670,13 @@ function ShowcaseVideoPreview({
|
||||
);
|
||||
}
|
||||
|
||||
function ShowcaseBundlePreview({ item }: { item: ShowcaseAssetItem }) {
|
||||
function ShowcaseBundlePreview({
|
||||
item,
|
||||
playSequences,
|
||||
}: {
|
||||
item: ShowcaseAssetItem;
|
||||
playSequences: boolean;
|
||||
}) {
|
||||
const visiblePreviews = item.previews.slice(0, 4);
|
||||
const primaryPreview = visiblePreviews[0];
|
||||
const previewClassName = [
|
||||
@@ -372,7 +704,7 @@ function ShowcaseBundlePreview({ item }: { item: ShowcaseAssetItem }) {
|
||||
className="creation-landing__asset-preview-tile"
|
||||
aria-label={preview.label}
|
||||
>
|
||||
<ShowcasePreview preview={preview} />
|
||||
<ShowcasePreview preview={preview} sequencePlaying={playSequences} />
|
||||
</span>
|
||||
))}
|
||||
{item.previews.length > visiblePreviews.length ? (
|
||||
@@ -387,11 +719,13 @@ function ShowcaseBundlePreview({ item }: { item: ShowcaseAssetItem }) {
|
||||
function CreationShowcaseModal({
|
||||
item,
|
||||
activeIndex,
|
||||
playSequences,
|
||||
onSelectIndex,
|
||||
onClose,
|
||||
}: {
|
||||
item: ShowcaseAssetItem | null;
|
||||
activeIndex: number;
|
||||
playSequences: boolean;
|
||||
onSelectIndex: (index: number) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
@@ -427,7 +761,11 @@ function CreationShowcaseModal({
|
||||
/>
|
||||
<div className="creation-landing__showcase-modal-stage">
|
||||
<div className="creation-landing__showcase-current">
|
||||
<ShowcasePreview preview={activePreview} variant="modal" />
|
||||
<ShowcasePreview
|
||||
preview={activePreview}
|
||||
variant="modal"
|
||||
sequencePlaying={playSequences}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="creation-landing__showcase-dock">
|
||||
@@ -470,7 +808,8 @@ function CreationShowcaseModal({
|
||||
>
|
||||
<span className="creation-landing__showcase-thumb-media">
|
||||
{preview.mediaType === 'image' ||
|
||||
preview.mediaType === 'audio' ? (
|
||||
preview.mediaType === 'audio' ||
|
||||
preview.mediaType === 'image-sequence' ? (
|
||||
<ShowcasePreview preview={preview} variant="thumb" />
|
||||
) : (
|
||||
getPreviewIcon(preview)
|
||||
@@ -493,6 +832,7 @@ export function CreationLandingView({
|
||||
onOpenCommunity,
|
||||
searchKeyword = '',
|
||||
}: CreationLandingViewProps) {
|
||||
const prefersReducedMotion = usePrefersReducedMotion();
|
||||
const authUi = useAuthUi();
|
||||
const isAuthenticated = Boolean(authUi?.user);
|
||||
const normalizedSearchKeyword = searchKeyword.trim().toLocaleLowerCase();
|
||||
@@ -520,6 +860,10 @@ export function CreationLandingView({
|
||||
useState<ShowcaseTabId>('all');
|
||||
const [selectedShowcaseItem, setSelectedShowcaseItem] =
|
||||
useState<ShowcaseAssetItem | null>(null);
|
||||
const [hoveredShowcaseSequenceCardId, setHoveredShowcaseSequenceCardId] =
|
||||
useState<string | null>(null);
|
||||
const [focusedShowcaseSequenceCardId, setFocusedShowcaseSequenceCardId] =
|
||||
useState<string | null>(null);
|
||||
const [selectedShowcaseIndex, setSelectedShowcaseIndex] = useState(0);
|
||||
const [likedShowcaseIds, setLikedShowcaseIds] = useState<Set<string>>(
|
||||
() => new Set(),
|
||||
@@ -906,12 +1250,34 @@ export function CreationLandingView({
|
||||
<button
|
||||
type="button"
|
||||
className="creation-landing__asset-card-open"
|
||||
onPointerEnter={() =>
|
||||
setHoveredShowcaseSequenceCardId(item.id)
|
||||
}
|
||||
onPointerLeave={() =>
|
||||
setHoveredShowcaseSequenceCardId((current) =>
|
||||
current === item.id ? null : current,
|
||||
)
|
||||
}
|
||||
onFocus={() => setFocusedShowcaseSequenceCardId(item.id)}
|
||||
onBlur={() =>
|
||||
setFocusedShowcaseSequenceCardId((current) =>
|
||||
current === item.id ? null : current,
|
||||
)
|
||||
}
|
||||
onClick={() => {
|
||||
setSelectedShowcaseItem(item);
|
||||
setSelectedShowcaseIndex(0);
|
||||
}}
|
||||
>
|
||||
<ShowcaseBundlePreview item={item} />
|
||||
<ShowcaseBundlePreview
|
||||
item={item}
|
||||
playSequences={
|
||||
!prefersReducedMotion &&
|
||||
(hoveredShowcaseSequenceCardId === item.id ||
|
||||
focusedShowcaseSequenceCardId === item.id) &&
|
||||
!selectedShowcaseItem
|
||||
}
|
||||
/>
|
||||
<div className="creation-landing__asset-meta">
|
||||
<h3>{item.label}</h3>
|
||||
<p>{item.prompt}</p>
|
||||
@@ -1166,6 +1532,7 @@ export function CreationLandingView({
|
||||
<CreationShowcaseModal
|
||||
item={selectedShowcaseItem}
|
||||
activeIndex={selectedShowcaseIndex}
|
||||
playSequences={!prefersReducedMotion}
|
||||
onSelectIndex={setSelectedShowcaseIndex}
|
||||
onClose={() => setSelectedShowcaseItem(null)}
|
||||
/>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { EditorProjectResourceSnapshot } from '../../services/image-editor/editorProjectClient';
|
||||
import { calculateEditorVideoPrice } from '../image-editor/ImageCanvasGenerationModel';
|
||||
import { buildCreationShowcaseItems } from './creationShowcaseModel';
|
||||
|
||||
function createResource(
|
||||
@@ -375,6 +376,19 @@ describe('creationShowcaseModel', () => {
|
||||
label: '待机动画',
|
||||
assetKind: 'character-animation',
|
||||
imageSrc: 'data:image/png;base64,animation',
|
||||
imageSequenceFrames: [
|
||||
{
|
||||
imageSrc: 'data:image/png;base64,animation-1',
|
||||
width: 512,
|
||||
height: 512,
|
||||
},
|
||||
{
|
||||
imageSrc: 'data:image/png;base64,animation-2',
|
||||
width: 512,
|
||||
height: 512,
|
||||
},
|
||||
],
|
||||
imageSequenceDurationMs: 4_000,
|
||||
generationInputs: {
|
||||
fields: [{ title: '动作描述', value: '待机动作' }],
|
||||
references: [
|
||||
@@ -531,6 +545,34 @@ describe('creationShowcaseModel', () => {
|
||||
expect(items[0]?.cost).toBe('5泥点');
|
||||
});
|
||||
|
||||
it('infers video cost from the duration field instead of legacy top-level metadata', () => {
|
||||
const items = buildCreationShowcaseItems({
|
||||
activeTab: 'all',
|
||||
projectResources: [
|
||||
createResource({
|
||||
resourceId: 'video-1',
|
||||
label: '角色宣传片',
|
||||
assetKind: 'video',
|
||||
imageSrc: '/generated-editor-videos/video.mp4',
|
||||
model: 'seedance2.0-fast',
|
||||
generationInputs: {
|
||||
fields: [
|
||||
{ title: '清晰度', value: '720p' },
|
||||
{ title: '时长', value: '6秒' },
|
||||
],
|
||||
references: [],
|
||||
durationSeconds: 4,
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
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',
|
||||
@@ -555,46 +597,118 @@ describe('creationShowcaseModel', () => {
|
||||
expect(items[0]?.previews[0]?.mediaType).toBe('video');
|
||||
});
|
||||
|
||||
it('infers costs from generated asset kinds when stored price is absent', () => {
|
||||
it.each([
|
||||
{ frameCount: 32, durationMs: 4_000 },
|
||||
{ frameCount: 40, durationMs: 5_000 },
|
||||
{ frameCount: 48, durationMs: 6_000 },
|
||||
])(
|
||||
'carries a $frameCount-frame/$durationMs ms character action into the sequence preview',
|
||||
({ frameCount, durationMs }) => {
|
||||
const frames = Array.from({ length: frameCount }, (_, index) => ({
|
||||
imageSrc: `/generated/action/frame-${index + 1}.png`,
|
||||
objectKey: `generated/action/frame-${index + 1}.png`,
|
||||
assetObjectId: `asset-object-frame-${index + 1}`,
|
||||
width: 192,
|
||||
height: 256,
|
||||
}));
|
||||
const items = buildCreationShowcaseItems({
|
||||
activeTab: 'all',
|
||||
projectResources: [
|
||||
createResource({
|
||||
resourceId: 'animation-1',
|
||||
label: '待机动作',
|
||||
assetKind: 'character-animation',
|
||||
imageSrc: frames[0]?.imageSrc,
|
||||
objectKey: frames[0]?.objectKey,
|
||||
imageSequenceFrames: frames,
|
||||
imageSequenceDurationMs: durationMs,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
expect(items).toHaveLength(1);
|
||||
expect(items[0]?.previews[0]).toMatchObject({
|
||||
mediaType: 'image-sequence',
|
||||
imageSequenceDurationMs: durationMs,
|
||||
});
|
||||
expect(items[0]?.previews[0]?.imageSequenceFrames).toHaveLength(
|
||||
frameCount,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it('omits a damaged character action instead of falling back to its first frame', () => {
|
||||
const items = buildCreationShowcaseItems({
|
||||
activeTab: 'characters',
|
||||
activeTab: 'all',
|
||||
projectResources: [
|
||||
createResource({
|
||||
resourceId: 'character-1',
|
||||
label: '游戏角色图',
|
||||
imageSrc: '/generated-editor-assets/character.png',
|
||||
objectKey: 'generated-editor-assets/character.png',
|
||||
assetKind: 'character',
|
||||
model: 'gpt-image-2',
|
||||
prompt: '角色提示词',
|
||||
}),
|
||||
createResource({
|
||||
resourceId: 'animation-1',
|
||||
label: '待机动画',
|
||||
resourceId: 'animation-broken',
|
||||
label: '损坏动作',
|
||||
assetKind: 'character-animation',
|
||||
imageSrc: '/generated-editor-videos/animation.mp4',
|
||||
objectKey: 'generated-editor-videos/animation.mp4',
|
||||
model: 'seedance2.0-fast',
|
||||
durationSeconds: 6,
|
||||
generationInputs: {
|
||||
fields: [{ title: '清晰度', value: '720p' }],
|
||||
references: [
|
||||
{
|
||||
title: '角色图片',
|
||||
label: '游戏角色图',
|
||||
refType: 'project-resource',
|
||||
refId: 'character-1',
|
||||
},
|
||||
],
|
||||
},
|
||||
imageSrc: '/generated/action/frame-01.png',
|
||||
objectKey: 'generated/action/frame-01.png',
|
||||
imageSequenceFrames: [
|
||||
{
|
||||
imageSrc: '/generated/action/frame-01.png',
|
||||
objectKey: 'generated/action/frame-01.png',
|
||||
width: 192,
|
||||
height: 256,
|
||||
},
|
||||
],
|
||||
imageSequenceDurationMs: 5_000,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
expect(items).toHaveLength(1);
|
||||
expect(items[0]?.cost).toBe('123泥点');
|
||||
expect(items[0]?.previews[0]?.objectKey).toBe(
|
||||
'generated-editor-assets/character.png',
|
||||
);
|
||||
expect(items).toEqual([]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ durationMs: 5_000, expectedCost: '103泥点' },
|
||||
{ durationMs: 6_000, expectedCost: '123泥点' },
|
||||
])(
|
||||
'infers costs from $durationMs ms character actions when stored price is absent',
|
||||
({ durationMs, expectedCost }) => {
|
||||
const items = buildCreationShowcaseItems({
|
||||
activeTab: 'characters',
|
||||
projectResources: [
|
||||
createResource({
|
||||
resourceId: 'character-1',
|
||||
label: '游戏角色图',
|
||||
imageSrc: '/generated-editor-assets/character.png',
|
||||
objectKey: 'generated-editor-assets/character.png',
|
||||
assetKind: 'character',
|
||||
model: 'gpt-image-2',
|
||||
prompt: '角色提示词',
|
||||
}),
|
||||
createResource({
|
||||
resourceId: 'animation-1',
|
||||
label: '待机动画',
|
||||
assetKind: 'character-animation',
|
||||
imageSrc: '/generated-editor-videos/animation.mp4',
|
||||
objectKey: 'generated-editor-videos/animation.mp4',
|
||||
model: 'seedance2.0-fast',
|
||||
imageSequenceDurationMs: durationMs,
|
||||
generationInputs: {
|
||||
fields: [{ title: '清晰度', value: '720p' }],
|
||||
references: [
|
||||
{
|
||||
title: '角色图片',
|
||||
label: '游戏角色图',
|
||||
refType: 'project-resource',
|
||||
refId: 'character-1',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
expect(items).toHaveLength(1);
|
||||
expect(items[0]?.cost).toBe(expectedCost);
|
||||
expect(items[0]?.previews[0]?.objectKey).toBe(
|
||||
'generated-editor-assets/character.png',
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type {
|
||||
EditorAssetGenerationInputReference,
|
||||
EditorAssetSnapshot,
|
||||
EditorImageSequenceFrameResult,
|
||||
EditorProjectResourceSnapshot,
|
||||
EditorShowcaseCampaignSnapshot,
|
||||
} from '../../services/image-editor/editorProjectClient';
|
||||
@@ -19,7 +20,11 @@ import {
|
||||
|
||||
export type ShowcaseTabId = 'all' | 'characters' | 'ui' | 'music' | 'marketing';
|
||||
|
||||
export type ShowcaseAssetMediaType = 'image' | 'video' | 'audio';
|
||||
export type ShowcaseAssetMediaType =
|
||||
| 'image'
|
||||
| 'video'
|
||||
| 'audio'
|
||||
| 'image-sequence';
|
||||
|
||||
export type ShowcaseAssetPreview = {
|
||||
id: string;
|
||||
@@ -30,6 +35,8 @@ export type ShowcaseAssetPreview = {
|
||||
mediaType: ShowcaseAssetMediaType;
|
||||
width?: number;
|
||||
height?: number;
|
||||
imageSequenceFrames?: EditorImageSequenceFrameResult[];
|
||||
imageSequenceDurationMs?: number;
|
||||
};
|
||||
|
||||
export type ShowcaseAssetItem = {
|
||||
@@ -246,7 +253,8 @@ function projectResourceToShowcaseAsset(
|
||||
actualPrompt: resource.actualPrompt ?? null,
|
||||
model: resource.model ?? null,
|
||||
taskId: resource.taskId ?? null,
|
||||
durationSeconds: resource.durationSeconds,
|
||||
imageSequenceFrames: resource.imageSequenceFrames,
|
||||
imageSequenceDurationMs: resource.imageSequenceDurationMs,
|
||||
sourceResourceId: resource.sourceResourceId,
|
||||
assetKind: resource.assetKind,
|
||||
showcaseCategory: normalizeShowcaseCategory(
|
||||
@@ -394,6 +402,9 @@ function isVideoAsset(asset: EditorAssetSnapshot) {
|
||||
function resolveAssetMediaType(
|
||||
asset: EditorAssetSnapshot,
|
||||
): ShowcaseAssetMediaType {
|
||||
if (isCharacterAnimationAsset(asset)) {
|
||||
return 'image-sequence';
|
||||
}
|
||||
if (isAudioAsset(asset)) {
|
||||
return 'audio';
|
||||
}
|
||||
@@ -792,18 +803,23 @@ function inferAssetCost(asset: EditorAssetSnapshot) {
|
||||
return calculateCharacterAnimationPrice(
|
||||
model ?? CHARACTER_ANIMATION_MODEL,
|
||||
inferVideoResolution(asset, ['480p', '720p'], '480p') as '480p' | '720p',
|
||||
normalizeInt(asset.durationSeconds, 4, 4, 6),
|
||||
normalizeInt(
|
||||
asset.imageSequenceDurationMs
|
||||
? asset.imageSequenceDurationMs / 1_000
|
||||
: undefined,
|
||||
4,
|
||||
4,
|
||||
6,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (kind === 'video' || isVideoAsset(asset)) {
|
||||
return calculateEditorVideoPrice(
|
||||
model ?? DEFAULT_VIDEO_MODEL,
|
||||
inferVideoResolution(asset, ['480p', '720p', '1080p'], '480p') as
|
||||
| '480p'
|
||||
| '720p'
|
||||
| '1080p',
|
||||
'480p' | '720p' | '1080p',
|
||||
normalizeInt(
|
||||
asset.durationSeconds,
|
||||
inferMediaDurationSeconds(asset),
|
||||
DEFAULT_VIDEO_DURATION_SECONDS,
|
||||
4,
|
||||
15,
|
||||
@@ -816,6 +832,15 @@ function inferAssetCost(asset: EditorAssetSnapshot) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function inferMediaDurationSeconds(asset: EditorAssetSnapshot) {
|
||||
const durationField = asset.generationInputs?.fields.find((field) => {
|
||||
const title = field.title.trim().toLowerCase();
|
||||
return title === '时长' || title === 'duration';
|
||||
});
|
||||
const matchedValue = durationField?.value.match(/\d+(?:\.\d+)?/u)?.[0];
|
||||
return matchedValue ? Number(matchedValue) : undefined;
|
||||
}
|
||||
|
||||
function inferVideoResolution(
|
||||
asset: EditorAssetSnapshot,
|
||||
allowedValues: string[],
|
||||
@@ -849,8 +874,70 @@ function resolveGroupCost(assets: EditorAssetSnapshot[]) {
|
||||
return `${totalCost}泥点`;
|
||||
}
|
||||
|
||||
function toAssetPreview(asset: EditorAssetSnapshot): ShowcaseAssetPreview {
|
||||
function normalizeShowcaseImageSequence(asset: EditorAssetSnapshot) {
|
||||
const frames = asset.imageSequenceFrames;
|
||||
const durationMs = asset.imageSequenceDurationMs;
|
||||
if (
|
||||
!Array.isArray(frames) ||
|
||||
frames.length < 2 ||
|
||||
typeof durationMs !== 'number' ||
|
||||
!Number.isFinite(durationMs) ||
|
||||
durationMs <= 0
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const normalizedFrames = frames.flatMap((frame) => {
|
||||
const imageSrc = frame.imageSrc?.trim() ?? '';
|
||||
if (
|
||||
!imageSrc ||
|
||||
!Number.isFinite(frame.width) ||
|
||||
frame.width <= 0 ||
|
||||
!Number.isFinite(frame.height) ||
|
||||
frame.height <= 0
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
{
|
||||
...frame,
|
||||
imageSrc,
|
||||
objectKey: frame.objectKey?.trim() || null,
|
||||
assetObjectId: frame.assetObjectId?.trim() || null,
|
||||
},
|
||||
];
|
||||
});
|
||||
if (normalizedFrames.length !== frames.length) {
|
||||
return null;
|
||||
}
|
||||
return { frames: normalizedFrames, durationMs };
|
||||
}
|
||||
|
||||
function toAssetPreview(
|
||||
asset: EditorAssetSnapshot,
|
||||
): ShowcaseAssetPreview | null {
|
||||
const mediaType = resolveAssetMediaType(asset);
|
||||
if (mediaType === 'image-sequence') {
|
||||
const sequence = normalizeShowcaseImageSequence(asset);
|
||||
if (!sequence) {
|
||||
return null;
|
||||
}
|
||||
const firstFrame = sequence.frames[0];
|
||||
if (!firstFrame) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
id: asset.assetId,
|
||||
label: asset.label,
|
||||
src: firstFrame.imageSrc,
|
||||
coverSrc: null,
|
||||
objectKey: firstFrame.objectKey,
|
||||
mediaType,
|
||||
width: firstFrame.width,
|
||||
height: firstFrame.height,
|
||||
imageSequenceFrames: sequence.frames,
|
||||
imageSequenceDurationMs: sequence.durationMs,
|
||||
};
|
||||
}
|
||||
return {
|
||||
id: asset.assetId,
|
||||
label: asset.label,
|
||||
@@ -870,7 +957,10 @@ function toLibraryShowcaseItem(
|
||||
const sortedAssets = sortGroupAssets(group.assets);
|
||||
const previews = sortedAssets
|
||||
.map(toAssetPreview)
|
||||
.filter((preview) => preview.src.trim());
|
||||
.filter(
|
||||
(preview): preview is ShowcaseAssetPreview =>
|
||||
Boolean(preview?.src.trim()),
|
||||
);
|
||||
if (!previews.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
+61
-55
@@ -16,6 +16,7 @@ import type {
|
||||
EditorAgentMessage,
|
||||
EditorAgentMessageResponse,
|
||||
} from '@/packages/shared/src/contracts';
|
||||
import { setPlainTextEditorValue } from '@/src/components/common/AutoGrowTextArea.test-utils.ts';
|
||||
import type { EditorAgentConversationClient } from '@/src/components/image-editor/EditorAgentConversation/useEditorAgentConversation.ts';
|
||||
import { EDITOR_AGENT_PATIENCE_NOTICE_DELAY_MS } from '@/src/components/image-editor/EditorAgentConversation/useEditorAgentConversation.ts';
|
||||
import { ImageCanvasActionsProvider } from '@/src/components/image-editor/ImageCanvasActionsProvider.tsx';
|
||||
@@ -135,9 +136,10 @@ function createClient(): EditorAgentConversationClient {
|
||||
}
|
||||
|
||||
function enterAttachmentPrompt() {
|
||||
fireEvent.change(screen.getByLabelText('发送给画布 Agent'), {
|
||||
target: { value: ATTACHMENT_PROMPT },
|
||||
});
|
||||
setPlainTextEditorValue(
|
||||
screen.getByLabelText('发送给画布 Agent'),
|
||||
ATTACHMENT_PROMPT,
|
||||
);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
@@ -294,9 +296,10 @@ describe('EditorAgentConversationPanelView', () => {
|
||||
name: '新建对话',
|
||||
}) as HTMLButtonElement;
|
||||
expect(newConversationButton.disabled).toBe(false);
|
||||
fireEvent.change(screen.getByLabelText('发送给画布 Agent'), {
|
||||
target: { value: '应保留到新会话的草稿' },
|
||||
});
|
||||
setPlainTextEditorValue(
|
||||
screen.getByLabelText('发送给画布 Agent'),
|
||||
'应保留到新会话的草稿',
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: '添加附件' }));
|
||||
const attachmentDialog = screen.getByRole('dialog', {
|
||||
name: '选择图片附件',
|
||||
@@ -332,25 +335,27 @@ describe('EditorAgentConversationPanelView', () => {
|
||||
).toBe('conversation-2');
|
||||
expect(newConversationButton.disabled).toBe(true);
|
||||
});
|
||||
expect(
|
||||
(screen.getByLabelText('发送给画布 Agent') as HTMLTextAreaElement).value,
|
||||
).toBe('应保留到新会话的草稿');
|
||||
expect(screen.getByLabelText('发送给画布 Agent').textContent).toBe(
|
||||
'应保留到新会话的草稿',
|
||||
);
|
||||
expect(
|
||||
screen.getByText('一二三四五六七八九十甲乙丙丁戊己庚辛壬癸子丑寅卯'),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByRole('option', { name: '角色参考' })).toBeTruthy();
|
||||
expect(screen.getByRole('option', { name: '新对话' })).toBeTruthy();
|
||||
|
||||
fireEvent.change(screen.getByLabelText('发送给画布 Agent'), {
|
||||
target: { value: '尚未发送的草稿' },
|
||||
});
|
||||
setPlainTextEditorValue(
|
||||
screen.getByLabelText('发送给画布 Agent'),
|
||||
'尚未发送的草稿',
|
||||
);
|
||||
expect(newConversationButton.disabled).toBe(true);
|
||||
fireEvent.click(newConversationButton);
|
||||
expect(client.createConversation).toHaveBeenCalledTimes(1);
|
||||
|
||||
fireEvent.change(screen.getByLabelText('发送给画布 Agent'), {
|
||||
target: { value: '参考附件做像素风' },
|
||||
});
|
||||
setPlainTextEditorValue(
|
||||
screen.getByLabelText('发送给画布 Agent'),
|
||||
'参考附件做像素风',
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: '发送' }));
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -491,9 +496,10 @@ describe('EditorAgentConversationPanelView', () => {
|
||||
}) as HTMLButtonElement;
|
||||
expect(newConversationButton.disabled).toBe(true);
|
||||
|
||||
fireEvent.change(screen.getByLabelText('发送给画布 Agent'), {
|
||||
target: { value: '未发送内容' },
|
||||
});
|
||||
setPlainTextEditorValue(
|
||||
screen.getByLabelText('发送给画布 Agent'),
|
||||
'未发送内容',
|
||||
);
|
||||
expect(newConversationButton.disabled).toBe(true);
|
||||
fireEvent.click(newConversationButton);
|
||||
expect(client.createConversation).not.toHaveBeenCalled();
|
||||
@@ -516,17 +522,18 @@ describe('EditorAgentConversationPanelView', () => {
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('已经看到画布内容')).toBeTruthy();
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText('发送给画布 Agent'), {
|
||||
target: { value: '需要保留的草稿' },
|
||||
});
|
||||
setPlainTextEditorValue(
|
||||
screen.getByLabelText('发送给画布 Agent'),
|
||||
'需要保留的草稿',
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: '新建对话' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('创建新会话失败')).toBeTruthy();
|
||||
});
|
||||
expect(
|
||||
(screen.getByLabelText('发送给画布 Agent') as HTMLTextAreaElement).value,
|
||||
).toBe('需要保留的草稿');
|
||||
expect(screen.getByLabelText('发送给画布 Agent').textContent).toBe(
|
||||
'需要保留的草稿',
|
||||
);
|
||||
expect(screen.getByText('已经看到画布内容')).toBeTruthy();
|
||||
expect((screen.getByLabelText('当前对话') as HTMLSelectElement).value).toBe(
|
||||
'conversation-1',
|
||||
@@ -556,15 +563,13 @@ describe('EditorAgentConversationPanelView', () => {
|
||||
});
|
||||
const draftInput = screen.getByLabelText(
|
||||
'发送给画布 Agent',
|
||||
) as HTMLTextAreaElement;
|
||||
fireEvent.change(draftInput, {
|
||||
target: { value: '旧项目草稿' },
|
||||
});
|
||||
) as HTMLElement;
|
||||
setPlainTextEditorValue(draftInput, '旧项目草稿');
|
||||
fireEvent.click(screen.getByRole('button', { name: '新建对话' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(client.createConversation).toHaveBeenCalledWith('project-1', {});
|
||||
expect(draftInput.disabled).toBe(false);
|
||||
expect(draftInput.getAttribute('aria-disabled')).toBeNull();
|
||||
});
|
||||
const conversationSelect = screen.getByLabelText(
|
||||
'当前对话',
|
||||
@@ -578,12 +583,10 @@ describe('EditorAgentConversationPanelView', () => {
|
||||
(screen.getByRole('button', { name: '添加附件' }) as HTMLButtonElement)
|
||||
.disabled,
|
||||
).toBe(false);
|
||||
fireEvent.change(draftInput, {
|
||||
target: { value: '创建期间更新的草稿' },
|
||||
});
|
||||
fireEvent.submit(draftInput.closest('form') as HTMLFormElement);
|
||||
setPlainTextEditorValue(draftInput, '创建期间更新的草稿');
|
||||
fireEvent.click(sendButton);
|
||||
expect(client.sendMessage).not.toHaveBeenCalled();
|
||||
expect(draftInput.value).toBe('创建期间更新的草稿');
|
||||
expect(draftInput.textContent).toBe('创建期间更新的草稿');
|
||||
|
||||
await act(async () => {
|
||||
resolveCreate({
|
||||
@@ -596,7 +599,7 @@ describe('EditorAgentConversationPanelView', () => {
|
||||
});
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(draftInput.value).toBe('创建期间更新的草稿');
|
||||
expect(draftInput.textContent).toBe('创建期间更新的草稿');
|
||||
expect(conversationSelect.value).toBe('conversation-2');
|
||||
expect(conversationSelect.disabled).toBe(false);
|
||||
expect(sendButton.disabled).toBe(false);
|
||||
@@ -659,13 +662,11 @@ describe('EditorAgentConversationPanelView', () => {
|
||||
});
|
||||
const draftInput = screen.getByLabelText(
|
||||
'发送给画布 Agent',
|
||||
) as HTMLTextAreaElement;
|
||||
) as HTMLElement;
|
||||
const conversationSelect = screen.getByLabelText(
|
||||
'当前对话',
|
||||
) as HTMLSelectElement;
|
||||
fireEvent.change(draftInput, {
|
||||
target: { value: '切换期间必须保留的草稿' },
|
||||
});
|
||||
setPlainTextEditorValue(draftInput, '切换期间必须保留的草稿');
|
||||
fireEvent.change(conversationSelect, {
|
||||
target: { value: 'conversation-2' },
|
||||
});
|
||||
@@ -677,9 +678,9 @@ describe('EditorAgentConversationPanelView', () => {
|
||||
expect(client.getConversation).toHaveBeenCalledWith('conversation-2');
|
||||
expect(sendButton.disabled).toBe(true);
|
||||
});
|
||||
fireEvent.submit(draftInput.closest('form') as HTMLFormElement);
|
||||
fireEvent.click(sendButton);
|
||||
expect(client.sendMessage).not.toHaveBeenCalled();
|
||||
expect(draftInput.value).toBe('切换期间必须保留的草稿');
|
||||
expect(draftInput.textContent).toBe('切换期间必须保留的草稿');
|
||||
|
||||
await act(async () => {
|
||||
resolveConversationLoad({
|
||||
@@ -702,7 +703,7 @@ describe('EditorAgentConversationPanelView', () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(await screen.findByText('背景会话内容')).toBeTruthy();
|
||||
expect(draftInput.value).toBe('切换期间必须保留的草稿');
|
||||
expect(draftInput.textContent).toBe('切换期间必须保留的草稿');
|
||||
expect(sendButton.disabled).toBe(false);
|
||||
});
|
||||
|
||||
@@ -729,9 +730,10 @@ describe('EditorAgentConversationPanelView', () => {
|
||||
});
|
||||
vi.useFakeTimers();
|
||||
|
||||
fireEvent.change(screen.getByLabelText('发送给画布 Agent'), {
|
||||
target: { value: '继续规划' },
|
||||
});
|
||||
setPlainTextEditorValue(
|
||||
screen.getByLabelText('发送给画布 Agent'),
|
||||
'继续规划',
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: '发送' }));
|
||||
|
||||
await act(async () => {
|
||||
@@ -1316,7 +1318,7 @@ describe('EditorAgentConversationPanelView', () => {
|
||||
}) as HTMLButtonElement;
|
||||
expect(sendButton.disabled).toBe(true);
|
||||
|
||||
fireEvent.submit(sendButton.closest('form')!);
|
||||
fireEvent.click(sendButton);
|
||||
|
||||
expect(client.sendMessage).not.toHaveBeenCalled();
|
||||
expect(screen.getByText('角色图层')).toBeTruthy();
|
||||
@@ -1368,15 +1370,18 @@ describe('EditorAgentConversationPanelView', () => {
|
||||
fireEvent.click(
|
||||
within(attachmentDialog).getByRole('button', { name: '应用' }),
|
||||
);
|
||||
fireEvent.change(screen.getByLabelText('发送给画布 Agent'), {
|
||||
target: { value: '失败后恢复这条草稿' },
|
||||
});
|
||||
setPlainTextEditorValue(
|
||||
screen.getByLabelText('发送给画布 Agent'),
|
||||
'失败后恢复这条草稿',
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: '发送' }));
|
||||
|
||||
expect(await screen.findByText('Network error')).toBeTruthy();
|
||||
expect(
|
||||
(screen.getByLabelText('发送给画布 Agent') as HTMLTextAreaElement).value,
|
||||
).toBe('失败后恢复这条草稿');
|
||||
await waitFor(() =>
|
||||
expect(screen.getByLabelText('发送给画布 Agent').textContent).toBe(
|
||||
'失败后恢复这条草稿',
|
||||
),
|
||||
);
|
||||
expect(screen.getByText('角色图层')).toBeTruthy();
|
||||
expect(
|
||||
within(
|
||||
@@ -1760,9 +1765,10 @@ describe('EditorAgentConversationPanelView', () => {
|
||||
expect(screen.getByText('已经看到画布内容')).toBeTruthy();
|
||||
});
|
||||
|
||||
fireEvent.change(screen.getByLabelText('发送给画布 Agent'), {
|
||||
target: { value: '生成一张图片' },
|
||||
});
|
||||
setPlainTextEditorValue(
|
||||
screen.getByLabelText('发送给画布 Agent'),
|
||||
'生成一张图片',
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: '发送' }));
|
||||
|
||||
await waitFor(() => {
|
||||
|
||||
+7
-10
@@ -10,7 +10,6 @@ import {
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
type FormEvent,
|
||||
useEffect,
|
||||
useState,
|
||||
type WheelEvent as ReactWheelEvent,
|
||||
@@ -139,8 +138,7 @@ export function EditorAgentConversationPanelView({
|
||||
void createConversation().catch(() => undefined);
|
||||
};
|
||||
|
||||
const submitMessage = (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
const submitMessage = () => {
|
||||
if (isMessageSubmissionBlocked) {
|
||||
return;
|
||||
}
|
||||
@@ -328,10 +326,7 @@ export function EditorAgentConversationPanelView({
|
||||
{attachmentError}
|
||||
</div>
|
||||
) : null}
|
||||
<form
|
||||
className="border-t border-slate-200 bg-white/95 p-3"
|
||||
onSubmit={submitMessage}
|
||||
>
|
||||
<div className="border-t border-slate-200 bg-white/95 p-3">
|
||||
{attachments.length ? (
|
||||
<div className="mb-2 flex flex-wrap gap-1.5">
|
||||
{attachments.map((attachment) => (
|
||||
@@ -354,19 +349,21 @@ export function EditorAgentConversationPanelView({
|
||||
</button>
|
||||
<EditorAgentDraftTextarea
|
||||
value={draftText}
|
||||
onChange={setDraftText}
|
||||
onValueChange={setDraftText}
|
||||
onPaste={handleInputPaste}
|
||||
onSubmitRequest={submitMessage}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
type="button"
|
||||
className="inline-flex h-10 min-w-16 shrink-0 items-center justify-center gap-1.5 rounded-full bg-slate-900 px-3 text-sm font-semibold text-white disabled:opacity-45"
|
||||
disabled={isMessageSubmissionBlocked}
|
||||
onClick={submitMessage}
|
||||
>
|
||||
<Send className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
发送
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</aside>
|
||||
<AttachmentPicker
|
||||
open={attachmentPickerOpen}
|
||||
|
||||
+66
-40
@@ -1,60 +1,86 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import type { FormEvent } from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { useState } from 'react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { EditorAgentDraftTextarea } from './EditorAgentDraftTextarea.tsx';
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal(
|
||||
'ResizeObserver',
|
||||
class ResizeObserver {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
function DraftHarness({
|
||||
onPaste,
|
||||
onSubmitRequest,
|
||||
}: {
|
||||
onPaste?: (event: ClipboardEvent) => void;
|
||||
onSubmitRequest: () => void;
|
||||
}) {
|
||||
const [value, setValue] = useState('');
|
||||
return (
|
||||
<EditorAgentDraftTextarea
|
||||
value={value}
|
||||
onValueChange={setValue}
|
||||
onPaste={onPaste}
|
||||
onSubmitRequest={onSubmitRequest}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
describe('EditorAgentDraftTextarea', () => {
|
||||
it('forwards draft changes and paste events through the Agent wrapper', () => {
|
||||
const onChange = vi.fn();
|
||||
const onPaste = vi.fn();
|
||||
it('forwards controlled draft edits and paste events', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onPaste = vi.fn((event: ClipboardEvent) => event.preventDefault());
|
||||
render(<DraftHarness onPaste={onPaste} onSubmitRequest={vi.fn()} />);
|
||||
|
||||
render(
|
||||
<EditorAgentDraftTextarea
|
||||
value=""
|
||||
onChange={onChange}
|
||||
onPaste={onPaste}
|
||||
/>,
|
||||
);
|
||||
|
||||
const input = screen.getByLabelText(
|
||||
'发送给画布 Agent',
|
||||
) as HTMLTextAreaElement;
|
||||
expect(input.className).toContain('auto-grow-text-area');
|
||||
expect(input.className).toContain(
|
||||
'editor-agent-conversation__draft-input',
|
||||
);
|
||||
|
||||
fireEvent.change(input, { target: { value: '新草稿' } });
|
||||
expect(onChange).toHaveBeenCalledWith('新草稿');
|
||||
fireEvent.paste(input);
|
||||
const editor = screen.getByRole('textbox', {
|
||||
name: '发送给画布 Agent',
|
||||
});
|
||||
await user.click(editor);
|
||||
fireEvent.paste(editor, {
|
||||
clipboardData: {
|
||||
files: [],
|
||||
getData: () => '',
|
||||
items: [],
|
||||
types: [],
|
||||
},
|
||||
});
|
||||
expect(onPaste).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('submits on Enter and keeps line breaks or IME confirmation from submitting', () => {
|
||||
const onSubmit = vi.fn((event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
});
|
||||
render(
|
||||
<form onSubmit={onSubmit}>
|
||||
<EditorAgentDraftTextarea value="草稿" onChange={vi.fn()} />
|
||||
</form>,
|
||||
it('submits on Enter but preserves Shift+Enter and IME confirmation', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSubmitRequest = vi.fn();
|
||||
const { container } = render(
|
||||
<DraftHarness onSubmitRequest={onSubmitRequest} />,
|
||||
);
|
||||
|
||||
const input = screen.getByLabelText('发送给画布 Agent');
|
||||
fireEvent.keyDown(input, { key: 'Enter', shiftKey: true });
|
||||
expect(onSubmit).not.toHaveBeenCalled();
|
||||
const editor = screen.getByRole('textbox', {
|
||||
name: '发送给画布 Agent',
|
||||
});
|
||||
await user.click(editor);
|
||||
fireEvent.keyDown(editor, { key: 'Enter', shiftKey: true });
|
||||
expect(onSubmitRequest).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.keyDown(input, {
|
||||
fireEvent.keyDown(editor, {
|
||||
key: 'Enter',
|
||||
isComposing: false,
|
||||
isComposing: true,
|
||||
keyCode: 229,
|
||||
});
|
||||
expect(onSubmit).not.toHaveBeenCalled();
|
||||
expect(onSubmitRequest).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.keyDown(input, { key: 'Enter' });
|
||||
expect(onSubmit).toHaveBeenCalledTimes(1);
|
||||
fireEvent.keyDown(editor, { key: 'Enter' });
|
||||
expect(onSubmitRequest).toHaveBeenCalledTimes(1);
|
||||
expect(container.querySelector('form')).toBeNull();
|
||||
expect(container.querySelector('textarea')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,39 +1,24 @@
|
||||
import {
|
||||
type ClipboardEventHandler,
|
||||
type KeyboardEvent,
|
||||
} from 'react';
|
||||
|
||||
import { AutoGrowTextArea } from '@/src/components/common/AutoGrowTextArea';
|
||||
|
||||
export function EditorAgentDraftTextarea({
|
||||
value,
|
||||
onChange,
|
||||
onValueChange,
|
||||
onPaste,
|
||||
onSubmitRequest,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
onPaste?: ClipboardEventHandler<HTMLTextAreaElement>;
|
||||
onValueChange: (value: string) => void;
|
||||
onPaste?: (event: ClipboardEvent) => void;
|
||||
onSubmitRequest: () => void;
|
||||
}) {
|
||||
const handleKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (
|
||||
event.key === 'Enter' &&
|
||||
!event.shiftKey &&
|
||||
!event.nativeEvent.isComposing &&
|
||||
event.nativeEvent.keyCode !== 229
|
||||
) {
|
||||
event.preventDefault();
|
||||
event.currentTarget.form?.requestSubmit();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AutoGrowTextArea
|
||||
className="editor-agent-conversation__draft-input min-w-0 flex-1 rounded-3xl border border-slate-200 bg-slate-50 px-3 py-2 text-sm text-slate-800 outline-none focus:border-slate-400"
|
||||
className="editor-agent-conversation__draft-input min-w-0 flex-1 border border-slate-200 bg-slate-50 text-sm text-slate-800 outline-none focus-within:border-slate-400"
|
||||
aria-label="发送给画布 Agent"
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.currentTarget.value)}
|
||||
onValueChange={onValueChange}
|
||||
onPaste={onPaste}
|
||||
onKeyDown={handleKeyDown}
|
||||
onSubmitRequest={onSubmitRequest}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import {
|
||||
type ClipboardEvent as ReactClipboardEvent,
|
||||
useCallback,
|
||||
useMemo,
|
||||
useRef,
|
||||
@@ -305,7 +304,7 @@ export function useConversationAttachments({
|
||||
);
|
||||
|
||||
const handleInputPaste = useCallback(
|
||||
(event: ReactClipboardEvent<HTMLTextAreaElement>) => {
|
||||
(event: ClipboardEvent) => {
|
||||
const imageFiles = extractClipboardImageFiles(event.clipboardData);
|
||||
if (!imageFiles.length || isPastingAttachmentRef.current) {
|
||||
return;
|
||||
|
||||
@@ -29,6 +29,7 @@ import type {
|
||||
EditorAssetFolder,
|
||||
UploadTarget,
|
||||
} from './ImageCanvasEditorTypes';
|
||||
import type { AssetSelectionInteraction } from './useImageCanvasAssetSelection';
|
||||
|
||||
type ImageCanvasAssetFolderSectionViewProps = {
|
||||
folder: GroupedEditorAssetFolder;
|
||||
@@ -36,6 +37,7 @@ type ImageCanvasAssetFolderSectionViewProps = {
|
||||
suppressAssetClickRef: { current: boolean };
|
||||
isAssetSelectionMode: boolean;
|
||||
selectedAssetIds: Set<string>;
|
||||
visibleAssetIds: string[];
|
||||
assetMoveDropFolderId: string | null;
|
||||
renamingFolder: { folderId: string; value: string } | null;
|
||||
renamingAsset: { assetId: string; value: string } | null;
|
||||
@@ -48,9 +50,11 @@ type ImageCanvasAssetFolderSectionViewProps = {
|
||||
setActiveUploadFolderId: Dispatch<SetStateAction<string>>;
|
||||
setUploadDropTarget: Dispatch<SetStateAction<'canvas' | 'assets' | null>>;
|
||||
setAssetPointerDrag: Dispatch<SetStateAction<AssetPointerDragState | null>>;
|
||||
setSelectedAssetIds: Dispatch<SetStateAction<Set<string>>>;
|
||||
updateAssetMoveDropFolder: (folderId: string | null) => void;
|
||||
addUploadedFiles: (files: FileList | File[], options?: UploadFilesOptions) => void;
|
||||
addUploadedFiles: (
|
||||
files: FileList | File[],
|
||||
options?: UploadFilesOptions,
|
||||
) => void;
|
||||
requestUpload: (target: UploadTarget, options?: UploadRequestOptions) => void;
|
||||
moveAssetToFolder: (assetId: string, folderId: string) => void;
|
||||
toggleAssetFolder: (folderId: string) => void;
|
||||
@@ -61,7 +65,10 @@ type ImageCanvasAssetFolderSectionViewProps = {
|
||||
commitAssetRename: (asset: EditorAsset) => void;
|
||||
deleteUploadedAsset: (asset: EditorAsset) => void;
|
||||
submitAssetToShowcase: (asset: EditorAsset) => void;
|
||||
toggleAssetSelected: (assetId: string) => void;
|
||||
toggleAssetSelected: (
|
||||
assetId: string,
|
||||
interaction: AssetSelectionInteraction,
|
||||
) => void;
|
||||
addAssetLayer: (asset: EditorAsset) => void;
|
||||
onDownloadAsset: (asset: EditorAsset) => void;
|
||||
};
|
||||
@@ -72,6 +79,7 @@ export function ImageCanvasAssetFolderSectionView({
|
||||
suppressAssetClickRef,
|
||||
isAssetSelectionMode,
|
||||
selectedAssetIds,
|
||||
visibleAssetIds,
|
||||
assetMoveDropFolderId,
|
||||
renamingFolder,
|
||||
renamingAsset,
|
||||
@@ -80,7 +88,6 @@ export function ImageCanvasAssetFolderSectionView({
|
||||
setActiveUploadFolderId,
|
||||
setUploadDropTarget,
|
||||
setAssetPointerDrag,
|
||||
setSelectedAssetIds,
|
||||
updateAssetMoveDropFolder,
|
||||
addUploadedFiles,
|
||||
requestUpload,
|
||||
@@ -110,6 +117,17 @@ export function ImageCanvasAssetFolderSectionView({
|
||||
aria-label={folder.label}
|
||||
data-asset-folder-id={folder.id}
|
||||
onDragOver={(event) => {
|
||||
if (isAssetSelectionMode) {
|
||||
if (
|
||||
hasDataTransferType(event.dataTransfer, ASSET_DRAG_MIME_TYPE) ||
|
||||
hasDataTransferType(event.dataTransfer, 'Files')
|
||||
) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
event.dataTransfer.dropEffect = 'none';
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (hasDataTransferType(event.dataTransfer, ASSET_DRAG_MIME_TYPE)) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
@@ -126,6 +144,18 @@ export function ImageCanvasAssetFolderSectionView({
|
||||
}
|
||||
}}
|
||||
onDrop={(event) => {
|
||||
if (isAssetSelectionMode) {
|
||||
if (
|
||||
getDraggedAssetId(event.dataTransfer) ||
|
||||
event.dataTransfer.files.length > 0
|
||||
) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setUploadDropTarget(null);
|
||||
updateAssetMoveDropFolder(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const movingAssetId = getDraggedAssetId(event.dataTransfer);
|
||||
if (movingAssetId) {
|
||||
event.preventDefault();
|
||||
@@ -241,11 +271,11 @@ export function ImageCanvasAssetFolderSectionView({
|
||||
suppressAssetClickRef={suppressAssetClickRef}
|
||||
isAssetSelectionMode={isAssetSelectionMode}
|
||||
selectedAssetIds={selectedAssetIds}
|
||||
visibleAssetIds={visibleAssetIds}
|
||||
renamingAsset={renamingAsset}
|
||||
setRenamingAsset={setRenamingAsset}
|
||||
setUploadDropTarget={setUploadDropTarget}
|
||||
setAssetPointerDrag={setAssetPointerDrag}
|
||||
setSelectedAssetIds={setSelectedAssetIds}
|
||||
updateAssetMoveDropFolder={updateAssetMoveDropFolder}
|
||||
addUploadedFiles={addUploadedFiles}
|
||||
moveAssetToFolder={moveAssetToFolder}
|
||||
|
||||
@@ -1,33 +1,25 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
areAllSelectableAssetsSelected,
|
||||
createAssetMarqueeFromPointer,
|
||||
createAssetMarqueeSelectionRect,
|
||||
createLocalAssetFolder,
|
||||
deleteAssetFolderLocally,
|
||||
filterGroupedAssetsBySearch,
|
||||
getSelectableAssets,
|
||||
groupAssetsByFolder,
|
||||
layerMatchesSearch,
|
||||
layerStartsWithSearch,
|
||||
moveAssetMarqueeToPointer,
|
||||
moveAssetToFolderLocally,
|
||||
removeAssetById,
|
||||
removeSelectedAssets,
|
||||
renameAssetById,
|
||||
renameAssetFolderById,
|
||||
replaceLocalAssetFolder,
|
||||
resolveAllAssetSelection,
|
||||
resolveAssetFolderIdFromPoint,
|
||||
resolveAssetShowcaseSubmissionState,
|
||||
resolveDefaultAssetFolder,
|
||||
resolvePinnedAssetMoveFolderId,
|
||||
resolvePreferredAssetUploadFolder,
|
||||
resolveUploadAssetFolder,
|
||||
selectUploadedAssetsInRect,
|
||||
toggleAssetFolderCollapsed,
|
||||
toggleAssetSelection,
|
||||
} from './ImageCanvasAssetLibraryModel';
|
||||
import type {
|
||||
CanvasLayer,
|
||||
@@ -105,16 +97,12 @@ describe('ImageCanvasAssetLibraryModel', () => {
|
||||
{ id: 'project', assets: [{ id: 'asset-a' }, { id: 'built-in' }] },
|
||||
{ id: 'folder-role', assets: [{ id: 'asset-b' }] },
|
||||
]);
|
||||
expect(getSelectableAssets(assets).map((asset) => asset.id)).toEqual([
|
||||
'asset-a',
|
||||
'asset-b',
|
||||
]);
|
||||
});
|
||||
|
||||
it('explains why assets cannot be submitted to showcase review', () => {
|
||||
expect(
|
||||
resolveAssetShowcaseSubmissionState(createAsset()).reason,
|
||||
).toBe('只有生成素材可以提交精选审核');
|
||||
expect(resolveAssetShowcaseSubmissionState(createAsset()).reason).toBe(
|
||||
'只有生成素材可以提交精选审核',
|
||||
);
|
||||
expect(
|
||||
resolveAssetShowcaseSubmissionState(
|
||||
createAsset({ sourceType: 'generated', persisted: false }),
|
||||
@@ -260,15 +248,15 @@ describe('ImageCanvasAssetLibraryModel', () => {
|
||||
});
|
||||
|
||||
it('renames assets, toggles folders and creates local folders', () => {
|
||||
expect(renameAssetById([createAsset()], 'asset-a', '新名字')[0]).toMatchObject(
|
||||
{ label: '新名字' },
|
||||
);
|
||||
expect(
|
||||
renameAssetById([createAsset()], 'asset-a', '新名字')[0],
|
||||
).toMatchObject({ label: '新名字' });
|
||||
expect(
|
||||
renameAssetFolderById([createFolder()], 'project', '默认素材')[0],
|
||||
).toMatchObject({ label: '默认素材' });
|
||||
expect(toggleAssetFolderCollapsed([createFolder()], 'project')[0]).toMatchObject(
|
||||
{ collapsed: true },
|
||||
);
|
||||
expect(
|
||||
toggleAssetFolderCollapsed([createFolder()], 'project')[0],
|
||||
).toMatchObject({ collapsed: true });
|
||||
expect(
|
||||
createLocalAssetFolder({
|
||||
folderId: 'folder-local',
|
||||
@@ -316,9 +304,9 @@ describe('ImageCanvasAssetLibraryModel', () => {
|
||||
createAsset({ id: 'asset-b', folderId: 'project' }),
|
||||
];
|
||||
|
||||
expect(removeAssetById(assets, 'asset-a').map((asset) => asset.id)).toEqual([
|
||||
'asset-b',
|
||||
]);
|
||||
expect(removeAssetById(assets, 'asset-a').map((asset) => asset.id)).toEqual(
|
||||
['asset-b'],
|
||||
);
|
||||
expect(resolveDefaultAssetFolder(folders)?.id).toBe('project');
|
||||
expect(
|
||||
resolveUploadAssetFolder([
|
||||
@@ -343,23 +331,11 @@ describe('ImageCanvasAssetLibraryModel', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('toggles selection, selects all uploaded assets and removes selected assets', () => {
|
||||
it('removes selected assets', () => {
|
||||
const assets = [
|
||||
createAsset({ id: 'asset-a' }),
|
||||
createAsset({ id: 'asset-b' }),
|
||||
];
|
||||
const selected = toggleAssetSelection(new Set<string>(), 'asset-a');
|
||||
|
||||
expect([...selected]).toEqual(['asset-a']);
|
||||
expect([...toggleAssetSelection(selected, 'asset-a')]).toEqual([]);
|
||||
expect(areAllSelectableAssetsSelected(assets, new Set(['asset-a']))).toBe(
|
||||
false,
|
||||
);
|
||||
const allSelected = resolveAllAssetSelection({
|
||||
allSelectableAssetsSelected: false,
|
||||
selectableAssets: assets,
|
||||
});
|
||||
expect([...allSelected]).toEqual(['asset-a', 'asset-b']);
|
||||
const removal = removeSelectedAssets(assets, new Set(['asset-b']));
|
||||
expect(removal.assets.map((asset) => asset.id)).toEqual(['asset-a']);
|
||||
expect(removal.deletedAssets.map((asset) => asset.id)).toEqual(['asset-b']);
|
||||
@@ -438,71 +414,4 @@ describe('ImageCanvasAssetLibraryModel', () => {
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('updates marquee geometry from pointer coordinates and normalizes reverse drags', () => {
|
||||
const marquee = createAssetMarqueeFromPointer({
|
||||
pointerId: 5,
|
||||
point: { clientX: 180, clientY: 220 },
|
||||
containerRect: { left: 100, top: 200 },
|
||||
});
|
||||
|
||||
expect(marquee).toEqual({
|
||||
pointerId: 5,
|
||||
startX: 80,
|
||||
startY: 20,
|
||||
currentX: 80,
|
||||
currentY: 20,
|
||||
});
|
||||
expect(
|
||||
moveAssetMarqueeToPointer({
|
||||
marquee,
|
||||
point: { clientX: 120, clientY: 205 },
|
||||
containerRect: { left: 100, top: 200 },
|
||||
}),
|
||||
).toMatchObject({ currentX: 20, currentY: 5 });
|
||||
expect(
|
||||
createAssetMarqueeSelectionRect({
|
||||
marquee,
|
||||
point: { clientX: 120, clientY: 205 },
|
||||
containerRect: { left: 100, top: 200 },
|
||||
}),
|
||||
).toEqual({
|
||||
left: 120,
|
||||
right: 180,
|
||||
top: 205,
|
||||
bottom: 220,
|
||||
});
|
||||
});
|
||||
|
||||
it('selects only uploaded assets intersecting the marquee rectangle', () => {
|
||||
const assets = [
|
||||
createAsset({ id: 'asset-a' }),
|
||||
createAsset({ id: 'asset-b' }),
|
||||
createAsset({ id: 'built-in', sourceKind: 'built-in' }),
|
||||
];
|
||||
const selectedIds = selectUploadedAssetsInRect({
|
||||
assets,
|
||||
assetTargets: [
|
||||
{
|
||||
assetId: 'asset-a',
|
||||
rect: { left: 10, right: 20, top: 10, bottom: 20 },
|
||||
},
|
||||
{
|
||||
assetId: 'asset-b',
|
||||
rect: { left: 30, right: 40, top: 30, bottom: 40 },
|
||||
},
|
||||
{
|
||||
assetId: 'built-in',
|
||||
rect: { left: 15, right: 25, top: 15, bottom: 25 },
|
||||
},
|
||||
{
|
||||
assetId: 'missing',
|
||||
rect: { left: 15, right: 25, top: 15, bottom: 25 },
|
||||
},
|
||||
],
|
||||
selectionRect: { left: 20, right: 35, top: 20, bottom: 35 },
|
||||
});
|
||||
|
||||
expect([...selectedIds]).toEqual(['asset-a', 'asset-b']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { isEditorInternalProcessingModel } from './ImageCanvasEditorModel';
|
||||
import type {
|
||||
AssetMarqueeState,
|
||||
CanvasLayer,
|
||||
EditorAsset,
|
||||
EditorAssetFolder,
|
||||
@@ -18,11 +17,6 @@ type AssetFolderHitTarget = {
|
||||
rect: ClientRectLike;
|
||||
};
|
||||
|
||||
type AssetHitTarget = {
|
||||
assetId: string;
|
||||
rect: ClientRectLike;
|
||||
};
|
||||
|
||||
export type GroupedAssetFolder = EditorAssetFolder & {
|
||||
assets: EditorAsset[];
|
||||
};
|
||||
@@ -38,15 +32,6 @@ function isPointInRect(point: ClientPoint, rect: ClientRectLike) {
|
||||
);
|
||||
}
|
||||
|
||||
function doRectsIntersect(rect: ClientRectLike, selectionRect: ClientRectLike) {
|
||||
return (
|
||||
rect.left <= selectionRect.right &&
|
||||
rect.right >= selectionRect.left &&
|
||||
rect.top <= selectionRect.bottom &&
|
||||
rect.bottom >= selectionRect.top
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveAssetFolderIdFromPoint({
|
||||
point,
|
||||
listRect,
|
||||
@@ -85,98 +70,6 @@ export function resolvePinnedAssetMoveFolderId({
|
||||
return folderId;
|
||||
}
|
||||
|
||||
export function shouldStartAssetMarquee({
|
||||
isAssetSelectionMode,
|
||||
button,
|
||||
isBlockedTarget,
|
||||
}: {
|
||||
isAssetSelectionMode: boolean;
|
||||
button: number;
|
||||
isBlockedTarget: boolean;
|
||||
}) {
|
||||
return isAssetSelectionMode && button === 0 && !isBlockedTarget;
|
||||
}
|
||||
|
||||
export function createAssetMarqueeFromPointer({
|
||||
pointerId,
|
||||
point,
|
||||
containerRect,
|
||||
}: {
|
||||
pointerId: number;
|
||||
point: ClientPoint;
|
||||
containerRect: Pick<DOMRect, 'left' | 'top'> | null | undefined;
|
||||
}): AssetMarqueeState {
|
||||
const startX = point.clientX - (containerRect?.left ?? 0);
|
||||
const startY = point.clientY - (containerRect?.top ?? 0);
|
||||
return {
|
||||
pointerId,
|
||||
startX,
|
||||
startY,
|
||||
currentX: startX,
|
||||
currentY: startY,
|
||||
};
|
||||
}
|
||||
|
||||
export function moveAssetMarqueeToPointer({
|
||||
marquee,
|
||||
point,
|
||||
containerRect,
|
||||
}: {
|
||||
marquee: AssetMarqueeState;
|
||||
point: ClientPoint;
|
||||
containerRect: Pick<DOMRect, 'left' | 'top'> | null | undefined;
|
||||
}) {
|
||||
return {
|
||||
...marquee,
|
||||
currentX: point.clientX - (containerRect?.left ?? 0),
|
||||
currentY: point.clientY - (containerRect?.top ?? 0),
|
||||
};
|
||||
}
|
||||
|
||||
export function createAssetMarqueeSelectionRect({
|
||||
marquee,
|
||||
point,
|
||||
containerRect,
|
||||
}: {
|
||||
marquee: AssetMarqueeState;
|
||||
point: ClientPoint;
|
||||
containerRect: Pick<DOMRect, 'left' | 'top'> | null | undefined;
|
||||
}): ClientRectLike {
|
||||
const startClientX = (containerRect?.left ?? 0) + marquee.startX;
|
||||
const startClientY = (containerRect?.top ?? 0) + marquee.startY;
|
||||
return {
|
||||
left: Math.min(startClientX, point.clientX),
|
||||
right: Math.max(startClientX, point.clientX),
|
||||
top: Math.min(startClientY, point.clientY),
|
||||
bottom: Math.max(startClientY, point.clientY),
|
||||
};
|
||||
}
|
||||
|
||||
export function selectUploadedAssetsInRect({
|
||||
assets,
|
||||
assetTargets,
|
||||
selectionRect,
|
||||
}: {
|
||||
assets: EditorAsset[];
|
||||
assetTargets: AssetHitTarget[];
|
||||
selectionRect: ClientRectLike;
|
||||
}) {
|
||||
const uploadedAssetIds = new Set(
|
||||
assets
|
||||
.filter((asset) => asset.sourceKind === 'uploaded')
|
||||
.map((asset) => asset.id),
|
||||
);
|
||||
return new Set(
|
||||
assetTargets
|
||||
.filter(
|
||||
(target) =>
|
||||
uploadedAssetIds.has(target.assetId) &&
|
||||
doRectsIntersect(target.rect, selectionRect),
|
||||
)
|
||||
.map((target) => target.assetId),
|
||||
);
|
||||
}
|
||||
|
||||
export function groupAssetsByFolder(
|
||||
assetFolders: EditorAssetFolder[],
|
||||
assets: EditorAsset[],
|
||||
@@ -191,14 +84,20 @@ function normalizeSearchQuery(query: string) {
|
||||
return query.trim().toLowerCase();
|
||||
}
|
||||
|
||||
function matchesSearchText(query: string, values: Array<string | null | undefined>) {
|
||||
function matchesSearchText(
|
||||
query: string,
|
||||
values: Array<string | null | undefined>,
|
||||
) {
|
||||
if (!query) {
|
||||
return true;
|
||||
}
|
||||
return values.some((value) => value?.toLowerCase().includes(query));
|
||||
}
|
||||
|
||||
function startsWithSearchText(query: string, values: Array<string | null | undefined>) {
|
||||
function startsWithSearchText(
|
||||
query: string,
|
||||
values: Array<string | null | undefined>,
|
||||
) {
|
||||
if (!query) {
|
||||
return true;
|
||||
}
|
||||
@@ -290,10 +189,6 @@ export function filterGroupedAssetsBySearch(
|
||||
.filter((folder) => folder.assets.length > 0);
|
||||
}
|
||||
|
||||
export function getSelectableAssets(assets: EditorAsset[]) {
|
||||
return assets.filter((asset) => asset.sourceKind === 'uploaded');
|
||||
}
|
||||
|
||||
export type AssetShowcaseSubmissionState = {
|
||||
canSubmit: boolean;
|
||||
label: string;
|
||||
@@ -370,16 +265,6 @@ export function resolveAssetShowcaseSubmissionState(
|
||||
};
|
||||
}
|
||||
|
||||
export function areAllSelectableAssetsSelected(
|
||||
selectableAssets: EditorAsset[],
|
||||
selectedAssetIds: Set<string>,
|
||||
) {
|
||||
return (
|
||||
selectableAssets.length > 0 &&
|
||||
selectableAssets.every((asset) => selectedAssetIds.has(asset.id))
|
||||
);
|
||||
}
|
||||
|
||||
export function renameAssetById(
|
||||
assets: EditorAsset[],
|
||||
assetId: string,
|
||||
@@ -485,7 +370,9 @@ export function renameAssetFolderById(
|
||||
|
||||
export function resolveDefaultAssetFolder(assetFolders: EditorAssetFolder[]) {
|
||||
return (
|
||||
assetFolders.find((folder) => folder.systemDefault) ?? assetFolders[0] ?? null
|
||||
assetFolders.find((folder) => folder.systemDefault) ??
|
||||
assetFolders[0] ??
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
@@ -500,7 +387,10 @@ export function resolveUploadAssetFolder(assetFolders: EditorAssetFolder[]) {
|
||||
export function resolvePreferredAssetUploadFolder(
|
||||
assetFolders: EditorAssetFolder[],
|
||||
) {
|
||||
return resolveUploadAssetFolder(assetFolders) ?? resolveDefaultAssetFolder(assetFolders);
|
||||
return (
|
||||
resolveUploadAssetFolder(assetFolders) ??
|
||||
resolveDefaultAssetFolder(assetFolders)
|
||||
);
|
||||
}
|
||||
|
||||
export function deleteAssetFolderLocally({
|
||||
@@ -527,36 +417,13 @@ export function deleteAssetFolderLocally({
|
||||
};
|
||||
}
|
||||
|
||||
export function toggleAssetSelection(
|
||||
selectedAssetIds: Set<string>,
|
||||
assetId: string,
|
||||
) {
|
||||
const nextIds = new Set(selectedAssetIds);
|
||||
if (nextIds.has(assetId)) {
|
||||
nextIds.delete(assetId);
|
||||
} else {
|
||||
nextIds.add(assetId);
|
||||
}
|
||||
return nextIds;
|
||||
}
|
||||
|
||||
export function resolveAllAssetSelection({
|
||||
allSelectableAssetsSelected,
|
||||
selectableAssets,
|
||||
}: {
|
||||
allSelectableAssetsSelected: boolean;
|
||||
selectableAssets: EditorAsset[];
|
||||
}) {
|
||||
return allSelectableAssetsSelected
|
||||
? new Set<string>()
|
||||
: new Set(selectableAssets.map((asset) => asset.id));
|
||||
}
|
||||
|
||||
export function removeSelectedAssets(
|
||||
assets: EditorAsset[],
|
||||
selectedAssetIds: Set<string>,
|
||||
) {
|
||||
const deletedAssets = assets.filter((asset) => selectedAssetIds.has(asset.id));
|
||||
const deletedAssets = assets.filter((asset) =>
|
||||
selectedAssetIds.has(asset.id),
|
||||
);
|
||||
return {
|
||||
assets: assets.filter((asset) => !selectedAssetIds.has(asset.id)),
|
||||
deletedAssets,
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user