合并远端master更新
Project CI / Repository checks (push) Successful in 51s
Project CI / Frontend tests (push) Successful in 3m7s
Project CI / Backend tests (push) Successful in 3m32s
Project CI / Native shell tests (push) Successful in 16m20s

融合移动端提示恢复与远端钱包刷新改动

# Conflicts:
#	src/components/platform-entry/PlatformEntryActiveFlowShell.test.tsx
#	src/components/platform-entry/PlatformEntryActiveFlowShell.tsx
This commit is contained in:
2026-08-08 15:56:47 +08:00
329 changed files with 48030 additions and 6615 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 MiB

@@ -166,7 +166,14 @@ test('direct account entry does not render the settings shell as another dialog'
renderAccountModal({ entryMode: 'account' });
const accountDialog = screen.getByRole('dialog', { name: '账号信息' });
const accountBody = accountDialog.firstElementChild as HTMLElement | null;
const accountWrapper = accountBody?.firstElementChild as HTMLElement | null;
expect(accountDialog).toBeTruthy();
expect(accountDialog.style.background).toBe('none');
expect(accountDialog.style.borderWidth).toBe('0px');
expect(accountDialog.className).toContain('!overflow-visible');
expect(accountBody?.className).toContain('!overflow-visible');
expect(accountWrapper?.className).toContain('overflow-visible');
expect(screen.queryByRole('dialog', { name: '设置与账号安全' })).toBeNull();
expect(screen.queryByText('设置与账号安全')).toBeNull();
expect(
+11 -3
View File
@@ -518,18 +518,26 @@ export function AccountModal({
authCardClassName={isDirectAccountMode ? '' : undefined}
panelClassName={
isDirectAccountMode
? 'relative !max-w-3xl !rounded-none !bg-transparent !shadow-none'
? 'relative !max-w-3xl !overflow-visible !rounded-none !bg-transparent !shadow-none'
: 'relative !h-[min(100%,calc(100vh-2rem))] !max-w-5xl !rounded-[28px] !p-5 sm:!p-6'
}
bodyClassName="!flex !min-h-0 !flex-1 !overflow-hidden !p-0"
bodyClassName={`!flex !min-h-0 !flex-1 !p-0 ${
isDirectAccountMode ? '!overflow-visible' : '!overflow-hidden'
}`}
panelStyle={{
maxHeight: ACCOUNT_MODAL_MAX_HEIGHT,
...(isDirectAccountMode
? {
background: 'none',
borderWidth: 0,
}
: {}),
}}
>
<div
className={
isDirectAccountMode
? 'relative flex max-h-full w-full max-w-3xl min-h-0 flex-col overflow-hidden'
? 'relative flex max-h-full w-full max-w-3xl min-h-0 flex-col overflow-visible'
: 'relative flex h-full w-full min-h-0 flex-col overflow-hidden'
}
style={{ maxHeight: ACCOUNT_MODAL_MAX_HEIGHT }}
+53 -1
View File
@@ -2,7 +2,7 @@
import { act, render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { useEffect, useState } from 'react';
import { StrictMode, useEffect, useState } from 'react';
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
import type { AuthSessionSummary, AuthUser } from '../../services/authService';
@@ -16,6 +16,26 @@ import { useAuthUi } from './AuthUiContext';
const browserReloadMock = vi.hoisted(() => vi.fn());
const walletLifecycleMocks = vi.hoisted(() => ({
usePlatformWalletLifecycle: vi.fn(),
}));
function createMemoryStorage(): Storage {
const values = new Map<string, string>();
return {
get length() {
return values.size;
},
clear: () => values.clear(),
getItem: (key) => values.get(key) ?? null,
key: (index) => Array.from(values.keys())[index] ?? null,
removeItem: (key) => values.delete(key),
setItem: (key, value) => values.set(key, String(value)),
};
}
const memoryLocalStorage = createMemoryStorage();
const authMocks = vi.hoisted(() => ({
authEntry: vi.fn(),
changePassword: vi.fn(),
@@ -77,6 +97,8 @@ vi.mock('../../services/authService', () => ({
startWechatLogin: authMocks.startWechatLogin,
}));
vi.mock('../../stores/usePlatformWalletStore', () => walletLifecycleMocks);
const hostBridgeMocks = vi.hoisted(() => ({
getHostRuntime: vi.fn(() => ({
kind: 'browser',
@@ -136,6 +158,10 @@ const mockUser: AuthUser = {
beforeEach(() => {
vi.clearAllMocks();
Object.defineProperty(window, 'localStorage', {
configurable: true,
value: memoryLocalStorage,
});
window.localStorage.clear();
window.history.replaceState(null, '', '/');
setAuthGateReloadForTest(vi.fn());
@@ -381,6 +407,32 @@ test('auth gate keeps a valid local token login when refresh rotation fails afte
expect(authMocks.getCurrentAuthUser).toHaveBeenCalledTimes(1);
});
test('auth root binds the single wallet lifecycle under StrictMode', async () => {
authMocks.getStoredAccessToken.mockReturnValue('jwt-existing-token');
authMocks.refreshStoredAccessToken.mockRejectedValue(
new Error('refresh cookie 失效'),
);
authMocks.getCurrentAuthUser.mockResolvedValue({
user: mockUser,
availableLoginMethods: ['phone'],
});
render(
<StrictMode>
<AuthGate>
<LogoutStateProbe />
</AuthGate>
</StrictMode>,
);
expect(await screen.findByText('当前用户:测试玩家')).toBeTruthy();
await waitFor(() => {
expect(
walletLifecycleMocks.usePlatformWalletLifecycle,
).toHaveBeenCalledWith('user-1', true);
});
});
test('auth gate does not auto-create a guest account when dev guest switch is not explicitly enabled', async () => {
authMocks.getAuthLoginOptions.mockResolvedValue({
availableLoginMethods: [],
+24 -20
View File
@@ -50,6 +50,7 @@ import {
reloadHostWebView,
requestHostLogin,
} from '../../services/host-bridge/hostBridge';
import { usePlatformWalletLifecycle } from '../../stores/usePlatformWalletStore';
import { PlatformActionButton } from '../common/PlatformActionButton';
import { AccountModal } from './AccountModal';
import { AuthUiContext, type PlatformSettingsSection } from './AuthUiContext';
@@ -117,10 +118,7 @@ function normalizeAvailableLoginMethods(
// 登录面板的核心入口必须稳定展示,login-options 只补充微信等环境相关入口。
return Array.from(
new Set<AuthLoginMethod>([
...REQUIRED_LOGIN_METHODS,
...normalizedMethods,
]),
new Set<AuthLoginMethod>([...REQUIRED_LOGIN_METHODS, ...normalizedMethods]),
);
}
@@ -192,10 +190,7 @@ export function AuthGate({ children }: AuthGateProps) {
}
const markAuthStateReloadIfChanged = useCallback(
(
nextUser: AuthUser | null,
options: { reloadOnChange?: boolean } = {},
) => {
(nextUser: AuthUser | null, options: { reloadOnChange?: boolean } = {}) => {
const nextHasUser = Boolean(nextUser);
const previousHasUser = lastStableAuthPresenceRef.current;
if (previousHasUser === null) {
@@ -204,23 +199,23 @@ export function AuthGate({ children }: AuthGateProps) {
}
lastStableAuthPresenceRef.current = nextHasUser;
if (
previousHasUser !== nextHasUser &&
options.reloadOnChange !== false
) {
if (previousHasUser !== nextHasUser && options.reloadOnChange !== false) {
pendingAuthStateReloadRef.current = true;
}
},
[],
);
const activateReadyUser = useCallback((nextUser: AuthUser) => {
// 受保护业务 hook 只在 readyUser 暴露后启动,必须先保证请求层能带 Bearer token。
authHydrateVersionRef.current += 1;
markAuthStateReloadIfChanged(nextUser);
setUser(nextUser);
setStatus('ready');
}, [markAuthStateReloadIfChanged]);
const activateReadyUser = useCallback(
(nextUser: AuthUser) => {
// 受保护业务 hook 只在 readyUser 暴露后启动,必须先保证请求层能带 Bearer token。
authHydrateVersionRef.current += 1;
markAuthStateReloadIfChanged(nextUser);
setUser(nextUser);
setStatus('ready');
},
[markAuthStateReloadIfChanged],
);
const clearLocalAuthenticatedState = useCallback(
(options: { reloadOnChange?: boolean } = {}) => {
@@ -687,6 +682,11 @@ export function AuthGate({ children }: AuthGateProps) {
],
);
usePlatformWalletLifecycle(
readyUser?.id ?? null,
status === 'ready' && Boolean(readyUser),
);
if (status === 'checking' && !canKeepPlatformContentMounted) {
return (
<div
@@ -969,7 +969,11 @@ export function AuthGate({ children }: AuthGateProps) {
const registrationInviteCode =
pendingInviteCode || readInviteCodeFromLocation();
const response = registrationInviteCode
? await loginWithPhoneCode(phone, code, registrationInviteCode)
? await loginWithPhoneCode(
phone,
code,
registrationInviteCode,
)
: await loginWithPhoneCode(phone, code);
const autoRedeemedInvite = response.referral?.ok === true;
setStoredLastLoginPhone(phone);
@@ -0,0 +1,47 @@
import { act } from '@testing-library/react';
import {
$createLineBreakNode,
$createParagraphNode,
$createTextNode,
$getRoot,
getNearestEditorFromDOMNode,
} from 'lexical';
export function setPlainTextEditorValue(
contentEditable: HTMLElement,
value: string,
) {
const editor = getNearestEditorFromDOMNode(contentEditable);
if (!editor) {
throw new Error('Expected a Lexical editor root');
}
act(() => {
editor.update(
() => {
const paragraph = $createParagraphNode();
value.split('\n').forEach((line, index, lines) => {
if (line) {
paragraph.append($createTextNode(line));
}
if (index < lines.length - 1) {
paragraph.append($createLineBreakNode());
}
});
$getRoot().clear().append(paragraph);
paragraph.selectEnd();
},
{ discrete: true },
);
});
}
export function getPlainTextEditorHost(contentEditable: HTMLElement) {
const host = contentEditable.closest<HTMLElement>(
'.auto-grow-text-area',
);
if (!host) {
throw new Error('Expected an auto-grow plain-text editor host');
}
return host;
}
+145 -258
View File
@@ -1,288 +1,175 @@
/* @vitest-environment jsdom */
import { act, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { useState } from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { AutoGrowTextArea } from './AutoGrowTextArea';
afterEach(() => {
vi.unstubAllGlobals();
beforeEach(() => {
Range.prototype.getBoundingClientRect = vi.fn(() => new DOMRect());
Range.prototype.getClientRects = vi.fn(() => [] as unknown as DOMRectList);
vi.stubGlobal(
'ResizeObserver',
class ResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
},
);
vi.stubGlobal(
'ClipboardEvent',
class ClipboardEvent extends Event {
clipboardData: DataTransfer;
constructor(
type: string,
init: EventInit & { clipboardData: DataTransfer },
) {
super(type, init);
this.clipboardData = init.clipboardData;
}
},
);
});
function pastePlainText(editor: HTMLElement, value: string) {
const clipboardData = {
files: [],
getData: (type: string) => (type === 'text/plain' ? value : ''),
items: [],
types: ['text/plain'],
} as unknown as DataTransfer;
fireEvent(
editor,
new ClipboardEvent('paste', {
bubbles: true,
cancelable: true,
clipboardData,
}),
);
}
function ControlledEditor({
initialValue = '',
maxLength,
}: {
initialValue?: string;
maxLength?: number;
}) {
const [value, setValue] = useState(initialValue);
return (
<>
<AutoGrowTextArea
aria-label="受控编辑器"
value={value}
maxLength={maxLength}
onValueChange={setValue}
/>
<output aria-label="React 文本状态">{value}</output>
<button type="button" onClick={() => setValue('外部\n更新')}>
外部更新
</button>
</>
);
}
describe('AutoGrowTextArea', () => {
it('uses native field sizing when the browser supports it', () => {
const supports = vi.fn().mockReturnValue(true);
const resizeObserver = vi.fn();
const onChange = vi.fn();
vi.stubGlobal('CSS', { supports });
vi.stubGlobal('ResizeObserver', resizeObserver);
it('keeps React state authoritative and preserves exact line breaks', async () => {
const user = userEvent.setup();
render(<ControlledEditor initialValue={'第一行\n第二行'} />);
render(
<AutoGrowTextArea
aria-label="自动增长输入框"
value=""
onChange={onChange}
/>,
const editor = screen.getByRole('textbox', { name: '受控编辑器' });
expect(screen.getByLabelText('React 文本状态').textContent).toBe(
'第一行\n第二行',
);
const input = screen.getByLabelText(
'自动增长输入框',
) as HTMLTextAreaElement;
expect(supports).toHaveBeenCalledWith('field-sizing', 'content');
expect(resizeObserver).not.toHaveBeenCalled();
expect(input.getAttribute('rows')).toBe('1');
expect(input.className).toContain('auto-grow-text-area');
expect(input.className).toContain('[field-sizing:content]');
expect(input.className).toContain('min-h-10');
expect(input.className).toContain('max-h-32');
expect(input.className).toContain('overflow-y-auto');
expect(input.className).toContain('disabled:cursor-not-allowed');
expect(input.className).toContain('disabled:opacity-60');
expect(input.style.height).toBe('');
fireEvent.change(input, { target: { value: '新内容' } });
expect(onChange).toHaveBeenCalledTimes(1);
await user.click(screen.getByRole('button', { name: '外部更新' }));
expect(screen.getByLabelText('React 文本状态').textContent).toBe(
'外部\n更新',
);
});
it('falls back to measured sizing, preserves css limits and remeasures width changes', () => {
let resizeObserverCallback!: ResizeObserverCallback;
const observe = vi.fn();
const disconnect = vi.fn();
vi.stubGlobal('CSS', { supports: vi.fn().mockReturnValue(false) });
vi.stubGlobal(
'ResizeObserver',
vi.fn().mockImplementation((callback: ResizeObserverCallback) => {
resizeObserverCallback = callback;
return {
observe,
unobserve: vi.fn(),
disconnect,
};
}),
it('publishes edits through onValueChange and enforces maxLength', async () => {
const user = userEvent.setup();
render(<ControlledEditor maxLength={4} />);
const editor = screen.getByRole('textbox', { name: '受控编辑器' });
await user.click(editor);
pastePlainText(editor, '12345');
await waitFor(() =>
expect(screen.getByLabelText('React 文本状态').textContent).toBe('1234'),
);
expect(editor.textContent).toBe('1234');
});
it('supports undo and redo while keeping React state synchronized', async () => {
const user = userEvent.setup();
render(<ControlledEditor />);
const editor = screen.getByRole('textbox', { name: '受控编辑器' });
await user.click(editor);
pastePlainText(editor, '草稿');
await waitFor(() =>
expect(screen.getByLabelText('React 文本状态').textContent).toBe('草稿'),
);
const { rerender, unmount } = render(
await user.keyboard('{Control>}z{/Control}');
expect(screen.getByLabelText('React 文本状态').textContent).toBe('');
await user.keyboard('{Control>}{Shift>}z{/Shift}{/Control}');
expect(screen.getByLabelText('React 文本状态').textContent).toBe('草稿');
});
it('exposes read-only and disabled accessibility without form controls', () => {
const { container } = render(
<AutoGrowTextArea
aria-label="自动增长输入框"
value=""
style={{
boxSizing: 'border-box',
minHeight: '80px',
maxHeight: '128px',
}}
onChange={vi.fn()}
aria-label="只读编辑器"
value="只读内容"
disabled
readOnly
onValueChange={vi.fn()}
/>,
);
const input = screen.getByLabelText(
'自动增长输入框',
) as HTMLTextAreaElement;
expect(observe).toHaveBeenCalledWith(input);
let contentHeight = 92;
Object.defineProperty(input, 'offsetHeight', {
configurable: true,
get: () => {
const styleHeight = Number.parseFloat(input.style.height);
return Number.isNaN(styleHeight) ? 82 : styleHeight;
},
});
Object.defineProperty(input, 'clientHeight', {
configurable: true,
get: () => input.offsetHeight - 2,
});
Object.defineProperty(input, 'scrollHeight', {
configurable: true,
get: () => contentHeight,
});
rerender(
const editor = screen.getByRole('textbox', { name: '只读编辑器' });
expect(editor.getAttribute('aria-disabled')).toBe('true');
expect(editor.getAttribute('aria-readonly')).toBe('true');
expect(editor.getAttribute('tabindex')).toBe('-1');
expect(container.querySelector('textarea')).toBeNull();
expect(container.querySelector('form')).toBeNull();
});
it('uses a distinct internal viewport for overflow content', async () => {
const { container } = render(
<AutoGrowTextArea
aria-label="自动增长输入框"
value="第一行\n第二行\n第三行\n第四行\n第五行"
style={{
boxSizing: 'border-box',
minHeight: '80px',
maxHeight: '128px',
}}
onChange={vi.fn()}
aria-label="滚动编辑器"
value={Array.from({ length: 20 }, (_, index) => `第 ${index + 1} 行`).join(
'\n',
)}
onValueChange={vi.fn()}
/>,
);
expect(input.style.height).toBe('94px');
expect(input.style.overflowY).toBe('hidden');
contentHeight = 180;
act(() => {
resizeObserverCallback(
[
{
target: input,
contentRect: { width: 180 },
} as unknown as ResizeObserverEntry,
],
{} as ResizeObserver,
const viewport = await waitFor(() => {
const element = container.querySelector<HTMLElement>(
'[data-overlayscrollbars-viewport]',
);
expect(element).not.toBeNull();
return element!;
});
expect(input.style.height).toBe('128px');
expect(input.style.overflowY).toBe('auto');
contentHeight = 30;
rerender(
<AutoGrowTextArea
aria-label="自动增长输入框"
value="短内容"
style={{
boxSizing: 'border-box',
minHeight: '80px',
maxHeight: '128px',
}}
onChange={vi.fn()}
/>,
const content = container.querySelector<HTMLElement>(
'[data-overlayscrollbars-content]',
);
expect(input.style.height).toBe('80px');
expect(input.style.overflowY).toBe('hidden');
unmount();
expect(disconnect).toHaveBeenCalledTimes(1);
expect(viewport).not.toBe(content);
expect(
viewport.contains(
screen.getByRole('textbox', { name: '滚动编辑器' }),
),
).toBe(true);
});
it('remeasures uncontrolled input changes and forwards the consumer input handler', () => {
const onInput = vi.fn();
vi.stubGlobal('CSS', { supports: vi.fn().mockReturnValue(false) });
vi.stubGlobal(
'ResizeObserver',
vi.fn().mockImplementation(() => ({
observe: vi.fn(),
unobserve: vi.fn(),
disconnect: vi.fn(),
})),
);
render(
<AutoGrowTextArea
aria-label="非受控自动增长输入框"
defaultValue="初始内容"
style={{
boxSizing: 'border-box',
minHeight: '40px',
maxHeight: '128px',
}}
onInput={onInput}
/>,
);
const input = screen.getByLabelText(
'非受控自动增长输入框',
) as HTMLTextAreaElement;
let contentHeight = 90;
Object.defineProperty(input, 'offsetHeight', {
configurable: true,
get: () => {
const styleHeight = Number.parseFloat(input.style.height);
return Number.isNaN(styleHeight) ? 42 : styleHeight;
},
});
Object.defineProperty(input, 'clientHeight', {
configurable: true,
get: () => input.offsetHeight - 2,
});
Object.defineProperty(input, 'scrollHeight', {
configurable: true,
get: () => contentHeight,
});
fireEvent.input(input, {
target: { value: '第一行\n第二行\n第三行\n第四行' },
});
expect(input.style.height).toBe('92px');
expect(input.style.overflowY).toBe('hidden');
contentHeight = 180;
fireEvent.input(input, {
target: { value: '很多行内容'.repeat(30) },
});
expect(input.style.height).toBe('128px');
expect(input.style.overflowY).toBe('auto');
contentHeight = 20;
fireEvent.input(input, { target: { value: '短内容' } });
expect(input.style.height).toBe('40px');
expect(input.style.overflowY).toBe('hidden');
expect(onInput).toHaveBeenCalledTimes(3);
});
it('measures content-box height without double-counting padding or borders', () => {
vi.stubGlobal('CSS', { supports: vi.fn().mockReturnValue(false) });
vi.stubGlobal(
'ResizeObserver',
vi.fn().mockImplementation(() => ({
observe: vi.fn(),
unobserve: vi.fn(),
disconnect: vi.fn(),
})),
);
const { rerender } = render(
<AutoGrowTextArea
aria-label="内容盒输入框"
value=""
style={{
boxSizing: 'content-box',
minHeight: '40px',
maxHeight: '128px',
paddingTop: '10px',
paddingBottom: '6px',
}}
onChange={vi.fn()}
/>,
);
const input = screen.getByLabelText(
'内容盒输入框',
) as HTMLTextAreaElement;
let contentHeight = 120;
Object.defineProperty(input, 'offsetHeight', {
configurable: true,
get: () => 122,
});
Object.defineProperty(input, 'clientHeight', {
configurable: true,
get: () => 120,
});
Object.defineProperty(input, 'scrollHeight', {
configurable: true,
get: () => contentHeight,
});
rerender(
<AutoGrowTextArea
aria-label="内容盒输入框"
value="多行内容"
style={{
boxSizing: 'content-box',
minHeight: '40px',
maxHeight: '128px',
paddingTop: '10px',
paddingBottom: '6px',
}}
onChange={vi.fn()}
/>,
);
expect(input.style.height).toBe('104px');
expect(input.style.overflowY).toBe('hidden');
contentHeight = 160;
rerender(
<AutoGrowTextArea
aria-label="内容盒输入框"
value="更多多行内容"
style={{
boxSizing: 'content-box',
minHeight: '40px',
maxHeight: '128px',
paddingTop: '10px',
paddingBottom: '6px',
}}
onChange={vi.fn()}
/>,
);
expect(input.style.height).toBe('128px');
expect(input.style.overflowY).toBe('auto');
});
});
File diff suppressed because it is too large Load Diff
@@ -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;
}
@@ -16,6 +16,7 @@ import type {
EditorAgentMessage,
EditorAgentMessageResponse,
} from '@/packages/shared/src/contracts';
import { setPlainTextEditorValue } from '@/src/components/common/AutoGrowTextArea.test-utils.ts';
import type { EditorAgentConversationClient } from '@/src/components/image-editor/EditorAgentConversation/useEditorAgentConversation.ts';
import { EDITOR_AGENT_PATIENCE_NOTICE_DELAY_MS } from '@/src/components/image-editor/EditorAgentConversation/useEditorAgentConversation.ts';
import { ImageCanvasActionsProvider } from '@/src/components/image-editor/ImageCanvasActionsProvider.tsx';
@@ -135,9 +136,10 @@ function createClient(): EditorAgentConversationClient {
}
function enterAttachmentPrompt() {
fireEvent.change(screen.getByLabelText('发送给画布 Agent'), {
target: { value: ATTACHMENT_PROMPT },
});
setPlainTextEditorValue(
screen.getByLabelText('发送给画布 Agent'),
ATTACHMENT_PROMPT,
);
}
afterEach(() => {
@@ -294,9 +296,10 @@ describe('EditorAgentConversationPanelView', () => {
name: '新建对话',
}) as HTMLButtonElement;
expect(newConversationButton.disabled).toBe(false);
fireEvent.change(screen.getByLabelText('发送给画布 Agent'), {
target: { value: '应保留到新会话的草稿' },
});
setPlainTextEditorValue(
screen.getByLabelText('发送给画布 Agent'),
'应保留到新会话的草稿',
);
fireEvent.click(screen.getByRole('button', { name: '添加附件' }));
const attachmentDialog = screen.getByRole('dialog', {
name: '选择图片附件',
@@ -332,25 +335,27 @@ describe('EditorAgentConversationPanelView', () => {
).toBe('conversation-2');
expect(newConversationButton.disabled).toBe(true);
});
expect(
(screen.getByLabelText('发送给画布 Agent') as HTMLTextAreaElement).value,
).toBe('应保留到新会话的草稿');
expect(screen.getByLabelText('发送给画布 Agent').textContent).toBe(
'应保留到新会话的草稿',
);
expect(
screen.getByText('一二三四五六七八九十甲乙丙丁戊己庚辛壬癸子丑寅卯'),
).toBeTruthy();
expect(screen.getByRole('option', { name: '角色参考' })).toBeTruthy();
expect(screen.getByRole('option', { name: '新对话' })).toBeTruthy();
fireEvent.change(screen.getByLabelText('发送给画布 Agent'), {
target: { value: '尚未发送的草稿' },
});
setPlainTextEditorValue(
screen.getByLabelText('发送给画布 Agent'),
'尚未发送的草稿',
);
expect(newConversationButton.disabled).toBe(true);
fireEvent.click(newConversationButton);
expect(client.createConversation).toHaveBeenCalledTimes(1);
fireEvent.change(screen.getByLabelText('发送给画布 Agent'), {
target: { value: '参考附件做像素风' },
});
setPlainTextEditorValue(
screen.getByLabelText('发送给画布 Agent'),
'参考附件做像素风',
);
fireEvent.click(screen.getByRole('button', { name: '发送' }));
await waitFor(() => {
@@ -491,9 +496,10 @@ describe('EditorAgentConversationPanelView', () => {
}) as HTMLButtonElement;
expect(newConversationButton.disabled).toBe(true);
fireEvent.change(screen.getByLabelText('发送给画布 Agent'), {
target: { value: '未发送内容' },
});
setPlainTextEditorValue(
screen.getByLabelText('发送给画布 Agent'),
'未发送内容',
);
expect(newConversationButton.disabled).toBe(true);
fireEvent.click(newConversationButton);
expect(client.createConversation).not.toHaveBeenCalled();
@@ -516,17 +522,18 @@ describe('EditorAgentConversationPanelView', () => {
await waitFor(() => {
expect(screen.getByText('已经看到画布内容')).toBeTruthy();
});
fireEvent.change(screen.getByLabelText('发送给画布 Agent'), {
target: { value: '需要保留的草稿' },
});
setPlainTextEditorValue(
screen.getByLabelText('发送给画布 Agent'),
'需要保留的草稿',
);
fireEvent.click(screen.getByRole('button', { name: '新建对话' }));
await waitFor(() => {
expect(screen.getByText('创建新会话失败')).toBeTruthy();
});
expect(
(screen.getByLabelText('发送给画布 Agent') as HTMLTextAreaElement).value,
).toBe('需要保留的草稿');
expect(screen.getByLabelText('发送给画布 Agent').textContent).toBe(
'需要保留的草稿',
);
expect(screen.getByText('已经看到画布内容')).toBeTruthy();
expect((screen.getByLabelText('当前对话') as HTMLSelectElement).value).toBe(
'conversation-1',
@@ -556,15 +563,13 @@ describe('EditorAgentConversationPanelView', () => {
});
const draftInput = screen.getByLabelText(
'发送给画布 Agent',
) as HTMLTextAreaElement;
fireEvent.change(draftInput, {
target: { value: '旧项目草稿' },
});
) as HTMLElement;
setPlainTextEditorValue(draftInput, '旧项目草稿');
fireEvent.click(screen.getByRole('button', { name: '新建对话' }));
await waitFor(() => {
expect(client.createConversation).toHaveBeenCalledWith('project-1', {});
expect(draftInput.disabled).toBe(false);
expect(draftInput.getAttribute('aria-disabled')).toBeNull();
});
const conversationSelect = screen.getByLabelText(
'当前对话',
@@ -578,12 +583,10 @@ describe('EditorAgentConversationPanelView', () => {
(screen.getByRole('button', { name: '添加附件' }) as HTMLButtonElement)
.disabled,
).toBe(false);
fireEvent.change(draftInput, {
target: { value: '创建期间更新的草稿' },
});
fireEvent.submit(draftInput.closest('form') as HTMLFormElement);
setPlainTextEditorValue(draftInput, '创建期间更新的草稿');
fireEvent.click(sendButton);
expect(client.sendMessage).not.toHaveBeenCalled();
expect(draftInput.value).toBe('创建期间更新的草稿');
expect(draftInput.textContent).toBe('创建期间更新的草稿');
await act(async () => {
resolveCreate({
@@ -596,7 +599,7 @@ describe('EditorAgentConversationPanelView', () => {
});
await Promise.resolve();
});
expect(draftInput.value).toBe('创建期间更新的草稿');
expect(draftInput.textContent).toBe('创建期间更新的草稿');
expect(conversationSelect.value).toBe('conversation-2');
expect(conversationSelect.disabled).toBe(false);
expect(sendButton.disabled).toBe(false);
@@ -659,13 +662,11 @@ describe('EditorAgentConversationPanelView', () => {
});
const draftInput = screen.getByLabelText(
'发送给画布 Agent',
) as HTMLTextAreaElement;
) as HTMLElement;
const conversationSelect = screen.getByLabelText(
'当前对话',
) as HTMLSelectElement;
fireEvent.change(draftInput, {
target: { value: '切换期间必须保留的草稿' },
});
setPlainTextEditorValue(draftInput, '切换期间必须保留的草稿');
fireEvent.change(conversationSelect, {
target: { value: 'conversation-2' },
});
@@ -677,9 +678,9 @@ describe('EditorAgentConversationPanelView', () => {
expect(client.getConversation).toHaveBeenCalledWith('conversation-2');
expect(sendButton.disabled).toBe(true);
});
fireEvent.submit(draftInput.closest('form') as HTMLFormElement);
fireEvent.click(sendButton);
expect(client.sendMessage).not.toHaveBeenCalled();
expect(draftInput.value).toBe('切换期间必须保留的草稿');
expect(draftInput.textContent).toBe('切换期间必须保留的草稿');
await act(async () => {
resolveConversationLoad({
@@ -702,7 +703,7 @@ describe('EditorAgentConversationPanelView', () => {
await Promise.resolve();
});
expect(await screen.findByText('背景会话内容')).toBeTruthy();
expect(draftInput.value).toBe('切换期间必须保留的草稿');
expect(draftInput.textContent).toBe('切换期间必须保留的草稿');
expect(sendButton.disabled).toBe(false);
});
@@ -729,9 +730,10 @@ describe('EditorAgentConversationPanelView', () => {
});
vi.useFakeTimers();
fireEvent.change(screen.getByLabelText('发送给画布 Agent'), {
target: { value: '继续规划' },
});
setPlainTextEditorValue(
screen.getByLabelText('发送给画布 Agent'),
'继续规划',
);
fireEvent.click(screen.getByRole('button', { name: '发送' }));
await act(async () => {
@@ -1316,7 +1318,7 @@ describe('EditorAgentConversationPanelView', () => {
}) as HTMLButtonElement;
expect(sendButton.disabled).toBe(true);
fireEvent.submit(sendButton.closest('form')!);
fireEvent.click(sendButton);
expect(client.sendMessage).not.toHaveBeenCalled();
expect(screen.getByText('角色图层')).toBeTruthy();
@@ -1368,15 +1370,18 @@ describe('EditorAgentConversationPanelView', () => {
fireEvent.click(
within(attachmentDialog).getByRole('button', { name: '应用' }),
);
fireEvent.change(screen.getByLabelText('发送给画布 Agent'), {
target: { value: '失败后恢复这条草稿' },
});
setPlainTextEditorValue(
screen.getByLabelText('发送给画布 Agent'),
'失败后恢复这条草稿',
);
fireEvent.click(screen.getByRole('button', { name: '发送' }));
expect(await screen.findByText('Network error')).toBeTruthy();
expect(
(screen.getByLabelText('发送给画布 Agent') as HTMLTextAreaElement).value,
).toBe('失败后恢复这条草稿');
await waitFor(() =>
expect(screen.getByLabelText('发送给画布 Agent').textContent).toBe(
'失败后恢复这条草稿',
),
);
expect(screen.getByText('角色图层')).toBeTruthy();
expect(
within(
@@ -1760,9 +1765,10 @@ describe('EditorAgentConversationPanelView', () => {
expect(screen.getByText('已经看到画布内容')).toBeTruthy();
});
fireEvent.change(screen.getByLabelText('发送给画布 Agent'), {
target: { value: '生成一张图片' },
});
setPlainTextEditorValue(
screen.getByLabelText('发送给画布 Agent'),
'生成一张图片',
);
fireEvent.click(screen.getByRole('button', { name: '发送' }));
await waitFor(() => {
@@ -10,7 +10,6 @@ import {
X,
} from 'lucide-react';
import {
type FormEvent,
useEffect,
useState,
type WheelEvent as ReactWheelEvent,
@@ -139,8 +138,7 @@ export function EditorAgentConversationPanelView({
void createConversation().catch(() => undefined);
};
const submitMessage = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const submitMessage = () => {
if (isMessageSubmissionBlocked) {
return;
}
@@ -328,10 +326,7 @@ export function EditorAgentConversationPanelView({
{attachmentError}
</div>
) : null}
<form
className="border-t border-slate-200 bg-white/95 p-3"
onSubmit={submitMessage}
>
<div className="border-t border-slate-200 bg-white/95 p-3">
{attachments.length ? (
<div className="mb-2 flex flex-wrap gap-1.5">
{attachments.map((attachment) => (
@@ -354,19 +349,21 @@ export function EditorAgentConversationPanelView({
</button>
<EditorAgentDraftTextarea
value={draftText}
onChange={setDraftText}
onValueChange={setDraftText}
onPaste={handleInputPaste}
onSubmitRequest={submitMessage}
/>
<button
type="submit"
type="button"
className="inline-flex h-10 min-w-16 shrink-0 items-center justify-center gap-1.5 rounded-full bg-slate-900 px-3 text-sm font-semibold text-white disabled:opacity-45"
disabled={isMessageSubmissionBlocked}
onClick={submitMessage}
>
<Send className="h-3.5 w-3.5" aria-hidden="true" />
发送
</button>
</div>
</form>
</div>
</aside>
<AttachmentPicker
open={attachmentPickerOpen}
@@ -1,60 +1,86 @@
/* @vitest-environment jsdom */
import { fireEvent, render, screen } from '@testing-library/react';
import type { FormEvent } from 'react';
import { describe, expect, it, vi } from 'vitest';
import userEvent from '@testing-library/user-event';
import { useState } from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { EditorAgentDraftTextarea } from './EditorAgentDraftTextarea.tsx';
beforeEach(() => {
vi.stubGlobal(
'ResizeObserver',
class ResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
},
);
});
function DraftHarness({
onPaste,
onSubmitRequest,
}: {
onPaste?: (event: ClipboardEvent) => void;
onSubmitRequest: () => void;
}) {
const [value, setValue] = useState('');
return (
<EditorAgentDraftTextarea
value={value}
onValueChange={setValue}
onPaste={onPaste}
onSubmitRequest={onSubmitRequest}
/>
);
}
describe('EditorAgentDraftTextarea', () => {
it('forwards draft changes and paste events through the Agent wrapper', () => {
const onChange = vi.fn();
const onPaste = vi.fn();
it('forwards controlled draft edits and paste events', async () => {
const user = userEvent.setup();
const onPaste = vi.fn((event: ClipboardEvent) => event.preventDefault());
render(<DraftHarness onPaste={onPaste} onSubmitRequest={vi.fn()} />);
render(
<EditorAgentDraftTextarea
value=""
onChange={onChange}
onPaste={onPaste}
/>,
);
const input = screen.getByLabelText(
'发送给画布 Agent',
) as HTMLTextAreaElement;
expect(input.className).toContain('auto-grow-text-area');
expect(input.className).toContain(
'editor-agent-conversation__draft-input',
);
fireEvent.change(input, { target: { value: '新草稿' } });
expect(onChange).toHaveBeenCalledWith('新草稿');
fireEvent.paste(input);
const editor = screen.getByRole('textbox', {
name: '发送给画布 Agent',
});
await user.click(editor);
fireEvent.paste(editor, {
clipboardData: {
files: [],
getData: () => '',
items: [],
types: [],
},
});
expect(onPaste).toHaveBeenCalledTimes(1);
});
it('submits on Enter and keeps line breaks or IME confirmation from submitting', () => {
const onSubmit = vi.fn((event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
});
render(
<form onSubmit={onSubmit}>
<EditorAgentDraftTextarea value="草稿" onChange={vi.fn()} />
</form>,
it('submits on Enter but preserves Shift+Enter and IME confirmation', async () => {
const user = userEvent.setup();
const onSubmitRequest = vi.fn();
const { container } = render(
<DraftHarness onSubmitRequest={onSubmitRequest} />,
);
const input = screen.getByLabelText('发送给画布 Agent');
fireEvent.keyDown(input, { key: 'Enter', shiftKey: true });
expect(onSubmit).not.toHaveBeenCalled();
const editor = screen.getByRole('textbox', {
name: '发送给画布 Agent',
});
await user.click(editor);
fireEvent.keyDown(editor, { key: 'Enter', shiftKey: true });
expect(onSubmitRequest).not.toHaveBeenCalled();
fireEvent.keyDown(input, {
fireEvent.keyDown(editor, {
key: 'Enter',
isComposing: false,
isComposing: true,
keyCode: 229,
});
expect(onSubmit).not.toHaveBeenCalled();
expect(onSubmitRequest).not.toHaveBeenCalled();
fireEvent.keyDown(input, { key: 'Enter' });
expect(onSubmit).toHaveBeenCalledTimes(1);
fireEvent.keyDown(editor, { key: 'Enter' });
expect(onSubmitRequest).toHaveBeenCalledTimes(1);
expect(container.querySelector('form')).toBeNull();
expect(container.querySelector('textarea')).toBeNull();
});
});
@@ -1,39 +1,24 @@
import {
type ClipboardEventHandler,
type KeyboardEvent,
} from 'react';
import { AutoGrowTextArea } from '@/src/components/common/AutoGrowTextArea';
export function EditorAgentDraftTextarea({
value,
onChange,
onValueChange,
onPaste,
onSubmitRequest,
}: {
value: string;
onChange: (value: string) => void;
onPaste?: ClipboardEventHandler<HTMLTextAreaElement>;
onValueChange: (value: string) => void;
onPaste?: (event: ClipboardEvent) => void;
onSubmitRequest: () => void;
}) {
const handleKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
if (
event.key === 'Enter' &&
!event.shiftKey &&
!event.nativeEvent.isComposing &&
event.nativeEvent.keyCode !== 229
) {
event.preventDefault();
event.currentTarget.form?.requestSubmit();
}
};
return (
<AutoGrowTextArea
className="editor-agent-conversation__draft-input min-w-0 flex-1 rounded-3xl border border-slate-200 bg-slate-50 px-3 py-2 text-sm text-slate-800 outline-none focus:border-slate-400"
className="editor-agent-conversation__draft-input min-w-0 flex-1 border border-slate-200 bg-slate-50 text-sm text-slate-800 outline-none focus-within:border-slate-400"
aria-label="发送给画布 Agent"
value={value}
onChange={(event) => onChange(event.currentTarget.value)}
onValueChange={onValueChange}
onPaste={onPaste}
onKeyDown={handleKeyDown}
onSubmitRequest={onSubmitRequest}
/>
);
}
@@ -1,5 +1,4 @@
import {
type ClipboardEvent as ReactClipboardEvent,
useCallback,
useMemo,
useRef,
@@ -305,7 +304,7 @@ export function useConversationAttachments({
);
const handleInputPaste = useCallback(
(event: ReactClipboardEvent<HTMLTextAreaElement>) => {
(event: ClipboardEvent) => {
const imageFiles = extractClipboardImageFiles(event.clipboardData);
if (!imageFiles.length || isPastingAttachmentRef.current) {
return;
@@ -29,6 +29,7 @@ import type {
EditorAssetFolder,
UploadTarget,
} from './ImageCanvasEditorTypes';
import type { AssetSelectionInteraction } from './useImageCanvasAssetSelection';
type ImageCanvasAssetFolderSectionViewProps = {
folder: GroupedEditorAssetFolder;
@@ -36,6 +37,7 @@ type ImageCanvasAssetFolderSectionViewProps = {
suppressAssetClickRef: { current: boolean };
isAssetSelectionMode: boolean;
selectedAssetIds: Set<string>;
visibleAssetIds: string[];
assetMoveDropFolderId: string | null;
renamingFolder: { folderId: string; value: string } | null;
renamingAsset: { assetId: string; value: string } | null;
@@ -48,9 +50,11 @@ type ImageCanvasAssetFolderSectionViewProps = {
setActiveUploadFolderId: Dispatch<SetStateAction<string>>;
setUploadDropTarget: Dispatch<SetStateAction<'canvas' | 'assets' | null>>;
setAssetPointerDrag: Dispatch<SetStateAction<AssetPointerDragState | null>>;
setSelectedAssetIds: Dispatch<SetStateAction<Set<string>>>;
updateAssetMoveDropFolder: (folderId: string | null) => void;
addUploadedFiles: (files: FileList | File[], options?: UploadFilesOptions) => void;
addUploadedFiles: (
files: FileList | File[],
options?: UploadFilesOptions,
) => void;
requestUpload: (target: UploadTarget, options?: UploadRequestOptions) => void;
moveAssetToFolder: (assetId: string, folderId: string) => void;
toggleAssetFolder: (folderId: string) => void;
@@ -61,7 +65,10 @@ type ImageCanvasAssetFolderSectionViewProps = {
commitAssetRename: (asset: EditorAsset) => void;
deleteUploadedAsset: (asset: EditorAsset) => void;
submitAssetToShowcase: (asset: EditorAsset) => void;
toggleAssetSelected: (assetId: string) => void;
toggleAssetSelected: (
assetId: string,
interaction: AssetSelectionInteraction,
) => void;
addAssetLayer: (asset: EditorAsset) => void;
onDownloadAsset: (asset: EditorAsset) => void;
};
@@ -72,6 +79,7 @@ export function ImageCanvasAssetFolderSectionView({
suppressAssetClickRef,
isAssetSelectionMode,
selectedAssetIds,
visibleAssetIds,
assetMoveDropFolderId,
renamingFolder,
renamingAsset,
@@ -80,7 +88,6 @@ export function ImageCanvasAssetFolderSectionView({
setActiveUploadFolderId,
setUploadDropTarget,
setAssetPointerDrag,
setSelectedAssetIds,
updateAssetMoveDropFolder,
addUploadedFiles,
requestUpload,
@@ -110,6 +117,17 @@ export function ImageCanvasAssetFolderSectionView({
aria-label={folder.label}
data-asset-folder-id={folder.id}
onDragOver={(event) => {
if (isAssetSelectionMode) {
if (
hasDataTransferType(event.dataTransfer, ASSET_DRAG_MIME_TYPE) ||
hasDataTransferType(event.dataTransfer, 'Files')
) {
event.preventDefault();
event.stopPropagation();
event.dataTransfer.dropEffect = 'none';
}
return;
}
if (hasDataTransferType(event.dataTransfer, ASSET_DRAG_MIME_TYPE)) {
event.preventDefault();
event.stopPropagation();
@@ -126,6 +144,18 @@ export function ImageCanvasAssetFolderSectionView({
}
}}
onDrop={(event) => {
if (isAssetSelectionMode) {
if (
getDraggedAssetId(event.dataTransfer) ||
event.dataTransfer.files.length > 0
) {
event.preventDefault();
event.stopPropagation();
setUploadDropTarget(null);
updateAssetMoveDropFolder(null);
}
return;
}
const movingAssetId = getDraggedAssetId(event.dataTransfer);
if (movingAssetId) {
event.preventDefault();
@@ -241,11 +271,11 @@ export function ImageCanvasAssetFolderSectionView({
suppressAssetClickRef={suppressAssetClickRef}
isAssetSelectionMode={isAssetSelectionMode}
selectedAssetIds={selectedAssetIds}
visibleAssetIds={visibleAssetIds}
renamingAsset={renamingAsset}
setRenamingAsset={setRenamingAsset}
setUploadDropTarget={setUploadDropTarget}
setAssetPointerDrag={setAssetPointerDrag}
setSelectedAssetIds={setSelectedAssetIds}
updateAssetMoveDropFolder={updateAssetMoveDropFolder}
addUploadedFiles={addUploadedFiles}
moveAssetToFolder={moveAssetToFolder}
@@ -1,33 +1,25 @@
import { describe, expect, it } from 'vitest';
import {
areAllSelectableAssetsSelected,
createAssetMarqueeFromPointer,
createAssetMarqueeSelectionRect,
createLocalAssetFolder,
deleteAssetFolderLocally,
filterGroupedAssetsBySearch,
getSelectableAssets,
groupAssetsByFolder,
layerMatchesSearch,
layerStartsWithSearch,
moveAssetMarqueeToPointer,
moveAssetToFolderLocally,
removeAssetById,
removeSelectedAssets,
renameAssetById,
renameAssetFolderById,
replaceLocalAssetFolder,
resolveAllAssetSelection,
resolveAssetFolderIdFromPoint,
resolveAssetShowcaseSubmissionState,
resolveDefaultAssetFolder,
resolvePinnedAssetMoveFolderId,
resolvePreferredAssetUploadFolder,
resolveUploadAssetFolder,
selectUploadedAssetsInRect,
toggleAssetFolderCollapsed,
toggleAssetSelection,
} from './ImageCanvasAssetLibraryModel';
import type {
CanvasLayer,
@@ -105,16 +97,12 @@ describe('ImageCanvasAssetLibraryModel', () => {
{ id: 'project', assets: [{ id: 'asset-a' }, { id: 'built-in' }] },
{ id: 'folder-role', assets: [{ id: 'asset-b' }] },
]);
expect(getSelectableAssets(assets).map((asset) => asset.id)).toEqual([
'asset-a',
'asset-b',
]);
});
it('explains why assets cannot be submitted to showcase review', () => {
expect(
resolveAssetShowcaseSubmissionState(createAsset()).reason,
).toBe('只有生成素材可以提交精选审核');
expect(resolveAssetShowcaseSubmissionState(createAsset()).reason).toBe(
'只有生成素材可以提交精选审核',
);
expect(
resolveAssetShowcaseSubmissionState(
createAsset({ sourceType: 'generated', persisted: false }),
@@ -260,15 +248,15 @@ describe('ImageCanvasAssetLibraryModel', () => {
});
it('renames assets, toggles folders and creates local folders', () => {
expect(renameAssetById([createAsset()], 'asset-a', '新名字')[0]).toMatchObject(
{ label: '新名字' },
);
expect(
renameAssetById([createAsset()], 'asset-a', '新名字')[0],
).toMatchObject({ label: '新名字' });
expect(
renameAssetFolderById([createFolder()], 'project', '默认素材')[0],
).toMatchObject({ label: '默认素材' });
expect(toggleAssetFolderCollapsed([createFolder()], 'project')[0]).toMatchObject(
{ collapsed: true },
);
expect(
toggleAssetFolderCollapsed([createFolder()], 'project')[0],
).toMatchObject({ collapsed: true });
expect(
createLocalAssetFolder({
folderId: 'folder-local',
@@ -316,9 +304,9 @@ describe('ImageCanvasAssetLibraryModel', () => {
createAsset({ id: 'asset-b', folderId: 'project' }),
];
expect(removeAssetById(assets, 'asset-a').map((asset) => asset.id)).toEqual([
'asset-b',
]);
expect(removeAssetById(assets, 'asset-a').map((asset) => asset.id)).toEqual(
['asset-b'],
);
expect(resolveDefaultAssetFolder(folders)?.id).toBe('project');
expect(
resolveUploadAssetFolder([
@@ -343,23 +331,11 @@ describe('ImageCanvasAssetLibraryModel', () => {
});
});
it('toggles selection, selects all uploaded assets and removes selected assets', () => {
it('removes selected assets', () => {
const assets = [
createAsset({ id: 'asset-a' }),
createAsset({ id: 'asset-b' }),
];
const selected = toggleAssetSelection(new Set<string>(), 'asset-a');
expect([...selected]).toEqual(['asset-a']);
expect([...toggleAssetSelection(selected, 'asset-a')]).toEqual([]);
expect(areAllSelectableAssetsSelected(assets, new Set(['asset-a']))).toBe(
false,
);
const allSelected = resolveAllAssetSelection({
allSelectableAssetsSelected: false,
selectableAssets: assets,
});
expect([...allSelected]).toEqual(['asset-a', 'asset-b']);
const removal = removeSelectedAssets(assets, new Set(['asset-b']));
expect(removal.assets.map((asset) => asset.id)).toEqual(['asset-a']);
expect(removal.deletedAssets.map((asset) => asset.id)).toEqual(['asset-b']);
@@ -438,71 +414,4 @@ describe('ImageCanvasAssetLibraryModel', () => {
}),
).toBeNull();
});
it('updates marquee geometry from pointer coordinates and normalizes reverse drags', () => {
const marquee = createAssetMarqueeFromPointer({
pointerId: 5,
point: { clientX: 180, clientY: 220 },
containerRect: { left: 100, top: 200 },
});
expect(marquee).toEqual({
pointerId: 5,
startX: 80,
startY: 20,
currentX: 80,
currentY: 20,
});
expect(
moveAssetMarqueeToPointer({
marquee,
point: { clientX: 120, clientY: 205 },
containerRect: { left: 100, top: 200 },
}),
).toMatchObject({ currentX: 20, currentY: 5 });
expect(
createAssetMarqueeSelectionRect({
marquee,
point: { clientX: 120, clientY: 205 },
containerRect: { left: 100, top: 200 },
}),
).toEqual({
left: 120,
right: 180,
top: 205,
bottom: 220,
});
});
it('selects only uploaded assets intersecting the marquee rectangle', () => {
const assets = [
createAsset({ id: 'asset-a' }),
createAsset({ id: 'asset-b' }),
createAsset({ id: 'built-in', sourceKind: 'built-in' }),
];
const selectedIds = selectUploadedAssetsInRect({
assets,
assetTargets: [
{
assetId: 'asset-a',
rect: { left: 10, right: 20, top: 10, bottom: 20 },
},
{
assetId: 'asset-b',
rect: { left: 30, right: 40, top: 30, bottom: 40 },
},
{
assetId: 'built-in',
rect: { left: 15, right: 25, top: 15, bottom: 25 },
},
{
assetId: 'missing',
rect: { left: 15, right: 25, top: 15, bottom: 25 },
},
],
selectionRect: { left: 20, right: 35, top: 20, bottom: 35 },
});
expect([...selectedIds]).toEqual(['asset-a', 'asset-b']);
});
});
@@ -1,6 +1,5 @@
import { isEditorInternalProcessingModel } from './ImageCanvasEditorModel';
import type {
AssetMarqueeState,
CanvasLayer,
EditorAsset,
EditorAssetFolder,
@@ -18,11 +17,6 @@ type AssetFolderHitTarget = {
rect: ClientRectLike;
};
type AssetHitTarget = {
assetId: string;
rect: ClientRectLike;
};
export type GroupedAssetFolder = EditorAssetFolder & {
assets: EditorAsset[];
};
@@ -38,15 +32,6 @@ function isPointInRect(point: ClientPoint, rect: ClientRectLike) {
);
}
function doRectsIntersect(rect: ClientRectLike, selectionRect: ClientRectLike) {
return (
rect.left <= selectionRect.right &&
rect.right >= selectionRect.left &&
rect.top <= selectionRect.bottom &&
rect.bottom >= selectionRect.top
);
}
export function resolveAssetFolderIdFromPoint({
point,
listRect,
@@ -85,98 +70,6 @@ export function resolvePinnedAssetMoveFolderId({
return folderId;
}
export function shouldStartAssetMarquee({
isAssetSelectionMode,
button,
isBlockedTarget,
}: {
isAssetSelectionMode: boolean;
button: number;
isBlockedTarget: boolean;
}) {
return isAssetSelectionMode && button === 0 && !isBlockedTarget;
}
export function createAssetMarqueeFromPointer({
pointerId,
point,
containerRect,
}: {
pointerId: number;
point: ClientPoint;
containerRect: Pick<DOMRect, 'left' | 'top'> | null | undefined;
}): AssetMarqueeState {
const startX = point.clientX - (containerRect?.left ?? 0);
const startY = point.clientY - (containerRect?.top ?? 0);
return {
pointerId,
startX,
startY,
currentX: startX,
currentY: startY,
};
}
export function moveAssetMarqueeToPointer({
marquee,
point,
containerRect,
}: {
marquee: AssetMarqueeState;
point: ClientPoint;
containerRect: Pick<DOMRect, 'left' | 'top'> | null | undefined;
}) {
return {
...marquee,
currentX: point.clientX - (containerRect?.left ?? 0),
currentY: point.clientY - (containerRect?.top ?? 0),
};
}
export function createAssetMarqueeSelectionRect({
marquee,
point,
containerRect,
}: {
marquee: AssetMarqueeState;
point: ClientPoint;
containerRect: Pick<DOMRect, 'left' | 'top'> | null | undefined;
}): ClientRectLike {
const startClientX = (containerRect?.left ?? 0) + marquee.startX;
const startClientY = (containerRect?.top ?? 0) + marquee.startY;
return {
left: Math.min(startClientX, point.clientX),
right: Math.max(startClientX, point.clientX),
top: Math.min(startClientY, point.clientY),
bottom: Math.max(startClientY, point.clientY),
};
}
export function selectUploadedAssetsInRect({
assets,
assetTargets,
selectionRect,
}: {
assets: EditorAsset[];
assetTargets: AssetHitTarget[];
selectionRect: ClientRectLike;
}) {
const uploadedAssetIds = new Set(
assets
.filter((asset) => asset.sourceKind === 'uploaded')
.map((asset) => asset.id),
);
return new Set(
assetTargets
.filter(
(target) =>
uploadedAssetIds.has(target.assetId) &&
doRectsIntersect(target.rect, selectionRect),
)
.map((target) => target.assetId),
);
}
export function groupAssetsByFolder(
assetFolders: EditorAssetFolder[],
assets: EditorAsset[],
@@ -191,14 +84,20 @@ function normalizeSearchQuery(query: string) {
return query.trim().toLowerCase();
}
function matchesSearchText(query: string, values: Array<string | null | undefined>) {
function matchesSearchText(
query: string,
values: Array<string | null | undefined>,
) {
if (!query) {
return true;
}
return values.some((value) => value?.toLowerCase().includes(query));
}
function startsWithSearchText(query: string, values: Array<string | null | undefined>) {
function startsWithSearchText(
query: string,
values: Array<string | null | undefined>,
) {
if (!query) {
return true;
}
@@ -290,10 +189,6 @@ export function filterGroupedAssetsBySearch(
.filter((folder) => folder.assets.length > 0);
}
export function getSelectableAssets(assets: EditorAsset[]) {
return assets.filter((asset) => asset.sourceKind === 'uploaded');
}
export type AssetShowcaseSubmissionState = {
canSubmit: boolean;
label: string;
@@ -370,16 +265,6 @@ export function resolveAssetShowcaseSubmissionState(
};
}
export function areAllSelectableAssetsSelected(
selectableAssets: EditorAsset[],
selectedAssetIds: Set<string>,
) {
return (
selectableAssets.length > 0 &&
selectableAssets.every((asset) => selectedAssetIds.has(asset.id))
);
}
export function renameAssetById(
assets: EditorAsset[],
assetId: string,
@@ -485,7 +370,9 @@ export function renameAssetFolderById(
export function resolveDefaultAssetFolder(assetFolders: EditorAssetFolder[]) {
return (
assetFolders.find((folder) => folder.systemDefault) ?? assetFolders[0] ?? null
assetFolders.find((folder) => folder.systemDefault) ??
assetFolders[0] ??
null
);
}
@@ -500,7 +387,10 @@ export function resolveUploadAssetFolder(assetFolders: EditorAssetFolder[]) {
export function resolvePreferredAssetUploadFolder(
assetFolders: EditorAssetFolder[],
) {
return resolveUploadAssetFolder(assetFolders) ?? resolveDefaultAssetFolder(assetFolders);
return (
resolveUploadAssetFolder(assetFolders) ??
resolveDefaultAssetFolder(assetFolders)
);
}
export function deleteAssetFolderLocally({
@@ -527,36 +417,13 @@ export function deleteAssetFolderLocally({
};
}
export function toggleAssetSelection(
selectedAssetIds: Set<string>,
assetId: string,
) {
const nextIds = new Set(selectedAssetIds);
if (nextIds.has(assetId)) {
nextIds.delete(assetId);
} else {
nextIds.add(assetId);
}
return nextIds;
}
export function resolveAllAssetSelection({
allSelectableAssetsSelected,
selectableAssets,
}: {
allSelectableAssetsSelected: boolean;
selectableAssets: EditorAsset[];
}) {
return allSelectableAssetsSelected
? new Set<string>()
: new Set(selectableAssets.map((asset) => asset.id));
}
export function removeSelectedAssets(
assets: EditorAsset[],
selectedAssetIds: Set<string>,
) {
const deletedAssets = assets.filter((asset) => selectedAssetIds.has(asset.id));
const deletedAssets = assets.filter((asset) =>
selectedAssetIds.has(asset.id),
);
return {
assets: assets.filter((asset) => !selectedAssetIds.has(asset.id)),
deletedAssets,
@@ -1,6 +1,7 @@
import {
Check,
CheckSquare,
Download,
Folder,
Square,
Trash2,
@@ -12,9 +13,11 @@ import type {
RefObject,
SetStateAction,
} from 'react';
import { useEffect, useState } from 'react';
import { PlatformActionButton } from '../common/PlatformActionButton';
import { PlatformBatchActionToolbar } from '../common/PlatformBatchActionToolbar';
import { PlatformDangerConfirmDialog } from '../common/PlatformDangerConfirmDialog';
import { PlatformTextField } from '../common/PlatformTextField';
import { ImageCanvasAssetFolderSectionView } from './ImageCanvasAssetFolderSectionView';
import { EditorIconButton } from './ImageCanvasEditorPrimitives';
@@ -25,6 +28,10 @@ import type {
EditorAssetFolder,
UploadTarget,
} from './ImageCanvasEditorTypes';
import {
type AssetSelectionInteraction,
countHiddenSelectedAssets,
} from './useImageCanvasAssetSelection';
export type GroupedEditorAssetFolder = EditorAssetFolder & {
assets: EditorAsset[];
@@ -49,13 +56,15 @@ export type ImageCanvasAssetLibraryPanelViewProps = {
assetFolders: EditorAssetFolder[];
isAssetSelectionMode: boolean;
selectedAssetIds: Set<string>;
visibleAssetIds: string[];
assetMoveDropFolderId: string | null;
pinnedAssetMoveFolderId: string | null;
creatingFolder: boolean;
newFolderName: string;
renamingFolder: { folderId: string; value: string } | null;
renamingAsset: { assetId: string; value: string } | null;
allSelectableAssetsSelected: boolean;
allVisibleAssetsSelected: boolean;
isExportingSelectedAssets: boolean;
assetMarquee: AssetMarqueeState | null;
setCreatingFolder: Dispatch<SetStateAction<boolean>>;
setNewFolderName: Dispatch<SetStateAction<string>>;
@@ -68,18 +77,20 @@ export type ImageCanvasAssetLibraryPanelViewProps = {
setActiveUploadFolderId: Dispatch<SetStateAction<string>>;
setUploadDropTarget: Dispatch<SetStateAction<'canvas' | 'assets' | null>>;
setAssetPointerDrag: Dispatch<SetStateAction<AssetPointerDragState | null>>;
setSelectedAssetIds: Dispatch<SetStateAction<Set<string>>>;
onAssetMarqueePointerDown: (
onAssetMarqueePointerDown: (event: ReactPointerEvent<HTMLDivElement>) => void;
onAssetMarqueePointerMove: (event: ReactPointerEvent<HTMLDivElement>) => void;
onAssetMarqueePointerUp: (event: ReactPointerEvent<HTMLDivElement>) => void;
onAssetMarqueePointerCancel: (
event: ReactPointerEvent<HTMLDivElement>,
) => void;
onAssetMarqueePointerMove: (
event: ReactPointerEvent<HTMLDivElement>,
) => void;
onAssetMarqueePointerUp: (
onAssetMarqueeLostPointerCapture: (
event: ReactPointerEvent<HTMLDivElement>,
) => void;
updateAssetMoveDropFolder: (folderId: string | null) => void;
addUploadedFiles: (files: FileList | File[], options?: UploadFilesOptions) => void;
addUploadedFiles: (
files: FileList | File[],
options?: UploadFilesOptions,
) => void;
requestUpload: (target: UploadTarget, options?: UploadRequestOptions) => void;
moveAssetToFolder: (assetId: string, folderId: string) => void;
commitNewAssetFolder: () => void | Promise<void>;
@@ -91,11 +102,15 @@ export type ImageCanvasAssetLibraryPanelViewProps = {
commitAssetRename: (asset: EditorAsset) => void;
deleteUploadedAsset: (asset: EditorAsset) => void;
submitAssetToShowcase: (asset: EditorAsset) => void;
toggleAssetSelected: (assetId: string) => void;
toggleAssetSelected: (
assetId: string,
interaction: AssetSelectionInteraction,
) => void;
addAssetLayer: (asset: EditorAsset) => void;
onDownloadAsset: (asset: EditorAsset) => void;
toggleAllAssetsSelected: () => void;
deleteSelectedAssets: () => void;
toggleAllAssetsSelected: (visibleAssetIds: string[]) => void;
downloadSelectedAssets: () => void | Promise<void>;
deleteSelectedAssets: (assetIds?: ReadonlySet<string>) => void;
closeAssetSelectionMode: () => void;
};
@@ -107,13 +122,15 @@ export function ImageCanvasAssetLibraryPanelView({
assetFolders,
isAssetSelectionMode,
selectedAssetIds,
visibleAssetIds,
assetMoveDropFolderId,
pinnedAssetMoveFolderId,
creatingFolder,
newFolderName,
renamingFolder,
renamingAsset,
allSelectableAssetsSelected,
allVisibleAssetsSelected,
isExportingSelectedAssets,
assetMarquee,
setCreatingFolder,
setNewFolderName,
@@ -122,10 +139,11 @@ export function ImageCanvasAssetLibraryPanelView({
setActiveUploadFolderId,
setUploadDropTarget,
setAssetPointerDrag,
setSelectedAssetIds,
onAssetMarqueePointerDown,
onAssetMarqueePointerMove,
onAssetMarqueePointerUp,
onAssetMarqueePointerCancel,
onAssetMarqueeLostPointerCapture,
updateAssetMoveDropFolder,
addUploadedFiles,
requestUpload,
@@ -143,148 +161,246 @@ export function ImageCanvasAssetLibraryPanelView({
addAssetLayer,
onDownloadAsset,
toggleAllAssetsSelected,
downloadSelectedAssets,
deleteSelectedAssets,
closeAssetSelectionMode,
}: ImageCanvasAssetLibraryPanelViewProps) {
const [hiddenSelectionDeleteSnapshot, setHiddenSelectionDeleteSnapshot] =
useState<{
assetIds: Set<string>;
visibleAssetIds: string[];
hiddenAssetCount: number;
} | null>(null);
let selectionToggleActionText = '全选';
if (allVisibleAssetsSelected) {
selectionToggleActionText = '取消全选';
}
let selectionToggleLabel = selectionToggleActionText;
if (selectedAssetIds.size > 0) {
selectionToggleLabel = `${selectionToggleActionText} · 已选 ${selectedAssetIds.size}`;
}
const hiddenSelectedAssetCount = countHiddenSelectedAssets(
selectedAssetIds,
visibleAssetIds,
);
useEffect(() => {
if (!hiddenSelectionDeleteSnapshot) {
return;
}
const selectedIdsUnchanged =
hiddenSelectionDeleteSnapshot.assetIds.size === selectedAssetIds.size &&
[...hiddenSelectionDeleteSnapshot.assetIds].every((assetId) =>
selectedAssetIds.has(assetId),
);
const visibleIdsUnchanged =
hiddenSelectionDeleteSnapshot.visibleAssetIds.length ===
visibleAssetIds.length &&
hiddenSelectionDeleteSnapshot.visibleAssetIds.every(
(assetId, index) => assetId === visibleAssetIds[index],
);
if (
!isAssetSelectionMode ||
!selectedIdsUnchanged ||
!visibleIdsUnchanged
) {
setHiddenSelectionDeleteSnapshot(null);
}
}, [
hiddenSelectionDeleteSnapshot,
isAssetSelectionMode,
selectedAssetIds,
visibleAssetIds,
]);
const requestDeleteSelectedAssets = () => {
if (hiddenSelectedAssetCount > 0) {
setHiddenSelectionDeleteSnapshot({
assetIds: new Set(selectedAssetIds),
visibleAssetIds: [...visibleAssetIds],
hiddenAssetCount: hiddenSelectedAssetCount,
});
return;
}
deleteSelectedAssets();
};
return (
<div
ref={assetListRef}
className="image-canvas-editor__asset-list"
onPointerDown={onAssetMarqueePointerDown}
onPointerMove={onAssetMarqueePointerMove}
onPointerUp={onAssetMarqueePointerUp}
onPointerCancel={onAssetMarqueePointerUp}
>
{pinnedAssetMoveFolderId ? (
<div
className="image-canvas-editor__asset-folder-sticky-target"
aria-hidden="true"
>
<Folder className="h-4 w-4" />
<span>
{assetFolders.find((folder) => folder.id === pinnedAssetMoveFolderId)
?.label ?? '目标文件夹'}
</span>
</div>
) : null}
{creatingFolder ? (
<form
className="image-canvas-editor__folder-create"
onSubmit={(event) => {
event.preventDefault();
void commitNewAssetFolder();
}}
>
<PlatformTextField
aria-label="素材文件夹名称"
value={newFolderName}
autoFocus
size="xs"
density="compact"
className="image-canvas-editor__folder-create-input"
onChange={(event) => setNewFolderName(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Escape') {
event.preventDefault();
<div className="image-canvas-editor__asset-library">
<div
ref={assetListRef}
className="image-canvas-editor__asset-list"
onPointerDown={onAssetMarqueePointerDown}
onPointerMove={onAssetMarqueePointerMove}
onPointerUp={onAssetMarqueePointerUp}
onPointerCancel={onAssetMarqueePointerCancel}
onLostPointerCapture={onAssetMarqueeLostPointerCapture}
>
{pinnedAssetMoveFolderId ? (
<div
className="image-canvas-editor__asset-folder-sticky-target"
aria-hidden="true"
>
<Folder className="h-4 w-4" />
<span>
{assetFolders.find(
(folder) => folder.id === pinnedAssetMoveFolderId,
)?.label ?? '目标文件夹'}
</span>
</div>
) : null}
{creatingFolder ? (
<form
className="image-canvas-editor__folder-create"
onSubmit={(event) => {
event.preventDefault();
void commitNewAssetFolder();
}}
>
<PlatformTextField
aria-label="素材文件夹名称"
value={newFolderName}
autoFocus
size="xs"
density="compact"
className="image-canvas-editor__folder-create-input"
onChange={(event) => setNewFolderName(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Escape') {
event.preventDefault();
setCreatingFolder(false);
setNewFolderName('');
}
}}
/>
<EditorIconButton
type="submit"
label="保存素材文件夹"
icon={Check}
/>
<EditorIconButton
label="取消新建素材文件夹"
icon={X}
onClick={() => {
setCreatingFolder(false);
setNewFolderName('');
}
}}
/>
</form>
) : null}
{groupedAssets.map((folder) => (
<ImageCanvasAssetFolderSectionView
key={folder.id}
folder={folder}
assetPointerDragRef={assetPointerDragRef}
suppressAssetClickRef={suppressAssetClickRef}
isAssetSelectionMode={isAssetSelectionMode}
selectedAssetIds={selectedAssetIds}
visibleAssetIds={visibleAssetIds}
assetMoveDropFolderId={assetMoveDropFolderId}
renamingFolder={renamingFolder}
renamingAsset={renamingAsset}
setRenamingFolder={setRenamingFolder}
setRenamingAsset={setRenamingAsset}
setActiveUploadFolderId={setActiveUploadFolderId}
setUploadDropTarget={setUploadDropTarget}
setAssetPointerDrag={setAssetPointerDrag}
updateAssetMoveDropFolder={updateAssetMoveDropFolder}
addUploadedFiles={addUploadedFiles}
requestUpload={requestUpload}
moveAssetToFolder={moveAssetToFolder}
toggleAssetFolder={toggleAssetFolder}
startRenamingFolder={startRenamingFolder}
commitFolderRename={commitFolderRename}
deleteAssetFolder={deleteAssetFolder}
startRenamingAsset={startRenamingAsset}
commitAssetRename={commitAssetRename}
deleteUploadedAsset={deleteUploadedAsset}
submitAssetToShowcase={submitAssetToShowcase}
toggleAssetSelected={toggleAssetSelected}
addAssetLayer={addAssetLayer}
onDownloadAsset={onDownloadAsset}
/>
))}
{assetMarquee ? (
<div
className="image-canvas-editor__asset-marquee"
aria-hidden="true"
style={{
left: Math.min(assetMarquee.startX, assetMarquee.currentX),
top: Math.min(assetMarquee.startY, assetMarquee.currentY),
width: Math.abs(assetMarquee.currentX - assetMarquee.startX),
height: Math.abs(assetMarquee.currentY - assetMarquee.startY),
}}
/>
<EditorIconButton type="submit" label="保存素材文件夹" icon={Check} />
<EditorIconButton
label="取消新建素材文件夹"
icon={X}
onClick={() => {
setCreatingFolder(false);
setNewFolderName('');
}}
/>
</form>
) : null}
{groupedAssets.map((folder) => (
<ImageCanvasAssetFolderSectionView
key={folder.id}
folder={folder}
assetPointerDragRef={assetPointerDragRef}
suppressAssetClickRef={suppressAssetClickRef}
isAssetSelectionMode={isAssetSelectionMode}
selectedAssetIds={selectedAssetIds}
assetMoveDropFolderId={assetMoveDropFolderId}
renamingFolder={renamingFolder}
renamingAsset={renamingAsset}
setRenamingFolder={setRenamingFolder}
setRenamingAsset={setRenamingAsset}
setActiveUploadFolderId={setActiveUploadFolderId}
setUploadDropTarget={setUploadDropTarget}
setAssetPointerDrag={setAssetPointerDrag}
setSelectedAssetIds={setSelectedAssetIds}
updateAssetMoveDropFolder={updateAssetMoveDropFolder}
addUploadedFiles={addUploadedFiles}
requestUpload={requestUpload}
moveAssetToFolder={moveAssetToFolder}
toggleAssetFolder={toggleAssetFolder}
startRenamingFolder={startRenamingFolder}
commitFolderRename={commitFolderRename}
deleteAssetFolder={deleteAssetFolder}
startRenamingAsset={startRenamingAsset}
commitAssetRename={commitAssetRename}
deleteUploadedAsset={deleteUploadedAsset}
submitAssetToShowcase={submitAssetToShowcase}
toggleAssetSelected={toggleAssetSelected}
addAssetLayer={addAssetLayer}
onDownloadAsset={onDownloadAsset}
/>
))}
) : null}
</div>
{isAssetSelectionMode ? (
<PlatformBatchActionToolbar
className="image-canvas-editor__asset-batch-toolbar"
className="static! left-auto! bottom-auto! grid! w-full! max-w-none! transform-none! grid-cols-3! gap-1.5! overflow-hidden! rounded-none! border-x-0! border-b-0! px-2.5! pt-2.5! pb-[calc(0.55rem+env(safe-area-inset-bottom,0px))]! shadow-[0_-8px_22px_rgba(15,23,42,0.08)]!"
label="素材批量操作"
>
<PlatformActionButton
className="image-canvas-editor__asset-batch-action col-span-3"
tone="secondary"
size="sm"
onClick={toggleAllAssetsSelected}
disabled={visibleAssetIds.length === 0 || isExportingSelectedAssets}
onClick={() => toggleAllAssetsSelected(visibleAssetIds)}
>
{allSelectableAssetsSelected ? (
{allVisibleAssetsSelected ? (
<CheckSquare className="h-4 w-4" />
) : (
<Square className="h-4 w-4" />
)}
{selectedAssetIds.size > 0
? `${allSelectableAssetsSelected ? '取消全选' : '全选'} · 已选 ${selectedAssetIds.size}`
: '全选'}
<span className="min-w-0 truncate">{selectionToggleLabel}</span>
</PlatformActionButton>
<PlatformActionButton
className="image-canvas-editor__asset-batch-action"
tone="secondary"
size="sm"
disabled={selectedAssetIds.size === 0 || isExportingSelectedAssets}
onClick={() => void downloadSelectedAssets()}
>
<Download className="h-4 w-4" />
{isExportingSelectedAssets ? '下载中' : '下载'}
</PlatformActionButton>
<PlatformActionButton
className="image-canvas-editor__asset-batch-action"
tone="warning"
size="sm"
disabled={selectedAssetIds.size === 0}
onClick={deleteSelectedAssets}
disabled={selectedAssetIds.size === 0 || isExportingSelectedAssets}
onClick={requestDeleteSelectedAssets}
>
<Trash2 className="h-4 w-4" />
删除
</PlatformActionButton>
<PlatformActionButton
className="image-canvas-editor__asset-batch-action"
tone="secondary"
size="sm"
onClick={closeAssetSelectionMode}
>
<X className="h-4 w-4" />
取消
</PlatformActionButton>
</PlatformBatchActionToolbar>
) : null}
{assetMarquee ? (
<div
className="image-canvas-editor__asset-marquee"
aria-hidden="true"
style={{
left: Math.min(assetMarquee.startX, assetMarquee.currentX),
top: Math.min(assetMarquee.startY, assetMarquee.currentY),
width: Math.abs(assetMarquee.currentX - assetMarquee.startX),
height: Math.abs(assetMarquee.currentY - assetMarquee.startY),
}}
/>
) : null}
<PlatformDangerConfirmDialog
open={isAssetSelectionMode && hiddenSelectionDeleteSnapshot !== null}
title="删除全部已选素材"
description={`将删除 ${hiddenSelectionDeleteSnapshot?.assetIds.size ?? 0} 个已选素材,其中 ${hiddenSelectionDeleteSnapshot?.hiddenAssetCount ?? 0} 个当前未显示。`}
confirmLabel="确认删除全部已选素材"
onClose={() => setHiddenSelectionDeleteSnapshot(null)}
onConfirm={() => {
const targetAssetIds = hiddenSelectionDeleteSnapshot?.assetIds;
setHiddenSelectionDeleteSnapshot(null);
if (targetAssetIds) {
deleteSelectedAssets(targetAssetIds);
}
}}
>
删除后不可恢复。
</PlatformDangerConfirmDialog>
</div>
);
}
@@ -4,7 +4,10 @@ import { fireEvent, render, screen } from '@testing-library/react';
import type { ReactNode } from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { ImageCanvasAssetRowView } from './ImageCanvasAssetRowView';
import {
ImageCanvasAssetRowView,
type ImageCanvasAssetRowViewProps,
} from './ImageCanvasAssetRowView';
import type { EditorAsset } from './ImageCanvasEditorTypes';
const useResolvedAssetReadUrlMock = vi.hoisted(() => vi.fn());
@@ -94,7 +97,7 @@ function renderAssetRow({
renamingAsset?: { assetId: string; value: string } | null;
suppressAssetClick?: boolean;
addAssetLayer?: (asset: EditorAsset) => void;
toggleAssetSelected?: (assetId: string) => void;
toggleAssetSelected?: ImageCanvasAssetRowViewProps['toggleAssetSelected'];
commitAssetRename?: (asset: EditorAsset) => void;
deleteUploadedAsset?: (asset: EditorAsset) => void;
submitAssetToShowcase?: (asset: EditorAsset) => void;
@@ -106,11 +109,11 @@ function renderAssetRow({
suppressAssetClickRef: { current: suppressAssetClick },
isAssetSelectionMode,
selectedAssetIds,
visibleAssetIds: [asset.id],
renamingAsset,
setRenamingAsset: vi.fn(),
setUploadDropTarget: vi.fn(),
setAssetPointerDrag: vi.fn(),
setSelectedAssetIds: vi.fn(),
updateAssetMoveDropFolder: vi.fn(),
addUploadedFiles: vi.fn(),
moveAssetToFolder: vi.fn(),
@@ -123,8 +126,10 @@ function renderAssetRow({
onDownloadAsset,
};
render(<ImageCanvasAssetRowView {...props} />);
return props;
return {
props,
...render(<ImageCanvasAssetRowView {...props} />),
};
}
describe('ImageCanvasAssetRowView', () => {
@@ -182,6 +187,25 @@ describe('ImageCanvasAssetRowView', () => {
expect(screen.queryByRole('menu')).toBeNull();
});
it('closes the right click menu when selection mode becomes active', () => {
const { props, rerender } = renderAssetRow();
fireEvent.contextMenu(
screen.getByRole('button', { name: '添加账号素材A' }),
{
clientX: 120,
clientY: 80,
},
);
expect(screen.getByRole('menu')).toBeTruthy();
rerender(
<ImageCanvasAssetRowView {...props} isAssetSelectionMode={true} />,
);
expect(screen.queryByRole('menu')).toBeNull();
});
it('selects the asset instead of adding it in selection mode', () => {
const addAssetLayer = vi.fn();
const toggleAssetSelected = vi.fn();
@@ -193,8 +217,76 @@ describe('ImageCanvasAssetRowView', () => {
fireEvent.click(screen.getByRole('button', { name: '选择素材账号素材A' }));
expect(toggleAssetSelected).toHaveBeenCalledWith('asset-1');
expect(toggleAssetSelected).toHaveBeenCalledWith('asset-1', {
visibleAssetIds: ['asset-1'],
shiftKey: false,
});
expect(addAssetLayer).not.toHaveBeenCalled();
expect(
screen.queryByRole('button', { name: '下载素材账号素材A' }),
).toBeNull();
});
it('disables rows that are outside the shared selectable-asset boundary', () => {
const toggleAssetSelected = vi.fn();
renderAssetRow({
asset: createAsset({ sourceKind: 'built-in' }),
isAssetSelectionMode: true,
toggleAssetSelected,
});
const button = screen.getByRole('button', {
name: '素材账号素材A不可选择',
}) as HTMLButtonElement;
expect(button.disabled).toBe(true);
fireEvent.click(screen.getByText('账号素材A'));
expect(toggleAssetSelected).not.toHaveBeenCalled();
});
it('forwards only the shift modifier used by selection semantics', () => {
const toggleAssetSelected = vi.fn();
renderAssetRow({ isAssetSelectionMode: true, toggleAssetSelected });
fireEvent.click(screen.getByText('账号素材A'), {
ctrlKey: true,
shiftKey: true,
});
expect(toggleAssetSelected).toHaveBeenCalledWith('asset-1', {
visibleAssetIds: ['asset-1'],
shiftKey: true,
});
});
it('blocks internal moves and external file drops in selection mode', () => {
const { container, props } = renderAssetRow({
isAssetSelectionMode: true,
});
const row = container.querySelector<HTMLElement>(
'[data-asset-id="asset-1"]',
);
if (!row) {
throw new Error('asset row should exist');
}
const fileTransfer = {
types: ['Files'],
files: [new File(['image'], '新增素材.png', { type: 'image/png' })],
dropEffect: 'copy',
getData: vi.fn(() => ''),
};
fireEvent.dragOver(row, { dataTransfer: fileTransfer });
fireEvent.drop(row, { dataTransfer: fileTransfer });
const assetTransfer = {
types: ['application/x-genarrative-editor-asset'],
files: [],
dropEffect: 'move',
getData: vi.fn(() => 'asset-other'),
};
fireEvent.dragOver(row, { dataTransfer: assetTransfer });
fireEvent.drop(row, { dataTransfer: assetTransfer });
expect(props.addUploadedFiles).not.toHaveBeenCalled();
expect(props.moveAssetToFolder).not.toHaveBeenCalled();
});
it('renders rename input and commits with Enter', () => {

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