4fb0de2c94
- resourceBookController 重写为真实元素 FLIP:begin 在提交前记录 First,play 测 Last 后给同一节点加反向 transform,sync 按布局签名做可中断重基 - 转场进行中重基用剩余时长(下限 80ms),空闲布局变化用完整 420ms,预览图尺寸晚到的二次布局变化不再瞬移 - 宿主节点几何基准改存 world 局部坐标,平移和缩放不会让上一次的干净矩形过期 - 不可见节点钉在 First 淡出、挂载节点淡入;搜索/排序导致的卸载保留一帧淡出;返回总览时源栏目未回到摞上的卡片按子画布布局淡出 - 新增 buildResourceBookScenePlan 渲染计划,渲染、转场签名与淡出集合共用同一份数据 - 删除 ResourceBookTransitionLayer 与克隆快照逻辑,清理 styles.css 的转场层和 data-book-motion 隐藏规则 - 重写 resourceBookController.test.ts,补 resourceBookLayout/resourceBookModel 用例与 appSurface 同节点断言 - 同步技术方案转场段与 pitfalls 经验条目
331 lines
10 KiB
TypeScript
331 lines
10 KiB
TypeScript
import type {
|
|
ResourceBookCardPresentation,
|
|
ResourceBookCategory,
|
|
ResourceBookState,
|
|
} from './resourceBookModel';
|
|
import {
|
|
resourceBookCategoryCardPresentation,
|
|
resourceBookShowsExitingCards,
|
|
} from './resourceBookModel';
|
|
import {
|
|
RESOURCE_CANVAS_CARD_HEIGHT,
|
|
RESOURCE_CANVAS_CARD_WIDTH,
|
|
type ResourceCanvasCardSize,
|
|
} from './resourceCanvasLayoutModel';
|
|
import type { ProjectResource } from './resourceProjectionModel';
|
|
import { projectResourceTypeLabel } from './resourceProjectionModel';
|
|
|
|
export type ResourceBookOverviewRect = {
|
|
left: number;
|
|
top: number;
|
|
width: number;
|
|
height: number;
|
|
};
|
|
|
|
export type ResourceBookStack = [type: string, items: ProjectResource[]];
|
|
|
|
export type ResourceBookCardLayout = {
|
|
x: number;
|
|
y: number;
|
|
width: number;
|
|
height: number;
|
|
dragX: number;
|
|
dragY: number;
|
|
rotation: number;
|
|
};
|
|
|
|
// 总览每个资源类型最多铺这么多张卡片;其余数量由标题栏的“N 项”表达。
|
|
// 叠放角度同样只取这几个值,避免素材变多后旋转角随下标无限增大。
|
|
const overviewStackRotations = [0.5, -1.5, 2.5] as const;
|
|
|
|
export const RESOURCE_BOOK_OVERVIEW_STACK_LIMIT = overviewStackRotations.length;
|
|
|
|
export function groupResourceBookStacks(
|
|
resources: readonly ProjectResource[],
|
|
): ResourceBookStack[] {
|
|
const groups = new Map<string, ProjectResource[]>();
|
|
for (const resource of resources) {
|
|
const items = groups.get(projectResourceTypeLabel(resource)) ?? [];
|
|
items.push(resource);
|
|
groups.set(projectResourceTypeLabel(resource), items);
|
|
}
|
|
return Array.from(
|
|
groups,
|
|
([type, items]) => [type, items] as ResourceBookStack,
|
|
);
|
|
}
|
|
|
|
export function groupResourceBookResourcesByCategory(
|
|
resources: readonly ProjectResource[],
|
|
) {
|
|
const result = new Map<ResourceBookCategory, ProjectResource[]>();
|
|
for (const resource of resources) {
|
|
const items = result.get(resource.category) ?? [];
|
|
items.push(resource);
|
|
result.set(resource.category, items);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* 总览摞只铺有限张卡。挑哪几张决定了进入子画布时的观感:若按数组顺序取,
|
|
* 用户看到的卡和入场视口里最近的卡不是同一批,FLIP 找不到对应终点,动画会
|
|
* 退化成淡入淡出、返回时还会跳成另一张图。这里改为取离当前视图中心最近的卡。
|
|
*/
|
|
export function selectResourceBookOverviewCards({
|
|
resources,
|
|
visibleResourceIds,
|
|
positions,
|
|
cardSizes,
|
|
viewCenter,
|
|
limit = RESOURCE_BOOK_OVERVIEW_STACK_LIMIT,
|
|
}: {
|
|
resources: readonly ProjectResource[];
|
|
visibleResourceIds: ReadonlySet<string>;
|
|
positions: ReadonlyMap<string, { x: number; y: number }>;
|
|
cardSizes: ReadonlyMap<string, ResourceCanvasCardSize>;
|
|
viewCenter: { x: number; y: number } | undefined;
|
|
limit?: number;
|
|
}): ProjectResource[] {
|
|
const visible = resources.filter((resource) =>
|
|
visibleResourceIds.has(resource.id),
|
|
);
|
|
if (!viewCenter || visible.length <= limit) {
|
|
return visible.slice(0, limit);
|
|
}
|
|
const distance = (resource: ProjectResource) => {
|
|
const position = positions.get(resource.id);
|
|
if (!position) return Number.POSITIVE_INFINITY;
|
|
const size = cardSizes.get(resource.id) ?? {
|
|
width: RESOURCE_CANVAS_CARD_WIDTH,
|
|
height: RESOURCE_CANVAS_CARD_HEIGHT,
|
|
};
|
|
const centerX = position.x + size.width / 2;
|
|
const centerY = position.y + size.height / 2;
|
|
return (centerX - viewCenter.x) ** 2 + (centerY - viewCenter.y) ** 2;
|
|
};
|
|
return visible
|
|
.map((resource, index) => ({
|
|
resource,
|
|
index,
|
|
distance: distance(resource),
|
|
}))
|
|
.sort(
|
|
(left, right) =>
|
|
left.distance - right.distance || left.index - right.index,
|
|
)
|
|
.slice(0, limit)
|
|
.map((entry) => entry.resource);
|
|
}
|
|
|
|
export function resourceBookOverviewCardLayout({
|
|
rect,
|
|
stackIndex,
|
|
stackColumn,
|
|
}: {
|
|
rect: ResourceBookOverviewRect | undefined;
|
|
stackIndex: number;
|
|
stackColumn: number;
|
|
}): ResourceBookCardLayout {
|
|
const rotationIndex = Math.min(
|
|
Math.max(stackIndex, 0),
|
|
overviewStackRotations.length - 1,
|
|
);
|
|
return {
|
|
x: (rect?.left ?? 0) + 18 + (stackColumn % 3) * 102,
|
|
y: (rect?.top ?? 0) + 58 + Math.min(stackIndex, 2) * 5,
|
|
width: 92,
|
|
height: 64,
|
|
dragX: 0,
|
|
dragY: 0,
|
|
rotation: overviewStackRotations[rotationIndex] ?? 0,
|
|
};
|
|
}
|
|
|
|
export function resourceBookChildCardLayout({
|
|
position,
|
|
size,
|
|
}: {
|
|
position: { x: number; y: number } | undefined;
|
|
size: ResourceCanvasCardSize;
|
|
}): ResourceBookCardLayout {
|
|
const x = position?.x ?? 0;
|
|
const y = position?.y ?? 0;
|
|
// The child scene applies the viewport transform on its card-world wrapper.
|
|
// Keep card geometry logical here so persistence, drag callbacks, and the
|
|
// public ResourceCard style contract are not polluted by screen space.
|
|
return {
|
|
x,
|
|
y,
|
|
width: size.width,
|
|
height: size.height,
|
|
dragX: x,
|
|
dragY: y,
|
|
rotation: 0,
|
|
};
|
|
}
|
|
|
|
const RESOURCE_BOOK_CARD_FALLBACK_SIZE: ResourceCanvasCardSize = {
|
|
width: 180,
|
|
height: 128,
|
|
};
|
|
|
|
export function resourceBookSceneCardKey(resourceId: string) {
|
|
return `card:${resourceId}`;
|
|
}
|
|
|
|
export function resourceBookSceneTitleKey(category: ResourceBookCategory) {
|
|
return `title:${category}`;
|
|
}
|
|
|
|
export type ResourceBookSceneCardPlan = {
|
|
key: string;
|
|
category: ResourceBookCategory;
|
|
resource: ProjectResource;
|
|
presentation: ResourceBookCardPresentation;
|
|
stackIndex: number;
|
|
stackColumn: number;
|
|
/** 同一类型摞里的卡片总数,用于总览态 z-index。 */
|
|
stackCount: number;
|
|
layout: ResourceBookCardLayout;
|
|
};
|
|
|
|
export type ResourceBookSceneCategoryPlan = {
|
|
category: ResourceBookCategory;
|
|
resourceCount: number;
|
|
titlebarActive: boolean;
|
|
titlebarOpacity: number;
|
|
cards: ResourceBookSceneCardPlan[];
|
|
};
|
|
|
|
/**
|
|
* 画本场景渲染计划。渲染、转场签名和淡出集合共用这一份计划,避免"渲染用一套、
|
|
* 测量用另一套"的错位:
|
|
* - 总览/返回态全部用总览摞布局;
|
|
* - 进入栏目时当前栏目换成子画布布局,其他栏目留在 DOM 里按总览布局淡出;
|
|
* - 返回总览时源栏目未回到摞上的卡片按子画布布局淡出;
|
|
* - 搜索/排序导致的卸载由 `exitingCards` 保留一帧,用同一套淡出撤下。
|
|
*/
|
|
export function buildResourceBookScenePlan({
|
|
state,
|
|
visibleCategoryOrder,
|
|
resourcesByCategory,
|
|
overviewRects,
|
|
positions,
|
|
cardSizes,
|
|
visibleResourceIds,
|
|
categoryViewCenters,
|
|
cardsReady,
|
|
exitingCategory = null,
|
|
exitingCards = [],
|
|
}: {
|
|
state: ResourceBookState;
|
|
visibleCategoryOrder: readonly ResourceBookCategory[];
|
|
resourcesByCategory: ReadonlyMap<ResourceBookCategory, ProjectResource[]>;
|
|
overviewRects: ReadonlyMap<ResourceBookCategory, ResourceBookOverviewRect>;
|
|
positions: ReadonlyMap<string, { x: number; y: number }>;
|
|
cardSizes: ReadonlyMap<string, ResourceCanvasCardSize>;
|
|
visibleResourceIds: ReadonlySet<string>;
|
|
categoryViewCenters: ReadonlyMap<
|
|
ResourceBookCategory,
|
|
{ x: number; y: number }
|
|
>;
|
|
cardsReady: boolean;
|
|
exitingCategory?: ResourceBookCategory | null;
|
|
exitingCards?: readonly ResourceBookSceneCardPlan[];
|
|
}): ResourceBookSceneCategoryPlan[] {
|
|
return visibleCategoryOrder.map((category) => {
|
|
const presentation = resourceBookCategoryCardPresentation(state, category);
|
|
const rect = overviewRects.get(category);
|
|
const cards: ResourceBookSceneCardPlan[] = [];
|
|
const categoryResources = resourcesByCategory.get(category) ?? [];
|
|
const stacks = groupResourceBookStacks(categoryResources);
|
|
const childLayout = presentation === 'child';
|
|
if (cardsReady && presentation) {
|
|
stacks.forEach(([, items], stackColumn) => {
|
|
// 总览只铺有限张,数量由栏目标题栏的“N 项”表达;子画布渲染全部资源。
|
|
const stackItems = childLayout
|
|
? items
|
|
: selectResourceBookOverviewCards({
|
|
resources: items,
|
|
visibleResourceIds,
|
|
positions,
|
|
cardSizes,
|
|
viewCenter: categoryViewCenters.get(category),
|
|
});
|
|
stackItems.forEach((resource, stackIndex) => {
|
|
cards.push({
|
|
key: resourceBookSceneCardKey(resource.id),
|
|
category,
|
|
resource,
|
|
presentation: childLayout ? 'child' : 'overview',
|
|
stackIndex,
|
|
stackColumn,
|
|
stackCount: stackItems.length,
|
|
layout: childLayout
|
|
? resourceBookChildCardLayout({
|
|
position: positions.get(resource.id),
|
|
size:
|
|
cardSizes.get(resource.id) ??
|
|
RESOURCE_BOOK_CARD_FALLBACK_SIZE,
|
|
})
|
|
: resourceBookOverviewCardLayout({
|
|
rect,
|
|
stackIndex,
|
|
stackColumn,
|
|
}),
|
|
});
|
|
});
|
|
});
|
|
}
|
|
if (
|
|
resourceBookShowsExitingCards(state) &&
|
|
exitingCategory === category &&
|
|
cardsReady
|
|
) {
|
|
const onStack = new Set(
|
|
cards
|
|
.filter((card) => card.presentation === 'overview')
|
|
.map((card) => card.key),
|
|
);
|
|
categoryResources.forEach((resource) => {
|
|
const key = resourceBookSceneCardKey(resource.id);
|
|
if (onStack.has(key)) return;
|
|
if (!visibleResourceIds.has(resource.id)) return;
|
|
cards.push({
|
|
key,
|
|
category,
|
|
resource,
|
|
presentation: 'exiting',
|
|
stackIndex: 0,
|
|
stackColumn: 0,
|
|
stackCount: 1,
|
|
layout: resourceBookChildCardLayout({
|
|
position: positions.get(resource.id),
|
|
size:
|
|
cardSizes.get(resource.id) ?? RESOURCE_BOOK_CARD_FALLBACK_SIZE,
|
|
}),
|
|
});
|
|
});
|
|
}
|
|
exitingCards.forEach((card) => {
|
|
if (card.category !== category) return;
|
|
if (cards.some((entry) => entry.key === card.key)) return;
|
|
cards.push({ ...card, presentation: 'exiting' });
|
|
});
|
|
return {
|
|
category,
|
|
resourceCount: categoryResources.length,
|
|
titlebarActive: presentation === 'child',
|
|
titlebarOpacity: childLayout || state.view === 'main' ? 1 : 0,
|
|
cards,
|
|
};
|
|
});
|
|
}
|
|
|
|
export function resourceBookSceneCardLayoutSignature(
|
|
card: ResourceBookSceneCardPlan,
|
|
) {
|
|
return `${card.presentation}:${card.layout.x},${card.layout.y}:${card.layout.width},${card.layout.height}:${card.layout.rotation}`;
|
|
}
|