重构资源画本独立转场与中断处理
Project CI / Repository checks (pull_request) Failing after 15s
Project CI / Backend tests (pull_request) Failing after 15s
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled

使用独立FLIP快照层和Web Animations统一动画收尾
统一分类导航并保留滚轮节流和各画布视口
修复快照缩放与悬停导致的终点偏移
补充动画回归测试并更新资源画本技术文档
This commit is contained in:
2026-09-05 11:21:17 +08:00
parent 65ffd4cc69
commit ba718e3d4b
10 changed files with 685 additions and 549 deletions
+21 -101
View File
@@ -5192,22 +5192,23 @@ iframe.preview-frame {
transition: none;
}
.game-resource-book-scene--entering .game-resource-book-scene-world {
transition: transform 420ms cubic-bezier(0.2, 0.78, 0.2, 1);
.game-resource-book-transition-layer {
position: absolute;
inset: 0;
z-index: 35;
overflow: hidden;
pointer-events: none;
}
.game-resource-book-scene--returning-main .game-resource-book-scene-world {
transition: none;
.game-resource-book-manager[data-book-motion='running']
.game-resource-book-scene-world {
opacity: 0;
}
.game-resource-book-scene--child {
pointer-events: auto;
}
.game-resource-book-scene--child.is-measuring {
pointer-events: none;
}
.game-resource-book-zoom {
position: absolute;
right: 14px;
@@ -5262,8 +5263,8 @@ iframe.preview-frame {
margin-left: 2px;
}
.game-resource-book-scene.is-measuring {
opacity: 0;
.game-resource-book-scene--main.is-measuring {
visibility: hidden;
}
.game-resource-book-scene::before {
@@ -5276,12 +5277,10 @@ iframe.preview-frame {
content: '';
opacity: 0;
pointer-events: none;
transition: opacity 180ms ease;
transition: none;
}
.game-resource-book-scene--child::before,
.game-resource-book-scene--returning::before,
.game-resource-book-scene--entering::before {
.game-resource-book-scene--child::before {
opacity: 1;
}
@@ -5295,13 +5294,7 @@ iframe.preview-frame {
box-shadow: 0 8px 18px rgb(112 70 52 / 8%);
pointer-events: none;
transform: translateY(0);
transition:
left 420ms cubic-bezier(0.2, 0.78, 0.2, 1),
top 420ms cubic-bezier(0.2, 0.78, 0.2, 1),
width 420ms cubic-bezier(0.2, 0.78, 0.2, 1),
transform 420ms cubic-bezier(0.2, 0.78, 0.2, 1),
translate 180ms ease,
opacity 180ms ease;
transition: none;
}
.game-resource-book-scene-titlebar.is-active {
@@ -5309,13 +5302,6 @@ iframe.preview-frame {
pointer-events: auto;
}
/* 子画布中的标题栏是固定 UI。拖动画布时 viewport 会逐帧更新,不能让
* 标题栏的逆变换继续走转场 transition,否则它会滞后跟随画布。 */
.game-resource-book-scene--child.game-resource-book-scene--idle
.game-resource-book-scene-titlebar.is-active {
transition: none;
}
.game-resource-book-scene-titlebar .game-resource-book-titlebar {
min-height: 42px;
border-bottom: 0;
@@ -5327,86 +5313,30 @@ iframe.preview-frame {
z-index: 25;
pointer-events: none;
translate: 0 0;
transition:
opacity 180ms ease,
transform 420ms cubic-bezier(0.2, 0.78, 0.2, 1),
translate 180ms ease;
transition: none;
}
.game-resource-book-manager:has(
.game-resource-book-manager--main:has(
.game-resource-book-thumbnail[data-resource-book-category='code']:hover,
.game-resource-book-thumbnail[data-resource-book-category='code']:focus-visible
)
.game-resource-book-scene-titlebar[data-resource-book-category='code'],
.game-resource-book-manager:has(
.game-resource-book-thumbnail[data-resource-book-category='code']:hover,
.game-resource-book-thumbnail[data-resource-book-category='code']:focus-visible
)
.game-resource-book-scene-card[data-resource-book-category='code'],
.game-resource-book-manager:has(
.game-resource-book-manager--main:has(
.game-resource-book-thumbnail[data-resource-book-category='document']:hover,
.game-resource-book-thumbnail[data-resource-book-category='document']:focus-visible
)
.game-resource-book-scene-titlebar[data-resource-book-category='document'],
.game-resource-book-manager:has(
.game-resource-book-thumbnail[data-resource-book-category='document']:hover,
.game-resource-book-thumbnail[data-resource-book-category='document']:focus-visible
)
.game-resource-book-scene-card[data-resource-book-category='document'],
.game-resource-book-manager:has(
.game-resource-book-manager--main:has(
.game-resource-book-thumbnail[data-resource-book-category='version']:hover,
.game-resource-book-thumbnail[data-resource-book-category='version']:focus-visible
)
.game-resource-book-scene-titlebar[data-resource-book-category='version'],
.game-resource-book-manager:has(
.game-resource-book-thumbnail[data-resource-book-category='version']:hover,
.game-resource-book-thumbnail[data-resource-book-category='version']:focus-visible
)
.game-resource-book-scene-card[data-resource-book-category='version'],
.game-resource-book-manager:has(
.game-resource-book-manager--main:has(
.game-resource-book-thumbnail[data-resource-book-category='art']:hover,
.game-resource-book-thumbnail[data-resource-book-category='art']:focus-visible
)
.game-resource-book-scene-titlebar[data-resource-book-category='art'],
.game-resource-book-manager:has(
.game-resource-book-thumbnail[data-resource-book-category='art']:hover,
.game-resource-book-thumbnail[data-resource-book-category='art']:focus-visible
)
.game-resource-book-scene-card[data-resource-book-category='art'],
.game-resource-book-manager:has(
.game-resource-book-thumbnail[data-resource-book-category='audio']:hover,
.game-resource-book-thumbnail[data-resource-book-category='audio']:focus-visible
)
.game-resource-book-scene-titlebar[data-resource-book-category='audio'],
.game-resource-book-manager:has(
.game-resource-book-thumbnail[data-resource-book-category='audio']:hover,
.game-resource-book-thumbnail[data-resource-book-category='audio']:focus-visible
)
.game-resource-book-scene-card[data-resource-book-category='audio'] {
translate: 0 -3px;
}
.game-resource-book-manager:has(
.game-resource-book-thumbnail[data-resource-book-category='code']:hover,
.game-resource-book-thumbnail[data-resource-book-category='code']:focus-visible
)
.game-resource-book-scene-titlebar[data-resource-book-category='code'],
.game-resource-book-manager:has(
.game-resource-book-thumbnail[data-resource-book-category='document']:hover,
.game-resource-book-thumbnail[data-resource-book-category='document']:focus-visible
)
.game-resource-book-scene-titlebar[data-resource-book-category='document'],
.game-resource-book-manager:has(
.game-resource-book-thumbnail[data-resource-book-category='version']:hover,
.game-resource-book-thumbnail[data-resource-book-category='version']:focus-visible
)
.game-resource-book-scene-titlebar[data-resource-book-category='version'],
.game-resource-book-manager:has(
.game-resource-book-thumbnail[data-resource-book-category='art']:hover,
.game-resource-book-thumbnail[data-resource-book-category='art']:focus-visible
)
.game-resource-book-scene-titlebar[data-resource-book-category='art'],
.game-resource-book-manager:has(
.game-resource-book-manager--main:has(
.game-resource-book-thumbnail[data-resource-book-category='audio']:hover,
.game-resource-book-thumbnail[data-resource-book-category='audio']:focus-visible
)
@@ -5418,16 +5348,6 @@ iframe.preview-frame {
}
.game-resource-book-scene-card .game-resource-card {
transition:
width 420ms cubic-bezier(0.2, 0.78, 0.2, 1),
height 420ms cubic-bezier(0.2, 0.78, 0.2, 1),
transform 420ms cubic-bezier(0.2, 0.78, 0.2, 1),
box-shadow 180ms ease;
}
.game-resource-book-scene--idle
.game-resource-book-scene-card
.game-resource-card {
transition: box-shadow 180ms ease;
}
@@ -5537,7 +5457,7 @@ iframe.preview-frame {
border-color: #cc8060;
outline: 0;
box-shadow: 0 16px 34px rgb(158 87 57 / 18%);
transform: translateY(-3px);
transform: none;
}
.game-resource-book-thumbnail-titlebar {
@@ -0,0 +1,9 @@
export function ResourceBookTransitionLayer() {
return (
<div
className="game-resource-book-transition-layer"
aria-hidden="true"
inert
/>
);
}
@@ -96,13 +96,13 @@ import {
} from './resourceBookLayout';
import {
initialResourceBookState,
resourceBookIsReturningCategory,
resourceBookReducer,
resourceBookSceneCategory,
resourceBookShowsChildCards,
resourceBookShowsOverviewCards,
type ResourceBookState,
} from './resourceBookModel';
import { ResourceBookTransitionLayer } from './ResourceBookTransitionLayer';
import {
DEFAULT_RESOURCE_BOOK_VIEWPORT,
keepResourceBookViewportAtReadableScale,
@@ -1080,13 +1080,8 @@ function ResourceBookScene({
const rect = overviewRects.get(category);
const categoryResources = resourcesByCategory.get(category) ?? [];
const stacks = groupResourceBookStacks(categoryResources);
const returning = resourceBookIsReturningCategory(
resourceBookState,
category,
);
const active =
showChildCards && sceneCategory === category && !returning;
const renderTransitionCards = showOverviewCards || returning;
const active = showChildCards && sceneCategory === category;
const renderTransitionCards = showOverviewCards;
const renderCards = cardsReady && (renderTransitionCards || active);
const titleLayout = active
? { left: 0, top: 0, width: '100%', opacity: 1 }
@@ -1870,11 +1865,17 @@ export default function ProjectDevelopmentView({
const next = new Map<ResourceCategory, ResourceBookOverviewRect>();
resourceBookThumbnailElementsRef.current.forEach((element, category) => {
const rect = element.getBoundingClientRect();
// Read layout coordinates, not the translated/scaled main canvas.
const main = element.closest<HTMLElement>('.game-resource-book-main');
const transformed = !main?.classList.contains('is-background');
const viewport = transformed
? resourceBookMainViewportRef.current
: DEFAULT_RESOURCE_BOOK_VIEWPORT;
next.set(category, {
left: rect.left - managerRect.left,
top: rect.top - managerRect.top,
width: rect.width,
height: rect.height,
left: (rect.left - managerRect.left - viewport.x) / viewport.scale,
top: (rect.top - managerRect.top - viewport.y) / viewport.scale,
width: rect.width / viewport.scale,
height: rect.height / viewport.scale,
});
});
setResourceBookOverviewRects((current) => {
@@ -2403,27 +2404,76 @@ export default function ProjectDevelopmentView({
};
}, []);
const cancelResourceBookTransition = useCallback(() => {
resourceBookTransitionControllerRef.current.invalidate();
}, []);
useLayoutEffect(() => {
const manager = resourceBookManagerRef.current;
if (resourceBookState.phase === 'idle') return;
if (!manager || mode !== 'resources') {
resourceBookTransitionControllerRef.current.settle();
dispatchResourceBook({
type: 'finish-transition',
token: resourceBookState.token,
});
return;
}
resourceBookTransitionControllerRef.current.play(
manager,
resourceBookState.token,
() => {
dispatchResourceBook({
type: 'finish-transition',
token: resourceBookState.token,
});
},
);
}, [resourceBookState.token, resourceBookState.phase, mode]);
const scheduleResourceBookTransition = useCallback(
(callback: (token: number) => void, delayMs: number, token: number) => {
resourceBookTransitionControllerRef.current.schedule(
callback,
delayMs,
token,
);
},
[],
);
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)
) {
resourceBookTransitionControllerRef.current.settle();
}
}, [
resourceBookState.token,
activeResourceCanvasViewport,
resourceBookOverviewRects,
resourcePositionById,
resourceCardSizeByResourceId,
canvasResources,
sortMode,
]);
const openResourceBookChild = useCallback(
(category: ResourceCategory) => {
cancelResourceBookTransition();
(category: ResourceCategory, fromWheel = false) => {
cancelResourceCardDrag();
cancelResourceCanvasPan();
cancelQueuedResourceCanvasPageWheel();
if (!fromWheel) cancelQueuedResourceCanvasPageWheel();
if (
resourceBookState.view === 'child' &&
@@ -2433,8 +2483,9 @@ export default function ProjectDevelopmentView({
return;
}
const transitionToken =
resourceBookTransitionControllerRef.current.begin();
const transitionToken = resourceBookTransitionControllerRef.current.begin(
resourceBookManagerRef.current,
);
// Keep the paged-canvas projection's logical target synchronous with the
// reducer action. React may commit the visible page later, but wheel and
// focus handlers arriving in the same turn must calculate from the new
@@ -2482,71 +2533,18 @@ export default function ProjectDevelopmentView({
// sort/category pair. Mark that fit as settled so the paged-canvas
// layout effect does not immediately recalculate it from a shared DOM
// measurement and overwrite the stored per-category viewport.
resourceCanvasFitKeysRef.current.add(categoryFitKey);
if (resourceBookState.view === 'child') {
// Keep the paged canvas and its outline in sync with the requested
// destination while the shared scene is running the return leg. The
// scene itself remains on the current child until the 320ms handoff.
resourceCanvasPageWheelRef.current.activeCategory = category;
setActiveResourceCategory(category);
// The reducer keeps the current child visible during the return leg.
scheduleResourceBookTransition(
(transitionToken) => {
setActiveResourceCategory(category);
dispatchResourceBook({
type: 'commit-return',
token: transitionToken,
});
resourceBookTransitionControllerRef.current.requestFrame(() => {
setResourceCanvasViewport(category, categoryViewport);
dispatchResourceBook({
type: 'commit-enter',
token: transitionToken,
});
scheduleResourceBookTransition(
(finishToken) => {
dispatchResourceBook({
type: 'finish-transition',
token: finishToken,
});
},
520,
transitionToken,
);
}, transitionToken);
},
320,
transitionToken,
);
return;
if (resourceBookSceneSize.width > 0 && resourceBookSceneSize.height > 0) {
resourceCanvasFitKeysRef.current.add(categoryFitKey);
}
setResourceCanvasViewport(category, categoryViewport);
resourceCanvasPageWheelRef.current.activeCategory = category;
setActiveResourceCategory(category);
scheduleResourceBookTransition(
(finishToken) => {
dispatchResourceBook({
type: 'finish-transition',
token: finishToken,
});
},
520,
transitionToken,
);
resourceBookTransitionControllerRef.current.requestFrame(() => {
dispatchResourceBook({
type: 'commit-enter',
token: transitionToken,
});
}, transitionToken);
},
[
cancelQueuedResourceCanvasPageWheel,
cancelResourceCanvasPan,
cancelResourceCardDrag,
cancelResourceBookTransition,
manifest.projectId,
projectPath,
resourceBookState,
@@ -2554,7 +2552,6 @@ export default function ProjectDevelopmentView({
resourceCardSizeByResourceId,
resourcePositionsByCategory,
setResourceCanvasViewport,
scheduleResourceBookTransition,
sortMode,
],
);
@@ -2563,61 +2560,32 @@ export default function ProjectDevelopmentView({
if (resourceBookState.view !== 'child') {
return;
}
cancelResourceBookTransition();
cancelResourceCardDrag();
cancelResourceCanvasPan();
cancelQueuedResourceCanvasPageWheel();
const transitionToken = resourceBookTransitionControllerRef.current.begin();
dispatchResourceBook({ type: 'return-to-main', token: transitionToken });
scheduleResourceBookTransition(
(finishToken) => {
dispatchResourceBook({ type: 'commit-return', token: finishToken });
scheduleResourceBookTransition(
(idleToken) => {
dispatchResourceBook({
type: 'finish-transition',
token: idleToken,
});
},
520,
finishToken,
);
},
320,
transitionToken,
const transitionToken = resourceBookTransitionControllerRef.current.begin(
resourceBookManagerRef.current,
);
dispatchResourceBook({ type: 'return-to-main', token: transitionToken });
}, [
cancelQueuedResourceCanvasPageWheel,
cancelResourceCanvasPan,
cancelResourceCardDrag,
cancelResourceBookTransition,
resourceBookState,
scheduleResourceBookTransition,
]);
const selectResourceCanvasPage = useCallback(
(category: ResourceCategory) => {
if (resourceBookView === 'main') {
openResourceBookChild(category);
return;
}
cancelResourceBookTransition();
cancelResourceCardDrag();
cancelResourceCanvasPan();
cancelQueuedResourceCanvasPageWheel();
resourceCanvasPageWheelRef.current.activeCategory = category;
setActiveResourceCategory(category);
openResourceBookChild(category);
},
[
cancelQueuedResourceCanvasPageWheel,
cancelResourceBookTransition,
cancelResourceCanvasPan,
cancelResourceCardDrag,
openResourceBookChild,
resourceBookView,
],
[openResourceBookChild],
);
const openResourceBookChildRef = useRef(openResourceBookChild);
useLayoutEffect(() => {
openResourceBookChildRef.current = openResourceBookChild;
}, [openResourceBookChild]);
const advanceResourceCanvasPage = useCallback(
(stepOffset: number) => {
const currentCategory = resourceCanvasPageWheelRef.current.activeCategory;
@@ -2639,39 +2607,9 @@ export default function ProjectDevelopmentView({
if (!nextCategory) {
return;
}
if (resourceBookState.phase === 'returning') {
if (!resourceBookState.pendingCategory) {
// A return-to-main transition has no destination category. Ignore
// any wheel step that was already queued before the collapse click;
// otherwise it can resurrect a child-enter transition and append a
// second viewport movement after the return leg.
return;
}
// A wheel gesture can arrive while a click-driven book transition is
// still in flight. Retarget the transition through the same tokenized
// path so the old timer cannot restore a stale category.
openResourceBookChild(nextCategory);
return;
}
cancelResourceCardDrag();
cancelResourceCanvasPan();
resourceCanvasPageWheelRef.current.activeCategory = nextCategory;
startTransition(() => {
setActiveResourceCategory((currentCategory) =>
resourceCanvasPageWheelRef.current.activeCategory === nextCategory
? nextCategory
: currentCategory,
);
});
openResourceBookChildRef.current(nextCategory, true);
},
[
cancelResourceCanvasPan,
cancelResourceCardDrag,
openResourceBookChild,
resourceBookState.pendingCategory,
resourceBookState.phase,
resourcePageCategories,
],
[resourcePageCategories],
);
useEffect(() => {
@@ -2735,6 +2673,7 @@ export default function ProjectDevelopmentView({
if (resourcePageCategories.length > 0) {
event.preventDefault();
if (event.ctrlKey || event.metaKey) {
resourceBookTransitionControllerRef.current.settle();
cancelQueuedResourceCanvasPageWheel();
const viewport = resolveViewport(event);
const category = viewport?.dataset.resourceSectionScroll as
@@ -2794,6 +2733,7 @@ export default function ProjectDevelopmentView({
};
resourceCanvasWheelHandlerRef.current = wheel;
const gestureStart = (rawEvent: Event) => {
resourceBookTransitionControllerRef.current.settle();
const event = rawEvent as WebKitGestureEvent;
cancelQueuedResourceCanvasPageWheel();
const viewport = resolveViewport(event);
@@ -3516,6 +3456,7 @@ export default function ProjectDevelopmentView({
);
const resetResourceCanvasViewport = useCallback(() => {
resourceBookTransitionControllerRef.current.settle();
if (!activePageCategory) {
return;
}
@@ -3532,6 +3473,7 @@ export default function ProjectDevelopmentView({
}, [activePageCategory, resourceCanvasFitBounds, setResourceCanvasViewport]);
const fitResourceBook = useCallback(() => {
resourceBookTransitionControllerRef.current.settle();
if (resourceBookView === 'main') {
const resetViewport = DEFAULT_RESOURCE_BOOK_VIEWPORT;
resourceBookMainViewportRef.current = resetViewport;
@@ -3560,6 +3502,8 @@ export default function ProjectDevelopmentView({
const zoomResourceBookBy = useCallback(
(factor: number) => {
resourceBookTransitionControllerRef.current.settle();
cancelQueuedResourceCanvasPageWheel();
const sceneElement = resourceBookManagerRef.current;
const rect = sceneElement?.getBoundingClientRect();
const anchorX = rect ? rect.width / 2 : 0;
@@ -3587,7 +3531,12 @@ export default function ProjectDevelopmentView({
if (next.scale === viewport.scale) return;
setResourceCanvasViewport(category, next);
},
[activePageCategory, resourceBookView, setResourceCanvasViewport],
[
activePageCategory,
resourceBookView,
setResourceCanvasViewport,
cancelQueuedResourceCanvasPageWheel,
],
);
const handleResourceBookWheel = useCallback(
@@ -3599,6 +3548,8 @@ export default function ProjectDevelopmentView({
}
return;
}
resourceBookTransitionControllerRef.current.settle();
cancelQueuedResourceCanvasPageWheel();
const sceneElement = resourceBookManagerRef.current;
if (!sceneElement) {
return;
@@ -3639,7 +3590,12 @@ export default function ProjectDevelopmentView({
});
setResourceCanvasViewport(category, result.viewport, true);
},
[activePageCategory, resourceBookView, setResourceCanvasViewport],
[
activePageCategory,
resourceBookView,
setResourceCanvasViewport,
cancelQueuedResourceCanvasPageWheel,
],
);
const handleResourceBookMainPointerDown = useCallback(
@@ -3656,6 +3612,7 @@ export default function ProjectDevelopmentView({
return;
}
event.currentTarget.setPointerCapture(event.pointerId);
resourceBookTransitionControllerRef.current.settle();
resourceBookMainPanRef.current = {
pointerId: event.pointerId,
startClientX: event.clientX,
@@ -3842,6 +3799,7 @@ export default function ProjectDevelopmentView({
}
cancelQueuedResourceCanvasPageWheel();
event.currentTarget.setPointerCapture(event.pointerId);
resourceBookTransitionControllerRef.current.settle();
resourceCanvasPanRef.current = {
pointerId: event.pointerId,
category: activePageCategory,
@@ -3888,6 +3846,7 @@ export default function ProjectDevelopmentView({
if (nextMode === sortMode) {
return;
}
resourceBookTransitionControllerRef.current.settle();
advanceFocusGeneration();
cancelResourceCardDrag();
cancelResourceCanvasPan();
@@ -5617,6 +5576,16 @@ export default function ProjectDevelopmentView({
className={`game-resource-manager game-resource-book-manager game-resource-book-manager--${resourceBookState.view} game-resource-book-manager--${resourceBookState.phase}`}
data-resource-book-view={resourceBookState.view}
data-resource-book-transition={resourceBookState.phase}
onPointerDownCapture={(event) => {
if (
event.currentTarget.hasAttribute('data-book-motion') &&
(event.target as Element).closest('.game-resource-card')
) {
event.preventDefault();
event.stopPropagation();
resourceBookTransitionControllerRef.current.settle();
}
}}
>
<ResourceBookScene
resources={canvasResources}
@@ -5640,6 +5609,7 @@ export default function ProjectDevelopmentView({
onPointerCancel={stopResourceCanvasPan}
onWheel={handleResourceBookWheel}
/>
<ResourceBookTransitionLayer />
<div className="game-resource-book-zoom">
<button
type="button"
@@ -1,84 +1,319 @@
export type ResourceBookTransitionController = {
begin(): number;
invalidate(): number;
isCurrent(token: number): boolean;
schedule(
callback: (token: number) => void,
delayMs: number,
token: number,
): void;
requestFrame(callback: (token: number) => void, token: number): void;
dispose(): void;
export const RESOURCE_BOOK_MOTION_DURATION = 420;
const EASING = 'cubic-bezier(0.2, 0.78, 0.2, 1)';
type Snapshot = {
key: string;
rect: { left: number; top: number; width: number; height: number };
opacity: number;
content: HTMLElement;
};
export type ResourceBookTransitionClock = {
setTimeout(callback: () => void, delayMs: number): number;
clearTimeout(timerId: number): void;
requestAnimationFrame(callback: () => void): number;
cancelAnimationFrame(frameId: number): void;
};
function browserTransitionClock(): ResourceBookTransitionClock {
return {
setTimeout: (callback, delayMs) => window.setTimeout(callback, delayMs),
clearTimeout: (timerId) => window.clearTimeout(timerId),
requestAnimationFrame: (callback) => window.requestAnimationFrame(callback),
cancelAnimationFrame: (frameId) => window.cancelAnimationFrame(frameId),
};
export function resourceBookFlipTransform(
from: Snapshot['rect'],
to: Snapshot['rect'],
) {
return `translate(${from.left - to.left}px, ${from.top - to.top}px) scale(${from.width / to.width}, ${from.height / to.height})`;
}
export function createResourceBookTransitionController(
clock: ResourceBookTransitionClock = browserTransitionClock(),
): ResourceBookTransitionController {
function validRect(rect: Snapshot['rect']) {
return (
Object.values(rect).every(Number.isFinite) &&
rect.width > 0 &&
rect.height > 0
);
}
// Freeze presentation only. Clones cannot expose duplicate controls, replay
// media, or depend on CSS selectors belonging to the old ancestor tree.
function clonePresentation(
source: HTMLElement,
rect: Snapshot['rect'],
): HTMLElement {
const sourceStyle = getComputedStyle(source);
const width =
source.offsetWidth || Number.parseFloat(sourceStyle.width) || rect.width;
const height =
source.offsetHeight || Number.parseFloat(sourceStyle.height) || rect.height;
let transform = `scale(${rect.width / width}, ${rect.height / height})`;
if (typeof DOMMatrix !== 'undefined') {
let matrix = new DOMMatrix();
for (
let element: HTMLElement | null = source;
element;
element = element.parentElement
) {
const value = getComputedStyle(element).transform;
if (value && value !== 'none')
matrix = new DOMMatrix(value).multiply(matrix);
}
const { a, b, c, d } = matrix;
const left = Math.min(0, a * width, c * height, a * width + c * height);
const top = Math.min(0, b * width, d * height, b * width + d * height);
transform = `matrix(${a}, ${b}, ${c}, ${d}, ${-left}, ${-top})`;
}
const clone = source.cloneNode(true) as HTMLElement;
const originals = [source, ...source.querySelectorAll<HTMLElement>('*')];
const copies = [clone, ...clone.querySelectorAll<HTMLElement>('*')];
originals.forEach((original, index) => {
const copy = copies[index]!;
const style = getComputedStyle(original);
for (const property of Array.from(style)) {
copy.style.setProperty(property, style.getPropertyValue(property));
}
for (const attribute of Array.from(copy.attributes)) {
if (
attribute.name === 'id' ||
attribute.name.startsWith('data-') ||
attribute.name.startsWith('aria-') ||
attribute.name === 'autoplay'
) {
copy.removeAttribute(attribute.name);
}
}
copy.style.transition = 'none';
copy.style.animation = 'none';
copy.style.pointerEvents = 'none';
copy.setAttribute('tabindex', '-1');
if (copy instanceof HTMLMediaElement) {
copy.removeAttribute('src');
copy.querySelectorAll('source').forEach((item) => item.remove());
copy.autoplay = false;
}
if (original instanceof HTMLVideoElement && original.readyState >= 2) {
try {
const bitmap = document.createElement('canvas');
bitmap.width = original.videoWidth;
bitmap.height = original.videoHeight;
bitmap.getContext('2d')?.drawImage(original, 0, 0);
const image = document.createElement('img');
image.src = bitmap.toDataURL();
image.style.cssText = copy.style.cssText;
copy.replaceWith(image);
} catch {
// Cross-origin media may prohibit readback; retain the existing poster.
}
}
});
Object.assign(clone.style, {
position: 'absolute',
left: '0',
top: '0',
margin: '0',
transform,
transformOrigin: '0 0',
translate: 'none',
rotate: 'none',
scale: 'none',
width: `${width}px`,
height: `${height}px`,
opacity: '1',
visibility: 'visible',
});
return clone;
}
function capture(root: HTMLElement): Map<string, Snapshot> {
const result = new Map<string, Snapshot>();
const bounds = root.getBoundingClientRect();
const layer = root.querySelector<HTMLElement>(
'.game-resource-book-transition-layer',
);
const moving = layer?.querySelectorAll<HTMLElement>('[data-motion-snapshot]');
const elements = moving?.length
? Array.from(moving)
: Array.from(
root.querySelectorAll<HTMLElement>(
'.game-resource-book-scene .game-resource-card, .game-resource-book-scene-titlebar',
),
);
for (const element of elements) {
const box = element.getBoundingClientRect();
const rect = {
left: box.left - bounds.left,
top: box.top - bounds.top,
width: box.width,
height: box.height,
};
if (
!validRect(rect) ||
box.right < bounds.left ||
box.left > bounds.right ||
box.bottom < bounds.top ||
box.top > bounds.bottom
)
continue;
let opacity = 1;
let visible = true;
for (
let parent: HTMLElement | null = element;
parent && parent !== root;
parent = parent.parentElement
) {
const style = getComputedStyle(parent);
opacity *= Number(style.opacity || 1);
if (style.visibility === 'hidden' || style.display === 'none')
visible = false;
}
if (!visible || opacity <= 0) continue;
const key =
element.dataset.motionSnapshot ??
(element.dataset.resourceCardId
? `card:${element.dataset.resourceCardId}`
: `title:${element.dataset.resourceBookCategory}`);
result.set(key, {
key,
rect,
opacity,
content: clonePresentation(
element.dataset.motionSnapshot
? (element.firstElementChild as HTMLElement)
: element,
rect,
),
});
}
return result;
}
export type ResourceBookTransitionController = ReturnType<
typeof createResourceBookTransitionController
>;
export function createResourceBookTransitionController() {
let token = 0;
let timerId: number | null = null;
let frameId: number | null = null;
let source = new Map<string, Snapshot>();
let animations: Animation[] = [];
let layer: HTMLElement | null = null;
let root: HTMLElement | null = null;
let completion: (() => void) | null = null;
let observer: ResizeObserver | null = null;
let motionPreference: MediaQueryList | null = null;
const clearPending = () => {
if (timerId !== null) {
clock.clearTimeout(timerId);
timerId = null;
}
if (frameId !== null) {
clock.cancelAnimationFrame(frameId);
frameId = null;
}
const clear = () => {
animations.forEach((animation) => animation.cancel());
animations = [];
layer?.replaceChildren();
root?.removeAttribute('data-book-motion');
observer?.disconnect();
observer = null;
window.removeEventListener('resize', settle);
motionPreference?.removeEventListener?.('change', settle);
motionPreference = null;
completion = null;
};
const settle = () => {
const done = completion;
clear();
source.clear();
done?.();
};
const invalidate = () => {
token += 1;
clearPending();
clear();
source.clear();
return token;
};
return {
begin: invalidate,
invalidate,
isCurrent: (expectedToken) => expectedToken === token,
schedule: (callback, delayMs, expectedToken) => {
if (timerId !== null) {
clock.clearTimeout(timerId);
}
timerId = clock.setTimeout(() => {
timerId = null;
if (expectedToken === token) {
callback(expectedToken);
}
}, delayMs);
},
requestFrame: (callback, expectedToken) => {
if (frameId !== null) {
clock.cancelAnimationFrame(frameId);
}
frameId = clock.requestAnimationFrame(() => {
frameId = null;
if (expectedToken === token) {
callback(expectedToken);
}
});
},
dispose: () => {
begin(manager: HTMLElement | null) {
const current = manager ? capture(manager) : new Map<string, Snapshot>();
invalidate();
root = manager;
source = current;
return token;
},
invalidate,
settle,
isCurrent: (expected: number) => token === expected,
play(manager: HTMLElement, expected: number, done: () => void) {
if (token !== expected) return;
root = manager;
completion = done;
layer = manager.querySelector('.game-resource-book-transition-layer');
motionPreference =
window.matchMedia?.('(prefers-reduced-motion: reduce)') ?? null;
if (
!layer ||
typeof layer.animate !== 'function' ||
motionPreference?.matches ||
source.size === 0
) {
settle();
return;
}
const target = capture(manager);
const keys = new Set([...source.keys(), ...target.keys()]);
if (!target.size) {
settle();
return;
}
try {
for (const key of keys) {
const from = source.get(key);
const to = target.get(key);
const item = to ?? from!;
const wrapper = document.createElement('div');
wrapper.dataset.motionSnapshot = key;
Object.assign(wrapper.style, {
position: 'absolute',
left: `${item.rect.left}px`,
top: `${item.rect.top}px`,
width: `${item.rect.width}px`,
height: `${item.rect.height}px`,
transformOrigin: '0 0',
pointerEvents: 'none',
});
wrapper.append(item.content);
layer.append(wrapper);
const animation = wrapper.animate(
[
{
transform: resourceBookFlipTransform(
from?.rect ?? item.rect,
item.rect,
),
opacity: from?.opacity ?? 0,
},
{
transform: 'translate(0px, 0px) scale(1, 1)',
opacity: to?.opacity ?? 0,
},
],
{
duration: RESOURCE_BOOK_MOTION_DURATION,
easing: EASING,
fill: 'both',
},
);
animations.push(animation);
// Attach rejection handling immediately in case a later item fails
// before the aggregate completion handler has been installed.
void animation.finished.catch(() => undefined);
}
} catch {
settle();
return;
}
manager.setAttribute('data-book-motion', 'running');
// Canceled promises belong to their original generation.
void Promise.all(animations.map((animation) => animation.finished)).then(
() => {
if (token === expected) settle();
},
() => {
if (token === expected) settle();
},
);
window.addEventListener('resize', settle);
motionPreference?.addEventListener?.('change', settle);
if (typeof ResizeObserver !== 'undefined') {
const box = manager.getBoundingClientRect();
observer = new ResizeObserver(() => {
const next = manager.getBoundingClientRect();
if (next.width !== box.width || next.height !== box.height) settle();
});
observer.observe(manager);
}
},
dispose: invalidate,
};
}
@@ -1,32 +1,24 @@
import type { ProjectResourceCategory } from './resourceProjectionModel';
export type ResourceBookCategory = ProjectResourceCategory;
export type ResourceBookPhase =
| 'idle'
| 'entering'
| 'returning'
| 'returning-main';
export type ResourceBookPhase = 'idle' | 'entering' | 'returning-main';
export type ResourceBookState = {
view: 'main' | 'child';
category: ResourceBookCategory | null;
pendingCategory: ResourceBookCategory | null;
phase: ResourceBookPhase;
token: number;
};
export type ResourceBookAction =
| { type: 'open-category'; category: ResourceBookCategory; token: number }
| { type: 'commit-enter'; token: number }
| { type: 'finish-transition'; token: number }
| { type: 'return-to-main'; token: number }
| { type: 'commit-return'; token: number }
| { type: 'reset'; token: number };
export const initialResourceBookState: ResourceBookState = {
view: 'main',
category: null,
pendingCategory: null,
phase: 'idle',
token: 0,
};
@@ -44,35 +36,12 @@ export function resourceBookReducer(
) {
return state;
}
if (state.view === 'child') {
return {
...state,
pendingCategory: action.category,
phase: 'returning',
token: action.token,
};
}
return {
...state,
category: null,
pendingCategory: action.category,
phase: 'entering',
token: action.token,
};
}
case 'commit-enter': {
if (state.phase !== 'entering' || state.token !== action.token) {
return state;
}
const category = state.pendingCategory;
if (!category) {
return state;
}
return {
...state,
view: 'child',
category,
pendingCategory: null,
category: action.category,
phase: 'entering',
token: action.token,
};
}
case 'finish-transition':
@@ -83,30 +52,11 @@ export function resourceBookReducer(
}
return {
...state,
phase: 'returning',
pendingCategory: null,
view: 'main',
category: null,
phase: 'returning-main',
token: action.token,
};
case 'commit-return':
return state.phase === 'returning' && state.token === action.token
? state.pendingCategory
? {
...state,
view: 'main',
category: null,
phase: 'entering',
}
: {
...state,
view: 'main',
category: null,
pendingCategory: null,
// Returning to the main canvas is already completed by the
// shared-card shrink leg. Keep a distinct phase so the main
// viewport does not receive a second transform transition.
phase: 'returning-main',
}
: state;
case 'reset':
return {
...initialResourceBookState,
@@ -118,24 +68,13 @@ export function resourceBookReducer(
}
export function resourceBookSceneCategory(state: ResourceBookState) {
return state.view === 'child' ? state.category : state.pendingCategory;
return state.category;
}
export function resourceBookShowsOverviewCards(state: ResourceBookState) {
// The overview is the steady state of the main canvas as well as the
// return/enter handoff. Keeping it visible outside the entering phase is
// what makes category titles and the shared preview cards present on the
// initial main canvas instead of only during a transition.
return state.view === 'main' || state.phase === 'returning';
return state.view === 'main';
}
export function resourceBookShowsChildCards(state: ResourceBookState) {
return state.view === 'child';
}
export function resourceBookIsReturningCategory(
state: ResourceBookState,
category: ResourceBookCategory,
) {
return state.phase === 'returning' && state.category === category;
}
@@ -793,18 +793,8 @@ export function registerProjectWorkbenchFoundationTests() {
) as HTMLElement,
).getByRole('button', { name: '收起资源' }),
);
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 340));
});
expect(
screen
.getByRole('region', { name: '资源主画布' })
.closest('[data-resource-book-view]')
?.getAttribute('data-resource-book-transition'),
).toBe('returning-main');
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 540));
});
// jsdom has no Web Animations: navigation must settle immediately,
// without waiting for a guessed CSS duration.
expect(
screen
.getByRole('region', { name: '资源主画布' })
@@ -2012,7 +2002,9 @@ export function registerProjectWorkbenchFoundationTests() {
).not.toBeNull();
expect(screen.queryByText('asset:asset-removed')).toBeNull();
fireEvent.click(screen.getByRole('button', { name: '收起资源' }));
fireEvent.click(
within(versionFocus).getByRole('button', { name: '收起资源' }),
);
fireEvent.click(
within(screen.getByLabelText('资源栏目大纲')).getByRole('button', {
name: /美术资源/,
@@ -1,72 +1,193 @@
import { describe, expect, it } from 'vitest';
/** @vitest-environment jsdom */
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
createResourceBookTransitionController,
type ResourceBookTransitionClock,
resourceBookFlipTransform,
} from '../src/view/project-development/resourceBookController';
function createClock() {
let nextId = 1;
const timers = new Map<number, () => void>();
const frames = new Map<number, () => void>();
const clock: ResourceBookTransitionClock = {
setTimeout(callback) {
const id = nextId++;
timers.set(id, callback);
return id;
},
clearTimeout(id) {
timers.delete(id);
},
requestAnimationFrame(callback) {
const id = nextId++;
frames.set(id, callback);
return id;
},
cancelAnimationFrame(id) {
frames.delete(id);
},
};
function rect(left = 0, top = 0, width = 100, height = 80): DOMRect {
return {
clock,
runTimer() {
const callback = timers.values().next().value as (() => void) | undefined;
if (callback) {
callback();
}
},
runFrame() {
const callback = frames.values().next().value as (() => void) | undefined;
if (callback) {
callback();
}
},
left,
top,
width,
height,
x: left,
y: top,
right: left + width,
bottom: top + height,
toJSON: () => ({}),
};
}
describe('resource book transition controller', () => {
it('invalidates delayed callbacks when a newer transition begins', () => {
const fake = createClock();
const controller = createResourceBookTransitionController(fake.clock);
const firstToken = controller.begin();
const calls: number[] = [];
controller.schedule((token) => calls.push(token), 320, firstToken);
function fixture() {
const root = document.createElement('div');
root.innerHTML = `<div class="game-resource-book-scene"><div class="game-resource-book-scene-world">
<div class="game-resource-card" data-resource-card-id="one"><button id="original">Open</button></div>
</div></div><div class="game-resource-book-transition-layer" inert aria-hidden="true"></div>`;
document.body.append(root);
vi.spyOn(root, 'getBoundingClientRect').mockReturnValue(rect(0, 0, 800, 600));
const card = root.querySelector<HTMLElement>('.game-resource-card')!;
const measure = vi
.spyOn(card, 'getBoundingClientRect')
.mockReturnValue(rect());
const flights: {
finish(): void;
reject(): void;
cancel: ReturnType<typeof vi.fn>;
frames: Keyframe[];
}[] = [];
vi.spyOn(Element.prototype, 'animate').mockImplementation((frames) => {
let finish!: () => void;
let reject!: () => void;
const finished = new Promise<Animation>((resolve, fail) => {
finish = () => resolve({} as Animation);
reject = () => fail(new Error('canceled'));
});
const cancel = vi.fn(reject);
flights.push({ finish, reject, cancel, frames: frames as Keyframe[] });
return { finished, cancel } as unknown as Animation;
});
return { root, card, measure, flights };
}
const secondToken = controller.begin();
fake.runTimer();
expect(calls).toEqual([]);
expect(controller.isCurrent(secondToken)).toBe(true);
const originalAnimate = Object.getOwnPropertyDescriptor(
Element.prototype,
'animate',
);
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
if (originalAnimate)
Object.defineProperty(Element.prototype, 'animate', originalAnimate);
else delete (Element.prototype as Partial<Element>).animate;
document.body.replaceChildren();
});
function setup() {
Object.defineProperty(Element.prototype, 'animate', {
configurable: true,
writable: true,
value: () => ({}),
});
return fixture();
}
describe('resource book FLIP controller', () => {
it('uses screen geometry independently of either viewport', () => {
expect(
resourceBookFlipTransform(rect(30, 40, 50, 20), rect(10, 10, 100, 80)),
).toBe('translate(20px, 30px) scale(0.5, 0.25)');
});
it('cancels a pending frame on disposal', () => {
const fake = createClock();
const controller = createResourceBookTransitionController(fake.clock);
const token = controller.begin();
const calls: number[] = [];
controller.requestFrame((frameToken) => calls.push(frameToken), token);
it('finishes from animation completion, removes clones and preserves real controls', async () => {
const { root, measure, flights } = setup();
const controller = createResourceBookTransitionController();
const token = controller.begin(root);
measure.mockReturnValue(rect(150, 100, 200, 160));
const done = vi.fn();
controller.play(root, token, done);
expect(root.dataset.bookMotion).toBe('running');
expect(root.querySelectorAll('#original')).toHaveLength(1);
expect(flights[0].frames[0].transform).toBe(
'translate(-150px, -100px) scale(0.5, 0.5)',
);
expect(done).not.toHaveBeenCalled();
flights[0].finish();
await vi.waitFor(() => expect(done).toHaveBeenCalledTimes(1));
expect(root.querySelectorAll('[data-motion-snapshot]')).toHaveLength(0);
expect(root.hasAttribute('data-book-motion')).toBe(false);
controller.dispose();
});
it('retargets from the current overlay geometry and ignores old completion', async () => {
const { root, measure, flights } = setup();
const controller = createResourceBookTransitionController();
const firstDone = vi.fn();
let token = controller.begin(root);
measure.mockReturnValue(rect(200, 100, 200, 160));
controller.play(root, token, firstDone);
const moving = root.querySelector<HTMLElement>('[data-motion-snapshot]')!;
vi.spyOn(moving, 'getBoundingClientRect').mockReturnValue(
rect(90, 40, 150, 120),
);
token = controller.begin(root);
measure.mockReturnValue(rect(300, 200, 100, 80));
const secondDone = vi.fn();
controller.play(root, token, secondDone);
expect(flights[1].frames[0].transform).toBe(
'translate(-210px, -160px) scale(1.5, 1.5)',
);
await Promise.resolve();
expect(firstDone).not.toHaveBeenCalled();
expect(secondDone).not.toHaveBeenCalled();
flights[1].finish();
await vi.waitFor(() => expect(secondDone).toHaveBeenCalledTimes(1));
controller.dispose();
});
it.each(['reduced', 'zero', 'unsupported'])(
'settles without a timer for %s motion',
(reason) => {
const { root, measure, flights } = setup();
if (reason === 'zero') measure.mockReturnValue(rect(0, 0, 0, 0));
if (reason === 'unsupported')
delete (Element.prototype as Partial<Element>).animate;
if (reason === 'reduced')
vi.stubGlobal('matchMedia', () => ({ matches: true }));
const controller = createResourceBookTransitionController();
const token = controller.begin(root);
const done = vi.fn();
controller.play(root, token, done);
expect(done).toHaveBeenCalledTimes(1);
expect(flights).toHaveLength(0);
controller.dispose();
},
);
it('settles on resize and suppresses completion after disposal', async () => {
const { root, flights } = setup();
const controller = createResourceBookTransitionController();
const done = vi.fn();
controller.play(root, controller.begin(root), done);
window.dispatchEvent(new Event('resize'));
expect(done).toHaveBeenCalledTimes(1);
controller.play(root, controller.begin(root), done);
controller.dispose();
flights[1].finish();
await Promise.resolve();
expect(done).toHaveBeenCalledTimes(1);
expect(root.hasAttribute('data-book-motion')).toBe(false);
});
it('cleans partially started animations when animation creation fails', () => {
const { root } = setup();
vi.mocked(Element.prototype.animate).mockImplementation(() => {
throw new Error('animation unavailable');
});
const controller = createResourceBookTransitionController();
const done = vi.fn();
controller.play(root, controller.begin(root), done);
expect(done).toHaveBeenCalledTimes(1);
expect(root.querySelectorAll('[data-motion-snapshot]')).toHaveLength(0);
expect(root.hasAttribute('data-book-motion')).toBe(false);
controller.dispose();
});
it('cancellation never waits for a CSS timer and leaves the viewport untouched', async () => {
const { root, flights } = setup();
const world = root.querySelector<HTMLElement>(
'.game-resource-book-scene-world',
)!;
world.style.transform = 'translate(20px, 40px) scale(1.5)';
const controller = createResourceBookTransitionController();
const done = vi.fn();
controller.play(root, controller.begin(root), done);
controller.settle();
expect(flights[0].cancel).toHaveBeenCalledTimes(1);
expect(done).toHaveBeenCalledTimes(1);
await Promise.resolve();
expect(world.style.transform).toBe('translate(20px, 40px) scale(1.5)');
controller.dispose();
fake.runFrame();
expect(calls).toEqual([]);
});
});
@@ -12,65 +12,38 @@ describe('resource book state machine', () => {
expect(resourceBookShowsOverviewCards(initialResourceBookState)).toBe(true);
});
it('keeps main view free of real cards until the enter frame commits', () => {
it('commits the child target immediately and only keeps the motion phase transient', () => {
const entering = resourceBookReducer(initialResourceBookState, {
type: 'open-category',
category: 'document',
token: 1,
});
expect(entering).toMatchObject({
view: 'main',
category: null,
pendingCategory: 'document',
view: 'child',
category: 'document',
phase: 'entering',
token: 1,
});
expect(resourceBookSceneCategory(entering)).toBe('document');
const child = resourceBookReducer(entering, {
type: 'commit-enter',
token: 1,
});
expect(child).toMatchObject({
view: 'child',
category: 'document',
pendingCategory: null,
phase: 'entering',
});
});
it('retains the requested destination across a return leg', () => {
it('retargets directly from one child canvas to another', () => {
const current = resourceBookReducer(initialResourceBookState, {
type: 'open-category',
category: 'document',
token: 1,
});
const child = resourceBookReducer(current, {
type: 'commit-enter',
token: 1,
});
const switching = resourceBookReducer(child, {
const switching = resourceBookReducer(current, {
type: 'open-category',
category: 'art',
token: 2,
});
expect(switching).toMatchObject({
view: 'child',
category: 'document',
pendingCategory: 'art',
phase: 'returning',
});
const overview = resourceBookReducer(switching, {
type: 'commit-return',
token: 2,
});
expect(overview).toMatchObject({
view: 'main',
category: null,
pendingCategory: 'art',
category: 'art',
phase: 'entering',
});
expect(resourceBookSceneCategory(overview)).toBe('art');
expect(resourceBookSceneCategory(switching)).toBe('art');
});
it('marks a return to the main canvas separately from a child enter transition', () => {
@@ -79,23 +52,13 @@ describe('resource book state machine', () => {
category: 'document',
token: 1,
});
const child = resourceBookReducer(entering, {
type: 'commit-enter',
token: 1,
});
const returning = resourceBookReducer(child, {
const returning = resourceBookReducer(entering, {
type: 'return-to-main',
token: 2,
});
const main = resourceBookReducer(returning, {
type: 'commit-return',
token: 2,
});
expect(main).toMatchObject({
expect(returning).toMatchObject({
view: 'main',
category: null,
pendingCategory: null,
phase: 'returning-main',
});
});
@@ -106,11 +69,7 @@ describe('resource book state machine', () => {
category: 'document',
token: 1,
});
const child = resourceBookReducer(entering, {
type: 'commit-enter',
token: 1,
});
const switching = resourceBookReducer(child, {
const switching = resourceBookReducer(entering, {
type: 'open-category',
category: 'art',
token: 2,
@@ -121,10 +80,9 @@ describe('resource book state machine', () => {
});
expect(main).toMatchObject({
view: 'child',
category: 'document',
pendingCategory: null,
phase: 'returning',
view: 'main',
category: null,
phase: 'returning-main',
token: 3,
});
});
@@ -140,9 +98,6 @@ describe('resource book state machine', () => {
category: 'art',
token: 2,
});
expect(
resourceBookReducer(switched, { type: 'commit-enter', token: 1 }),
).toEqual(switched);
expect(
resourceBookReducer(switched, { type: 'finish-transition', token: 1 }),
).toEqual(switched);
@@ -2,6 +2,13 @@
> 当前口径:本文件保留可复用的排障经验;历史条目的旧路由、旧版本和已删除文档仅作根因背景,不得据此恢复退役入口。当前命令、路由和 schema 以代码与 `docs/README.md` 为准。
## 2026-09-05 资源画本转场与视口分离
- `resourceBookController` 使用独立 FLIP 快照层和 Web Animations 完成信号;逻辑栏目立即提交,不用定时器猜 CSS 动画终点。再次导航从当前快照位置开始,旧完成回调由 token 失效。
- 屏幕 DOMRect 与逻辑布局坐标不能混用。主画布缩略入口测量先逆 viewport;快照复制必须保留祖先缩放对内部图片、文字的影响,单纯把克隆外框改成屏幕宽高会产生内部留白。
- 点击和滚轮共用导航入口,但滚轮提交不得重置已有的冷却和队列;监听器使用最新导航回调引用,避免视觉 phase 更新重装监听器并清空待处理手势。
- jsdom 不执行真实转场;定向模型测试之外,仍需浏览器核验中断连续性、图片/文字尺寸、viewport 保留和窄屏布局。完整契约见资源自由画板技术方案。
## 2026-09-02 Tauri 事件桥在浏览器预览中必须 fail-safe
- **现象**Vitest/jsdom 挂载 AGC 客户端时,错误报告通知调用 `@tauri-apps/api/event.listen`,因缺少 `window.__TAURI_INTERNALS__` 产生未处理拒绝;测试断言虽通过,CI 仍以 unhandled errors 失败。
@@ -1,38 +1,26 @@
# Game Agent 资源自由画板与快速编辑
## 2026-09-04 动画回归排查与重构收敛方案
## 2026-09-05 资源画本独立转场
最近一组资源画本提交(`f339b383f``019b1761f``f883d4bf2``069248b29``820fbfd98`)把主画布、子画布、真实资源卡、分页画布和滚轮转发逐步叠加在同一个页面组件中,造成状态、坐标和副作用互相耦合。本阶段先完成低风险收敛:
- `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,过期完成和取消均不能提交新状态。窗口尺寸变化、排序或异步布局更新时直接收尾到当前目标,避免使用过期几何。
- 缩放、适应内容和空白拖动先结束视觉转场再执行用户输入;转场中的资源卡 pointer-down 不启动持久化拖拽。普通滚轮保留分页 controller 的原有阈值、冷却与队列,导航提交不能重置该冷却;Ctrl/Meta 滚轮取消待切页队列。
- viewport 只属于用户交互状态,按主画布以及 `sortMode/category` 隔离保存。转场不插值也不持久化 viewport;测量主画布缩略入口时先逆变换回布局坐标,避免缩放后的 DOMRect 被二次缩放。只有真实非零测量才能标记首次 fit 完成。
- 真实 `ResourceCard` 只保留一棵由 `ResourceBookScene` 管理的节点树;主画布缩略卡只负责入口展示,不再复制真实卡片或预览
- 修复主画布稳定 `main/idle` 状态误把标题和共享预览卡隐藏的问题;主画布与返回中的 overview 统一保持可见,只有进入子画布后才切换为活动栏目。
- 转场 timer、`requestAnimationFrame` 和项目切换绑定同一个 token;新目标、切项目或卸载时统一失效旧回调,避免旧动画写入新栏目。
- 从大纲或“下一页”进入时使用已测量的缩略卡布局作为过渡起点;子画布互切先同步逻辑目标栏目,再按既有 320ms/520ms 节奏完成返回与进入动画。
- 主画布 viewport 与子画布 viewport 分离;普通滚轮直接复用分页画布 handler,Ctrl/Meta 滚轮只更新当前 viewport,不再向隐藏 DOM 重新派发 `WheelEvent`
- 转场阶段由 `ResourceBookScene` 统一承载当前 viewport:主画布缩放后的背景、标题、预览卡,以及进入/返回子画布时的目标缩放,都在同一条 `transform` 动画链路中完成,避免缩放后卡片和背景使用不同坐标系。
- 依赖图尚未完成读取时不挂载真实资源卡;失败时允许使用明确的 fallback 快照,避免预览和布局在未确定的图上提前启动。
重构按三个边界推进,不改变当前动画视觉参数:
1. 已抽离 `resourceBookModel``resourceBookLayout``resourceBookViewport` 纯模块,以 reducer 管理 `main / child / entering / returning`,组件只订阅投影并渲染。
2. 已抽离 `resourceBookController``resourceCanvasController`,由 tokenized transition controller 管理 timer/RAF,由分页 controller 管理滚轮阈值、方向、节流和重置;主画布、子画布和分页画布不再通过隐藏 DOM 转发推进状态。
3. 已删除历史资源画本动画规则和重复 hover 选择器,并为快速切换、项目切换、origin 解析、viewport 隔离、唯一预览观察和滚轮状态补充回归测试。
### 当前验证与未收敛项(2026-09-04)
`npm --prefix apps/ai-game-creator-shell run typecheck``npm run check:encoding``git diff --check` 和相关 ESLint 检查已通过。`apps/ai-game-creator-shell/tests/appSurface.test.ts` 已恢复为 `384 passed / 0 failed`,资源画本模型与 controller 测试合计 `9 passed / 0 failed`;另补充主画布稳定态标题与共享预览卡回归场景。
测试仍会输出既有的 React `act(...)` 提示,以及 jsdom 未实现 `HTMLMediaElement.prototype.pause` 的 stderr;这些是测试环境警告,不影响断言结果,也未新增生产兼容层。
验收使用 `resourceBookController.test.ts``resourceBookModel.test.ts``resourceBookViewport.test.ts``appSurface.test.ts`;页面 `.suite.ts``appSurface.test.ts` 注册,不能作为独立 Vitest 文件运行。真实浏览器需覆盖主/子往返、连续改选、滚轮切换、取消后缩放、尺寸变化、减少动画偏好,以及快照图片和字体的正确比例。jsdom 不模拟真实插值,不能代替浏览器验收
### 2026-09-05 子画布无限平移与初始可读比例
子画布改为固定视口加可无限平移的 world 层:视口本身保持 `overflow: hidden`,平移和缩放只作用于 `.game-resource-book-scene-world`,不再让内容容器的边界限制拖动范围。首次 fit(包括隐藏分页画布提前完成的 fit)统一保证比例不低于 `1:1`,避免子画布展开后卡片缩成不可读的小块;用户后续主动缩放或平移的视口仍按每个分类独立保留。新增 viewport 回归测试覆盖初始可读比例和超出内容边界双向平移。
返回主画布时使用独立的 `returning-main` 阶段:卡片先完成子画布到主画布预览位的缩回,随后主画布直接进入稳定布局,不再重复播放 world/main viewport 的平移动画;子画布之间切换仍使用原有的返回后进入动画。
返回主画布时立即提交主画布布局,由独立快照层完成缩回;world/main viewport 不播放附加动画。
## 2026-09-04 资源画本 UI 阶段补充
资源工作台当前产品方向调整为“主画布 + 子画布”的统一画本模型。主画布展示各子画布的缩略卡片,不直接展开真实资源内容;缩略卡片内部按资源类型显示有限层叠卡片,最多展示三层,更多资源以虚化卡片提示。进入子画布时使用共享元素转场:标题栏和资源卡片沿用同一组 DOM 元素,从缩略卡片的原始位置移动、展开到子画布中的目标位置,不允许先播放独立放大动画再硬切另一套内容;主画布预览和子画布展开态必须复用同一套卡片视觉与标题栏结构。进入子画布时在标题栏提供缩回主画布的缩放按钮;从一个子画布切换到另一个子画布时必须先完成缩回主画布,再进入目标子画布。
资源工作台采用“主画布 + 子画布”的统一画本模型。主画布展示各子画布的缩略入口,内部按资源类型显示有限层叠卡片,最多三层,更多资源以虚化卡片提示。主画布预览和子画布展开态复用同一套卡片视觉与标题栏结构,共享元素快照从当前屏幕位置过渡到目标位置。子画布标题栏提供缩回主画布的按钮;子画布互切直接到达目标,不强制经过主画布。
本阶段先实现前端 UI、导航状态和转场表现,资源真实跨画布移动、主画布正式资源登记、上传/生成任务、版本绑定以及 UI 编辑器导入与 UI 包工作流继续沿用现有权威链路,待 UI 编辑器完善后再接入。临时画本视图状态不得替代 manifest、草稿或其它后端业务真相。