合并编辑器素材库能力

合并 codex/editor-asset-library 到宣发素材演示分支

保留素材库、生成器、音频、UI设计图和持久化相关能力

补回宣发素材入口与 5 泥点计费契约
This commit is contained in:
2026-06-18 22:36:54 +08:00
120 changed files with 16619 additions and 3198 deletions
@@ -1,6 +1,6 @@
/* @vitest-environment jsdom */
import { render, screen } from '@testing-library/react';
import { fireEvent, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, it, vi } from 'vitest';
@@ -28,4 +28,26 @@ describe('PlatformFloatingMenu', () => {
expect(onRename).toHaveBeenCalledOnce();
});
it('keeps pointer events from leaking to canvas-style parents', () => {
const onParentPointerDown = vi.fn();
const onMenuPointerDown = vi.fn();
render(
<div onPointerDown={onParentPointerDown}>
<PlatformFloatingMenu
label="项目菜单"
placement="bottom-end"
onPointerDown={onMenuPointerDown}
>
<PlatformFloatingMenuItem>重命名</PlatformFloatingMenuItem>
</PlatformFloatingMenu>
</div>,
);
fireEvent.pointerDown(screen.getByRole('menu', { name: '项目菜单' }));
expect(onMenuPointerDown).toHaveBeenCalledOnce();
expect(onParentPointerDown).not.toHaveBeenCalled();
});
});
@@ -1,6 +1,6 @@
import type { ButtonHTMLAttributes, CSSProperties, ReactNode } from 'react';
import type { ButtonHTMLAttributes, CSSProperties, HTMLAttributes, ReactNode } from 'react';
type PlatformFloatingMenuProps = {
type PlatformFloatingMenuProps = HTMLAttributes<HTMLDivElement> & {
children: ReactNode;
className?: string;
label?: string;
@@ -26,9 +26,12 @@ export function PlatformFloatingMenu({
label,
placement = 'top-end',
style,
onPointerDown,
...divProps
}: PlatformFloatingMenuProps) {
return (
<div
{...divProps}
className={[
'platform-floating-menu',
`platform-floating-menu--${placement}`,
@@ -39,6 +42,10 @@ export function PlatformFloatingMenu({
role="menu"
aria-label={label}
style={style}
onPointerDown={(event) => {
event.stopPropagation();
onPointerDown?.(event);
}}
>
{children}
</div>
+22 -11
View File
@@ -1,9 +1,11 @@
import { forwardRef } from 'react';
import type {
ButtonHTMLAttributes,
HTMLAttributes,
KeyboardEvent,
LabelHTMLAttributes,
ReactNode,
Ref,
} from 'react';
type PlatformIconButtonBaseProps = {
@@ -52,16 +54,22 @@ type PlatformIconButtonProps =
* 平台通用图标动作按钮。
* 统一承接纯图标动作、图标上传 label 和带短标签的浮动图标动作。
*/
export function PlatformIconButton({
label,
icon,
children,
variant = 'platformIcon',
title,
className,
asChild,
...actionProps
}: PlatformIconButtonProps) {
export const PlatformIconButton = forwardRef<
HTMLButtonElement | HTMLLabelElement | HTMLSpanElement,
PlatformIconButtonProps
>(function PlatformIconButton(
{
label,
icon,
children,
variant = 'platformIcon',
title,
className,
asChild,
...actionProps
},
ref,
) {
const variantClassName = {
platformIcon: 'platform-icon-button',
surfaceFloating:
@@ -78,6 +86,7 @@ export function PlatformIconButton({
return (
<label
{...(actionProps as LabelHTMLAttributes<HTMLLabelElement>)}
ref={ref as Ref<HTMLLabelElement>}
title={title}
className={actionClassName}
>
@@ -112,6 +121,7 @@ export function PlatformIconButton({
return (
<span
{...spanProps}
ref={ref as Ref<HTMLSpanElement>}
aria-disabled={disabled}
aria-label={label}
className={actionClassName}
@@ -133,6 +143,7 @@ export function PlatformIconButton({
return (
<button
{...buttonProps}
ref={ref as Ref<HTMLButtonElement>}
type={type}
aria-label={label}
title={title}
@@ -142,4 +153,4 @@ export function PlatformIconButton({
{children}
</button>
);
}
});
@@ -1,4 +1,4 @@
import type { ButtonHTMLAttributes, ReactNode } from 'react';
import { forwardRef, type ButtonHTMLAttributes, type ReactNode } from 'react';
type PlatformInlineOptionButtonProps = Omit<
ButtonHTMLAttributes<HTMLButtonElement>,
@@ -12,13 +12,13 @@ type PlatformInlineOptionButtonProps = Omit<
* 平台内联选项按钮。
* 统一承接工具面板里“当前选项 + 下拉箭头”这类轻量 pill 动作。
*/
export function PlatformInlineOptionButton({
children,
trailingIcon,
className,
type = 'button',
...buttonProps
}: PlatformInlineOptionButtonProps) {
export const PlatformInlineOptionButton = forwardRef<
HTMLButtonElement,
PlatformInlineOptionButtonProps
>(function PlatformInlineOptionButton(
{ children, trailingIcon, className, type = 'button', ...buttonProps },
ref,
) {
const actionClassName = [
'platform-inline-option-button inline-flex items-center justify-center border-0 bg-transparent text-sm font-black text-slate-700 transition-colors hover:text-slate-950 disabled:cursor-not-allowed disabled:opacity-55',
className,
@@ -27,7 +27,7 @@ export function PlatformInlineOptionButton({
.join(' ');
return (
<button {...buttonProps} type={type} className={actionClassName}>
<button {...buttonProps} ref={ref} type={type} className={actionClassName}>
<span className="platform-inline-option-button__label">{children}</span>
{trailingIcon ? (
<span className="platform-inline-option-button__trailing" aria-hidden="true">
@@ -36,4 +36,4 @@ export function PlatformInlineOptionButton({
) : null}
</button>
);
}
});
@@ -1,7 +1,7 @@
/* @vitest-environment jsdom */
import { fireEvent, render, screen } from '@testing-library/react';
import { useState } from 'react';
import { fireEvent, render, screen, within } from '@testing-library/react';
import { createRef, useState } from 'react';
import { describe, expect, it, vi } from 'vitest';
import type {
@@ -17,6 +17,10 @@ function createDialog(
mode: 'generate',
prompt: '初始提示',
status: 'idle',
generationReferences: [],
imageModel: 'gemini-3.1-flash-image-preview',
aspectRatio: '16:9',
imageSize: '1K',
...patch,
};
}
@@ -35,6 +39,9 @@ function BasicGenerationHarness({
const [dialog, setDialog] = useState<GenerateDialogState | null>(
initialDialog,
);
const [isReferenceMenuOpen, setIsReferenceMenuOpen] = useState(false);
const [isPickingReference, setIsPickingReference] = useState(false);
const generationReferenceButtonRef = createRef<HTMLButtonElement>();
return dialog ? (
<div>
@@ -42,13 +49,24 @@ function BasicGenerationHarness({
dialog={dialog}
style={{ left: 10, top: 20 }}
setGenerateDialog={setDialog}
generationReferenceButtonRef={generationReferenceButtonRef}
isGenerationReferenceMenuOpen={isReferenceMenuOpen}
setIsGenerationReferenceMenuOpen={setIsReferenceMenuOpen}
setIsPickingGenerationReferenceFromCanvas={setIsPickingReference}
renderEditorPortal={(node) => node}
buildPortalMenuStyle={() => ({ position: 'fixed', left: 0, top: 0 })}
onRequestUpload={onRequestUpload}
onToggleReferenceMenu={() => setIsReferenceMenuOpen((open) => !open)}
onSubmit={onSubmit}
onClose={onClose}
/>
<output aria-label="当前提示词">{dialog.prompt}</output>
<output aria-label="当前比例">{dialog.aspectRatio}</output>
<output aria-label="当前尺寸">{dialog.imageSize}</output>
<output aria-label="当前模型">{dialog.imageModel}</output>
<output aria-label="当前状态">{dialog.status}</output>
<output aria-label="当前错误">{dialog.errorMessage ?? '-'}</output>
<output aria-label="选择参考图">{String(isPickingReference)}</output>
</div>
) : null;
}
@@ -71,31 +89,129 @@ describe('ImageCanvasBasicGenerationComposerView', () => {
fireEvent.change(screen.getByLabelText('生成提示词'), {
target: { value: '新的提示' },
});
fireEvent.click(screen.getByRole('button', { name: '添加参考图' }));
const panel = screen.getByRole('dialog', { name: '生成图片' });
fireEvent.click(within(panel).getByRole('button', { name: '添加参考图' }));
fireEvent.click(screen.getByRole('menuitem', { name: '上传图片' }));
fireEvent.click(screen.getByRole('button', { name: '生成' }));
expect(screen.getByLabelText('当前提示词').textContent).toBe('新的提示');
expect(screen.getByLabelText('当前状态').textContent).toBe('idle');
expect(screen.getByLabelText('当前错误').textContent).toBe('-');
expect(requestUpload).toHaveBeenCalledWith('asset');
expect(requestUpload).toHaveBeenCalledWith('generation-reference');
expect(submitGeneration).toHaveBeenCalledWith(
expect.objectContaining({ prompt: '新的提示', status: 'idle' }),
);
});
it('runs placeholder actions and closes through its interface', () => {
it('keeps references on the first row and appends a new add-reference entry', () => {
render(
<BasicGenerationHarness
initialDialog={createDialog({
generationReferences: [
{
id: 'ref-a',
label: '参考图A',
src: 'data:image/png;base64,cmVmQQ==',
},
{
id: 'ref-b',
label: '参考图B',
src: 'data:image/png;base64,cmVmQg==',
},
],
})}
/>,
);
const panel = screen.getByRole('dialog', { name: '生成图片' });
const strip = panel.querySelector('.image-canvas-editor__reference-strip');
expect(strip).toBeTruthy();
expect(within(strip as HTMLElement).getByText('参考图A')).toBeTruthy();
expect(within(strip as HTMLElement).getByText('参考图B')).toBeTruthy();
expect(
within(strip as HTMLElement).getByRole('button', { name: '添加参考图' }),
).toBeTruthy();
});
it('removes an existing reference from its hover delete button', () => {
render(
<BasicGenerationHarness
initialDialog={createDialog({
generationReferences: [
{
id: 'ref-a',
label: '参考图A',
src: 'data:image/png;base64,cmVmQQ==',
},
],
})}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '删除参考图A' }));
expect(screen.queryByText('参考图A')).toBeNull();
});
it('shows only current image options and opens upward option panels', () => {
render(<BasicGenerationHarness />);
const panel = screen.getByRole('dialog', { name: '生成图片' });
fireEvent.click(
within(panel).getByRole('button', {
name: '生成图片尺寸 16:9 · 1K',
}),
);
const dimensionPanel = screen.getByRole('menu', {
name: '生成图片尺寸选项',
});
expect(within(dimensionPanel).getByText('比例')).toBeTruthy();
fireEvent.click(within(dimensionPanel).getByRole('button', { name: '2K' }));
expect(screen.getByLabelText('当前尺寸').textContent).toBe('2K');
fireEvent.click(
within(panel).getByRole('button', {
name: '生成图片模型 nanobanana2',
}),
);
const modelPanel = screen.getByRole('menu', { name: '生成图片模型选项' });
fireEvent.click(
within(modelPanel).getByRole('button', { name: /gpt-image-2/ }),
);
expect(screen.getByLabelText('当前模型').textContent).toBe('gpt-image-2');
const submitButton = within(panel).getByRole('button', { name: '生成' });
expect(submitButton.textContent).toBe('生成12泥点');
});
it('clicks the parent generation panel to collapse an opened option panel', () => {
render(<BasicGenerationHarness />);
const panel = screen.getByRole('dialog', { name: '生成图片' });
fireEvent.click(
within(panel).getByRole('button', {
name: '生成图片尺寸 16:9 · 1K',
}),
);
expect(
screen.getByRole('menu', { name: '生成图片尺寸选项' }),
).toBeTruthy();
fireEvent.click(screen.getByRole('textbox', { name: '生成提示词' }));
expect(
screen.queryByRole('menu', { name: '生成图片尺寸选项' }),
).toBeNull();
});
it('closes through its interface', () => {
const closeComposer = vi.fn();
const alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => {});
render(<BasicGenerationHarness onClose={closeComposer} />);
fireEvent.click(screen.getByRole('button', { name: '生成比例 1:1 2k 1张' }));
fireEvent.click(screen.getByRole('button', { name: '生成模型 GPT Image' }));
fireEvent.click(screen.getByRole('button', { name: '关闭生成图片' }));
expect(alertSpy).toHaveBeenCalledWith('生成参数功能建设中');
expect(alertSpy).toHaveBeenCalledWith('模型选择功能建设中');
expect(closeComposer).toHaveBeenCalledTimes(1);
alertSpy.mockRestore();
});
});
@@ -1,30 +1,49 @@
import { ChevronDown, ImageIcon, X } from 'lucide-react';
import { type CSSProperties, type Dispatch, type SetStateAction } from 'react';
import { ImageIcon, X } from 'lucide-react';
import {
type CSSProperties,
type Dispatch,
type ReactNode,
type RefObject,
type SetStateAction,
} from 'react';
import { PlatformActionButton } from '../common/PlatformActionButton';
import { PlatformIconButton } from '../common/PlatformIconButton';
import { PlatformInlineOptionButton } from '../common/PlatformInlineOptionButton';
import {
PlatformFloatingMenu,
PlatformFloatingMenuItem,
} from '../common/PlatformFloatingMenu';
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
import { PlatformTextField } from '../common/PlatformTextField';
import { EditorIconButton } from './ImageCanvasEditorPrimitives';
import type {
CharacterReferenceImage,
GenerateDialogState,
UploadTarget,
} from './ImageCanvasEditorTypes';
import { ImageCanvasGenerationImageOptionsView } from './ImageCanvasGenerationImageOptionsView';
import { ImageCanvasReferenceSlot } from './ImageCanvasReferenceSlot';
import {
EDITOR_GENERATION_MUD_POINT_CONFIG,
} from './ImageCanvasGenerationModel';
import { useImageCanvasFloatingOptionDismiss } from './useImageCanvasFloatingOptionDismiss';
type ImageCanvasBasicGenerationComposerViewProps = {
dialog: GenerateDialogState;
style: CSSProperties;
setGenerateDialog: Dispatch<SetStateAction<GenerateDialogState | null>>;
onRequestUpload: (target: UploadTarget) => void;
generationReferenceButtonRef?: RefObject<HTMLButtonElement | null>;
isGenerationReferenceMenuOpen?: boolean;
setIsGenerationReferenceMenuOpen?: Dispatch<SetStateAction<boolean>>;
setIsPickingGenerationReferenceFromCanvas?: Dispatch<SetStateAction<boolean>>;
renderEditorPortal?: (node: ReactNode) => ReactNode;
buildPortalMenuStyle?: (
anchor: HTMLElement | null,
placement: 'above' | 'below',
) => CSSProperties;
onRequestUpload: (target: 'generation-reference') => void;
onToggleReferenceMenu?: () => void;
onRememberImageModel?: (model: string) => void;
onSubmit: (dialog: GenerateDialogState) => void;
onClose: () => void;
};
function triggerPlaceholderAction(label: string) {
window.alert(`${label}功能建设中`);
}
function resetFailedDialogStatus(dialog: GenerateDialogState) {
return {
...dialog,
@@ -33,44 +52,105 @@ function resetFailedDialogStatus(dialog: GenerateDialogState) {
};
}
function ReferenceChip({
reference,
index,
onRemove,
}: {
reference: CharacterReferenceImage;
index: number;
onRemove: () => void;
}) {
const label = reference.label || `参考图${index + 1}`;
return (
<ImageCanvasReferenceSlot
tone="default"
icon={<ImageIcon className="h-4 w-4" aria-hidden="true" />}
imageSrc={reference.src}
label={label}
ariaLabel={label}
title={reference.label}
onRemove={onRemove}
removeLabel={`删除${label}`}
/>
);
}
export function ImageCanvasBasicGenerationComposerView({
dialog,
style,
setGenerateDialog,
generationReferenceButtonRef,
isGenerationReferenceMenuOpen = false,
setIsGenerationReferenceMenuOpen,
setIsPickingGenerationReferenceFromCanvas,
renderEditorPortal = (node) => node,
buildPortalMenuStyle = () => ({}),
onRequestUpload,
onToggleReferenceMenu,
onRememberImageModel = () => {},
onSubmit,
onClose,
}: ImageCanvasBasicGenerationComposerViewProps) {
const references = dialog.generationReferences ?? [];
useImageCanvasFloatingOptionDismiss({
isOpen: isGenerationReferenceMenuOpen,
boundaryRefs: [generationReferenceButtonRef],
onDismiss: () => setIsGenerationReferenceMenuOpen?.(false),
});
return (
<form
className="image-canvas-editor__generation-composer"
style={style}
role="dialog"
aria-label="生成图片"
onPointerDown={(event) => event.stopPropagation()}
onSubmit={(event) => {
event.preventDefault();
if (dialog.status !== 'generating') {
onSubmit(dialog);
}
}}
>
<PlatformIconButton
variant="surfaceFloating"
className="image-canvas-editor__generation-ref"
label="添加参考图"
disabled={dialog.status === 'generating'}
onClick={() => onRequestUpload('asset')}
icon={<ImageIcon className="h-4 w-4" />}
<>
<form
className="image-canvas-editor__generation-composer image-canvas-editor__generation-composer--image"
style={style}
role="dialog"
aria-label="生成图片"
onPointerDown={(event) => event.stopPropagation()}
onSubmit={(event) => {
event.preventDefault();
if (dialog.status !== 'generating') {
onSubmit(dialog);
}
}}
>
<span>参考图</span>
</PlatformIconButton>
<div className="image-canvas-editor__reference-strip">
{references.map((reference, index) => (
<ReferenceChip
key={reference.id}
reference={reference}
index={index}
onRemove={() =>
setGenerateDialog((currentDialog) =>
currentDialog
? {
...resetFailedDialogStatus(currentDialog),
generationReferences: (
currentDialog.generationReferences ?? []
).filter((item) => item.id !== reference.id),
}
: currentDialog,
)
}
/>
))}
<ImageCanvasReferenceSlot
buttonRef={generationReferenceButtonRef}
tone="default"
icon={<ImageIcon className="h-4 w-4" aria-hidden="true" />}
label="参考图"
ariaLabel="添加参考图"
disabled={dialog.status === 'generating'}
isAdd
onClick={() => onToggleReferenceMenu?.()}
/>
</div>
<PlatformTextField
variant="textarea"
aria-label="生成提示词"
value={dialog.prompt}
disabled={dialog.status === 'generating'}
placeholder="今天我们要创作什么"
placeholder="今天想生成什么画面?"
size="sm"
density="compact"
className="image-canvas-editor__generation-prompt"
@@ -86,35 +166,18 @@ export function ImageCanvasBasicGenerationComposerView({
}
/>
<div className="image-canvas-editor__generation-composer-footer">
<PlatformInlineOptionButton
className="image-canvas-editor__generation-ratio"
aria-label="生成比例 1:1 2k 1张"
disabled={dialog.status === 'generating'}
onClick={() => triggerPlaceholderAction('生成参数')}
trailingIcon={<ChevronDown className="h-3 w-3" />}
>
中 · 1:1(2k) · 1张
</PlatformInlineOptionButton>
<PlatformInlineOptionButton
className="image-canvas-editor__generation-model"
aria-label="生成模型 GPT Image"
disabled={dialog.status === 'generating'}
onClick={() => triggerPlaceholderAction('模型选择')}
trailingIcon={<ChevronDown className="h-3 w-3" />}
>
GPT Im...
</PlatformInlineOptionButton>
<PlatformActionButton
type="submit"
tone="secondary"
size="xs"
shape="pill"
className="image-canvas-editor__generation-submit"
disabled={dialog.status === 'generating'}
aria-label="生成"
>
{dialog.status === 'generating' ? '生成中' : '12'}
</PlatformActionButton>
<ImageCanvasGenerationImageOptionsView
dialog={dialog}
setGenerateDialog={setGenerateDialog}
includeDimensions
onRememberImageModel={onRememberImageModel}
optionLabelPrefix="生成图片"
cost={EDITOR_GENERATION_MUD_POINT_CONFIG.image}
submitLabel="生成"
submitAriaLabel="生成"
renderEditorPortal={renderEditorPortal}
buildPortalMenuStyle={buildPortalMenuStyle}
/>
</div>
{dialog.status === 'generating' ? (
<PlatformStatusMessage
@@ -146,6 +209,38 @@ export function ImageCanvasBasicGenerationComposerView({
disabled={dialog.status === 'generating'}
onClick={onClose}
/>
</form>
</form>
{isGenerationReferenceMenuOpen && generationReferenceButtonRef
? renderEditorPortal(
<PlatformFloatingMenu
className="image-canvas-editor__spec-menu image-canvas-editor__portal-menu"
label="参考图来源"
placement="top-start"
style={buildPortalMenuStyle(
generationReferenceButtonRef.current,
'above',
)}
>
<PlatformFloatingMenuItem
onClick={() => {
setIsGenerationReferenceMenuOpen?.(false);
setIsPickingGenerationReferenceFromCanvas?.(true);
}}
>
从画布中选择
</PlatformFloatingMenuItem>
<PlatformFloatingMenuItem
onClick={() => {
setIsGenerationReferenceMenuOpen?.(false);
setIsPickingGenerationReferenceFromCanvas?.(false);
onRequestUpload('generation-reference');
}}
>
上传图片
</PlatformFloatingMenuItem>
</PlatformFloatingMenu>,
)
: null}
</>
);
}
@@ -9,12 +9,18 @@ import { ImageCanvasBottomToolbarView } from './ImageCanvasBottomToolbarView';
describe('ImageCanvasBottomToolbarView', () => {
it('renders the canvas tools and forwards tool changes', () => {
const switchTool = vi.fn();
const specToolWrapRef = createRef<HTMLSpanElement>();
const musicToolWrapRef = createRef<HTMLSpanElement>();
const publicationToolWrapRef = createRef<HTMLSpanElement>();
render(
<ImageCanvasBottomToolbarView
specToolWrapRef={createRef<HTMLSpanElement>()}
publicationToolWrapRef={createRef<HTMLSpanElement>()}
specToolWrapRef={specToolWrapRef}
musicToolWrapRef={musicToolWrapRef}
publicationToolWrapRef={publicationToolWrapRef}
effectiveTool="generate"
onSwitchTool={switchTool}
onOpenToolOptions={vi.fn()}
onCloseToolOptions={vi.fn()}
/>,
);
@@ -22,9 +28,9 @@ describe('ImageCanvasBottomToolbarView', () => {
expect(
within(toolbar)
.getByRole('button', { name: '生成工具' })
.getByRole('button', { name: '生成图片' })
.getAttribute('aria-pressed'),
).toBe('true');
).toBe('false');
expect(
within(toolbar)
.getByRole('button', { name: '选择工具' })
@@ -32,13 +38,112 @@ describe('ImageCanvasBottomToolbarView', () => {
).toBe('false');
fireEvent.click(within(toolbar).getByRole('button', { name: '抓手工具' }));
fireEvent.click(within(toolbar).getByRole('button', { name: '生成视频' }));
fireEvent.click(within(toolbar).getByRole('button', { name: '生成规范' }));
fireEvent.click(
within(toolbar).getByRole('button', { name: '生成UI设计图' }),
);
fireEvent.click(within(toolbar).getByRole('button', { name: '生成音乐' }));
fireEvent.click(
within(toolbar).getByRole('button', { name: '生成图标素材' }),
);
fireEvent.click(within(toolbar).getByRole('button', { name: '宣发素材' }));
fireEvent.click(within(toolbar).getByRole('button', { name: '文字工具' }));
expect(switchTool).toHaveBeenNthCalledWith(1, 'hand');
expect(switchTool).toHaveBeenNthCalledWith(2, 'spec');
expect(switchTool).toHaveBeenNthCalledWith(3, 'publication');
expect(switchTool).toHaveBeenNthCalledWith(4, 'text');
expect(switchTool).toHaveBeenNthCalledWith(2, 'video');
expect(switchTool).toHaveBeenNthCalledWith(3, 'spec');
expect(switchTool).toHaveBeenNthCalledWith(4, 'ui-design');
expect(switchTool).toHaveBeenNthCalledWith(5, 'music');
expect(switchTool).toHaveBeenNthCalledWith(6, 'icon');
expect(switchTool).toHaveBeenNthCalledWith(7, 'publication');
});
it('only keeps the select and hand tools visibly pressed', () => {
const specToolWrapRef = createRef<HTMLSpanElement>();
const musicToolWrapRef = createRef<HTMLSpanElement>();
const publicationToolWrapRef = createRef<HTMLSpanElement>();
const renderToolbar = (effectiveTool: Parameters<
typeof ImageCanvasBottomToolbarView
>[0]['effectiveTool']) => (
<ImageCanvasBottomToolbarView
specToolWrapRef={specToolWrapRef}
musicToolWrapRef={musicToolWrapRef}
publicationToolWrapRef={publicationToolWrapRef}
effectiveTool={effectiveTool}
onSwitchTool={vi.fn()}
onOpenToolOptions={vi.fn()}
onCloseToolOptions={vi.fn()}
/>
);
const { rerender } = render(renderToolbar('select'));
const toolbar = screen.getByRole('toolbar', { name: 'AI画布工具栏' });
expect(
within(toolbar)
.getByRole('button', { name: '选择工具' })
.getAttribute('aria-pressed'),
).toBe('true');
rerender(renderToolbar('hand'));
expect(
within(toolbar)
.getByRole('button', { name: '抓手工具' })
.getAttribute('aria-pressed'),
).toBe('true');
for (const tool of [
['upload', '上传工具'],
['generate', '生成图片'],
['video', '生成视频'],
['music', '生成音乐'],
['spec', '生成规范'],
['character', '生成角色形象'],
['icon', '生成图标素材'],
['ui-design', '生成UI设计图'],
['publication', '宣发素材'],
] as const) {
rerender(renderToolbar(tool[0]));
expect(
within(toolbar)
.getByRole('button', { name: tool[1] })
.getAttribute('aria-pressed'),
).toBe('false');
}
});
it('opens and closes bottom option tools on hover', () => {
const openToolOptions = vi.fn();
const closeToolOptions = vi.fn();
const specToolWrapRef = createRef<HTMLSpanElement>();
const musicToolWrapRef = createRef<HTMLSpanElement>();
const publicationToolWrapRef = createRef<HTMLSpanElement>();
render(
<ImageCanvasBottomToolbarView
specToolWrapRef={specToolWrapRef}
musicToolWrapRef={musicToolWrapRef}
publicationToolWrapRef={publicationToolWrapRef}
effectiveTool="select"
onSwitchTool={vi.fn()}
onOpenToolOptions={openToolOptions}
onCloseToolOptions={closeToolOptions}
/>,
);
fireEvent.pointerEnter(specToolWrapRef.current!);
fireEvent.pointerLeave(specToolWrapRef.current!);
fireEvent.pointerEnter(musicToolWrapRef.current!);
fireEvent.pointerLeave(musicToolWrapRef.current!);
fireEvent.pointerEnter(publicationToolWrapRef.current!);
fireEvent.pointerLeave(publicationToolWrapRef.current!);
expect(openToolOptions).toHaveBeenNthCalledWith(1, 'spec');
expect(closeToolOptions).toHaveBeenNthCalledWith(1, 'spec');
expect(openToolOptions).toHaveBeenNthCalledWith(2, 'music');
expect(closeToolOptions).toHaveBeenNthCalledWith(2, 'music');
expect(openToolOptions).toHaveBeenNthCalledWith(3, 'publication');
expect(closeToolOptions).toHaveBeenNthCalledWith(3, 'publication');
});
});
@@ -1,51 +1,78 @@
import {
AppWindow,
Clapperboard,
ClipboardList,
Download,
Grid2X2,
Hand,
ImageIcon,
ImagePlus,
Megaphone,
MousePointer2,
Shapes,
Sparkles,
Type,
WandSparkles,
Music,
UserRound,
} from 'lucide-react';
import type { RefObject } from 'react';
import { EditorIconButton } from './ImageCanvasEditorPrimitives';
import type { CanvasTool } from './ImageCanvasEditorTypes';
type ToolbarOptionTool = Extract<CanvasTool, 'music' | 'spec' | 'publication'>;
type ImageCanvasBottomToolbarViewProps = {
specToolWrapRef: RefObject<HTMLSpanElement | null>;
musicToolWrapRef: RefObject<HTMLSpanElement | null>;
publicationToolWrapRef: RefObject<HTMLSpanElement | null>;
effectiveTool: CanvasTool;
onSwitchTool: (tool: CanvasTool) => void;
onOpenToolOptions: (tool: ToolbarOptionTool) => void;
onCloseToolOptions: (tool: ToolbarOptionTool) => void;
};
const canvasTools: Array<{
id: CanvasTool;
label: string;
icon: typeof MousePointer2;
separatorBefore?: boolean;
}> = [
{ id: 'select', label: '选择工具', icon: MousePointer2 },
{ id: 'hand', label: '抓手工具', icon: Hand },
{ id: 'upload', label: '上传工具', icon: ImagePlus },
{ id: 'generate', label: '生成工具', icon: WandSparkles },
{ id: 'spec', label: '生成规范', icon: ClipboardList },
{ id: 'character', label: '生成角色形象', icon: Sparkles },
{ id: 'icon', label: '生成图标素材', icon: ImageIcon },
{
id: 'generate',
label: '生成图片',
icon: ImageIcon,
separatorBefore: true,
},
{ id: 'video', label: '生成视频', icon: Clapperboard },
{ id: 'music', label: '生成音乐', icon: Music },
{ id: 'publication', label: '宣发素材', icon: Megaphone },
{ id: 'text', label: '文字工具', icon: Type },
{ id: 'shape', label: '形状标注工具', icon: Shapes },
{ id: 'export', label: '导出工具', icon: Download },
{
id: 'spec',
label: '生成规范',
icon: ClipboardList,
separatorBefore: true,
},
{ id: 'character', label: '生成角色形象', icon: UserRound },
{ id: 'icon', label: '生成图标素材', icon: Grid2X2 },
{ id: 'ui-design', label: '生成UI设计图', icon: AppWindow },
];
function isToolbarOptionTool(tool: CanvasTool): tool is ToolbarOptionTool {
return tool === 'music' || tool === 'spec' || tool === 'publication';
}
function hasPersistentPressedState(tool: CanvasTool) {
return tool === 'select' || tool === 'hand';
}
export function ImageCanvasBottomToolbarView({
specToolWrapRef,
musicToolWrapRef,
publicationToolWrapRef,
effectiveTool,
onSwitchTool,
onOpenToolOptions,
onCloseToolOptions,
}: ImageCanvasBottomToolbarViewProps) {
return (
<div
@@ -54,46 +81,68 @@ export function ImageCanvasBottomToolbarView({
aria-label="AI画布工具栏"
onPointerDown={(event) => event.stopPropagation()}
>
{canvasTools.map(({ id, label, icon: Icon }) =>
id === 'spec' ? (
<span
key={id}
ref={specToolWrapRef}
className="image-canvas-editor__spec-tool-wrap"
>
{canvasTools.map(({ id, label, icon: Icon, separatorBefore }) => {
if (isToolbarOptionTool(id)) {
return (
<span key={id} className="image-canvas-editor__bottom-tool-group">
{separatorBefore ? (
<span
aria-hidden="true"
className="image-canvas-editor__bottom-toolbar-divider"
/>
) : null}
<span
ref={
id === 'spec'
? specToolWrapRef
: id === 'music'
? musicToolWrapRef
: publicationToolWrapRef
}
className={[
'image-canvas-editor__bottom-toolbar-option-wrap',
id === 'spec' ? 'image-canvas-editor__spec-tool-wrap' : '',
id === 'publication'
? 'image-canvas-editor__publication-tool-wrap'
: '',
]
.filter(Boolean)
.join(' ')}
onPointerEnter={() => onOpenToolOptions(id)}
onPointerLeave={() => onCloseToolOptions(id)}
onFocus={() => onOpenToolOptions(id)}
onBlur={() => onCloseToolOptions(id)}
>
<EditorIconButton
label={label}
title={label}
icon={Icon}
pressed={hasPersistentPressedState(id) && effectiveTool === id}
onClick={() => onSwitchTool(id)}
/>
</span>
</span>
);
}
return (
<span key={id} className="image-canvas-editor__bottom-tool-group">
{separatorBefore ? (
<span
aria-hidden="true"
className="image-canvas-editor__bottom-toolbar-divider"
/>
) : null}
<EditorIconButton
label={label}
title={label}
icon={Icon}
pressed={effectiveTool === id}
pressed={hasPersistentPressedState(id) && effectiveTool === id}
onClick={() => onSwitchTool(id)}
/>
</span>
) : id === 'publication' ? (
<span
key={id}
ref={publicationToolWrapRef}
className="image-canvas-editor__publication-tool-wrap"
>
<EditorIconButton
label={label}
title={label}
icon={Icon}
pressed={effectiveTool === id}
onClick={() => onSwitchTool(id)}
/>
</span>
) : (
<EditorIconButton
key={id}
label={label}
title={label}
icon={Icon}
pressed={effectiveTool === id}
onClick={() => onSwitchTool(id)}
/>
),
)}
);
})}
</div>
);
}
@@ -4,9 +4,27 @@ import { fireEvent, render, screen } from '@testing-library/react';
import { useState } from 'react';
import { describe, expect, it, vi } from 'vitest';
import type { CharacterAnimationPanelState } from './ImageCanvasEditorTypes';
import type { CanvasLayer, CharacterAnimationPanelState } from './ImageCanvasEditorTypes';
import { ImageCanvasCharacterAnimationPanelView } from './ImageCanvasCharacterAnimationPanelView';
function createSourceLayer(): CanvasLayer {
return {
id: 'layer-a',
resourceId: 'resource-a',
title: '角色源图',
src: 'data:image/png;base64,Y2hhcmFjdGVy',
x: 0,
y: 0,
width: 128,
height: 128,
originalWidth: 128,
originalHeight: 128,
zIndex: 1,
sourceType: 'uploaded',
};
}
function createPanel(
patch: Partial<CharacterAnimationPanelState> = {},
): CharacterAnimationPanelState {
@@ -64,6 +82,7 @@ function CharacterAnimationPanelHarness({
<div>
<ImageCanvasCharacterAnimationPanelView
panel={panel}
sourceLayer={createSourceLayer()}
style={{ left: 12, top: 24 }}
price={18}
setCharacterAnimationPanel={setPanel}
@@ -84,7 +103,7 @@ function CharacterAnimationPanelHarness({
}
describe('ImageCanvasCharacterAnimationPanelView', () => {
it('updates prompt, resolution and ratio while clearing failed state', () => {
it('keeps source reference first and updates prompt, resolution and ratio from a menu while clearing failed state', () => {
render(
<CharacterAnimationPanelHarness
initialPanel={createPanel({
@@ -97,20 +116,37 @@ describe('ImageCanvasCharacterAnimationPanelView', () => {
fireEvent.change(screen.getByLabelText('动画描述'), {
target: { value: `${'a'.repeat(4001)}` },
});
fireEvent.change(screen.getByLabelText('分辨率'), {
target: { value: '720p' },
});
fireEvent.change(screen.getByLabelText('画面比例'), {
target: { value: '16:9' },
});
fireEvent.click(
screen.getByRole('button', { name: '动画参数 同图尺寸 · 4秒 · 480p' }),
);
const menu = screen.getByRole('menu', { name: '动画参数选项' });
fireEvent.click(screen.getByRole('button', { name: '清晰度 720p' }));
fireEvent.click(screen.getByRole('button', { name: '比例 16:9' }));
expect(screen.getByRole('menu', { name: '动画参数选项' })).toBeTruthy();
expect(screen.getByLabelText('当前动画描述').textContent).toHaveLength(4000);
expect(screen.getByLabelText('当前分辨率').textContent).toBe('720p');
expect(screen.getByLabelText('当前比例').textContent).toBe('16:9');
expect(menu).toBeTruthy();
expect(screen.getByLabelText('当前状态').textContent).toBe('idle');
expect(screen.getByLabelText('当前错误').textContent).toBe('-');
});
it('clicks the parent animation panel to collapse the parameter menu', () => {
render(
<CharacterAnimationPanelHarness initialPanel={createPanel()} />,
);
fireEvent.click(
screen.getByRole('button', { name: '动画参数 同图尺寸 · 4秒 · 480p' }),
);
expect(screen.getByRole('menu', { name: '动画参数选项' })).toBeTruthy();
fireEvent.click(screen.getByRole('textbox', { name: '动画描述' }));
expect(screen.queryByRole('menu', { name: '动画参数选项' })).toBeNull();
});
it('applies preset prompt and duration updates', () => {
const updateDuration = vi.fn();
render(
@@ -124,9 +160,10 @@ describe('ImageCanvasCharacterAnimationPanelView', () => {
);
fireEvent.click(screen.getByRole('button', { name: '行走' }));
fireEvent.change(screen.getByLabelText('时长'), {
target: { value: '48' },
});
fireEvent.click(
screen.getByRole('button', { name: '动画参数 同图尺寸 · 4秒 · 480p' }),
);
fireEvent.click(screen.getByRole('button', { name: '时长 48帧·6秒' }));
expect(screen.getByLabelText('当前动画描述').textContent).toBe(
'循环行走动作,步伐稳定。',
@@ -145,7 +182,7 @@ describe('ImageCanvasCharacterAnimationPanelView', () => {
/>,
);
fireEvent.click(screen.getByRole('button', { name: '生成' }));
fireEvent.click(screen.getByRole('button', { name: '生成18泥点' }));
fireEvent.click(
screen.getByRole('button', { name: '关闭角色动画生成面板' }),
);
@@ -177,7 +214,7 @@ describe('ImageCanvasCharacterAnimationPanelView', () => {
it('renders completed frame count and failed error state', () => {
const result = {
taskId: 'task-a',
model: 'seedance2.0' as const,
model: 'seedance2.0-fast' as const,
prompt: '行走动作',
previewVideoPath: '/preview.mp4',
frames: [],
File diff suppressed because it is too large Load Diff
@@ -92,6 +92,23 @@ function CharacterGenerationHarness({
}
describe('ImageCanvasCharacterGenerationComposerView', () => {
it('keeps the prompt as a borderless single text input with a question placeholder', () => {
render(<CharacterGenerationHarness />);
const panel = screen.getByRole('dialog', { name: '生成角色形象' });
const prompt = screen.getByRole('textbox', { name: '角色设定' });
expect(
Array.from(panel.querySelectorAll('.image-canvas-editor__field-title'))
.map((node) => node.textContent)
.join(''),
).not.toContain('角色设定');
expect(prompt.getAttribute('placeholder')).toBe('你希望角色如何设计?');
expect(prompt.className).toContain(
'image-canvas-editor__generation-prompt--borderless',
);
});
it('updates character prompt, clears failed state and submits', () => {
const submitCharacter = vi.fn();
render(
@@ -149,4 +166,32 @@ describe('ImageCanvasCharacterGenerationComposerView', () => {
expect(screen.getByLabelText('选择常规参考图').textContent).toBe('true');
});
it('removes bound character spec and regular references from their delete buttons', () => {
render(<CharacterGenerationHarness />);
fireEvent.click(screen.getByRole('button', { name: '删除角色形象规范' }));
expect(screen.getByRole('button', { name: '角色形象规范' })).toBeTruthy();
expect(screen.queryByRole('button', { name: '角色规范A' })).toBeNull();
fireEvent.click(screen.getByRole('button', { name: '删除参考图A' }));
expect(screen.queryByText('参考图A')).toBeNull();
});
it('clicks the parent character panel to collapse reference menus', () => {
render(
<CharacterGenerationHarness
initialDialog={createDialog()}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '角色规范A' }));
expect(screen.getByRole('menu', { name: '角色形象规范来源' })).toBeTruthy();
fireEvent.click(screen.getByRole('textbox', { name: '角色设定' }));
expect(
screen.queryByRole('menu', { name: '角色形象规范来源' }),
).toBeNull();
});
});
@@ -7,20 +7,22 @@ import {
type SetStateAction,
} from 'react';
import { PlatformActionButton } from '../common/PlatformActionButton';
import { PlatformFieldLabel } from '../common/PlatformFieldLabel';
import {
PlatformFloatingMenu,
PlatformFloatingMenuItem,
} from '../common/PlatformFloatingMenu';
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
import { PlatformTextField } from '../common/PlatformTextField';
import { ImageCanvasReferenceSlot } from './ImageCanvasReferenceSlot';
import type {
CharacterReferenceImage,
GenerateDialogState,
SpecGenerationType,
UploadTarget,
} from './ImageCanvasEditorTypes';
import { ImageCanvasGenerationImageOptionsView } from './ImageCanvasGenerationImageOptionsView';
import { EDITOR_GENERATION_MUD_POINT_CONFIG } from './ImageCanvasGenerationModel';
import { useImageCanvasFloatingOptionDismiss } from './useImageCanvasFloatingOptionDismiss';
type ImageCanvasCharacterGenerationComposerViewProps = {
dialog: GenerateDialogState;
@@ -53,6 +55,30 @@ function resetFailedDialogStatus(dialog: GenerateDialogState) {
};
}
function ReferenceChip({
reference,
index,
onRemove,
}: {
reference: CharacterReferenceImage;
index: number;
onRemove: () => void;
}) {
const label = reference.label || `参考图${index + 1}`;
return (
<ImageCanvasReferenceSlot
tone="character"
icon={<ImagePlus className="h-4 w-4" aria-hidden="true" />}
imageSrc={reference.src}
label={label}
ariaLabel={label}
title={reference.label}
onRemove={onRemove}
removeLabel={`删除${label}`}
/>
);
}
export function ImageCanvasCharacterGenerationComposerView({
dialog,
style,
@@ -72,6 +98,15 @@ export function ImageCanvasCharacterGenerationComposerView({
onRememberImageModel,
onSubmit,
}: ImageCanvasCharacterGenerationComposerViewProps) {
useImageCanvasFloatingOptionDismiss({
isOpen: isCharacterSpecMenuOpen || isCharacterReferenceMenuOpen,
boundaryRefs: [characterSpecButtonRef, characterReferenceButtonRef],
onDismiss: () => {
setIsCharacterSpecMenuOpen(false);
setIsCharacterReferenceMenuOpen(false);
},
});
return (
<form
className="image-canvas-editor__character-composer"
@@ -86,39 +121,33 @@ export function ImageCanvasCharacterGenerationComposerView({
}
}}
>
<div className="image-canvas-editor__character-reference-row">
<div className="image-canvas-editor__field-block image-canvas-editor__character-reference-field image-canvas-editor__character-reference-field--spec">
<PlatformFieldLabel
variant="field"
className="image-canvas-editor__field-title"
>
角色形象规范
</PlatformFieldLabel>
<span className="image-canvas-editor__character-spec-wrap">
<button
ref={characterSpecButtonRef}
type="button"
className="image-canvas-editor__character-spec-ref image-canvas-editor__reference-tile image-canvas-editor__reference-tile--spec"
disabled={dialog.status === 'generating'}
onClick={() => setIsCharacterSpecMenuOpen((open) => !open)}
>
<span className="image-canvas-editor__reference-tile-visual">
{dialog.characterSpecReference ? (
<img
src={dialog.characterSpecReference.src}
alt=""
aria-hidden="true"
/>
) : (
<ClipboardList className="h-4 w-4" aria-hidden="true" />
)}
</span>
<span className="image-canvas-editor__reference-tile-copy">
{dialog.characterSpecReference?.label ?? '角色形象规范'}
</span>
</button>
</span>
</div>
<div className="image-canvas-editor__reference-strip">
<ImageCanvasReferenceSlot
buttonRef={characterSpecButtonRef}
tone="spec"
icon={<ClipboardList className="h-4 w-4" aria-hidden="true" />}
imageSrc={dialog.characterSpecReference?.src}
label="角色规范"
ariaLabel={dialog.characterSpecReference?.label ?? '角色形象规范'}
title={dialog.characterSpecReference?.label ?? '角色形象规范'}
disabled={dialog.status === 'generating'}
isAdd={!dialog.characterSpecReference}
onClick={() => setIsCharacterSpecMenuOpen((open) => !open)}
onRemove={
dialog.characterSpecReference
? () =>
setGenerateDialog((currentDialog) =>
currentDialog?.mode === 'character'
? {
...resetFailedDialogStatus(currentDialog),
characterSpecReference: null,
}
: currentDialog,
)
: undefined
}
removeLabel="删除角色形象规范"
/>
{isCharacterSpecMenuOpen
? renderEditorPortal(
<PlatformFloatingMenu
@@ -160,90 +189,78 @@ export function ImageCanvasCharacterGenerationComposerView({
</PlatformFloatingMenu>,
)
: null}
<div className="image-canvas-editor__field-block image-canvas-editor__character-reference-field image-canvas-editor__character-reference-field--regular">
<PlatformFieldLabel
variant="field"
className="image-canvas-editor__field-title"
>
常规参考图
</PlatformFieldLabel>
<div className="image-canvas-editor__character-reference-list">
{(dialog.characterReferences ?? []).map((reference, index) => (
<span
key={reference.id}
className="image-canvas-editor__character-ref-thumb"
title={reference.label}
{(dialog.characterReferences ?? []).map((reference, index) => (
<ReferenceChip
key={reference.id}
reference={reference}
index={index}
onRemove={() =>
setGenerateDialog((currentDialog) =>
currentDialog?.mode === 'character'
? {
...resetFailedDialogStatus(currentDialog),
characterReferences: (
currentDialog.characterReferences ?? []
).filter((item) => item.id !== reference.id),
}
: currentDialog,
)
}
/>
))}
<ImageCanvasReferenceSlot
buttonRef={characterReferenceButtonRef}
tone="default"
icon={<ImagePlus className="h-4 w-4" aria-hidden="true" />}
label="参考图"
ariaLabel="上传常规参考图"
disabled={dialog.status === 'generating'}
isAdd
onClick={() => setIsCharacterReferenceMenuOpen((open) => !open)}
/>
{isCharacterReferenceMenuOpen
? renderEditorPortal(
<PlatformFloatingMenu
className="image-canvas-editor__character-spec-menu image-canvas-editor__portal-menu"
label="常规参考图来源"
placement="top-start"
style={buildPortalMenuStyle(
characterReferenceButtonRef.current,
'above',
)}
>
<img src={reference.src} alt={reference.label} />
<span className="image-canvas-editor__character-ref-index">
{index + 1}
</span>
</span>
))}
<button
ref={characterReferenceButtonRef}
type="button"
className="image-canvas-editor__character-reference-add image-canvas-editor__reference-tile image-canvas-editor__reference-tile--upload"
disabled={dialog.status === 'generating'}
onClick={() => setIsCharacterReferenceMenuOpen((open) => !open)}
>
<span className="image-canvas-editor__reference-tile-visual">
<ImagePlus className="h-4 w-4" aria-hidden="true" />
</span>
<span className="image-canvas-editor__reference-tile-copy">
上传常规参考图
</span>
</button>
{isCharacterReferenceMenuOpen
? renderEditorPortal(
<PlatformFloatingMenu
className="image-canvas-editor__character-spec-menu image-canvas-editor__portal-menu"
label="常规参考图来源"
placement="top-start"
style={buildPortalMenuStyle(
characterReferenceButtonRef.current,
'above',
)}
>
<PlatformFloatingMenuItem
className="image-canvas-editor__context-menu-item"
onClick={() => {
setIsPickingCharacterReferenceFromCanvas(true);
setIsCharacterReferenceMenuOpen(false);
}}
>
从画布中选择
</PlatformFloatingMenuItem>
<PlatformFloatingMenuItem
className="image-canvas-editor__context-menu-item"
onClick={() => {
setIsCharacterReferenceMenuOpen(false);
onRequestUpload('character-reference');
}}
>
上传图片
</PlatformFloatingMenuItem>
</PlatformFloatingMenu>,
)
: null}
</div>
</div>
<PlatformFloatingMenuItem
className="image-canvas-editor__context-menu-item"
onClick={() => {
setIsPickingCharacterReferenceFromCanvas(true);
setIsCharacterReferenceMenuOpen(false);
}}
>
从画布中选择
</PlatformFloatingMenuItem>
<PlatformFloatingMenuItem
className="image-canvas-editor__context-menu-item"
onClick={() => {
setIsCharacterReferenceMenuOpen(false);
onRequestUpload('character-reference');
}}
>
上传图片
</PlatformFloatingMenuItem>
</PlatformFloatingMenu>,
)
: null}
</div>
<label className="image-canvas-editor__field-block">
<PlatformFieldLabel
variant="field"
className="image-canvas-editor__field-title"
>
角色设定
</PlatformFieldLabel>
<label className="image-canvas-editor__field-block image-canvas-editor__field-block--single">
<PlatformTextField
variant="textarea"
aria-label="角色设定"
value={dialog.prompt}
disabled={dialog.status === 'generating'}
placeholder="你希望角色如何设计?"
size="sm"
density="compact"
className="image-canvas-editor__generation-prompt"
className="image-canvas-editor__generation-prompt image-canvas-editor__generation-prompt--borderless"
onChange={(event) =>
setGenerateDialog((currentDialog) =>
currentDialog?.mode === 'character'
@@ -273,17 +290,13 @@ export function ImageCanvasCharacterGenerationComposerView({
setGenerateDialog={setGenerateDialog}
includeDimensions
onRememberImageModel={onRememberImageModel}
optionLabelPrefix="生成图片"
cost={EDITOR_GENERATION_MUD_POINT_CONFIG.character}
submitLabel="生成"
submitAriaLabel="生成"
renderEditorPortal={renderEditorPortal}
buildPortalMenuStyle={buildPortalMenuStyle}
/>
<PlatformActionButton
type="submit"
tone="secondary"
size="xs"
shape="pill"
className="image-canvas-editor__generation-submit"
disabled={dialog.status === 'generating'}
>
{dialog.status === 'generating' ? '生成中' : '生成'}
</PlatformActionButton>
</div>
</form>
);
@@ -0,0 +1,108 @@
import { type CSSProperties, type Dispatch, type SetStateAction } from 'react';
import { X } from 'lucide-react';
import { PlatformActionButton } from '../common/PlatformActionButton';
import { PlatformTextField } from '../common/PlatformTextField';
import { EditorIconButton } from './ImageCanvasEditorPrimitives';
import type { CanvasLayer, CropExpandPanelState } from './ImageCanvasEditorTypes';
type ImageCanvasCropExpandPanelViewProps = {
panel: CropExpandPanelState;
sourceLayer: CanvasLayer;
style: CSSProperties;
setCropExpandPanel: Dispatch<SetStateAction<CropExpandPanelState | null>>;
onSubmit: () => void;
};
const CROP_EXPAND_FIELDS = [
{ key: 'left', label: '左' },
{ key: 'top', label: '上' },
{ key: 'right', label: '右' },
{ key: 'bottom', label: '下' },
] as const;
function resetFailedCropExpandPanel(panel: CropExpandPanelState) {
return {
...panel,
status: panel.status === 'failed' ? 'idle' : panel.status,
errorMessage: panel.status === 'failed' ? undefined : panel.errorMessage,
};
}
export function ImageCanvasCropExpandPanelView({
panel,
sourceLayer,
style,
setCropExpandPanel,
onSubmit,
}: ImageCanvasCropExpandPanelViewProps) {
return (
<form
className="image-canvas-editor__crop-expand-panel"
style={style}
role="dialog"
aria-label="裁扩图片"
onPointerDown={(event) => event.stopPropagation()}
onSubmit={(event) => {
event.preventDefault();
if (panel.status !== 'processing') {
onSubmit();
}
}}
>
<div className="image-canvas-editor__quick-edit-head">
<div className="image-canvas-editor__quick-edit-reference">
<img src={sourceLayer.src} alt={`${sourceLayer.title}参考图`} />
<span>{sourceLayer.title}</span>
</div>
<EditorIconButton
label="关闭裁扩图片"
title="关闭"
icon={X}
onClick={() => setCropExpandPanel(null)}
/>
</div>
<div className="image-canvas-editor__crop-expand-fields">
{CROP_EXPAND_FIELDS.map(({ key, label }) => (
<label key={key} className="image-canvas-editor__crop-expand-field">
<span>{label}</span>
<PlatformTextField
aria-label={`裁扩${label}`}
value={String(panel.insets[key])}
inputMode="numeric"
size="xs"
density="compact"
onChange={(event) =>
setCropExpandPanel((currentPanel) =>
currentPanel
? {
...resetFailedCropExpandPanel(currentPanel),
insets: {
...currentPanel.insets,
[key]: event.target.value,
},
}
: currentPanel,
)
}
/>
</label>
))}
</div>
{panel.status === 'failed' ? (
<p className="image-canvas-editor__crop-expand-error" role="alert">
{panel.errorMessage}
</p>
) : null}
<PlatformActionButton
type="submit"
tone="secondary"
size="sm"
className="image-canvas-editor__quick-edit-submit"
disabled={panel.status === 'processing'}
>
{panel.status === 'processing' ? '处理中' : '应用'}
</PlatformActionButton>
</form>
);
}
File diff suppressed because it is too large Load Diff
@@ -7,9 +7,15 @@ import {
normalizeAssetLibrary,
normalizeCanvasBackgroundHex,
resolveSnappedLayerPosition,
serializeCanvasLayout,
serializeLayer,
splitCanvasLayoutItems,
} from './ImageCanvasEditorModel';
import type { CanvasLayer, EditorAsset } from './ImageCanvasEditorTypes';
import type {
CanvasGenerationDialogState,
CanvasLayer,
EditorAsset,
} from './ImageCanvasEditorTypes';
describe('ImageCanvasEditorModel', () => {
it('normalizes valid canvas background hex values and rejects invalid input', () => {
@@ -122,6 +128,143 @@ describe('ImageCanvasEditorModel', () => {
});
});
it('hydrates audio media type and sound effect asset kind from saved layout', () => {
const hydrated = hydrateLayer(
{
layerId: 'layer-audio',
resourceId: 'resource-audio',
title: '游戏音效',
x: 10,
y: 20,
width: 420,
height: 120,
originalWidth: 420,
originalHeight: 120,
zIndex: 5,
sourceType: 'generated',
mediaType: 'audio',
assetKind: 'sound-effect',
},
new Map([
[
'resource-audio',
{ imageSrc: '/generated-character-drafts/editor-audios/sfx.mp3' },
],
]),
);
expect(hydrated).toMatchObject({
id: 'layer-audio',
src: '/generated-character-drafts/editor-audios/sfx.mp3',
mediaType: 'audio',
assetKind: 'sound-effect',
});
});
it('restores audio generation dialogs from saved layout', () => {
const dialog: CanvasGenerationDialogState = {
id: 'generation-dialog-audio',
mode: 'audio-sound-effect',
prompt: '金币掉落叮当声',
status: 'idle',
composerOpen: true,
soundType: 'loop',
soundTempo: 120,
generatedLayerId: 'layer-audio',
placeholder: {
x: 100,
y: 120,
width: 420,
height: 120,
originalWidth: 420,
originalHeight: 120,
},
};
const { generationDialogs } = splitCanvasLayoutItems(
serializeCanvasLayout({
layers: [],
canvasGenerationDialogs: [dialog],
}),
);
expect(generationDialogs).toHaveLength(1);
expect(generationDialogs[0]).toMatchObject({
id: 'generation-dialog-audio',
mode: 'audio-sound-effect',
prompt: '金币掉落叮当声',
soundType: 'loop',
soundTempo: 120,
generatedLayerId: 'layer-audio',
});
});
it('serializes generation dialogs beside layers and splits them on load', () => {
const layer: CanvasLayer = {
id: 'layer-generated',
resourceId: 'resource-generated',
title: '生成图',
src: 'data:image/png;base64,heavy',
x: 10,
y: 20,
width: 1024,
height: 768,
originalWidth: 1024,
originalHeight: 768,
zIndex: 9,
sourceType: 'generated',
};
const dialog: CanvasGenerationDialogState = {
id: 'generation-dialog-9',
mode: 'generate',
prompt: '刷新后要继续保留',
status: 'generating',
composerOpen: false,
generatedLayerId: 'layer-generated',
imageModel: 'gpt-image-2',
placeholder: {
x: 100,
y: 120,
width: 420,
height: 420,
originalWidth: 2048,
originalHeight: 2048,
},
generationReferences: [
{
id: 'reference-1',
label: '参考图',
src: 'data:image/png;base64,ref',
},
],
};
const layout = serializeCanvasLayout({
layers: [layer],
canvasGenerationDialogs: [dialog],
});
const { layerItems, generationDialogs } = splitCanvasLayoutItems(layout);
expect(layerItems).toHaveLength(1);
expect(generationDialogs).toHaveLength(1);
expect(generationDialogs[0]).toMatchObject({
id: 'generation-dialog-9',
mode: 'generate',
prompt: '刷新后要继续保留',
status: 'generating',
generatedLayerId: 'layer-generated',
imageModel: 'gpt-image-2',
placeholder: {
x: 100,
y: 120,
width: 420,
},
});
expect(generationDialogs[0]?.generationReferences?.[0]).toMatchObject({
label: '参考图',
});
});
it('snaps moving layers to nearby canvas and layer guides', () => {
const movingLayer: CanvasLayer = {
id: 'moving',
@@ -4,10 +4,13 @@ import type {
} from '../../services/image-editor/editorProjectClient';
import type {
CanvasAssetKind,
CanvasContextMenuState,
CanvasGenerationDialogState,
CanvasGenerationInputs,
CanvasLayer,
CanvasContextMenuState,
CanvasMediaType,
CanvasViewport,
CharacterReferenceImage,
EditorAsset,
EditorAssetFolder,
SnapCandidate,
@@ -150,6 +153,7 @@ export function serializeLayer(
originalHeight: layer.originalHeight,
zIndex: layer.zIndex,
sourceType: layer.sourceType,
mediaType: layer.mediaType,
prompt: layer.prompt,
actualPrompt: layer.actualPrompt,
model: layer.model,
@@ -169,6 +173,154 @@ export function serializeLayer(
};
}
type CanvasGenerationDialogSnapshot = EditorProjectLayerSnapshot & {
itemType: 'generation-dialog';
dialog: CanvasGenerationDialogState;
};
export type CanvasLayoutItems = EditorProjectLayerSnapshot[];
export function serializeCanvasGenerationDialog(
dialog: CanvasGenerationDialogState,
): CanvasGenerationDialogSnapshot {
return {
itemType: 'generation-dialog',
layerId: `generation-dialog:${dialog.id}`,
resourceId: `generation-dialog:${dialog.id}`,
dialog,
};
}
export function serializeCanvasLayout({
layers,
canvasGenerationDialogs,
}: {
layers: CanvasLayer[];
canvasGenerationDialogs: CanvasGenerationDialogState[];
}): CanvasLayoutItems {
return [
...layers.map(serializeLayer),
...canvasGenerationDialogs.map(serializeCanvasGenerationDialog),
];
}
export function isCanvasGenerationDialogLayoutItem(
item: EditorProjectLayerSnapshot,
): item is CanvasGenerationDialogSnapshot {
return (
item.itemType === 'generation-dialog' &&
Boolean(
hydrateCanvasGenerationDialog(
(item as { dialog?: unknown }).dialog,
),
)
);
}
export function splitCanvasLayoutItems(
items: EditorProjectLayerSnapshot[],
): {
layerItems: EditorProjectLayerSnapshot[];
generationDialogs: CanvasGenerationDialogState[];
} {
const layerItems: EditorProjectLayerSnapshot[] = [];
const generationDialogs: CanvasGenerationDialogState[] = [];
items.forEach((item) => {
if (isCanvasGenerationDialogLayoutItem(item)) {
const dialog = hydrateCanvasGenerationDialog(item.dialog);
if (dialog) {
generationDialogs.push(dialog);
}
return;
}
layerItems.push(item);
});
return { layerItems, generationDialogs };
}
export function hydrateCanvasGenerationDialog(
value: unknown,
): CanvasGenerationDialogState | null {
if (!value || typeof value !== 'object') {
return null;
}
const snapshot = value as Partial<CanvasGenerationDialogState>;
const id = stringOrNull(snapshot.id);
const prompt = typeof snapshot.prompt === 'string' ? snapshot.prompt : '';
if (!id || !isCanvasGenerationDialogMode(snapshot.mode)) {
return null;
}
return {
id,
mode: snapshot.mode,
prompt,
status: isGenerationStatus(snapshot.status) ? snapshot.status : 'idle',
composerOpen:
typeof snapshot.composerOpen === 'boolean'
? snapshot.composerOpen
: true,
sourceLayerId: stringOrUndefined(snapshot.sourceLayerId),
generatedLayerId: stringOrUndefined(snapshot.generatedLayerId),
specType: isSpecGenerationType(snapshot.specType)
? snapshot.specType
: undefined,
specValues: hydrateSpecFormValues(snapshot.specValues),
specReference: hydrateCharacterReference(snapshot.specReference),
generationReferences: hydrateCharacterReferences(
snapshot.generationReferences,
),
characterSpecReference: hydrateCharacterReference(
snapshot.characterSpecReference,
),
characterReferences: hydrateCharacterReferences(
snapshot.characterReferences,
),
iconSpecReference: hydrateCharacterReference(snapshot.iconSpecReference),
iconDescriptions: Array.isArray(snapshot.iconDescriptions)
? snapshot.iconDescriptions.filter(
(description): description is string =>
typeof description === 'string',
)
: undefined,
uiDesignSpecReference: hydrateCharacterReference(
snapshot.uiDesignSpecReference,
),
imageModel: stringOrUndefined(snapshot.imageModel),
videoModel:
typeof snapshot.videoModel === 'string'
? snapshot.videoModel
: undefined,
videoAspectRatio: stringOrUndefined(snapshot.videoAspectRatio),
videoResolution:
snapshot.videoResolution === '480p' || snapshot.videoResolution === '720p'
? snapshot.videoResolution
: undefined,
videoDurationSeconds:
snapshot.videoDurationSeconds === 4 ||
snapshot.videoDurationSeconds === 5
? snapshot.videoDurationSeconds
: undefined,
videoMode: snapshot.videoMode === 'std' ? 'std' : undefined,
videoSound: snapshot.videoSound === 'off' ? 'off' : undefined,
soundType:
snapshot.soundType === 'loop' || snapshot.soundType === 'one-shot'
? snapshot.soundType
: undefined,
soundTempo: bpmOrNull(snapshot.soundTempo),
makeInstrumental:
typeof snapshot.makeInstrumental === 'boolean'
? snapshot.makeInstrumental
: undefined,
aspectRatio: stringOrUndefined(snapshot.aspectRatio),
imageSize: stringOrUndefined(snapshot.imageSize),
errorMessage: stringOrUndefined(snapshot.errorMessage),
placeholder: hydrateGenerationPlaceholder(snapshot.placeholder),
};
}
export function hydrateLayer(
snapshot: EditorProjectLayerSnapshot,
resourcesById: Map<string, { imageSrc: string }>,
@@ -207,6 +359,10 @@ export function hydrateLayer(
sourceType: isCanvasSourceType(snapshot.sourceType)
? snapshot.sourceType
: 'uploaded',
mediaType:
snapshot.mediaType === 'video' || snapshot.mediaType === 'audio'
? snapshot.mediaType
: 'image',
prompt: stringOrNull(snapshot.prompt),
actualPrompt: stringOrNull(snapshot.actualPrompt),
model: stringOrNull(snapshot.model),
@@ -292,10 +448,25 @@ export function numberFromSnapshot(value: unknown, fallback: number) {
return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
}
export function bpmOrNull(value: unknown) {
if (value === null) {
return null;
}
if (typeof value !== 'number' || !Number.isFinite(value)) {
return undefined;
}
const roundedValue = Math.round(value);
return roundedValue >= 1 && roundedValue <= 300 ? roundedValue : undefined;
}
export function stringOrNull(value: unknown) {
return typeof value === 'string' && value.trim() ? value : null;
}
export function stringOrUndefined(value: unknown) {
return typeof value === 'string' && value.trim() ? value : undefined;
}
export function booleanFromSnapshot(value: unknown) {
return value === true;
}
@@ -406,7 +577,11 @@ export function canvasAssetKindOrNull(value: unknown): CanvasAssetKind | null {
return value === 'spec' ||
value === 'character' ||
value === 'icon' ||
value === 'icon-spec'
value === 'icon-spec' ||
value === 'ui-design' ||
value === 'video' ||
value === 'sound-effect' ||
value === 'background-music'
? value
: null;
}
@@ -425,6 +600,120 @@ export function isGeneratedLayer(layer: CanvasLayer) {
);
}
function isCanvasGenerationDialogMode(
value: unknown,
): value is CanvasGenerationDialogState['mode'] {
return (
value === 'generate' ||
value === 'spec' ||
value === 'character' ||
value === 'icon' ||
value === 'ui-design' ||
value === 'video' ||
value === 'audio-sound-effect' ||
value === 'audio-background-music'
);
}
function isGenerationStatus(
value: unknown,
): value is CanvasGenerationDialogState['status'] {
return value === 'idle' || value === 'generating' || value === 'failed';
}
function isSpecGenerationType(
value: unknown,
): value is NonNullable<CanvasGenerationDialogState['specType']> {
return (
value === 'character' ||
value === 'ui' ||
value === 'icon' ||
value === 'custom'
);
}
function hydrateSpecFormValues(
value: unknown,
): CanvasGenerationDialogState['specValues'] {
if (!value || typeof value !== 'object') {
return undefined;
}
const snapshot = value as Record<string, unknown>;
return {
playSetting:
typeof snapshot.playSetting === 'string' ? snapshot.playSetting : '',
artStyle: typeof snapshot.artStyle === 'string' ? snapshot.artStyle : '',
bodyRatio: typeof snapshot.bodyRatio === 'string' ? snapshot.bodyRatio : '',
characterView:
typeof snapshot.characterView === 'string'
? snapshot.characterView
: '',
customPrompt:
typeof snapshot.customPrompt === 'string' ? snapshot.customPrompt : '',
};
}
function hydrateCharacterReference(value: unknown): CharacterReferenceImage | null {
if (!value || typeof value !== 'object') {
return null;
}
const snapshot = value as Record<string, unknown>;
const id = stringOrNull(snapshot.id);
const label = stringOrNull(snapshot.label);
const src = stringOrNull(snapshot.src);
const mediaType: CanvasMediaType =
snapshot.mediaType === 'video' || snapshot.mediaType === 'audio'
? snapshot.mediaType
: 'image';
const mimeType = stringOrUndefined(snapshot.mimeType);
const sizeBytes = numberFromSnapshot(snapshot.sizeBytes, 0) || undefined;
const durationSeconds =
numberFromSnapshot(snapshot.durationSeconds, 0) || undefined;
const objectKey = stringOrUndefined(snapshot.objectKey);
const assetObjectId = stringOrUndefined(snapshot.assetObjectId);
return id && label && src
? {
id,
label,
src,
mediaType,
mimeType,
sizeBytes,
durationSeconds,
objectKey,
assetObjectId,
}
: null;
}
function hydrateCharacterReferences(
value: unknown,
): CharacterReferenceImage[] | undefined {
return Array.isArray(value)
? value.flatMap((reference) => {
const hydrated = hydrateCharacterReference(reference);
return hydrated ? [hydrated] : [];
})
: undefined;
}
function hydrateGenerationPlaceholder(
value: unknown,
): CanvasGenerationDialogState['placeholder'] {
if (!value || typeof value !== 'object') {
return undefined;
}
const snapshot = value as Record<string, unknown>;
return {
x: numberFromSnapshot(snapshot.x, 0),
y: numberFromSnapshot(snapshot.y, 0),
width: numberFromSnapshot(snapshot.width, 320),
height: numberFromSnapshot(snapshot.height, 320),
originalWidth: numberFromSnapshot(snapshot.originalWidth, 320),
originalHeight: numberFromSnapshot(snapshot.originalHeight, 320),
};
}
export function getLayerBounds(targetLayers: CanvasLayer[]) {
if (targetLayers.length === 0) {
return null;
@@ -4,14 +4,12 @@ import { fireEvent, render, screen } from '@testing-library/react';
import { createRef } from 'react';
import { describe, expect, it, vi } from 'vitest';
import type {
ImageCanvasMetadataModalViewProps,
} from './ImageCanvasMetadataModalView';
import { ImageCanvasEditorShellView } from './ImageCanvasEditorShellView';
import type { CanvasLayer } from './ImageCanvasEditorTypes';
import type { ImageCanvasMetadataModalViewProps } from './ImageCanvasMetadataModalView';
import type { ImageCanvasSidebarViewProps } from './ImageCanvasSidebarView';
import type { ImageCanvasStageViewProps } from './ImageCanvasStageView';
import type { ImageCanvasTopbarViewProps } from './ImageCanvasTopbarView';
import type { CanvasLayer } from './ImageCanvasEditorTypes';
import { ImageCanvasEditorShellView } from './ImageCanvasEditorShellView';
function createLayer(overrides: Partial<CanvasLayer> = {}): CanvasLayer {
return {
@@ -115,6 +113,7 @@ function createStageProps(): ImageCanvasStageViewProps {
return {
canvasViewportRef: createRef<HTMLDivElement>(),
specToolWrapRef: createRef<HTMLSpanElement>(),
musicToolWrapRef: createRef<HTMLSpanElement>(),
publicationToolWrapRef: createRef<HTMLSpanElement>(),
isPanning: false,
effectiveTool: 'select',
@@ -161,10 +160,13 @@ function createStageProps(): ImageCanvasStageViewProps {
onOpenLayerMetadata: vi.fn(),
onGenerationFramePointerDown: vi.fn(),
onActivateGenerationDialog: vi.fn(),
onDeleteSelectedLayer: vi.fn(),
onDeleteGenerationDialog: vi.fn(),
onOpenQuickEditPanel: vi.fn(),
onOpenEditDialog: vi.fn(),
onOpenRedrawPanel: vi.fn(),
onOpenCropExpandPanel: vi.fn(),
onRemoveBackground: vi.fn(),
onOpenCharacterAnimationPanel: vi.fn(),
onDownloadLayer: vi.fn(),
onPasteCanvasClipboard: vi.fn(),
onCopyContextLayers: vi.fn(),
onDuplicateContextLayers: vi.fn(),
@@ -192,6 +194,8 @@ function createStageProps(): ImageCanvasStageViewProps {
onToggleMinimap: vi.fn(),
onMinimapPointerDown: vi.fn(),
onSwitchTool: vi.fn(),
onOpenToolOptions: vi.fn(),
onCloseToolOptions: vi.fn(),
};
}
@@ -220,15 +224,15 @@ describe('ImageCanvasEditorShellView', () => {
/>,
);
expect(
screen.getByRole('region', { name: '图片画布编辑器' }),
).toBeTruthy();
expect(screen.getByRole('region', { name: '图片画布编辑器' })).toBeTruthy();
expect(screen.getByRole('heading', { name: '默认项目' })).toBeTruthy();
expect(screen.getByLabelText('画布工作区')).toBeTruthy();
expect(screen.getByRole('toolbar', { name: 'AI画布工具栏' })).toBeTruthy();
expect(screen.getByRole('dialog', { name: '测试图片图片信息' })).toBeTruthy();
expect(screen.getByRole('dialog', { name: '图片信息' })).toBeTruthy();
const uploadInput = screen.getByLabelText('上传图片文件') as HTMLInputElement;
const uploadInput = screen.getByLabelText(
'上传图片文件',
) as HTMLInputElement;
fireEvent.change(uploadInput);
expect(uploadInput.multiple).toBe(true);
@@ -18,6 +18,7 @@ type AssetDragPreview = {
type ImageCanvasEditorShellViewProps = {
editorRootRef: RefObject<HTMLElement | null>;
uploadInputRef: RefObject<HTMLInputElement | null>;
uploadAccept?: string;
onUploadInputChange: ChangeEventHandler<HTMLInputElement>;
assetDragPreview: AssetDragPreview | null;
sidebarProps: ComponentProps<typeof ImageCanvasSidebarView>;
@@ -29,6 +30,7 @@ type ImageCanvasEditorShellViewProps = {
export function ImageCanvasEditorShellView({
editorRootRef,
uploadInputRef,
uploadAccept = 'image/*',
onUploadInputChange,
assetDragPreview,
sidebarProps,
@@ -36,6 +38,11 @@ export function ImageCanvasEditorShellView({
stageProps,
metadataProps,
}: ImageCanvasEditorShellViewProps) {
const uploadInputLabel =
uploadAccept.includes('video') || uploadAccept.includes('audio')
? '上传媒体文件'
: '上传图片文件';
return (
<section
ref={editorRootRef}
@@ -46,9 +53,9 @@ export function ImageCanvasEditorShellView({
<input
ref={uploadInputRef}
type="file"
accept="image/*"
accept={uploadAccept}
multiple
aria-label="上传图片文件"
aria-label={uploadInputLabel}
hidden
onChange={onUploadInputChange}
/>
@@ -3,16 +3,29 @@ import type {
EditorCharacterAnimationGenerationResult,
EditorCharacterAnimationRatio,
EditorCharacterAnimationResolution,
EditorVideoModel,
} from '../../services/image-editor/editorProjectClient';
export type CanvasSourceType = 'uploaded' | 'generated' | 'mock_generated';
export type CanvasAssetKind = 'spec' | 'character' | 'icon' | 'icon-spec';
export type CanvasAssetKind =
| 'spec'
| 'character'
| 'icon'
| 'icon-spec'
| 'publication-material'
| 'ui-design'
| 'video'
| 'sound-effect'
| 'background-music';
export type CanvasMediaType = 'image' | 'video' | 'audio';
export type EditorAsset = {
id: string;
label: string;
src: string;
mediaType?: CanvasMediaType;
width: number;
height: number;
folderId: string;
@@ -52,6 +65,7 @@ export type CanvasLayer = {
resourceId: string;
title: string;
src: string;
mediaType?: CanvasMediaType;
x: number;
y: number;
width: number;
@@ -89,13 +103,13 @@ export type CanvasTool =
| 'hand'
| 'upload'
| 'generate'
| 'video'
| 'music'
| 'spec'
| 'character'
| 'icon'
| 'publication'
| 'text'
| 'shape'
| 'export';
| 'ui-design';
export type SidebarPanel = 'assets' | 'layers';
@@ -121,6 +135,12 @@ export type CharacterReferenceImage = {
id: string;
label: string;
src: string;
mediaType?: CanvasMediaType;
mimeType?: string;
sizeBytes?: number;
durationSeconds?: number;
objectKey?: string | null;
assetObjectId?: string | null;
};
export type PublicationMaterialsGameInfo = {
@@ -136,7 +156,17 @@ export type PublicationMaterialsWorkflowId =
export type GenerateDialogState = {
id?: string;
mode: 'generate' | 'edit' | 'spec' | 'character' | 'icon' | 'publication';
mode:
| 'generate'
| 'edit'
| 'spec'
| 'character'
| 'icon'
| 'publication'
| 'ui-design'
| 'video'
| 'audio-sound-effect'
| 'audio-background-music';
prompt: string;
status: 'idle' | 'generating' | 'failed';
composerOpen?: boolean;
@@ -145,6 +175,7 @@ export type GenerateDialogState = {
specType?: SpecGenerationType;
specValues?: SpecFormValues;
specReference?: CharacterReferenceImage | null;
generationReferences?: CharacterReferenceImage[];
characterSpecReference?: CharacterReferenceImage | null;
characterReferences?: CharacterReferenceImage[];
iconSpecReference?: CharacterReferenceImage | null;
@@ -152,7 +183,18 @@ export type GenerateDialogState = {
publicationWorkflowId?: PublicationMaterialsWorkflowId;
publicationGameInfo?: PublicationMaterialsGameInfo;
publicationReferences?: CharacterReferenceImage[];
uiDesignSpecReference?: CharacterReferenceImage | null;
imageModel?: string;
videoModel?: EditorVideoModel;
videoAspectRatio?: string;
videoResolution?: '480p' | '720p';
videoDurationSeconds?: 4 | 5;
videoMode?: 'std';
videoSound?: 'off';
soundType?: 'one-shot' | 'loop';
// 中文注释:音效 tempo 改为可空 BPM,null 表示用户不指定节奏。
soundTempo?: number | null;
makeInstrumental?: boolean;
aspectRatio?: string;
imageSize?: string;
errorMessage?: string;
@@ -212,6 +254,7 @@ export type CanvasContextMenuState =
};
export type QuickEditPanelState = {
mode?: 'quick-edit' | 'redraw';
sourceLayerId: string;
prompt: string;
size: string;
@@ -220,6 +263,18 @@ export type QuickEditPanelState = {
errorMessage?: string;
};
export type CropExpandPanelState = {
sourceLayerId: string;
insets: {
left: string;
top: string;
right: string;
bottom: string;
};
status: 'idle' | 'processing' | 'failed';
errorMessage?: string;
};
export type CharacterAnimationPanelState = {
sourceLayerId: string;
promptText: string;
@@ -234,10 +289,15 @@ export type CharacterAnimationPanelState = {
export type UploadTarget =
| 'asset'
| 'generation-reference'
| 'video-reference-image'
| 'video-reference-video'
| 'video-reference-audio'
| 'spec-reference'
| 'character-spec'
| 'character-reference'
| 'icon-spec'
| 'ui-design-icon-spec'
| 'publication-reference';
export type SnapGuide = {
@@ -15,9 +15,9 @@ import { describe, expect, it, vi } from 'vitest';
import {
ApiClientError,
AuthUiContext,
ImageCanvasEditorView,
createAuthValue,
dispatchPointerEvent,
ImageCanvasEditorView,
readZipText,
setupImageCanvasEditorViewTestLifecycle,
} from './ImageCanvasEditorView.test-utils';
@@ -437,8 +437,7 @@ describe('ImageCanvasEditorView', () => {
).toBe(true);
});
it('shows image resolution on hover and placeholder toolbar after selecting a layer', () => {
const alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => {});
it('shows image resolution on hover and opens the crop-expand panel after selecting a layer', () => {
render(<ImageCanvasEditorView />);
const canvasImage = screen.getByAltText('画布图片:拼图素材');
@@ -455,7 +454,7 @@ describe('ImageCanvasEditorView', () => {
clientY: 120,
});
const cropButton = screen.getByRole('button', { name: '裁剪占位' });
const cropButton = screen.getByRole('button', { name: '裁扩按钮' });
fireEvent.pointerDown(cropButton, {
button: 0,
pointerId: 2,
@@ -464,7 +463,7 @@ describe('ImageCanvasEditorView', () => {
});
fireEvent.click(cropButton);
expect(alertSpy).toHaveBeenCalledWith('裁剪功能建设中');
expect(screen.getByRole('dialog', { name: '裁扩图片' })).toBeTruthy();
expect(screen.getByRole('toolbar', { name: '图片工具栏' })).toBeTruthy();
});
@@ -489,7 +488,7 @@ describe('ImageCanvasEditorView', () => {
);
fireEvent.click(infoButton);
const infoPanel = screen.getByRole('dialog', { name: '拼图素材图片信息' });
const infoPanel = screen.getByRole('dialog', { name: '图片信息' });
expect(within(infoPanel).getByText('图片类型')).toBeTruthy();
expect(within(infoPanel).getByText('上传图片')).toBeTruthy();
expect(within(infoPanel).getByText('生成输入')).toBeTruthy();
@@ -505,7 +504,7 @@ describe('ImageCanvasEditorView', () => {
expect(
within(infoPanel).queryByRole('button', { name: '复制Prompt' }),
).toBeNull();
expect(screen.queryByRole('button', { name: '修改图片' })).toBeNull();
expect(screen.getByRole('button', { name: '重绘' })).toBeTruthy();
});
it('hydrates canvas images from Resolution instead of saved Size', async () => {
@@ -547,15 +546,21 @@ describe('ImageCanvasEditorView', () => {
fireEvent.click(
screen.getAllByRole('button', { name: '查看旧布局图片图片信息' })[0]!,
);
const infoPanel = screen.getByRole('dialog', {
name: '旧布局图片图片信息',
});
const infoPanel = screen.getByRole('dialog', { name: '图片信息' });
expect(within(infoPanel).queryByText('Size')).toBeNull();
expect(within(infoPanel).getByText('Resolution')).toBeTruthy();
expect(within(infoPanel).getByText('1536 x 1024 px')).toBeTruthy();
});
it('deletes the selected layer from the floating toolbar', () => {
it('downloads the selected layer from the floating toolbar', () => {
let downloadName = '';
let downloadHref = '';
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(
function click(this: HTMLAnchorElement) {
downloadName = this.download;
downloadHref = this.href;
},
);
render(<ImageCanvasEditorView />);
expect(screen.getByAltText('画布图片:拼图素材')).toBeTruthy();
@@ -569,9 +574,11 @@ describe('ImageCanvasEditorView', () => {
clientY: 120,
},
);
fireEvent.click(screen.getByRole('button', { name: '删除图片' }));
fireEvent.click(screen.getByRole('button', { name: '下载按钮' }));
expect(screen.queryByAltText('画布图片:拼图素材')).toBeNull();
expect(downloadName).toBe('拼图素材.png');
expect(downloadHref).toContain('/creation-type-references/puzzle.webp');
expect(screen.getByAltText('画布图片:拼图素材')).toBeTruthy();
expect(screen.getByAltText('画布图片:大鱼素材')).toBeTruthy();
});
@@ -825,7 +832,7 @@ describe('ImageCanvasEditorView', () => {
expect(
screen.getByRole('button', { name: '查看大鱼素材图片信息' }),
).toBeTruthy();
expect(screen.queryByRole('button', { name: '修改图片' })).toBeNull();
expect(screen.getByRole('button', { name: '重绘' })).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '打开素材' }));
@@ -1237,30 +1244,23 @@ describe('ImageCanvasEditorView', () => {
});
it('switches tools and restores the previous tool after holding Space', async () => {
const user = userEvent.setup();
render(<ImageCanvasEditorView />);
const bottomToolbar = screen.getByRole('toolbar', { name: 'AI画布工具栏' });
const selectTool = within(bottomToolbar).getByRole('button', {
name: '选择工具',
});
const textTool = within(bottomToolbar).getByRole('button', {
name: '文字工具',
});
const handTool = within(bottomToolbar).getByRole('button', {
name: '抓手工具',
});
expect(selectTool.getAttribute('aria-pressed')).toBe('true');
await user.click(textTool);
expect(textTool.getAttribute('aria-pressed')).toBe('true');
fireEvent.keyDown(window, { code: 'Space', key: ' ' });
expect(handTool.getAttribute('aria-pressed')).toBe('true');
fireEvent.keyUp(window, { code: 'Space', key: ' ' });
expect(textTool.getAttribute('aria-pressed')).toBe('true');
expect(selectTool.getAttribute('aria-pressed')).toBe('true');
});
it('switches away from hand tool from the bottom toolbar', async () => {
@@ -1271,15 +1271,15 @@ describe('ImageCanvasEditorView', () => {
const handTool = within(bottomToolbar).getByRole('button', {
name: '抓手工具',
});
const textTool = within(bottomToolbar).getByRole('button', {
name: '文字工具',
const selectTool = within(bottomToolbar).getByRole('button', {
name: '选择工具',
});
await user.click(handTool);
expect(handTool.getAttribute('aria-pressed')).toBe('true');
await user.click(textTool);
expect(textTool.getAttribute('aria-pressed')).toBe('true');
await user.click(selectTool);
expect(selectTool.getAttribute('aria-pressed')).toBe('true');
expect(handTool.getAttribute('aria-pressed')).toBe('false');
});
@@ -1357,12 +1357,12 @@ describe('ImageCanvasEditorView', () => {
});
const bottomToolbar = screen.getByRole('toolbar', { name: 'AI画布工具栏' });
const textTool = within(bottomToolbar).getByRole('button', {
name: '文字工具',
const handTool = within(bottomToolbar).getByRole('button', {
name: '抓手工具',
});
await user.click(textTool);
await user.click(handTool);
expect(textTool.getAttribute('aria-pressed')).toBe('true');
expect(handTool.getAttribute('aria-pressed')).toBe('true');
expect(
screen.queryByTestId('image-canvas-editor-snap-guide-vertical'),
).toBeNull();
File diff suppressed because it is too large Load Diff
@@ -2,7 +2,86 @@ export function isImageFile(file: File) {
return file.type.startsWith('image/');
}
export function readImageFileAsDataUrl(file: File) {
export const SEEDANCE_REFERENCE_ACCEPT = {
image:
'.jpg,.jpeg,.png,.webp,.bmp,.tiff,.tif,.gif,.heic,.heif,image/jpeg,image/png,image/webp,image/bmp,image/tiff,image/gif,image/heic,image/heif',
video: '.mp4,.mov,video/mp4,video/quicktime',
audio: '.wav,.mp3,audio/wav,audio/x-wav,audio/mpeg,audio/mp3',
} as const;
export function getEditorUploadAccept(target: string) {
if (target === 'video-reference-video') {
return SEEDANCE_REFERENCE_ACCEPT.video;
}
if (target === 'video-reference-audio') {
return SEEDANCE_REFERENCE_ACCEPT.audio;
}
if (target === 'video-reference-image') {
return SEEDANCE_REFERENCE_ACCEPT.image;
}
return 'image/*';
}
export const SEEDANCE_REFERENCE_FILE_LIMITS = {
imageBytes: 30 * 1024 * 1024,
videoBytes: 50 * 1024 * 1024,
audioBytes: 15 * 1024 * 1024,
mediaMinDurationSeconds: 2,
mediaMaxDurationSeconds: 15,
mediaTotalDurationSeconds: 15,
} as const;
const SEEDANCE_IMAGE_EXTENSIONS = new Set([
'jpg',
'jpeg',
'png',
'webp',
'bmp',
'tiff',
'tif',
'gif',
'heic',
'heif',
]);
const SEEDANCE_VIDEO_EXTENSIONS = new Set(['mp4', 'mov']);
const SEEDANCE_AUDIO_EXTENSIONS = new Set(['wav', 'mp3']);
function getFileExtension(file: File) {
return file.name.split('.').pop()?.toLowerCase() ?? '';
}
function hasAllowedExtension(file: File, extensions: Set<string>) {
return extensions.has(getFileExtension(file));
}
export function isSeedanceReferenceImageFile(file: File) {
return (
(file.type.startsWith('image/') || file.type === '') &&
hasAllowedExtension(file, SEEDANCE_IMAGE_EXTENSIONS)
);
}
export function isSeedanceReferenceVideoFile(file: File) {
return (
(file.type === 'video/mp4' ||
file.type === 'video/quicktime' ||
file.type === '') &&
hasAllowedExtension(file, SEEDANCE_VIDEO_EXTENSIONS)
);
}
export function isSeedanceReferenceAudioFile(file: File) {
return (
(file.type === 'audio/wav' ||
file.type === 'audio/x-wav' ||
file.type === 'audio/mpeg' ||
file.type === 'audio/mp3' ||
file.type === '') &&
hasAllowedExtension(file, SEEDANCE_AUDIO_EXTENSIONS)
);
}
export function readFileAsDataUrl(file: File) {
return new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
@@ -10,9 +89,46 @@ export function readImageFileAsDataUrl(file: File) {
resolve(reader.result);
return;
}
reject(new Error('图片读取失败'));
reject(new Error('文件读取失败'));
};
reader.onerror = () => reject(reader.error ?? new Error('图片读取失败'));
reader.onerror = () => reject(reader.error ?? new Error('文件读取失败'));
reader.readAsDataURL(file);
});
}
export function readImageFileAsDataUrl(file: File) {
return readFileAsDataUrl(file);
}
export function probeMediaDurationSeconds(
file: File,
mediaType: 'video' | 'audio',
) {
return new Promise<number>((resolve, reject) => {
if (typeof document === 'undefined' || typeof URL === 'undefined') {
resolve(Number.NaN);
return;
}
const element =
mediaType === 'video'
? document.createElement('video')
: document.createElement('audio');
const objectUrl = URL.createObjectURL(file);
const cleanup = () => {
URL.revokeObjectURL(objectUrl);
element.removeAttribute('src');
element.load();
};
element.preload = 'metadata';
element.onloadedmetadata = () => {
const duration = element.duration;
cleanup();
resolve(duration);
};
element.onerror = () => {
cleanup();
reject(new Error('读取媒体时长失败'));
};
element.src = objectUrl;
});
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -7,24 +7,29 @@ import type {
} from './ImageCanvasEditorTypes';
import {
appendCharacterReference,
appendIconDescriptionToDialog,
appendGenerationReference,
assignCharacterSpecReference,
assignIconSpecReference,
assignUiDesignSpecReference,
closeGenerateComposerDialog,
createBackgroundMusicGenerationDialogDraft,
createCharacterAnimationPanelDraft,
createCharacterGenerationDialogDraft,
createEditDialogDraft,
createGenerateDialogDraft,
createIconGenerationDialogDraft,
createPublicationGenerationDialogDraft,
createQuickEditPanelDraft,
createSoundEffectGenerationDialogDraft,
createSpecDialogDraft,
createUiDesignGenerationDialogDraft,
createVideoGenerationDialogDraft,
formatIconDescriptionsText,
hideGeneratedLayerComposerAfterBlur,
parseIconDescriptionsText,
updateCharacterAnimationDurationPanel,
updateIconDescriptionInDialog,
updateIconDescriptionsTextInDialog,
updateSpecFormDialogValue,
} from './ImageCanvasGenerationDialogModel';
import { ICON_DESCRIPTION_LIMIT } from './ImageCanvasGenerationModel';
function createLayer(overrides: Partial<CanvasLayer> = {}): CanvasLayer {
return {
@@ -115,6 +120,7 @@ describe('ImageCanvasGenerationDialogModel', () => {
imageModel: 'unknown-model',
aspectRatio: '1:1',
imageSize: '1K',
prompt: '返回按钮\n设置按钮\n下一关按钮\n提示按钮\n原图按钮\n冻结按钮',
iconSpecReference: null,
iconDescriptions: [
'返回按钮',
@@ -133,58 +139,85 @@ describe('ImageCanvasGenerationDialogModel', () => {
});
});
it('creates detail gallery publication drafts in 720p portrait ratio', () => {
const canvasSize = { width: 1000, height: 900 };
it('creates video and UI design drafts restored from the original generation entry work', () => {
const canvasSize = { width: 960, height: 720 };
const viewport = { x: 0, y: 0, scale: 1 };
expect(createVideoGenerationDialogDraft({ canvasSize, viewport }))
.toMatchObject({
mode: 'video',
status: 'idle',
composerOpen: true,
generationReferences: [],
videoModel: 'seedance2.0-fast',
videoAspectRatio: '16:9',
videoResolution: '480p',
videoDurationSeconds: 4,
videoMode: 'std',
videoSound: 'off',
placeholder: {
x: 200,
y: 202.5,
width: 560,
height: 315,
originalWidth: 1280,
originalHeight: 720,
},
});
expect(
createPublicationGenerationDialogDraft({
createUiDesignGenerationDialogDraft({
canvasSize,
viewport,
workflowId: 'publication-detail-gallery',
imageModel: 'gpt-image-2',
}),
).toMatchObject({
mode: 'publication',
publicationWorkflowId: 'publication-detail-gallery',
publicationGameInfo: {
gameName: '',
gameCategories: '',
gameDescription: '',
},
publicationReferences: [],
mode: 'ui-design',
imageModel: 'gpt-image-2',
aspectRatio: '16:9',
imageSize: '1K',
uiDesignSpecReference: null,
placeholder: {
x: 320,
y: 130,
width: 360,
height: 640,
originalWidth: 720,
originalHeight: 1280,
x: 200,
y: 202.5,
width: 560,
height: 315,
},
});
});
it('creates promo poster publication drafts in 720p landscape ratio', () => {
const canvasSize = { width: 1000, height: 900 };
it('creates audio generation drafts for sound effects and background music', () => {
const canvasSize = { width: 960, height: 720 };
const viewport = { x: 0, y: 0, scale: 1 };
expect(
createPublicationGenerationDialogDraft({
canvasSize,
viewport,
workflowId: 'publication-promo-poster',
}),
).toMatchObject({
mode: 'publication',
publicationWorkflowId: 'publication-promo-poster',
placeholder: {
x: 220,
y: 292.5,
width: 560,
height: 315,
originalWidth: 1280,
originalHeight: 720,
},
});
expect(createSoundEffectGenerationDialogDraft({ canvasSize, viewport }))
.toMatchObject({
mode: 'audio-sound-effect',
status: 'idle',
composerOpen: true,
soundType: 'one-shot',
soundTempo: null,
placeholder: {
x: 270,
y: 300,
width: 420,
height: 120,
originalWidth: 420,
originalHeight: 120,
},
});
expect(createBackgroundMusicGenerationDialogDraft({ canvasSize, viewport }))
.toMatchObject({
mode: 'audio-background-music',
status: 'idle',
composerOpen: true,
makeInstrumental: true,
placeholder: {
x: 270,
y: 300,
width: 420,
height: 120,
},
});
});
it('creates edit, quick-edit, and character animation panel drafts', () => {
@@ -202,6 +235,7 @@ describe('ImageCanvasGenerationDialogModel', () => {
sourceLayerId: 'layer-source',
});
expect(createQuickEditPanelDraft(sourceLayer)).toEqual({
mode: 'quick-edit',
sourceLayerId: 'layer-source',
prompt: '',
size: '1024x768',
@@ -269,6 +303,150 @@ describe('ImageCanvasGenerationDialogModel', () => {
expect(assignIconSpecReference(iconDialog, sourceLayer)).toBe(iconDialog);
});
it('routes picked canvas references to spec, image, video, and UI design fields', () => {
const sourceLayer = createLayer({ title: '参考图' });
const videoLayer = createLayer({
id: 'layer-video',
title: '动作参考',
src: 'https://signed.example.com/reference.mp4',
mediaType: 'video',
objectKey: 'generated-character-drafts/editor/seedance-references/video/reference.mp4',
assetObjectId: 'asset-object-video',
});
expect(
appendGenerationReference(
{
mode: 'spec',
prompt: '',
status: 'failed',
errorMessage: '失败',
},
sourceLayer,
),
).toMatchObject({
status: 'idle',
errorMessage: undefined,
specReference: { label: '参考图' },
});
expect(
appendGenerationReference(
{
mode: 'video',
prompt: '',
status: 'idle',
generationReferences: [],
},
sourceLayer,
),
).toMatchObject({
generationReferences: [{ label: '参考图' }],
});
expect(
appendGenerationReference(
{
mode: 'video',
prompt: '',
status: 'idle',
videoModel: 'seedance2.0-fast',
generationReferences: [],
},
videoLayer,
),
).toMatchObject({
generationReferences: [
{
label: '动作参考',
mediaType: 'video',
objectKey:
'generated-character-drafts/editor/seedance-references/video/reference.mp4',
assetObjectId: 'asset-object-video',
},
],
});
const imageDialog: GenerateDialogState = {
mode: 'generate',
prompt: '',
status: 'idle',
generationReferences: [],
};
expect(appendGenerationReference(imageDialog, videoLayer)).toBe(imageDialog);
const specDialog: GenerateDialogState = {
mode: 'spec',
prompt: '',
status: 'idle',
};
expect(appendGenerationReference(specDialog, videoLayer)).toBe(specDialog);
const uiDialog: GenerateDialogState = {
mode: 'ui-design',
prompt: '',
status: 'failed',
errorMessage: '失败',
};
expect(assignUiDesignSpecReference(uiDialog, sourceLayer)).toBe(uiDialog);
expect(
assignUiDesignSpecReference(
uiDialog,
createLayer({ title: '图标规范', assetKind: 'icon-spec' }),
),
).toMatchObject({
status: 'idle',
errorMessage: undefined,
uiDesignSpecReference: { label: '图标规范' },
});
});
it('limits Seedance canvas-picked references by media type', () => {
const videoDialog: GenerateDialogState = {
mode: 'video',
prompt: '',
status: 'idle',
videoModel: 'seedance2.0',
generationReferences: Array.from({ length: 3 }, (_, index) => ({
id: `video-${index}`,
label: `视频${index + 1}`,
src: `generated-character-drafts/video-${index}.mp4`,
mediaType: 'video',
})),
};
expect(
appendGenerationReference(
videoDialog,
createLayer({
id: 'video-extra',
title: '额外视频',
mediaType: 'video',
src: 'generated-character-drafts/video-extra.mp4',
}),
),
).toMatchObject({
generationReferences: [
{ label: '视频1' },
{ label: '视频2' },
{ label: '视频3' },
],
});
expect(
appendGenerationReference(
videoDialog,
createLayer({
id: 'audio-first',
title: '旁白',
mediaType: 'audio',
src: 'generated-character-drafts/audio.mp3',
}),
),
).toMatchObject({
generationReferences: [
{ label: '视频1' },
{ label: '视频2' },
{ label: '视频3' },
{ label: '旁白', mediaType: 'audio' },
],
});
});
it('updates failed spec and icon dialog fields back to idle state', () => {
const specDialog: GenerateDialogState = {
mode: 'spec',
@@ -305,31 +483,22 @@ describe('ImageCanvasGenerationDialogModel', () => {
errorMessage: '描述错误',
iconDescriptions: ['旧描述'],
};
expect(updateIconDescriptionInDialog(iconDialog, 0, '新描述')).toEqual(
expect(updateIconDescriptionsTextInDialog(iconDialog, '剑\n盾、药水')).toEqual(
expect.objectContaining({
status: 'idle',
errorMessage: undefined,
iconDescriptions: ['新描述'],
}),
);
expect(appendIconDescriptionToDialog(iconDialog)).toEqual(
expect.objectContaining({
iconDescriptions: ['旧描述', ''],
}),
);
expect(
appendIconDescriptionToDialog({
...iconDialog,
iconDescriptions: Array.from(
{ length: ICON_DESCRIPTION_LIMIT },
(_, index) => `图标${index}`,
),
}),
).toEqual(
expect.objectContaining({
iconDescriptions: expect.arrayContaining(['图标0']),
prompt: '剑\n盾、药水',
iconDescriptions: ['剑', '盾', '药水'],
}),
);
expect(parseIconDescriptionsText('剑,盾\n药水/宝箱|钥匙')).toEqual([
'剑',
'盾',
'药水',
'宝箱',
'钥匙',
]);
expect(formatIconDescriptionsText(['剑', '盾'])).toBe('剑\n盾');
});
it('updates character animation duration and composer visibility', () => {

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