修复 AGC 资源搜索框与栏目标题栏挤在同一水平带的重叠

- styles.css:新增 `.game-resource-book-tools` 工具条带,搜索框、筛选条与状态提示在里面按流式排布;`.game-resource-search` 去掉 `margin: 10px 12px 0`,尺寸与间距交给工具条带。
- styles.css:`.game-resource-manager` 加 `padding-top: var(--game-resource-book-tools-height, 0px)`,`.game-resource-book-scene` 的 `inset: 0` 改成同一变量让出顶边;管理区与画本场景共用一条实测高度,两者不再落在同一条排布带上(总览态标题栏与分页态栏目标题栏都整体下移)。
- styles.css:`@media (max-width: 560px)` 下搜索框独占整行,窄屏筛选条换行时工具条带变高、场景按实测高度继续下移,不靠 z-index 硬压。
- styles.css:更新工具条带注释,说明 `z-index: 40` 仍保留(高于场景的 20),作用只剩"工具条内容溢出时不被画本盖住"。
- index.tsx:把画本场景以外的流式工具条包进 `game-resource-book-tools`,搜索框挂 `resourceBookToolsRef`,用 ResizeObserver 实测 `offsetTop + offsetHeight` 写入 `--game-resource-book-tools-height`;搜索、筛选、「清除搜索并定位」与 `resourceSearchRef` 全部零改动。
- index.tsx:新增 `resourceCanvasSceneSize()`,视口居中、「适应内容」、总览入场中心、滚轮与缩放锚点都按"管理区盒子减掉工具条带"计算,不再按"场景一直顶到管理区顶端"排布;`handleResourceBookWheel` 与 `zoomResourceBookBy` 补上新增依赖。
- index.tsx:`measureResourceBookOverview()` 改用画本场景自己的 rect 作为总览栏目矩形原点,场景盒子上移后栏目矩形与世界坐标仍严格同源。
- project-development.suite.ts:在既有 CSS 声明级守卫里补断言——管理区 `padding-top` 与场景 `inset` 必须指向同一个 `--game-resource-book-tools-height` 变量,工具条带是绝对定位的独立排布行,栏目标题栏 z-index 低于工具条带;选择器加行首断言,避免误取 `.game-workbench-stage > .game-resource-manager`。
- project-development.suite.ts:在栏目大纲用例里补真实渲染的 DOM 归属断言——搜索框的父链不得命中画本场景或资源总览层,工具条带是管理区直接子节点;该断言在改动前失败、改动后通过,已用"把工具条带类名改掉"的变异验证它不是恒真假守卫。
This commit is contained in:
2026-09-10 21:12:30 +08:00
parent df4168f71f
commit 2fefb373d1
3 changed files with 261 additions and 113 deletions
+40 -7
View File
@@ -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 {
@@ -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<HTMLDivElement>(null);
/**
* 工具条带的第一条流式元素(搜索框)。工具条带本身没有独立包裹节点,命中它就能把
* "第一条流式元素顶边到管理区顶边"的高度算出来,正好是画本场景要让出的那条带。
*/
const resourceBookToolsRef = useRef<HTMLLabelElement>(null);
const resourceBookPlanKeysRef = useRef(
new Map<string, ResourceBookSceneCardPlan>(),
);
@@ -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<ResourceSortMode>('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<HTMLElement>('.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<ResourceCategory, ResourceBookOverviewRect>();
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({
<Maximize2 size={16} aria-hidden="true" />
</button>
</div>
<label className="game-resource-search">
<Search size={16} aria-hidden="true" />
<input
ref={resourceSearchRef}
type="search"
aria-label="搜索项目资源"
value={searchText}
placeholder="搜索资源名称、路径或类型"
onChange={(event) => {
advanceFocusGeneration();
setSearchText(event.currentTarget.value);
setHiddenCommittedResourceId(null);
}}
/>
</label>
<PlatformResourceFilterBar
ariaLabel="资源筛选"
categoryItems={RESOURCE_REFERENCE_FILTERS}
activeCategoryId={resourceCategoryFilter}
onCategoryChange={showResourceCategoryFilter}
tagItems={resourceTagLibrary}
activeTags={resourceCanvasActiveTags}
onToggleTag={(tag) => {
setResourceCanvasActiveTags((current) =>
current.includes(tag)
? current.filter((item) => item !== tag)
: [...current, tag],
);
}}
className="game-resource-category-filter"
/>
{resourceWorkbenchNotice ? (
<div className="game-resource-live-notice" role="status">
<span>{resourceWorkbenchNotice}</span>
{hiddenCommittedResourceId ? (
<button
type="button"
onClick={() => {
const intent = pendingResourceFocusRef.current;
if (
!intent ||
intent.resourceId !== hiddenCommittedResourceId
) {
return;
}
const focusGeneration = advanceFocusGeneration();
pendingResourceFocusRef.current = {
...intent,
focusGeneration,
completed: false,
};
setSearchText('');
setHiddenCommittedResourceId(null);
setResourceWorkbenchNotice('正在定位新资源…');
}}
>
</button>
) : null}
</div>
) : null}
{attachments.some(
(attachment) => attachment.status === 'failed',
) ? (
<section
className="game-attachment-errors"
aria-label="附件导入失败"
role="status"
<div className="game-resource-book-tools">
<label
ref={resourceBookToolsRef}
className="game-resource-search"
>
{attachments
.filter((attachment) => attachment.status === 'failed')
.map((attachment) => (
<p key={`${attachment.fileName}:${attachment.error}`}>
<strong>{attachment.fileName}</strong>
<span>{attachment.error || '附件导入失败'}</span>
</p>
))}
</section>
) : null}
<Search size={16} aria-hidden="true" />
<input
ref={resourceSearchRef}
type="search"
aria-label="搜索项目资源"
value={searchText}
placeholder="搜索资源名称、路径或类型"
onChange={(event) => {
advanceFocusGeneration();
setSearchText(event.currentTarget.value);
setHiddenCommittedResourceId(null);
}}
/>
</label>
<PlatformResourceFilterBar
ariaLabel="资源筛选"
categoryItems={RESOURCE_REFERENCE_FILTERS}
activeCategoryId={resourceCategoryFilter}
onCategoryChange={showResourceCategoryFilter}
tagItems={resourceTagLibrary}
activeTags={resourceCanvasActiveTags}
onToggleTag={(tag) => {
setResourceCanvasActiveTags((current) =>
current.includes(tag)
? current.filter((item) => item !== tag)
: [...current, tag],
);
}}
className="game-resource-category-filter"
/>
{resourceWorkbenchNotice ? (
<div className="game-resource-live-notice" role="status">
<span>{resourceWorkbenchNotice}</span>
{hiddenCommittedResourceId ? (
<button
type="button"
onClick={() => {
const intent = pendingResourceFocusRef.current;
if (
!intent ||
intent.resourceId !== hiddenCommittedResourceId
) {
return;
}
const focusGeneration = advanceFocusGeneration();
pendingResourceFocusRef.current = {
...intent,
focusGeneration,
completed: false,
};
setSearchText('');
setHiddenCommittedResourceId(null);
setResourceWorkbenchNotice('正在定位新资源…');
}}
>
</button>
) : null}
</div>
) : null}
{attachments.some(
(attachment) => attachment.status === 'failed',
) ? (
<section
className="game-attachment-errors"
aria-label="附件导入失败"
role="status"
>
{attachments
.filter((attachment) => attachment.status === 'failed')
.map((attachment) => (
<p key={`${attachment.fileName}:${attachment.error}`}>
<strong>{attachment.fileName}</strong>
<span>{attachment.error || '附件导入失败'}</span>
</p>
))}
</section>
) : null}
</div>
<section
className={`game-resource-book-main${resourceBookState.view === 'child' ? ' is-background' : ''}`}
aria-label="资源总览"
@@ -1191,22 +1191,32 @@ export function registerProjectWorkbenchFoundationTests() {
// 但"在场景之上"只是不被盖住,不等于两者不重叠:上一版把搜索框常态居中浮在画本顶上,
// 分页态栏目标题栏正好被压在同一水平带上。所以还要钉住"工具条带独占顶部那条带"。
const scene = styleRuleBody(styles, '\\.game-resource-book-scene');
expect(scene).toMatch(/inset:\s*var\(--game-resource-book-tools-height\)/u);
// 管理区给工具条带留出的高度与场景让出的顶边必须是同一个变量,否则两者会再次互相压住
const manager = styleRuleBody(styles, '\\.game-resource-manager');
//
// 不重叠由两条声明级约定保证,缺一条就会退回重叠:
// 1. 管理区按实测的工具条带高度留出 padding-top
// 2. 画本场景把自己的顶边按同一个变量下移
// 两条都指向 `--game-resource-book-tools-height`,所以断言两边用的是同一个变量,
// 而不是各写各的数字(写死数字时任何一处改动都会重新压住搜索框)。
const toolsHeightVariable = '--game-resource-book-tools-height';
// 选择器前缀加行首断言:`.game-workbench-stage > .game-resource-manager` 这种
// 后代选择器里也含同名类,不加断言会取到那条只有 grid-row 的规则。
const manager = styleRuleBody(styles, '(?:^|\\n)\\.game-resource-manager');
expect(manager).toMatch(
/padding-top:\s*var\(--game-resource-book-tools-height\)/u,
new RegExp(`padding-top:\\s*var\\(${toolsHeightVariable}`, 'u'),
);
const scene = styleRuleBody(styles, '\\.game-resource-book-scene');
expect(scene).toMatch(
new RegExp(`inset:\\s*var\\(${toolsHeightVariable}`, 'u'),
);
// 场景只让出顶边:宽度与底边必须仍是 0,否则画本区域会缩成一条缝。
expect(scene).toMatch(/inset:\s*var\([^)]*\)\s+0\s+0;/u);
// 工具条带是绝对定位的独立排布行,不是流内浮层;搜索框按整行宽度算,
// 不与任何栏目标题栏共享同一水平带。
// 不与任何栏目标题栏共享同一水平带(分页态标题栏 z-index 30 < 工具条带 40
const tools = styleRuleBody(styles, '\\.game-resource-book-tools');
expect(tools).toMatch(/position:\s*absolute/u);
expect(styleNumber(tools, 'top')).toBe(0);
// 搜到的是场景里真正的栏目标题栏(分页态被激活时铺满整行),它必须整体排在工具条带下面。
const titlebar = styleRuleBody(
styles,
'\\.game-resource-book-scene-titlebar',
@@ -1215,8 +1225,11 @@ export function registerProjectWorkbenchFoundationTests() {
styleNumber(tools, 'z-index'),
);
expect(tsxSource).toContain('game-resource-book-tools');
expect(tsxSource).not.toContain('game-resource-book-scene-titlebar');
// 结构上搜索框必须待在工具条带里:离开这条带就又会浮回画本顶部。
const toolsTag = tsxSource.indexOf('game-resource-book-tools');
const searchTag = tsxSource.indexOf('game-resource-search');
expect(toolsTag).toBeGreaterThan(-1);
expect(searchTag).toBeGreaterThan(toolsTag);
});
it('keeps the outline dock readable without hover', () => {
@@ -1325,6 +1338,34 @@ export function registerProjectWorkbenchFoundationTests() {
.getByRole('button', { name: '角色与对象' })
.getAttribute('aria-current'),
).toBe('page');
// 搜索框与栏目标题栏分属两条排布带,这不是 CSS 声明能表达的:画本场景是管理区里
// 铺满整块的绝对定位层,搜索框只要还在场景内部,就一定会被栏目标题栏压在同一个
// 水平带上。所以这里钉住渲染出来的 DOM 归属关系。
const searchLabel = screen.getByLabelText('搜索项目资源').closest('label');
expect(searchLabel?.className).toContain('game-resource-search');
const toolsBand = searchLabel?.parentElement ?? null;
expect(toolsBand?.className).toContain('game-resource-book-tools');
const manager = toolsBand?.parentElement ?? null;
expect(manager?.className).toContain('game-resource-manager');
// 画本场景是管理区里铺满整块的绝对定位层:搜索框只要落在它内部,就会被栏目标题栏
// 压在同一个水平带上。入场动画会替换场景节点,所以这里用祖先链判断归属关系,
// 不依赖某一个具体节点的身份,也不受节点替换影响。
// 从父节点起算:label 自己就带 `game-resource-search`closest 要排除自身。
const searchAncestors = searchLabel?.parentElement ?? null;
expect(searchAncestors?.closest('.game-resource-book-scene')).toBeNull();
expect(searchAncestors?.closest('.game-resource-book-main')).toBeNull();
expect(searchAncestors?.closest('.game-resource-book-tools')).toBe(
toolsBand,
);
// 两条排布带在管理区里是并列的:搜索框所在的工具条带与画本场景各占一条。
expect(toolsBand?.parentElement).toBe(manager);
expect(
manager?.querySelector(':scope > .game-resource-book-scene'),
).not.toBeNull();
});
it('keeps the shared selection overlay colours resolvable from the scene root', () => {