feat: sync timeline highlight with scroll
This commit is contained in:
@@ -118,6 +118,7 @@ export default function MessageSection(props: MessageSectionProps) {
|
||||
})
|
||||
|
||||
const hasTimelineSegments = () => timelineSegments().length > 0
|
||||
const [activeMessageId, setActiveMessageId] = createSignal<string | null>(null)
|
||||
|
||||
const changeToken = createMemo(() => String(sessionRevision()))
|
||||
|
||||
@@ -353,9 +354,44 @@ export default function MessageSection(props: MessageSectionProps) {
|
||||
observer.observe(bottomTarget)
|
||||
onCleanup(() => observer.disconnect())
|
||||
})
|
||||
|
||||
|
||||
createEffect(() => {
|
||||
const container = scrollElement()
|
||||
const ids = messageIds()
|
||||
if (!container || ids.length === 0) return
|
||||
if (typeof document === "undefined") return
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
let best: IntersectionObserverEntry | null = null
|
||||
for (const entry of entries) {
|
||||
if (!entry.isIntersecting) continue
|
||||
if (!best || entry.boundingClientRect.top < best.boundingClientRect.top) {
|
||||
best = entry
|
||||
}
|
||||
}
|
||||
if (best) {
|
||||
const anchorId = (best.target as HTMLElement).id
|
||||
const messageId = anchorId.startsWith("message-anchor-") ? anchorId.slice("message-anchor-".length) : anchorId
|
||||
setActiveMessageId((current) => (current === messageId ? current : messageId))
|
||||
}
|
||||
},
|
||||
{ root: container, rootMargin: "-10% 0px -80% 0px", threshold: 0 },
|
||||
)
|
||||
|
||||
ids.forEach((messageId) => {
|
||||
const anchor = document.getElementById(getMessageAnchorId(messageId))
|
||||
if (anchor) {
|
||||
observer.observe(anchor)
|
||||
}
|
||||
})
|
||||
|
||||
onCleanup(() => observer.disconnect())
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
|
||||
|
||||
if (pendingScrollFrame !== null) {
|
||||
cancelAnimationFrame(pendingScrollFrame)
|
||||
}
|
||||
@@ -462,7 +498,11 @@ export default function MessageSection(props: MessageSectionProps) {
|
||||
|
||||
<Show when={hasTimelineSegments()}>
|
||||
<div class="message-timeline-sidebar">
|
||||
<MessageTimeline segments={timelineSegments()} onSegmentClick={handleTimelineSegmentClick} />
|
||||
<MessageTimeline
|
||||
segments={timelineSegments()}
|
||||
onSegmentClick={handleTimelineSegmentClick}
|
||||
activeMessageId={activeMessageId()}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { For, type Component } from "solid-js"
|
||||
import { For, createEffect, onCleanup, type Component } from "solid-js"
|
||||
import type { ClientPart } from "../types/message"
|
||||
import type { MessageRecord } from "../stores/message-v2/types"
|
||||
import { buildRecordDisplayData } from "../stores/message-v2/record-display-cache"
|
||||
@@ -16,6 +16,7 @@ export interface TimelineSegment {
|
||||
interface MessageTimelineProps {
|
||||
segments: TimelineSegment[]
|
||||
onSegmentClick?: (segment: TimelineSegment) => void
|
||||
activeMessageId?: string | null
|
||||
}
|
||||
|
||||
const SEGMENT_LABELS: Record<TimelineSegmentType, string> = {
|
||||
@@ -202,23 +203,57 @@ export function buildTimelineSegments(instanceId: string, record: MessageRecord)
|
||||
}
|
||||
|
||||
const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
||||
const buttonRefs = new Map<string, HTMLButtonElement>()
|
||||
|
||||
const registerButtonRef = (segmentId: string, element: HTMLButtonElement | null) => {
|
||||
if (element) {
|
||||
buttonRefs.set(segmentId, element)
|
||||
} else {
|
||||
buttonRefs.delete(segmentId)
|
||||
}
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
const activeId = props.activeMessageId
|
||||
if (!activeId) return
|
||||
const targetSegment = props.segments.find((segment) => segment.messageId === activeId)
|
||||
if (!targetSegment) return
|
||||
const element = buttonRefs.get(targetSegment.id)
|
||||
if (!element) return
|
||||
const timer = typeof window !== "undefined" ? window.setTimeout(() => {
|
||||
element.scrollIntoView({ block: "nearest", behavior: "smooth" })
|
||||
}, 120) : null
|
||||
onCleanup(() => {
|
||||
if (timer !== null && typeof window !== "undefined") {
|
||||
window.clearTimeout(timer)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
return (
|
||||
<div class="message-timeline" role="navigation" aria-label="Message timeline">
|
||||
<For each={props.segments}>
|
||||
{(segment) => (
|
||||
<button
|
||||
type="button"
|
||||
class={`message-timeline-segment message-timeline-${segment.type}`}
|
||||
title={segment.tooltip}
|
||||
onClick={() => props.onSegmentClick?.(segment)}
|
||||
>
|
||||
<span class="message-timeline-label message-timeline-label-full">{segment.label}</span>
|
||||
<span class="message-timeline-label message-timeline-label-short">{SEGMENT_SHORT_LABELS[segment.type]}</span>
|
||||
</button>
|
||||
)}
|
||||
{(segment) => {
|
||||
onCleanup(() => buttonRefs.delete(segment.id))
|
||||
const isActive = () => props.activeMessageId === segment.messageId
|
||||
return (
|
||||
<button
|
||||
ref={(el) => registerButtonRef(segment.id, el)}
|
||||
type="button"
|
||||
class={`message-timeline-segment message-timeline-${segment.type} ${isActive() ? "message-timeline-segment-active" : ""}`}
|
||||
title={segment.tooltip}
|
||||
aria-current={isActive() ? "true" : undefined}
|
||||
onClick={() => props.onSegmentClick?.(segment)}
|
||||
>
|
||||
<span class="message-timeline-label message-timeline-label-full">{segment.label}</span>
|
||||
<span class="message-timeline-label message-timeline-label-short">{SEGMENT_SHORT_LABELS[segment.type]}</span>
|
||||
</button>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
export default MessageTimeline
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
.message-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 0.5rem;
|
||||
gap: 0rem;
|
||||
width: 100%;
|
||||
min-height: 0;
|
||||
flex: 1 1 auto;
|
||||
@@ -77,7 +77,15 @@
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-primary);
|
||||
transition: transform 0.15s ease, background-color 0.2s ease, color 0.2s ease;
|
||||
transition: transform 0.15s ease, background-color 0.2s ease, color 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.message-timeline-segment-active {
|
||||
border-color: transparent;
|
||||
background-color: #0f5b44;
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.message-timeline-segment:hover,
|
||||
@@ -88,6 +96,15 @@
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.message-timeline-segment-active,
|
||||
.message-timeline-segment-active:hover,
|
||||
.message-timeline-segment-active:focus-visible {
|
||||
background-color: #0f5b44;
|
||||
color: #fff;
|
||||
transform: none;
|
||||
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.message-timeline-segment:focus-visible {
|
||||
box-shadow: 0 0 0 2px var(--surface-base), 0 0 0 4px var(--accent-primary);
|
||||
}
|
||||
@@ -107,6 +124,14 @@
|
||||
background-color: var(--surface-secondary);
|
||||
}
|
||||
|
||||
.message-timeline-segment-active {
|
||||
background-color: #0f5b44 !important;
|
||||
border-color: transparent !important;
|
||||
color: #fff !important;
|
||||
font-weight: 700;
|
||||
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.message-timeline-label {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user