修复画布图片副本刷新后消失

复制粘贴和创建副本改为复用原项目资源
补齐临时资源共享回填与缓存去重
新增副本保存重进和结构化布局回归测试
同步画布持久化文档与项目决策记录
This commit is contained in:
2026-08-03 14:04:53 +08:00
parent 38c43b708a
commit b27c0e8443
12 changed files with 296 additions and 21 deletions
@@ -5908,3 +5908,9 @@
- 显式协作合同:autonomous 的旧 `code-prototype + quality-review` 首批合同退出。显式 project collaboration policy 或持久 batch 恢复若进入首批 `agent.delegate` 路径,只允许且要求三个 Director 各一次;策划与程序 Director 是只读规划且 `expectedArtifacts=[]`,美术 Director 是非只读规范图任务且必须交付 `assets/art-spec.png`。任何非 repair 底层委派与 isolated child 都在首批失败关闭;默认 manifest DAG 仍是唯一自动首轮执行链,不额外复制三个 Director 委派。
- 输出决策:保留未提交 `streaming / ready` 的当前 revision 门;已提交的专业 Agent final reply 继续使用既有 durable response-stream 身份,后续项目 revision 变化不再隐藏早期阶段回复。
- 关联:`apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs``start-dev-stack.mjs``src-tauri/src/agent/runtime_protocol/autonomous_completion.rs``response_stream.rs``docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`
## 2026-08-03 图片画布复制固定为布局实例复用项目资源
- 背景:画布复制逻辑曾为副本生成 `local-resource-copy-*`,但没有创建对应项目资源;自动保存虽然触发,结构化画布会拒绝缺资源引用,legacy 快照刷新时也无法 hydrate 副本。
- 决策:`layerId` 是同一 canvas 内唯一的布局实例身份,`resourceId` 是允许多个布局实例共享的项目媒体身份。复制、粘贴、创建副本和剪切后粘贴只生成新 `layerId`,复用来源 `resourceId`;已登记资源不得再次上传或创建。临时 `local-*` 资源按用户、项目和临时 ID 合并创建,正式 ID 返回后更新全部共享该临时 ID 的当前图层;session 项目缓存也按 `resourceId` 只保留一份资源快照。
- 后端边界:现有 `editor_canvas_layer` 已以 `layer_id` 建立行身份并单独保存 `resource_id`,天然支持多 layer 引用同一 resource;本次不修改 HTTP DTO、SpacetimeDB schema、迁移或 bindings,只增加结构化 round-trip 契约测试。
@@ -57,6 +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`;副本可独立移动、排序、分组、显隐和删除,但不得重复上传媒体或为同一图片创建虚假项目资源。没有内部画布剪贴板时,系统剪贴板图片仍按新素材上传。
- 背景色控件只修改编辑器工作区底色,不恢复网格线或棋盘格底纹,也不影响图片本体。
- 吸附阈值以屏幕像素为准,换算到世界坐标后参与拖拽计算;边缘 / 中心线和等距吸附共用同一阈值。拖拽结束后只保存最终图层或生成占位布局,不保存临时参考线。
- 项目页封面和画布图片图层必须先渲染项目卡、图层外框、标题、尺寸和操作 chrome;图片换签或解码未完成时,只在图片区域显示轻量加载态,不阻塞外框和文字等低成本信息先出现。
@@ -31,6 +31,8 @@
保存 `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 的布局实例。
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,兼容现有画布快照语义。
### 3.2 `editor_canvas_generation_dialog`
@@ -9691,6 +9691,36 @@ mod tests {
);
}
#[test]
fn structured_canvas_allows_distinct_layers_to_share_one_resource() {
let shared = r#"[
{"layerId":"layer-1","resourceId":"resource-shared","x":10},
{"layerId":"layer-2","resourceId":"resource-shared","x":42}
]"#;
let layout = parse_structured_canvas_layout(shared)
.expect("distinct layout instances may share a resource");
assert_eq!(layout.layers.len(), 2);
assert_eq!(layout.layers[0].resource_id, "resource-shared");
assert_eq!(layout.layers[1].resource_id, "resource-shared");
let rebuilt = serialize_structured_canvas_layout(&layout)
.expect("shared-resource layout should serialize");
let rebuilt_value: JsonValue =
serde_json::from_str(&rebuilt).expect("rebuilt layout should be valid JSON");
assert_eq!(rebuilt_value.as_array().map(Vec::len), Some(2));
let shared_integrity = canvas_layout_integrity(shared).expect("shared integrity");
let single_integrity =
canvas_layout_integrity(r#"[{"layerId":"layer-1","resourceId":"resource-shared"}]"#)
.expect("single integrity");
assert_eq!(shared_integrity.layer_count, 2);
assert_eq!(
shared_integrity.resource_refs_sha256,
single_integrity.resource_refs_sha256
);
}
#[test]
fn structured_canvas_rollback_rejects_layout_over_two_mib() {
let oversized = "x".repeat(EDITOR_PROJECT_MAX_LAYOUT_JSON_BYTES + 1);
@@ -17,6 +17,7 @@ import {
createAuthValue,
createDataTransferStub,
createDeferred,
defaultEditorProjectResources,
dispatchPointerEvent,
ImageCanvasEditorView,
setupImageCanvasEditorViewTestLifecycle,
@@ -1287,6 +1288,64 @@ describe('ImageCanvasEditorView asset library integration', () => {
);
});
it('restores a copied layout that references the same project resource after re-entering', async () => {
const firstRender = render(<ImageCanvasEditorView />);
await waitFor(() => {
expect(loadOrCreateRecentEditorProjectMock).toHaveBeenCalled();
});
saveEditorProjectLayoutMock.mockClear();
createEditorProjectResourceMock.mockClear();
fireEvent.contextMenu(
screen.getByAltText('画布图片:拼图素材').closest('button')!,
{
clientX: 510,
clientY: 330,
},
);
fireEvent.click(screen.getByRole('menuitem', { name: '复制' }));
fireEvent.contextMenu(screen.getByLabelText('画布工作区'), {
clientX: 360,
clientY: 240,
});
fireEvent.click(screen.getByRole('menuitem', { name: '粘贴' }));
await waitFor(() => {
expect(saveEditorProjectLayoutMock).toHaveBeenCalled();
});
const savedLayers = saveEditorProjectLayoutMock.mock.calls.at(-1)?.[1]
?.layers as Array<{ layerId: string; resourceId: string }>;
const copiedPuzzleLayouts = savedLayers.filter(
(layer) => layer.resourceId === 'resource-puzzle',
);
expect(copiedPuzzleLayouts).toHaveLength(2);
expect(
new Set(copiedPuzzleLayouts.map((layer) => layer.layerId)).size,
).toBe(2);
expect(createEditorProjectResourceMock).not.toHaveBeenCalled();
firstRender.unmount();
window.sessionStorage.clear();
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce(
withEditorProjectCanvasRevision(
{
projectId: 'editor-project-default',
title: '默认项目',
viewport: { x: 0, y: 0, scale: 1 },
layers: savedLayers,
resources: defaultEditorProjectResources,
updatedAt: '2026-08-03T00:00:00.000Z',
},
1,
),
);
render(<ImageCanvasEditorView />);
await waitFor(() => {
expect(screen.getAllByAltText(//u)).toHaveLength(2);
});
});
it('drops files into the asset panel only once without creating canvas layers', async () => {
render(<ImageCanvasEditorView />);
@@ -1793,6 +1793,7 @@ export function ImageCanvasEditorView({
selectedLayerIds,
canvasGenerationDialogs,
setLayers,
appendCanvasLayersWithResources,
setSelectedLayerId,
setSelectedLayerIds,
setHoveredLayerId,
@@ -81,7 +81,7 @@ describe('ImageCanvasLayerCommandModel', () => {
expect(duplicated).toMatchObject([
{
id: 'layer-copy-test-0',
resourceId: 'local-resource-copy-test-0',
resourceId: 'resource-first',
title: '第一张 副本',
x: 300,
y: 200,
@@ -90,7 +90,7 @@ describe('ImageCanvasLayerCommandModel', () => {
},
{
id: 'layer-copy-test-1',
resourceId: 'local-resource-copy-test-1',
resourceId: 'resource-second',
title: '第二张 副本',
x: 350,
y: 260,
@@ -107,6 +107,7 @@ describe('ImageCanvasLayerCommandModel', () => {
});
expect(cutPaste[0]?.title).toBe('第一张');
expect(cutPaste[0]?.x).toBe(first.x + 32);
expect(cutPaste[0]?.resourceId).toBe(first.resourceId);
});
it('creates a cloned clipboard and removes target layers', () => {
@@ -76,7 +76,6 @@ export function duplicateCanvasLayers({
return sourceLayers.map((layer, index) => ({
...cloneLayer(layer),
id: `layer-copy-${stamp}-${index}`,
resourceId: `local-resource-copy-${stamp}-${index}`,
title: renameCopies ? `${layer.title} 副本` : layer.title,
x: canvasPoint ? canvasPoint.x + (layer.x - minX) : layer.x + 32,
y: canvasPoint ? canvasPoint.y + (layer.y - minY) : layer.y + 32,
@@ -140,6 +140,8 @@ function LayerCommandsHarness({
selectedLayerIds,
canvasGenerationDialogs,
setLayers,
appendCanvasLayersWithResources: (nextLayers) =>
setLayers((currentLayers) => [...currentLayers, ...nextLayers]),
setSelectedLayerId,
setSelectedLayerIds,
setHoveredLayerId,
@@ -492,6 +494,11 @@ describe('useImageCanvasLayerCommands', () => {
expect(screen.getByTestId('selection').textContent).toContain(
'layer-copy-',
);
fireEvent.click(screen.getByRole('button', { name: '创建选中副本' }));
const layerIds = (screen.getByTestId('layers').textContent ?? '')
.split('|')
.map((entry) => entry.split(':')[0]);
expect(new Set(layerIds).size).toBe(layerIds.length);
fireEvent.click(screen.getByRole('button', { name: '只选第三层' }));
fireEvent.click(screen.getByRole('button', { name: '选中置顶' }));
@@ -43,6 +43,7 @@ type LayerCommandsOptions = {
selectedLayerIds: string[];
canvasGenerationDialogs?: CanvasGenerationDialogState[];
setLayers: Dispatch<SetStateAction<CanvasLayer[]>>;
appendCanvasLayersWithResources: (nextLayers: CanvasLayer[]) => void;
setSelectedLayerId: Dispatch<SetStateAction<string | null>>;
setSelectedLayerIds: Dispatch<SetStateAction<string[]>>;
setHoveredLayerId: Dispatch<SetStateAction<string | null>>;
@@ -132,6 +133,7 @@ export function useImageCanvasLayerCommands({
selectedLayerIds,
canvasGenerationDialogs = [],
setLayers,
appendCanvasLayersWithResources,
setSelectedLayerId,
setSelectedLayerIds,
setHoveredLayerId,
@@ -148,6 +150,7 @@ export function useImageCanvasLayerCommands({
const [canvasClipboard, setCanvasClipboard] =
useState<CanvasClipboard | null>(null);
const canvasClipboardRef = useRef<CanvasClipboard | null>(null);
const duplicateLayerSequenceRef = useRef(0);
const closeContextMenus = useCallback(() => {
setContextMenu(null);
setImageContextMenu(null);
@@ -172,13 +175,16 @@ export function useImageCanvasLayerCommands({
sourceLayers: CanvasLayer[],
canvasPoint?: { x: number; y: number },
options: { renameCopies?: boolean } = {},
) =>
duplicateCanvasLayers({
) => {
duplicateLayerSequenceRef.current += 1;
return duplicateCanvasLayers({
sourceLayers,
allLayers: layers,
canvasPoint,
renameCopies: options.renameCopies !== false,
}),
stamp: `${Date.now()}-${duplicateLayerSequenceRef.current}`,
});
},
[layers],
);
@@ -195,7 +201,7 @@ export function useImageCanvasLayerCommands({
return false;
}
captureCanvasHistory({ type: 'paste-image', count: nextLayers.length });
setLayers((currentLayers) => [...currentLayers, ...nextLayers]);
appendCanvasLayersWithResources(nextLayers);
setSelectedLayerIds(nextLayers.map((layer) => layer.id));
setSelectedLayerId(nextLayers[0]?.id ?? null);
setActiveTool('select');
@@ -206,8 +212,8 @@ export function useImageCanvasLayerCommands({
captureCanvasHistory,
closeContextMenus,
duplicateLayersToPoint,
appendCanvasLayersWithResources,
setActiveTool,
setLayers,
setSelectedLayerId,
setSelectedLayerIds,
],
@@ -309,7 +315,7 @@ export function useImageCanvasLayerCommands({
return;
}
captureCanvasHistory({ type: 'duplicate-image', count: nextLayers.length });
setLayers((currentLayers) => [...currentLayers, ...nextLayers]);
appendCanvasLayersWithResources(nextLayers);
setSelectedLayerIds(nextLayers.map((layer) => layer.id));
setSelectedLayerId(nextLayers[0]?.id ?? null);
closeContextMenus();
@@ -319,7 +325,7 @@ export function useImageCanvasLayerCommands({
duplicateLayersToPoint,
getContextTargetLayerIds,
layers,
setLayers,
appendCanvasLayersWithResources,
setSelectedLayerId,
setSelectedLayerIds,
]);
@@ -332,7 +338,7 @@ export function useImageCanvasLayerCommands({
return;
}
captureCanvasHistory({ type: 'duplicate-image', count: nextLayers.length });
setLayers((currentLayers) => [...currentLayers, ...nextLayers]);
appendCanvasLayersWithResources(nextLayers);
setSelectedLayerIds(nextLayers.map((layer) => layer.id));
setSelectedLayerId(nextLayers[0]?.id ?? null);
setContextMenu(null);
@@ -342,9 +348,9 @@ export function useImageCanvasLayerCommands({
duplicateLayersToPoint,
getSelectedTargetLayerIds,
layers,
appendCanvasLayersWithResources,
setContextMenu,
setImageContextMenu,
setLayers,
setSelectedLayerId,
setSelectedLayerIds,
]);
@@ -264,6 +264,30 @@ 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={() => {
@@ -629,6 +653,76 @@ 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,6 +152,20 @@ 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) {
@@ -352,21 +366,25 @@ function createEditorProjectSessionCacheSnapshot({
layoutItems: EditorProjectSnapshot['layers'];
layers: CanvasLayer[];
}): EditorProjectSnapshot | null {
const resources = layers.map((layer) =>
createEditorProjectSessionCacheResource({ projectId, layer }),
);
if (resources.some((resource) => !resource)) {
return null;
const resourcesById = new Map<string, EditorProjectResourceSnapshot>();
for (const layer of layers) {
const resource = createEditorProjectSessionCacheResource({
projectId,
layer,
});
if (!resource) {
return null;
}
if (!resourcesById.has(resource.resourceId)) {
resourcesById.set(resource.resourceId, resource);
}
}
return {
projectId,
title: title.trim() || '未命名画布',
viewport,
layers: layoutItems,
resources: resources.filter(
(resource): resource is EditorProjectResourceSnapshot =>
Boolean(resource),
),
resources: Array.from(resourcesById.values()),
updatedAt: new Date().toISOString(),
};
}
@@ -413,6 +431,7 @@ export function useImageCanvasProjectPersistence({
const pendingCreatedProjectResourceLayersRef = useRef<
PendingCreatedProjectResourceLayer[]
>([]);
const activeProjectResourceCreateKeysRef = useRef(new Set<string>());
const pendingProjectLayoutSaveRef = useRef<PendingProjectLayoutSave | null>(
null,
);
@@ -874,6 +893,33 @@ 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,
);
@@ -928,12 +974,30 @@ 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,
@@ -974,6 +1038,11 @@ export function useImageCanvasProjectPersistence({
if (isEditorAuthError(error)) {
openEditorLoginModal();
}
})
.finally(() => {
if (requestKey) {
activeProjectResourceCreateKeysRef.current.delete(requestKey);
}
});
},
[applyCreatedProjectResourceLayer, openEditorLoginModal],