修复画布图片副本刷新后消失
复制粘贴和创建副本改为复用原项目资源 补齐临时资源共享回填与缓存去重 新增副本保存重进和结构化布局回归测试 同步画布持久化文档与项目决策记录
This commit is contained in:
@@ -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],
|
||||
|
||||
Reference in New Issue
Block a user