合并 C3:资源画本转场改为真实元素 FLIP

- 合入 feat/agc-canvas-real-flip(resourceBookController 重写为同节点 FLIP、新增 sync 可中断重基、删除 ResourceBookTransitionLayer)
- 冲突解决:index.tsx 的 resourceBookLayout 导入取 C3 版(buildResourceBookScenePlan 等),去掉已被收进内部的旧 helper 导入避免未使用
- 冲突解决:资源自由画板文档的验证命令合并两侧并去掉已删除的 assetCanvasSurface.test.tsx
This commit is contained in:
2026-09-09 21:24:13 +08:00
12 changed files with 1649 additions and 921 deletions
+4 -13
View File
@@ -5619,19 +5619,6 @@ iframe.preview-frame {
transition: none;
}
.game-resource-book-transition-layer {
position: absolute;
inset: 0;
z-index: 35;
overflow: hidden;
pointer-events: none;
}
.game-resource-book-manager[data-book-motion='running']
.game-resource-book-scene-world {
opacity: 0;
}
.game-resource-book-scene--child {
pointer-events: auto;
}
@@ -5721,6 +5708,8 @@ iframe.preview-frame {
box-shadow: 0 8px 18px rgb(112 70 52 / 8%);
pointer-events: none;
transform: translateY(0);
/* FLIP 反向 transform 围绕节点自身原点缩放。 */
transform-origin: 0 0;
transition: none;
}
@@ -5739,6 +5728,8 @@ iframe.preview-frame {
position: absolute;
z-index: 25;
pointer-events: none;
/* 卡片包装节点是 0x0FLIP 反向 transform 围绕它的原点缩放。 */
transform-origin: 0 0;
translate: 0 0;
transition: none;
}
@@ -1,9 +0,0 @@
export function ResourceBookTransitionLayer() {
return (
<div
className="game-resource-book-transition-layer"
aria-hidden="true"
inert
/>
);
}
@@ -75,22 +75,19 @@ import {
type ResourceBookTransitionController,
} from './resourceBookController';
import {
groupResourceBookResourcesByCategory,
groupResourceBookStacks,
resourceBookChildCardLayout,
resourceBookOverviewCardLayout,
buildResourceBookScenePlan,
type ResourceBookOverviewRect,
selectResourceBookOverviewCards,
resourceBookSceneCardKey,
resourceBookSceneCardLayoutSignature,
type ResourceBookSceneCardPlan,
type ResourceBookSceneCategoryPlan,
resourceBookSceneTitleKey,
} from './resourceBookLayout';
import {
initialResourceBookState,
resourceBookReducer,
resourceBookSceneCategory,
resourceBookShowsChildCards,
resourceBookShowsOverviewCards,
type ResourceBookState,
} from './resourceBookModel';
import { ResourceBookTransitionLayer } from './ResourceBookTransitionLayer';
import {
DEFAULT_RESOURCE_BOOK_VIEWPORT,
ensureResourceBookContentVisible,
@@ -979,15 +976,11 @@ function ResourceBookTitleBar({
}
function ResourceBookScene({
resources,
visibleCategoryOrder,
plan,
resourceBookState,
overviewRects,
positions,
cardSizes,
overviewReady,
visibleResourceIds,
categoryViewCenters,
cardsReady,
viewport,
mainViewport,
renderCard,
@@ -998,15 +991,11 @@ function ResourceBookScene({
onPointerCancel,
onWheel,
}: {
resources: ProjectResource[];
visibleCategoryOrder: readonly ResourceCategory[];
plan: ResourceBookSceneCategoryPlan[];
resourceBookState: ResourceBookState;
overviewRects: Map<ResourceCategory, ResourceBookOverviewRect>;
positions: Map<string, { x: number; y: number; section?: ResourceCategory }>;
cardSizes: Map<string, ResourceCanvasCardSize>;
overviewReady: boolean;
visibleResourceIds: Set<string>;
categoryViewCenters: Map<ResourceCategory, { x: number; y: number }>;
cardsReady: boolean;
viewport: CanvasViewport;
mainViewport: CanvasViewport;
renderCard: (
@@ -1028,14 +1017,6 @@ function ResourceBookScene({
onPointerCancel: (event: ReactPointerEvent<HTMLDivElement>) => void;
onWheel: (event: ReactWheelEvent<HTMLDivElement>) => void;
}) {
const resourcesByCategory = groupResourceBookResourcesByCategory(resources);
const sceneCategory = resourceBookSceneCategory(resourceBookState);
const showOverviewCards = resourceBookShowsOverviewCards(resourceBookState);
const showChildCards = resourceBookShowsChildCards(resourceBookState);
const overviewReady = visibleCategoryOrder.every((category) => {
const rect = overviewRects.get(category);
return Boolean(rect && rect.width > 0 && rect.height > 0);
});
const safeViewport = normalizeResourceBookViewport(viewport);
const safeMainViewport = normalizeResourceBookViewport(mainViewport);
const sceneViewport =
@@ -1059,29 +1040,25 @@ function ResourceBookScene({
} as CSSProperties
}
>
{visibleCategoryOrder.map((category) => {
const rect = overviewRects.get(category);
const categoryResources = resourcesByCategory.get(category) ?? [];
const stacks = groupResourceBookStacks(categoryResources);
const active = showChildCards && sceneCategory === category;
const renderTransitionCards = showOverviewCards;
const renderCards = cardsReady && (renderTransitionCards || active);
{plan.map((group) => {
const active = group.titlebarActive;
const rect = overviewRects.get(group.category);
const titleLayout = active
? { left: 0, top: 0, width: '100%', opacity: 1 }
: {
left: rect?.left ?? 0,
top: rect?.top ?? 0,
width: `${rect?.width ?? 280}px`,
opacity: renderTransitionCards ? 1 : 0,
opacity: group.titlebarOpacity,
};
const titlebarTransform = active
? `translate(${-safeViewport.x / safeViewport.scale}px, ${-safeViewport.y / safeViewport.scale}px) scale(${1 / safeViewport.scale})`
: undefined;
return (
<Fragment key={category}>
<Fragment key={group.category}>
<div
className={`game-resource-book-scene-titlebar${active ? ' is-active' : ''}`}
data-resource-book-category={category}
data-resource-book-category={group.category}
style={
{
left: `${titleLayout.left}px`,
@@ -1094,73 +1071,46 @@ function ResourceBookScene({
}
>
<ResourceBookTitleBar
category={category}
countLabel={`${categoryResources.length}`}
category={group.category}
countLabel={`${group.resourceCount}`}
onCollapse={active ? onCollapse : undefined}
/>
</div>
{renderCards &&
stacks.flatMap(([_type, items], stackColumn) => {
const expanded = active;
// 总览只铺前几张,避免一个类型素材变多时把整摞堆到同一处;
// 数量由栏目标题栏的“N 项”表达,子画布仍然渲染全部资源。
const stackItems = expanded
? items
: selectResourceBookOverviewCards({
resources: items,
visibleResourceIds,
positions,
cardSizes,
viewCenter: categoryViewCenters.get(category),
});
return stackItems.map((resource, stackIndex) => {
const position = positions.get(resource.id);
const size = cardSizes.get(resource.id) ?? {
width: 180,
height: 128,
};
const cardVisible =
visibleResourceIds.has(resource.id) &&
(renderTransitionCards || expanded);
const layout = expanded
? resourceBookChildCardLayout({
position,
size,
})
: resourceBookOverviewCardLayout({
rect,
stackIndex,
stackColumn,
});
return (
<div
className={`game-resource-book-scene-card${expanded ? ' is-expanded' : ''}`}
data-resource-book-category={category}
aria-hidden={!cardVisible}
key={resource.id}
style={
{
left: 0,
top: 0,
width: 0,
height: 0,
opacity: cardVisible ? 1 : 0,
// 总览摞里下标小的卡片压在上层,保证最上面那张是清晰的。
zIndex: expanded
? undefined
: stackItems.length - stackIndex,
pointerEvents:
expanded && visibleResourceIds.has(resource.id)
? 'auto'
: 'none',
} as CSSProperties
}
>
{renderCard(resource, layout)}
</div>
);
});
})}
{group.cards.map((card) => {
const expanded = card.presentation === 'child';
const cardVisible =
visibleResourceIds.has(card.resource.id) &&
(expanded ||
(card.presentation === 'overview' &&
resourceBookState.view === 'main'));
return (
<div
className={`game-resource-book-scene-card${expanded ? ' is-expanded' : ''}`}
data-resource-book-category={group.category}
aria-hidden={!cardVisible}
key={card.key}
style={
{
left: 0,
top: 0,
width: 0,
height: 0,
opacity: cardVisible ? 1 : 0,
// 总览摞里下标小的卡片压在上层,保证最上面那张是清晰的。
zIndex: expanded
? undefined
: card.stackCount - card.stackIndex,
pointerEvents:
expanded && visibleResourceIds.has(card.resource.id)
? 'auto'
: 'none',
} as CSSProperties
}
>
{renderCard(card.resource, card.layout)}
</div>
);
})}
</Fragment>
);
})}
@@ -1210,6 +1160,9 @@ export default function ProjectDevelopmentView({
createResourceBookTransitionController(),
);
const resourceBookManagerRef = useRef<HTMLDivElement>(null);
const resourceBookPlanKeysRef = useRef(
new Map<string, ResourceBookSceneCardPlan>(),
);
const resourceBookThumbnailElementsRef = useRef(
new Map<ResourceCategory, HTMLElement>(),
);
@@ -2434,6 +2387,121 @@ export default function ProjectDevelopmentView({
};
}, []);
// 搜索/排序导致的卸载:保留一帧,让同一套 FLIP 把卡片淡出后再撤下。
const [resourceBookExitingCards, setResourceBookExitingCards] = useState<
ResourceBookSceneCardPlan[]
>([]);
const resourceBookExitingTimerRef = useRef<number | null>(null);
const resourceBookScenePlan = useMemo(
() =>
buildResourceBookScenePlan({
state: resourceBookState,
visibleCategoryOrder,
resourcesByCategory: projectResourcesByCategory,
overviewRects: resourceBookOverviewRects,
positions: resourcePositionById,
cardSizes: resourceCardSizeByResourceId,
visibleResourceIds,
categoryViewCenters: resourceBookCategoryViewCenters,
cardsReady:
sortMode !== 'dependency' || resourceGraphInitializationReady,
exitingCategory:
resourceBookState.phase === 'returning-main'
? activePageCategory
: null,
exitingCards: resourceBookExitingCards,
}),
[
activePageCategory,
projectResourcesByCategory,
resourceBookCategoryViewCenters,
resourceBookExitingCards,
resourceBookOverviewRects,
resourceBookState,
resourceCardSizeByResourceId,
resourceGraphInitializationReady,
resourcePositionById,
sortMode,
visibleResourceIds,
],
);
const resourceBookOverviewReady = useMemo(
() =>
visibleCategoryOrder.every((category) => {
const rect = resourceBookOverviewRects.get(category);
return Boolean(rect && rect.width > 0 && rect.height > 0);
}),
[resourceBookOverviewRects],
);
const resourceBookMotionSignatures = useMemo(() => {
const signatures = new Map<string, string>();
for (const group of resourceBookScenePlan) {
const rect = resourceBookOverviewRects.get(group.category);
signatures.set(
resourceBookSceneTitleKey(group.category),
group.titlebarActive
? 'active'
: `inactive:${rect?.left ?? 0},${rect?.top ?? 0},${rect?.width ?? 0}`,
);
for (const card of group.cards) {
signatures.set(card.key, resourceBookSceneCardLayoutSignature(card));
}
}
return signatures;
}, [resourceBookOverviewRects, resourceBookScenePlan]);
// 拖拽中的卡片只跟指针走,不参与布局 FLIP。
const resourceBookFrozenMotionKeys = useMemo(() => {
const keys = new Set<string>();
if (resourceCardDragPreview) {
keys.add(resourceBookSceneCardKey(resourceCardDragPreview.resourceId));
}
return keys;
}, [resourceCardDragPreview]);
useLayoutEffect(() => {
const previousKeys = resourceBookPlanKeysRef.current;
const currentKeys = new Map<string, ResourceBookSceneCardPlan>();
for (const group of resourceBookScenePlan) {
for (const card of group.cards) {
// 只跟踪"当前看得见"的卡片:转场期间正在淡出的节点不需要再补一次淡出。
const visible =
visibleResourceIds.has(card.resource.id) &&
(card.presentation === 'child' || resourceBookState.view === 'main');
if (card.presentation !== 'exiting' && visible) {
currentKeys.set(card.key, card);
}
}
}
resourceBookPlanKeysRef.current = currentKeys;
const removed = Array.from(previousKeys)
.filter(([key]) => !currentKeys.has(key))
.map(([, card]) => card);
if (removed.length === 0) return;
if (resourceBookExitingTimerRef.current !== null) {
window.clearTimeout(resourceBookExitingTimerRef.current);
resourceBookExitingTimerRef.current = null;
}
const remaining =
resourceBookTransitionControllerRef.current.motionRemainingMs();
if (remaining <= 0) {
setResourceBookExitingCards([]);
return;
}
setResourceBookExitingCards(removed);
resourceBookExitingTimerRef.current = window.setTimeout(() => {
resourceBookExitingTimerRef.current = null;
setResourceBookExitingCards([]);
}, remaining + 80);
}, [resourceBookScenePlan, resourceBookState.view, visibleResourceIds]);
useEffect(
() => () => {
if (resourceBookExitingTimerRef.current !== null) {
window.clearTimeout(resourceBookExitingTimerRef.current);
resourceBookExitingTimerRef.current = null;
}
},
[],
);
useLayoutEffect(() => {
const manager = resourceBookManagerRef.current;
if (resourceBookState.phase === 'idle') return;
@@ -2454,62 +2522,21 @@ export default function ProjectDevelopmentView({
token: resourceBookState.token,
});
},
// 进入时重叠在总览摞上,沿用来源顺序;返回时重叠在摞上,沿用目标顺序。
resourceBookState.phase === 'returning-main' ? 'target' : 'source',
);
}, [resourceBookState.token, resourceBookState.phase, mode]);
const motionLayoutRef = useRef({
token: resourceBookState.token,
viewport: activeResourceCanvasViewport,
overview: resourceBookOverviewRects,
positions: resourcePositionById,
sizes: resourceCardSizeByResourceId,
resources: canvasResources,
sortMode,
});
// 每次布局提交后同步一次:布局签名变化(含预览图尺寸晚到)从"此刻像素"重基。
useLayoutEffect(() => {
const current = {
token: resourceBookState.token,
viewport: activeResourceCanvasViewport,
overview: resourceBookOverviewRects,
positions: resourcePositionById,
sizes: resourceCardSizeByResourceId,
resources: canvasResources,
sortMode,
};
const previous = motionLayoutRef.current;
motionLayoutRef.current = current;
if (
previous.token === current.token &&
(previous.viewport !== current.viewport ||
previous.overview !== current.overview ||
previous.positions !== current.positions ||
previous.sizes !== current.sizes ||
previous.resources !== current.resources ||
previous.sortMode !== current.sortMode)
) {
// 动画期间布局(卡片尺寸/位置)会随预览图尺寸到位而更新。此时直接 settle
// 会让真实场景瞬间接管,看起来就是结束瞬间跳一下;改为只更新运行中动画的
// 终点,不重建叠加层、不打断飞行。
const manager = resourceBookManagerRef.current;
const controller = resourceBookTransitionControllerRef.current;
if (resourceBookState.phase !== 'idle' && manager) {
controller.retarget(manager);
} else {
controller.settle();
}
}
}, [
resourceBookState.token,
resourceBookState.phase,
activeResourceCanvasViewport,
resourceBookOverviewRects,
resourcePositionById,
resourceCardSizeByResourceId,
canvasResources,
sortMode,
]);
const manager = resourceBookManagerRef.current;
if (!manager || mode !== 'resources') return;
// 主画布首帧还在测量时不建动画,避免卡片从临时位置飞向真实位置。
if (resourceBookState.view === 'main' && !resourceBookOverviewReady) return;
resourceBookTransitionControllerRef.current.sync(
manager,
resourceBookMotionSignatures,
resourceBookFrozenMotionKeys,
);
});
const openResourceBookChild = useCallback(
(category: ResourceCategory) => {
@@ -5142,7 +5169,8 @@ export default function ProjectDevelopmentView({
data-resource-book-transition={resourceBookState.phase}
onPointerDownCapture={(event) => {
if (
event.currentTarget.hasAttribute('data-book-motion') &&
event.currentTarget.dataset.resourceBookTransition !==
'idle' &&
(event.target as Element).closest('.game-resource-card')
) {
event.preventDefault();
@@ -5152,18 +5180,11 @@ export default function ProjectDevelopmentView({
}}
>
<ResourceBookScene
resources={canvasResources}
visibleCategoryOrder={visibleCategoryOrder}
plan={resourceBookScenePlan}
resourceBookState={resourceBookState}
overviewRects={resourceBookOverviewRects}
positions={resourcePositionById}
cardSizes={resourceCardSizeByResourceId}
overviewReady={resourceBookOverviewReady}
visibleResourceIds={visibleResourceIds}
categoryViewCenters={resourceBookCategoryViewCenters}
cardsReady={
sortMode !== 'dependency' ||
resourceGraphInitializationReady
}
viewport={activeResourceCanvasViewport}
mainViewport={resourceBookMainViewport}
renderCard={renderResourceBookCard}
@@ -5174,7 +5195,6 @@ export default function ProjectDevelopmentView({
onPointerCancel={stopResourceCanvasPan}
onWheel={handleResourceBookWheel}
/>
<ResourceBookTransitionLayer />
<div className="game-resource-book-zoom">
<button
type="button"
File diff suppressed because it is too large Load Diff
@@ -1,4 +1,12 @@
import type { ResourceBookCategory } from './resourceBookModel';
import type {
ResourceBookCardPresentation,
ResourceBookCategory,
ResourceBookState,
} from './resourceBookModel';
import {
resourceBookCategoryCardPresentation,
resourceBookShowsExitingCards,
} from './resourceBookModel';
import {
RESOURCE_CANVAS_CARD_HEIGHT,
RESOURCE_CANVAS_CARD_WIDTH,
@@ -94,12 +102,14 @@ export function selectResourceBookOverviewCards({
};
const centerX = position.x + size.width / 2;
const centerY = position.y + size.height / 2;
return (
(centerX - viewCenter.x) ** 2 + (centerY - viewCenter.y) ** 2
);
return (centerX - viewCenter.x) ** 2 + (centerY - viewCenter.y) ** 2;
};
return visible
.map((resource, index) => ({ resource, index, distance: distance(resource) }))
.map((resource, index) => ({
resource,
index,
distance: distance(resource),
}))
.sort(
(left, right) =>
left.distance - right.distance || left.index - right.index,
@@ -154,3 +164,167 @@ export function resourceBookChildCardLayout({
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}`;
}
@@ -78,3 +78,32 @@ export function resourceBookShowsOverviewCards(state: ResourceBookState) {
export function resourceBookShowsChildCards(state: ResourceBookState) {
return state.view === 'child';
}
/**
* 栏目卡片的呈现方式:
* - `child`:子画布布局,当前栏目;
* - `overview`:总览摞布局。进入栏目时其他栏目保留在 DOM 里淡出,总览/返回态全部用总览布局;
* - `null`:不渲染(子画布静止态的非活动栏目)。
*
* 返回总览时,源栏目里没有回到摞上的卡片由布局层额外标成 `exiting`。
*/
export type ResourceBookCardPresentation = 'child' | 'overview' | 'exiting';
export function resourceBookCategoryCardPresentation(
state: ResourceBookState,
category: ResourceBookCategory,
): Exclude<ResourceBookCardPresentation, 'exiting'> | null {
if (state.view === 'main') {
return 'overview';
}
if (category === state.category) {
return 'child';
}
// 进入栏目期间其他栏目的卡片留在 DOM 里淡出,不再整层克隆。
return state.phase === 'entering' ? 'overview' : null;
}
/** 返回总览期间,需要把源栏目未回到摞上的卡片按子画布布局淡出。 */
export function resourceBookShowsExitingCards(state: ResourceBookState) {
return state.view === 'main' && state.phase === 'returning-main';
}
@@ -720,7 +720,26 @@ export function registerProjectWorkbenchFoundationTests() {
expect(
screen.getByRole('button', { name: /下一页\s*美术资源/ }),
).not.toBeNull();
const documentCardBefore = document.querySelector<HTMLElement>(
'.game-resource-book-scene-card[data-resource-book-category="document"] .game-resource-card',
);
const documentTitlebarBefore = document.querySelector<HTMLElement>(
'.game-resource-book-scene-titlebar[data-resource-book-category="document"]',
);
expect(documentCardBefore).not.toBeNull();
expect(documentTitlebarBefore).not.toBeNull();
await openResourceBookCategory('设计文档');
// 真实元素 FLIP:卡片和标题栏在总览与子画布之间是同一批 DOM 节点,
// 转场不引入克隆快照层。
expect(documentCardBefore!.isConnected).toBe(true);
expect(documentCardBefore!.closest('[data-resource-book-view]')).toBe(
document.querySelector('[data-resource-book-view="child"]'),
);
expect(documentTitlebarBefore!.isConnected).toBe(true);
expect(documentTitlebarBefore!.classList.contains('is-active')).toBe(true);
expect(
document.querySelector('.game-resource-book-transition-layer'),
).toBeNull();
expect(
within(outline).queryByRole('button', { name: /游戏代码/ }),
File diff suppressed because it is too large Load Diff
@@ -1,9 +1,11 @@
import { describe, expect, it } from 'vitest';
import {
buildResourceBookScenePlan,
groupResourceBookStacks,
RESOURCE_BOOK_OVERVIEW_STACK_LIMIT,
resourceBookOverviewCardLayout,
resourceBookSceneCardKey,
selectResourceBookOverviewCards,
} from '../src/view/project-development/resourceBookLayout';
import type { ProjectResource } from '../src/view/project-development/resourceProjectionModel';
@@ -121,3 +123,152 @@ describe('groupResourceBookStacks', () => {
expect(stacks[0]?.[1]).toHaveLength(4);
});
});
describe('buildResourceBookScenePlan', () => {
const resources = ['art-0', 'art-1', 'art-2', 'art-3'].map(resource);
const positions = new Map(
resources.map((item, index) => [item.id, { x: index * 200, y: 0 }]),
);
const cardSizes = new Map(
resources.map((item) => [item.id, { width: 180, height: 128 }]),
);
const base = {
visibleCategoryOrder: ['art'] as const,
resourcesByCategory: new Map([['art', resources]]),
overviewRects: new Map([['art', rect]]),
positions,
cardSizes,
visibleResourceIds: new Set(resources.map((item) => item.id)),
categoryViewCenters: new Map([['art', { x: 0, y: 64 }]]),
cardsReady: true,
};
it('renders the bounded overview stack on the steady main canvas', () => {
const plan = buildResourceBookScenePlan({
...base,
state: {
view: 'main',
category: null,
phase: 'idle',
token: 0,
},
});
expect(plan).toHaveLength(1);
expect(plan[0]!.cards).toHaveLength(RESOURCE_BOOK_OVERVIEW_STACK_LIMIT);
expect(plan[0]!.cards[0]!.presentation).toBe('overview');
expect(plan[0]!.cards[0]!.stackCount).toBe(
RESOURCE_BOOK_OVERVIEW_STACK_LIMIT,
);
expect(plan[0]!.cards[0]!.layout).toMatchObject({
x: rect.left + 18,
y: rect.top + 58,
width: 92,
height: 64,
});
expect(plan[0]!.titlebarActive).toBe(false);
expect(plan[0]!.titlebarOpacity).toBe(1);
expect(plan[0]!.resourceCount).toBe(4);
});
it('keeps other categories mounted for a fade-out while entering', () => {
const plan = buildResourceBookScenePlan({
...base,
visibleCategoryOrder: ['art', 'code'],
resourcesByCategory: new Map([
['art', resources],
['code', [resource('code-0')]],
]),
overviewRects: new Map([
['art', rect],
['code', { ...rect, left: 500 }],
]),
state: { view: 'child', category: 'art', phase: 'entering', token: 1 },
});
const art = plan.find((group) => group.category === 'art')!;
const code = plan.find((group) => group.category === 'code')!;
expect(art.cards.every((card) => card.presentation === 'child')).toBe(true);
expect(art.cards).toHaveLength(4);
expect(art.titlebarActive).toBe(true);
// 其他栏目留在 DOM 里淡出,而不是直接卸载。
expect(code.cards.every((card) => card.presentation === 'overview')).toBe(
true,
);
expect(code.titlebarActive).toBe(false);
expect(code.titlebarOpacity).toBe(0);
});
it('marks cards that do not return to the stack as exiting', () => {
const plan = buildResourceBookScenePlan({
...base,
state: {
view: 'main',
category: null,
phase: 'returning-main',
token: 2,
},
exitingCategory: 'art',
});
const cards = plan[0]!.cards;
expect(
cards.filter((card) => card.presentation === 'overview'),
).toHaveLength(RESOURCE_BOOK_OVERVIEW_STACK_LIMIT);
const exiting = cards.filter((card) => card.presentation === 'exiting');
expect(exiting).toHaveLength(1);
// 淡出节点按子画布布局渲染,位置由 FLIP 钉在 First。
expect(exiting[0]!.layout.x).toBe(positions.get('art-3')!.x);
});
it('keeps search-driven removals mounted without duplicating keys', () => {
const plan = buildResourceBookScenePlan({
...base,
state: { view: 'main', category: null, phase: 'idle', token: 0 },
exitingCards: [
{
key: resourceBookSceneCardKey('art-3'),
category: 'art',
resource: resources[3]!,
presentation: 'exiting',
stackIndex: 0,
stackColumn: 0,
stackCount: 1,
layout: {
x: 0,
y: 0,
width: 180,
height: 128,
dragX: 0,
dragY: 0,
rotation: 0,
},
},
{
key: resourceBookSceneCardKey('art-0'),
category: 'art',
resource: resources[0]!,
presentation: 'exiting',
stackIndex: 0,
stackColumn: 0,
stackCount: 1,
layout: {
x: 0,
y: 0,
width: 180,
height: 128,
dragX: 0,
dragY: 0,
rotation: 0,
},
},
],
});
const keys = plan[0]!.cards.map((card) => card.key);
expect(keys).toContain(resourceBookSceneCardKey('art-3'));
expect(
keys.filter((key) => key === resourceBookSceneCardKey('art-0')),
).toHaveLength(1);
});
});
@@ -2,8 +2,10 @@ import { describe, expect, it } from 'vitest';
import {
initialResourceBookState,
resourceBookCategoryCardPresentation,
resourceBookReducer,
resourceBookSceneCategory,
resourceBookShowsExitingCards,
resourceBookShowsOverviewCards,
} from '../src/view/project-development/resourceBookModel';
@@ -103,3 +105,58 @@ describe('resource book state machine', () => {
).toEqual(switched);
});
});
describe('resource book card presentation', () => {
it('uses the overview stack on the steady main canvas', () => {
expect(
resourceBookCategoryCardPresentation(initialResourceBookState, 'art'),
).toBe('overview');
});
it('keeps other categories mounted while entering so they can fade out', () => {
const entering = resourceBookReducer(initialResourceBookState, {
type: 'open-category',
category: 'art',
token: 1,
});
expect(resourceBookCategoryCardPresentation(entering, 'art')).toBe('child');
expect(resourceBookCategoryCardPresentation(entering, 'code')).toBe(
'overview',
);
});
it('drops other categories once the child canvas is steady', () => {
const steady = resourceBookReducer(
resourceBookReducer(initialResourceBookState, {
type: 'open-category',
category: 'art',
token: 1,
}),
{ type: 'finish-transition', token: 1 },
);
expect(resourceBookCategoryCardPresentation(steady, 'art')).toBe('child');
expect(resourceBookCategoryCardPresentation(steady, 'code')).toBeNull();
});
it('returns every category to the stack and exposes the exiting window', () => {
const returning = resourceBookReducer(
resourceBookReducer(initialResourceBookState, {
type: 'open-category',
category: 'art',
token: 1,
}),
{ type: 'return-to-main', token: 2 },
);
expect(resourceBookCategoryCardPresentation(returning, 'art')).toBe(
'overview',
);
expect(resourceBookCategoryCardPresentation(returning, 'code')).toBe(
'overview',
);
expect(resourceBookShowsExitingCards(returning)).toBe(true);
expect(resourceBookShowsExitingCards(initialResourceBookState)).toBe(false);
});
});
@@ -38,8 +38,10 @@
- 无限画布的指针交互必须绑定未变换、铺满可视区域的视口,平移和缩放只作用于内部 world;若连交互容器一起移动或缩小,内容移走后的空白处会失去拖动入口。内容层不裁剪,裁剪仅由固定视口承担;主画布滚轮使用非 passive 监听,避免浏览器默认滚动或缩放。
- `resourceBookController` 使用独立 FLIP 快照层和 Web Animations 完成信号;逻辑栏目立即提交,不用定时器猜 CSS 动画终点再次导航从当前快照位置开始,旧完成回调由 token 失效。
- 屏幕 DOMRect 与逻辑布局坐标不能混用。主画布缩略入口测量先逆 viewport;快照复制必须保留祖先缩放对内部图片、文字的影响,单纯把克隆外框改成屏幕宽高会产生内部留白
- `resourceBookController` 对真实元素做 FLIP`begin` 在状态提交前记录 First,提交后 `play` 测 Last 并给同一个节点加反向 transform 过渡回 identity`sync` 在每次布局提交后比较布局签名做可中断重基。逻辑栏目立即提交,不用定时器猜 CSS 动画终点再次导航从当前像素开始,旧完成回调由 token 失效。
- 屏幕 DOMRect 与逻辑布局坐标不能混用。主画布缩略入口测量先逆 viewport;FLIP 的 translate 写在 world 局部坐标系,屏幕位移必须除以 world 缩放,宿主节点原点不在卡片左上角时(卡片包装节点是 0x0)还要按原点偏移换算
- 克隆快照 + 动画结束交接给真实节点这条路线有两个结构性缺陷,反复补丁也治不了:一是交接要求两个不同 DOM 对象的终态像素级一致;二是快照层在 world 之外、真实卡片在 world 之内,克隆坐标是屏幕空间、真实节点是 world 局部空间,交接时必然要重映射一次坐标。共享元素转场只在"同一批 DOM 节点、同一父节点"成立时才用真实元素 FLIP,不要引入克隆层。
- 卡片尺寸依赖异步到达的预览图尺寸,布局会在转场动画之后(甚至动画结束之后)再变一次。所有布局变化必须走同一套"同一节点反向 transform",并在动画运行中以"此刻像素"为起点重基(转场中取剩余时长,下限 80ms;空闲布局变化取完整时长),否则晚到的那次布局变化仍是瞬移。
- 点击和滚轮共用导航入口,但滚轮提交不得重置已有的冷却和队列;监听器使用最新导航回调引用,避免视觉 phase 更新重装监听器并清空待处理手势。
- jsdom 不执行真实转场;定向模型测试之外,仍需浏览器核验中断连续性、图片/文字尺寸、viewport 保留和窄屏布局。完整契约见资源自由画板技术方案。
@@ -3,26 +3,27 @@
## 2026-09-05 资源画本独立转场
- `resourceBookModel` 立即提交目标 `main / child + category``entering / returning-main` 只表示尚未收尾的视觉过程,不承载待提交的栏目。缩略入口、大纲、下一页与资源定位共用分类导航入口;栏目互切直接到达目标,不经过总览。滚轮不切换栏目。
- 真实 `ResourceCard` 仍由唯一的 `ResourceBookScene` 挂载,保持预览请求、播放器和交互的单一所有者。转场前 `resourceBookController.begin` 捕获当前可见标题与资源卡的屏幕矩形和表现;目标 DOM 提交后的 layout effect 调 `play`
- `ResourceBookTransitionLayer` 是 manager 内未变换的独立层。快照仅复制表现,不挂载 React 业务组件,移除重复 ID、业务 data 属性及可访问控件身份,整层 `inert / aria-hidden / pointer-events:none`。播放中的视频优先捕获当前帧;跨源禁止读取时保留已有 poster,不启动第二个播放器
- FLIP 只动画快照的 transform/opacity,统一时长 420ms,所有 `Animation.finished` 完成后删除快照并恢复目标真实节点;动画创建失败、零尺寸或不支持 Web Animations 时直接完成。`prefers-reduced-motion` 不创建动画,不等待定时器
- 再次导航先读取当前动画快照的实际位置,再取消旧动画并重新建立目标;token 隔离旧 promise,过期完成和取消均不能提交新状态。窗口尺寸变化、排序或异步布局更新时直接收尾到当前目标,避免使用过期几何
- 转场对**真实元素**做 FLIP,不使用克隆快照层。总览与子画布之间同一张卡是同一个 DOM 节点(同一 React key、同一父节点,只有布局函数在 `resourceBookOverviewCardLayout` `resourceBookChildCardLayout` 之间切换),标题栏同理。`resourceBookController.begin` 在状态提交前按 key 记录 First 屏幕矩形与当前不透明度;提交后的 layout effect 调 `play` 测 Last,给同一个节点加反向 transform 再过渡回 identity。动画结束没有"克隆 → 真实节点"的交接,因此不存在交接瞬间的跳变;遮挡关系由真实 DOM 的 z-index/DOM 顺序决定,与静止态一致
- 反向 transform 写在宿主节点自身坐标系:`transform-origin: 0 0`,缩放比取 `First.size / Last.size`,位移按宿主原点偏移换算后除以 `.game-resource-book-scene-world` 的缩放。标题栏激活时内联的"抵消 world"transform 作为 base 与反向 transform 组合,结束帧回到该 base
- `begin` 之后的所有布局提交都调 `sync`:比较每个节点的布局签名,变化时取消旧动画、重新测量,以**当前视觉矩形**(含运行中的 transform)为起点重基到新的干净终点。转场进行中用剩余时长(`startTime + 420 - now`,下限 80ms),空闲布局变化用完整 420ms。卡片预览尺寸是异步到达的,因此"动画期间甚至动画结束之后"的二次布局变化同样是动画而不是瞬移。窗口/容器尺寸变化与拖拽中的卡片不做布局动画
- 目标不可见的节点(内联 `opacity` 为 0)钉在 First 位置淡出,没有 First 的挂载节点淡入;搜索/排序导致的卸载保留一帧后用同一套淡出撤下,进入栏目期间其他栏目保留挂载淡出,返回总览时源栏目未回到摞上的卡片按子画布布局淡出。转场时长统一 420ms;动画创建失败、零尺寸、容器尺寸变化或 `prefers-reduced-motion` 时不创建动画,也不等待定时器
- 再次导航先读取当前像素位置,再取消旧动画并重新建立目标;token 隔离旧 promise,过期完成和取消均不能提交新状态。
- 缩放、适应内容和空白拖动先结束视觉转场再执行用户输入;转场中的资源卡 pointer-down 不启动持久化拖拽。普通滚轮只平移当前视图,Ctrl/Meta 滚轮以指针为锚点缩放,均不切换栏目。
- viewport 只属于用户交互状态,按主画布以及 `sortMode/category` 隔离保存。转场不插值也不持久化 viewport;测量主画布缩略入口时先逆变换回布局坐标,避免缩放后的 DOMRect 被二次缩放。只有真实非零测量才能标记首次 fit 完成。
验收使用 `resourceBookController.test.ts``resourceBookModel.test.ts``resourceBookViewport.test.ts``appSurface.test.ts`;页面 `.suite.ts``appSurface.test.ts` 注册,不能作为独立 Vitest 文件运行。真实浏览器需覆盖总览/栏目往返、连续改选、滚轮平移、缩放、尺寸变化、减少动画偏好,以及快照图片和字体的正确比例。jsdom 不模拟真实插值,不能代替浏览器验收。
验收使用 `resourceBookController.test.ts``resourceBookLayout.test.ts``resourceBookModel.test.ts``resourceBookViewport.test.ts``appSurface.test.ts`;页面 `.suite.ts``appSurface.test.ts` 注册,不能作为独立 Vitest 文件运行。真实浏览器需覆盖总览/栏目往返、连续改选、滚轮平移、缩放、尺寸变化、减少动画偏好,以及真实元素的图片和字体比例。jsdom 不实现 Web Animations,只覆盖几何计算、状态机与调用契约,不能代替浏览器验收。
### 2026-09-05 子画布无限平移与初始可读比例
子画布改为固定视口加可无限平移的 world 层:视口本身保持 `overflow: hidden`,平移和缩放只作用于 `.game-resource-book-scene-world`,不再让内容容器的边界限制拖动范围。首次 fit(包括隐藏分页画布提前完成的 fit)统一保证比例不低于 `1:1`,避免子画布展开后卡片缩成不可读的小块;用户后续主动缩放或平移的视口仍按每个分类独立保留。新增 viewport 回归测试覆盖初始可读比例和超出内容边界双向平移。
返回主画布时立即提交主画布布局,由独立快照层完成缩回;world/main viewport 不播放附加动画。
返回主画布时立即提交主画布布局,由真实元素 FLIP 完成缩回;world/main viewport 不播放附加动画。
总览和栏目视图同样使用固定铺满 manager 的交互视口,只有内部 world 层承担平移和缩放,内容层不裁剪缩略入口。空白拖动允许双向越过全部内容边界;移走内容后仍可在原视口继续拖动。普通滚轮/触控板平移,Shift 滚轮横向平移,Ctrl/Meta 滚轮以指针为锚点缩放;wheel 监听阻止浏览器滚动或页面缩放,但不切换栏目。总览/栏目往返保留总览原有位置和比例,只有主动适应内容才复位。
## 2026-09-04 资源画本 UI 阶段补充
资源工作台采用“主画布 + 子画布”的统一画本模型。主画布展示各子画布的缩略入口,内部按资源类型显示有限层叠卡片,最多三层,更多资源以虚化卡片提示。主画布预览和子画布展开态复用同一套卡片视觉与标题栏结构,共享元素快照从当前屏幕位置过渡到目标位置。子画布标题栏提供缩回主画布的按钮;子画布互切直接到达目标,不强制经过主画布。
资源工作台采用“主画布 + 子画布”的统一画本模型。主画布展示各子画布的缩略入口,内部按资源类型显示有限层叠卡片,最多三层,更多资源以虚化卡片提示。主画布预览和子画布展开态复用同一套卡片视觉与标题栏结构,同一批 DOM 节点从当前屏幕位置过渡到目标位置。子画布标题栏提供缩回主画布的按钮;子画布互切直接到达目标,不强制经过主画布。
本阶段先实现前端 UI、导航状态和转场表现,资源真实跨画布移动、主画布正式资源登记、上传/生成任务、版本绑定以及 UI 编辑器导入与 UI 包工作流继续沿用现有权威链路,待 UI 编辑器完善后再接入。临时画本视图状态不得替代 manifest、草稿或其它后端业务真相。
@@ -111,7 +112,8 @@
```powershell
npm run ai-game-creator-shell:typecheck
node node_modules/vitest/vitest.mjs run apps/ai-game-creator-shell/tests/projectResourceLiveIntegration.test.tsx apps/ai-game-creator-shell/tests/resourceEditModel.test.ts apps/ai-game-creator-shell/tests/resourceCanvasLayoutModel.test.ts apps/ai-game-creator-shell/tests/ResourceDependencyOverlay.test.ts apps/ai-game-creator-shell/tests/useProjectResourceCardPreviews.test.ts apps/ai-game-creator-shell/tests/resourceCanvasLayoutContract.test.ts
node node_modules/vitest/vitest.mjs run apps/ai-game-creator-shell/tests/resourceBookController.test.ts apps/ai-game-creator-shell/tests/resourceBookLayout.test.ts apps/ai-game-creator-shell/tests/resourceBookModel.test.ts apps/ai-game-creator-shell/tests/resourceBookViewport.test.ts
node node_modules/vitest/vitest.mjs run apps/ai-game-creator-shell/tests/projectResourceLiveIntegration.test.tsx apps/ai-game-creator-shell/tests/resourceCanvasLayoutModel.test.ts apps/ai-game-creator-shell/tests/ResourceDependencyOverlay.test.ts apps/ai-game-creator-shell/tests/useProjectResourceCardPreviews.test.ts apps/ai-game-creator-shell/tests/resourceCanvasLayoutContract.test.ts
node node_modules/vitest/vitest.mjs run apps/ai-game-creator-shell/tests/appSurface.test.ts
cargo fmt --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml -- --check
cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml project::resource_editor -- --test-threads=1