diff --git a/apps/ai-game-creator-shell/src/styles.css b/apps/ai-game-creator-shell/src/styles.css index 20721db11..6d2c336ba 100644 --- a/apps/ai-game-creator-shell/src/styles.css +++ b/apps/ai-game-creator-shell/src/styles.css @@ -5192,6 +5192,60 @@ iframe.preview-frame { pointer-events: none; } +.game-resource-book-zoom { + position: absolute; + right: 14px; + bottom: 14px; + z-index: 40; + display: inline-flex; + align-items: center; + gap: 2px; + height: 34px; + padding: 0 4px; + border: 1px solid #ecdcd4; + border-radius: 12px; + background: rgb(255 253 250 / 94%); + box-shadow: 0 6px 18px rgb(112 70 52 / 12%); + backdrop-filter: blur(6px); +} + +.game-resource-book-zoom-button, +.game-resource-book-zoom-fit { + display: inline-flex; + align-items: center; + justify-content: center; + width: 26px; + height: 26px; + padding: 0; + border: 0; + border-radius: 8px; + background: transparent; + color: #8c6252; + cursor: pointer; +} + +.game-resource-book-zoom-button:hover, +.game-resource-book-zoom-button:focus-visible, +.game-resource-book-zoom-fit:hover, +.game-resource-book-zoom-fit:focus-visible { + background: #f7ebe4; + color: #b5582f; + outline: 0; +} + +.game-resource-book-zoom-value { + min-width: 42px; + color: #6d4b3f; + font-size: 11px; + font-variant-numeric: tabular-nums; + text-align: center; + white-space: nowrap; +} + +.game-resource-book-zoom-fit { + margin-left: 2px; +} + .game-resource-book-scene.is-measuring { opacity: 0; } 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 1ada65aa3..79d15577d 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,6 +1,8 @@ import type { ImageCanvasHostScope } from '@genarrative/image-canvas-core'; import { type CanvasViewport, + MAX_SCALE, + MIN_SCALE, resolveViewportFromWheel, } from '@genarrative/image-canvas-core'; import { @@ -11,9 +13,12 @@ import { Image, Info, ListFilter, + Maximize2, + Minus, Music2, Pause, Play, + Plus, RotateCcw, Search, Settings2, @@ -36,6 +41,7 @@ import { useMemo, useRef, useState, + type WheelEvent as ReactWheelEvent, } from 'react'; import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; @@ -326,6 +332,7 @@ function defaultResourceCanvasViewports(): ResourceCanvasViewportBySortMode { const RESOURCE_CANVAS_FALLBACK_WIDTH = 900; const RESOURCE_CANVAS_FALLBACK_HEIGHT = 640; +const RESOURCE_CANVAS_BOOK_FIT_MAX_SCALE = 1; function resourceCanvasViewportElement(element: HTMLElement | null) { const pageCanvas = element?.querySelector( @@ -977,6 +984,11 @@ function ResourceBookScene({ onPointerMove, onPointerUp, onPointerCancel, + onWheel, + zoomPercent, + onZoomOut, + onZoomIn, + onZoomFit, }: { resources: ProjectResource[]; visibleCategoryOrder: readonly ResourceCategory[]; @@ -1005,6 +1017,11 @@ function ResourceBookScene({ onPointerMove: (event: ReactPointerEvent) => void; 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) => { @@ -1024,7 +1041,42 @@ function ResourceBookScene({ onPointerMove={onPointerMove} onPointerUp={onPointerUp} onPointerCancel={onPointerCancel} + onWheel={onWheel} > + {resourceBookView === 'child' ? ( +
+ + + {Math.round(zoomPercent)}% + + + +
+ ) : null} {visibleCategoryOrder.map((category) => { const rect = overviewRects.get(category); const categoryResources = resourcesByCategory.get(category) ?? []; @@ -2414,6 +2466,7 @@ export default function ProjectDevelopmentView({ bounds: switchFitBounds, canvasSize: resourceBookSceneSize, padding: RESOURCE_CANVAS_FIT_PADDING, + maxScale: RESOURCE_CANVAS_BOOK_FIT_MAX_SCALE, }); setResourceBookTransition('returning'); scheduleResourceBookTransition(() => { @@ -2443,6 +2496,7 @@ export default function ProjectDevelopmentView({ bounds: fitBounds, canvasSize: resourceBookSceneSize, padding: RESOURCE_CANVAS_FIT_PADDING, + maxScale: RESOURCE_CANVAS_BOOK_FIT_MAX_SCALE, }), ); setActiveResourceCategory(category); @@ -3419,10 +3473,109 @@ export default function ProjectDevelopmentView({ bounds: resourceCanvasFitBounds, canvasSize, padding: RESOURCE_CANVAS_FIT_PADDING, + maxScale: RESOURCE_CANVAS_BOOK_FIT_MAX_SCALE, }), ); }, [activePageCategory, resourceCanvasFitBounds, setResourceCanvasViewport]); + const fitResourceBookScene = useCallback(() => { + if (!activePageCategory) { + return; + } + setResourceCanvasViewport( + activePageCategory, + fitResourceCanvasViewportToContent({ + bounds: resourceCanvasFitBounds, + canvasSize: resourceBookSceneSize, + padding: RESOURCE_CANVAS_FIT_PADDING, + maxScale: RESOURCE_CANVAS_BOOK_FIT_MAX_SCALE, + }), + ); + }, [ + activePageCategory, + resourceBookSceneSize, + resourceCanvasFitBounds, + setResourceCanvasViewport, + ]); + + const zoomResourceBookSceneBy = useCallback( + (factor: number) => { + 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, + }); + }, + [activePageCategory, setResourceCanvasViewport], + ); + + const handleResourceBookSceneWheel = useCallback( + (event: ReactWheelEvent) => { + const category = activePageCategory; + const sceneElement = resourceBookManagerRef.current; + if (!category || !sceneElement) { + 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(); + } + return; + } + event.preventDefault(); + const rect = sceneElement.getBoundingClientRect(); + const result = resolveViewportFromWheel({ + 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, + }, + }); + setResourceCanvasViewport(category, result.viewport, true); + }, + [activePageCategory, setResourceCanvasViewport], + ); + const handleResourceCardPointerDown = useCallback( ( event: ReactPointerEvent, @@ -3564,7 +3717,7 @@ export default function ProjectDevelopmentView({ const target = event.target as HTMLElement; if ( target.closest( - 'button, input, textarea, select, a, .game-resource-card, .game-resource-book-scene-titlebar', + 'button, input, textarea, select, a, .game-resource-card, .game-resource-book-scene-titlebar, .game-resource-book-zoom', ) ) { return; @@ -5388,6 +5541,11 @@ export default function ProjectDevelopmentView({ onPointerMove={handleResourceCanvasPointerMove} onPointerUp={stopResourceCanvasPan} onPointerCancel={stopResourceCanvasPan} + onWheel={handleResourceBookSceneWheel} + zoomPercent={activeResourceCanvasViewport.scale * 100} + onZoomOut={() => zoomResourceBookSceneBy(1 / 1.2)} + onZoomIn={() => zoomResourceBookSceneBy(1.2)} + onZoomFit={fitResourceBookScene} />