保留并修复不兼容标签图层
前端按媒体族禁用不兼容标签,并在更新入口再次校验。 重载历史非法覆盖时保留图层、回退资源默认类型并显示提示。 只重写命中的布局项并自动保存清理后的覆盖,保留其他历史项。 补充标签矩阵、菜单限制及保存重载回归测试并同步架构决策文档。
This commit is contained in:
@@ -5982,6 +5982,8 @@
|
||||
- local 状态:`local-*` 只是 ID 形状,不能直接解释为“素材仍在保存”。新上传 / 新生成素材是否 pending 取资源登记在途状态;严格满足兼容谓词的历史自包含本地角色动作序列是持久化终态,不得误报等待。若当前版本尚不能复制这类序列,以准确原因失败关闭;既非 pending 又不满足历史谓词的 unresolved local 图层也失败关闭,但不得承诺稍后一定自动恢复。layout PATCH pending 不参与资源登记判断,系统剪贴板图片导入不受影响。
|
||||
- schema 与迁移:在现有 `EditorCanvasLayer` 结构体末尾追加 `#[default(None::<String>)] asset_kind_override: Option<String>`,不删除、改名、重排或改类型。legacy 图层类型与资源默认相同则迁移为 `None`,不同则迁移为 override;资源无默认值时只有全部引用图层显式同值才补资源默认,否则保留各自 override;自包含历史序列的显式类型迁入 override,不伪造资源。同步 `migration.rs`、表目录 / 数据契约、生成 bindings、HTTP DTO 与结构化 canonical hash,并运行 `npm run spacetime:generate`、`npm run check:spacetime-schema`。
|
||||
- 并发边界:未登记图层被禁止复制后,不再按临时资源 ID 合并项目资源创建请求,也不再用一次响应批量改写共享临时 ID。每个合法新增图层保留自己的响应快照与回调;layout PATCH 的串行 latest-wins 队列、共享资源的多布局引用和 session 资源快照按 `resourceId` 去重继续保留,它们与资源创建 single-flight 是不同机制。
|
||||
- 媒体兼容边界:override 只允许在资源默认类型的同一媒体族内变化。动作、视频各自独立成族,`audio/sound-effect/background-music` 同属音频族,其余类型与空默认值同属图片族。前端菜单禁用跨族标签且更新入口重复校验;后端对每个结构化图层按资源完整校验,跨族值清空后回退资源默认类型,不因该兼容错误拒绝整个保存。
|
||||
- 历史恢复:客户端读取到已持久化的跨族 override 时必须保留图层、回退资源默认类型、显示明确提示并自动提交清理后的布局;不得再用 `hydrateLayer() -> null -> filter(Boolean)` 静默隐藏持久层仍存在的图层。修复保存只替换命中图层的规范化布局项,其他尚不能 hydrate 的历史项原样保留,避免修复一个标签时顺带删除无关数据。
|
||||
|
||||
## 2026-08-03 Agent Runtime 原生工具合同本地失败关闭
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
- 中央画布支持背景拖拽平移、滚轮二维平移、`Ctrl / Cmd + 滚轮` 缩放、缩放百分比菜单、显示所有元素和固定比例缩放。
|
||||
- 画布左下角提供 Lovart 式状态控件:背景色圆点、素材 / 图层入口、小地图开关;小地图显示图层缩略分布和当前视口框,点击小地图执行显示所有元素。
|
||||
- 画布 chrome 的边框、hover / 选中态、吸附 / 框选参考线、生成类按钮和通用 active 控件使用陶泥儿暖色主题(以 `--platform-accent`、陶土橙主按钮和深棕文字为基准),不得回退为黑色或蓝色主题;元素类型自身的识别色可继续保留。
|
||||
- 画布中的图片可展示、悬浮显示图片 Resolution 尺寸与边框,点击后在图片上方显示浮动工具栏;浮动工具栏只保留当前可执行的编辑动作,不放调整 / 复制 / 删除 / 查看信息占位按钮。图片右上角素材类型标签、图片信息角标和悬浮尺寸标签在画布缩小时必须按 viewport 反向缩放,保持屏幕可读尺寸。素材类型采用双层语义:`editor_project_resource.asset_kind` 是跨布局共享的资源默认类型,`editor_canvas_layer.asset_kind_override` 是当前布局实例可空的覆盖值,显示与功能判断统一使用 `effectiveAssetKind = assetKindOverride ?? resource.assetKind`;两层都为空时显示 `未知`。点击标签只修改或清除当前图层的 `assetKindOverride`,不能创建新资源、替换 `resourceId` 或修改其他引用同一资源的图层;清除覆盖后立即恢复继承资源默认类型,也不能触发图层选择 / 拖拽事件。图片信息角标使用圆形 `i` 图标,不使用中括号或花括号样式。图片不再维护独立展示 `Size` 字段,画布显示宽高统一取 `originalWidth/originalHeight`(图片信息中的 `Resolution`)。
|
||||
- 画布中的图片可展示、悬浮显示图片 Resolution 尺寸与边框,点击后在图片上方显示浮动工具栏;浮动工具栏只保留当前可执行的编辑动作,不放调整 / 复制 / 删除 / 查看信息占位按钮。图片右上角素材类型标签、图片信息角标和悬浮尺寸标签在画布缩小时必须按 viewport 反向缩放,保持屏幕可读尺寸。素材类型采用双层语义:`editor_project_resource.asset_kind` 是跨布局共享的资源默认类型,`editor_canvas_layer.asset_kind_override` 是当前布局实例可空的覆盖值,显示与功能判断统一使用 `effectiveAssetKind = assetKindOverride ?? resource.assetKind`;两层都为空时显示 `未知`。点击标签只修改或清除当前图层的 `assetKindOverride`,不能创建新资源、替换 `resourceId` 或修改其他引用同一资源的图层;清除覆盖后立即恢复继承资源默认类型,也不能触发图层选择 / 拖拽事件。标签覆盖必须与资源默认类型属于同一媒体族:`character-animation` 独立为动作族,`video` 独立为视频族,`audio/sound-effect/background-music` 属于音频族,其余类型和空默认值属于图片族;标签菜单禁用跨族选项,更新回调再次校验。加载到历史跨族 override 时不得静默过滤图层,必须清除内存 override、按资源默认类型恢复图层、向用户提示并排队修复保存。图片信息角标使用圆形 `i` 图标,不使用中括号或花括号样式。图片不再维护独立展示 `Size` 字段,画布显示宽高统一取 `originalWidth/originalHeight`(图片信息中的 `Resolution`)。
|
||||
- 默认工具为选择模式;底部工具栏采用 AI 画布工作流工具组:选择、抓手、上传、生成图片、生成视频、生成音乐、生成规范、生成角色形象、生成图标素材、生成 UI 设计图。底部栏不再展示文字工具、形状标注工具和导出工具;上传与生成图片之间、生成音乐与生成规范之间各有一个半图标高度分割线。
|
||||
- 鼠标中键拖拽始终平移画布;长按 Space 临时进入抓手模式,松开后恢复原工具。
|
||||
- 图片拖拽时显示水平 / 垂直吸附参考线,吸附到其它图层、生成占位框或画板的边缘与中心线;当移动元素接近两个同轴元素形成的等距位置时,支持横向或纵向等距吸附。
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
CANVAS_WORLD_ORIGIN,
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
formatCanvasDisplayScalePercent,
|
||||
hydrateCanvasGenerationDialog,
|
||||
hydrateLayer,
|
||||
isCanvasAssetKindOverrideCompatible,
|
||||
normalizeAssetLibrary,
|
||||
normalizeCanvasBackgroundHex,
|
||||
resolveLayerResourceAssetKind,
|
||||
@@ -737,6 +738,64 @@ describe('ImageCanvasEditorModel', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back from an incompatible persisted override without dropping the layer', () => {
|
||||
const onAssetKindOverrideFallback = vi.fn();
|
||||
const hydrated = hydrateLayer(
|
||||
{
|
||||
layerId: 'layer-image-with-action-label',
|
||||
resourceId: 'resource-image',
|
||||
title: '普通图片',
|
||||
x: 10,
|
||||
y: 20,
|
||||
width: 320,
|
||||
height: 240,
|
||||
originalWidth: 320,
|
||||
originalHeight: 240,
|
||||
zIndex: 1,
|
||||
sourceType: 'uploaded',
|
||||
assetKindOverride: 'character-animation',
|
||||
},
|
||||
new Map([
|
||||
['resource-image', { imageSrc: '/read/image.png', assetKind: 'image' }],
|
||||
]),
|
||||
{ onAssetKindOverrideFallback },
|
||||
);
|
||||
|
||||
expect(hydrated).toMatchObject({
|
||||
id: 'layer-image-with-action-label',
|
||||
src: '/read/image.png',
|
||||
mediaType: 'image',
|
||||
resourceAssetKind: 'image',
|
||||
assetKindOverride: null,
|
||||
assetKind: 'image',
|
||||
});
|
||||
expect(onAssetKindOverrideFallback).toHaveBeenCalledWith({
|
||||
layerId: 'layer-image-with-action-label',
|
||||
resourceId: 'resource-image',
|
||||
resourceAssetKind: 'image',
|
||||
rejectedAssetKindOverride: 'character-animation',
|
||||
});
|
||||
});
|
||||
|
||||
it('uses the same media-family compatibility matrix for layer labels', () => {
|
||||
expect(isCanvasAssetKindOverrideCompatible('image', 'character')).toBe(
|
||||
true,
|
||||
);
|
||||
expect(isCanvasAssetKindOverrideCompatible(null, 'icon')).toBe(true);
|
||||
expect(
|
||||
isCanvasAssetKindOverrideCompatible('sound-effect', 'background-music'),
|
||||
).toBe(true);
|
||||
expect(isCanvasAssetKindOverrideCompatible('image', 'video')).toBe(false);
|
||||
expect(
|
||||
isCanvasAssetKindOverrideCompatible('image', 'character-animation'),
|
||||
).toBe(false);
|
||||
expect(isCanvasAssetKindOverrideCompatible('video', 'character')).toBe(
|
||||
false,
|
||||
);
|
||||
expect(isCanvasAssetKindOverrideCompatible('audio', 'icon')).toBe(false);
|
||||
expect(isCanvasAssetKindOverrideCompatible('video', null)).toBe(true);
|
||||
});
|
||||
|
||||
it('hydrates audio display duration only from resource generation inputs', () => {
|
||||
const hydrated = hydrateLayer(
|
||||
{
|
||||
|
||||
@@ -729,6 +729,11 @@ export function hydrateCanvasGenerationDialog(
|
||||
export function hydrateLayer(
|
||||
snapshot: EditorProjectLayerSnapshot,
|
||||
resourcesById: Map<string, CanvasLayerResourceMetadata>,
|
||||
options: {
|
||||
onAssetKindOverrideFallback?: (
|
||||
issue: CanvasAssetKindOverrideFallbackIssue,
|
||||
) => void;
|
||||
} = {},
|
||||
): CanvasLayer | null {
|
||||
const resourceId =
|
||||
typeof snapshot.resourceId === 'string' ? snapshot.resourceId : '';
|
||||
@@ -742,12 +747,25 @@ export function hydrateLayer(
|
||||
resource?.imageSequenceFrames,
|
||||
);
|
||||
const resourceAssetKind = canvasAssetKindOrNull(resource?.assetKind);
|
||||
const assetKindOverride = Object.prototype.hasOwnProperty.call(
|
||||
const hasPersistedAssetKindOverride = Object.prototype.hasOwnProperty.call(
|
||||
snapshot,
|
||||
'assetKindOverride',
|
||||
)
|
||||
);
|
||||
const persistedAssetKindOverride = hasPersistedAssetKindOverride
|
||||
? canvasAssetKindOrNull(snapshot.assetKindOverride)
|
||||
: canvasAssetKindOrNull(snapshot.assetKind);
|
||||
const assetKindOverrideFellBack = Boolean(
|
||||
resource &&
|
||||
hasPersistedAssetKindOverride &&
|
||||
persistedAssetKindOverride &&
|
||||
!isCanvasAssetKindOverrideCompatible(
|
||||
resourceAssetKind,
|
||||
persistedAssetKindOverride,
|
||||
),
|
||||
);
|
||||
const assetKindOverride = assetKindOverrideFellBack
|
||||
? null
|
||||
: persistedAssetKindOverride;
|
||||
const assetKind = assetKindOverride ?? resourceAssetKind;
|
||||
const isSelfContainedLocalResource =
|
||||
!resource && isSelfContainedLegacyLocalImageSequence(snapshot);
|
||||
@@ -788,6 +806,14 @@ export function hydrateLayer(
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
if (assetKindOverrideFellBack && persistedAssetKindOverride) {
|
||||
options.onAssetKindOverrideFallback?.({
|
||||
layerId,
|
||||
resourceId,
|
||||
resourceAssetKind,
|
||||
rejectedAssetKindOverride: persistedAssetKindOverride,
|
||||
});
|
||||
}
|
||||
let resourcePersistenceState: CanvasLayer['resourcePersistenceState'];
|
||||
if (resource) {
|
||||
resourcePersistenceState = 'registered';
|
||||
@@ -1215,6 +1241,46 @@ export type CanvasLayerResourceMetadata = {
|
||||
updatedAt?: string | null;
|
||||
};
|
||||
|
||||
export type CanvasAssetKindOverrideFallbackIssue = {
|
||||
layerId: string;
|
||||
resourceId: string;
|
||||
resourceAssetKind: CanvasAssetKind | null;
|
||||
rejectedAssetKindOverride: CanvasAssetKind;
|
||||
};
|
||||
|
||||
type CanvasAssetMediaFamily =
|
||||
'image' | 'character-animation' | 'video' | 'audio';
|
||||
|
||||
function resolveCanvasAssetMediaFamily(
|
||||
assetKind: CanvasAssetKind | null | undefined,
|
||||
): CanvasAssetMediaFamily {
|
||||
if (assetKind === 'character-animation') {
|
||||
return 'character-animation';
|
||||
}
|
||||
if (assetKind === 'video') {
|
||||
return 'video';
|
||||
}
|
||||
if (
|
||||
assetKind === 'audio' ||
|
||||
assetKind === 'sound-effect' ||
|
||||
assetKind === 'background-music'
|
||||
) {
|
||||
return 'audio';
|
||||
}
|
||||
return 'image';
|
||||
}
|
||||
|
||||
export function isCanvasAssetKindOverrideCompatible(
|
||||
resourceAssetKind: CanvasAssetKind | null | undefined,
|
||||
assetKindOverride: CanvasAssetKind | null,
|
||||
) {
|
||||
return (
|
||||
assetKindOverride === null ||
|
||||
resolveCanvasAssetMediaFamily(resourceAssetKind) ===
|
||||
resolveCanvasAssetMediaFamily(assetKindOverride)
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveLayerResourceAssetKind(
|
||||
layer: CanvasLayer,
|
||||
): CanvasAssetKind | null {
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
canvasAssetKindOrNull,
|
||||
DEFAULT_CANVAS_BACKGROUND_COLOR,
|
||||
generationInputsOrNull,
|
||||
isCanvasAssetKindOverrideCompatible,
|
||||
isInlineEditorMediaSource,
|
||||
resolveContextMenuPosition,
|
||||
resolveLayerResourceAssetKind,
|
||||
@@ -1206,6 +1207,7 @@ export function ImageCanvasEditorView({
|
||||
const {
|
||||
projectId,
|
||||
isProjectReady,
|
||||
assetKindFallbackNotice,
|
||||
appendCanvasLayersWithResources,
|
||||
applyProjectSnapshot,
|
||||
flushProjectPersistence,
|
||||
@@ -1423,6 +1425,11 @@ export function ImageCanvasEditorView({
|
||||
generationSurface.refreshTaskList();
|
||||
}, [generationSurface]);
|
||||
const showGenerationWarning = generationSurface.showGenerationWarning;
|
||||
useEffect(() => {
|
||||
if (assetKindFallbackNotice) {
|
||||
showGenerationWarning(assetKindFallbackNotice.message);
|
||||
}
|
||||
}, [assetKindFallbackNotice, showGenerationWarning]);
|
||||
const handleExternalGenerationTasksCompleted = useCallback(
|
||||
(tasks: ExternalGenerationTaskRecord[]) => {
|
||||
if (!projectId || tasks.length === 0) {
|
||||
@@ -1878,12 +1885,16 @@ export function ImageCanvasEditorView({
|
||||
if (!targetLayer || targetLayer.assetKind === assetKind) {
|
||||
return;
|
||||
}
|
||||
const resourceAssetKind = resolveLayerResourceAssetKind(targetLayer);
|
||||
if (!isCanvasAssetKindOverrideCompatible(resourceAssetKind, assetKind)) {
|
||||
showGenerationWarning('该标签与素材的媒体类型不兼容,已保留原标签。');
|
||||
return;
|
||||
}
|
||||
captureCanvasHistory({
|
||||
type: 'change-asset-kind',
|
||||
count: 1,
|
||||
layerIds: [layerId],
|
||||
});
|
||||
const resourceAssetKind = resolveLayerResourceAssetKind(targetLayer);
|
||||
const assetKindOverride =
|
||||
assetKind === resourceAssetKind ? null : assetKind;
|
||||
const nextLayer = {
|
||||
@@ -1905,7 +1916,7 @@ export function ImageCanvasEditorView({
|
||||
setImageContextMenu(null);
|
||||
setContextMenu(null);
|
||||
},
|
||||
[captureCanvasHistory],
|
||||
[captureCanvasHistory, showGenerationWarning],
|
||||
);
|
||||
const selectAllCanvasObjects = useCallback(() => {
|
||||
const layerSelectionIds = layersRef.current
|
||||
|
||||
@@ -1067,6 +1067,28 @@ describe('ImageCanvasWorldView', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('disables labels from other media families', () => {
|
||||
const imageLayer = createLayer({
|
||||
resourceAssetKind: 'image',
|
||||
assetKind: 'character',
|
||||
});
|
||||
renderWorldView({ layers: [imageLayer] });
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '素材标签:角色' }));
|
||||
|
||||
const menu = screen.getByRole('menu', { name: '选择素材标签' });
|
||||
expect(
|
||||
within(menu)
|
||||
.getByRole('menuitem', { name: '图标' })
|
||||
.hasAttribute('disabled'),
|
||||
).toBe(false);
|
||||
for (const label of ['动作', '视频', '音效', '背景音乐']) {
|
||||
const item = within(menu).getByRole('menuitem', { name: label });
|
||||
expect(item.hasAttribute('disabled')).toBe(true);
|
||||
expect(item.title).toBe('该标签与素材的媒体类型不兼容');
|
||||
}
|
||||
});
|
||||
|
||||
it('hides resolution and duration text for focused audio generation frames', () => {
|
||||
const dialog = createGenerationDialog({
|
||||
id: 'dialog-audio',
|
||||
|
||||
@@ -31,7 +31,11 @@ import {
|
||||
import { PlatformIconButton } from '../common/PlatformIconButton';
|
||||
import { PlatformPillBadge } from '../common/PlatformPillBadge';
|
||||
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
|
||||
import { CANVAS_WORLD_SIZE } from './ImageCanvasEditorModel';
|
||||
import {
|
||||
CANVAS_WORLD_SIZE,
|
||||
isCanvasAssetKindOverrideCompatible,
|
||||
resolveLayerResourceAssetKind,
|
||||
} from './ImageCanvasEditorModel';
|
||||
import type {
|
||||
CanvasAssetKind,
|
||||
CanvasGenerationDialogState,
|
||||
@@ -1125,23 +1129,35 @@ export function ImageCanvasWorldView({
|
||||
onWheelCapture={(event) => event.stopPropagation()}
|
||||
onWheel={(event) => event.stopPropagation()}
|
||||
>
|
||||
{CANVAS_ASSET_KIND_TAG_OPTIONS.map((option) => (
|
||||
<PlatformFloatingMenuItem
|
||||
key={option.value ?? 'unknown'}
|
||||
className={
|
||||
option.value === (layer.assetKind ?? null)
|
||||
? 'image-canvas-editor__kind-menu-item--active'
|
||||
: undefined
|
||||
}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onUpdateLayerAssetKind(layer.id, option.value);
|
||||
setOpenLayerKindMenuId(null);
|
||||
}}
|
||||
>
|
||||
{option.label}
|
||||
</PlatformFloatingMenuItem>
|
||||
))}
|
||||
{CANVAS_ASSET_KIND_TAG_OPTIONS.map((option) => {
|
||||
const compatible = isCanvasAssetKindOverrideCompatible(
|
||||
resolveLayerResourceAssetKind(layer),
|
||||
option.value,
|
||||
);
|
||||
return (
|
||||
<PlatformFloatingMenuItem
|
||||
key={option.value ?? 'unknown'}
|
||||
className={
|
||||
option.value === (layer.assetKind ?? null)
|
||||
? 'image-canvas-editor__kind-menu-item--active'
|
||||
: undefined
|
||||
}
|
||||
disabled={!compatible}
|
||||
title={
|
||||
compatible
|
||||
? undefined
|
||||
: '该标签与素材的媒体类型不兼容'
|
||||
}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onUpdateLayerAssetKind(layer.id, option.value);
|
||||
setOpenLayerKindMenuId(null);
|
||||
}}
|
||||
>
|
||||
{option.label}
|
||||
</PlatformFloatingMenuItem>
|
||||
);
|
||||
})}
|
||||
</PlatformFloatingMenu>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -468,6 +468,9 @@ function ProjectPersistenceHarness({
|
||||
.join(',')}
|
||||
</span>
|
||||
<span data-testid="background">{canvasBackgroundColor}</span>
|
||||
<span data-testid="asset-kind-fallback-notice">
|
||||
{persistence.assetKindFallbackNotice?.message ?? '-'}
|
||||
</span>
|
||||
<span data-testid="flush-completed">{String(flushCompleted)}</span>
|
||||
<button
|
||||
type="button"
|
||||
@@ -822,6 +825,78 @@ describe('useImageCanvasProjectPersistence', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps and repairs a layer whose persisted override crosses media families', async () => {
|
||||
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
|
||||
projectId: 'editor-project-default',
|
||||
title: '历史坏标签画布',
|
||||
canvas: {
|
||||
canvasId: 'editor-project-default:canvas:default',
|
||||
projectId: 'editor-project-default',
|
||||
title: '默认画布',
|
||||
viewport: { x: 0, y: 0, scale: 1 },
|
||||
layers: [],
|
||||
revision: 7,
|
||||
layoutStorageVersion: 0,
|
||||
updatedAt: '2026-08-05T00:00:00.000Z',
|
||||
},
|
||||
viewport: { x: 0, y: 0, scale: 1 },
|
||||
layers: [
|
||||
{
|
||||
layerId: 'layer-image',
|
||||
resourceId: 'resource-image',
|
||||
title: '普通图片',
|
||||
x: 10,
|
||||
y: 20,
|
||||
width: 320,
|
||||
height: 240,
|
||||
originalWidth: 320,
|
||||
originalHeight: 240,
|
||||
zIndex: 1,
|
||||
sourceType: 'uploaded',
|
||||
assetKindOverride: 'character-animation',
|
||||
},
|
||||
],
|
||||
resources: [
|
||||
{
|
||||
resourceId: 'resource-image',
|
||||
projectId: 'editor-project-default',
|
||||
imageSrc: '/read/image.png',
|
||||
width: 320,
|
||||
height: 240,
|
||||
sourceType: 'uploaded',
|
||||
assetKind: 'image',
|
||||
},
|
||||
],
|
||||
updatedAt: '2026-08-05T00:00:00.000Z',
|
||||
});
|
||||
|
||||
render(<ProjectPersistenceHarness />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('layers').textContent).toContain(
|
||||
'layer-image:resource-image:image',
|
||||
);
|
||||
});
|
||||
expect(
|
||||
screen.getByTestId('asset-kind-fallback-notice').textContent,
|
||||
).toContain('已保留图层并恢复为素材默认标签');
|
||||
await waitFor(() => {
|
||||
expect(saveEditorProjectLayoutMock).toHaveBeenCalledWith(
|
||||
'editor-project-default',
|
||||
expect.objectContaining({
|
||||
expectedRevision: 7,
|
||||
layers: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
layerId: 'layer-image',
|
||||
resourceId: 'resource-image',
|
||||
assetKindOverride: null,
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('saves appended layers with the server resource id immediately after resource creation', async () => {
|
||||
render(<ProjectPersistenceHarness />);
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
isInlineEditorMediaSource,
|
||||
resolveLayerResourceAssetKind,
|
||||
serializeCanvasLayout,
|
||||
serializeLayer,
|
||||
splitCanvasLayoutItems,
|
||||
viewportToCanvasDisplayViewport,
|
||||
} from './ImageCanvasEditorModel';
|
||||
@@ -552,6 +553,11 @@ export function useImageCanvasProjectPersistence({
|
||||
const coverSnapshotViewportSizeRef = useRef(canvasSize);
|
||||
const [projectId, setProjectId] = useState<string | null>(null);
|
||||
const [isProjectReady, setIsProjectReady] = useState(false);
|
||||
const [assetKindFallbackNotice, setAssetKindFallbackNotice] = useState<{
|
||||
id: number;
|
||||
message: string;
|
||||
} | null>(null);
|
||||
const assetKindFallbackNoticeIdRef = useRef(0);
|
||||
const {
|
||||
setProjectTitle,
|
||||
setProjectRenameValue,
|
||||
@@ -1292,9 +1298,32 @@ export function useImageCanvasProjectPersistence({
|
||||
resourcesById,
|
||||
currentUserId,
|
||||
);
|
||||
const assetKindFallbackLayerIds = new Set<string>();
|
||||
const hydratedLayers = layerItems
|
||||
.map((layer) => hydrateLayer(layer, resourcesById))
|
||||
.map((layer) =>
|
||||
hydrateLayer(layer, resourcesById, {
|
||||
onAssetKindOverrideFallback: (issue) => {
|
||||
assetKindFallbackLayerIds.add(issue.layerId);
|
||||
},
|
||||
}),
|
||||
)
|
||||
.filter((layer): layer is CanvasLayer => Boolean(layer));
|
||||
const hydratedLayersById = new Map(
|
||||
hydratedLayers.map((layer) => [layer.id, layer]),
|
||||
);
|
||||
const repairedLayoutItems = assetKindFallbackLayerIds.size
|
||||
? appliedLayoutItems.map((item) => {
|
||||
const layerId = canvasLayoutItemId(item);
|
||||
const hydratedLayer = layerId
|
||||
? hydratedLayersById.get(layerId)
|
||||
: undefined;
|
||||
return layerId &&
|
||||
assetKindFallbackLayerIds.has(layerId) &&
|
||||
hydratedLayer
|
||||
? serializeLayer(hydratedLayer)
|
||||
: item;
|
||||
})
|
||||
: appliedLayoutItems;
|
||||
setLayerCounter(hydratedLayers.length);
|
||||
refs.layersRef.current = hydratedLayers;
|
||||
setLayers(hydratedLayers);
|
||||
@@ -1332,21 +1361,28 @@ export function useImageCanvasProjectPersistence({
|
||||
hasAuthoritativeProjectSnapshotRef.current &&
|
||||
authoritativeProjectIdRef.current === project.projectId;
|
||||
if (projectIsAuthoritative) {
|
||||
if (assetKindFallbackLayerIds.size) {
|
||||
assetKindFallbackNoticeIdRef.current += 1;
|
||||
setAssetKindFallbackNotice({
|
||||
id: assetKindFallbackNoticeIdRef.current,
|
||||
message: `检测到 ${assetKindFallbackLayerIds.size} 个图层的素材标签与媒体类型不兼容,已保留图层并恢复为素材默认标签。`,
|
||||
});
|
||||
}
|
||||
writeEditorProjectSessionCache(
|
||||
pendingLocalLayout
|
||||
pendingLocalLayout || assetKindFallbackLayerIds.size
|
||||
? {
|
||||
...project,
|
||||
viewport: appliedViewport,
|
||||
layers: appliedLayoutItems,
|
||||
layers: repairedLayoutItems,
|
||||
}
|
||||
: project,
|
||||
currentUserId,
|
||||
project.canvas?.revision,
|
||||
);
|
||||
if (pendingLocalLayout) {
|
||||
if (pendingLocalLayout || assetKindFallbackLayerIds.size) {
|
||||
queueProjectLayoutSave(project.projectId, {
|
||||
viewport: appliedViewport,
|
||||
layers: appliedLayoutItems,
|
||||
layers: repairedLayoutItems,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1550,6 +1586,7 @@ export function useImageCanvasProjectPersistence({
|
||||
return {
|
||||
projectId,
|
||||
isProjectReady,
|
||||
assetKindFallbackNotice,
|
||||
projectIdRef,
|
||||
createProjectResourceForLayer,
|
||||
appendCanvasLayersWithResources,
|
||||
|
||||
Reference in New Issue
Block a user