修复画布参考图上传删除链路 (#164)
主站规范生成支持多张参考图 修复普通生成参考图删除与提交状态不同步 优化触屏设备删除按钮可达性并补充回归测试 --------- Co-authored-by: kdletters <kdletters@qq.com> Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/164 Co-authored-by: 孟豪 <mh18530625731@163.com> Co-committed-by: 孟豪 <mh18530625731@163.com>
This commit was merged in pull request #164.
This commit is contained in:
@@ -862,8 +862,14 @@ async fn game_chat_art_receipt_wakes_the_same_code_prototype_run() {
|
||||
.expect("waiting main task exists");
|
||||
let wake_started = wake_waiting_static_delegate_parent_run_at(&root, &waiting)
|
||||
.expect("wake same main run after art receipt");
|
||||
cancel_game_creator_agent_runtime_task_at(&root, "code-prototype", &main.run_id)
|
||||
.expect("cancel resumed fixture run");
|
||||
if let Err(error) =
|
||||
cancel_game_creator_agent_runtime_task_at(&root, "code-prototype", &main.run_id)
|
||||
{
|
||||
assert!(
|
||||
error.contains("Agent Runtime 任务已结束,不能取消"),
|
||||
"cancel resumed fixture run: {error}"
|
||||
);
|
||||
}
|
||||
let release_deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
|
||||
loop {
|
||||
if game_creator_agent_runtime_task_lock_is_available(&root, "code-prototype")
|
||||
|
||||
@@ -85,10 +85,10 @@
|
||||
|
||||
## 生成规范参考图
|
||||
|
||||
- `生成规范 -> 角色规范`、`图标规范`、`图标规范`、`自定义规范` 的设定面板都支持上传 1 张参考图,并统一放在面板首行参考图区域。
|
||||
- `生成规范 -> 角色规范`、`自定义规范` 的设定面板支持上传多张参考图,最多 5 张;`图标规范` 保持单张规范参考图。所有参考图统一放在面板首行参考图区域,并可逐张删除。
|
||||
- 参考图入口只展示字段标题、缩略图或上传图标、文件名,不把参考规则说明铺在 UI 上。
|
||||
- 提交生成规范时,若存在参考图,前端必须把参考图作为 `referenceImageSrcs[0]` 提交到 `/api/editor/images/generations`,并在生图提示词开头自动追加“参考图生成规范”语义:要求模型参考图 1 的构图、风格、材质、色彩、形状语言和视觉层级生成规范图,但不要复制参考图中的文字、水印或无关背景。
|
||||
- 生成结果的信息快照必须记录该参考图,标题为 `参考图`,便于后续在图片信息面板回看生成输入。
|
||||
- 提交生成规范时,若存在参考图,前端必须按用户当前顺序把全部参考图提交到 `/api/editor/images/generations` 的 `referenceImageSrcs`,并在生图提示词开头自动追加“参考图生成规范”语义:要求模型参考图的构图、风格、材质、色彩、形状语言和视觉层级生成规范图,但不要复制参考图中的文字、水印或无关背景。
|
||||
- 生成结果的信息快照必须按当前顺序记录全部参考图,首张标题为 `参考图`,后续依次为 `参考图2`、`参考图3` 等,便于在图片信息面板回看生成输入。
|
||||
|
||||
## 新建生成图落点避让
|
||||
|
||||
|
||||
@@ -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'
|
||||
? {
|
||||
|
||||
@@ -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 [
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user