mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-30 07:05:24 +00:00
fix: smooth host editor jumps to lower sections
This commit is contained in:
1 parent
071711dafc
commit
dd5573cc10
3 files changed
+147
-7
No files matched your search
@@ -1,5 +1,11 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 26.10.2
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Picking a section lower down in the host editor, like Warpgate, stuttered on the way and sometimes stopped before it got there
|
||||||
|
|
||||||
## 26.10.1
|
## 26.10.1
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -119,17 +119,45 @@ export function useScrollSpy(
|
|||||||
let lastHeight = area.scrollHeight;
|
let lastHeight = area.scrollHeight;
|
||||||
area.scrollTo?.({ top: lastTop, behavior: "smooth" });
|
area.scrollTo?.({ top: lastTop, behavior: "smooth" });
|
||||||
// Sections above can still be mounting, which moves the target or
|
// Sections above can still be mounting, which moves the target or
|
||||||
// cuts the scroll short. Follow it until the layout settles.
|
// cuts the scroll short. Follow it until the scroll lands and the
|
||||||
|
// layout stops changing, unless the user scrolls first.
|
||||||
const started = Date.now();
|
const started = Date.now();
|
||||||
|
let changedAt = started;
|
||||||
|
let userScrolled = false;
|
||||||
|
const onUser = () => {
|
||||||
|
userScrolled = true;
|
||||||
|
};
|
||||||
|
area.addEventListener("wheel", onUser, { passive: true });
|
||||||
|
area.addEventListener("touchstart", onUser, { passive: true });
|
||||||
|
const done = () => {
|
||||||
|
area.removeEventListener("wheel", onUser);
|
||||||
|
area.removeEventListener("touchstart", onUser);
|
||||||
|
};
|
||||||
const follow = () => {
|
const follow = () => {
|
||||||
if (!el.isConnected || Date.now() - started > 1500) return;
|
const now = Date.now();
|
||||||
|
if (userScrolled || !el.isConnected || now - started > 5000) {
|
||||||
|
return done();
|
||||||
|
}
|
||||||
const top = target();
|
const top = target();
|
||||||
|
// Keep the spy quiet for the whole trip, not just the first scroll.
|
||||||
|
lockUntil.current = now + 600;
|
||||||
if (Math.abs(top - lastTop) > 4 || area.scrollHeight !== lastHeight) {
|
if (Math.abs(top - lastTop) > 4 || area.scrollHeight !== lastHeight) {
|
||||||
lastTop = top;
|
lastTop = top;
|
||||||
lastHeight = area.scrollHeight;
|
lastHeight = area.scrollHeight;
|
||||||
lockUntil.current = Date.now() + 600;
|
changedAt = now;
|
||||||
area.scrollTo?.({ top, behavior: "smooth" });
|
area.scrollTo?.({ top, behavior: "smooth" });
|
||||||
}
|
}
|
||||||
|
const reachable = Math.min(
|
||||||
|
lastTop,
|
||||||
|
area.scrollHeight - area.clientHeight,
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
Math.abs(area.scrollTop - reachable) <= 2 &&
|
||||||
|
now - changedAt > 400
|
||||||
|
) {
|
||||||
|
setActive(id);
|
||||||
|
return done();
|
||||||
|
}
|
||||||
requestAnimationFrame(follow);
|
requestAnimationFrame(follow);
|
||||||
};
|
};
|
||||||
requestAnimationFrame(follow);
|
requestAnimationFrame(follow);
|
||||||
@@ -137,6 +165,24 @@ export function useScrollSpy(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Resolves once the element's height holds still for a few frames. */
|
||||||
|
function untilLayoutSettles(el: HTMLElement | null, max = 800): Promise<void> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
if (!el || typeof requestAnimationFrame === "undefined") return resolve();
|
||||||
|
const started = Date.now();
|
||||||
|
let last = -1;
|
||||||
|
let still = 0;
|
||||||
|
const check = () => {
|
||||||
|
const height = el.scrollHeight;
|
||||||
|
still = height === last ? still + 1 : 0;
|
||||||
|
last = height;
|
||||||
|
if (still >= 3 || Date.now() - started > max) return resolve();
|
||||||
|
requestAnimationFrame(check);
|
||||||
|
};
|
||||||
|
requestAnimationFrame(check);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/** Wraps a section so the nav can find it. */
|
/** Wraps a section so the nav can find it. */
|
||||||
export function EditorSection({
|
export function EditorSection({
|
||||||
id,
|
id,
|
||||||
@@ -253,7 +299,15 @@ export function EditorPane({
|
|||||||
const ids = visibleBands.flatMap((band) => band.items.map((item) => item.id));
|
const ids = visibleBands.flatMap((band) => band.items.map((item) => item.id));
|
||||||
const { active, scrollTo } = useScrollSpy(scrollRef, ids);
|
const { active, scrollTo } = useScrollSpy(scrollRef, ids);
|
||||||
const showNav = bands.flatMap((band) => band.items).length > 1;
|
const showNav = bands.flatMap((band) => band.items).length > 1;
|
||||||
if (revealRef) revealRef.current = scrollTo;
|
// A jump mounts every lazy section first and scrolls once the form stops
|
||||||
|
// growing. Mounting them on the way down made the scroll stall and restart.
|
||||||
|
const [mountAll, setMountAll] = useState(false);
|
||||||
|
const jump = (id: string) => {
|
||||||
|
if (mountAll) return scrollTo(id);
|
||||||
|
setMountAll(true);
|
||||||
|
void untilLayoutSettles(scrollRef.current).then(() => scrollTo(id));
|
||||||
|
};
|
||||||
|
if (revealRef) revealRef.current = jump;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("flex min-h-0 flex-1", className)}>
|
<div className={cn("flex min-h-0 flex-1", className)}>
|
||||||
@@ -272,7 +326,7 @@ export function EditorPane({
|
|||||||
key={item.id}
|
key={item.id}
|
||||||
type="button"
|
type="button"
|
||||||
aria-current={on ? "true" : undefined}
|
aria-current={on ? "true" : undefined}
|
||||||
onClick={() => scrollTo(item.id)}
|
onClick={() => jump(item.id)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex w-full items-center gap-2 border-l-2 py-1.5 pl-2 pr-2 text-left transition-colors focus-visible:relative focus-visible:z-10 focus-visible:ring-1 focus-visible:ring-ring",
|
"flex w-full items-center gap-2 border-l-2 py-1.5 pl-2 pr-2 text-left transition-colors focus-visible:relative focus-visible:z-10 focus-visible:ring-1 focus-visible:ring-ring",
|
||||||
on
|
on
|
||||||
@@ -320,7 +374,7 @@ export function EditorPane({
|
|||||||
band.items.map((item) => ({ id: item.id, label: item.label })),
|
band.items.map((item) => ({ id: item.id, label: item.label })),
|
||||||
)}
|
)}
|
||||||
activeTab={active}
|
activeTab={active}
|
||||||
onTabChange={scrollTo}
|
onTabChange={jump}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -336,7 +390,7 @@ export function EditorPane({
|
|||||||
className={`mx-auto flex max-w-3xl flex-col ${PANEL.gap} pb-6`}
|
className={`mx-auto flex max-w-3xl flex-col ${PANEL.gap} pb-6`}
|
||||||
>
|
>
|
||||||
{banner}
|
{banner}
|
||||||
<SearchingContext.Provider value={searching}>
|
<SearchingContext.Provider value={searching || mountAll}>
|
||||||
{children}
|
{children}
|
||||||
</SearchingContext.Provider>
|
</SearchingContext.Provider>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -92,6 +92,25 @@ describe("EditorPane search", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("EditorPane jump", () => {
|
||||||
|
it("mounts lazy sections before scrolling to one", async () => {
|
||||||
|
// Never intersecting, so lazy sections only mount when asked to.
|
||||||
|
vi.stubGlobal(
|
||||||
|
"IntersectionObserver",
|
||||||
|
class {
|
||||||
|
observe() {}
|
||||||
|
disconnect() {}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
renderEditor();
|
||||||
|
expect(screen.queryByText("Font size")).toBeNull();
|
||||||
|
// The side nav, not the narrow-screen tab strip.
|
||||||
|
fireEvent.click(screen.getAllByRole("button", { name: "Terminal" })[0]);
|
||||||
|
await waitFor(() => expect(screen.getByText("Font size")).toBeTruthy());
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("useScrollSpy scrollTo", () => {
|
describe("useScrollSpy scrollTo", () => {
|
||||||
it("follows a section that moves while the form is still mounting", async () => {
|
it("follows a section that moves while the form is still mounting", async () => {
|
||||||
const container = document.createElement("div");
|
const container = document.createElement("div");
|
||||||
@@ -116,4 +135,65 @@ describe("useScrollSpy scrollTo", () => {
|
|||||||
await waitFor(() => expect(calls).toContain(892));
|
await waitFor(() => expect(calls).toContain(892));
|
||||||
container.remove();
|
container.remove();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps following past 1.5s while lazy sections still mount", async () => {
|
||||||
|
vi.useFakeTimers({ toFake: ["Date", "requestAnimationFrame"] });
|
||||||
|
const container = document.createElement("div");
|
||||||
|
const section = document.createElement("div");
|
||||||
|
section.dataset.section = "warpgate";
|
||||||
|
container.appendChild(section);
|
||||||
|
document.body.appendChild(container);
|
||||||
|
let sectionTop = 300;
|
||||||
|
section.getBoundingClientRect = () => ({ top: sectionTop }) as DOMRect;
|
||||||
|
container.getBoundingClientRect = () => ({ top: 0 }) as DOMRect;
|
||||||
|
// Still scrolling toward the target, so the follow stays on.
|
||||||
|
Object.defineProperty(container, "scrollHeight", { value: 5000 });
|
||||||
|
const calls: number[] = [];
|
||||||
|
container.scrollTo = ((options: ScrollToOptions) => {
|
||||||
|
calls.push(options.top ?? 0);
|
||||||
|
}) as typeof container.scrollTo;
|
||||||
|
|
||||||
|
const ref = { current: container };
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useScrollSpy(ref, ["general", "warpgate"]),
|
||||||
|
);
|
||||||
|
act(() => result.current.scrollTo("warpgate"));
|
||||||
|
act(() => vi.advanceTimersByTime(2000));
|
||||||
|
sectionTop = 1400;
|
||||||
|
act(() => vi.advanceTimersByTime(100));
|
||||||
|
expect(calls).toContain(1392);
|
||||||
|
|
||||||
|
vi.useRealTimers();
|
||||||
|
container.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stops following once the user scrolls", () => {
|
||||||
|
vi.useFakeTimers({ toFake: ["Date", "requestAnimationFrame"] });
|
||||||
|
const container = document.createElement("div");
|
||||||
|
const section = document.createElement("div");
|
||||||
|
section.dataset.section = "warpgate";
|
||||||
|
container.appendChild(section);
|
||||||
|
document.body.appendChild(container);
|
||||||
|
let sectionTop = 300;
|
||||||
|
section.getBoundingClientRect = () => ({ top: sectionTop }) as DOMRect;
|
||||||
|
container.getBoundingClientRect = () => ({ top: 0 }) as DOMRect;
|
||||||
|
Object.defineProperty(container, "scrollHeight", { value: 5000 });
|
||||||
|
const calls: number[] = [];
|
||||||
|
container.scrollTo = ((options: ScrollToOptions) => {
|
||||||
|
calls.push(options.top ?? 0);
|
||||||
|
}) as typeof container.scrollTo;
|
||||||
|
|
||||||
|
const ref = { current: container };
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useScrollSpy(ref, ["general", "warpgate"]),
|
||||||
|
);
|
||||||
|
act(() => result.current.scrollTo("warpgate"));
|
||||||
|
fireEvent.wheel(container);
|
||||||
|
sectionTop = 900;
|
||||||
|
act(() => vi.advanceTimersByTime(200));
|
||||||
|
expect(calls).toEqual([292]);
|
||||||
|
|
||||||
|
vi.useRealTimers();
|
||||||
|
container.remove();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
Reference in new issue
Block a user