修复图集素材标签持久化与拆分校验
项目资源复用增加素材类型匹配,避免同源旧类型资源回流 标签保存期间禁用拆分并在服务端确认后回写资源引用 补齐乱序请求、快照刷新、失败回滚和撤销历史保护 新增前后端回归测试并同步图集入口设计文档
This commit is contained in:
@@ -14,6 +14,7 @@
|
||||
- 图标素材面板锚定在占位图下方,和现有生成输入框同一层级展示。
|
||||
- 透明背景处理正常成功后删除占位态:透明 spritesheet 作为主图(`assetKind: "icon-spritesheet"`,`generatedLayerId` 锚点)放入画布,provider 带背景原图作为第二个同类型图层放在透明主图右侧,按 alpha 连通域成功拆出的 `assetKind: "icon"` 素材从原图右侧继续铺放;透明背景处理最终失败时,后端完成快照只用 provider 原图替换占位态。
|
||||
- 选中 `assetKind: "icon-spritesheet"` 图层时,图片浮动工具栏显示 `拆分图集`;手动拆分只追加独立素材,不复制原图集。
|
||||
- 用户把现有图层手动标记为“图集”时,必须先持久化一条 `assetKind: "icon-spritesheet"` 的项目资源并把返回的 `resourceId` 写回图层;项目资源只能在媒体来源和 `assetKind` 都相同时复用,不得因同源图片而返回旧类型资源。持久化完成前必须禁用“拆分图集”,持久化失败时回滚到上一个已确认的素材标签和资源引用,并失效该轮未确认的标签撤销记录。
|
||||
- 图标规范图写入 `assetKind: "icon-spec"`,用于刷新后保留标签和限制点选来源。
|
||||
|
||||
## 面板结构
|
||||
@@ -81,4 +82,5 @@
|
||||
- 图标素材生成可以上传普通参考图;提交时图标规范图仍走 `referenceImageSrc`,普通参考图走 `referenceImageSrcs`,二者都必须是稳定引用(`objectKey` / 项目资源 ID / 素材 ID),禁止 Data URL / Blob URL,并写入 `generationInputs.references`。
|
||||
- 透明背景处理和自动拆分都成功后,画布同时出现透明 spritesheet 主图、其右侧的 provider 原图,以及从原图右侧铺开的按描述命名的独立图标图层;透明图集成功但拆分失败时仍出现透明主图与右侧原图,透明背景处理最终失败时只出现 provider 原图。
|
||||
- 选中透明图集图层时显示 `拆分图集`;点击后源图集显示扫描蒙层与 `拆图中` 状态,工具栏按钮同步切换为旋转图标和 `拆图中` 并禁用重复提交。完成后恢复工具栏,不新增第二张图集,只在 provider 原图右侧追加自动识别的独立素材,并同步写入素材库。
|
||||
- 把同源派生图层从其它标签改为“图集”时,在项目资源返回新 `resourceId` 前“拆分图集”保持禁用;持久化成功后拆分请求必须指向 `assetKind: "icon-spritesheet"` 的新资源,失败时标签回滚且不发起拆分请求。
|
||||
- 生成图标素材提交体包含按模型和尺寸计算的 `priceMudPoints`;`nanobanana2 1K` 应为 `12`,`gpt-image-2 1K` 应为 `3`,`gpt-image-2 2K` 应为 `5`。若前端传入与后端计费配置不一致的值,后端返回 `priceMudPoints` 校验错误,不继续调用上游生成。
|
||||
|
||||
@@ -2087,6 +2087,7 @@ fn create_editor_project_resource(
|
||||
project_id.as_str(),
|
||||
owner_user_id.as_str(),
|
||||
source_resource_id.as_ref(),
|
||||
asset_kind.as_deref(),
|
||||
asset_object_id.as_ref(),
|
||||
object_key.as_ref(),
|
||||
image_src.as_str(),
|
||||
@@ -4948,6 +4949,7 @@ fn find_reusable_project_resource_for_input(
|
||||
project_id: &str,
|
||||
owner_user_id: &str,
|
||||
source_resource_id: Option<&String>,
|
||||
asset_kind: Option<&str>,
|
||||
asset_object_id: Option<&String>,
|
||||
object_key: Option<&String>,
|
||||
image_src: &str,
|
||||
@@ -4963,14 +4965,15 @@ fn find_reusable_project_resource_for_input(
|
||||
.by_editor_project_resource_project_id()
|
||||
.filter(&project_key)
|
||||
.filter(|resource| {
|
||||
resource.owner_user_id == owner_user_id
|
||||
&& (resource.resource_id == source_resource_id
|
||||
|| resource
|
||||
.source_resource_id
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.is_some_and(|value| value == source_resource_id))
|
||||
&& project_resource_matches_media(resource, asset_object_id, object_key, image_src)
|
||||
project_resource_matches_reuse_input(
|
||||
resource,
|
||||
owner_user_id,
|
||||
source_resource_id,
|
||||
asset_kind,
|
||||
asset_object_id,
|
||||
object_key,
|
||||
image_src,
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
matches.sort_by(|left, right| {
|
||||
@@ -4988,6 +4991,26 @@ fn find_reusable_project_resource_for_input(
|
||||
matches.into_iter().next()
|
||||
}
|
||||
|
||||
fn project_resource_matches_reuse_input(
|
||||
resource: &EditorProjectResource,
|
||||
owner_user_id: &str,
|
||||
source_resource_id: &str,
|
||||
asset_kind: Option<&str>,
|
||||
asset_object_id: Option<&String>,
|
||||
object_key: Option<&String>,
|
||||
image_src: &str,
|
||||
) -> bool {
|
||||
resource.owner_user_id == owner_user_id
|
||||
&& (resource.resource_id == source_resource_id
|
||||
|| resource
|
||||
.source_resource_id
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.is_some_and(|value| value == source_resource_id))
|
||||
&& resource.asset_kind.as_deref() == asset_kind
|
||||
&& project_resource_matches_media(resource, asset_object_id, object_key, image_src)
|
||||
}
|
||||
|
||||
fn matching_optional_text_ref(left: Option<&String>, right: Option<&String>) -> bool {
|
||||
match (normalize_optional_ref(left), normalize_optional_ref(right)) {
|
||||
(Some(left_value), Some(right_value)) => left_value == right_value,
|
||||
@@ -9283,6 +9306,44 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_project_resource_reuse_rejects_different_asset_kind() {
|
||||
let resource = generated_editor_project_resource(
|
||||
"resource-derived",
|
||||
"task-1",
|
||||
Some("resource-source"),
|
||||
);
|
||||
|
||||
assert!(!project_resource_matches_reuse_input(
|
||||
&resource,
|
||||
"user-1",
|
||||
"resource-source",
|
||||
Some("icon-spritesheet"),
|
||||
resource.asset_object_id.as_ref(),
|
||||
resource.object_key.as_ref(),
|
||||
resource.image_src.as_str(),
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_project_resource_reuse_accepts_same_asset_kind() {
|
||||
let resource = generated_editor_project_resource(
|
||||
"resource-derived",
|
||||
"task-1",
|
||||
Some("resource-source"),
|
||||
);
|
||||
|
||||
assert!(project_resource_matches_reuse_input(
|
||||
&resource,
|
||||
"user-1",
|
||||
"resource-source",
|
||||
Some("icon"),
|
||||
resource.asset_object_id.as_ref(),
|
||||
resource.object_key.as_ref(),
|
||||
resource.image_src.as_str(),
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_editor_generation_pricing_models_accepts_complete_matrix() {
|
||||
let normalized =
|
||||
|
||||
@@ -303,9 +303,11 @@ export type CanvasHistoryActionType =
|
||||
export type CanvasHistoryAction = {
|
||||
type: CanvasHistoryActionType;
|
||||
count?: number;
|
||||
layerIds?: string[];
|
||||
};
|
||||
|
||||
export type CanvasHistoryEntry = {
|
||||
entryId: number;
|
||||
snapshot: CanvasHistorySnapshot;
|
||||
action: CanvasHistoryAction;
|
||||
createdAt: number;
|
||||
|
||||
@@ -111,6 +111,7 @@ const generateEditorCharacterAnimationMock = vi.hoisted(() => vi.fn());
|
||||
const editEditorImageMock = vi.hoisted(() => vi.fn());
|
||||
const createEditorAssetMock = vi.hoisted(() => vi.fn());
|
||||
const createEditorProjectResourceMock = vi.hoisted(() => vi.fn());
|
||||
const splitEditorIconSpritesheetMock = vi.hoisted(() => vi.fn());
|
||||
const createEditorAssetFolderMock = vi.hoisted(() => vi.fn());
|
||||
const updateEditorAssetMock = vi.hoisted(() => vi.fn());
|
||||
const updateEditorAssetFolderMock = vi.hoisted(() => vi.fn());
|
||||
@@ -136,6 +137,7 @@ vi.mock('../../services/image-editor/editorProjectClient', async () => {
|
||||
createEditorAsset: createEditorAssetMock,
|
||||
createEditorAssetFolder: createEditorAssetFolderMock,
|
||||
createEditorProjectResource: createEditorProjectResourceMock,
|
||||
splitEditorIconSpritesheet: splitEditorIconSpritesheetMock,
|
||||
deleteEditorAsset: deleteEditorAssetMock,
|
||||
deleteEditorAssetFolder: deleteEditorAssetFolderMock,
|
||||
generateEditorCharacterAnimation: generateEditorCharacterAnimationMock,
|
||||
@@ -322,6 +324,10 @@ describe('ImageCanvasEditorView', () => {
|
||||
});
|
||||
confirmEditorAgentToolCallMock.mockReset();
|
||||
cancelEditorAgentToolCallMock.mockReset();
|
||||
splitEditorIconSpritesheetMock.mockReset();
|
||||
splitEditorIconSpritesheetMock.mockImplementation(
|
||||
() => new Promise(() => undefined),
|
||||
);
|
||||
getPlatformProfileDashboardMock.mockResolvedValue({
|
||||
walletBalance: 1234,
|
||||
totalPlayTimeMs: 0,
|
||||
@@ -2041,6 +2047,160 @@ describe('ImageCanvasEditorView', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps atlas splitting busy until the new asset kind resource is confirmed', async () => {
|
||||
const persistedResource = createDeferred<EditorProjectResourceSnapshot>();
|
||||
createEditorProjectResourceMock.mockImplementationOnce(
|
||||
() => persistedResource.promise,
|
||||
);
|
||||
render(<ImageCanvasEditorView />);
|
||||
|
||||
const layerButton = await screen.findByRole('button', {
|
||||
name: '选择拼图素材',
|
||||
});
|
||||
fireEvent.click(layerButton);
|
||||
fireEvent.click(
|
||||
within(layerButton).getByRole('button', { name: '素材标签:未知' }),
|
||||
);
|
||||
fireEvent.click(
|
||||
within(screen.getByRole('menu', { name: '选择素材标签' })).getByRole(
|
||||
'menuitem',
|
||||
{ name: '图集' },
|
||||
),
|
||||
);
|
||||
|
||||
const pendingSplitButton = await screen.findByRole('button', {
|
||||
name: '素材类型保存中',
|
||||
});
|
||||
expect((pendingSplitButton as HTMLButtonElement).disabled).toBe(true);
|
||||
expect(pendingSplitButton.getAttribute('aria-busy')).toBe('true');
|
||||
fireEvent.click(pendingSplitButton);
|
||||
expect(splitEditorIconSpritesheetMock).not.toHaveBeenCalled();
|
||||
|
||||
await act(async () => {
|
||||
persistedResource.resolve({
|
||||
resourceId: 'resource-confirmed-atlas',
|
||||
projectId: 'editor-project-default',
|
||||
imageSrc: '/creation-type-references/puzzle.webp',
|
||||
width: 640,
|
||||
height: 640,
|
||||
sourceType: 'uploaded',
|
||||
assetKind: 'icon-spritesheet',
|
||||
});
|
||||
await persistedResource.promise;
|
||||
});
|
||||
|
||||
const splitButton = await screen.findByRole('button', {
|
||||
name: '拆分图集',
|
||||
});
|
||||
expect((splitButton as HTMLButtonElement).disabled).toBe(false);
|
||||
fireEvent.click(splitButton);
|
||||
await waitFor(() => {
|
||||
expect(splitEditorIconSpritesheetMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
projectId: 'editor-project-default',
|
||||
sourceResourceId: 'resource-confirmed-atlas',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('rolls back the asset kind and resource when persistence fails', async () => {
|
||||
const persistedResource = createDeferred<EditorProjectResourceSnapshot>();
|
||||
createEditorProjectResourceMock.mockImplementationOnce(
|
||||
() => persistedResource.promise,
|
||||
);
|
||||
render(<ImageCanvasEditorView />);
|
||||
|
||||
const layerButton = await screen.findByRole('button', {
|
||||
name: '选择拼图素材',
|
||||
});
|
||||
fireEvent.click(layerButton);
|
||||
fireEvent.click(
|
||||
within(layerButton).getByRole('button', { name: '素材标签:未知' }),
|
||||
);
|
||||
fireEvent.click(
|
||||
within(screen.getByRole('menu', { name: '选择素材标签' })).getByRole(
|
||||
'menuitem',
|
||||
{ name: '图集' },
|
||||
),
|
||||
);
|
||||
await screen.findByRole('button', { name: '素材类型保存中' });
|
||||
|
||||
await act(async () => {
|
||||
persistedResource.reject(new Error('资源类型写入失败'));
|
||||
try {
|
||||
await persistedResource.promise;
|
||||
} catch {
|
||||
// The component consumes the rejection and restores the prior layer.
|
||||
}
|
||||
});
|
||||
|
||||
await within(layerButton).findByRole('button', {
|
||||
name: '素材标签:未知',
|
||||
});
|
||||
expect(
|
||||
screen.getByText('素材类型保存失败,已恢复原标签:资源类型写入失败'),
|
||||
).toBeTruthy();
|
||||
expect(screen.queryByRole('button', { name: '拆分图集' })).toBeNull();
|
||||
await waitFor(() => {
|
||||
const latestLayout = saveEditorProjectLayoutMock.mock.calls.at(-1)?.[1];
|
||||
expect(latestLayout.layers).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
layerId: 'layer-puzzle',
|
||||
resourceId: 'resource-puzzle',
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('rolls back when the resource response keeps the old asset kind', async () => {
|
||||
createEditorProjectResourceMock.mockResolvedValueOnce({
|
||||
resourceId: 'resource-stale-icon-spec',
|
||||
projectId: 'editor-project-default',
|
||||
imageSrc: '/creation-type-references/puzzle.webp',
|
||||
width: 640,
|
||||
height: 640,
|
||||
sourceType: 'uploaded',
|
||||
assetKind: 'icon-spec',
|
||||
});
|
||||
render(<ImageCanvasEditorView />);
|
||||
|
||||
const layerButton = await screen.findByRole('button', {
|
||||
name: '选择拼图素材',
|
||||
});
|
||||
fireEvent.click(layerButton);
|
||||
fireEvent.click(
|
||||
within(layerButton).getByRole('button', { name: '素材标签:未知' }),
|
||||
);
|
||||
fireEvent.click(
|
||||
within(screen.getByRole('menu', { name: '选择素材标签' })).getByRole(
|
||||
'menuitem',
|
||||
{ name: '图集' },
|
||||
),
|
||||
);
|
||||
|
||||
await within(layerButton).findByRole('button', { name: '素材标签:未知' });
|
||||
expect(
|
||||
screen.getByText(
|
||||
'素材类型保存失败,已恢复原标签:服务器未确认新的素材类型',
|
||||
),
|
||||
).toBeTruthy();
|
||||
expect(screen.queryByRole('button', { name: '拆分图集' })).toBeNull();
|
||||
await waitFor(() => {
|
||||
const latestLayout = saveEditorProjectLayoutMock.mock.calls.at(-1)?.[1];
|
||||
expect(latestLayout.layers).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
layerId: 'layer-puzzle',
|
||||
resourceId: 'resource-puzzle',
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('persists an undone asset kind and ignores the older pending response', async () => {
|
||||
const characterResource = createDeferred<EditorProjectResourceSnapshot>();
|
||||
const restoredResource = createDeferred<EditorProjectResourceSnapshot>();
|
||||
@@ -2119,6 +2279,167 @@ describe('ImageCanvasEditorView', () => {
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('rolls back an overlapping undo failure to the last confirmed resource', async () => {
|
||||
const staleCharacterResource =
|
||||
createDeferred<EditorProjectResourceSnapshot>();
|
||||
const failedUndoResource = createDeferred<EditorProjectResourceSnapshot>();
|
||||
createEditorProjectResourceMock
|
||||
.mockImplementationOnce(() => staleCharacterResource.promise)
|
||||
.mockImplementationOnce(() => failedUndoResource.promise);
|
||||
render(<ImageCanvasEditorView />);
|
||||
|
||||
const layerButton = await screen.findByRole('button', {
|
||||
name: '选择拼图素材',
|
||||
});
|
||||
fireEvent.click(
|
||||
within(layerButton).getByRole('button', { name: '素材标签:未知' }),
|
||||
);
|
||||
fireEvent.click(
|
||||
within(screen.getByRole('menu', { name: '选择素材标签' })).getByRole(
|
||||
'menuitem',
|
||||
{ name: '角色' },
|
||||
),
|
||||
);
|
||||
await within(layerButton).findByRole('button', {
|
||||
name: '素材标签:角色',
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '撤销' }));
|
||||
await within(layerButton).findByRole('button', { name: '素材标签:未知' });
|
||||
await waitFor(() => {
|
||||
expect(createEditorProjectResourceMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
failedUndoResource.reject(new Error('撤销后资源写入失败'));
|
||||
try {
|
||||
await failedUndoResource.promise;
|
||||
} catch {
|
||||
// The latest failure must restore the server-confirmed original layer.
|
||||
}
|
||||
});
|
||||
|
||||
await within(layerButton).findByRole('button', { name: '素材标签:未知' });
|
||||
expect(
|
||||
screen.getByText('素材类型保存失败,已恢复原标签:撤销后资源写入失败'),
|
||||
).toBeTruthy();
|
||||
await waitFor(() => {
|
||||
const latestLayout = saveEditorProjectLayoutMock.mock.calls.at(-1)?.[1];
|
||||
expect(latestLayout.layers).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
layerId: 'layer-puzzle',
|
||||
resourceId: 'resource-puzzle',
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
const undoButton = screen.getByRole('button', { name: '撤销' });
|
||||
expect((undoButton as HTMLButtonElement).disabled).toBe(true);
|
||||
fireEvent.click(undoButton);
|
||||
expect(createEditorProjectResourceMock).toHaveBeenCalledTimes(2);
|
||||
|
||||
await act(async () => {
|
||||
staleCharacterResource.resolve({
|
||||
resourceId: 'resource-stale-character-after-failed-undo',
|
||||
projectId: 'editor-project-default',
|
||||
imageSrc: '/creation-type-references/puzzle.webp',
|
||||
width: 640,
|
||||
height: 640,
|
||||
sourceType: 'uploaded',
|
||||
assetKind: 'character',
|
||||
});
|
||||
await staleCharacterResource.promise;
|
||||
});
|
||||
|
||||
expect(
|
||||
within(layerButton).getByRole('button', { name: '素材标签:未知' }),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
saveEditorProjectLayoutMock.mock.calls.some((call) =>
|
||||
call[1].layers.some(
|
||||
(layer: { resourceId?: unknown }) =>
|
||||
layer.resourceId === 'resource-stale-character-after-failed-undo',
|
||||
),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps the newer asset kind pending when an older request fails', async () => {
|
||||
const staleCharacterResource =
|
||||
createDeferred<EditorProjectResourceSnapshot>();
|
||||
const currentAtlasResource =
|
||||
createDeferred<EditorProjectResourceSnapshot>();
|
||||
createEditorProjectResourceMock
|
||||
.mockImplementationOnce(() => staleCharacterResource.promise)
|
||||
.mockImplementationOnce(() => currentAtlasResource.promise);
|
||||
render(<ImageCanvasEditorView />);
|
||||
|
||||
const layerButton = await screen.findByRole('button', {
|
||||
name: '选择拼图素材',
|
||||
});
|
||||
fireEvent.click(layerButton);
|
||||
fireEvent.click(
|
||||
within(layerButton).getByRole('button', { name: '素材标签:未知' }),
|
||||
);
|
||||
fireEvent.click(
|
||||
within(screen.getByRole('menu', { name: '选择素材标签' })).getByRole(
|
||||
'menuitem',
|
||||
{ name: '角色' },
|
||||
),
|
||||
);
|
||||
await within(layerButton).findByRole('button', {
|
||||
name: '素材标签:角色',
|
||||
});
|
||||
|
||||
fireEvent.click(
|
||||
within(layerButton).getByRole('button', { name: '素材标签:角色' }),
|
||||
);
|
||||
fireEvent.click(
|
||||
within(screen.getByRole('menu', { name: '选择素材标签' })).getByRole(
|
||||
'menuitem',
|
||||
{ name: '图集' },
|
||||
),
|
||||
);
|
||||
await screen.findByRole('button', { name: '素材类型保存中' });
|
||||
|
||||
await act(async () => {
|
||||
staleCharacterResource.reject(new Error('旧请求失败'));
|
||||
try {
|
||||
await staleCharacterResource.promise;
|
||||
} catch {
|
||||
// The stale rejection must not affect the newer request state.
|
||||
}
|
||||
});
|
||||
|
||||
expect(
|
||||
within(layerButton).getByRole('button', { name: '素材标签:图集' }),
|
||||
).toBeTruthy();
|
||||
const stillPendingButton = screen.getByRole('button', {
|
||||
name: '素材类型保存中',
|
||||
});
|
||||
expect((stillPendingButton as HTMLButtonElement).disabled).toBe(true);
|
||||
expect(screen.queryByText(/素材类型保存失败/u)).toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
currentAtlasResource.resolve({
|
||||
resourceId: 'resource-current-atlas',
|
||||
projectId: 'editor-project-default',
|
||||
imageSrc: '/creation-type-references/puzzle.webp',
|
||||
width: 640,
|
||||
height: 640,
|
||||
sourceType: 'uploaded',
|
||||
assetKind: 'icon-spritesheet',
|
||||
});
|
||||
await currentAtlasResource.promise;
|
||||
});
|
||||
|
||||
expect(
|
||||
await screen.findByRole('button', { name: '拆分图集' }),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it('offers Lovart-style zoom menu commands', async () => {
|
||||
render(<ImageCanvasEditorView />);
|
||||
|
||||
@@ -2251,8 +2572,7 @@ describe('ImageCanvasEditorView', () => {
|
||||
object_image_id: 'source-image',
|
||||
prompt: '把图片换成像素风',
|
||||
},
|
||||
displayArgs:
|
||||
createEditImageToolCallDisplayArgs('把图片换成像素风'),
|
||||
displayArgs: createEditImageToolCallDisplayArgs('把图片换成像素风'),
|
||||
images: [],
|
||||
error: null,
|
||||
},
|
||||
@@ -2273,8 +2593,7 @@ describe('ImageCanvasEditorView', () => {
|
||||
status: 'not_completed',
|
||||
externalJobId: 'task-edit-canvas-1',
|
||||
args: {},
|
||||
displayArgs:
|
||||
createEditImageToolCallDisplayArgs('把图片换成像素风'),
|
||||
displayArgs: createEditImageToolCallDisplayArgs('把图片换成像素风'),
|
||||
images: [],
|
||||
error: null,
|
||||
},
|
||||
@@ -2335,9 +2654,7 @@ describe('ImageCanvasEditorView', () => {
|
||||
clientX: 400,
|
||||
clientY: 280,
|
||||
});
|
||||
expect(world.style.transform).toBe(
|
||||
'translate(-80px, -120px) scale(0.5)',
|
||||
);
|
||||
expect(world.style.transform).toBe('translate(-80px, -120px) scale(0.5)');
|
||||
expect(
|
||||
screen.getByRole('button', { name: '当前缩放比例 100%' }),
|
||||
).toBeTruthy();
|
||||
|
||||
@@ -316,10 +316,15 @@ export function ImageCanvasEditorView({
|
||||
const discardCanvasHistoryEntriesContainingLayerRef = useRef<
|
||||
(matchesLayer: (layer: CanvasLayer) => boolean) => void
|
||||
>(() => {});
|
||||
const persistRestoredAssetKindRef = useRef<(layer: CanvasLayer) => void>(
|
||||
() => {},
|
||||
);
|
||||
const persistRestoredAssetKindRef = useRef<
|
||||
(layer: CanvasLayer, historyEntryId?: number) => void
|
||||
>(() => {});
|
||||
const layerResourcePersistenceVersionRef = useRef(new Map<string, number>());
|
||||
const assetKindRollbackLayerByIdRef = useRef(new Map<string, CanvasLayer>());
|
||||
const assetKindHistoryEntryIdsByLayerIdRef = useRef(
|
||||
new Map<string, Set<number>>(),
|
||||
);
|
||||
const persistingAssetKindLayerIdsRef = useRef(new Set<string>());
|
||||
const resetCanvasInteractionStateRef = useRef<() => void>(() => {});
|
||||
const closeGenerationTransientStateRef = useRef<() => void>(() => {});
|
||||
const specToolWrapRef = useRef<HTMLSpanElement | null>(null);
|
||||
@@ -785,8 +790,10 @@ export function ImageCanvasEditorView({
|
||||
);
|
||||
const canvasHistoryEffects = useMemo(
|
||||
() => ({
|
||||
persistRestoredAssetKind: (layer: CanvasLayer) =>
|
||||
persistRestoredAssetKindRef.current(layer),
|
||||
persistRestoredAssetKind: (
|
||||
layer: CanvasLayer,
|
||||
historyEntryId?: number,
|
||||
) => persistRestoredAssetKindRef.current(layer, historyEntryId),
|
||||
}),
|
||||
[],
|
||||
);
|
||||
@@ -796,6 +803,7 @@ export function ImageCanvasEditorView({
|
||||
getCanvasHistorySnapshot,
|
||||
captureCanvasHistory,
|
||||
discardCanvasHistoryEntriesContainingLayer,
|
||||
discardCanvasHistoryEntriesById,
|
||||
undoCanvasChange,
|
||||
redoCanvasChange,
|
||||
} = useCanvasHistory({
|
||||
@@ -809,6 +817,8 @@ export function ImageCanvasEditorView({
|
||||
discardCanvasHistoryEntriesContainingLayer;
|
||||
const [historyFeedback, setHistoryFeedback] =
|
||||
useState<CanvasHistoryFeedback | null>(null);
|
||||
const [persistingAssetKindLayerIds, setPersistingAssetKindLayerIds] =
|
||||
useState<ReadonlySet<string>>(() => new Set());
|
||||
useEffect(() => {
|
||||
if (!historyFeedback) {
|
||||
return;
|
||||
@@ -1187,18 +1197,105 @@ export function ImageCanvasEditorView({
|
||||
.then(applyGeneratedProjectSnapshot)
|
||||
.catch(() => undefined);
|
||||
}, [applyGeneratedProjectSnapshot, projectId]);
|
||||
const setLayerAssetKindPersistencePending = useCallback(
|
||||
(layerId: string, isPending: boolean) => {
|
||||
const nextLayerIds = new Set(persistingAssetKindLayerIdsRef.current);
|
||||
if (isPending) {
|
||||
nextLayerIds.add(layerId);
|
||||
} else {
|
||||
nextLayerIds.delete(layerId);
|
||||
}
|
||||
persistingAssetKindLayerIdsRef.current = nextLayerIds;
|
||||
setPersistingAssetKindLayerIds(nextLayerIds);
|
||||
},
|
||||
[],
|
||||
);
|
||||
const persistUpdatedLayerResource = useCallback(
|
||||
(layer: CanvasLayer) => {
|
||||
(
|
||||
layer: CanvasLayer,
|
||||
rollbackLayer?: CanvasLayer,
|
||||
historyEntryId?: number,
|
||||
) => {
|
||||
const requestVersion =
|
||||
(layerResourcePersistenceVersionRef.current.get(layer.id) ?? 0) + 1;
|
||||
layerResourcePersistenceVersionRef.current.set(layer.id, requestVersion);
|
||||
const confirmedRollbackLayer = rollbackLayer
|
||||
? (assetKindRollbackLayerByIdRef.current.get(layer.id) ?? rollbackLayer)
|
||||
: undefined;
|
||||
if (confirmedRollbackLayer) {
|
||||
assetKindRollbackLayerByIdRef.current.set(
|
||||
layer.id,
|
||||
confirmedRollbackLayer,
|
||||
);
|
||||
if (historyEntryId !== undefined) {
|
||||
const historyEntryIds = new Set(
|
||||
assetKindHistoryEntryIdsByLayerIdRef.current.get(layer.id),
|
||||
);
|
||||
historyEntryIds.add(historyEntryId);
|
||||
assetKindHistoryEntryIdsByLayerIdRef.current.set(
|
||||
layer.id,
|
||||
historyEntryIds,
|
||||
);
|
||||
}
|
||||
setLayerAssetKindPersistencePending(layer.id, true);
|
||||
}
|
||||
const rollbackLatestRequest = (error: unknown) => {
|
||||
if (
|
||||
layerResourcePersistenceVersionRef.current.get(layer.id) !==
|
||||
requestVersion ||
|
||||
!confirmedRollbackLayer
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setLayers((currentLayers) => {
|
||||
const nextLayers = currentLayers.map((currentLayer) =>
|
||||
currentLayer.id === layer.id
|
||||
? {
|
||||
...currentLayer,
|
||||
assetKind: confirmedRollbackLayer.assetKind,
|
||||
resourceId: confirmedRollbackLayer.resourceId,
|
||||
}
|
||||
: currentLayer,
|
||||
);
|
||||
layersRef.current = nextLayers;
|
||||
return nextLayers;
|
||||
});
|
||||
setMetadataLayer((currentLayer) =>
|
||||
currentLayer?.id === layer.id
|
||||
? {
|
||||
...currentLayer,
|
||||
assetKind: confirmedRollbackLayer.assetKind,
|
||||
resourceId: confirmedRollbackLayer.resourceId,
|
||||
}
|
||||
: currentLayer,
|
||||
);
|
||||
const historyEntryIds =
|
||||
assetKindHistoryEntryIdsByLayerIdRef.current.get(layer.id);
|
||||
if (historyEntryIds) {
|
||||
discardCanvasHistoryEntriesById(historyEntryIds);
|
||||
}
|
||||
assetKindHistoryEntryIdsByLayerIdRef.current.delete(layer.id);
|
||||
assetKindRollbackLayerByIdRef.current.delete(layer.id);
|
||||
setLayerAssetKindPersistencePending(layer.id, false);
|
||||
const reason =
|
||||
error instanceof Error && error.message.trim()
|
||||
? error.message.trim()
|
||||
: '请稍后重试';
|
||||
setHistoryFeedback((currentFeedback) => ({
|
||||
id: (currentFeedback?.id ?? 0) + 1,
|
||||
tone: 'warning',
|
||||
text: `素材类型保存失败,已恢复原标签:${reason}`,
|
||||
}));
|
||||
};
|
||||
if (!projectId) {
|
||||
rollbackLatestRequest(new Error('项目尚未就绪'));
|
||||
return;
|
||||
}
|
||||
const imageSrc = resolveLayerProjectResourceImageSrc(layer);
|
||||
if (!imageSrc) {
|
||||
rollbackLatestRequest(new Error('素材图片尚未就绪'));
|
||||
return;
|
||||
}
|
||||
const requestVersion =
|
||||
(layerResourcePersistenceVersionRef.current.get(layer.id) ?? 0) + 1;
|
||||
layerResourcePersistenceVersionRef.current.set(layer.id, requestVersion);
|
||||
void createEditorProjectResource(projectId, {
|
||||
imageSrc,
|
||||
objectKey: layer.objectKey,
|
||||
@@ -1215,30 +1312,68 @@ export function ImageCanvasEditorView({
|
||||
sourceResourceId: layer.sourceResourceId,
|
||||
assetKind: layer.assetKind,
|
||||
generationInputs: layer.generationInputs,
|
||||
}).then((resource) => {
|
||||
if (
|
||||
layerResourcePersistenceVersionRef.current.get(layer.id) !==
|
||||
requestVersion
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setLayers((currentLayers) => {
|
||||
const nextLayers = currentLayers.map((currentLayer) =>
|
||||
currentLayer.id === layer.id
|
||||
})
|
||||
.then((resource) => {
|
||||
if (
|
||||
layerResourcePersistenceVersionRef.current.get(layer.id) !==
|
||||
requestVersion
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
confirmedRollbackLayer &&
|
||||
(resource.assetKind !== layer.assetKind ||
|
||||
!resource.resourceId.trim())
|
||||
) {
|
||||
rollbackLatestRequest(new Error('服务器未确认新的素材类型'));
|
||||
return;
|
||||
}
|
||||
setLayers((currentLayers) => {
|
||||
const nextLayers = currentLayers.map((currentLayer) =>
|
||||
currentLayer.id === layer.id
|
||||
? {
|
||||
...currentLayer,
|
||||
assetKind: layer.assetKind,
|
||||
resourceId: resource.resourceId,
|
||||
}
|
||||
: currentLayer,
|
||||
);
|
||||
layersRef.current = nextLayers;
|
||||
return nextLayers;
|
||||
});
|
||||
setMetadataLayer((currentLayer) =>
|
||||
currentLayer?.id === layer.id
|
||||
? {
|
||||
...currentLayer,
|
||||
assetKind: layer.assetKind,
|
||||
resourceId: resource.resourceId,
|
||||
}
|
||||
: currentLayer,
|
||||
);
|
||||
layersRef.current = nextLayers;
|
||||
return nextLayers;
|
||||
});
|
||||
});
|
||||
if (confirmedRollbackLayer) {
|
||||
assetKindHistoryEntryIdsByLayerIdRef.current.delete(layer.id);
|
||||
assetKindRollbackLayerByIdRef.current.delete(layer.id);
|
||||
setLayerAssetKindPersistencePending(layer.id, false);
|
||||
}
|
||||
})
|
||||
.catch(rollbackLatestRequest);
|
||||
},
|
||||
[projectId],
|
||||
[
|
||||
discardCanvasHistoryEntriesById,
|
||||
projectId,
|
||||
setLayerAssetKindPersistencePending,
|
||||
],
|
||||
);
|
||||
persistRestoredAssetKindRef.current = persistUpdatedLayerResource;
|
||||
persistRestoredAssetKindRef.current = (layer, historyEntryId) => {
|
||||
const rollbackLayer = layersRef.current.find(
|
||||
(currentLayer) => currentLayer.id === layer.id,
|
||||
);
|
||||
persistUpdatedLayerResource(
|
||||
layer,
|
||||
rollbackLayer ?? layer,
|
||||
historyEntryId,
|
||||
);
|
||||
};
|
||||
const {
|
||||
assetExportStatus,
|
||||
isExportingAssets,
|
||||
@@ -1793,7 +1928,11 @@ export function ImageCanvasEditorView({
|
||||
if (!targetLayer || targetLayer.assetKind === assetKind) {
|
||||
return;
|
||||
}
|
||||
captureCanvasHistory({ type: 'change-asset-kind', count: 1 });
|
||||
const historyEntryId = captureCanvasHistory({
|
||||
type: 'change-asset-kind',
|
||||
count: 1,
|
||||
layerIds: [layerId],
|
||||
});
|
||||
const nextLayer = {
|
||||
...targetLayer,
|
||||
assetKind,
|
||||
@@ -1808,7 +1947,7 @@ export function ImageCanvasEditorView({
|
||||
setMetadataLayer((currentLayer) =>
|
||||
currentLayer?.id === layerId ? nextLayer : currentLayer,
|
||||
);
|
||||
persistUpdatedLayerResource(nextLayer);
|
||||
persistUpdatedLayerResource(nextLayer, targetLayer, historyEntryId);
|
||||
setImageContextMenu(null);
|
||||
setContextMenu(null);
|
||||
},
|
||||
@@ -2216,6 +2355,7 @@ export function ImageCanvasEditorView({
|
||||
generationComposerStyle,
|
||||
selectedToolbarStyle,
|
||||
splittingIconSpritesheetLayerIds,
|
||||
persistingAssetKindLayerIds,
|
||||
uploadDropTarget,
|
||||
contextMenu,
|
||||
canvasClipboard,
|
||||
@@ -2266,7 +2406,12 @@ export function ImageCanvasEditorView({
|
||||
onOpenRedrawPanel: openRedrawPanel,
|
||||
onOpenCropExpandPanel: openCropExpandPanel,
|
||||
onRemoveBackground: removeSelectedLayerBackground,
|
||||
onSplitIconSpritesheet: splitSelectedIconSpritesheet,
|
||||
onSplitIconSpritesheet: (layer: CanvasLayer) => {
|
||||
if (persistingAssetKindLayerIdsRef.current.has(layer.id)) {
|
||||
return;
|
||||
}
|
||||
void splitSelectedIconSpritesheet(layer);
|
||||
},
|
||||
onExtractUiDesignAssets: extractUiDesignAssets,
|
||||
onUiAssetExtractionToolChange: changeUiAssetExtractionTool,
|
||||
onUiAssetExtractionModelChange: changeUiAssetExtractionModel,
|
||||
|
||||
@@ -183,6 +183,22 @@ describe('ImageCanvasHistoryModel', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('preserves the current asset kind for layers outside the history action target', () => {
|
||||
const targetLayer = createLayer({ assetKind: 'icon-spritesheet', x: 10 });
|
||||
const currentLayer = createLayer({ assetKind: 'character', x: 90 });
|
||||
|
||||
const merged = mergeCanvasHistorySnapshotForRestore({
|
||||
current: createSnapshot([currentLayer]),
|
||||
target: createSnapshot([targetLayer]),
|
||||
assetKindLayerIds: new Set(),
|
||||
});
|
||||
|
||||
expect(merged.layers[0]).toMatchObject({
|
||||
assetKind: 'character',
|
||||
x: 10,
|
||||
});
|
||||
});
|
||||
|
||||
it('restores the complete target layer after that layer was deleted', () => {
|
||||
const targetLayer = createLayer({
|
||||
resourceId: 'deleted-resource',
|
||||
|
||||
@@ -131,6 +131,7 @@ function cloneDialogPlaceholder(
|
||||
function mergeLayerForHistoryRestore(
|
||||
target: CanvasLayer,
|
||||
current: CanvasLayer | undefined,
|
||||
restoreAssetKind: boolean,
|
||||
): CanvasLayer {
|
||||
if (!current) {
|
||||
return { ...target };
|
||||
@@ -141,7 +142,7 @@ function mergeLayerForHistoryRestore(
|
||||
y: target.y,
|
||||
zIndex: target.zIndex,
|
||||
groupId: target.groupId,
|
||||
assetKind: target.assetKind,
|
||||
assetKind: restoreAssetKind ? target.assetKind : current.assetKind,
|
||||
hidden: target.hidden,
|
||||
locked: target.locked,
|
||||
flipX: target.flipX,
|
||||
@@ -186,9 +187,11 @@ function mergeDialogForHistoryRestore<
|
||||
export function mergeCanvasHistorySnapshotForRestore({
|
||||
current,
|
||||
target,
|
||||
assetKindLayerIds,
|
||||
}: {
|
||||
current: CanvasHistorySnapshot;
|
||||
target: CanvasHistorySnapshot;
|
||||
assetKindLayerIds?: ReadonlySet<string>;
|
||||
}): CanvasHistorySnapshot {
|
||||
const currentLayerById = new Map(
|
||||
current.layers.map((layer) => [layer.id, layer] as const),
|
||||
@@ -210,7 +213,11 @@ export function mergeCanvasHistorySnapshotForRestore({
|
||||
return {
|
||||
...target,
|
||||
layers: target.layers.map((layer) =>
|
||||
mergeLayerForHistoryRestore(layer, currentLayerById.get(layer.id)),
|
||||
mergeLayerForHistoryRestore(
|
||||
layer,
|
||||
currentLayerById.get(layer.id),
|
||||
assetKindLayerIds?.has(layer.id) ?? true,
|
||||
),
|
||||
),
|
||||
generateDialog: target.generateDialog
|
||||
? mergeTargetDialog(target.generateDialog)
|
||||
|
||||
@@ -43,6 +43,7 @@ function renderSelectedToolbar(
|
||||
onOpenCropExpandPanel: vi.fn(),
|
||||
onRemoveBackground: vi.fn(),
|
||||
isSplittingIconSpritesheet: false,
|
||||
isPersistingAssetKind: false,
|
||||
onSplitIconSpritesheet: vi.fn(),
|
||||
onExtractUiDesignAssets: vi.fn(),
|
||||
onOpenCharacterAnimationPanel: vi.fn(),
|
||||
@@ -183,6 +184,25 @@ describe('ImageCanvasSelectedLayerToolbarView', () => {
|
||||
expect(props.onSplitIconSpritesheet).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not invoke atlas splitting while the selected asset kind is persisting', () => {
|
||||
const layer = createLayer({ assetKind: 'icon-spritesheet' });
|
||||
const props = renderSelectedToolbar({
|
||||
selectedLayer: layer,
|
||||
isPersistingAssetKind: true,
|
||||
});
|
||||
const splitButton = screen.getByRole('button', {
|
||||
name: '素材类型保存中',
|
||||
});
|
||||
|
||||
expect(splitButton.getAttribute('aria-busy')).toBe('true');
|
||||
expect((splitButton as HTMLButtonElement).disabled).toBe(true);
|
||||
expect(splitButton.textContent).toContain('保存中');
|
||||
|
||||
fireEvent.click(splitButton);
|
||||
|
||||
expect(props.onSplitIconSpritesheet).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps only remodel and download for audio layers', () => {
|
||||
const layer = createLayer({
|
||||
title: '游戏音效',
|
||||
@@ -322,6 +342,7 @@ describe('ImageCanvasSelectedLayerToolbarView', () => {
|
||||
onOpenCropExpandPanel={vi.fn()}
|
||||
onRemoveBackground={vi.fn()}
|
||||
isSplittingIconSpritesheet={false}
|
||||
isPersistingAssetKind={false}
|
||||
onSplitIconSpritesheet={vi.fn()}
|
||||
onExtractUiDesignAssets={vi.fn()}
|
||||
onOpenCharacterAnimationPanel={vi.fn()}
|
||||
@@ -340,6 +361,7 @@ describe('ImageCanvasSelectedLayerToolbarView', () => {
|
||||
onOpenCropExpandPanel={vi.fn()}
|
||||
onRemoveBackground={vi.fn()}
|
||||
isSplittingIconSpritesheet={false}
|
||||
isPersistingAssetKind={false}
|
||||
onSplitIconSpritesheet={vi.fn()}
|
||||
onExtractUiDesignAssets={vi.fn()}
|
||||
onOpenCharacterAnimationPanel={vi.fn()}
|
||||
|
||||
@@ -23,6 +23,7 @@ type ImageCanvasSelectedLayerToolbarViewProps = {
|
||||
onOpenCropExpandPanel: (layer: CanvasLayer) => void;
|
||||
onRemoveBackground: (layer: CanvasLayer) => void;
|
||||
isSplittingIconSpritesheet: boolean;
|
||||
isPersistingAssetKind: boolean;
|
||||
onSplitIconSpritesheet: (layer: CanvasLayer) => void;
|
||||
onExtractUiDesignAssets: (layer: CanvasLayer) => void;
|
||||
onOpenCharacterAnimationPanel: (layer: CanvasLayer) => void;
|
||||
@@ -37,6 +38,7 @@ export function ImageCanvasSelectedLayerToolbarView({
|
||||
onOpenCropExpandPanel,
|
||||
onRemoveBackground,
|
||||
isSplittingIconSpritesheet,
|
||||
isPersistingAssetKind,
|
||||
onSplitIconSpritesheet,
|
||||
onExtractUiDesignAssets,
|
||||
onOpenCharacterAnimationPanel,
|
||||
@@ -123,20 +125,38 @@ export function ImageCanvasSelectedLayerToolbarView({
|
||||
{selectedLayer.assetKind === 'icon-spritesheet' ? (
|
||||
<PlatformIconButton
|
||||
className="image-canvas-editor__floating-toolbar-text-button"
|
||||
label={isSplittingIconSpritesheet ? '拆图中' : '拆分图集'}
|
||||
title={isSplittingIconSpritesheet ? '拆图中' : '拆分图集'}
|
||||
label={
|
||||
isPersistingAssetKind
|
||||
? '素材类型保存中'
|
||||
: isSplittingIconSpritesheet
|
||||
? '拆图中'
|
||||
: '拆分图集'
|
||||
}
|
||||
title={
|
||||
isPersistingAssetKind
|
||||
? '素材类型保存中'
|
||||
: isSplittingIconSpritesheet
|
||||
? '拆图中'
|
||||
: '拆分图集'
|
||||
}
|
||||
icon={
|
||||
isSplittingIconSpritesheet ? (
|
||||
isPersistingAssetKind || isSplittingIconSpritesheet ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Scissors className="h-4 w-4" />
|
||||
)
|
||||
}
|
||||
disabled={isSplittingIconSpritesheet}
|
||||
aria-busy={isSplittingIconSpritesheet}
|
||||
disabled={isPersistingAssetKind || isSplittingIconSpritesheet}
|
||||
aria-busy={isPersistingAssetKind || isSplittingIconSpritesheet}
|
||||
onClick={() => onSplitIconSpritesheet(selectedLayer)}
|
||||
>
|
||||
<span>{isSplittingIconSpritesheet ? '拆图中' : '拆分图集'}</span>
|
||||
<span>
|
||||
{isPersistingAssetKind
|
||||
? '保存中'
|
||||
: isSplittingIconSpritesheet
|
||||
? '拆图中'
|
||||
: '拆分图集'}
|
||||
</span>
|
||||
</PlatformIconButton>
|
||||
) : null}
|
||||
{selectedLayer.assetKind === 'ui-design' ? (
|
||||
|
||||
@@ -78,6 +78,7 @@ export type ImageCanvasStageViewProps = {
|
||||
generationComposerStyle: CSSProperties | null;
|
||||
selectedToolbarStyle: CSSProperties | null;
|
||||
splittingIconSpritesheetLayerIds?: ReadonlySet<string>;
|
||||
persistingAssetKindLayerIds?: ReadonlySet<string>;
|
||||
uploadDropTarget: 'canvas' | 'assets' | null;
|
||||
contextMenu: CanvasContextMenuState | null;
|
||||
canvasClipboard: CanvasClipboard | null;
|
||||
@@ -232,6 +233,7 @@ export function ImageCanvasStageView({
|
||||
generationComposerStyle,
|
||||
selectedToolbarStyle,
|
||||
splittingIconSpritesheetLayerIds = EMPTY_LAYER_ID_SET,
|
||||
persistingAssetKindLayerIds = EMPTY_LAYER_ID_SET,
|
||||
uploadDropTarget,
|
||||
contextMenu,
|
||||
canvasClipboard,
|
||||
@@ -399,6 +401,9 @@ export function ImageCanvasStageView({
|
||||
selectedLayer &&
|
||||
splittingIconSpritesheetLayerIds.has(selectedLayer.id),
|
||||
)}
|
||||
isPersistingAssetKind={Boolean(
|
||||
selectedLayer && persistingAssetKindLayerIds.has(selectedLayer.id),
|
||||
)}
|
||||
onOpenQuickEditPanel={onOpenQuickEditPanel}
|
||||
onOpenRedrawPanel={onOpenRedrawPanel}
|
||||
onOpenCropExpandPanel={onOpenCropExpandPanel}
|
||||
|
||||
@@ -533,6 +533,7 @@ describe('useCanvasHistory', () => {
|
||||
resourceId: 'resource-first-icon',
|
||||
assetKind: undefined,
|
||||
}),
|
||||
expect.any(Number),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
@@ -543,6 +544,7 @@ describe('useCanvasHistory', () => {
|
||||
);
|
||||
expect(persistRestoredAssetKind).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ assetKind: 'icon' }),
|
||||
persistRestoredAssetKind.mock.calls[0]?.[1],
|
||||
);
|
||||
expect(persistRestoredAssetKind).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
@@ -42,7 +42,10 @@ type CanvasHistoryResetters = {
|
||||
};
|
||||
|
||||
type CanvasHistoryEffects = {
|
||||
persistRestoredAssetKind?: (layer: CanvasLayer) => void;
|
||||
persistRestoredAssetKind?: (
|
||||
layer: CanvasLayer,
|
||||
historyEntryId?: number,
|
||||
) => void;
|
||||
};
|
||||
|
||||
function cloneGenerateDialog(dialog: GenerateDialogState): GenerateDialogState {
|
||||
@@ -74,6 +77,7 @@ export function useCanvasHistory({
|
||||
}) {
|
||||
const undoStackRef = useRef<CanvasHistoryEntry[]>([]);
|
||||
const redoStackRef = useRef<CanvasHistoryEntry[]>([]);
|
||||
const nextHistoryEntryIdRef = useRef(0);
|
||||
const [historyVersion, setHistoryVersion] = useState(0);
|
||||
|
||||
const getCanvasHistorySnapshot = useCallback(
|
||||
@@ -93,11 +97,21 @@ export function useCanvasHistory({
|
||||
);
|
||||
|
||||
const restoreCanvasHistorySnapshot = useCallback(
|
||||
(snapshot: CanvasHistorySnapshot, action?: CanvasHistoryAction) => {
|
||||
(
|
||||
snapshot: CanvasHistorySnapshot,
|
||||
action?: CanvasHistoryAction,
|
||||
historyEntryId?: number,
|
||||
) => {
|
||||
const currentSnapshot = getCanvasHistorySnapshot();
|
||||
const mergedSnapshot = mergeCanvasHistorySnapshotForRestore({
|
||||
current: currentSnapshot,
|
||||
target: snapshot,
|
||||
assetKindLayerIds:
|
||||
action?.type === 'change-asset-kind'
|
||||
? action.layerIds?.length
|
||||
? new Set(action.layerIds)
|
||||
: undefined
|
||||
: new Set(),
|
||||
});
|
||||
setters.setLayers(mergedSnapshot.layers.map((layer) => ({ ...layer })));
|
||||
setters.setViewport({ ...mergedSnapshot.viewport });
|
||||
@@ -122,7 +136,7 @@ export function useCanvasHistory({
|
||||
for (const layer of mergedSnapshot.layers) {
|
||||
const currentLayer = currentLayerById.get(layer.id);
|
||||
if (currentLayer && currentLayer.assetKind !== layer.assetKind) {
|
||||
effects?.persistRestoredAssetKind?.({ ...layer });
|
||||
effects?.persistRestoredAssetKind?.({ ...layer }, historyEntryId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -135,9 +149,12 @@ export function useCanvasHistory({
|
||||
action: CanvasHistoryAction,
|
||||
options: { snapshot?: CanvasHistorySnapshot } = {},
|
||||
) => {
|
||||
nextHistoryEntryIdRef.current += 1;
|
||||
const entryId = nextHistoryEntryIdRef.current;
|
||||
undoStackRef.current = [
|
||||
...undoStackRef.current.slice(-(MAX_HISTORY_STEPS - 1)),
|
||||
{
|
||||
entryId,
|
||||
snapshot: options.snapshot ?? getCanvasHistorySnapshot(),
|
||||
action,
|
||||
createdAt: Date.now(),
|
||||
@@ -145,6 +162,7 @@ export function useCanvasHistory({
|
||||
];
|
||||
redoStackRef.current = [];
|
||||
setHistoryVersion((version) => version + 1);
|
||||
return entryId;
|
||||
},
|
||||
[getCanvasHistorySnapshot],
|
||||
);
|
||||
@@ -168,6 +186,28 @@ export function useCanvasHistory({
|
||||
[],
|
||||
);
|
||||
|
||||
const discardCanvasHistoryEntriesById = useCallback(
|
||||
(entryIds: ReadonlySet<number>) => {
|
||||
if (entryIds.size === 0) {
|
||||
return;
|
||||
}
|
||||
const keepEntry = (entry: CanvasHistoryEntry) =>
|
||||
!entryIds.has(entry.entryId);
|
||||
const nextUndoStack = undoStackRef.current.filter(keepEntry);
|
||||
const nextRedoStack = redoStackRef.current.filter(keepEntry);
|
||||
if (
|
||||
nextUndoStack.length === undoStackRef.current.length &&
|
||||
nextRedoStack.length === redoStackRef.current.length
|
||||
) {
|
||||
return;
|
||||
}
|
||||
undoStackRef.current = nextUndoStack;
|
||||
redoStackRef.current = nextRedoStack;
|
||||
setHistoryVersion((version) => version + 1);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const undoCanvasChange = useCallback((): CanvasHistoryApplyResult => {
|
||||
const previousEntry = undoStackRef.current.at(-1);
|
||||
if (!previousEntry) {
|
||||
@@ -191,12 +231,17 @@ export function useCanvasHistory({
|
||||
redoStackRef.current = [
|
||||
...redoStackRef.current.slice(-(MAX_HISTORY_STEPS - 1)),
|
||||
{
|
||||
entryId: previousEntry.entryId,
|
||||
snapshot: currentSnapshot,
|
||||
action: previousEntry.action,
|
||||
createdAt: Date.now(),
|
||||
},
|
||||
];
|
||||
restoreCanvasHistorySnapshot(previousEntry.snapshot, previousEntry.action);
|
||||
restoreCanvasHistorySnapshot(
|
||||
previousEntry.snapshot,
|
||||
previousEntry.action,
|
||||
previousEntry.entryId,
|
||||
);
|
||||
setHistoryVersion((version) => version + 1);
|
||||
return { status: 'success', action: previousEntry.action };
|
||||
}, [getCanvasHistorySnapshot, restoreCanvasHistorySnapshot]);
|
||||
@@ -223,12 +268,17 @@ export function useCanvasHistory({
|
||||
undoStackRef.current = [
|
||||
...undoStackRef.current.slice(-(MAX_HISTORY_STEPS - 1)),
|
||||
{
|
||||
entryId: nextEntry.entryId,
|
||||
snapshot: currentSnapshot,
|
||||
action: nextEntry.action,
|
||||
createdAt: Date.now(),
|
||||
},
|
||||
];
|
||||
restoreCanvasHistorySnapshot(nextEntry.snapshot, nextEntry.action);
|
||||
restoreCanvasHistorySnapshot(
|
||||
nextEntry.snapshot,
|
||||
nextEntry.action,
|
||||
nextEntry.entryId,
|
||||
);
|
||||
setHistoryVersion((version) => version + 1);
|
||||
return { status: 'success', action: nextEntry.action };
|
||||
}, [getCanvasHistorySnapshot, restoreCanvasHistorySnapshot]);
|
||||
@@ -241,6 +291,7 @@ export function useCanvasHistory({
|
||||
restoreCanvasHistorySnapshot,
|
||||
captureCanvasHistory,
|
||||
discardCanvasHistoryEntriesContainingLayer,
|
||||
discardCanvasHistoryEntriesById,
|
||||
undoCanvasChange,
|
||||
redoCanvasChange,
|
||||
};
|
||||
|
||||
@@ -355,7 +355,11 @@ export function useImageCanvasLayerCommands({
|
||||
if (!targetIds.length) {
|
||||
return;
|
||||
}
|
||||
captureCanvasHistory({ type: 'change-asset-kind', count: targetIds.length });
|
||||
captureCanvasHistory({
|
||||
type: 'change-asset-kind',
|
||||
count: targetIds.length,
|
||||
layerIds: targetIds,
|
||||
});
|
||||
setLayers((currentLayers) =>
|
||||
updateCanvasLayersByIds(currentLayers, targetIds, updater),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user