合并远端 master(直连 Codex 改造)
Project CI / Frontend tests (pull_request) Failing after 3m11s
Project CI / Backend tests (pull_request) Successful in 3m44s
Project CI / Native shell tests (pull_request) Failing after 9m13s
Project CI / Repository checks (pull_request) Failing after 2m16s

冲突 19 处 / 6 个文件,逐处判定归属而不是二选一:

· commands.rs —— 保留本分支的 hydrate_game_creator_plan_gdd_state 输入解析,
  丢掉与 master 重复的项目根定义(master 把它挪到了后面)。
· types.ts / useHomeProjectCreation.ts —— 取 master 把 mode/prompt/attachments
  重构成 HomeDraft 的形状,保留本分支新增的 startMode。
· App.tsx —— directGameChatRuntime(master)与 !planningStartMode(本分支)是两个
  互不相干的条件,合并保留;refreshDirectProjectSurface 在 merge base 里就带预览
  逻辑,是 master 主动删掉并改名成 refreshDirectProjectManifest 的,取 master;
  capturePendingGameChatStageManifestBeforeNextRun 是分支新增且仍被调用,保留。
· SupervisorChatOnlyView.tsx —— master 的 directCodex 短路与本分支的
  projectedRuntime / descendantsStillActive 正交,逐处合成。
· project-development.suite.ts —— 那段专业 Agent 断言 base 里就有、master 主动
  删除(直连 Codex 之后不再适用),取 master。

两处语义缺口一并补上:master 新增的 codex_app_server 构造
AgentRuntimeProviderRequestSnapshot 时缺本分支新增的 planning_session_binding
字段(首页直连对话不属于任何立项 session,填 None);首页提交按钮的 aria-label
还引用着已被删除的 homeAgentMode,取 master 的固定文案。

