fix: snap operation details to view port when scrolling through a large operation tree

This commit is contained in:
Gareth
2026-03-10 22:38:09 -07:00
parent bebe608505
commit 7344541588
2 changed files with 144 additions and 12 deletions
+137
View File
@@ -0,0 +1,137 @@
import React, { useCallback, useEffect, useRef } from "react";
/**
* StickyPanel keeps its content visible within the nearest scrollable ancestor's
* visible bounds.
*
* - Content shorter than visible area: stays at the top of the visible area.
* - Content taller than visible area: scrolling down pins the bottom to the
* visible bottom; scrolling up pins the top to the visible top.
*
* Uses a constraint-based approach: on each frame, the current offset is nudged
* only when content has drifted out of the visible region, then clamped to stay
* within the container. No explicit scroll-direction tracking is needed.
*/
export const StickyPanel = ({ children }: { children: React.ReactNode }) => {
const containerRef = useRef<HTMLDivElement>(null);
const innerRef = useRef<HTMLDivElement>(null);
const currentOffset = useRef(0);
const rafId = useRef(0);
const findScrollParent = useCallback((el: HTMLElement) => {
let parent = el.parentElement;
while (parent) {
const style = getComputedStyle(parent);
if (
["auto", "scroll"].includes(style.overflowY) ||
["auto", "scroll"].includes(style.overflow)
) {
return parent;
}
parent = parent.parentElement;
}
return null;
}, []);
const update = useCallback(() => {
rafId.current = 0;
const container = containerRef.current;
const inner = innerRef.current;
if (!container || !inner) return;
const containerRect = container.getBoundingClientRect();
const innerHeight = inner.scrollHeight;
// Visible bounds from nearest scrollable ancestor, or the viewport.
const scrollParent = findScrollParent(container);
let visibleTop = 0;
let visibleBottom = window.innerHeight;
if (scrollParent) {
const rect = scrollParent.getBoundingClientRect();
visibleTop = rect.top;
visibleBottom = rect.bottom;
}
let offset = currentOffset.current;
// Constraint: if bottom of content is above visible bottom, push it down.
const innerBottom = containerRect.top + offset + innerHeight;
if (innerBottom < visibleBottom) {
offset += visibleBottom - innerBottom;
}
// Constraint: if top of content is below visible top, pull it up.
const innerTop = containerRect.top + offset;
if (innerTop > visibleTop) {
offset = visibleTop - containerRect.top;
}
// Clamp: keep content within the container.
const maxOffset = Math.max(0, containerRect.height - innerHeight);
offset = Math.max(0, Math.min(offset, maxOffset));
currentOffset.current = offset;
inner.style.transform = `translateY(${offset}px)`;
}, [findScrollParent]);
const scheduleUpdate = useCallback(() => {
if (!rafId.current) {
rafId.current = requestAnimationFrame(update);
}
}, [update]);
// Attach scroll/resize listeners.
useEffect(() => {
const container = containerRef.current;
if (!container) return;
// Collect all scrollable ancestors.
const scrollParents: (HTMLElement | Window)[] = [window];
let parent = container.parentElement;
while (parent) {
const style = getComputedStyle(parent);
if (
["auto", "scroll"].includes(style.overflowY) ||
["auto", "scroll"].includes(style.overflow)
) {
scrollParents.push(parent);
}
parent = parent.parentElement;
}
for (const sp of scrollParents) {
sp.addEventListener("scroll", scheduleUpdate, { passive: true });
}
window.addEventListener("resize", scheduleUpdate, { passive: true });
// Run once to set initial position.
update();
return () => {
if (rafId.current) cancelAnimationFrame(rafId.current);
for (const sp of scrollParents) {
sp.removeEventListener("scroll", scheduleUpdate);
}
window.removeEventListener("resize", scheduleUpdate);
};
}, [scheduleUpdate, update]);
// Reset offset and reposition when children change.
useEffect(() => {
currentOffset.current = 0;
if (innerRef.current) {
innerRef.current.style.transform = "translateY(0px)";
}
// Re-run positioning so content snaps into view immediately.
update();
}, [children, update]);
return (
<div
ref={containerRef}
style={{ width: "100%", height: "100%", position: "relative", overflow: "hidden" }}
>
<div ref={innerRef}>{children}</div>
</div>
);
};
@@ -72,6 +72,7 @@ import {
} from "../../api/flowDisplayAggregator";
import { OperationIcon } from "./OperationIcon";
import { shouldHideOperation } from "../../api/oplog";
import { StickyPanel } from "../../components/common/StickyPanel";
import { create, toJsonString } from "@bufbuild/protobuf";
import { useConfig } from "../../app/provider";
@@ -191,7 +192,9 @@ export const OperationTreeView = ({
operations={instanceBackups}
isPlanView={isPlanView}
onSelect={(flow) => {
setSelectedBackupId(flow ? flow.flowID : null);
if (flow) {
setSelectedBackupId(flow.flowID);
}
}}
/>
);
@@ -259,14 +262,14 @@ export const OperationTreeView = ({
</SplitterPanel>
<SplitterResizeTrigger id="tree:view" />
<SplitterPanel id="view">
<Box paddingLeft="2" height="100%" overflowY="auto">
<BackupViewContainer>
<Box paddingLeft="2" height="100%" overflow="hidden">
<StickyPanel>
{selectedBackupId ? (
<BackupView
backup={backups.find((b) => b.flowID === selectedBackupId)}
/>
) : null}
</BackupViewContainer>
</StickyPanel>
</Box>
</SplitterPanel>
</Splitter>
@@ -539,14 +542,6 @@ const DisplayOperationTree = ({
);
};
const BackupViewContainer = ({ children }: { children: React.ReactNode }) => {
return (
<Box position="sticky" top="0" width="100%">
{children}
</Box>
);
};
const BackupView = ({ backup }: { backup?: FlowDisplayInfo }) => {
if (!backup) {
return (