合并 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:
@@ -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;
|
||||
/* 卡片包装节点是 0x0,FLIP 反向 transform 围绕它的原点缩放。 */
|
||||
transform-origin: 0 0;
|
||||
translate: 0 0;
|
||||
transition: none;
|
||||
}
|
||||
|
||||
-9
@@ -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"
|
||||
|
||||
+536
-374
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
|
||||
|
||||
Reference in New Issue
Block a user