**做方案 UI 入口暂时缺失,待定。** master 在 bacd7a9da 里把 HomeAgentMode 整个删了,
首页的「做游戏 / 做素材 / 做方案」模式选择不再存在,而 startMode 在前端只有那一个
来源(homeDraftStartMode(mode))。立项策划链路的代码全部保留——后端、prompt、
澄清卡、审批卡、E2E 都在——但首页现在统一按 direct-build 进入
(HOME_DRAFT_START_MODE 常量,已在原处留注释)。入口放哪由产品侧决定后另做一笔。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-21 13:49:37 +00:00
114 changed files with 6589 additions and 3405 deletions
@@ -156,6 +156,49 @@ describe('ImageCanvasBasicGenerationComposerView', () => {
expect(screen.queryByText('参考图A')).toBeNull();
});
it('removes only the selected reference when multiple references are present', () => {
const submitGeneration = vi.fn();
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==',
},
{
id: 'ref-c',
label: '参考图C',
src: 'data:image/png;base64,cmVmQw==',
},
],
})}
onSubmit={submitGeneration}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '删除参考图B' }));
expect(screen.queryByText('参考图B')).toBeNull();
expect(screen.getByText('参考图A')).toBeTruthy();
expect(screen.getByText('参考图C')).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '生成' }));
expect(submitGeneration).toHaveBeenCalledWith(
expect.objectContaining({
generationReferences: [
expect.objectContaining({ id: 'ref-a' }),
expect.objectContaining({ id: 'ref-c' }),
],
}),
);
});
it('shows only current image options and opens upward option panels', () => {
render(<BasicGenerationHarness />);
@@ -194,7 +194,12 @@ describe('ImageCanvasEditorView generation integration', () => {
candidate.getAttribute('title') === label ||
candidate.textContent?.includes(label),
);
expect(button).toBeTruthy();
const chip =
button ??
Array.from(panel.querySelectorAll('[aria-label]')).find(
(candidate) => candidate.getAttribute('aria-label') === label,
);
expect(chip).toBeTruthy();
};
const createGeneratedResource = ({
@@ -2361,10 +2366,12 @@ describe('ImageCanvasEditorView generation integration', () => {
clientY: 120,
},
);
expectReferenceChip(
screen.getByRole('dialog', { name: '生成规范' }),
'拼图素材',
);
await waitFor(() => {
expectReferenceChip(
screen.getByRole('dialog', { name: '生成规范' }),
'拼图素材',
);
});
await userEvent.click(
within(screen.getByRole('dialog', { name: '生成规范' })).getByRole(
@@ -62,7 +62,10 @@ import type {
UploadTarget,
} from './ImageCanvasEditorTypes';
import { getEditorUploadAccept, isImageFile } from './ImageCanvasFileModel';
import { createLayerGenerationDialogDraft } from './ImageCanvasGenerationDialogModel';
import {
createLayerGenerationDialogDraft,
resolveSpecGenerationReferences,
} from './ImageCanvasGenerationDialogModel';
import { hydrateCanvasGenerationInputsResult } from './ImageCanvasGenerationInputsModel';
import {
applyEditorGenerationPricingConfig,
@@ -1666,8 +1669,16 @@ export function ImageCanvasEditorView({
return generateDialog?.mode === 'spec'
? {
label: '规范参考图',
currentCount: 0,
limit: 1,
currentCount:
generateDialog.specType === 'icon'
? 0
: imageReferenceCount(
resolveSpecGenerationReferences(generateDialog),
),
limit:
generateDialog.specType === 'icon'
? 1
: resolveDialogExtraImageReferenceLimit(generateDialog),
contextId: generateDialogContextId,
}
: null;
@@ -1731,7 +1742,9 @@ export function ImageCanvasEditorView({
? {
label: '生成参考图',
currentCount: imageReferenceCount(
generateDialog.generationReferences,
generateDialog.mode === 'spec'
? resolveSpecGenerationReferences(generateDialog)
: generateDialog.generationReferences,
),
limit: resolveDialogExtraImageReferenceLimit(generateDialog),
contextId: generateDialogContextId,
@@ -33,6 +33,7 @@ import {
createVideoGenerationDialogDraft,
getUnavailableNormalizedGenerationInputReferences,
hideGeneratedLayerComposerAfterBlur,
resolveSpecGenerationReferences,
updateCharacterAnimationDurationPanel,
updateIconDescriptionsTextInDialog,
updateSpecFormDialogValue,
@@ -1554,6 +1555,7 @@ describe('ImageCanvasGenerationDialogModel', () => {
prompt: '',
status: 'failed',
errorMessage: '失败',
specType: 'icon',
},
sourceLayer,
),
@@ -1562,6 +1564,29 @@ describe('ImageCanvasGenerationDialogModel', () => {
errorMessage: undefined,
specReference: { label: '参考图' },
});
const legacySpecReference = {
id: 'legacy-spec-reference',
label: '历史参考图',
src: 'data:image/png;base64,legacy',
};
const migratedSpecDialog = appendGenerationReference(
{
mode: 'spec',
prompt: '',
status: 'idle',
specType: 'character',
specReference: legacySpecReference,
},
sourceLayer,
);
expect(migratedSpecDialog).toMatchObject({
specReference: null,
generationReferences: [legacySpecReference, { label: '参考图' }],
});
expect(resolveSpecGenerationReferences(migratedSpecDialog)).toEqual(
migratedSpecDialog?.generationReferences,
);
expect(
appendGenerationReference(
{
@@ -1654,6 +1679,49 @@ describe('ImageCanvasGenerationDialogModel', () => {
});
});
it('restores canonical non-icon spec references for same-source generation', () => {
const legacyReference = {
id: 'legacy-reference',
label: '历史参考',
src: 'data:image/png;base64,legacy',
};
const currentReference = {
id: 'current-reference',
label: '当前参考',
src: 'data:image/png;base64,current',
};
expect(
createSameSourceGenerationDialogDraft({
sourceLayer: createLayer({
sourceType: 'generated',
assetKind: 'spec',
generationInputs: {
fields: [{ title: '规范类型', value: '角色规范' }],
references: [],
},
}),
sourceDialog: {
id: 'source-spec-dialog',
mode: 'spec',
prompt: '',
status: 'idle',
specType: 'character',
specReference: legacyReference,
generationReferences: [currentReference],
},
canvasSize: { width: 960, height: 720 },
viewport: { x: 0, y: 0, scale: 1 },
mode: 'redraw',
}),
).toMatchObject({
mode: 'spec',
specType: 'character',
specReference: null,
generationReferences: [currentReference],
});
});
it('limits Seedance canvas-picked references by media type', () => {
const videoDialog: GenerateDialogState = {
mode: 'video',
@@ -94,6 +94,41 @@ function resetFailedGenerationDialog(dialog: GenerateDialogState) {
};
}
export function resolveSpecGenerationReferences(
dialog: GenerateDialogState | null | undefined,
) {
if (!dialog || dialog.mode !== 'spec') {
return [];
}
if (dialog.specType === 'icon') {
return dialog.specReference ? [dialog.specReference] : [];
}
if (dialog.generationReferences?.length) {
return dialog.generationReferences;
}
return dialog.specReference ? [dialog.specReference] : [];
}
export function setSpecGenerationReferences(
dialog: GenerateDialogState,
references: NonNullable<GenerateDialogState['generationReferences']>,
) {
if (dialog.mode !== 'spec') {
return dialog;
}
return dialog.specType === 'icon'
? {
...dialog,
specReference: references[0] ?? null,
generationReferences: [],
}
: {
...dialog,
specReference: null,
generationReferences: references,
};
}
function resetFailedGenerationPanel(panel: QuickEditPanelState) {
return {
...panel,
@@ -1017,12 +1052,22 @@ function createNormalizedGenerationDialogDraft({
characterView: getNormalizedString(sourceLayer, 'characterView'),
customPrompt: getNormalizedString(sourceLayer, 'customPrompt'),
},
specReference:
resolveNormalizedReferences(
sourceLayer,
'reference',
availableLayers,
)[0] ?? null,
...(specType === 'icon'
? {
specReference:
resolveNormalizedReferences(
sourceLayer,
'reference',
availableLayers,
)[0] ?? null,
}
: {
generationReferences: resolveNormalizedReferences(
sourceLayer,
'reference',
availableLayers,
),
}),
},
sourceLayer,
);
@@ -1776,14 +1821,25 @@ export function createSameSourceGenerationDialogDraft({
if (sourceMode === 'spec') {
const specType = resolveSpecTypeFromSourceLayer(sourceLayer, sourceDialog);
const sourceReferences =
sourceDialog?.mode === 'spec'
? resolveSpecGenerationReferences(sourceDialog)
: [];
return placeDraftBesideSourceLayer(
restoreSpecValuesFromLayer(
{
...createSpecDialogDraft({ canvasSize, viewport, specType }),
prompt,
sourceLayerId: sourceLayer.id,
specReference:
sourceDialog?.mode === 'spec' ? sourceDialog.specReference : null,
...(specType === 'icon'
? {
specReference: sourceReferences[0] ?? null,
generationReferences: [],
}
: {
specReference: null,
generationReferences: sourceReferences,
}),
},
sourceLayer,
specType,
@@ -2102,11 +2158,20 @@ export function appendGenerationReference(
if (getReferenceMediaType(layer) !== 'image') {
return dialog;
}
return {
const resetDialog = {
...resetFailedGenerationDialog(dialog),
specReference: createCanvasLayerReference(layer),
composerOpen: true,
};
return setSpecGenerationReferences(
resetDialog,
dialog.specType === 'icon'
? [createCanvasLayerReference(layer)]
: appendLimitedImageReferences(
resolveSpecGenerationReferences(dialog),
[createCanvasLayerReference(layer)],
resolveDialogExtraImageReferenceLimit(dialog),
),
);
}
if (dialog?.mode === 'video' && !isSeedanceVideoModel(dialog.videoModel)) {
return dialog;
@@ -52,6 +52,7 @@ import {
isNormalizedCanvasGenerationInputs,
isQuickEditSupportedLayer,
resolveCharacterAnimationSourceImageSrc,
resolveDialogExtraImageReferenceLimit,
resolveEditorImageGenerationPixelSize,
resolveImageGenerationErrorMessage,
VIDEO_MODEL_KLING_3,
@@ -68,6 +69,26 @@ describe('ImageCanvasGenerationModel', () => {
});
});
it('uses the fixed spec model provider limit for multi-reference spec dialogs', () => {
expect(
resolveDialogExtraImageReferenceLimit({
mode: 'spec',
specType: 'character',
prompt: '',
status: 'idle',
}),
).toBe(5);
expect(
resolveDialogExtraImageReferenceLimit({
mode: 'spec',
specType: 'custom',
prompt: '',
status: 'idle',
imageModel: IMAGE_MODEL_NANOBANANA2,
}),
).toBe(5);
});
it('展示时长时收敛到两位小数且不改动其他字段', () => {
const format = (title: string, value: string) =>
formatEditorGenerationInputFieldValue({ title, value });
@@ -977,13 +998,14 @@ describe('ImageCanvasGenerationModel', () => {
});
it('adds reference image semantics and snapshots for spec generation references', () => {
expect(
buildSpecPrompt(
'custom',
{ ...blankSpecValues, customPrompt: '生成一张怪兽规范图' },
true,
),
).toContain('生成一张怪兽规范图');
const prompt = buildSpecPrompt(
'custom',
{ ...blankSpecValues, customPrompt: '生成一张怪兽规范图' },
true,
);
expect(prompt).toContain('严格参考全部参考图');
expect(prompt).not.toContain('严格参考图1');
expect(prompt).toContain('生成一张怪兽规范图');
expect(
buildSpecGenerationInputs(
'custom',
@@ -464,7 +464,8 @@ export function resolveExtraImageReferenceLimit(
export function resolveDialogExtraImageReferenceLimit(
dialog: GenerateDialogState,
model: string | null | undefined = dialog.imageModel,
model: string | null | undefined = dialog.imageModel ??
(dialog.mode === 'spec' ? SPEC_GENERATION_MODEL : undefined),
) {
if (dialog.mode === 'icon') {
return resolveExtraImageReferenceLimit(
@@ -494,7 +495,10 @@ export function resolveDialogExtraImageReferenceLimit(
1,
);
}
return IMAGE_GENERATION_REFERENCE_LIMIT;
return resolveExtraImageReferenceLimit(
model,
IMAGE_GENERATION_REFERENCE_LIMIT,
);
}
export function countDialogExtraImageReferences(dialog: GenerateDialogState) {
@@ -757,7 +761,7 @@ export function buildSpecPrompt(
return prompt;
}
return [
'参考图生成规范:严格参考图1的构图、风格、材质、色彩、形状语言和视觉层级生成本次规范图;参考图只作为美术方向和规范语义依据,不要直接复制参考图中的文字、水印或无关背景。',
'参考图生成规范:严格参考全部参考图的构图、风格、材质、色彩、形状语言和视觉层级生成本次规范图;参考图只作为美术方向和规范语义依据,不要直接复制参考图中的文字、水印或无关背景。',
prompt,
].join('\n');
}
@@ -1873,12 +1877,18 @@ export function buildSpecGenerationInputs(
specType: SpecGenerationType,
values: SpecFormValues,
reference?: CharacterReferenceImage | null,
extraReferences: CharacterReferenceImage[] = [],
): CanvasGenerationInputs {
const references = reference
? createGenerationInputReference('参考图', reference, {
id: 'reference',
})
: [];
const references = [
...(reference ? [reference] : []),
...extraReferences,
].flatMap((item, index) =>
createGenerationInputReference(
index === 0 ? '参考图' : `参考图${index + 1}`,
item,
{ id: index === 0 ? 'reference' : `reference-${index + 1}` },
),
);
if (specType === 'custom') {
return {
version: 2,
@@ -760,6 +760,11 @@ export function buildImageGenerationSubmissionPlan({
},
};
}
const specReferences = dialog.generationReferences?.length
? dialog.generationReferences
: dialog.specReference
? [dialog.specReference]
: [];
return {
kind: 'image',
normalizedPrompt,
@@ -767,18 +772,18 @@ export function buildImageGenerationSubmissionPlan({
prompt: buildSpecPrompt(
specType,
specValues,
Boolean(dialog.specReference?.src),
specReferences.some((reference) => Boolean(reference.src)),
),
size: SPEC_GENERATION_SIZE,
model: SPEC_GENERATION_MODEL,
aspectRatio: SPEC_GENERATION_ASPECT_RATIO,
imageSize: SPEC_GENERATION_IMAGE_SIZE,
kind: 'spec',
...(dialog.specReference?.src
...(specReferences.length
? {
referenceImageSrcs: [
resolveImageReferenceSubmissionSource(dialog.specReference),
],
referenceImageSrcs: specReferences.map(
resolveImageReferenceSubmissionSource,
),
}
: {}),
},
@@ -791,7 +796,8 @@ export function buildImageGenerationSubmissionPlan({
generationInputs: buildSpecGenerationInputs(
specType,
specValues,
dialog.specReference,
specReferences[0],
specReferences.slice(1),
),
},
};
@@ -1,6 +1,6 @@
/* @vitest-environment jsdom */
import { render } from '@testing-library/react';
import { render, screen } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { ImageCanvasReferenceSlot } from './ImageCanvasReferenceSlot';
@@ -14,7 +14,10 @@ vi.mock('../../hooks/useResolvedAssetReadUrl', () => ({
describe('ImageCanvasReferenceSlot', () => {
beforeEach(() => {
useResolvedAssetReadUrlMock.mockImplementation(
(source: string | null | undefined, options?: { objectKey?: string | null }) => ({
(
source: string | null | undefined,
options?: { objectKey?: string | null },
) => ({
resolvedUrl: options?.objectKey
? `https://oss.example.com/${options.objectKey}?signature=1`
: (source ?? ''),
@@ -49,4 +52,21 @@ describe('ImageCanvasReferenceSlot', () => {
'https://oss.example.com/generated-character-drafts/editor/legacy-resources/editor-resource-1.png?signature=1',
);
});
it('keeps an explicitly marked reference remove action visible', () => {
render(
<ImageCanvasReferenceSlot
icon={<span />}
imageSrc="/reference.png"
label="参考图1"
ariaLabel="参考图1"
onRemove={vi.fn()}
alwaysShowRemove
/>,
);
expect(
screen.getByRole('button', { name: '删除参考图1' }).className,
).toContain('image-canvas-editor__reference-chip-remove--always-visible');
});
});
@@ -28,6 +28,7 @@ type ImageCanvasReferenceSlotProps = {
onClick?: () => void;
onRemove?: () => void;
removeLabel?: string;
alwaysShowRemove?: boolean;
};
export function ImageCanvasReferenceSlot({
@@ -45,6 +46,7 @@ export function ImageCanvasReferenceSlot({
onClick,
onRemove,
removeLabel,
alwaysShowRemove = false,
}: ImageCanvasReferenceSlotProps) {
const hasPreviewImage = Boolean(imageSrc?.trim() || objectKey?.trim());
const slotClassName = [
@@ -106,7 +108,11 @@ export function ImageCanvasReferenceSlot({
{onRemove ? (
<button
type="button"
className="image-canvas-editor__reference-chip-remove"
className={`image-canvas-editor__reference-chip-remove${
alwaysShowRemove
? ' image-canvas-editor__reference-chip-remove--always-visible'
: ''
}`}
disabled={disabled}
aria-label={removeLabel ?? `删除${ariaLabel}`}
title="删除"
@@ -106,6 +106,30 @@ function UiDesignHarness({
) : null;
}
function CharacterSpecReferenceMenuHarness({
onRequestUpload = vi.fn(),
}: {
onRequestUpload?: (target: UploadTarget) => void;
}) {
const [isMenuOpen, setIsMenuOpen] = useState(false);
const referenceButtonRef = createRef<HTMLButtonElement>();
return (
<ImageCanvasSpecGenerationPanelView
dialog={createSpecDialog({ generationReferences: [] })}
style={{ left: 10, top: 20 }}
generationReferenceButtonRef={referenceButtonRef}
isGenerationReferenceMenuOpen={isMenuOpen}
setIsGenerationReferenceMenuOpen={setIsMenuOpen}
setIsPickingGenerationReferenceFromCanvas={vi.fn()}
renderEditorPortal={(node) => node}
buildPortalMenuStyle={() => ({ position: 'fixed', left: 0, top: 0 })}
onUpdateSpecFormValue={vi.fn()}
onRequestUpload={onRequestUpload}
onSubmit={vi.fn()}
/>
);
}
function IconSpecHarness({
playSetting = '回合制占点',
artStyle = '低多边形',
@@ -278,6 +302,71 @@ describe('ImageCanvasSpecGenerationPanelView', () => {
expect(screen.queryByLabelText('界面风格参考')).toBeNull();
});
it('removes only the selected character spec reference and keeps its action visible', () => {
render(
<UiDesignHarness
initialDialog={createSpecDialog({
generationReferences: [
{ id: 'ref-a', label: '参考一', src: 'data:image/png;base64,a' },
{ id: 'ref-b', label: '参考二', src: 'data:image/png;base64,b' },
{ id: 'ref-c', label: '参考三', src: 'data:image/png;base64,c' },
],
})}
/>,
);
expect(screen.getAllByRole('button', { name: /删除参考/ })).toHaveLength(3);
fireEvent.click(screen.getByRole('button', { name: '删除参考二' }));
expect(screen.queryByLabelText('参考二')).toBeNull();
expect(screen.getByLabelText('参考一')).toBeTruthy();
expect(screen.getByLabelText('参考三')).toBeTruthy();
});
it('shows and removes a legacy non-icon spec reference', () => {
render(
<UiDesignHarness
initialDialog={createSpecDialog({
generationReferences: [],
specReference: {
id: 'legacy-reference',
label: '历史角色参考',
src: 'data:image/png;base64,legacy',
},
})}
/>,
);
expect(screen.getByLabelText('历史角色参考')).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '删除历史角色参考' }));
expect(screen.queryByLabelText('历史角色参考')).toBeNull();
expect(screen.getByRole('button', { name: '参考图' })).toBeTruthy();
});
it('hides a stale legacy spec reference when canonical references exist', () => {
render(
<UiDesignHarness
initialDialog={createSpecDialog({
specReference: {
id: 'legacy-reference',
label: '过期角色参考',
src: 'data:image/png;base64,legacy',
},
generationReferences: [
{
id: 'canonical-reference',
label: '当前角色参考',
src: 'data:image/png;base64,current',
},
],
})}
/>,
);
expect(screen.getByLabelText('当前角色参考')).toBeTruthy();
expect(screen.queryByLabelText('过期角色参考')).toBeNull();
});
it('renders character spec fields and forwards updates', () => {
const updateSpecFormValue = vi.fn();
renderPanel({
@@ -314,6 +403,17 @@ describe('ImageCanvasSpecGenerationPanelView', () => {
);
});
it('keeps the character spec reference source menu open after clicking the add slot', () => {
render(<CharacterSpecReferenceMenuHarness />);
const addButton = screen.getByRole('button', { name: '参考图' });
fireEvent.click(addButton);
expect(screen.getByRole('menu', { name: '参考图来源' })).toBeTruthy();
fireEvent.click(addButton);
expect(screen.queryByRole('menu', { name: '参考图来源' })).toBeNull();
});
it('uses gameplay and art style hints for icon specs', () => {
renderPanel({
dialog: createSpecDialog({
@@ -41,6 +41,10 @@ import type {
SpecGenerationType,
UploadTarget,
} from './ImageCanvasEditorTypes';
import {
resolveSpecGenerationReferences,
setSpecGenerationReferences,
} from './ImageCanvasGenerationDialogModel';
import { ImageCanvasGenerationImageOptionsView } from './ImageCanvasGenerationImageOptionsView';
import {
calculateEditorSpecGenerationPrice,
@@ -144,7 +148,13 @@ export function ImageCanvasSpecGenerationPanelView({
: 'spec-reference';
const reference = isUiDesignDialog
? dialog.uiDesignSpecReference
: dialog.specReference;
: dialog.specType === 'icon'
? dialog.specReference
: null;
const extraSpecReferences =
!isUiDesignDialog && dialog.specType !== 'icon'
? resolveSpecGenerationReferences(dialog)
: [];
const isGenerating = dialog.status === 'generating';
const isIconSpec = dialog.mode === 'spec' && dialog.specType === 'icon';
const [playSettingOptimization, setPlaySettingOptimization] =
@@ -278,7 +288,7 @@ export function ImageCanvasSpecGenerationPanelView({
onPointerDown={(event) => event.stopPropagation()}
>
<div className="image-canvas-editor__reference-strip">
{isUiDesignDialog || dialog.specType ? (
{isUiDesignDialog || dialog.specType === 'icon' ? (
<ImageCanvasReferenceSlot
buttonRef={generationReferenceButtonRef}
tone={isUiDesignDialog ? 'ui' : 'spec'}
@@ -337,6 +347,59 @@ export function ImageCanvasSpecGenerationPanelView({
removeLabel={`删除${referenceLabel}`}
/>
) : null}
{extraSpecReferences.map((item, index) => (
<ImageCanvasReferenceSlot
key={`${item.id}-${index}`}
tone="default"
icon={<ImagePlus className="h-4 w-4" aria-hidden="true" />}
imageSrc={item.src}
objectKey={item.objectKey}
label={`参考图${index + 1}`}
ariaLabel={item.label || `参考图${index + 1}`}
title={item.label}
disabled={isGenerating}
onRemove={
setGenerateDialog
? () =>
setGenerateDialog((currentDialog) => {
if (currentDialog?.mode !== 'spec') {
return currentDialog;
}
return setSpecGenerationReferences(
{
...currentDialog,
status:
currentDialog.status === 'failed'
? 'idle'
: currentDialog.status,
errorMessage:
currentDialog.status === 'failed'
? undefined
: currentDialog.errorMessage,
},
resolveSpecGenerationReferences(currentDialog).filter(
(referenceItem) => referenceItem.id !== item.id,
),
);
})
: undefined
}
removeLabel={`删除${item.label || `参考图${index + 1}`}`}
alwaysShowRemove
/>
))}
{!isUiDesignDialog && dialog.specType !== 'icon' ? (
<ImageCanvasReferenceSlot
buttonRef={generationReferenceButtonRef}
tone="default"
icon={<ImagePlus className="h-4 w-4" aria-hidden="true" />}
label="参考图"
ariaLabel={referenceLabel}
disabled={isGenerating}
isAdd
onClick={openReferenceMenu}
/>
) : null}
{isUiDesignDialog
? (dialog.generationReferences ?? []).map((item, index) => (
<ImageCanvasReferenceSlot
@@ -349,7 +349,7 @@ describe('ImageCanvasUploadModel', () => {
imageSrc: 'data:image/png;base64,ref',
}),
).toEqual({
id: 'upload-character-reference-1781697600000-2',
id: 'upload-character-reference-1781697600000-2-1',
label: '参考图3',
src: 'data:image/png;base64,ref',
mediaType: 'image',
@@ -361,6 +361,26 @@ describe('ImageCanvasUploadModel', () => {
});
});
it('keeps uploaded reference identities unique across same-time batches', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-06-17T12:00:00.000Z'));
const first = createUploadedGenerationReference({
idPrefix: 'upload-spec-reference',
index: 0,
fallbackLabel: '参考图1',
imageSrc: 'data:image/png;base64,first',
});
const second = createUploadedGenerationReference({
idPrefix: 'upload-spec-reference',
index: 0,
fallbackLabel: '参考图1',
imageSrc: 'data:image/png;base64,second',
});
expect(first.id).not.toBe(second.id);
});
it('resets failed generation dialogs when reference uploads are applied', () => {
expect(
setFailedGenerationIdle(
@@ -381,7 +401,7 @@ describe('ImageCanvasUploadModel', () => {
expect(
applyGenerationReferenceUpload({
dialog: createDialog({ mode: 'spec' }),
dialog: createDialog({ mode: 'spec', specType: 'icon' }),
target: 'spec-reference',
references: [reference],
}),
@@ -415,6 +435,71 @@ describe('ImageCanvasUploadModel', () => {
});
});
it('migrates a legacy non-icon spec reference before appending uploads', () => {
const legacyReference = {
id: 'ref-legacy',
label: '历史参考图',
src: 'data:image/png;base64,legacy',
};
const uploadedReferences = Array.from({ length: 5 }, (_, index) => ({
id: `ref-new-${index + 1}`,
label: `新参考图 ${index + 1}`,
src: `data:image/png;base64,new-${index + 1}`,
}));
expect(
applyGenerationReferenceUpload({
dialog: createDialog({
mode: 'spec',
specType: 'character',
specReference: legacyReference,
generationReferences: [],
}),
target: 'spec-reference',
references: uploadedReferences,
}),
).toMatchObject({
specReference: null,
generationReferences: [
legacyReference,
...uploadedReferences.slice(0, 4),
],
});
});
it('prefers canonical spec references over a stale legacy reference on upload', () => {
const canonicalReference = {
id: 'ref-current',
label: '当前参考图',
src: 'data:image/png;base64,current',
};
const nextReference = {
id: 'ref-next',
label: '新增参考图',
src: 'data:image/png;base64,next',
};
expect(
applyGenerationReferenceUpload({
dialog: createDialog({
mode: 'spec',
specType: 'custom',
specReference: {
id: 'ref-stale',
label: '过期参考图',
src: 'data:image/png;base64,stale',
},
generationReferences: [canonicalReference],
}),
target: 'generation-reference',
references: [nextReference],
}),
).toMatchObject({
specReference: null,
generationReferences: [canonicalReference, nextReference],
});
});
it('appends character reference uploads without changing unmatched dialogs', () => {
const previousReference = {
id: 'ref-old',
@@ -11,6 +11,10 @@ import type {
GenerateDialogState,
QuickEditPanelState,
} from './ImageCanvasEditorTypes';
import {
resolveSpecGenerationReferences,
setSpecGenerationReferences,
} from './ImageCanvasGenerationDialogModel';
import {
appendLimitedImageReferences,
QUICK_EDIT_REFERENCE_LIMIT,
@@ -47,6 +51,8 @@ type GenerationReferenceUploadTarget =
| 'ui-design-icon-spec'
| 'publication-reference';
let uploadedGenerationReferenceSequence = 0;
const VIDEO_REFERENCE_LIMITS = {
image: 9,
video: 3,
@@ -128,11 +134,12 @@ export function createUploadedGenerationReference({
resourceId?: string | null;
sourceAssetId?: string | null;
}): CharacterReferenceImage {
uploadedGenerationReferenceSequence += 1;
return {
id:
typeof index === 'number'
? `${idPrefix}-${Date.now()}-${index}`
: `${idPrefix}-${Date.now()}`,
? `${idPrefix}-${Date.now()}-${index}-${uploadedGenerationReferenceSequence}`
: `${idPrefix}-${Date.now()}-${uploadedGenerationReferenceSequence}`,
label: fileName || fallbackLabel,
src: imageSrc,
mediaType,
@@ -168,12 +175,19 @@ export function applyGenerationReferenceUpload({
return dialog;
}
if (target === 'spec-reference') {
return dialog?.mode === 'spec'
? {
...setFailedGenerationIdle(dialog),
specReference: firstReference,
}
: dialog;
if (dialog?.mode !== 'spec') {
return dialog;
}
return setSpecGenerationReferences(
setFailedGenerationIdle(dialog),
dialog.specType === 'icon'
? [firstReference]
: appendLimitedImageReferences(
resolveSpecGenerationReferences(dialog),
references,
resolveDialogExtraImageReferenceLimit(dialog),
),
);
}
if (target === 'character-spec') {
return dialog?.mode === 'character'
@@ -220,10 +234,21 @@ export function applyGenerationReferenceUpload({
: dialog;
}
if (target === 'generation-reference') {
if (dialog?.mode === 'spec') {
return setSpecGenerationReferences(
setFailedGenerationIdle(dialog),
dialog.specType === 'icon'
? [firstReference]
: appendLimitedImageReferences(
resolveSpecGenerationReferences(dialog),
references,
resolveDialogExtraImageReferenceLimit(dialog),
),
);
}
return dialog?.mode === 'generate' ||
dialog?.mode === 'scene' ||
dialog?.mode === 'video' ||
dialog?.mode === 'spec' ||
dialog?.mode === 'icon' ||
dialog?.mode === 'ui-design'
? {
@@ -287,6 +287,69 @@ describe('ImageCanvasWorldView', () => {
expect(screen.getByText('角色')).toBeTruthy();
});
it('only rerenders the moved image layer when other layer objects stay stable', () => {
const firstLayer = createLayer({
id: 'layer-first',
resourceId: 'resource-first',
title: '第一张图片',
zIndex: 1,
});
const movedLayer = createLayer({
id: 'layer-moved',
resourceId: 'resource-moved',
title: '移动图片',
zIndex: 2,
});
const lastLayer = createLayer({
id: 'layer-last',
resourceId: 'resource-last',
title: '最后一张图片',
zIndex: 3,
});
const { props, rerender } = renderWorldView({
layers: [firstLayer, movedLayer, lastLayer],
});
const initialReadUrlCallCount =
useResolvedAssetReadUrlMock.mock.calls.length;
rerender(
<ImageCanvasWorldView
{...props}
layers={[
firstLayer,
{ ...movedLayer, x: movedLayer.x + 24, y: movedLayer.y + 16 },
lastLayer,
]}
/>,
);
expect(useResolvedAssetReadUrlMock).toHaveBeenCalledTimes(
initialReadUrlCallCount + 1,
);
});
it('calls the latest layer callback without rerendering a stable layer', () => {
const layer = createLayer();
const firstOnLayerClick = vi.fn();
const latestOnLayerClick = vi.fn();
const { props, rerender } = renderWorldView({
layers: [layer],
onLayerClick: firstOnLayerClick,
});
rerender(
<ImageCanvasWorldView
{...props}
layers={[layer]}
onLayerClick={latestOnLayerClick}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '选择角色主图' }));
expect(latestOnLayerClick).toHaveBeenCalledWith(expect.any(Object), layer);
expect(firstOnLayerClick).not.toHaveBeenCalled();
});
it('shows editable kind tags for tagged and untagged layers', () => {
const layer = createLayer({ assetKind: null });
renderWorldView({
File diff suppressed because it is too large Load Diff
@@ -3960,6 +3960,69 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
);
});
it('normalizes non-icon spec sources with canonical reference metadata', async () => {
generateEditorImageMock.mockResolvedValueOnce(
createGenerated({
imageSrc: 'data:image/png;base64,character-spec',
width: 2048,
height: 1152,
}),
);
render(
<SubmissionWorkflowHarness
initialDialog={{
id: 'dialog-character-spec-mixed-references',
mode: 'spec',
prompt: '',
status: 'idle',
composerOpen: true,
specType: 'character',
specValues: {
playSetting: '动作冒险',
artStyle: '赛璐璐',
bodyRatio: '4',
characterView: '正面站姿',
customPrompt: '',
},
specReference: {
id: 'legacy-reference',
label: '历史参考',
src: '/generated/legacy-fallback.png',
objectKey: 'generated/legacy-object.png',
resourceId: 'resource-legacy',
},
generationReferences: [
{
id: 'canonical-reference',
label: '当前参考',
src: '/generated/current-fallback.png',
objectKey: 'generated/current-object.png',
resourceId: 'resource-current',
},
],
}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '设置初始对话' }));
fireEvent.click(screen.getByRole('button', { name: '提交当前生成' }));
await waitFor(() => {
expect(generateEditorImageMock).toHaveBeenCalledWith(
expect.objectContaining({
kind: 'spec',
referenceImageSrcs: ['generated/current-object.png'],
generationInputs: expect.objectContaining({
references: [expect.objectContaining({ label: '当前参考' })],
}),
}),
);
});
expect(
generateEditorImageMock.mock.calls[0]?.[0]?.referenceImageSrcs,
).not.toContain('generated/legacy-object.png');
});
it('validates icon submission before calling the API', async () => {
render(
<SubmissionWorkflowHarness
@@ -50,6 +50,7 @@ import type {
QuickEditPanelState,
SidebarPanel,
} from './ImageCanvasEditorTypes';
import { resolveSpecGenerationReferences } from './ImageCanvasGenerationDialogModel';
import {
applyImageEditResultToSourceLayer,
createAudioResultLayer,
@@ -411,7 +412,7 @@ function resolveImageGenerationDialogReferences(
dialog: GenerateDialogState,
): CharacterReferenceImage[] {
if (dialog.mode === 'spec') {
return dialog.specReference ? [dialog.specReference] : [];
return resolveSpecGenerationReferences(dialog);
}
if (dialog.mode === 'character') {
return [
@@ -110,6 +110,7 @@ function asCanvasGenerationDialog(
}
function StageInteractionsHarness({
includeConcurrentLayerFixture = false,
pickCharacterSpecFromLayer = vi.fn(),
pickGenerationReferenceFromLayer = vi.fn(),
pickIconSpecFromLayer = vi.fn(),
@@ -122,6 +123,7 @@ function StageInteractionsHarness({
onViewportInteractionEnd = vi.fn(),
captureCanvasHistory = vi.fn(),
}: {
includeConcurrentLayerFixture?: boolean;
pickCharacterSpecFromLayer?: (layer: CanvasLayer) => void;
pickGenerationReferenceFromLayer?: (layer: CanvasLayer) => void;
pickIconSpecFromLayer?: (layer: CanvasLayer) => void;
@@ -150,9 +152,12 @@ function StageInteractionsHarness({
const canvasViewportRef = useRef<HTMLDivElement | null>(null);
const worldRef = useRef<HTMLDivElement | null>(null);
const [activeTool, setActiveTool] = useState<'select' | 'hand'>('select');
const [layers, setLayers] = useState<CanvasLayer[]>([
const [layers, setLayers] = useState<CanvasLayer[]>(() => [
createLayer({ id: 'first', x: 40, y: 40, zIndex: 1 }),
createLayer({ id: 'second', x: 220, y: 60, zIndex: 2 }),
...(includeConcurrentLayerFixture
? [createLayer({ id: 'removed', x: 900, y: 900, zIndex: 3 })]
: []),
]);
const [viewport, setViewport] = useState<CanvasViewport>({
x: 0,
@@ -331,6 +336,14 @@ function StageInteractionsHarness({
)
.join('|')}
</span>
<span data-testid="layer-details">
{layers
.map(
(layer) =>
`${layer.id}:${layer.title}:${layer.x.toFixed(1)},${layer.y.toFixed(1)}`,
)
.join('|')}
</span>
<span data-testid="viewport-state">
{viewport.x.toFixed(1)},{viewport.y.toFixed(1)},{viewport.scale}
</span>
@@ -351,6 +364,17 @@ function StageInteractionsHarness({
? `${generateDialog.placeholder.x.toFixed(1)},${generateDialog.placeholder.y.toFixed(1)}`
: '-'}
</span>
<span data-testid="dialog-details">
{generateDialog
? [
generateDialog.status,
generateDialog.prompt,
generateDialog.errorMessage ?? '-',
generateDialog.placeholder?.width ?? '-',
generateDialog.placeholder?.height ?? '-',
].join(':')
: '-'}
</span>
<span data-testid="dialog-selection-id">
{getCanvasGenerationSelectionId('dialog-1')}
</span>
@@ -440,6 +464,48 @@ function StageInteractionsHarness({
>
</button>
<button
type="button"
onClick={() => {
[
{ clientX: 230, clientY: 70 },
{ clientX: 250, clientY: 90 },
{ clientX: 280, clientY: 120 },
].forEach((pointer) => {
interaction.handlePointerMove(
createPointerEvent(getViewportElement(), {
pointerId: 2,
...pointer,
}),
);
});
}}
>
</button>
<button
type="button"
onClick={() => {
setLayers((currentLayers) => [
...currentLayers
.filter((layer) => layer.id !== 'removed')
.map((layer) =>
layer.id === 'first' || layer.id === 'second'
? { ...layer, title: `${layer.id}-concurrent` }
: layer,
),
createLayer({
id: 'added',
title: 'added-concurrent',
x: 640,
y: 360,
zIndex: 4,
}),
]);
}}
>
</button>
<button
type="button"
onClick={() => {
@@ -589,6 +655,79 @@ function StageInteractionsHarness({
>
</button>
<button
type="button"
onClick={() => {
const frameElement = document.createElement('div');
const dialog = asCanvasGenerationDialog(generateDialog);
if (!dialog) {
return;
}
interaction.handleGenerationFramePointerDown(
createPointerEvent(frameElement, {
pointerId: 24,
clientX: 300,
clientY: 200,
}),
dialog,
);
[
{ clientX: 320, clientY: 215 },
{ clientX: 340, clientY: 230 },
].forEach((pointer) => {
interaction.handlePointerMove(
createPointerEvent(getViewportElement(), {
pointerId: 24,
...pointer,
}),
);
});
}}
>
</button>
<button
type="button"
onClick={() => {
setGenerateDialog((currentDialog) =>
currentDialog
? {
...currentDialog,
status: 'failed',
prompt: '并发提示',
errorMessage: '并发失败',
placeholder: currentDialog.placeholder
? {
...currentDialog.placeholder,
width: 222,
height: 111,
}
: undefined,
}
: currentDialog,
);
}}
>
</button>
<button
type="button"
onClick={() => {
setGenerateDialog((currentDialog) =>
currentDialog
? {
...currentDialog,
status: 'failed',
prompt: '并发移除占位',
errorMessage: '占位已移除',
placeholder: undefined,
}
: currentDialog,
);
}}
>
</button>
<button
type="button"
onClick={(event) => {
@@ -809,9 +948,7 @@ describe('useImageCanvasStageInteractions', () => {
it('selects and drags multiple layers from stage pointer events', () => {
const captureCanvasHistory = vi.fn();
render(
<StageInteractionsHarness
captureCanvasHistory={captureCanvasHistory}
/>,
<StageInteractionsHarness captureCanvasHistory={captureCanvasHistory} />,
);
act(() => {
@@ -871,9 +1008,7 @@ describe('useImageCanvasStageInteractions', () => {
it('keeps sub-threshold pointer jitter from moving layers or creating history', () => {
const captureCanvasHistory = vi.fn();
render(
<StageInteractionsHarness
captureCanvasHistory={captureCanvasHistory}
/>,
<StageInteractionsHarness captureCanvasHistory={captureCanvasHistory} />,
);
act(() => {
@@ -887,6 +1022,171 @@ describe('useImageCanvasStageInteractions', () => {
expect(captureCanvasHistory).not.toHaveBeenCalled();
});
it('coalesces high-frequency layer moves to the latest animation frame', () => {
let animationFrameCallback: FrameRequestCallback | null = null;
const captureCanvasHistory = vi.fn();
const requestAnimationFrameSpy = vi
.spyOn(window, 'requestAnimationFrame')
.mockImplementation((callback) => {
animationFrameCallback = callback;
return 41;
});
render(
<StageInteractionsHarness captureCanvasHistory={captureCanvasHistory} />,
);
act(() => {
screen.getByRole('button', { name: '直接追加第二层' }).click();
screen.getByRole('button', { name: '连续移动图层' }).click();
});
expect(requestAnimationFrameSpy).toHaveBeenCalledTimes(1);
expect(captureCanvasHistory).toHaveBeenCalledTimes(1);
expect(screen.getByTestId('layers').textContent).not.toContain(
'second:280.0,120.0',
);
act(() => {
animationFrameCallback?.(16);
});
expect(screen.getByTestId('layers').textContent).toContain(
'second:280.0,120.0',
);
expect(captureCanvasHistory).toHaveBeenCalledTimes(1);
});
it('preserves concurrent layer updates while applying a pending animation-frame move', () => {
let animationFrameCallback: FrameRequestCallback | null = null;
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => {
animationFrameCallback = callback;
return 44;
});
render(<StageInteractionsHarness includeConcurrentLayerFixture />);
act(() => {
screen.getByRole('button', { name: '直接追加第二层' }).click();
screen.getByRole('button', { name: '连续移动图层' }).click();
});
act(() => {
screen.getByRole('button', { name: '并发更新图层' }).click();
});
expect(screen.getByTestId('layer-details').textContent).toContain(
'second:second-concurrent:',
);
expect(screen.getByTestId('layer-details').textContent).not.toContain(
'second:second-concurrent:280.0,120.0',
);
expect(screen.getByTestId('layer-details').textContent).not.toContain(
'removed:',
);
act(() => {
animationFrameCallback?.(16);
});
const layerDetails = screen.getByTestId('layer-details').textContent;
expect(layerDetails).toContain('first:first-concurrent:40.0,40.0');
expect(layerDetails).toContain('second:second-concurrent:280.0,120.0');
expect(layerDetails).toContain('added:added-concurrent:640.0,360.0');
expect(layerDetails).not.toContain('removed:');
});
it('preserves concurrent generation fields while applying only pending placeholder coordinates', () => {
let animationFrameCallback: FrameRequestCallback | null = null;
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => {
animationFrameCallback = callback;
return 45;
});
render(<StageInteractionsHarness />);
act(() => {
screen.getByRole('button', { name: '连续拖生成占位' }).click();
});
act(() => {
screen.getByRole('button', { name: '并发更新生成占位' }).click();
});
expect(screen.getByTestId('dialog-position').textContent).toBe(
'320.0,215.0',
);
expect(screen.getByTestId('dialog-details').textContent).toBe(
'failed:并发提示:并发失败:222:111',
);
act(() => {
animationFrameCallback?.(16);
});
expect(screen.getByTestId('dialog-position').textContent).toBe(
'340.0,230.0',
);
expect(screen.getByTestId('dialog-details').textContent).toBe(
'failed:并发提示:并发失败:222:111',
);
});
it('does not revive a generation placeholder removed before a pending animation-frame move', () => {
let animationFrameCallback: FrameRequestCallback | null = null;
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => {
animationFrameCallback = callback;
return 46;
});
render(<StageInteractionsHarness />);
act(() => {
screen.getByRole('button', { name: '连续拖生成占位' }).click();
});
act(() => {
screen.getByRole('button', { name: '并发移除生成占位' }).click();
});
expect(screen.getByTestId('dialog-position').textContent).toBe('-');
expect(screen.queryByTestId('generation-frame')).toBeNull();
act(() => {
animationFrameCallback?.(16);
});
expect(screen.getByTestId('dialog-position').textContent).toBe('-');
expect(screen.getByTestId('dialog-details').textContent).toBe(
'failed:并发移除占位:占位已移除:-:-',
);
expect(screen.queryByTestId('generation-frame')).toBeNull();
});
it('flushes the latest coalesced layer move before pointer release', () => {
vi.spyOn(window, 'requestAnimationFrame').mockImplementation(() => 42);
const cancelAnimationFrameSpy = vi.spyOn(window, 'cancelAnimationFrame');
render(<StageInteractionsHarness />);
act(() => {
screen.getByRole('button', { name: '直接追加第二层' }).click();
screen.getByRole('button', { name: '连续移动图层' }).click();
screen.getByRole('button', { name: '直接结束图层拖拽' }).click();
});
expect(cancelAnimationFrameSpy).toHaveBeenCalledWith(42);
expect(screen.getByTestId('layers').textContent).toContain(
'second:280.0,120.0',
);
});
it('cancels a pending layer move frame when the stage unmounts', () => {
vi.spyOn(window, 'requestAnimationFrame').mockImplementation(() => 43);
const cancelAnimationFrameSpy = vi.spyOn(window, 'cancelAnimationFrame');
const view = render(<StageInteractionsHarness />);
act(() => {
screen.getByRole('button', { name: '直接追加第二层' }).click();
screen.getByRole('button', { name: '连续移动图层' }).click();
});
view.unmount();
expect(cancelAnimationFrameSpy).toHaveBeenCalledWith(43);
});
it('handles marquee, temporary hand mode, and generation frame dragging', () => {
const activateCanvasGenerationDialog = vi.fn();
render(
@@ -1028,7 +1328,7 @@ describe('useImageCanvasStageInteractions', () => {
);
});
it('marks viewport interactions for pan and minimap drags', () => {
it('marks layout interactions for layer, generation, pan, and minimap drags', () => {
const onViewportInteractionStart = vi.fn();
const onViewportInteractionEnd = vi.fn();
const flushMinimapViewportDrag = vi.fn();
@@ -1040,28 +1340,46 @@ describe('useImageCanvasStageInteractions', () => {
/>,
);
act(() => {
screen.getByRole('button', { name: '直接追加第二层' }).click();
screen.getByRole('button', { name: '直接移动图层' }).click();
});
expect(onViewportInteractionStart).toHaveBeenCalledTimes(1);
act(() => {
screen.getByRole('button', { name: '直接结束图层拖拽' }).click();
});
expect(onViewportInteractionEnd).toHaveBeenCalledTimes(1);
act(() => {
screen.getByRole('button', { name: '直接拖生成占位' }).click();
});
expect(onViewportInteractionStart).toHaveBeenCalledTimes(2);
act(() => {
screen.getByRole('button', { name: '清理交互' }).click();
});
expect(onViewportInteractionEnd).toHaveBeenCalledTimes(2);
act(() => {
screen.getByRole('button', { name: '切抓手' }).click();
});
act(() => {
screen.getByRole('button', { name: '直接开始平移' }).click();
});
expect(onViewportInteractionStart).toHaveBeenCalledTimes(1);
expect(onViewportInteractionStart).toHaveBeenCalledTimes(3);
act(() => {
screen.getByRole('button', { name: '清理交互' }).click();
});
expect(onViewportInteractionEnd).toHaveBeenCalledTimes(1);
expect(onViewportInteractionEnd).toHaveBeenCalledTimes(3);
act(() => {
screen.getByRole('button', { name: '直接拖小地图' }).click();
});
expect(onViewportInteractionStart).toHaveBeenCalledTimes(2);
expect(onViewportInteractionStart).toHaveBeenCalledTimes(4);
act(() => {
screen.getByRole('button', { name: '直接结束小地图拖拽' }).click();
});
expect(flushMinimapViewportDrag).toHaveBeenCalledTimes(1);
expect(onViewportInteractionEnd).toHaveBeenCalledTimes(2);
expect(onViewportInteractionEnd).toHaveBeenCalledTimes(4);
});
it('allows middle-button dragging and rejects right-button dragging in select, hand, and Space modes', () => {
@@ -528,24 +528,33 @@ export function useImageCanvasUploadWorkflow({
return;
}
const reservation = reserveImageReferenceUpload('spec-reference', 1);
const reservation = reserveImageReferenceUpload(
'spec-reference',
Array.from(files).filter(isImageFile).length,
);
if (!reservation) {
return;
}
try {
const imageSrc = await readImageFileAsDataUrl(imageFile);
const imageFiles = Array.from(files).filter(isImageFile);
const fileReferences = await Promise.all(
imageFiles.map(async (file, index) => ({
file,
index,
imageSrc: await readImageFileAsDataUrl(file),
})),
);
if (!validateImageReferenceUploadReservation(reservation)) {
return;
}
const references = await persistReferenceUploads(
[
{
idPrefix: 'upload-spec-reference',
file: imageFile,
fallbackLabel: '参考图',
imageSrc,
},
],
fileReferences.map(({ file, imageSrc, index }) => ({
idPrefix: 'upload-spec-reference',
index,
file,
fallbackLabel: `参考图${index + 1}`,
imageSrc,
})),
reservation,
);
if (
+14
View File
@@ -7024,6 +7024,12 @@ button.image-canvas-editor__reference-chip:disabled {
transform 120ms ease;
}
.image-canvas-editor__reference-chip-remove--always-visible {
opacity: 1;
pointer-events: auto;
transform: scale(1);
}
.image-canvas-editor__reference-slot:hover
.image-canvas-editor__reference-chip-remove,
.image-canvas-editor__reference-slot:focus-within
@@ -7033,6 +7039,14 @@ button.image-canvas-editor__reference-chip:disabled {
transform: scale(1);
}
@media (hover: none) {
.image-canvas-editor__reference-chip-remove {
opacity: 1;
pointer-events: auto;
transform: scale(1);
}
}
.image-canvas-editor__reference-chip-remove:disabled {
cursor: not-allowed;
opacity: 0;