From 5bcaca72335f99ff5eed69e6f0c7dc90bc9c5ae3 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Sat, 5 Sep 2026 09:10:56 +0800 Subject: [PATCH] =?UTF-8?q?=E9=87=8D=E6=9E=84=E8=B5=84=E6=BA=90=E7=94=BB?= =?UTF-8?q?=E6=9C=AC=E7=94=BB=E5=B8=83=E4=B8=8E=E8=BD=AC=E5=9C=BA=E7=8A=B6?= =?UTF-8?q?=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 统一主画布与子画布的可见渲染层和 viewport 状态管理。 将子画布改为固定视口加无限平移 world,并保证首次 fit 的可读比例。 补充转场、滚轮、viewport 和资源画本回归测试,更新技术方案文档。 --- apps/ai-game-creator-shell/src/styles.css | 152 +-- .../src/view/project-development/index.tsx | 1080 ++++++++++------- .../resourceBookController.ts | 84 ++ .../project-development/resourceBookLayout.ts | 93 ++ .../project-development/resourceBookModel.ts | 138 +++ .../resourceBookViewport.ts | 119 ++ .../resourceCanvasController.ts | 94 ++ .../tests/appSurface/home.suite.ts | 49 +- .../appSurface/project-development.suite.ts | 271 +++-- .../tests/resourceBookController.test.ts | 72 ++ .../tests/resourceBookModel.test.ts | 94 ++ .../tests/resourceBookViewport.test.ts | 32 + .../tests/resourceCanvasController.test.ts | 71 ++ ...】GameAgent资源自由画板与快速编辑-2026-08-20.md | 28 + 14 files changed, 1710 insertions(+), 667 deletions(-) create mode 100644 apps/ai-game-creator-shell/src/view/project-development/resourceBookController.ts create mode 100644 apps/ai-game-creator-shell/src/view/project-development/resourceBookLayout.ts create mode 100644 apps/ai-game-creator-shell/src/view/project-development/resourceBookModel.ts create mode 100644 apps/ai-game-creator-shell/src/view/project-development/resourceBookViewport.ts create mode 100644 apps/ai-game-creator-shell/src/view/project-development/resourceCanvasController.ts create mode 100644 apps/ai-game-creator-shell/tests/resourceBookController.test.ts create mode 100644 apps/ai-game-creator-shell/tests/resourceBookModel.test.ts create mode 100644 apps/ai-game-creator-shell/tests/resourceBookViewport.test.ts create mode 100644 apps/ai-game-creator-shell/tests/resourceCanvasController.test.ts diff --git a/apps/ai-game-creator-shell/src/styles.css b/apps/ai-game-creator-shell/src/styles.css index 6d2c336ba..73324f471 100644 --- a/apps/ai-game-creator-shell/src/styles.css +++ b/apps/ai-game-creator-shell/src/styles.css @@ -5182,6 +5182,19 @@ iframe.preview-frame { inset: 0; overflow: hidden; pointer-events: none; + transition: none; +} + +.game-resource-book-scene-world { + position: absolute; + inset: 0; + transform-origin: 0 0; + transition: none; +} + +.game-resource-book-scene--entering .game-resource-book-scene-world, +.game-resource-book-scene--returning .game-resource-book-scene-world { + transition: transform 420ms cubic-bezier(0.2, 0.78, 0.2, 1); } .game-resource-book-scene--child { @@ -5283,6 +5296,7 @@ iframe.preview-frame { 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; } @@ -5292,6 +5306,13 @@ 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; @@ -5437,16 +5458,24 @@ iframe.preview-frame { } .game-resource-book-main { + display: flex; + flex-direction: column; position: relative; flex: 1 1 auto; min-height: 0; padding: clamp(24px, 4vw, 52px); - overflow: auto; + overflow: hidden; + user-select: none; background: radial-gradient(#eaded8 0.8px, transparent 0.8px) 0 0 / 18px 18px, linear-gradient(145deg, #fffdfa 0%, #fff8f2 100%); } +.game-resource-book-manager--entering .game-resource-book-main, +.game-resource-book-manager--returning .game-resource-book-main { + transition: transform 420ms cubic-bezier(0.2, 0.78, 0.2, 1); +} + .game-resource-book-main-heading { display: flex; align-items: flex-end; @@ -5722,122 +5751,19 @@ iframe.preview-frame { box-shadow: 0 3px 10px rgb(158 87 57 / 14%); } -@keyframes resource-book-child-enter { - from { - opacity: 0.25; - clip-path: inset( - var(--resource-book-origin-top) 0 0 var(--resource-book-origin-left) round - 16px - ); - transform: translate( - calc(var(--resource-book-origin-left) * -1), - calc(var(--resource-book-origin-top) * -1) - ) - scale(0.28); - } - to { - opacity: 1; - clip-path: inset(0 0 0 0 round 0); - transform: translateY(0) scale(1); - } -} - -@keyframes resource-book-preview-expand { - from { - opacity: 1; - left: var(--resource-book-origin-left); - top: var(--resource-book-origin-top); - width: var(--resource-book-origin-width); - height: var(--resource-book-origin-height); - } - to { - opacity: 1; - left: 0; - top: 0; - width: var(--resource-book-target-width); - height: var(--resource-book-target-height); - } -} - -@keyframes resource-book-preview-shrink { - from { - opacity: 1; - left: 0; - top: 0; - width: var(--resource-book-target-width); - height: var(--resource-book-target-height); - } - to { - opacity: 1; - left: var(--resource-book-origin-left); - top: var(--resource-book-origin-top); - width: var(--resource-book-origin-width); - height: var(--resource-book-origin-height); - } -} - -@keyframes resource-book-card-expand { - from { - left: 12px; - top: calc(16px + var(--preview-stack-index) * 7px); - width: min(80%, 180px); - height: 64px; - opacity: 1; - transform: rotate(calc((var(--preview-stack-index) - 1) * 1deg)); - } - to { - left: var(--target-x); - top: var(--target-y); - width: var(--target-width); - height: var(--target-height); - opacity: 1; - transform: rotate(0deg); - } -} - -@keyframes resource-book-card-shrink { - from { - left: var(--target-x); - top: var(--target-y); - width: var(--target-width); - height: var(--target-height); - opacity: 1; - transform: rotate(0deg); - } - to { - left: 12px; - top: calc(16px + var(--preview-stack-index) * 7px); - width: min(80%, 180px); - height: 64px; - opacity: 1; - transform: rotate(calc((var(--preview-stack-index) - 1) * 1deg)); - } -} - -@keyframes resource-book-child-return { - from { - opacity: 1; - clip-path: inset(0 0 0 0 round 0); - transform: translateY(0) scale(1); - } - to { - opacity: 0.15; - clip-path: inset( - var(--resource-book-origin-top) 0 0 var(--resource-book-origin-left) round - 16px - ); - transform: translate( - calc(var(--resource-book-origin-left) * -1), - calc(var(--resource-book-origin-top) * -1) - ) - scale(0.28); - } -} - @media (prefers-reduced-motion: reduce) { .game-resource-book-main { animation: none; } + + .game-resource-book-scene, + .game-resource-book-scene::before, + .game-resource-book-scene-titlebar, + .game-resource-book-scene-card, + .game-resource-book-scene-card .game-resource-card, + .game-resource-book-thumbnail { + transition: none !important; + } } .game-resource-search { diff --git a/apps/ai-game-creator-shell/src/view/project-development/index.tsx b/apps/ai-game-creator-shell/src/view/project-development/index.tsx index 79d15577d..4f0e6e1ce 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/index.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/index.tsx @@ -1,8 +1,6 @@ import type { ImageCanvasHostScope } from '@genarrative/image-canvas-core'; import { type CanvasViewport, - MAX_SCALE, - MIN_SCALE, resolveViewportFromWheel, } from '@genarrative/image-canvas-core'; import { @@ -39,6 +37,7 @@ import { useId, useLayoutEffect, useMemo, + useReducer, useRef, useState, type WheelEvent as ReactWheelEvent, @@ -84,12 +83,48 @@ import { resolveResourceFocusIntent, type ResourceFocusIntent, } from './projectResourceLiveUpdateModel'; +import { + createResourceBookTransitionController, + type ResourceBookTransitionController, +} from './resourceBookController'; +import { + groupResourceBookResourcesByCategory, + groupResourceBookStacks, + resourceBookChildCardLayout, + resourceBookOverviewCardLayout, + type ResourceBookOverviewRect, +} from './resourceBookLayout'; +import { + initialResourceBookState, + resourceBookIsReturningCategory, + resourceBookReducer, + resourceBookSceneCategory, + resourceBookShowsChildCards, + resourceBookShowsOverviewCards, + type ResourceBookState, +} from './resourceBookModel'; +import { + DEFAULT_RESOURCE_BOOK_VIEWPORT, + keepResourceBookViewportAtReadableScale, + normalizeResourceBookViewport, + panResourceBookViewport, + resolveResourceBookWheelViewport, + zoomResourceBookViewport, +} from './resourceBookViewport'; +import { + createResourceCanvasPageWheelState, + queueResourceCanvasPageWheel, + resetResourceCanvasPageWheel, + type ResourceCanvasPageWheelState, + takeResourceCanvasPageWheelStep, +} from './resourceCanvasController'; import { createResourceCanvasCardSizeByResourceId, fitResourceCanvasViewportToContent, normalizeInfiniteResourceCanvasViewport, RESOURCE_CANVAS_DRAG_THRESHOLD, RESOURCE_CANVAS_FIT_PADDING, + RESOURCE_CANVAS_INITIAL_FIT_MAX_SCALE, RESOURCE_CANVAS_SECTION_MIN_HEIGHT, RESOURCE_CANVAS_SECTION_MIN_WIDTH, RESOURCE_CANVAS_SECTION_ORDER, @@ -149,8 +184,6 @@ export type { type ResourceCategory = ProjectResourceCategory; type ResourceSortMode = ProjectResourceCanvasLayoutMode; type WorkbenchMode = 'resources' | 'run'; -type ResourceBookView = 'main' | 'child'; -type ResourceBookTransition = 'idle' | 'entering' | 'returning'; type ApprovalMode = 'strict' | 'risk' | 'none'; type OrchestrationMode = 'single-supervisor' | 'professional-dag'; @@ -332,7 +365,11 @@ function defaultResourceCanvasViewports(): ResourceCanvasViewportBySortMode { const RESOURCE_CANVAS_FALLBACK_WIDTH = 900; const RESOURCE_CANVAS_FALLBACK_HEIGHT = 640; -const RESOURCE_CANVAS_BOOK_FIT_MAX_SCALE = 1; +// Keep the child canvas fit ceiling aligned with the established resource +// canvas behavior. The book split changes ownership of the DOM, not the +// viewport scale contract. +const RESOURCE_CANVAS_BOOK_FIT_MAX_SCALE = + RESOURCE_CANVAS_INITIAL_FIT_MAX_SCALE; function resourceCanvasViewportElement(element: HTMLElement | null) { const pageCanvas = element?.querySelector( @@ -855,7 +892,7 @@ function ResourceBookThumbnail({ }: { category: ResourceCategory; resources: ProjectResource[]; - onOpen: (category: ResourceCategory, source: HTMLElement) => void; + onOpen: (category: ResourceCategory) => void; registerElement?: ( category: ResourceCategory, element: HTMLElement | null, @@ -885,12 +922,12 @@ function ResourceBookThumbnail({ ) ) return; - onOpen(category, event.currentTarget); + onOpen(category); }} onKeyDown={(event) => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); - onOpen(category, event.currentTarget); + onOpen(category); } }} aria-label={`进入${categoryLabels[category]}子画布`} @@ -960,24 +997,17 @@ function ResourceBookTitleBar({ ); } -type ResourceBookOverviewRect = { - left: number; - top: number; - width: number; - height: number; -}; - function ResourceBookScene({ resources, visibleCategoryOrder, - resourceBookView, - resourceBookTransition, - resourceBookCategory, + resourceBookState, overviewRects, positions, cardSizes, visibleResourceIds, + cardsReady, viewport, + mainViewport, renderCard, onCollapse, onPointerDown, @@ -985,21 +1015,17 @@ function ResourceBookScene({ onPointerUp, onPointerCancel, onWheel, - zoomPercent, - onZoomOut, - onZoomIn, - onZoomFit, }: { resources: ProjectResource[]; visibleCategoryOrder: readonly ResourceCategory[]; - resourceBookView: ResourceBookView; - resourceBookTransition: ResourceBookTransition; - resourceBookCategory: ResourceCategory | null; + resourceBookState: ResourceBookState; overviewRects: Map; positions: Map; cardSizes: Map; visibleResourceIds: Set; + cardsReady: boolean; viewport: CanvasViewport; + mainViewport: CanvasViewport; renderCard: ( resource: ProjectResource, layout: { @@ -1018,180 +1044,143 @@ function ResourceBookScene({ onPointerUp: (event: ReactPointerEvent) => void; onPointerCancel: (event: ReactPointerEvent) => void; onWheel: (event: ReactWheelEvent) => void; - zoomPercent: number; - onZoomOut: () => void; - onZoomIn: () => void; - onZoomFit: () => void; }) { - const resourcesByCategory = new Map(); - resources.forEach((resource) => { - const list = resourcesByCategory.get(resource.category) ?? []; - list.push(resource); - resourcesByCategory.set(resource.category, list); - }); + const resourcesByCategory = groupResourceBookResourcesByCategory(resources); + const sceneCategory = resourceBookSceneCategory(resourceBookState); + const showOverviewCards = resourceBookShowsOverviewCards(resourceBookState); + const showChildCards = resourceBookShowsChildCards(resourceBookState); const overviewReady = visibleCategoryOrder.every((category) => { const rect = overviewRects.get(category); return Boolean(rect && rect.width > 0 && rect.height > 0); }); + const safeViewport = normalizeResourceBookViewport(viewport); + const safeMainViewport = normalizeResourceBookViewport(mainViewport); + const sceneViewport = + resourceBookState.view === 'main' ? safeMainViewport : safeViewport; return (
- {resourceBookView === 'child' ? ( -
- - - {Math.round(zoomPercent)}% - - - -
- ) : null} - {visibleCategoryOrder.map((category) => { - const rect = overviewRects.get(category); - const categoryResources = resourcesByCategory.get(category) ?? []; - const stacks = Array.from( - categoryResources.reduce((groups, resource) => { - const type = projectResourceTypeLabel(resource); - const current = groups.get(type) ?? []; - current.push(resource); - groups.set(type, current); - return groups; - }, new Map()), - ); - const returning = - resourceBookTransition === 'returning' && - resourceBookCategory === category; - const active = - resourceBookView === 'child' && - resourceBookCategory === category && - !returning; - const showOverview = resourceBookView === 'main' || returning; - const titleLayout = active - ? { left: 0, top: 0, width: '100%', opacity: 1 } - : { - left: rect?.left ?? 0, - top: rect?.top ?? 0, - width: `${rect?.width ?? 280}px`, - opacity: showOverview ? 1 : 0, - }; - return ( - -
- -
- {stacks.flatMap(([type, items]) => - items.map((resource, stackIndex) => { - const position = positions.get(resource.id); - const size = cardSizes.get(resource.id) ?? { - width: 180, - height: 128, - }; - const overviewLeft = - (rect?.left ?? 0) + - 18 + - (stacks.findIndex(([stackType]) => stackType === type) % 3) * - 102; - const overviewTop = - (rect?.top ?? 0) + 58 + Math.min(stackIndex, 2) * 5; - const overflow = stackIndex >= 3; - const expanded = active; - const layout = { - x: expanded - ? (position?.x ?? 0) * viewport.scale + viewport.x - : overviewLeft, - y: expanded - ? (position?.y ?? 0) * viewport.scale + viewport.y - : overviewTop, - width: expanded ? size.width * viewport.scale : 92, - height: expanded ? size.height * viewport.scale : 64, - dragX: expanded ? (position?.x ?? 0) : 0, - dragY: expanded ? (position?.y ?? 0) : 0, - rotation: expanded - ? 0 - : (stackIndex % 2 ? -1 : 1) * (stackIndex + 0.5), - }; - return ( -
- {renderCard(resource, layout)} -
- ); - }), - )} -
- ); - })} +
+ {visibleCategoryOrder.map((category) => { + 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 renderCards = cardsReady && (renderTransitionCards || active); + const titleLayout = active + ? { left: 0, top: 0, width: '100%', opacity: 1 } + : { + left: rect?.left ?? 0, + top: rect?.top ?? 0, + width: `${rect?.width ?? 280}px`, + opacity: renderTransitionCards ? 1 : 0, + }; + const titlebarTransform = active + ? `translate(${-safeViewport.x / safeViewport.scale}px, ${-safeViewport.y / safeViewport.scale}px) scale(${1 / safeViewport.scale})` + : undefined; + return ( + +
+ +
+ {renderCards && + stacks.flatMap(([_type, items], stackColumn) => + items.map((resource, stackIndex) => { + const position = positions.get(resource.id); + const size = cardSizes.get(resource.id) ?? { + width: 180, + height: 128, + }; + const overflow = stackIndex >= 3; + const expanded = active; + const cardVisible = + visibleResourceIds.has(resource.id) && + (renderTransitionCards || expanded); + const layout = expanded + ? resourceBookChildCardLayout({ + position, + size, + }) + : resourceBookOverviewCardLayout({ + rect, + stackIndex, + stackColumn, + }); + return ( +
+ {renderCard(resource, layout)} +
+ ); + }), + )} +
+ ); + })} +
); } @@ -1213,17 +1202,28 @@ export default function ProjectDevelopmentView({ }: ProjectDevelopmentViewProps) { const professionalDagVisible = orchestrationMode === 'professional-dag'; const [mode, setMode] = useState('resources'); - const [resourceBookView, setResourceBookView] = - useState('main'); - const [resourceBookCategory, setResourceBookCategory] = - useState(null); - const [resourceBookTransition, setResourceBookTransition] = - useState('idle'); - const [resourceBookTransitionOrigin, setResourceBookTransitionOrigin] = - useState({ left: 0, top: 0, width: 280, height: 210 }); - const [resourceBookTransitionTarget, setResourceBookTransitionTarget] = - useState({ width: 900, height: 640 }); - const resourceBookTransitionTimerRef = useRef(null); + const [resourceBookState, dispatchResourceBook] = useReducer( + resourceBookReducer, + initialResourceBookState, + ); + const resourceBookView = resourceBookState.view; + const [resourceBookMainViewport, setResourceBookMainViewport] = + useState({ x: 0, y: 0, scale: 1 }); + const resourceBookMainViewportRef = useRef({ + x: 0, + y: 0, + scale: 1, + }); + const resourceBookMainPanRef = useRef<{ + pointerId: number; + startClientX: number; + startClientY: number; + startViewport: CanvasViewport; + } | null>(null); + const resourceBookTransitionControllerRef = + useRef( + createResourceBookTransitionController(), + ); const resourceBookManagerRef = useRef(null); const resourceBookThumbnailElementsRef = useRef( new Map(), @@ -1315,25 +1315,18 @@ export default function ProjectDevelopmentView({ element: HTMLMediaElement | null; } | null>(null); const resourceCanvasRef = useRef(null); + const resourceCanvasWheelHandlerRef = useRef< + ((event: WheelEvent) => void) | null + >(null); const resourceCanvasViewportRef = useRef({ x: 48, y: 48, scale: 1, }); const resourceCanvasFitKeysRef = useRef>(new Set()); - const resourceCanvasPageWheelRef = useRef<{ - activeCategory: ResourceCategory | null; - accumulatedDeltaY: number; - lastSwitchAt: number; - pendingStepCount: number; - pendingTimerId: number | null; - }>({ - activeCategory: null, - accumulatedDeltaY: 0, - lastSwitchAt: 0, - pendingStepCount: 0, - pendingTimerId: null, - }); + const resourceCanvasPageWheelRef = useRef< + ResourceCanvasPageWheelState + >(createResourceCanvasPageWheelState()); const renderedResourceCategoryRef = useRef(null); const resourceCanvasPanRef = useRef<{ pointerId: number; @@ -1828,13 +1821,14 @@ export default function ProjectDevelopmentView({ [manifest.projectId, projectPath], ); useEffect(() => { - if (resourceBookTransitionTimerRef.current !== null) { - window.clearTimeout(resourceBookTransitionTimerRef.current); - resourceBookTransitionTimerRef.current = null; - } - setResourceBookView('main'); - setResourceBookCategory(null); - setResourceBookTransition('idle'); + const transitionToken = + resourceBookTransitionControllerRef.current.invalidate(); + resourceBookMainViewportRef.current = { x: 0, y: 0, scale: 1 }; + setResourceBookMainViewport({ x: 0, y: 0, scale: 1 }); + dispatchResourceBook({ + type: 'reset', + token: transitionToken, + }); }, [resourceCategoryScopeKey]); const resourcePageCategories = useMemo( () => (canvasResources.length > 0 ? visibleCategoryOrder : []), @@ -2373,11 +2367,7 @@ export default function ProjectDevelopmentView({ if (state.pendingTimerId !== null) { window.clearTimeout(state.pendingTimerId); } - state.accumulatedDeltaY = 0; - state.lastSwitchAt = 0; - state.pendingStepCount = 0; - state.pendingTimerId = null; - state.activeCategory = renderedResourceCategoryRef.current; + resetResourceCanvasPageWheel(state, renderedResourceCategoryRef.current); }, []); const cancelResourceCanvasPan = useCallback(() => { @@ -2407,139 +2397,201 @@ export default function ProjectDevelopmentView({ }, [clearSkippedResourceCardClick]); useEffect(() => { + const transitionController = resourceBookTransitionControllerRef.current; return () => { - if (resourceBookTransitionTimerRef.current !== null) { - window.clearTimeout(resourceBookTransitionTimerRef.current); - } + transitionController.dispose(); }; }, []); + const cancelResourceBookTransition = useCallback(() => { + resourceBookTransitionControllerRef.current.invalidate(); + }, []); + const scheduleResourceBookTransition = useCallback( - (callback: () => void, delayMs: number) => { - if (resourceBookTransitionTimerRef.current !== null) { - window.clearTimeout(resourceBookTransitionTimerRef.current); - } - resourceBookTransitionTimerRef.current = window.setTimeout(() => { - resourceBookTransitionTimerRef.current = null; - callback(); - }, delayMs); + (callback: (token: number) => void, delayMs: number, token: number) => { + resourceBookTransitionControllerRef.current.schedule( + callback, + delayMs, + token, + ); }, [], ); const openResourceBookChild = useCallback( - (category: ResourceCategory, source?: HTMLElement) => { + (category: ResourceCategory) => { + cancelResourceBookTransition(); cancelResourceCardDrag(); cancelResourceCanvasPan(); cancelQueuedResourceCanvasPageWheel(); - if (source && resourceBookManagerRef.current) { - const sourceRect = source.getBoundingClientRect(); - const managerRect = - resourceBookManagerRef.current.getBoundingClientRect(); - setResourceBookTransitionTarget({ - width: managerRect.width, - height: managerRect.height, - }); - setResourceBookTransitionOrigin({ - left: Math.max(0, sourceRect.left - managerRect.left), - top: Math.max(0, sourceRect.top - managerRect.top), - width: sourceRect.width, - height: sourceRect.height, - }); - } - if ( - resourceBookView === 'child' && - resourceBookCategory === category && - resourceBookTransition === 'idle' + resourceBookState.view === 'child' && + resourceBookState.category === category && + resourceBookState.phase === 'idle' ) { return; } - if (resourceBookView === 'child') { - const switchFitBounds = resourceCanvasContentBounds( - resourcePositionsByCategory.get(category) ?? [], - resourceCardSizeByResourceId, - ); - const switchFitViewport = fitResourceCanvasViewportToContent({ - bounds: switchFitBounds, - canvasSize: resourceBookSceneSize, - padding: RESOURCE_CANVAS_FIT_PADDING, - maxScale: RESOURCE_CANVAS_BOOK_FIT_MAX_SCALE, - }); - setResourceBookTransition('returning'); - scheduleResourceBookTransition(() => { - setActiveResourceCategory(category); - setResourceBookCategory(category); - setResourceBookView('main'); - setResourceBookTransition('idle'); - window.requestAnimationFrame(() => { - setResourceCanvasViewport(category, switchFitViewport); - setResourceBookView('child'); - setResourceBookTransition('entering'); - scheduleResourceBookTransition(() => { - setResourceBookTransition('idle'); - }, 520); - }); - }, 320); - return; - } + const transitionToken = + resourceBookTransitionControllerRef.current.begin(); + // 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 + // target rather than the previous rendered category. + resourceCanvasPageWheelRef.current.activeCategory = category; + dispatchResourceBook({ + type: 'open-category', + category, + token: transitionToken, + }); - const fitBounds = resourceCanvasContentBounds( + const categoryFitKey = `${projectPath}\n${manifest.projectId}\n${sortMode}\n${category}`; + const fitCanvasSize = + resourceBookSceneSize.width > 0 && resourceBookSceneSize.height > 0 + ? resourceBookSceneSize + : { + width: RESOURCE_CANVAS_FALLBACK_WIDTH, + height: RESOURCE_CANVAS_FALLBACK_HEIGHT, + }; + const fitMaxScale = + resourceBookSceneSize.width > 0 && resourceBookSceneSize.height > 0 + ? RESOURCE_CANVAS_BOOK_FIT_MAX_SCALE + : 1; + const categoryBounds = resourceCanvasContentBounds( resourcePositionsByCategory.get(category) ?? [], resourceCardSizeByResourceId, ); - setResourceCanvasViewport( - category, - fitResourceCanvasViewportToContent({ - bounds: fitBounds, - canvasSize: resourceBookSceneSize, - padding: RESOURCE_CANVAS_FIT_PADDING, - maxScale: RESOURCE_CANVAS_BOOK_FIT_MAX_SCALE, - }), - ); + const categoryViewport = resourceCanvasFitKeysRef.current.has( + categoryFitKey, + ) + ? resourceCanvasViewportTargetsRef.current[sortMode][category] + : keepResourceBookViewportAtReadableScale({ + viewport: fitResourceCanvasViewportToContent({ + bounds: categoryBounds, + canvasSize: fitCanvasSize, + padding: RESOURCE_CANVAS_FIT_PADDING, + maxScale: fitMaxScale, + }), + bounds: categoryBounds, + canvasSize: fitCanvasSize, + padding: RESOURCE_CANVAS_FIT_PADDING, + }); + + // Opening a child explicitly establishes the initial viewport for this + // 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; + } + + setResourceCanvasViewport(category, categoryViewport); + resourceCanvasPageWheelRef.current.activeCategory = category; setActiveResourceCategory(category); - setResourceBookCategory(category); - setResourceBookView('child'); - setResourceBookTransition('entering'); - scheduleResourceBookTransition(() => { - setResourceBookTransition('idle'); - }, 520); + scheduleResourceBookTransition( + (finishToken) => { + dispatchResourceBook({ + type: 'finish-transition', + token: finishToken, + }); + }, + 520, + transitionToken, + ); + resourceBookTransitionControllerRef.current.requestFrame(() => { + dispatchResourceBook({ + type: 'commit-enter', + token: transitionToken, + }); + }, transitionToken); }, [ cancelQueuedResourceCanvasPageWheel, cancelResourceCanvasPan, cancelResourceCardDrag, - resourceBookCategory, - resourceBookManagerRef, + cancelResourceBookTransition, + manifest.projectId, + projectPath, + resourceBookState, resourceBookSceneSize, - resourceBookTransition, - resourceBookView, resourceCardSizeByResourceId, resourcePositionsByCategory, setResourceCanvasViewport, scheduleResourceBookTransition, + sortMode, ], ); const returnToResourceBookMain = useCallback(() => { - if (resourceBookView !== 'child') { + if (resourceBookState.view !== 'child') { return; } + cancelResourceBookTransition(); cancelResourceCardDrag(); cancelResourceCanvasPan(); cancelQueuedResourceCanvasPageWheel(); - setResourceBookTransition('returning'); - scheduleResourceBookTransition(() => { - setResourceBookView('main'); - setResourceBookTransition('idle'); - }, 320); + 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, + ); }, [ cancelQueuedResourceCanvasPageWheel, cancelResourceCanvasPan, cancelResourceCardDrag, - resourceBookView, + cancelResourceBookTransition, + resourceBookState, scheduleResourceBookTransition, ]); @@ -2549,6 +2601,7 @@ export default function ProjectDevelopmentView({ openResourceBookChild(category); return; } + cancelResourceBookTransition(); cancelResourceCardDrag(); cancelResourceCanvasPan(); cancelQueuedResourceCanvasPageWheel(); @@ -2557,6 +2610,7 @@ export default function ProjectDevelopmentView({ }, [ cancelQueuedResourceCanvasPageWheel, + cancelResourceBookTransition, cancelResourceCanvasPan, cancelResourceCardDrag, openResourceBookChild, @@ -2585,6 +2639,13 @@ export default function ProjectDevelopmentView({ if (!nextCategory) { return; } + if (resourceBookState.phase === 'returning') { + // 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; @@ -2596,7 +2657,13 @@ export default function ProjectDevelopmentView({ ); }); }, - [cancelResourceCanvasPan, cancelResourceCardDrag, resourcePageCategories], + [ + cancelResourceCanvasPan, + cancelResourceCardDrag, + openResourceBookChild, + resourceBookState.phase, + resourcePageCategories, + ], ); useEffect(() => { @@ -2608,19 +2675,20 @@ export default function ProjectDevelopmentView({ return undefined; } const zoomFrames = resourceSectionZoomFramesRef.current; + const pageWheelState = resourceCanvasPageWheelRef.current; const pageWheelSwitchIntervalMs = 180; - const pageWheelDeltaThreshold = 20; const maxQueuedPageWheelSteps = Math.max( 1, resourcePageCategories.length - 1, ); const flushPageWheel = () => { const state = resourceCanvasPageWheelRef.current; - if (state.pendingStepCount === 0) { - return; - } - const now = performance.now(); - const waitMs = pageWheelSwitchIntervalMs - (now - state.lastSwitchAt); + const result = takeResourceCanvasPageWheelStep( + state, + performance.now(), + pageWheelSwitchIntervalMs, + ); + const waitMs = result.waitMs; if (waitMs > 0) { if (state.pendingTimerId === null) { const timerId = window.setTimeout(() => { @@ -2635,16 +2703,21 @@ export default function ProjectDevelopmentView({ } return; } - const stepOffset = state.pendingStepCount; - state.pendingStepCount = 0; - state.lastSwitchAt = now; - advanceResourceCanvasPage(stepOffset); + if (result.stepOffset !== 0) { + advanceResourceCanvasPage(result.stepOffset); + } }; const resolveViewport = (event: Event) => { const target = event.target; return target instanceof Element - ? target.closest('[data-resource-section-scroll]') - : null; + ? (target.closest('[data-resource-section-scroll]') ?? + resourceCanvasRef.current?.querySelector( + `[data-resource-section-scroll="${renderedResourceCategoryRef.current ?? ''}"]`, + ) ?? + null) + : (resourceCanvasRef.current?.querySelector( + `[data-resource-section-scroll="${renderedResourceCategoryRef.current ?? ''}"]`, + ) ?? null); }; const wheel = (event: WheelEvent) => { const target = event.target instanceof Element ? event.target : null; @@ -2681,50 +2754,12 @@ export default function ProjectDevelopmentView({ if (!Number.isFinite(event.deltaY) || event.deltaY === 0) { return; } - const pageWheelState = resourceCanvasPageWheelRef.current; - const deltaModeMultiplier = - event.deltaMode === 1 - ? 16 - : event.deltaMode === 2 - ? Math.max(canvas.clientHeight, 1) - : 1; - const pageWheelDeltaPerStep = - event.deltaMode === 1 - ? 48 - : event.deltaMode === 2 - ? Math.max(canvas.clientHeight, 1) - : 120; - const normalizedDeltaY = event.deltaY * deltaModeMultiplier; - const direction = Math.sign(normalizedDeltaY); - if ( - (pageWheelState.accumulatedDeltaY !== 0 && - direction !== Math.sign(pageWheelState.accumulatedDeltaY)) || - (pageWheelState.pendingStepCount !== 0 && - direction !== Math.sign(pageWheelState.pendingStepCount)) - ) { - pageWheelState.accumulatedDeltaY = 0; - pageWheelState.pendingStepCount = 0; - } - pageWheelState.accumulatedDeltaY += normalizedDeltaY; - if ( - Math.abs(pageWheelState.accumulatedDeltaY) >= pageWheelDeltaThreshold - ) { - const stepCount = Math.max( - 1, - Math.floor( - Math.abs(pageWheelState.accumulatedDeltaY) / - pageWheelDeltaPerStep, - ), - ); - pageWheelState.accumulatedDeltaY = 0; - pageWheelState.pendingStepCount = Math.max( - -maxQueuedPageWheelSteps, - Math.min( - maxQueuedPageWheelSteps, - pageWheelState.pendingStepCount + direction * stepCount, - ), - ); - } + queueResourceCanvasPageWheel(resourceCanvasPageWheelRef.current, { + deltaY: event.deltaY, + deltaMode: event.deltaMode, + canvasHeight: canvas.clientHeight, + maxQueuedSteps: maxQueuedPageWheelSteps, + }); flushPageWheel(); return; } @@ -2749,6 +2784,7 @@ export default function ProjectDevelopmentView({ event.clientY, ); }; + resourceCanvasWheelHandlerRef.current = wheel; const gestureStart = (rawEvent: Event) => { const event = rawEvent as WebKitGestureEvent; cancelQueuedResourceCanvasPageWheel(); @@ -2817,17 +2853,20 @@ export default function ProjectDevelopmentView({ canvas.addEventListener('gestureend', gestureEnd, { passive: false }); return () => { canvas.removeEventListener('wheel', wheel); + if (resourceCanvasWheelHandlerRef.current === wheel) { + resourceCanvasWheelHandlerRef.current = null; + } canvas.removeEventListener('gesturestart', gestureStart); canvas.removeEventListener('gesturechange', gestureChange); canvas.removeEventListener('gestureend', gestureEnd); resourceSectionGestureRef.current = null; - const pageWheelState = resourceCanvasPageWheelRef.current; if (pageWheelState.pendingTimerId !== null) { window.clearTimeout(pageWheelState.pendingTimerId); } - pageWheelState.accumulatedDeltaY = 0; - pageWheelState.pendingStepCount = 0; - pageWheelState.pendingTimerId = null; + resetResourceCanvasPageWheel( + pageWheelState, + renderedResourceCategoryRef.current, + ); for (const pending of zoomFrames.values()) { window.cancelAnimationFrame(pending.frameId); } @@ -2863,11 +2902,20 @@ export default function ProjectDevelopmentView({ viewportElement?.clientHeight || rect?.height || 0; if (measuredWidth > 0 && measuredHeight > 0) { resourceCanvasFitKeysRef.current.add(fitKey); + const canvasSize = { + width: measuredWidth, + height: measuredHeight, + }; setResourceCanvasViewport( activePageCategory, - fitResourceCanvasViewportToContent({ + keepResourceBookViewportAtReadableScale({ + viewport: fitResourceCanvasViewportToContent({ + bounds: resourceCanvasFitBounds, + canvasSize, + padding: RESOURCE_CANVAS_FIT_PADDING, + }), bounds: resourceCanvasFitBounds, - canvasSize: { width: measuredWidth, height: measuredHeight }, + canvasSize, padding: RESOURCE_CANVAS_FIT_PADDING, }), ); @@ -2876,10 +2924,11 @@ export default function ProjectDevelopmentView({ } // Resource extents can change after an image finishes measuring or a // card is manually moved. Keep the user's infinite pan/zoom unchanged. - setResourceCanvasViewport( - activePageCategory, - resourceCanvasViewportRef.current, - ); + const preservedViewport = + resourceCanvasViewportTargetsRef.current[sortMode][ + activePageCategory + ] ?? activeResourceCanvasViewport; + setResourceCanvasViewport(activePageCategory, preservedViewport); }; fitInitialViewportOrPreserve(); const observer = window.ResizeObserver @@ -2898,6 +2947,8 @@ export default function ProjectDevelopmentView({ mode, projectPath, resourceCanvasFitBounds, + activeResourceCanvasViewport, + resourceCanvasViewportTargetsRef, setResourceCanvasViewport, sortMode, ]); @@ -3046,13 +3097,7 @@ export default function ProjectDevelopmentView({ if (resourceCanvasPageWheelRef.current.pendingTimerId !== null) { window.clearTimeout(resourceCanvasPageWheelRef.current.pendingTimerId); } - resourceCanvasPageWheelRef.current = { - activeCategory: null, - accumulatedDeltaY: 0, - lastSwitchAt: 0, - pendingStepCount: 0, - pendingTimerId: null, - }; + resetResourceCanvasPageWheel(resourceCanvasPageWheelRef.current, null); cancelResourceCardDrag(); resourceCanvasPanRef.current = null; resourceDependencyOverlayRef.current?.clearDragPreview(); @@ -3478,7 +3523,13 @@ export default function ProjectDevelopmentView({ ); }, [activePageCategory, resourceCanvasFitBounds, setResourceCanvasViewport]); - const fitResourceBookScene = useCallback(() => { + const fitResourceBook = useCallback(() => { + if (resourceBookView === 'main') { + const resetViewport = DEFAULT_RESOURCE_BOOK_VIEWPORT; + resourceBookMainViewportRef.current = resetViewport; + setResourceBookMainViewport(resetViewport); + return; + } if (!activePageCategory) { return; } @@ -3494,86 +3545,145 @@ export default function ProjectDevelopmentView({ }, [ activePageCategory, resourceBookSceneSize, + resourceBookView, resourceCanvasFitBounds, setResourceCanvasViewport, ]); - const zoomResourceBookSceneBy = useCallback( + const zoomResourceBookBy = useCallback( (factor: number) => { + const sceneElement = resourceBookManagerRef.current; + const rect = sceneElement?.getBoundingClientRect(); + const anchorX = rect ? rect.width / 2 : 0; + const anchorY = rect ? rect.height / 2 : 0; + if (resourceBookView === 'main') { + const viewport = resourceBookMainViewportRef.current; + const next = zoomResourceBookViewport(viewport, factor, { + x: anchorX, + y: anchorY, + }); + if (next.scale === viewport.scale) return; + resourceBookMainViewportRef.current = next; + setResourceBookMainViewport(next); + return; + } const category = activePageCategory; if (!category) { return; } const viewport = resourceCanvasViewportRef.current; - const scale = Math.min( - MAX_SCALE, - Math.max(MIN_SCALE, viewport.scale * factor), - ); - if (scale === viewport.scale) { - return; - } - const sceneElement = resourceBookManagerRef.current; - const rect = sceneElement?.getBoundingClientRect(); - const anchorX = rect ? rect.width / 2 : 0; - const anchorY = rect ? rect.height / 2 : 0; - const ratio = scale / viewport.scale; - setResourceCanvasViewport(category, { - scale, - x: anchorX - (anchorX - viewport.x) * ratio, - y: anchorY - (anchorY - viewport.y) * ratio, + const next = zoomResourceBookViewport(viewport, factor, { + x: anchorX, + y: anchorY, }); + if (next.scale === viewport.scale) return; + setResourceCanvasViewport(category, next); }, - [activePageCategory, setResourceCanvasViewport], + [activePageCategory, resourceBookView, setResourceCanvasViewport], ); - const handleResourceBookSceneWheel = useCallback( + const handleResourceBookWheel = useCallback( (event: ReactWheelEvent) => { - const category = activePageCategory; - const sceneElement = resourceBookManagerRef.current; - if (!category || !sceneElement) { + if (!(event.ctrlKey || event.metaKey)) { + if (resourceBookView !== 'main') { + resourceCanvasWheelHandlerRef.current?.(event.nativeEvent); + event.nativeEvent.preventDefault(); + } return; } - if (!(event.ctrlKey || event.metaKey)) { - const pageCanvas = - resourceCanvasRef.current?.querySelector( - '[data-resource-section-scroll]', - ); - if (pageCanvas) { - pageCanvas.dispatchEvent( - new WheelEvent('wheel', { - bubbles: true, - cancelable: true, - clientX: event.clientX, - clientY: event.clientY, - deltaX: event.deltaX, - deltaY: event.deltaY, - deltaMode: event.deltaMode, - ctrlKey: event.ctrlKey, - metaKey: event.metaKey, - shiftKey: event.shiftKey, - }), - ); - event.preventDefault(); - } + const sceneElement = resourceBookManagerRef.current; + if (!sceneElement) { return; } event.preventDefault(); const rect = sceneElement.getBoundingClientRect(); - const result = resolveViewportFromWheel({ + const screenPoint = { + x: event.clientX - rect.left, + y: event.clientY - rect.top, + }; + if (resourceBookView === 'main') { + const current = resourceBookMainViewportRef.current; + const result = resolveResourceBookWheelViewport({ + viewport: current, + deltaX: event.deltaX, + deltaY: event.deltaY, + shiftKey: event.shiftKey, + ctrlKey: event.ctrlKey, + metaKey: event.metaKey, + screenPoint, + }); + resourceBookMainViewportRef.current = result.viewport; + setResourceBookMainViewport(result.viewport); + return; + } + const category = activePageCategory; + if (!category) { + return; + } + const result = resolveResourceBookWheelViewport({ viewport: resourceCanvasViewportRef.current, deltaX: event.deltaX, deltaY: event.deltaY, shiftKey: event.shiftKey, ctrlKey: event.ctrlKey, metaKey: event.metaKey, - screenPoint: { - x: event.clientX - rect.left, - y: event.clientY - rect.top, - }, + screenPoint, }); setResourceCanvasViewport(category, result.viewport, true); }, - [activePageCategory, setResourceCanvasViewport], + [activePageCategory, resourceBookView, setResourceCanvasViewport], + ); + + const handleResourceBookMainPointerDown = useCallback( + (event: ReactPointerEvent) => { + if (resourceBookView !== 'main' || event.button !== 0) { + return; + } + const target = event.target as HTMLElement; + if ( + target.closest( + 'button, input, textarea, select, a, .game-resource-book-thumbnail, .game-resource-book-zoom', + ) + ) { + return; + } + event.currentTarget.setPointerCapture(event.pointerId); + resourceBookMainPanRef.current = { + pointerId: event.pointerId, + startClientX: event.clientX, + startClientY: event.clientY, + startViewport: resourceBookMainViewportRef.current, + }; + }, + [resourceBookView], + ); + + const handleResourceBookMainPointerMove = useCallback( + (event: ReactPointerEvent) => { + const pan = resourceBookMainPanRef.current; + if (!pan || pan.pointerId !== event.pointerId) { + return; + } + const next = panResourceBookViewport(pan.startViewport, { + x: event.clientX - pan.startClientX, + y: event.clientY - pan.startClientY, + }); + resourceBookMainViewportRef.current = next; + setResourceBookMainViewport(next); + }, + [], + ); + + const stopResourceBookMainPan = useCallback( + (event: ReactPointerEvent) => { + if (resourceBookMainPanRef.current?.pointerId === event.pointerId) { + resourceBookMainPanRef.current = null; + if (event.currentTarget.hasPointerCapture(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId); + } + } + }, + [], ); const handleResourceCardPointerDown = useCallback( @@ -4934,13 +5044,8 @@ export default function ProjectDevelopmentView({ resourceCardDragPreview?.resourceId === resource.id ? resourceCardDragPreview : null; - const viewport = activeResourceCanvasViewport; - const previewX = dragPreview - ? dragPreview.x * viewport.scale + viewport.x - : undefined; - const previewY = dragPreview - ? dragPreview.y * viewport.scale + viewport.y - : undefined; + const previewX = dragPreview ? dragPreview.x : undefined; + const previewY = dragPreview ? dragPreview.y : undefined; return ( zoomResourceBookSceneBy(1 / 1.2)} - onZoomIn={() => zoomResourceBookSceneBy(1.2)} - onZoomFit={fitResourceBookScene} + onWheel={handleResourceBookWheel} /> +
+ + + {Math.round( + (resourceBookState.view === 'main' + ? resourceBookMainViewport.scale + : activeResourceCanvasViewport.scale) * 100, + )} + % + + + +