fix(ui): sync xray overlay with timeline scroll
This commit is contained in:
@@ -1,5 +1,4 @@
|
|||||||
import { For, Show, createEffect, createMemo, createSignal, onCleanup, on, untrack, type Component, type Accessor } from "solid-js"
|
import { For, Show, createEffect, createMemo, createSignal, onCleanup, on, untrack, type Component, type Accessor } from "solid-js"
|
||||||
import { Portal } from "solid-js/web"
|
|
||||||
import MessagePreview from "./message-preview"
|
import MessagePreview from "./message-preview"
|
||||||
import { messageStoreBus } from "../stores/message-v2/bus"
|
import { messageStoreBus } from "../stores/message-v2/bus"
|
||||||
import type { ClientPart } from "../types/message"
|
import type { ClientPart } from "../types/message"
|
||||||
@@ -416,12 +415,9 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
|||||||
// Stable layout offsets per badge (relative to scroll content), recomputed only
|
// Stable layout offsets per badge (relative to scroll content), recomputed only
|
||||||
// on activation, resize, or expansion — NOT on every scroll frame.
|
// on activation, resize, or expansion — NOT on every scroll frame.
|
||||||
const [badgeOffsets, setBadgeOffsets] = createSignal<Record<string, { layoutTop: number; height: number }>>({})
|
const [badgeOffsets, setBadgeOffsets] = createSignal<Record<string, { layoutTop: number; height: number }>>({})
|
||||||
// Lightweight scroll state: 1 getBoundingClientRect on container per frame.
|
|
||||||
const [containerScroll, setContainerScroll] = createSignal({ containerTop: 0, scrollTop: 0, left: 0 })
|
|
||||||
const [windowWidth, setWindowWidth] = createSignal(typeof window !== "undefined" ? window.innerWidth : 1200)
|
const [windowWidth, setWindowWidth] = createSignal(typeof window !== "undefined" ? window.innerWidth : 1200)
|
||||||
const [clipBounds, setClipBounds] = createSignal<{ top: number; bottom: number }>({ top: 0, bottom: typeof window !== "undefined" ? window.innerHeight : 800 })
|
|
||||||
let scrollContainerRef: HTMLDivElement | undefined
|
let scrollContainerRef: HTMLDivElement | undefined
|
||||||
let scrollRafId: number | null = null
|
let xrayOverlayRef: HTMLDivElement | undefined
|
||||||
|
|
||||||
// Full layout recomputation: reads every badge's getBoundingClientRect once,
|
// Full layout recomputation: reads every badge's getBoundingClientRect once,
|
||||||
// then stores offsets relative to the scroll content so they survive scrolling.
|
// then stores offsets relative to the scroll content so they survive scrolling.
|
||||||
@@ -441,37 +437,19 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
setBadgeOffsets(offsets)
|
setBadgeOffsets(offsets)
|
||||||
setContainerScroll({ containerTop: containerRect.top, scrollTop, left: containerRect.left })
|
if (xrayOverlayRef) {
|
||||||
|
xrayOverlayRef.style.setProperty("--xray-scroll-y", `${-scrollTop}px`)
|
||||||
|
}
|
||||||
|
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
setWindowWidth(window.innerWidth)
|
setWindowWidth(window.innerWidth)
|
||||||
const layout = scrollContainerRef.closest(".message-layout")
|
|
||||||
if (layout) {
|
|
||||||
const layoutRect = layout.getBoundingClientRect()
|
|
||||||
// Shrink clip bottom when the delete toolbar is visible so bars
|
|
||||||
// disappear behind it instead of overlapping.
|
|
||||||
const toolbar = layout.querySelector(".message-delete-mode-toolbar")
|
|
||||||
const toolbarInset = toolbar ? toolbar.getBoundingClientRect().height + 16 : 0
|
|
||||||
setClipBounds({ top: layoutRect.top, bottom: layoutRect.bottom - toolbarInset })
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// RAF-throttled scroll handler: only 1 container getBoundingClientRect per frame
|
const handleScroll = () => {
|
||||||
// instead of N badge getBoundingClientRect calls.
|
|
||||||
const handleScrollRaf = () => {
|
|
||||||
if (!isSelectionActive()) return
|
if (!isSelectionActive()) return
|
||||||
if (scrollRafId !== null) return
|
if (!scrollContainerRef || !xrayOverlayRef) return
|
||||||
scrollRafId = requestAnimationFrame(() => {
|
xrayOverlayRef.style.setProperty("--xray-scroll-y", `${-scrollContainerRef.scrollTop}px`)
|
||||||
scrollRafId = null
|
|
||||||
if (!scrollContainerRef) return
|
|
||||||
const containerRect = scrollContainerRef.getBoundingClientRect()
|
|
||||||
setContainerScroll({
|
|
||||||
containerTop: containerRect.top,
|
|
||||||
scrollTop: scrollContainerRef.scrollTop,
|
|
||||||
left: containerRect.left,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
@@ -484,10 +462,6 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
|||||||
window.addEventListener("resize", computeBadgeLayout)
|
window.addEventListener("resize", computeBadgeLayout)
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
window.removeEventListener("resize", computeBadgeLayout)
|
window.removeEventListener("resize", computeBadgeLayout)
|
||||||
if (scrollRafId !== null) {
|
|
||||||
cancelAnimationFrame(scrollRafId)
|
|
||||||
scrollRafId = null
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -733,7 +707,7 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
|||||||
class={`message-timeline${isSelectionActive() ? " message-timeline--selection-active" : ""}`}
|
class={`message-timeline${isSelectionActive() ? " message-timeline--selection-active" : ""}`}
|
||||||
role="navigation"
|
role="navigation"
|
||||||
aria-label={t("messageTimeline.ariaLabel")}
|
aria-label={t("messageTimeline.ariaLabel")}
|
||||||
onScroll={handleScrollRaf}
|
onScroll={handleScroll}
|
||||||
>
|
>
|
||||||
<For each={props.segments}>
|
<For each={props.segments}>
|
||||||
{(segment, segIndex) => {
|
{(segment, segIndex) => {
|
||||||
@@ -895,57 +869,60 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
|||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Portal>
|
<Show when={isSelectionActive()}>
|
||||||
<Show when={isSelectionActive()}>
|
<div
|
||||||
<div class="message-timeline-xray-overlay" style={{ "--max-rib-width": `${maxRibWidth()}px`, "clip-path": `inset(${clipBounds().top}px 0 ${(typeof window !== "undefined" ? window.innerHeight : 0) - clipBounds().bottom}px 0)` }}>
|
ref={(el) => {
|
||||||
<For each={xraySegments()}>
|
xrayOverlayRef = el
|
||||||
{(segment) => {
|
if (xrayOverlayRef && scrollContainerRef) {
|
||||||
// Derive screen position from stable layout offset + scroll state.
|
xrayOverlayRef.style.setProperty("--xray-scroll-y", `${-scrollContainerRef.scrollTop}px`)
|
||||||
// Only arithmetic — no DOM reads per segment per scroll frame.
|
}
|
||||||
const pos = () => {
|
}}
|
||||||
const offset = badgeOffsets()[segment.id]
|
class="message-timeline-xray-overlay"
|
||||||
if (!offset) return null
|
style={{ "--max-rib-width": `${maxRibWidth()}px` }}
|
||||||
const scroll = containerScroll()
|
>
|
||||||
const top = scroll.containerTop + offset.layoutTop - scroll.scrollTop + offset.height / 2
|
<div class="message-timeline-xray-overlay-inner">
|
||||||
const bounds = clipBounds()
|
<For each={xraySegments()}>
|
||||||
if (top < bounds.top - 20 || top > bounds.bottom + 20) return null
|
{(segment) => {
|
||||||
return { top, left: scroll.left }
|
const pos = () => {
|
||||||
}
|
const offset = badgeOffsets()[segment.id]
|
||||||
const tokens = () => getSegmentTokens(segment)
|
if (!offset) return null
|
||||||
const relativeWeight = () => tokens() / maxTokens()
|
return { top: offset.layoutTop + offset.height / 2 }
|
||||||
const absoluteWeight = () => Math.min(tokens() / ABSOLUTE_TOKEN_CAP, 1.0)
|
}
|
||||||
const isOverflow = () => tokens() > ABSOLUTE_TOKEN_CAP
|
const tokens = () => getSegmentTokens(segment)
|
||||||
const isParent = segment.type === "assistant" || segment.type === "user"
|
const relativeWeight = () => tokens() / maxTokens()
|
||||||
const displayTokens = () =>
|
const absoluteWeight = () => Math.min(tokens() / ABSOLUTE_TOKEN_CAP, 1.0)
|
||||||
isParent ? getMessageAggregateTokens(segment.messageId) : tokens()
|
const isOverflow = () => tokens() > ABSOLUTE_TOKEN_CAP
|
||||||
return (
|
const isParent = segment.type === "assistant" || segment.type === "user"
|
||||||
<Show when={pos()}>
|
const displayTokens = () =>
|
||||||
|
isParent ? getMessageAggregateTokens(segment.messageId) : tokens()
|
||||||
|
return (
|
||||||
|
<Show when={pos()}>
|
||||||
|
<div
|
||||||
|
class="message-timeline-xray-rib"
|
||||||
|
style={{
|
||||||
|
top: `${pos()!.top}px`,
|
||||||
|
left: "var(--xray-overhang)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span class="message-timeline-xray-token-label">
|
||||||
|
{formatTokenLabel(displayTokens())}
|
||||||
|
</span>
|
||||||
<div
|
<div
|
||||||
class="message-timeline-xray-rib"
|
class="message-timeline-relative-bar"
|
||||||
style={{
|
style={{ "--segment-weight": relativeWeight() }}
|
||||||
top: `${pos()!.top}px`,
|
/>
|
||||||
left: `${pos()!.left}px`,
|
<div
|
||||||
}}
|
class={`message-timeline-absolute-bar${isOverflow() ? " message-timeline-absolute-bar-overflow" : ""}`}
|
||||||
>
|
style={{ "--segment-weight": absoluteWeight() }}
|
||||||
<span class="message-timeline-xray-token-label">
|
/>
|
||||||
{formatTokenLabel(displayTokens())}
|
</div>
|
||||||
</span>
|
</Show>
|
||||||
<div
|
)
|
||||||
class="message-timeline-relative-bar"
|
}}
|
||||||
style={{ "--segment-weight": relativeWeight() }}
|
</For>
|
||||||
/>
|
|
||||||
<div
|
|
||||||
class={`message-timeline-absolute-bar${isOverflow() ? " message-timeline-absolute-bar-overflow" : ""}`}
|
|
||||||
style={{ "--segment-weight": absoluteWeight() }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</For>
|
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</div>
|
||||||
</Portal>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -325,18 +325,30 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.message-timeline-xray-overlay {
|
.message-timeline-xray-overlay {
|
||||||
position: fixed;
|
position: absolute;
|
||||||
top: 0;
|
inset: 0;
|
||||||
left: 0;
|
/* Extend the overlay box into the stream so ribs are not relying on
|
||||||
width: 100vw;
|
overflow-visible behavior (which is brittle around scroll containers). */
|
||||||
height: 100vh;
|
--xray-overhang: calc(var(--max-rib-width, 50vw) + 84px);
|
||||||
|
left: calc(-1 * var(--xray-overhang));
|
||||||
|
width: calc(100% + var(--xray-overhang));
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 0.25rem;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
/* Below Command Palette (z-50) but above normal content. */
|
/* Above the scroll container background; still non-interactive. */
|
||||||
z-index: 40;
|
z-index: 2;
|
||||||
|
--xray-scroll-y: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-timeline-xray-overlay-inner {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
transform: translateY(var(--xray-scroll-y));
|
||||||
|
will-change: transform;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-timeline-xray-rib {
|
.message-timeline-xray-rib {
|
||||||
position: fixed;
|
position: absolute;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: flex-end;
|
align-items: flex-end;
|
||||||
|
|||||||
Reference in New Issue
Block a user