重构资源画本独立转场与中断处理
使用独立FLIP快照层和Web Animations统一动画收尾 统一分类导航并保留滚轮节流和各画布视口 修复快照缩放与悬停导致的终点偏移 补充动画回归测试并更新资源画本技术文档
This commit is contained in:
@@ -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 {
|
||||
|
||||
+9
@@ -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"
|
||||
|
||||
+303
-68
@@ -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、草稿或其它后端业务真相。
|
||||
|
||||
|
||||
Reference in New Issue
Block a user