fix: fit the story graph to the window and scroll each column on its own
This commit is contained in:
@@ -103,6 +103,8 @@ Modal 按[共享表单 Modal 规则](../../../UI-DESIGN.md#615-表单-modal)复
|
||||
|
||||
使用 `supervisor` 页面壳层,最大内容宽度为 `1440px`。当前生产图谱左侧为紧凑的事件、实体和关系列表,中间为 SVG 画布,右侧为详情与来源。桌面列宽为 `216px minmax(0, 1fr) 280px`,画布获得主要空间。外圈事件按发生时间排序后等距逆时针排列,不表示实际时间间隔;起止之间保留缺口和方向箭头。内部显示真实实体名称,实线仅连接实际事件与实体,虚线仅连接实际实体关系。
|
||||
|
||||
高度:容器宽度不小于 861px 时,图谱这一行的高度等于窗口可见高度减去图谱顶部位置,并在底部保留约 24px 边距(不小于页面底部内边距),最低 420px。三栏时整页不再出现纵向滚动;左侧列表、中间画布、右侧详情各自在这一行内独立滚动,滚动只影响自身,不带动页面。两栏(861~1190px)时第一行按同样方式填满,详情换到下一行,保持自然高度并随页面滚动。单栏保持原有页面滚动和列表 320px 上限。窗口大小或图谱上方内容(提示、工具栏)变化时重新计算。
|
||||
|
||||
实体及其影响连线复用现有 `--graph-node-*-border` 令牌;颜色用于辅助辨认实体,不代表故事分类或确认状态。选中项加粗并突出连线,其余节点降低图形强调度但保留可读文字。详情分为名称与说明、图谱关联、确认操作和来源,来源正文按需读取。图例常显,读图说明可展开。
|
||||
|
||||
画布每批最多显示 8 个事件、6 个实体;同时间事件分开排布。列表保留全部返回记录和完整名称,选择实体会定位到真实关联事件所在批次,选择关系会优先显示两端实体。滑块明确命名“事件浏览”,只切换当前事件,不还原实体历史状态,也不宣称完整历史回放。当前接口返回单条故事线,不复制 Demo 中的三条模拟故事。后续视图规划见[图谱设计](../story-graph/prd.md)。
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { SupervisionReviewExecution } from '../../shared/supervision-review
|
||||
import { EmptyState, PageTabs } from './WorkspacePrimitives'
|
||||
import { SupervisionDiscussion } from './SupervisionDiscussion'
|
||||
import { SupervisionStoryDigest } from './SupervisionStoryDigest'
|
||||
import { useFillHeight } from './use-fill-height'
|
||||
import { SupervisionEventStory, SupervisionExperienceDetail, SupervisionExperienceList, SupervisionStoryDetail, SupervisionStoryList, useSupervisionStories } from './SupervisionStories'
|
||||
import type { AssistantProject } from '../../shared/assistant-contracts'
|
||||
import { heartbeatScopeSchema } from '../../shared/assistant-contracts'
|
||||
@@ -478,6 +479,8 @@ export function SupervisorWorkspace({
|
||||
? (JSON.parse(graph.storyLine.scope_json) as SupervisionRunRequest['scope'])
|
||||
: undefined
|
||||
const storyState = useSupervisionStories(graphScope, tab === 'graph', graph)
|
||||
const graphLayoutRef = useRef<HTMLDivElement>(null)
|
||||
useFillHeight(graphLayoutRef, tab === 'graph' && graph.events.length > 0)
|
||||
// The work review reads the selected result's own scope, so it matches the period shown.
|
||||
const recapStories = useSupervisionStories(latest?.scope, tab === 'overview', latest?.id)
|
||||
const selectedStory = selection?.kind === 'story' ? storyState.view.stories.find((story) => story.id === selection.id) : undefined
|
||||
@@ -718,7 +721,7 @@ export function SupervisorWorkspace({
|
||||
/>
|
||||
)}
|
||||
{graph.events.length > 0 && (
|
||||
<div className="supervisor-workspace__graph-layout">
|
||||
<div className="supervisor-workspace__graph-layout" ref={graphLayoutRef}>
|
||||
<aside
|
||||
className="supervisor-workspace__graph-list"
|
||||
aria-label={t('supervisor.selection')}
|
||||
|
||||
@@ -388,6 +388,41 @@
|
||||
.supervisor-workspace__graph-canvas {
|
||||
border-inline: 1px solid var(--border-default);
|
||||
}
|
||||
/*
|
||||
* Wide layouts: the graph row is as tall as the window allows (`--fill-height`, set by useFillHeight with a
|
||||
* steady bottom margin), and each column in it scrolls on its own, so long lists never grow the page.
|
||||
* With three columns that is the whole layout; with two, the detail wraps below at its natural height.
|
||||
* Single-column layouts keep page scrolling.
|
||||
*/
|
||||
@container supervisor (min-width: 861px) {
|
||||
.supervisor-workspace__graph-layout {
|
||||
grid-template-rows: var(--fill-height, auto);
|
||||
grid-auto-rows: auto;
|
||||
}
|
||||
.supervisor-workspace__graph-canvas,
|
||||
.supervisor-workspace__graph-layout > .supervisor-workspace__detail {
|
||||
min-height: 0;
|
||||
max-height: var(--fill-height, none);
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
.supervisor-workspace__graph-list {
|
||||
contain: none;
|
||||
height: 100%;
|
||||
}
|
||||
.supervisor-workspace__graph-list > .supervisor-workspace__list-panel {
|
||||
overscroll-behavior: contain;
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
}
|
||||
@container supervisor (min-width: 861px) and (max-width: 1190px) {
|
||||
.supervisor-workspace__graph-layout > .supervisor-workspace__detail {
|
||||
max-height: none;
|
||||
overflow-y: visible;
|
||||
}
|
||||
}
|
||||
.supervisor-workspace__canvas-heading {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { useLayoutEffect, type RefObject } from 'react'
|
||||
|
||||
/**
|
||||
* Sets `--fill-height` on the element: the visible height of its scroll container from the element's
|
||||
* top down, minus `bottomGap`, never below `minimum`. Lets a panel fill the window with a steady bottom
|
||||
* margin while its columns scroll inside, instead of growing the page with their content.
|
||||
* Measured with the container scrolled to the top, so scrolling does not change the value.
|
||||
*/
|
||||
export function useFillHeight(ref: RefObject<HTMLElement | null>, enabled: boolean, bottomGap = 24, minimum = 420): void {
|
||||
useLayoutEffect(() => {
|
||||
const element = ref.current
|
||||
if (!element || !enabled) return
|
||||
let container: HTMLElement | null = element.parentElement
|
||||
while (container && !/(auto|scroll)/.test(getComputedStyle(container).overflowY)) container = container.parentElement
|
||||
const scroller = container ?? document.documentElement
|
||||
const measure = () => {
|
||||
const top = element.getBoundingClientRect().top - scroller.getBoundingClientRect().top + scroller.scrollTop
|
||||
const padding = parseFloat(getComputedStyle(scroller).paddingBottom) || 0
|
||||
// The layout's own border sits outside the row; subtract it so the outer box ends exactly at the gap.
|
||||
const style = getComputedStyle(element)
|
||||
const borders = (parseFloat(style.borderTopWidth) || 0) + (parseFloat(style.borderBottomWidth) || 0)
|
||||
const height = Math.max(minimum, Math.floor(scroller.clientHeight - top - Math.max(bottomGap, padding) - borders))
|
||||
element.style.setProperty('--fill-height', `${height}px`)
|
||||
}
|
||||
measure()
|
||||
const observer = new ResizeObserver(measure)
|
||||
observer.observe(scroller)
|
||||
// Content above the element (notices, toolbars, headers) moves its top: observe earlier siblings up the tree.
|
||||
// Parents are not observed, since the element's own height changes theirs.
|
||||
for (let node: HTMLElement | null = element; node && node !== scroller; node = node.parentElement) {
|
||||
for (let sibling = node.previousElementSibling; sibling; sibling = sibling.previousElementSibling) observer.observe(sibling)
|
||||
}
|
||||
window.addEventListener('resize', measure)
|
||||
return () => { observer.disconnect(); window.removeEventListener('resize', measure); element.style.removeProperty('--fill-height') }
|
||||
}, [ref, enabled, bottomGap, minimum])
|
||||
}
|
||||
@@ -824,6 +824,38 @@ app
|
||||
await settle()
|
||||
await writeFile(join(artifacts, `dense-bottom-${width}.png`), (await win.webContents.capturePage()).toPNG())
|
||||
}
|
||||
// The graph row fills the window down to a steady bottom margin; each column scrolls on its own.
|
||||
for (const [width, height] of [[1440, 900], [1440, 700], [1024, 800]]) {
|
||||
win.setContentSize(width, height)
|
||||
await settle()
|
||||
const fill = await js(`(() => {
|
||||
const shell = document.querySelector('.page-shell');
|
||||
shell.scrollTop = 0;
|
||||
const layout = document.querySelector('.supervisor-workspace__graph-layout');
|
||||
const shellBox = shell.getBoundingClientRect(), box = layout.getBoundingClientRect();
|
||||
const columns = [...layout.children].map(column => {
|
||||
const scroller = column.matches('.supervisor-workspace__graph-list') ? column.querySelector('[role=tabpanel]') : column;
|
||||
const r = column.getBoundingClientRect();
|
||||
return { name: column.className.split(' ')[0], top: r.top, bottom: r.bottom, scrolls: getComputedStyle(scroller).overflowY,
|
||||
clientHeight: scroller.clientHeight, scrollHeight: scroller.scrollHeight };
|
||||
});
|
||||
// Bottom of the first grid row (the side-by-side columns), measured from the window bottom.
|
||||
const rowBottom = Math.max(...columns.filter(column => Math.abs(column.top - columns[0].top) < 1).map(column => column.bottom));
|
||||
return { bottomGap: shellBox.bottom - rowBottom, wrapped: columns.length - columns.filter(column => Math.abs(column.top - columns[0].top) < 1).length,
|
||||
height: box.height, pageScroll: shell.scrollHeight - shell.clientHeight, columns };
|
||||
})()`)
|
||||
reports.push({ fill: { width, height, ...fill } })
|
||||
await writeFile(join(artifacts, `fill-${width}x${height}.json`), JSON.stringify({ width, height, bottomGap: Math.round(fill.bottomGap), wrapped: fill.wrapped, pageScroll: fill.pageScroll, rowHeight: Math.round(fill.columns[0].bottom - fill.columns[0].top) }))
|
||||
assert(fill.bottomGap >= 20 && fill.bottomGap <= 40, `Graph must stop above the window bottom: ${JSON.stringify(fill)}`)
|
||||
// Three columns fit the window with no page scroll; at two columns only the wrapped detail extends the page.
|
||||
if (!fill.wrapped) assert(fill.pageScroll <= 1, `Page must not scroll at three columns: ${JSON.stringify(fill)}`)
|
||||
const sideBySide = fill.columns.filter(column => Math.abs(column.top - fill.columns[0].top) < 1)
|
||||
for (const column of sideBySide) {
|
||||
assert(Math.abs(column.bottom - sideBySide[0].bottom) < 1, `Columns must share the row height: ${JSON.stringify(fill)}`)
|
||||
assert(['auto', 'scroll'].includes(column.scrolls), `Column must scroll on its own: ${JSON.stringify(column)}`)
|
||||
}
|
||||
await writeFile(join(artifacts, `fill-${width}x${height}.png`), (await win.webContents.capturePage()).toPNG())
|
||||
}
|
||||
win.setContentSize(1440, 1100)
|
||||
await js(
|
||||
'document.querySelectorAll(".supervisor-workspace__graph-list [role=tab]")[2].click()'
|
||||
|
||||
Reference in New Issue
Block a user