修复未登记画布素材复制竞态
禁止复制、剪切或创建仍使用临时资源 ID 的图层 移除临时资源创建请求的 single-flight 合并与共享回填逻辑 补充菜单禁用、快捷键反馈、资源回填恢复和持久化回归测试 同步图片画布技术方案、决策记录与排障经验
This commit is contained in:
@@ -5912,5 +5912,6 @@
|
||||
## 2026-08-03 图片画布复制固定为布局实例复用项目资源
|
||||
|
||||
- 背景:画布复制逻辑曾为副本生成 `local-resource-copy-*`,但没有创建对应项目资源;自动保存虽然触发,结构化画布会拒绝缺资源引用,legacy 快照刷新时也无法 hydrate 副本。
|
||||
- 决策:`layerId` 是同一 canvas 内唯一的布局实例身份,`resourceId` 是允许多个布局实例共享的项目媒体身份。复制、粘贴、创建副本和剪切后粘贴只生成新 `layerId`,复用来源 `resourceId`;已登记资源不得再次上传或创建。临时 `local-*` 资源按用户、项目和临时 ID 合并创建,正式 ID 返回后更新全部共享该临时 ID 的当前图层;session 项目缓存也按 `resourceId` 只保留一份资源快照。
|
||||
- 决策:`layerId` 是同一 canvas 内唯一的布局实例身份,`resourceId` 是允许多个布局实例共享的项目媒体身份。复制、粘贴、创建副本和剪切后粘贴只生成新 `layerId`,复用来源正式 `resourceId`;已登记资源不得再次上传或创建。只要目标中存在 `resourceId` 仍以 `local-*` 开头的未登记图层,整次复制、剪切或创建副本都失败关闭,右键动作禁用并由快捷键反馈短提示;正式 ID 回填后恢复,layout PATCH 是否 pending 不参与判断,系统剪贴板图片导入不受影响。
|
||||
- 并发边界:未登记图层被禁止复制后,不再按临时资源 ID 合并项目资源创建请求,也不再用一次响应批量改写共享临时 ID。每个合法新增图层保留自己的响应快照与回调;layout PATCH 的串行 latest-wins 队列、正式资源的多布局引用和 session 资源快照按正式 `resourceId` 去重继续保留,它们与资源创建 single-flight 是不同机制。
|
||||
- 后端边界:现有 `editor_canvas_layer` 已以 `layer_id` 建立行身份并单独保存 `resource_id`,天然支持多 layer 引用同一 resource;本次不修改 HTTP DTO、SpacetimeDB schema、迁移或 bindings,只增加结构化 round-trip 契约测试。
|
||||
|
||||
@@ -457,6 +457,14 @@
|
||||
- 验证:`npm run test -- src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx -- --runInBand` 应覆盖裁扩先上传再创建项目资源,以及去背景队列完成后对未完成占位进行二次项目读取。
|
||||
- 关联:`src/components/image-editor/useImageCanvasGenerationWorkflow.ts`、`src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts`、`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。
|
||||
|
||||
## 图片画布未登记素材复制会放大异步资源竞态
|
||||
|
||||
- 现象:刚加入画布、`resourceId` 仍为 `local-*` 的图层被复制后,权威项目快照刷新与资源创建响应交错,副本可能刷新后消失;用临时 ID single-flight 合并请求还会吞掉后续调用自己的快照或回调。
|
||||
- 原因:复制语义本应只复用已经存在的正式项目资源,但未登记素材还没有可稳定复用的资源身份。把多个布局调用压进一个 Promise 并不能自动合并每次调用的恢复上下文。
|
||||
- 处理:只要目标集合含 `local-*` 图层,复制、剪切、创建副本和内部粘贴整体拒绝;正式 `resourceId` 回填后再开放。不要把普通 layout PATCH pending 当作未完成,也不要阻止系统剪贴板图片导入。资源创建不再按临时 ID single-flight 合并;布局保存的串行 latest-wins 队列仍保留。
|
||||
- 验证:`ImageCanvasLayerCommandModel.test.ts`、`useImageCanvasLayerCommands.test.tsx` 和 `ImageCanvasContextMenusView.test.tsx` 覆盖多选整体拒绝、回填后恢复和菜单禁用;`useImageCanvasProjectPersistence.test.tsx` 继续覆盖单个资源响应与权威快照交错恢复。
|
||||
- 关联:`src/components/image-editor/ImageCanvasLayerCommandModel.ts`、`src/components/image-editor/useImageCanvasLayerCommands.ts`、`src/components/image-editor/useImageCanvasProjectPersistence.ts`。
|
||||
|
||||
## 图片画布项目封面上传失败要有本地展示兜底
|
||||
|
||||
- 现象:画布项目已反复打开、保存或操作,但 `/project` 列表卡片仍只显示“项目”占位,没有封面图。
|
||||
|
||||
@@ -57,7 +57,7 @@
|
||||
- 右上角缩放控件只展示当前缩放百分比;点击后弹出菜单:放大、缩小、显示画布所有元素、缩放至 50%、缩放至 100%、缩放至 200%。缩放百分比以实际画布 `viewport.scale = 0.5` 作为显示 `100%` 的基准,菜单中的 `50% / 100% / 200%` 分别对应实际 `0.25 / 0.5 / 1`,工程持久化保存和读取仍使用用户可见缩放语义。
|
||||
- 缩放菜单支持 `Ctrl/Cmd +`、`Ctrl/Cmd -` 和 `Shift + 1`;快捷键只改变 viewport,不修改工程资源。
|
||||
- 右上角提供快捷键入口,点击后打开独立快捷键弹窗;画布不常驻展示说明文案。Windows 快捷键覆盖 `Ctrl+Z` / `Ctrl+Shift+Z` 撤销与恢复、`Ctrl+A/C/V/X/D` 全选 / 复制 / 粘贴 / 剪切 / 复制一份、`Ctrl+0/1/+/-` 视图控制、`V/H/U/G/Shift+V/M` 工具切换、`Alt+1/Alt+2/Alt+M` 面板切换、`Ctrl+]` / `Ctrl+[` / `Ctrl+Shift+]` / `Ctrl+Shift+[` 层级调整、方向键微移 / `Shift+方向键` 大步移动、`Ctrl+Shift+S` 下载画布素材和 `F2` 重命名项目;快捷键只触发对应画布交互,不绕过既有保存 / 生成 / 上传工作流。撤销与恢复的内容保护、提示和验收边界统一以 [`图片画布撤销、恢复范围与操作提示方案`](../【图片画布】撤销范围与操作提示方案-2026-07-17.md) 为准。
|
||||
- 画布内部复制 / 粘贴和创建副本只新增独立 `layerId` 布局实例,继续引用来源图层的同一项目 `resourceId`;副本可独立移动、排序、分组、显隐和删除,但不得重复上传媒体或为同一图片创建虚假项目资源。没有内部画布剪贴板时,系统剪贴板图片仍按新素材上传。
|
||||
- 画布内部复制 / 粘贴和创建副本只新增独立 `layerId` 布局实例,继续引用来源图层的同一正式项目 `resourceId`;副本可独立移动、排序、分组、显隐和删除,但不得重复上传媒体或为同一图片创建虚假项目资源。`resourceId` 仍以 `local-*` 开头时表示素材正在登记,复制、剪切、创建副本和内部画布粘贴必须整体拒绝,右键动作禁用,快捷键提示“素材仍在保存,请稍后重试”;正式 ID 回填后立即恢复,不因普通 layout PATCH 仍在保存而禁用。没有内部画布剪贴板时,系统剪贴板图片仍按新素材上传。临时资源创建不再做同 ID single-flight 合并,每个合法新增图层独立承接响应、快照恢复和回调。
|
||||
- 背景色控件只修改编辑器工作区底色,不恢复网格线或棋盘格底纹,也不影响图片本体。
|
||||
- 吸附阈值以屏幕像素为准,换算到世界坐标后参与拖拽计算;边缘 / 中心线和等距吸附共用同一阈值。拖拽结束后只保存最终图层或生成占位布局,不保存临时参考线。
|
||||
- 项目页封面和画布图片图层必须先渲染项目卡、图层外框、标题、尺寸和操作 chrome;图片换签或解码未完成时,只在图片区域显示轻量加载态,不阻塞外框和文字等低成本信息先出现。
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
|
||||
保存 `layer_id`、`canvas_id`、`project_id`、`owner_user_id`、坐标、宽高、原始尺寸、层级顺序、可选 `group_id`、hidden / locked / flip 状态、`resource_id`、有界的未结构化扩展 JSON、创建与更新时间。查询有 canvas / project 索引;同一 canvas 的层级顺序由 `sort_order` 决定。
|
||||
|
||||
同一 canvas 内 `layer_id` 是唯一的布局实例身份,`resource_id` 是可复用的媒体资源引用,不要求唯一。复制、粘贴或创建副本只创建新的 `layer_id` 和独立几何 / 层级状态,继续引用来源图层的同一 `resource_id`;不得为同一媒体生成虚假的本地资源 ID、重复上传对象或重复创建项目资源。临时 `local-*` 资源尚在登记时,前端按临时 ID 合并资源创建,并在正式 ID 返回后更新全部引用该临时 ID 的布局实例。
|
||||
同一 canvas 内 `layer_id` 是唯一的布局实例身份,`resource_id` 是可复用的媒体资源引用,不要求唯一。复制、粘贴或创建副本只创建新的 `layer_id` 和独立几何 / 层级状态,继续引用来源图层的同一正式 `resource_id`;不得为同一媒体生成虚假的本地资源 ID、重复上传对象或重复创建项目资源。`resource_id` 仍以 `local-*` 开头表示素材尚未完成项目资源登记:只要复制目标中有一个此类图层,整次复制、剪切或创建副本都必须拒绝;正式 ID 回填后立即恢复操作。该约束不绑定 layout 自动保存状态,也不影响从系统剪贴板导入新图片。每个合法新增图层独立完成自己的资源登记,前端不再按临时资源 ID 合并创建请求。
|
||||
|
||||
layer 只表达“某个资源怎样放在画布上”。`src / prompt / actualPrompt / model / provider / taskId / objectKey / assetObjectId / sourceResourceId / assetKind / generationInputs / sourceType` 不得为方便展示而重复进入 `item_json` 或成为 layer 真相;迁移或结构化保存时先逐字段核对 `editor_project_resource`,缺少资源、字段冲突或无法无损重组时必须 fail-closed。历史 layout 若把 `sourceResourceId` 错写成当前图层自己的 `resourceId`,这是无意义的自引用,不作为 A/B 来源冲突:迁移时删除该重复字段并以项目资源表为真相;其他非空且不一致的来源 ID 继续拒绝。唯一存量缺资源例外是历史角色动作产生的自包含本地图层:`resourceId` 必须以 `local-` 开头、`sourceType=generated`、`mediaType=image-sequence`,至少包含一帧;帧序号必须从 1 连续递增,宽高必须是有限正数;所有 `imageSequenceFrames[].imageSrc` 及可选 `thumbnailSrc / previewVideoPath` 都必须是无 query / fragment、无路径回退段的站内根路径,且不能含 `data:`、`blob:`、HTTP 或签名 URL;可选帧 `objectKey` 必须与 `imageSrc` 去掉首斜杠后完全一致,图层级 `imageSrc / objectKey / assetObjectId` 必须为空,图层级 `src` 只允许为空或与首帧 `imageSrc` 完全一致。满足这些条件但资源行已不存在时,保留其有界媒体扩展和生成元数据以便前端从首帧恢复,不把这些字段从 canonical hash 中剥离;`sourceResourceId` 目标仍存在时必须属于同工程和 owner,目标已删除时保留原引用参与 hash,不据此伪造资源行。该例外不适用于普通图片、视频、音频、非本地 ID、空帧序列或已有资源字段冲突。存量 `assetKind / generationInputs` 仅允许在资源行尚未记录时由 apply 事务补入资源表,dry-run 只计算预览而不写库。前端读取项目快照时继续使用随项目返回的 resources 按 `resource_id` hydrate,兼容现有画布快照语义。
|
||||
|
||||
|
||||
@@ -36,6 +36,8 @@ function renderContextMenus(
|
||||
contextMenuLayer: null,
|
||||
contextShouldShowLayer: false,
|
||||
contextShouldUnlockLayer: false,
|
||||
canCopyContextLayers: true,
|
||||
isCanvasClipboardCopyBlocked: false,
|
||||
onPasteCanvasClipboard: vi.fn(),
|
||||
onCopyContextLayers: vi.fn(),
|
||||
onDuplicateContextLayers: vi.fn(),
|
||||
@@ -183,6 +185,36 @@ describe('ImageCanvasContextMenusView', () => {
|
||||
expect(props.onDeleteContextLayers).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('disables copy actions while the target resource is still being saved', () => {
|
||||
const layer = createLayer({ resourceId: 'local-resource-pending' });
|
||||
const props = renderContextMenus({
|
||||
contextMenu: {
|
||||
kind: 'layer',
|
||||
x: 16,
|
||||
y: 18,
|
||||
layerId: layer.id,
|
||||
canvasPoint: { x: 40, y: 42 },
|
||||
},
|
||||
contextMenuLayer: layer,
|
||||
canCopyContextLayers: false,
|
||||
});
|
||||
|
||||
expect(
|
||||
(screen.getByRole('menuitem', { name: '复制' }) as HTMLButtonElement)
|
||||
.disabled,
|
||||
).toBe(true);
|
||||
expect(
|
||||
(screen.getByRole('menuitem', { name: '剪切' }) as HTMLButtonElement)
|
||||
.disabled,
|
||||
).toBe(true);
|
||||
expect(
|
||||
(screen.getByRole('menuitem', { name: '创建副本' }) as HTMLButtonElement)
|
||||
.disabled,
|
||||
).toBe(true);
|
||||
expect(props.onCopyContextLayers).not.toHaveBeenCalled();
|
||||
expect(props.onDuplicateContextLayers).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps measured layer menus and submenus inside the viewport', async () => {
|
||||
const originalInnerWidth = window.innerWidth;
|
||||
const originalInnerHeight = window.innerHeight;
|
||||
|
||||
@@ -29,6 +29,8 @@ type ImageCanvasContextMenusViewProps = {
|
||||
contextMenuLayer: CanvasLayer | null;
|
||||
contextShouldShowLayer: boolean;
|
||||
contextShouldUnlockLayer: boolean;
|
||||
canCopyContextLayers: boolean;
|
||||
isCanvasClipboardCopyBlocked: boolean;
|
||||
onPasteCanvasClipboard: (canvasPoint?: { x: number; y: number }) => void;
|
||||
onCopyContextLayers: (options?: { cut?: boolean }) => void;
|
||||
onDuplicateContextLayers: () => void;
|
||||
@@ -69,6 +71,8 @@ export function ImageCanvasContextMenusView({
|
||||
contextMenuLayer,
|
||||
contextShouldShowLayer,
|
||||
contextShouldUnlockLayer,
|
||||
canCopyContextLayers,
|
||||
isCanvasClipboardCopyBlocked,
|
||||
onPasteCanvasClipboard,
|
||||
onCopyContextLayers,
|
||||
onDuplicateContextLayers,
|
||||
@@ -282,6 +286,7 @@ export function ImageCanvasContextMenusView({
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
disabled={isCanvasClipboardCopyBlocked}
|
||||
onClick={() => onPasteCanvasClipboard(contextMenu.canvasPoint)}
|
||||
>
|
||||
粘贴
|
||||
@@ -331,6 +336,7 @@ export function ImageCanvasContextMenusView({
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
disabled={!canCopyContextLayers}
|
||||
onClick={() => onCopyContextLayers()}
|
||||
>
|
||||
复制
|
||||
@@ -338,6 +344,7 @@ export function ImageCanvasContextMenusView({
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
disabled={!canCopyContextLayers}
|
||||
onClick={() => onCopyContextLayers({ cut: true })}
|
||||
>
|
||||
剪切
|
||||
@@ -345,6 +352,7 @@ export function ImageCanvasContextMenusView({
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
disabled={isCanvasClipboardCopyBlocked}
|
||||
onClick={() => onPasteCanvasClipboard(contextMenu.canvasPoint)}
|
||||
>
|
||||
粘贴
|
||||
@@ -352,6 +360,7 @@ export function ImageCanvasContextMenusView({
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
disabled={!canCopyContextLayers}
|
||||
onClick={onDuplicateContextLayers}
|
||||
>
|
||||
创建副本
|
||||
|
||||
@@ -180,6 +180,8 @@ function createStageProps(): ImageCanvasStageViewProps {
|
||||
contextMenuLayer: null,
|
||||
contextShouldShowLayer: false,
|
||||
contextShouldUnlockLayer: false,
|
||||
canCopyContextLayers: true,
|
||||
isCanvasClipboardCopyBlocked: false,
|
||||
canUndo: false,
|
||||
canRedo: false,
|
||||
historyFeedback: null,
|
||||
|
||||
@@ -828,6 +828,13 @@ export function ImageCanvasEditorView({
|
||||
const timer = window.setTimeout(() => setHistoryFeedback(null), 3000);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [historyFeedback]);
|
||||
const showPendingResourceCopyWarning = useCallback(() => {
|
||||
setHistoryFeedback((currentFeedback) => ({
|
||||
id: (currentFeedback?.id ?? 0) + 1,
|
||||
tone: 'warning',
|
||||
text: '素材仍在保存,请稍后重试',
|
||||
}));
|
||||
}, []);
|
||||
const handleUndoCanvasChange = useCallback(() => {
|
||||
const result = undoCanvasChange();
|
||||
if (result.status === 'empty') {
|
||||
@@ -1769,6 +1776,8 @@ export function ImageCanvasEditorView({
|
||||
: null;
|
||||
const {
|
||||
canvasClipboard,
|
||||
canCopyContextLayers,
|
||||
isCanvasClipboardCopyBlocked,
|
||||
pasteCanvasClipboard,
|
||||
copyContextLayers,
|
||||
copySelectedLayers,
|
||||
@@ -1806,6 +1815,7 @@ export function ImageCanvasEditorView({
|
||||
onDeleteLayerSideEffects: clearDeletedLayerGenerationState,
|
||||
onDeleteGenerationDialogSideEffects: removeCanvasGenerationDialogById,
|
||||
exportLayerImage,
|
||||
onPendingResourceCopyBlocked: showPendingResourceCopyWarning,
|
||||
});
|
||||
const {
|
||||
canvasMarquee,
|
||||
@@ -2384,6 +2394,8 @@ export function ImageCanvasEditorView({
|
||||
imageContextMenuLayer,
|
||||
contextShouldShowLayer,
|
||||
contextShouldUnlockLayer,
|
||||
canCopyContextLayers,
|
||||
isCanvasClipboardCopyBlocked,
|
||||
canUndo,
|
||||
canRedo,
|
||||
historyFeedback,
|
||||
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
CanvasLayer,
|
||||
} from './ImageCanvasEditorTypes';
|
||||
import {
|
||||
canCopyCanvasLayers,
|
||||
createCanvasLayerClipboard,
|
||||
duplicateCanvasLayers,
|
||||
flipCanvasLayers,
|
||||
@@ -110,6 +111,27 @@ describe('ImageCanvasLayerCommandModel', () => {
|
||||
expect(cutPaste[0]?.resourceId).toBe(first.resourceId);
|
||||
});
|
||||
|
||||
it('only allows copy semantics after every resource is registered', () => {
|
||||
const readyLayer = createLayer({ resourceId: 'resource-ready' });
|
||||
const pendingLayer = createLayer({
|
||||
id: 'pending',
|
||||
resourceId: 'local-resource-pending',
|
||||
});
|
||||
|
||||
expect(canCopyCanvasLayers([readyLayer])).toBe(true);
|
||||
expect(canCopyCanvasLayers([readyLayer, pendingLayer])).toBe(false);
|
||||
expect(
|
||||
duplicateCanvasLayers({
|
||||
sourceLayers: [pendingLayer],
|
||||
allLayers: [pendingLayer],
|
||||
stamp: 'pending',
|
||||
}),
|
||||
).toEqual([]);
|
||||
expect(
|
||||
createCanvasLayerClipboard([pendingLayer], [pendingLayer.id], 'copy'),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('creates a cloned clipboard and removes target layers', () => {
|
||||
const layers = [
|
||||
createLayer({ id: 'first' }),
|
||||
|
||||
@@ -49,6 +49,13 @@ export function getCanvasLayersByIds(
|
||||
return layers.filter((layer) => targetIds.includes(layer.id));
|
||||
}
|
||||
|
||||
export function canCopyCanvasLayers(layers: CanvasLayer[]) {
|
||||
return (
|
||||
layers.length > 0 &&
|
||||
layers.every((layer) => !layer.resourceId.startsWith('local-'))
|
||||
);
|
||||
}
|
||||
|
||||
export function duplicateCanvasLayers({
|
||||
sourceLayers,
|
||||
allLayers,
|
||||
@@ -62,7 +69,7 @@ export function duplicateCanvasLayers({
|
||||
renameCopies?: boolean;
|
||||
stamp?: number | string;
|
||||
}) {
|
||||
if (!sourceLayers.length) {
|
||||
if (!canCopyCanvasLayers(sourceLayers)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -90,7 +97,7 @@ export function createCanvasLayerClipboard(
|
||||
mode: CanvasClipboard['mode'],
|
||||
): CanvasClipboard | null {
|
||||
const targetLayers = getCanvasLayersByIds(layers, targetIds);
|
||||
if (!targetLayers.length) {
|
||||
if (!canCopyCanvasLayers(targetLayers)) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
|
||||
@@ -87,6 +87,8 @@ export type ImageCanvasStageViewProps = {
|
||||
contextMenuLayer: CanvasLayer | null;
|
||||
contextShouldShowLayer: boolean;
|
||||
contextShouldUnlockLayer: boolean;
|
||||
canCopyContextLayers: boolean;
|
||||
isCanvasClipboardCopyBlocked: boolean;
|
||||
canUndo: boolean;
|
||||
canRedo: boolean;
|
||||
historyFeedback: CanvasHistoryFeedback | null;
|
||||
@@ -241,6 +243,8 @@ export function ImageCanvasStageView({
|
||||
contextMenuLayer,
|
||||
contextShouldShowLayer,
|
||||
contextShouldUnlockLayer,
|
||||
canCopyContextLayers,
|
||||
isCanvasClipboardCopyBlocked,
|
||||
canUndo,
|
||||
canRedo,
|
||||
historyFeedback,
|
||||
@@ -447,6 +451,8 @@ export function ImageCanvasStageView({
|
||||
contextMenuLayer={contextMenuLayer}
|
||||
contextShouldShowLayer={contextShouldShowLayer}
|
||||
contextShouldUnlockLayer={contextShouldUnlockLayer}
|
||||
canCopyContextLayers={canCopyContextLayers}
|
||||
isCanvasClipboardCopyBlocked={isCanvasClipboardCopyBlocked}
|
||||
onPasteCanvasClipboard={onPasteCanvasClipboard}
|
||||
onCopyContextLayers={onCopyContextLayers}
|
||||
onDuplicateContextLayers={onDuplicateContextLayers}
|
||||
|
||||
@@ -71,6 +71,7 @@ function LayerCommandsHarness({
|
||||
exportLayerImage = vi.fn(),
|
||||
onDeleteLayerSideEffects = vi.fn(),
|
||||
onDeleteGenerationDialogSideEffects = vi.fn(),
|
||||
onPendingResourceCopyBlocked = vi.fn(),
|
||||
initialLayers,
|
||||
}: {
|
||||
exportLayerImage?: (
|
||||
@@ -79,6 +80,7 @@ function LayerCommandsHarness({
|
||||
) => void;
|
||||
onDeleteLayerSideEffects?: (layerId: string) => void;
|
||||
onDeleteGenerationDialogSideEffects?: (dialogId: string) => void;
|
||||
onPendingResourceCopyBlocked?: () => void;
|
||||
initialLayers?: CanvasLayer[];
|
||||
}) {
|
||||
const [layers, setLayers] = useState<CanvasLayer[]>(
|
||||
@@ -156,6 +158,7 @@ function LayerCommandsHarness({
|
||||
onDeleteLayerSideEffects,
|
||||
onDeleteGenerationDialogSideEffects,
|
||||
exportLayerImage,
|
||||
onPendingResourceCopyBlocked,
|
||||
});
|
||||
|
||||
return (
|
||||
@@ -285,6 +288,20 @@ function LayerCommandsHarness({
|
||||
>
|
||||
混合选择
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setLayers((currentLayers) =>
|
||||
currentLayers.map((layer) =>
|
||||
layer.resourceId.startsWith('local-')
|
||||
? { ...layer, resourceId: `resource-saved-${layer.id}` }
|
||||
: layer,
|
||||
),
|
||||
)
|
||||
}
|
||||
>
|
||||
完成素材保存
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -511,6 +528,38 @@ describe('useImageCanvasLayerCommands', () => {
|
||||
expect(onDeleteLayerSideEffects).toHaveBeenCalledWith('third');
|
||||
});
|
||||
|
||||
it('blocks the whole copy operation until every selected resource is registered', () => {
|
||||
const onPendingResourceCopyBlocked = vi.fn();
|
||||
render(
|
||||
<LayerCommandsHarness
|
||||
initialLayers={[
|
||||
createLayer('first', 10, 1),
|
||||
{
|
||||
...createLayer('second', 160, 2),
|
||||
resourceId: 'local-resource-second',
|
||||
},
|
||||
createLayer('third', 310, 3),
|
||||
]}
|
||||
onPendingResourceCopyBlocked={onPendingResourceCopyBlocked}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '复制选中' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '剪切选中' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '创建选中副本' }));
|
||||
|
||||
expect(onPendingResourceCopyBlocked).toHaveBeenCalledTimes(3);
|
||||
expect(screen.getByTestId('clipboard').textContent).toBe('-');
|
||||
expect(screen.getByTestId('layers').textContent).not.toContain('副本');
|
||||
expect(screen.getByTestId('layers').textContent).toContain('second');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '完成素材保存' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '复制选中' }));
|
||||
|
||||
expect(screen.getByTestId('clipboard').textContent).toBe('copy:2');
|
||||
expect(onPendingResourceCopyBlocked).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('applies layer commands and clears menus without owning menu positioning', () => {
|
||||
const exportLayerImage = vi.fn();
|
||||
render(<LayerCommandsHarness exportLayerImage={exportLayerImage} />);
|
||||
|
||||
@@ -15,6 +15,7 @@ import type {
|
||||
} from './ImageCanvasEditorTypes';
|
||||
import { readLayerImageBlob } from './ImageCanvasExportModel';
|
||||
import {
|
||||
canCopyCanvasLayers,
|
||||
type CanvasLayerFlipAxis,
|
||||
type CanvasLayerMoveMode,
|
||||
createCanvasLayerClipboard,
|
||||
@@ -59,6 +60,7 @@ type LayerCommandsOptions = {
|
||||
layer: CanvasLayer | null,
|
||||
options?: { mode?: ImageSequenceExportMode },
|
||||
) => void;
|
||||
onPendingResourceCopyBlocked?: () => void;
|
||||
};
|
||||
|
||||
function createGroupId() {
|
||||
@@ -146,6 +148,7 @@ export function useImageCanvasLayerCommands({
|
||||
onDeleteLayerSideEffects,
|
||||
onDeleteGenerationDialogSideEffects,
|
||||
exportLayerImage,
|
||||
onPendingResourceCopyBlocked,
|
||||
}: LayerCommandsOptions) {
|
||||
const [canvasClipboard, setCanvasClipboard] =
|
||||
useState<CanvasClipboard | null>(null);
|
||||
@@ -188,12 +191,28 @@ export function useImageCanvasLayerCommands({
|
||||
[layers],
|
||||
);
|
||||
|
||||
const rejectPendingResourceCopy = useCallback(
|
||||
(targetLayers: CanvasLayer[]) => {
|
||||
if (canCopyCanvasLayers(targetLayers)) {
|
||||
return false;
|
||||
}
|
||||
if (targetLayers.length) {
|
||||
onPendingResourceCopyBlocked?.();
|
||||
}
|
||||
return true;
|
||||
},
|
||||
[onPendingResourceCopyBlocked],
|
||||
);
|
||||
|
||||
const pasteCanvasClipboard = useCallback(
|
||||
(canvasPoint?: { x: number; y: number }) => {
|
||||
const clipboard = canvasClipboardRef.current;
|
||||
if (!clipboard?.layers.length) {
|
||||
return false;
|
||||
}
|
||||
if (rejectPendingResourceCopy(clipboard.layers)) {
|
||||
return true;
|
||||
}
|
||||
const nextLayers = duplicateLayersToPoint(clipboard.layers, canvasPoint, {
|
||||
renameCopies: clipboard.mode !== 'cut',
|
||||
});
|
||||
@@ -213,6 +232,7 @@ export function useImageCanvasLayerCommands({
|
||||
closeContextMenus,
|
||||
duplicateLayersToPoint,
|
||||
appendCanvasLayersWithResources,
|
||||
rejectPendingResourceCopy,
|
||||
setActiveTool,
|
||||
setSelectedLayerId,
|
||||
setSelectedLayerIds,
|
||||
@@ -222,6 +242,10 @@ export function useImageCanvasLayerCommands({
|
||||
const copyContextLayers = useCallback(
|
||||
(options: { cut?: boolean } = {}) => {
|
||||
const targetIds = getContextTargetLayerIds();
|
||||
const targetLayers = getCanvasLayersByIds(layers, targetIds);
|
||||
if (rejectPendingResourceCopy(targetLayers)) {
|
||||
return;
|
||||
}
|
||||
const clipboard = createCanvasLayerClipboard(
|
||||
layers,
|
||||
targetIds,
|
||||
@@ -256,6 +280,7 @@ export function useImageCanvasLayerCommands({
|
||||
getContextTargetLayerIds,
|
||||
layers,
|
||||
onDeleteLayerSideEffects,
|
||||
rejectPendingResourceCopy,
|
||||
selectSingleLayer,
|
||||
setLayers,
|
||||
setMetadataLayer,
|
||||
@@ -265,6 +290,10 @@ export function useImageCanvasLayerCommands({
|
||||
const copySelectedLayers = useCallback(
|
||||
(options: { cut?: boolean } = {}) => {
|
||||
const targetIds = getSelectedTargetLayerIds();
|
||||
const targetLayers = getCanvasLayersByIds(layers, targetIds);
|
||||
if (rejectPendingResourceCopy(targetLayers)) {
|
||||
return;
|
||||
}
|
||||
const clipboard = createCanvasLayerClipboard(
|
||||
layers,
|
||||
targetIds,
|
||||
@@ -299,6 +328,7 @@ export function useImageCanvasLayerCommands({
|
||||
getSelectedTargetLayerIds,
|
||||
layers,
|
||||
onDeleteLayerSideEffects,
|
||||
rejectPendingResourceCopy,
|
||||
selectSingleLayer,
|
||||
setContextMenu,
|
||||
setImageContextMenu,
|
||||
@@ -310,6 +340,9 @@ export function useImageCanvasLayerCommands({
|
||||
const duplicateContextLayers = useCallback(() => {
|
||||
const targetIds = getContextTargetLayerIds();
|
||||
const targetLayers = getCanvasLayersByIds(layers, targetIds);
|
||||
if (rejectPendingResourceCopy(targetLayers)) {
|
||||
return;
|
||||
}
|
||||
const nextLayers = duplicateLayersToPoint(targetLayers);
|
||||
if (!nextLayers.length) {
|
||||
return;
|
||||
@@ -326,6 +359,7 @@ export function useImageCanvasLayerCommands({
|
||||
getContextTargetLayerIds,
|
||||
layers,
|
||||
appendCanvasLayersWithResources,
|
||||
rejectPendingResourceCopy,
|
||||
setSelectedLayerId,
|
||||
setSelectedLayerIds,
|
||||
]);
|
||||
@@ -333,6 +367,9 @@ export function useImageCanvasLayerCommands({
|
||||
const duplicateSelectedLayers = useCallback(() => {
|
||||
const targetIds = getSelectedTargetLayerIds();
|
||||
const targetLayers = getCanvasLayersByIds(layers, targetIds);
|
||||
if (rejectPendingResourceCopy(targetLayers)) {
|
||||
return;
|
||||
}
|
||||
const nextLayers = duplicateLayersToPoint(targetLayers);
|
||||
if (!nextLayers.length) {
|
||||
return;
|
||||
@@ -349,6 +386,7 @@ export function useImageCanvasLayerCommands({
|
||||
getSelectedTargetLayerIds,
|
||||
layers,
|
||||
appendCanvasLayersWithResources,
|
||||
rejectPendingResourceCopy,
|
||||
setContextMenu,
|
||||
setImageContextMenu,
|
||||
setSelectedLayerId,
|
||||
@@ -718,8 +756,17 @@ export function useImageCanvasLayerCommands({
|
||||
);
|
||||
}, [captureCanvasHistory, selectedLayerId, selectedLayerIds, setLayers]);
|
||||
|
||||
const contextTargetLayers = getCanvasLayersByIds(
|
||||
layers,
|
||||
getContextTargetLayerIds(),
|
||||
);
|
||||
|
||||
return {
|
||||
canvasClipboard,
|
||||
canCopyContextLayers: canCopyCanvasLayers(contextTargetLayers),
|
||||
isCanvasClipboardCopyBlocked: Boolean(
|
||||
canvasClipboard && !canCopyCanvasLayers(canvasClipboard.layers),
|
||||
),
|
||||
getContextTargetLayerIds,
|
||||
pasteCanvasClipboard,
|
||||
copyContextLayers,
|
||||
|
||||
@@ -264,30 +264,6 @@ function ProjectPersistenceHarness({
|
||||
>
|
||||
append
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const sourceLayer = createLayer('layer-shared-source', {
|
||||
resourceId: 'local-shared-resource',
|
||||
src: '/generated/shared-resource.png',
|
||||
objectKey: 'generated/shared-resource.png',
|
||||
sourceAssetId: undefined,
|
||||
});
|
||||
persistence.appendCanvasLayersWithResources([
|
||||
sourceLayer,
|
||||
{
|
||||
...sourceLayer,
|
||||
id: 'layer-shared-copy',
|
||||
title: '账号素材A 副本',
|
||||
x: sourceLayer.x + 32,
|
||||
y: sourceLayer.y + 32,
|
||||
zIndex: sourceLayer.zIndex + 1,
|
||||
},
|
||||
]);
|
||||
}}
|
||||
>
|
||||
append shared layouts
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
@@ -653,76 +629,6 @@ describe('useImageCanvasProjectPersistence', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('coalesces shared local resource creation and saves every layout with one server resource id', async () => {
|
||||
const resourceCreate =
|
||||
createDeferred<EditorProjectSnapshot['resources'][number]>();
|
||||
createEditorProjectResourceMock.mockReturnValueOnce(resourceCreate.promise);
|
||||
render(<ProjectPersistenceHarness />);
|
||||
expect(await screen.findByText('editor-project-default')).toBeTruthy();
|
||||
|
||||
act(() => {
|
||||
screen.getByRole('button', { name: 'append shared layouts' }).click();
|
||||
});
|
||||
|
||||
expect(createEditorProjectResourceMock).toHaveBeenCalledTimes(1);
|
||||
expect(screen.getByTestId('layers').textContent).toContain(
|
||||
'layer-shared-source:local-shared-resource',
|
||||
);
|
||||
expect(screen.getByTestId('layers').textContent).toContain(
|
||||
'layer-shared-copy:local-shared-resource',
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
resourceCreate.resolve({
|
||||
resourceId: 'resource-shared',
|
||||
projectId: 'editor-project-default',
|
||||
imageSrc: '/generated/shared-resource.png',
|
||||
objectKey: 'generated/shared-resource.png',
|
||||
width: 320,
|
||||
height: 240,
|
||||
sourceType: 'uploaded',
|
||||
});
|
||||
await resourceCreate.promise;
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('layers').textContent).toContain(
|
||||
'layer-shared-source:resource-shared',
|
||||
);
|
||||
expect(screen.getByTestId('layers').textContent).toContain(
|
||||
'layer-shared-copy:resource-shared',
|
||||
);
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(saveEditorProjectLayoutMock).toHaveBeenCalledWith(
|
||||
'editor-project-default',
|
||||
expect.objectContaining({
|
||||
layers: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
layerId: 'layer-shared-source',
|
||||
resourceId: 'resource-shared',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
layerId: 'layer-shared-copy',
|
||||
resourceId: 'resource-shared',
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
const cached = JSON.parse(
|
||||
window.sessionStorage.getItem(
|
||||
'genarrative.imageCanvas.projectSnapshot.v2:user-test:editor-project-default',
|
||||
) ?? '{}',
|
||||
) as { project?: EditorProjectSnapshot };
|
||||
expect(
|
||||
cached.project?.resources.filter(
|
||||
(resource) => resource.resourceId === 'resource-shared',
|
||||
),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('merges a late resource response into a reloaded authoritative layout', async () => {
|
||||
const resourceCreate =
|
||||
createDeferred<EditorProjectSnapshot['resources'][number]>();
|
||||
|
||||
@@ -152,20 +152,6 @@ function isLocalProjectResourceId(resourceId: string) {
|
||||
return resourceId.startsWith('local-');
|
||||
}
|
||||
|
||||
function projectResourceCreateKey({
|
||||
authorityEpoch,
|
||||
projectId,
|
||||
ownerUserId,
|
||||
resourceId,
|
||||
}: {
|
||||
authorityEpoch: number;
|
||||
projectId: string;
|
||||
ownerUserId: string | null | undefined;
|
||||
resourceId: string;
|
||||
}) {
|
||||
return `${authorityEpoch}\0${projectId}\0${ownerUserId ?? ''}\0${resourceId}`;
|
||||
}
|
||||
|
||||
function resolveProjectResourceCreateImageSrc(layer: CanvasLayer) {
|
||||
const objectKey = layer.objectKey?.trim();
|
||||
if (objectKey) {
|
||||
@@ -431,7 +417,6 @@ export function useImageCanvasProjectPersistence({
|
||||
const pendingCreatedProjectResourceLayersRef = useRef<
|
||||
PendingCreatedProjectResourceLayer[]
|
||||
>([]);
|
||||
const activeProjectResourceCreateKeysRef = useRef(new Set<string>());
|
||||
const pendingProjectLayoutSaveRef = useRef<PendingProjectLayoutSave | null>(
|
||||
null,
|
||||
);
|
||||
@@ -893,33 +878,6 @@ export function useImageCanvasProjectPersistence({
|
||||
const { layer, options } = pendingLayer;
|
||||
options.onCreated?.(pendingLayer.resourceId);
|
||||
const currentLayers = refs.layersRef.current;
|
||||
const shouldRemapSharedLocalResource = isLocalProjectResourceId(
|
||||
layer.resourceId,
|
||||
);
|
||||
if (
|
||||
shouldRemapSharedLocalResource &&
|
||||
currentLayers.some(
|
||||
(candidate) => candidate.resourceId === layer.resourceId,
|
||||
)
|
||||
) {
|
||||
const nextLayers = currentLayers.map((candidate) =>
|
||||
candidate.resourceId === layer.resourceId
|
||||
? { ...candidate, resourceId: pendingLayer.resourceId }
|
||||
: candidate,
|
||||
);
|
||||
refs.layersRef.current = nextLayers;
|
||||
setLayers(nextLayers);
|
||||
queueProjectLayoutSave(pendingLayer.projectId, {
|
||||
viewport: viewportToCanvasDisplayViewport(refs.viewportRef.current),
|
||||
layers: serializeCanvasLayout({
|
||||
layers: nextLayers,
|
||||
canvasGenerationDialogs:
|
||||
refs.canvasGenerationDialogsRef.current,
|
||||
canvasBackgroundColor: refs.canvasBackgroundColorRef.current,
|
||||
}),
|
||||
});
|
||||
return true;
|
||||
}
|
||||
const currentLayer = currentLayers.find(
|
||||
(candidate) => candidate.id === layer.id,
|
||||
);
|
||||
@@ -974,30 +932,12 @@ export function useImageCanvasProjectPersistence({
|
||||
return;
|
||||
}
|
||||
const requestOwnerUserId = currentUserIdRef.current;
|
||||
const requestAuthorityEpoch = projectAuthorityEpochRef.current;
|
||||
const requestAuthoritativeSnapshotSequence =
|
||||
acceptedAuthoritativeSnapshotSequenceRef.current;
|
||||
const imageSrc = resolveProjectResourceCreateImageSrc(layer);
|
||||
if (!imageSrc) {
|
||||
return;
|
||||
}
|
||||
const requestKey = isLocalProjectResourceId(layer.resourceId)
|
||||
? projectResourceCreateKey({
|
||||
authorityEpoch: requestAuthorityEpoch,
|
||||
projectId: readyProjectId,
|
||||
ownerUserId: requestOwnerUserId,
|
||||
resourceId: layer.resourceId,
|
||||
})
|
||||
: null;
|
||||
if (
|
||||
requestKey &&
|
||||
activeProjectResourceCreateKeysRef.current.has(requestKey)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (requestKey) {
|
||||
activeProjectResourceCreateKeysRef.current.add(requestKey);
|
||||
}
|
||||
createEditorProjectResource(readyProjectId, {
|
||||
imageSrc,
|
||||
objectKey: layer.objectKey,
|
||||
@@ -1038,11 +978,6 @@ export function useImageCanvasProjectPersistence({
|
||||
if (isEditorAuthError(error)) {
|
||||
openEditorLoginModal();
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (requestKey) {
|
||||
activeProjectResourceCreateKeysRef.current.delete(requestKey);
|
||||
}
|
||||
});
|
||||
},
|
||||
[applyCreatedProjectResourceLayer, openEditorLoginModal],
|
||||
|
||||
Reference in New Issue
Block a user