diff --git a/apps/ai-game-creator-shell/src/styles.css b/apps/ai-game-creator-shell/src/styles.css index 5a56894ea..68dd0c241 100644 --- a/apps/ai-game-creator-shell/src/styles.css +++ b/apps/ai-game-creator-shell/src/styles.css @@ -5640,6 +5640,13 @@ iframe.preview-frame { flex-direction: column; flex: 1 1 auto; min-height: 0; + /* + * 顶部工具条带(搜索框、筛选条、状态提示)在流内占一条独立排布带, + * `--game-resource-book-tools-height` 由实测高度写入。铺满管理区的画本场景按同一 + * 变量把自己缩到这条带下面,栏目标题栏与栏目大纲就不会和搜索框落在同一条排布带上 + * (分页态与总览态都适用)。 + */ + padding-top: var(--game-resource-book-tools-height, 0px); } .game-resource-book-manager { @@ -5651,7 +5658,8 @@ iframe.preview-frame { .game-resource-book-scene { position: absolute; z-index: 20; - inset: 0; + /* 顶边让出工具条带:栏目标题栏与栏目大纲整体下移,不与搜索框争同一条排布带。 */ + inset: var(--game-resource-book-tools-height, 0px) 0 0; overflow: hidden; pointer-events: none; transition: none; @@ -6158,20 +6166,35 @@ iframe.preview-frame { } /* - * 资源画本的卡片与栏目标题栏都画在铺满资源区的 `.game-resource-book-scene` - * (z-index 20)里,流式工具条默认被它整个盖住。搜索框和它下面的提示条必须抬到 - * 场景之上(与右下角缩放 Dock 同为 40),否则放开 display 也只是"画了但看不见"。 + * 工具条带:搜索框、筛选条与状态提示在这里按独立排布行排列,画本场景从这条带下面 + * 开始(见 `.game-resource-manager` 的 padding-top)。工具条里的浮层元素仍高于场景 + * (z-index 40 > 场景的 20),工具条内容溢出时不会被画本盖住。 */ +.game-resource-book-tools { + position: absolute; + top: 0; + right: 0; + left: 0; + z-index: 40; + display: flex; + flex-direction: column; + gap: 8px; + padding: 10px 12px 4px; +} + +.game-resource-book-tools > * { + margin: 0; + width: 100%; +} + .game-resource-search { position: relative; z-index: 40; display: flex; - /* 常态居中,避开总览态标题和分页态栏目标题栏左右两端的内容。 */ - align-self: center; align-items: center; gap: 8px; + align-self: center; width: min(300px, 100%); - margin: 10px 12px 0; padding: 0 10px; border: 1px solid #ecdcd4; border-radius: 10px; @@ -8859,6 +8882,16 @@ iframe.preview-frame { font-size: 12px; } +/* + * 窄屏(移动端优先):筛选条与标签开始换行时,搜索框改为整行独占, + * 工具条带变高后画本场景按实测高度继续下移,任何宽度下都不与标题栏重叠。 + */ +@media (max-width: 560px) { + .game-resource-search { + width: 100%; + } +} + @media (max-width: 620px) { .game-resource-recovery-item, .game-resource-recovery-error { 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 8c8522fb9..b6fde1e08 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 @@ -422,6 +422,21 @@ function resourceCanvasElementSize(element: HTMLElement | null) { }; } +/** + * 资源工具条(搜索框、筛选条、状态提示)在管理区顶部占一条独立排布带,画本场景的盒子 + * 从这条带下面开始。按视口排布资源时要减掉这条带,否则"适应内容"仍按旧的 + * "场景一直顶到管理区顶端"居中,把卡片排到工具条底下。 + */ +function resourceCanvasSceneSize( + size: { width: number; height: number }, + chromeHeight: number, +) { + return { + width: size.width, + height: Math.max(0, size.height - Math.max(0, chromeHeight)), + }; +} + function resourceCanvasViewportsEqual( left: CanvasViewport, right: CanvasViewport, @@ -1223,6 +1238,11 @@ export default function ProjectDevelopmentView({ createResourceBookTransitionController(), ); const resourceBookManagerRef = useRef(null); + /** + * 工具条带的第一条流式元素(搜索框)。工具条带本身没有独立包裹节点,命中它就能把 + * "第一条流式元素顶边到管理区顶边"的高度算出来,正好是画本场景要让出的那条带。 + */ + const resourceBookToolsRef = useRef(null); const resourceBookPlanKeysRef = useRef( new Map(), ); @@ -1236,6 +1256,15 @@ export default function ProjectDevelopmentView({ width: 900, height: 640, }); + const [resourceBookToolsHeight, setResourceBookToolsHeight] = useState(0); + /** + * 场景局部的可排布尺寸:管理区盒子减掉顶部工具条带。视口居中与"适应内容"都按它算。 + */ + const sceneFitCanvasSize = useMemo( + () => + resourceCanvasSceneSize(resourceBookSceneSize, resourceBookToolsHeight), + [resourceBookSceneSize, resourceBookToolsHeight], + ); const [sortMode, setSortMode] = useState('dependency'); const [searchText, setSearchText] = useState(''); const [resourceCanvasCategoryFilters, setResourceCanvasCategoryFilters] = @@ -2061,11 +2090,16 @@ export default function ProjectDevelopmentView({ return; } const managerRect = manager.getBoundingClientRect(); + // 画本场景是管理区内缩掉顶部工具条带之后的盒子;总览栏目矩形与场景世界都按它定位。 + const sceneRect = + manager + .querySelector('.game-resource-book-scene') + ?.getBoundingClientRect() ?? managerRect; setResourceBookSceneSize((current) => - Math.abs(current.width - managerRect.width) < 0.5 && - Math.abs(current.height - managerRect.height) < 0.5 + Math.abs(current.width - sceneRect.width) < 0.5 && + Math.abs(current.height - sceneRect.height) < 0.5 ? current - : { width: managerRect.width, height: managerRect.height }, + : { width: sceneRect.width, height: sceneRect.height }, ); const next = new Map(); resourceBookThumbnailElementsRef.current.forEach((element, category) => { @@ -2126,6 +2160,29 @@ export default function ProjectDevelopmentView({ window.removeEventListener('resize', measureResourceBookOverview); }; }, [measureResourceBookOverview, resourceBookView, canvasResources.length]); + + useLayoutEffect(() => { + const tools = resourceBookToolsRef.current; + if (!tools) { + return undefined; + } + const measureTools = () => { + const height = tools.offsetTop + tools.offsetHeight; + setResourceBookToolsHeight((current) => + Math.abs(current - height) < 0.5 ? current : height, + ); + }; + measureTools(); + if (typeof ResizeObserver === 'undefined') { + window.addEventListener('resize', measureTools); + return () => window.removeEventListener('resize', measureTools); + } + // 工具条会随窗口宽度换行(窄屏下搜索框与筛选条各占一行),高度必须实测: + // 场景盒子按它下移,两者才不会落在同一条排布带上。 + const observer = new ResizeObserver(measureTools); + observer.observe(tools); + return () => observer.disconnect(); + }, [mode, resourceBookView, searchText]); const viewedResourceCategory = mode === 'resources' ? activePageCategory : null; useEffect(() => { @@ -2302,8 +2359,8 @@ export default function ProjectDevelopmentView({ // 返回总览时也不会跳成另一张图。 const resourceBookCategoryViewCenters = useMemo(() => { const canvasSize = - resourceBookSceneSize.width > 0 && resourceBookSceneSize.height > 0 - ? resourceBookSceneSize + sceneFitCanvasSize.width > 0 && sceneFitCanvasSize.height > 0 + ? sceneFitCanvasSize : { width: RESOURCE_CANVAS_FALLBACK_WIDTH, height: RESOURCE_CANVAS_FALLBACK_HEIGHT, @@ -2322,7 +2379,7 @@ export default function ProjectDevelopmentView({ ] as const; }), ); - }, [resourceBookSceneSize, resourceCanvasViewports, sortMode]); + }, [sceneFitCanvasSize, resourceCanvasViewports, sortMode]); const setResourceCanvasViewport = useCallback( ( category: ResourceCategory, @@ -2815,14 +2872,14 @@ export default function ProjectDevelopmentView({ const categoryFitKey = `${projectPath}\n${manifest.projectId}\n${sortMode}\n${category}`; const fitCanvasSize = - resourceBookSceneSize.width > 0 && resourceBookSceneSize.height > 0 - ? resourceBookSceneSize + sceneFitCanvasSize.width > 0 && sceneFitCanvasSize.height > 0 + ? sceneFitCanvasSize : { width: RESOURCE_CANVAS_FALLBACK_WIDTH, height: RESOURCE_CANVAS_FALLBACK_HEIGHT, }; const fitMaxScale = - resourceBookSceneSize.width > 0 && resourceBookSceneSize.height > 0 + sceneFitCanvasSize.width > 0 && sceneFitCanvasSize.height > 0 ? RESOURCE_CANVAS_BOOK_FIT_MAX_SCALE : 1; const categoryBounds = resourceCanvasContentBounds( @@ -2867,7 +2924,7 @@ 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. - if (resourceBookSceneSize.width > 0 && resourceBookSceneSize.height > 0) { + if (sceneFitCanvasSize.width > 0 && sceneFitCanvasSize.height > 0) { resourceCanvasFitKeysRef.current.add(categoryFitKey); } @@ -2880,7 +2937,7 @@ export default function ProjectDevelopmentView({ manifest.projectId, projectPath, resourceBookState, - resourceBookSceneSize, + sceneFitCanvasSize, resourceCardSizeByResourceId, resourcePositionsByCategory, setResourceCanvasViewport, @@ -3825,14 +3882,14 @@ export default function ProjectDevelopmentView({ activePageCategory, fitResourceCanvasViewportToContent({ bounds: resourceCanvasFitBounds, - canvasSize: resourceBookSceneSize, + canvasSize: sceneFitCanvasSize, padding: RESOURCE_CANVAS_FIT_PADDING, maxScale: RESOURCE_CANVAS_BOOK_FIT_MAX_SCALE, }), ); }, [ activePageCategory, - resourceBookSceneSize, + sceneFitCanvasSize, resourceBookView, resourceCanvasFitBounds, setResourceCanvasViewport, @@ -3844,7 +3901,8 @@ export default function ProjectDevelopmentView({ const sceneElement = resourceBookManagerRef.current; const rect = sceneElement?.getBoundingClientRect(); const anchorX = rect ? rect.width / 2 : 0; - const anchorY = rect ? rect.height / 2 : 0; + // 锚点取真正看得见的画本区域中心:顶部工具条带不算画布。 + const anchorY = rect ? (rect.height - resourceBookToolsHeight) / 2 : 0; if (resourceBookView === 'main') { const viewport = resourceBookMainViewportRef.current; const next = zoomResourceBookViewport(viewport, factor, { @@ -3868,7 +3926,12 @@ export default function ProjectDevelopmentView({ if (next.scale === viewport.scale) return; setResourceCanvasViewport(category, next); }, - [activePageCategory, resourceBookView, setResourceCanvasViewport], + [ + activePageCategory, + resourceBookToolsHeight, + resourceBookView, + setResourceCanvasViewport, + ], ); useLayoutEffect(() => { @@ -3904,9 +3967,10 @@ export default function ProjectDevelopmentView({ } event.preventDefault(); const rect = sceneElement.getBoundingClientRect(); + // 工具条带里没有画布,滚轮锚点先减掉它,缩放才不会以被盖住的区域为中心。 const screenPoint = { x: event.clientX - rect.left, - y: event.clientY - rect.top, + y: event.clientY - rect.top - resourceBookToolsHeight, }; if (resourceBookView === 'main') { const current = resourceBookMainViewportRef.current; @@ -3938,7 +4002,12 @@ export default function ProjectDevelopmentView({ }); setResourceCanvasViewport(category, result.viewport, true); }, - [activePageCategory, resourceBookView, setResourceCanvasViewport], + [ + activePageCategory, + resourceBookToolsHeight, + resourceBookView, + setResourceCanvasViewport, + ], ); useEffect(() => { @@ -5735,85 +5804,90 @@ export default function ProjectDevelopmentView({