固定生成规范参数并支持删除生成中占位

生成规范类图片固定提交 16:9、2K 和 gpt-image-2,并在面板底部只读展示。

生成中占位支持通过 Delete 或 Backspace 删除,不新增可见删除按钮。

异步生成回写前按生成对象 ID 校验,避免已删除占位重新落回画布。

更新编辑器生成面板与图片画布技术文档及相关测试。
This commit is contained in:
2026-06-18 16:53:59 +08:00
parent 9eab60e427
commit 6f1ae6caef
18 changed files with 183 additions and 26 deletions
@@ -18,7 +18,7 @@
- 图片拖拽时显示水平 / 垂直吸附参考线,吸附到其它图层或画板的边缘与中心线。
- 生成资源右上角显示元数据按钮,点击打开独立元数据窗口。图片信息页不展示后端组装后的生图 Prompt,也不提供复制 Prompt;只展示该图片生成时用户在面板里提交的输入快照,包括普通生成提示词、规范表单字段、角色设定、图标素材描述、修改要求,以及角色形象规范 / 常规参考图 / 图标素材规范 / 修改参考图等参考图卡片。旧数据或上传图片没有输入快照时显示 `-`,禁止回退展示内部 Prompt。
- 对生成资源执行修改时,在右侧创建新的生成结果图层,并自动调整视图显示原图和新图。
- 图片生成 / 修改统一经 api-server BFF 接入 VectorEngine。普通生成、生成规范和快速编辑保留既有 `gpt-image-2` 路径;`生成角色形象``生成图标素材` 支持 `nanobanana2``gemini-3.1-flash-image-preview`)和 `gpt-image-2`,默认 `nanobanana2`,并在两类面板之间沿用用户上次选择的模型。`nanobanana2``/v1beta/models/{model}:generateContent`,请求体写入 `generationConfig.imageConfig.aspectRatio/imageSize``gpt-image-2``/v1/images/generations``/v1/images/edits`,请求体按 VectorEngine 文档映射 `size`。纯文本生成走 `/api/editor/images/generations`,基于当前生成图的修改走 `/api/editor/images/edits``生成视频``/api/editor/videos/generations`,前端模型入口仅展示 Seedance 2.0 Fast / Seedance 2.0 / Kling 3.0 / Kling 3.0 Omni,不展示 Veo 入口,默认 Seedance 2.0 Fast;首期固定文字转视频、`16:9`、标准模式、静音,生成结果以视频图层加入画布。纯文本生成入口采用 Lovart 式画布内占位图 + 锚定生成输入框:点击生成工具后以当前视口世界中心为目标,经统一 placement 避让后创建选中的灰色占位框,输入框跟随占位框显示;待生成、生成中和失败后保留的占位图都必须继续支持拖动,生成完成时真实生成图或视频落在最新占位框位置,输入框继续跟随新生成图层;占位图失焦时隐藏高亮边框、左上角生成器名称和右上角原始尺寸,重新聚焦时再显示,且名称 / 尺寸在画布缩小时按 viewport 反向缩放保持屏幕尺寸稳定;点击所有图片 / 视频生成入口并确认请求开始后,必须隐藏对应设置面板,只保留画布内占位图或原图预览,并在预览上显示 Lovart 式生成中遮罩,避免“面板仍占屏”或“预览一起消失”。快速编辑和修改图片在调用后端前必须把当前图层图片源读取为图片 Data URL,来源可以是本地上传 Data URL、站内 public 图片、历史 `/generated-*` 路径或可读取的 OSS generated URL;后端仍只接收图片 Data URL,不把普通 URL 直接透传到 VectorEngine edits。前端不持有 provider 密钥;上游失败或配置缺失时恢复当前生成设置面板展示失败,不创建 mock 成功图。
- 图片生成 / 修改统一经 api-server BFF 接入 VectorEngine。普通生成、生成规范和快速编辑保留既有 `gpt-image-2` 路径;其中生成规范类图片固定 `16:9``2K``gpt-image-2`,面板只展示只读参数,不允许在该面板改比例、尺寸或模型。`生成角色形象``生成图标素材` 支持 `nanobanana2``gemini-3.1-flash-image-preview`)和 `gpt-image-2`,默认 `nanobanana2`,并在两类面板之间沿用用户上次选择的模型。`nanobanana2``/v1beta/models/{model}:generateContent`,请求体写入 `generationConfig.imageConfig.aspectRatio/imageSize``gpt-image-2``/v1/images/generations``/v1/images/edits`,请求体按 VectorEngine 文档映射 `size`。纯文本生成走 `/api/editor/images/generations`,基于当前生成图的修改走 `/api/editor/images/edits``生成视频``/api/editor/videos/generations`,前端模型入口仅展示 Seedance 2.0 Fast / Seedance 2.0 / Kling 3.0 / Kling 3.0 Omni,不展示 Veo 入口,默认 Seedance 2.0 Fast;首期固定文字转视频、`16:9`、标准模式、静音,生成结果以视频图层加入画布。纯文本生成入口采用 Lovart 式画布内占位图 + 锚定生成输入框:点击生成工具后以当前视口世界中心为目标,经统一 placement 避让后创建选中的灰色占位框,输入框跟随占位框显示;待生成、生成中和失败后保留的占位图都必须继续支持拖动,生成完成时真实生成图或视频落在最新占位框位置,输入框继续跟随新生成图层;占位图失焦时隐藏高亮边框、左上角生成器名称和右上角原始尺寸,重新聚焦时再显示,且名称 / 尺寸在画布缩小时按 viewport 反向缩放保持屏幕尺寸稳定;点击所有图片 / 视频生成入口并确认请求开始后,必须隐藏对应设置面板,只保留画布内占位图或原图预览,并在预览上显示 Lovart 式生成中遮罩,避免“面板仍占屏”或“预览一起消失”。快速编辑和修改图片在调用后端前必须把当前图层图片源读取为图片 Data URL,来源可以是本地上传 Data URL、站内 public 图片、历史 `/generated-*` 路径或可读取的 OSS generated URL;后端仍只接收图片 Data URL,不把普通 URL 直接透传到 VectorEngine edits。前端不持有 provider 密钥;上游失败或配置缺失时恢复当前生成设置面板展示失败,不创建 mock 成功图。
- 底部生成类按钮每次点击都必须创建独立的画布生成对象;新建规范、角色形象或图标素材时,只切换当前编辑面板,不得销毁此前尚未生成或已生成后的其它生成对象状态。归档为非当前编辑对象的生成占位仍可拖动、删除和等待异步完成,完成 / 失败回写必须按生成对象 ID 读取最新占位状态,不能使用提交瞬间的旧快照。
- 所有会新建画布生成占位的入口必须先创建 draft,再统一经过 `ImageCanvasGenerationPlacementModel` 计算落点,禁止各入口自行使用当前视口中心裸坐标。当前覆盖入口包括 `生成图片``生成规范``生成角色形象``生成图标素材``生成视频``生成UI设计图`。placement 模型的避让对象为所有未隐藏画布图层,以及当前 active / inactive generation dialogs 中仍存在的 placeholder;每个避让矩形按 32px 画布世界坐标间距外扩。候选落点以当前视口世界中心为距离目标,优先选择离视口中心最近且不重叠的占位位置;若中心被占用,会按上下左右和环形候选继续寻找。打开生成面板时必须把避让后的 placeholder 写入 `openCanvasGenerationDialog(...)`,并立即调用 `centerViewportOnPlacement(...)` 居中到新占位中心,保持原 viewport scale 不变。
@@ -87,8 +87,10 @@
- 默认选择模式;底部工具栏能切换工具;中键拖拽和 Space 临时抓手都能平移画布。
- 拖拽图片接近其它图片边缘或中心时显示吸附线,并保存吸附后的最终布局。
- 生成工具点击后显示画布内 `Image Generator` 占位框和跟随占位框的生成输入框,生成失败保留占位和输入状态,生成成功后在占位位置创建真实图层,并让输入框继续跟随该生成图。
- 生成中的占位图聚焦后支持键盘 `Delete` / `Backspace` 删除,不新增可见删除按钮;删除后对应异步回写必须按生成器 ID 判空并丢弃,不能把已删除素材重新落回画布。
- 生成器快照刷新后必须恢复;待生成、生成中、失败和已生成后跟随成品图层的生成器都不能因为刷新丢失输入、参数、参考图或占位框位置。
- 生成类入口打开画布内面板时,底部 AI 工具栏必须保持可见;`生成规范`、角色 / 图标规范来源、角色常规参考图来源这类轻量菜单通过页面级 fixed portal 渲染,不能留在底部工具栏或参考图横向滚动容器内部,避免被局部 `overflow` 裁切。角色形象规范和常规参考图来源菜单必须向上弹出;常规参考图点击后先选择“从画布中选择”或“上传图片”,从画布取图时只绑定参考图,不触发普通画布图层选中、聚焦、面板隐藏或拖拽逻辑,绑定后退出画布选择状态。所有生成面板参考图槽位统一为方形图标组件;角色形象规范槽位只显示规范 logo 和 `角色规范` 四字,绑定来源标题只保留给可访问名称、悬浮 title 和图片信息。已有参考图槽位只有在 hover / focus 时显示右上角 `×`,点击后只解绑对应参考图。
- 生成规范类图片面板底部必须显示只读 `16:9``2K``gpt-image-2`,提交到 `/api/editor/images/generations` 时也固定携带这些参数。
- 点击生成、生成规范、生成角色形象或生成图标素材后创建的占位图可继续保留;点击画布空白区域让当前图片或占位图失焦时,关闭当前生成面板并移除图片选中样式,但不删除占位图本身。
- 生成资源显示元数据按钮,元数据窗口展示来源、生成输入快照、model、provider、task、Resolution 和 OSS 引用;生成输入快照只包含用户面板输入和参考图,不包含后端拼接 Prompt,不再展示独立 Size 字段。
- 修改生成资源后,右侧出现新生成结果图层,并自动 fit 原图 + 新图。
@@ -59,6 +59,7 @@
- 底边栏中会在上方弹出二级选项的入口不再依赖点击展开。鼠标悬停到入口即可打开二级面板,鼠标离开入口和二级面板后自动收起;当前范围包括 `生成规范``生成音乐`
- 底边栏二级选项面板必须锚定到对应入口按钮本身,不使用屏幕居中或固定底部偏移;移动端窄屏下也应保持跟随入口位置。
- 生成图片和生成视频文本输入框紧贴参考图下方,取消旧网格预留导致的空白高度。
- 生成规范类图片固定使用 `16:9 · 2K · gpt-image-2`。这三个参数在面板底部以只读短胶囊展示,不提供比例、尺寸或模型修改入口。
- 不再在底部常驻展开全部可选项。
## 泥点显示
@@ -76,6 +77,7 @@
- 距离目标:以当前视口世界中心为目标点,在候选落点中选择距离最近且不重叠的位置;中心被占用时按相邻边、四角和外圈候选继续寻找。
- 居中行为:调用 `openCanvasGenerationDialog(...)` 时写入避让后的 placeholder;随后调用 `centerViewportOnPlacement(...)`,让屏幕中心移动到新占位中心,并保持原 viewport scale。
- 入口状态:新入口打开后设置对应 activeTool,清空画布选中、右键菜单、快速编辑、角色动画和其它生成来源选择状态;只切换当前面板,不删除已有生成占位。
- 生成中的占位图允许通过键盘 `Delete` / `Backspace` 删除;不额外增加画布上的可见删除按钮。用户删除后,后续异步成功或失败回写不得重新创建该生成对象。
## 画布悬浮信息
@@ -129,5 +131,7 @@
- 底边栏 `生成规范` / `生成音乐` 悬停即可看到上方选项面板;鼠标从按钮移动到选项面板时不闪退,离开按钮和选项面板后收起。
- `生成音乐` 选项面板出现在音乐按钮上方,不再固定在底栏中间。
- 规范面板比图片生成面板更紧凑,字段间距和输入高度更小,但外层 shell、首行参考图和底部按钮区必须继续对齐生成图片 / 生成角色 / 生成视频。
- 生成规范类图片底部展示只读 `16:9``2K``gpt-image-2`,提交参数也固定为这三项,不出现可编辑的比例 / 尺寸 / 模型选项。
- 生成中的占位图聚焦后可用 `Delete` / `Backspace` 删除;删除后异步结果不再落回画布,也不显示额外删除 UI。
- 生成视频结果以视频图层加入画布,画布媒体元素标记为 `画布视频:生成视频 N`
- 生成器输入、参数、参考图和占位框在刷新后仍存在;已生成对象的生成器面板继续跟随成品图层。
@@ -224,6 +224,8 @@ export function ImageCanvasEditorView() {
canvasGenerationDialogs,
openCanvasGenerationDialog,
updateCanvasGenerationDialogById,
removeCanvasGenerationDialogById,
hasCanvasGenerationDialogById,
activateCanvasGenerationDialog,
restoreCanvasGenerationDialogs,
removeCanvasGenerationDialogsByLayerId,
@@ -456,6 +458,7 @@ export function ImageCanvasEditorView() {
canvasGenerationDialogs,
openCanvasGenerationDialog,
updateCanvasGenerationDialogById,
hasCanvasGenerationDialogById,
removeCanvasGenerationDialogsByLayerId,
getGeneratingDialogPlaceholder,
appendCanvasLayersWithResources,
@@ -615,6 +618,11 @@ export function ImageCanvasEditorView() {
redoCanvasChange,
undoCanvasChange,
deleteLayerById: deleteLayerByIdFromShortcut,
removeCanvasGenerationDialogById: (dialogId: string) => {
captureCanvasHistory();
removeCanvasGenerationDialogById(dialogId);
setActiveTool('select');
},
setActiveTool,
setGenerateDialog,
setImageContextMenu,
@@ -46,6 +46,9 @@ export const UI_DESIGN_FRAME_DISPLAY_SIZE = { width: 560, height: 315 };
export const IMAGE_MODEL_GPT_IMAGE_2 = 'gpt-image-2';
export const IMAGE_MODEL_NANOBANANA2 = 'gemini-3.1-flash-image-preview';
export const DEFAULT_IMAGE_MODEL = IMAGE_MODEL_NANOBANANA2;
export const SPEC_GENERATION_MODEL = IMAGE_MODEL_GPT_IMAGE_2;
export const SPEC_GENERATION_ASPECT_RATIO = '16:9';
export const SPEC_GENERATION_IMAGE_SIZE = '2K';
export const ICON_DESCRIPTION_LIMIT = 100;
// 图标素材面板按描述项扩宽,避免在画布子面板里做滑动列表。
export const ICON_DESCRIPTION_CARD_WIDTH_REM = 8.4;
@@ -116,6 +116,9 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
normalizedPrompt: 'AI 生成图片',
input: {
size: '2048x1152',
model: 'gpt-image-2',
aspectRatio: '16:9',
imageSize: '2K',
kind: 'spec',
referenceImageSrcs: ['data:image/png;base64,ref'],
prompt: expect.stringContaining('参考图生成规范'),
@@ -20,6 +20,9 @@ import {
DEFAULT_VIDEO_MODEL,
EDITOR_GENERATION_MUD_POINT_CONFIG,
SEEDANCE_VIDEO_REFERENCE_LIMITS,
SPEC_GENERATION_ASPECT_RATIO,
SPEC_GENERATION_IMAGE_SIZE,
SPEC_GENERATION_MODEL,
SPEC_GENERATION_SIZE,
SPEC_TYPE_LABEL,
buildIconGenerationInputs,
@@ -210,7 +213,9 @@ export function buildImageGenerationSubmissionPlan({
Boolean(dialog.specReference?.src),
),
size: SPEC_GENERATION_SIZE,
model: DEFAULT_IMAGE_MODEL,
model: SPEC_GENERATION_MODEL,
aspectRatio: SPEC_GENERATION_ASPECT_RATIO,
imageSize: SPEC_GENERATION_IMAGE_SIZE,
kind: 'spec',
...(dialog.specReference?.src
? { referenceImageSrcs: [dialog.specReference.src] }
@@ -89,6 +89,15 @@ describe('ImageCanvasSpecGenerationPanelView', () => {
).toBe(true);
const submitButton = screen.getByRole('button', { name: '提交生成规范' });
expect(submitButton.textContent).toBe('生成5泥点');
expect(screen.getByLabelText('生成规范固定参数').textContent).toBe(
'16:92Kgpt-image-2',
);
expect(
screen.queryByRole('button', { name: //u }),
).toBeNull();
expect(
screen.queryByRole('button', { name: //u }),
).toBeNull();
});
it('renders UI design as a single borderless prompt below the first reference row', () => {
@@ -29,7 +29,10 @@ import { ImageCanvasReferenceSlot } from './ImageCanvasReferenceSlot';
import {
CHARACTER_SPEC_VIEW_OPTIONS,
EDITOR_GENERATION_MUD_POINT_CONFIG,
SPEC_GENERATION_ASPECT_RATIO,
SPEC_GENERATION_COST,
SPEC_GENERATION_IMAGE_SIZE,
SPEC_GENERATION_MODEL,
} from './ImageCanvasGenerationModel';
import { useImageCanvasFloatingOptionDismiss } from './useImageCanvasFloatingOptionDismiss';
@@ -82,6 +85,7 @@ export function ImageCanvasSpecGenerationPanelView({
const reference = isUiDesignDialog
? dialog.uiDesignSpecReference
: dialog.specReference;
const isGenerating = dialog.status === 'generating';
useImageCanvasFloatingOptionDismiss({
isOpen: isGenerationReferenceMenuOpen,
boundaryRefs: [generationReferenceButtonRef],
@@ -127,7 +131,7 @@ export function ImageCanvasSpecGenerationPanelView({
label={referenceSlotLabel}
ariaLabel={referenceLabel}
title={reference?.label ?? referenceLabel}
disabled={dialog.status === 'generating'}
disabled={isGenerating}
isAdd={!reference}
onClick={openReferenceMenu}
onRemove={
@@ -178,7 +182,7 @@ export function ImageCanvasSpecGenerationPanelView({
variant="textarea"
aria-label="UI设计要求"
value={dialog.prompt}
disabled={dialog.status === 'generating'}
disabled={isGenerating}
placeholder="你希望这个 UI 长什么样?"
size="sm"
density="compact"
@@ -220,7 +224,7 @@ export function ImageCanvasSpecGenerationPanelView({
variant="textarea"
aria-label="自定义规范提示词"
value={dialog.specValues?.customPrompt ?? ''}
disabled={dialog.status === 'generating'}
disabled={isGenerating}
size="sm"
density="compact"
className="image-canvas-editor__spec-textarea"
@@ -241,7 +245,7 @@ export function ImageCanvasSpecGenerationPanelView({
<PlatformTextField
aria-label="玩法设定"
value={dialog.specValues?.playSetting ?? ''}
disabled={dialog.status === 'generating'}
disabled={isGenerating}
size="sm"
density="compact"
className="image-canvas-editor__spec-input"
@@ -260,7 +264,7 @@ export function ImageCanvasSpecGenerationPanelView({
<PlatformTextField
aria-label="美术风格"
value={dialog.specValues?.artStyle ?? ''}
disabled={dialog.status === 'generating'}
disabled={isGenerating}
size="sm"
density="compact"
className="image-canvas-editor__spec-input"
@@ -281,7 +285,7 @@ export function ImageCanvasSpecGenerationPanelView({
<PlatformSelectField
aria-label="头身比"
value={dialog.specValues?.bodyRatio ?? '3'}
disabled={dialog.status === 'generating'}
disabled={isGenerating}
size="sm"
density="compact"
className="image-canvas-editor__spec-input"
@@ -306,7 +310,7 @@ export function ImageCanvasSpecGenerationPanelView({
<PlatformSelectField
aria-label="角色视角"
value={dialog.specValues?.characterView ?? ''}
disabled={dialog.status === 'generating'}
disabled={isGenerating}
size="sm"
density="compact"
className="image-canvas-editor__spec-input"
@@ -353,26 +357,33 @@ export function ImageCanvasSpecGenerationPanelView({
/>
) : (
<>
<span aria-hidden="true" />
<PlatformActionButton
<div
className="image-canvas-editor__readonly-generation-options"
aria-label="生成规范固定参数"
>
<span>{SPEC_GENERATION_ASPECT_RATIO}</span>
<span>{SPEC_GENERATION_IMAGE_SIZE}</span>
<span>{SPEC_GENERATION_MODEL}</span>
</div>
<PlatformActionButton
type="submit"
tone="secondary"
size="xs"
shape="pill"
className="image-canvas-editor__generation-submit image-canvas-editor__spec-submit"
disabled={dialog.status === 'generating'}
disabled={isGenerating}
aria-label="提交生成规范"
>
{dialog.status === 'generating' ? (
'生成中'
) : (
<>
<span></span>
<span className="image-canvas-editor__mud-point-inline">
{SPEC_GENERATION_COST}
</span>
</>
)}
{isGenerating ? (
'生成中'
) : (
<>
<span></span>
<span className="image-canvas-editor__mud-point-inline">
{SPEC_GENERATION_COST}
</span>
</>
)}
</PlatformActionButton>
</>
)}
@@ -100,6 +100,14 @@ export function useCanvasGenerationDialogs({
[updateCanvasGenerationDialogById],
);
const hasCanvasGenerationDialogById = useCallback((dialogId: string) => {
const currentDialog = generateDialogRef.current;
return [
...(isCanvasGenerationDialog(currentDialog) ? [currentDialog] : []),
...inactiveGenerateDialogsRef.current,
].some((dialog) => dialog.id === dialogId);
}, []);
const activateCanvasGenerationDialog = useCallback(
(targetDialog: CanvasGenerationDialogState) => {
setInactiveGenerateDialogs((currentDialogs) => {
@@ -216,6 +224,7 @@ export function useCanvasGenerationDialogs({
openCanvasGenerationDialog,
updateCanvasGenerationDialogById,
removeCanvasGenerationDialogById,
hasCanvasGenerationDialogById,
activateCanvasGenerationDialog,
restoreCanvasGenerationDialogs,
removeCanvasGenerationDialogsByLayerId,
@@ -133,6 +133,7 @@ function SubmissionWorkflowHarness({
setCharacterAnimationPanel,
setGenerateDialog: dialogs.setGenerateDialog,
updateCanvasGenerationDialogById: dialogs.updateCanvasGenerationDialogById,
hasCanvasGenerationDialogById: dialogs.hasCanvasGenerationDialogById,
getGeneratingDialogPlaceholder: dialogs.getGeneratingDialogPlaceholder,
appendCanvasLayersWithResources: (nextLayers) =>
setLayers((currentLayers) => [...currentLayers, ...nextLayers]),
@@ -69,6 +69,7 @@ type GenerationSubmissionWorkflowOptions = {
dialogId: string,
updater: CanvasGenerationDialogUpdater,
) => void;
hasCanvasGenerationDialogById: (dialogId: string) => boolean;
getGeneratingDialogPlaceholder: (
dialog: GenerateDialogState,
) => GenerateDialogState['placeholder'];
@@ -94,6 +95,7 @@ export function useImageCanvasGenerationSubmissionWorkflow({
setCharacterAnimationPanel,
setGenerateDialog,
updateCanvasGenerationDialogById,
hasCanvasGenerationDialogById,
getGeneratingDialogPlaceholder,
appendCanvasLayersWithResources,
selectSingleLayer,
@@ -127,6 +129,9 @@ export function useImageCanvasGenerationSubmissionWorkflow({
generationInputs?: CanvasGenerationInputs;
} = {},
) => {
if (options.dialogId && !hasCanvasGenerationDialogById(options.dialogId)) {
return;
}
layerCounterRef.current += 1;
const generatedIndex = layerCounterRef.current;
const nextLayer = createGeneratedResultLayer({
@@ -170,6 +175,7 @@ export function useImageCanvasGenerationSubmissionWorkflow({
setActiveTool,
setGenerateDialog,
updateCanvasGenerationDialogById,
hasCanvasGenerationDialogById,
viewport,
],
);
@@ -219,6 +225,9 @@ export function useImageCanvasGenerationSubmissionWorkflow({
frame?: GenerateDialogState['placeholder'],
dialogId?: string,
) => {
if (dialogId && !hasCanvasGenerationDialogById(dialogId)) {
return;
}
const startIndex = layerCounterRef.current + 1;
const nextLayers = createIconSpritesheetResultLayers({
generated,
@@ -256,6 +265,7 @@ export function useImageCanvasGenerationSubmissionWorkflow({
setActiveSidebarPanel,
setActiveTool,
updateCanvasGenerationDialogById,
hasCanvasGenerationDialogById,
viewport,
],
);
@@ -268,6 +278,9 @@ export function useImageCanvasGenerationSubmissionWorkflow({
frame?: GenerateDialogState['placeholder'],
dialogId?: string,
) => {
if (dialogId && !hasCanvasGenerationDialogById(dialogId)) {
return;
}
layerCounterRef.current += 1;
const generatedIndex = layerCounterRef.current;
const nextLayer = createVideoResultLayer({
@@ -302,6 +315,7 @@ export function useImageCanvasGenerationSubmissionWorkflow({
setActiveSidebarPanel,
setActiveTool,
updateCanvasGenerationDialogById,
hasCanvasGenerationDialogById,
viewport,
],
);
@@ -314,6 +328,9 @@ export function useImageCanvasGenerationSubmissionWorkflow({
frame?: GenerateDialogState['placeholder'],
dialogId?: string,
) => {
if (dialogId && !hasCanvasGenerationDialogById(dialogId)) {
return;
}
layerCounterRef.current += 1;
const generatedIndex = layerCounterRef.current;
const nextLayer = createAudioResultLayer({
@@ -348,6 +365,7 @@ export function useImageCanvasGenerationSubmissionWorkflow({
setActiveSidebarPanel,
setActiveTool,
updateCanvasGenerationDialogById,
hasCanvasGenerationDialogById,
viewport,
],
);
@@ -90,6 +90,7 @@ function GenerationSurfaceHarness() {
canvasGenerationDialogs: dialogs.canvasGenerationDialogs,
openCanvasGenerationDialog: dialogs.openCanvasGenerationDialog,
updateCanvasGenerationDialogById: dialogs.updateCanvasGenerationDialogById,
hasCanvasGenerationDialogById: dialogs.hasCanvasGenerationDialogById,
removeCanvasGenerationDialogsByLayerId:
dialogs.removeCanvasGenerationDialogsByLayerId,
getGeneratingDialogPlaceholder: dialogs.getGeneratingDialogPlaceholder,
@@ -64,6 +64,7 @@ type ImageCanvasGenerationSurfaceOptions = {
dialogId: string,
updater: CanvasGenerationDialogUpdater,
) => void;
hasCanvasGenerationDialogById: (dialogId: string) => boolean;
removeCanvasGenerationDialogsByLayerId: (targetLayerId: string) => void;
getGeneratingDialogPlaceholder: (
dialog: GenerateDialogState,
@@ -132,6 +133,7 @@ export function useImageCanvasGenerationSurface({
canvasGenerationDialogs,
openCanvasGenerationDialog,
updateCanvasGenerationDialogById,
hasCanvasGenerationDialogById,
removeCanvasGenerationDialogsByLayerId,
getGeneratingDialogPlaceholder,
appendCanvasLayersWithResources,
@@ -158,6 +160,7 @@ export function useImageCanvasGenerationSurface({
setGenerateDialog,
openCanvasGenerationDialog,
updateCanvasGenerationDialogById,
hasCanvasGenerationDialogById,
removeCanvasGenerationDialogsByLayerId,
getGeneratingDialogPlaceholder,
appendCanvasLayersWithResources,
@@ -110,6 +110,7 @@ function GenerationWorkflowHarness({
setGenerateDialog: dialogs.setGenerateDialog,
openCanvasGenerationDialog: dialogs.openCanvasGenerationDialog,
updateCanvasGenerationDialogById: dialogs.updateCanvasGenerationDialogById,
hasCanvasGenerationDialogById: dialogs.hasCanvasGenerationDialogById,
removeCanvasGenerationDialogsByLayerId:
dialogs.removeCanvasGenerationDialogsByLayerId,
getGeneratingDialogPlaceholder: dialogs.getGeneratingDialogPlaceholder,
@@ -459,7 +460,9 @@ describe('useImageCanvasGenerationWorkflow', () => {
expect.objectContaining({
size: '2048x1152',
kind: 'spec',
model: 'gemini-3.1-flash-image-preview',
model: 'gpt-image-2',
aspectRatio: '16:9',
imageSize: '2K',
referenceImageSrcs: ['data:image/png;base64,ref'],
prompt: expect.stringContaining('参考图生成规范'),
}),
@@ -79,6 +79,7 @@ type GenerationWorkflowOptions = {
dialogId: string,
updater: CanvasGenerationDialogUpdater,
) => void;
hasCanvasGenerationDialogById: (dialogId: string) => boolean;
removeCanvasGenerationDialogsByLayerId: (targetLayerId: string) => void;
getGeneratingDialogPlaceholder: (
dialog: GenerateDialogState,
@@ -104,6 +105,7 @@ export function useImageCanvasGenerationWorkflow({
setGenerateDialog,
openCanvasGenerationDialog,
updateCanvasGenerationDialogById,
hasCanvasGenerationDialogById,
removeCanvasGenerationDialogsByLayerId,
getGeneratingDialogPlaceholder,
appendCanvasLayersWithResources,
@@ -485,6 +487,7 @@ export function useImageCanvasGenerationWorkflow({
setCharacterAnimationPanel,
setGenerateDialog,
updateCanvasGenerationDialogById,
hasCanvasGenerationDialogById,
getGeneratingDialogPlaceholder,
appendCanvasLayersWithResources,
selectSingleLayer,
@@ -59,6 +59,7 @@ function KeyboardShortcutsHarness({
undoCanvasChange = vi.fn(),
redoCanvasChange = vi.fn(),
deleteLayerById = vi.fn(),
removeCanvasGenerationDialogById = vi.fn(),
closeEditorChromePanels = vi.fn(),
}: {
selectedLayerId?: string | null;
@@ -68,6 +69,7 @@ function KeyboardShortcutsHarness({
undoCanvasChange?: () => void;
redoCanvasChange?: () => void;
deleteLayerById?: (layerId: string | null) => void;
removeCanvasGenerationDialogById?: (dialogId: string) => void;
closeEditorChromePanels?: () => void;
}) {
const [activeTool, setActiveTool] = useState<CanvasTool>(initialTool);
@@ -108,6 +110,7 @@ function KeyboardShortcutsHarness({
redoCanvasChange,
undoCanvasChange,
deleteLayerById,
removeCanvasGenerationDialogById,
setActiveTool: (tool) => setActiveTool(tool),
setGenerateDialog: (updater) =>
setGenerateDialogState((currentDialog) =>
@@ -227,10 +230,12 @@ describe('useImageCanvasKeyboardShortcuts', () => {
});
it('removes editable generation placeholders with Backspace', () => {
const removeCanvasGenerationDialogById = vi.fn();
render(
<KeyboardShortcutsHarness
initialTool="character"
initialGenerateDialog={createPlaceholderDialog('character')}
removeCanvasGenerationDialogById={removeCanvasGenerationDialogById}
/>,
);
@@ -238,7 +243,10 @@ describe('useImageCanvasKeyboardShortcuts', () => {
fireEvent.keyDown(window, { key: 'Backspace', code: 'Backspace' });
});
expect(screen.getByTestId('generate-dialog').textContent).toBe('none');
expect(removeCanvasGenerationDialogById).toHaveBeenCalledWith('dialog-1');
expect(screen.getByTestId('generate-dialog').textContent).toBe(
'character:idle:true',
);
expect(screen.getByTestId('active-tool').textContent).toBe('select');
expect(screen.getByTestId('character-menu').textContent).toBe('false');
expect(screen.getByTestId('character-picking').textContent).toBe('false');
@@ -246,6 +254,26 @@ describe('useImageCanvasKeyboardShortcuts', () => {
expect(screen.getByTestId('icon-picking').textContent).toBe('false');
});
it('removes generating placeholders with Delete', () => {
const removeCanvasGenerationDialogById = vi.fn();
const generatingDialog = createPlaceholderDialog('generate');
generatingDialog.status = 'generating';
render(
<KeyboardShortcutsHarness
initialTool="generate"
initialGenerateDialog={generatingDialog}
removeCanvasGenerationDialogById={removeCanvasGenerationDialogById}
/>,
);
act(() => {
fireEvent.keyDown(window, { key: 'Delete', code: 'Delete' });
});
expect(removeCanvasGenerationDialogById).toHaveBeenCalledWith('dialog-1');
expect(screen.getByTestId('active-tool').textContent).toBe('select');
});
it('closes transient editor panels with Escape and collapses idle composers', () => {
const closeEditorChromePanels = vi.fn();
render(
@@ -1,6 +1,7 @@
import { type RefObject, useEffect } from 'react';
import type {
CanvasGenerationDialogState,
GenerateDialogState,
QuickEditPanelState,
} from './ImageCanvasEditorTypes';
@@ -11,6 +12,7 @@ type UseImageCanvasKeyboardShortcutsOptions = {
redoCanvasChange: () => void;
undoCanvasChange: () => void;
deleteLayerById: (layerId: string | null) => void;
removeCanvasGenerationDialogById?: (dialogId: string) => void;
setActiveTool: (tool: 'select') => void;
setGenerateDialog: (
updater:
@@ -61,7 +63,6 @@ function isCanvasGenerationPlaceholderDialog(
) {
return (
Boolean(dialog?.placeholder) &&
dialog?.status !== 'generating' &&
(dialog?.mode === 'generate' ||
dialog?.mode === 'spec' ||
dialog?.mode === 'character' ||
@@ -71,12 +72,19 @@ function isCanvasGenerationPlaceholderDialog(
);
}
function isCanvasGenerationDialogState(
dialog: GenerateDialogState | null,
): dialog is CanvasGenerationDialogState {
return Boolean(dialog?.id && isCanvasGenerationPlaceholderDialog(dialog));
}
export function useImageCanvasKeyboardShortcuts({
generateDialogRef,
selectedLayerIdRef,
redoCanvasChange,
undoCanvasChange,
deleteLayerById,
removeCanvasGenerationDialogById,
setActiveTool,
setGenerateDialog,
setImageContextMenu,
@@ -171,7 +179,15 @@ export function useImageCanvasKeyboardShortcuts({
}
if (isCanvasGenerationPlaceholderDialog(generateDialogRef.current)) {
event.preventDefault();
setGenerateDialog(null);
const currentDialog = generateDialogRef.current;
if (
isCanvasGenerationDialogState(currentDialog) &&
removeCanvasGenerationDialogById
) {
removeCanvasGenerationDialogById(currentDialog.id);
} else {
setGenerateDialog(null);
}
setActiveTool('select');
setIsGenerationReferenceMenuOpen(false);
setIsPickingGenerationReferenceFromCanvas(false);
@@ -217,6 +233,7 @@ export function useImageCanvasKeyboardShortcuts({
closeEditorChromePanels,
deleteLayerById,
generateDialogRef,
removeCanvasGenerationDialogById,
redoCanvasChange,
selectedLayerIdRef,
setActiveTool,
+29
View File
@@ -5917,6 +5917,30 @@ button.image-canvas-editor__reference-chip:disabled {
gap: 0.24rem;
}
.image-canvas-editor__readonly-generation-options {
display: inline-flex;
min-width: 0;
min-height: 2.25rem;
align-items: center;
gap: 0.28rem;
justify-self: start;
color: #475569;
font-size: 0.78rem;
font-weight: 820;
}
.image-canvas-editor__readonly-generation-options span {
display: inline-flex;
min-height: 1.72rem;
align-items: center;
justify-content: center;
border: 1px solid rgba(148, 163, 184, 0.18);
border-radius: 999px;
background: #f8fafc;
padding: 0 0.58rem;
white-space: nowrap;
}
.image-canvas-editor__context-menu {
position: fixed;
z-index: 35;
@@ -6325,6 +6349,7 @@ button.image-canvas-editor__reference-chip:disabled {
.image-canvas-editor__option-cluster--dimensions,
.image-canvas-editor__option-cluster--model,
.image-canvas-editor__readonly-generation-options,
.image-canvas-editor__generation-submit,
.image-canvas-editor__character-animation-submit {
grid-column: auto;
@@ -6337,6 +6362,10 @@ button.image-canvas-editor__reference-chip:disabled {
width: 100%;
}
.image-canvas-editor__readonly-generation-options {
flex-wrap: wrap;
}
.image-canvas-editor__character-composer {
position: fixed;
left: 0.75rem !important;