diff --git a/apps/ai-game-creator-shell/src/styles.css b/apps/ai-game-creator-shell/src/styles.css index 0d8b31f97..9ed191020 100644 --- a/apps/ai-game-creator-shell/src/styles.css +++ b/apps/ai-game-creator-shell/src/styles.css @@ -765,7 +765,7 @@ textarea { } .client-auth-panel button:not(:disabled):active { - transform: translateY(0); + translate: 0 0; } .client-auth-panel button:disabled { @@ -5149,6 +5149,586 @@ iframe.preview-frame { min-height: 0; } +.game-resource-book-manager { + position: relative; + overflow: hidden; + background: #fffdfa; +} + +.game-resource-book-scene { + position: absolute; + z-index: 20; + inset: 0; + overflow: hidden; + pointer-events: none; +} + +.game-resource-book-scene::before { + position: absolute; + z-index: 0; + inset: 0; + background: + radial-gradient(#eaded8 0.8px, transparent 0.8px) 0 0 / 18px 18px, + #fffdfa; + content: ''; + opacity: 0; + transition: opacity 180ms ease; +} + +.game-resource-book-scene--child::before, +.game-resource-book-scene--returning::before, +.game-resource-book-scene--entering::before { + opacity: 1; +} + +.game-resource-book-scene-titlebar { + position: absolute; + z-index: 30; + min-height: 42px; + border: 1px solid #ebd9cf; + border-radius: 16px 16px 0 0; + background: rgb(255 250 246 / 96%); + 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), + translate 180ms ease, + opacity 180ms ease; +} + +.game-resource-book-scene-titlebar.is-active { + border-radius: 0; + pointer-events: auto; +} + +.game-resource-book-scene-titlebar .game-resource-book-titlebar { + min-height: 42px; + border-bottom: 0; + background: transparent; +} + +.game-resource-book-scene-card { + position: absolute; + 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; +} + +.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='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-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-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-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-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--main + .game-resource-book-scene-card + .game-resource-card { + border-radius: 10px; + box-shadow: 0 5px 12px rgb(112 70 52 / 10%); +} + +.game-resource-book-scene--child + .game-resource-book-scene-card.is-expanded + .game-resource-card { + border-radius: 12px; +} + +.game-resource-book-main.is-background, +.game-resource-canvas.is-background { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + opacity: 0; + pointer-events: none; +} + +.game-resource-book-thumbnail-titlebar--placeholder { + min-height: 42px; + border-bottom: 1px solid #f0e2da; + background: rgb(255 250 246 / 90%); +} + +.game-resource-book-main { + position: relative; + flex: 1 1 auto; + min-height: 0; + padding: clamp(24px, 4vw, 52px); + overflow: auto; + background: + radial-gradient(#eaded8 0.8px, transparent 0.8px) 0 0 / 18px 18px, + linear-gradient(145deg, #fffdfa 0%, #fff8f2 100%); + animation: resource-book-main-settle 360ms ease both; +} + +.game-resource-book-main-heading { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 24px; + max-width: 1120px; + margin: 0 auto 24px; +} + +.game-resource-book-main-heading small { + display: block; + margin-bottom: 4px; + color: #b18370; + font-size: 11px; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.game-resource-book-main-heading h2 { + margin: 0; + color: #56372d; + font-size: clamp(22px, 3vw, 32px); + line-height: 1.1; +} + +.game-resource-book-main-heading > span { + color: #a4877a; + font-size: 12px; +} + +.game-resource-book-main-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 280px), 1fr)); + gap: 18px; + max-width: 1120px; + margin: 0 auto; +} + +.game-resource-book-thumbnail { + position: relative; + display: flex; + flex-direction: column; + min-height: 210px; + padding: 0; + overflow: hidden; + border: 1px solid #ebd9cf; + border-radius: 16px; + background: rgb(255 255 255 / 82%); + color: #67483d; + cursor: pointer; + text-align: left; + box-shadow: 0 12px 28px rgb(112 70 52 / 9%); + transition: + border-color 180ms ease, + box-shadow 180ms ease, + transform 180ms ease; +} + +.game-resource-book-thumbnail:hover, +.game-resource-book-thumbnail:focus-visible { + border-color: #cc8060; + outline: 0; + box-shadow: 0 16px 34px rgb(158 87 57 / 18%); + transform: translateY(-3px); +} + +.game-resource-book-thumbnail-titlebar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + min-height: 42px; + padding: 0 14px; + border-bottom: 1px solid #f0e2da; + background: rgb(255 250 246 / 90%); +} + +.game-resource-book-titlebar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + width: 100%; + min-height: 42px; + padding: 0 14px; + border-bottom: 1px solid #f0e2da; + background: rgb(255 250 246 / 90%); +} + +.game-resource-book-titlebar-label, +.game-resource-book-titlebar-meta { + display: inline-flex; + align-items: center; + gap: 7px; + min-width: 0; +} + +.game-resource-book-titlebar-label strong { + overflow: hidden; + font-size: 13px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.game-resource-book-titlebar-meta { + flex: 0 0 auto; + gap: 10px; +} + +.game-resource-book-titlebar-meta small { + color: #b18f81; + font-size: 11px; +} + +.game-resource-book-thumbnail-titlebar { + padding: 0; + border-bottom: 0; +} + +.game-resource-book-thumbnail-titlebar > span { + display: inline-flex; + align-items: center; + gap: 7px; + min-width: 0; +} + +.game-resource-book-thumbnail-titlebar strong { + overflow: hidden; + font-size: 13px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.game-resource-book-thumbnail-titlebar small { + flex: 0 0 auto; + color: #b18f81; + font-size: 11px; +} + +.game-resource-book-thumbnail-body { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(94px, 1fr)); + align-content: center; + gap: 12px; + flex: 1 1 auto; + min-height: 132px; + padding: 18px; +} + +.game-resource-book-stack { + position: relative; + display: block; + min-height: 78px; +} + +.game-resource-book-stack:has(.game-resource-card) { + min-height: 132px; +} + +.game-resource-book-stack-card { + position: absolute; + inset: 0 0 auto; + display: flex; + flex-direction: column; + justify-content: center; + min-height: 64px; + padding: 8px 9px; + overflow: hidden; + border: 1px solid #e7cfc2; + border-radius: 10px; + background: linear-gradient(145deg, #fff 0%, #fff6ef 100%); + box-shadow: 0 5px 12px rgb(112 70 52 / 10%); + transform: translateY(calc(var(--stack-index, 0) * 5px)); +} + +.game-resource-book-stack-card:nth-last-child(2) { + transform: translateY(5px) rotate(-1deg); +} + +.game-resource-book-stack-card:nth-last-child(3) { + transform: translateY(10px) rotate(1.4deg); +} + +.game-resource-book-stack-card small { + overflow: hidden; + color: #b48876; + font-size: 9px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.game-resource-book-stack-card strong { + display: block; + margin-top: 4px; + overflow: hidden; + color: #68463a; + font-size: 10px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.game-resource-book-stack-card.is-blurred { + z-index: 0; + align-items: center; + color: #aa8576; + filter: blur(1.7px); + font-size: 12px; + opacity: 0.72; + transform: translateY(15px) scale(0.96); +} + +.game-resource-book-stack-card--control { + width: 100%; + min-height: 128px; + padding: 0; + overflow: visible; + border: 0; + background: transparent; + box-shadow: none; +} + +.game-resource-book-stack-card--control .game-resource-card { + width: 100%; + min-width: 0; + height: 100%; + min-height: 0; +} + +.game-resource-book-thumbnail-empty { + display: grid; + min-height: 96px; + border: 1px dashed #e1cfc5; + border-radius: 12px; + color: #b79b8e; + font-size: 12px; + place-items: center; +} + +.game-resource-book-thumbnail-footer { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 14px; + border-top: 1px solid #f0e2da; + color: #a98271; + font-size: 10px; +} + +.game-resource-page-heading-meta { + display: inline-flex; + align-items: center; + gap: 10px; +} + +.game-resource-page-collapse { + display: inline-flex; + align-items: center; + gap: 5px; + min-height: 28px; + padding: 0 9px; + border: 1px solid #e5cfc4; + border-radius: 8px; + background: rgb(255 250 246 / 85%); + color: #9d6650; + cursor: pointer; + font-size: 11px; +} + +.game-resource-page-collapse:hover, +.game-resource-page-collapse:focus-visible { + border-color: #cc8060; + outline: 0; + 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); + } +} + +@keyframes resource-book-main-settle { + from { + opacity: 0.6; + transform: scale(0.98); + } + to { + opacity: 1; + transform: scale(1); + } +} + +@media (prefers-reduced-motion: reduce) { + .game-resource-book-main, + .game-resource-book-transition-preview-card, + .game-resource-book-transition-card { + animation: none; + } +} + .game-resource-search { display: none; align-items: center; 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 f539fc02f..0561b306d 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 @@ -20,9 +20,11 @@ import { SlidersHorizontal, Sparkles, X, + ZoomOut, } from 'lucide-react'; import { type CSSProperties, + Fragment, memo, type PointerEvent as ReactPointerEvent, type ReactNode, @@ -141,6 +143,8 @@ 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'; @@ -526,6 +530,7 @@ const ResourceCard = memo(function ResourceCard({ relationState, x, y, + rotation = 0, dragging, previewIdentity, preview, @@ -549,6 +554,7 @@ const ResourceCard = memo(function ResourceCard({ relationState: 'version-binding' | null; x: number; y: number; + rotation?: number; dragging: boolean; previewIdentity: string; preview: ProjectResourceCardPreviewState; @@ -776,6 +782,7 @@ const ResourceCard = memo(function ResourceCard({ className={`game-resource-card${selected ? ' is-selected' : ''}${ relationState ? ` is-relation-${relationState}` : '' }${dragging ? ' is-dragging' : ''}`} + data-resource-card-id={resource.id} data-preview-kind={kind} style={ { @@ -783,6 +790,7 @@ const ResourceCard = memo(function ResourceCard({ '--resource-y': `${y}px`, '--resource-card-width': `${cardSize.width}px`, '--resource-card-height': `${cardSize.height}px`, + transform: `translate3d(var(--resource-x, 0), var(--resource-y, 0), 0) rotate(${rotation}deg)`, } as CSSProperties } onPointerDown={(event) => onPointerDown(event, resource, x, y)} @@ -841,6 +849,334 @@ const ResourceCard = memo(function ResourceCard({ ); }); +function ResourceBookThumbnail({ + category, + resources, + onOpen, + registerElement, +}: { + category: ResourceCategory; + resources: ProjectResource[]; + onOpen: (category: ResourceCategory, source: HTMLElement) => void; + registerElement?: ( + category: ResourceCategory, + element: HTMLElement | null, + ) => void; +}) { + const stacks = Array.from( + resources.reduce((groups, resource) => { + const type = projectResourceTypeLabel(resource); + const current = groups.get(type) ?? []; + current.push(resource); + groups.set(type, current); + return groups; + }, new Map()), + ); + + return ( +
registerElement?.(category, element)} + role="button" + tabIndex={0} + className="game-resource-book-thumbnail" + data-resource-book-category={category} + onClick={(event) => { + if ( + (event.target as HTMLElement).closest( + 'button, input, a, .game-resource-card', + ) + ) + return; + onOpen(category, event.currentTarget); + }} + onKeyDown={(event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + onOpen(category, event.currentTarget); + } + }} + aria-label={`进入${categoryLabels[category]}子画布`} + > +
+ ); +} + +function ResourceBookTitleBar({ + category, + countLabel, + onCollapse, +}: { + category: ResourceCategory; + countLabel: string; + onCollapse?: () => void; +}) { + const Icon = categoryIcons[category]; + return ( + + + + + {countLabel} + {onCollapse ? ( + + ) : null} + + + ); +} + +type ResourceBookOverviewRect = { + left: number; + top: number; + width: number; + height: number; +}; + +function ResourceBookScene({ + resources, + visibleCategoryOrder, + resourceBookView, + resourceBookTransition, + resourceBookCategory, + overviewRects, + positions, + cardSizes, + visibleResourceIds, + sceneSize, + renderCard, + onCollapse, +}: { + resources: ProjectResource[]; + visibleCategoryOrder: readonly ResourceCategory[]; + resourceBookView: ResourceBookView; + resourceBookTransition: ResourceBookTransition; + resourceBookCategory: ResourceCategory | null; + overviewRects: Map; + positions: Map; + cardSizes: Map; + visibleResourceIds: Set; + sceneSize: { width: number; height: number }; + renderCard: ( + resource: ProjectResource, + layout: { + x: number; + y: number; + width: number; + height: number; + rotation: number; + }, + ) => ReactNode; + onCollapse: () => void; +}) { + const resourcesByCategory = new Map(); + resources.forEach((resource) => { + const list = resourcesByCategory.get(resource.category) ?? []; + list.push(resource); + resourcesByCategory.set(resource.category, list); + }); + + return ( +
+ {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 positionedItems = categoryResources.map((item) => ({ + resource: item, + position: positions.get(item.id), + size: cardSizes.get(item.id) ?? { + width: 180, + height: 128, + }, + })); + const positioned = positionedItems.filter( + (item) => item.position, + ) as Array<{ + resource: ProjectResource; + position: { x: number; y: number }; + size: ResourceCanvasCardSize; + }>; + const minX = positioned.length + ? Math.min(...positioned.map((item) => item.position.x)) + : 0; + const minY = positioned.length + ? Math.min(...positioned.map((item) => item.position.y)) + : 0; + const maxX = positioned.length + ? Math.max( + ...positioned.map( + (item) => item.position.x + item.size.width, + ), + ) + : 720; + const maxY = positioned.length + ? Math.max( + ...positioned.map( + (item) => item.position.y + item.size.height, + ), + ) + : 480; + const availableWidth = Math.max(320, sceneSize.width - 96); + const availableHeight = Math.max(240, sceneSize.height - 96); + const expandedScale = Math.min( + 1, + availableWidth / Math.max(1, maxX - minX), + availableHeight / Math.max(1, maxY - minY), + ); + const expandedLeft = position + ? 48 + (position.x - minX) * expandedScale + : 48 + (stackIndex % 4) * 180; + const expandedTop = position + ? 48 + (position.y - minY) * expandedScale + : 48 + Math.floor(stackIndex / 4) * 150; + const expanded = active; + const layout = { + x: expanded ? expandedLeft : overviewLeft, + y: expanded ? expandedTop : overviewTop, + width: expanded ? size.width * expandedScale : 92, + height: expanded ? size.height * expandedScale : 64, + rotation: expanded + ? 0 + : (stackIndex % 2 ? -1 : 1) * (stackIndex + 0.5), + }; + return ( +
+ {renderCard(resource, layout)} +
+ ); + }), + )} +
+ ); + })} +
+ ); +} + export default function ProjectDevelopmentView({ orchestrationMode = 'professional-dag', projectName, @@ -858,6 +1194,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 resourceBookManagerRef = useRef(null); + const resourceBookThumbnailElementsRef = useRef( + new Map(), + ); + const [resourceBookOverviewRects, setResourceBookOverviewRects] = useState( + () => new Map(), + ); + const [resourceBookSceneSize, setResourceBookSceneSize] = useState({ + width: 900, + height: 640, + }); const [sortMode, setSortMode] = useState('dependency'); const [searchText, setSearchText] = useState(''); const [resourceCanvasViewports, setResourceCanvasViewports] = @@ -1450,6 +1808,15 @@ export default function ProjectDevelopmentView({ () => JSON.stringify([projectPath, manifest.projectId]), [manifest.projectId, projectPath], ); + useEffect(() => { + if (resourceBookTransitionTimerRef.current !== null) { + window.clearTimeout(resourceBookTransitionTimerRef.current); + resourceBookTransitionTimerRef.current = null; + } + setResourceBookView('main'); + setResourceBookCategory(null); + setResourceBookTransition('idle'); + }, [resourceCategoryScopeKey]); const resourcePageCategories = useMemo( () => (canvasResources.length > 0 ? visibleCategoryOrder : []), [canvasResources.length], @@ -1463,6 +1830,80 @@ export default function ProjectDevelopmentView({ ) ?? resourcePageCategories[0] ?? null; + + const registerResourceBookThumbnail = useCallback( + (category: ResourceCategory, element: HTMLElement | null) => { + if (element) { + resourceBookThumbnailElementsRef.current.set(category, element); + } else { + resourceBookThumbnailElementsRef.current.delete(category); + } + }, + [], + ); + + const measureResourceBookOverview = useCallback(() => { + const manager = resourceBookManagerRef.current; + if (!manager) { + return; + } + const managerRect = manager.getBoundingClientRect(); + setResourceBookSceneSize((current) => + Math.abs(current.width - managerRect.width) < 0.5 && + Math.abs(current.height - managerRect.height) < 0.5 + ? current + : { width: managerRect.width, height: managerRect.height }, + ); + const next = new Map(); + resourceBookThumbnailElementsRef.current.forEach((element, category) => { + const rect = element.getBoundingClientRect(); + next.set(category, { + left: rect.left - managerRect.left, + top: rect.top - managerRect.top, + width: rect.width, + height: rect.height, + }); + }); + setResourceBookOverviewRects((current) => { + if (current.size === next.size) { + let same = true; + next.forEach((rect, category) => { + const previous = current.get(category); + if ( + !previous || + Math.abs(previous.left - rect.left) > 0.5 || + Math.abs(previous.top - rect.top) > 0.5 || + Math.abs(previous.width - rect.width) > 0.5 || + Math.abs(previous.height - rect.height) > 0.5 + ) { + same = false; + } + }); + if (same) { + return current; + } + } + return next; + }); + }, []); + + useLayoutEffect(() => { + measureResourceBookOverview(); + const manager = resourceBookManagerRef.current; + if (!manager || typeof ResizeObserver === 'undefined') { + return undefined; + } + const observer = new ResizeObserver(measureResourceBookOverview); + observer.observe(manager); + resourceBookThumbnailElementsRef.current.forEach((element) => + observer.observe(element), + ); + window.addEventListener('resize', measureResourceBookOverview); + return () => { + observer.disconnect(); + window.removeEventListener('resize', measureResourceBookOverview); + }; + }, [measureResourceBookOverview, resourceBookView]); const viewedResourceCategory = mode === 'resources' ? activePageCategory : null; useEffect(() => { @@ -1943,8 +2384,121 @@ export default function ProjectDevelopmentView({ clearSkippedResourceCardClick(); }, [clearSkippedResourceCardClick]); + useEffect(() => { + return () => { + if (resourceBookTransitionTimerRef.current !== null) { + window.clearTimeout(resourceBookTransitionTimerRef.current); + } + }; + }, []); + + const scheduleResourceBookTransition = useCallback( + (callback: () => void, delayMs: number) => { + if (resourceBookTransitionTimerRef.current !== null) { + window.clearTimeout(resourceBookTransitionTimerRef.current); + } + resourceBookTransitionTimerRef.current = window.setTimeout(() => { + resourceBookTransitionTimerRef.current = null; + callback(); + }, delayMs); + }, + [], + ); + + const openResourceBookChild = useCallback( + (category: ResourceCategory, source?: HTMLElement) => { + 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' + ) { + return; + } + + if (resourceBookView === 'child') { + setResourceBookTransition('returning'); + scheduleResourceBookTransition(() => { + setActiveResourceCategory(category); + setResourceBookCategory(category); + setResourceBookView('main'); + setResourceBookTransition('idle'); + window.requestAnimationFrame(() => { + setResourceBookView('child'); + setResourceBookTransition('entering'); + scheduleResourceBookTransition(() => { + setResourceBookTransition('idle'); + }, 520); + }); + }, 320); + return; + } + + setActiveResourceCategory(category); + setResourceBookCategory(category); + setResourceBookView('child'); + setResourceBookTransition('entering'); + scheduleResourceBookTransition(() => { + setResourceBookTransition('idle'); + }, 520); + }, + [ + cancelQueuedResourceCanvasPageWheel, + cancelResourceCanvasPan, + cancelResourceCardDrag, + resourceBookCategory, + resourceBookManagerRef, + resourceBookTransition, + resourceBookView, + scheduleResourceBookTransition, + ], + ); + + const returnToResourceBookMain = useCallback(() => { + if (resourceBookView !== 'child') { + return; + } + cancelResourceCardDrag(); + cancelResourceCanvasPan(); + cancelQueuedResourceCanvasPageWheel(); + setResourceBookTransition('returning'); + scheduleResourceBookTransition(() => { + setResourceBookView('main'); + setResourceBookTransition('idle'); + }, 320); + }, [ + cancelQueuedResourceCanvasPageWheel, + cancelResourceCanvasPan, + cancelResourceCardDrag, + resourceBookView, + scheduleResourceBookTransition, + ]); + const selectResourceCanvasPage = useCallback( (category: ResourceCategory) => { + if (resourceBookView === 'main') { + openResourceBookChild(category); + return; + } cancelResourceCardDrag(); cancelResourceCanvasPan(); cancelQueuedResourceCanvasPageWheel(); @@ -1955,6 +2509,8 @@ export default function ProjectDevelopmentView({ cancelQueuedResourceCanvasPageWheel, cancelResourceCanvasPan, cancelResourceCardDrag, + openResourceBookChild, + resourceBookView, ], ); @@ -4208,6 +4764,83 @@ export default function ProjectDevelopmentView({ !resourceEditorRoute && !uiEditorRoute; + const renderResourceBookCard = useCallback( + ( + resource: ProjectResource, + layout: { + x: number; + y: number; + width: number; + height: number; + rotation: number; + }, + ) => { + const previewIdentity = resourceCardPreviews.identityByResourceId.get( + resource.id, + ); + if (!previewIdentity) { + return null; + } + const dragPreview = + resourceCardDragPreview?.resourceId === resource.id + ? resourceCardDragPreview + : null; + return ( + + handleResourceCardPointerDown(event, resource, layout.x, layout.y) + } + onPointerMove={handleResourceCardPointerMove} + onPointerUp={handleResourceCardPointerUp} + onPointerCancel={handleResourceCardPointerCancel} + onObservePreview={resourceCardPreviews.observePreview} + onRequestPreview={resourceCardPreviews.requestPreview} + onPreviewDecodeError={handleCardPreviewDecodeError} + onMediaToggle={handleCardMediaToggle} + onMediaReady={handleCardMediaReady} + onMediaPlaybackChange={handleCardMediaPlaybackChange} + /> + ); + }, + [ + activeCardMediaIdentity, + handleCardMediaPlaybackChange, + handleCardMediaReady, + handleCardMediaToggle, + handleCardPreviewDecodeError, + handleResourceCardOpenEditor, + handleResourceCardPointerCancel, + handleResourceCardPointerDown, + handleResourceCardPointerMove, + handleResourceCardPointerUp, + handleResourceSelect, + resourceCardDragPreview, + resourceCardPreviews, + selectedResourceId, + selectedVersionBindingResourceIds, + ], + ); + return (
) : null} -
+
+