修复未登记画布素材复制竞态

禁止复制、剪切或创建仍使用临时资源 ID 的图层

移除临时资源创建请求的 single-flight 合并与共享回填逻辑

补充菜单禁用、快捷键反馈、资源回填恢复和持久化回归测试

同步图片画布技术方案、决策记录与排障经验
This commit is contained in:
2026-08-03 14:47:04 +08:00
parent b27c0e8443
commit 29ea594fec
15 changed files with 200 additions and 164 deletions
@@ -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],