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 hasTimelineSegments = () => timelineSegments().length > 0
|
||||||
|
const [activeMessageId, setActiveMessageId] = createSignal<string | null>(null)
|
||||||
|
|
||||||
const changeToken = createMemo(() => String(sessionRevision()))
|
const changeToken = createMemo(() => String(sessionRevision()))
|
||||||
|
|
||||||
@@ -353,9 +354,44 @@ export default function MessageSection(props: MessageSectionProps) {
|
|||||||
observer.observe(bottomTarget)
|
observer.observe(bottomTarget)
|
||||||
onCleanup(() => observer.disconnect())
|
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(() => {
|
onCleanup(() => {
|
||||||
|
|
||||||
|
|
||||||
if (pendingScrollFrame !== null) {
|
if (pendingScrollFrame !== null) {
|
||||||
cancelAnimationFrame(pendingScrollFrame)
|
cancelAnimationFrame(pendingScrollFrame)
|
||||||
}
|
}
|
||||||
@@ -462,7 +498,11 @@ export default function MessageSection(props: MessageSectionProps) {
|
|||||||
|
|
||||||
<Show when={hasTimelineSegments()}>
|
<Show when={hasTimelineSegments()}>
|
||||||
<div class="message-timeline-sidebar">
|
<div class="message-timeline-sidebar">
|
||||||
<MessageTimeline segments={timelineSegments()} onSegmentClick={handleTimelineSegmentClick} />
|
<MessageTimeline
|
||||||
|
segments={timelineSegments()}
|
||||||
|
onSegmentClick={handleTimelineSegmentClick}
|
||||||
|
activeMessageId={activeMessageId()}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</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 { ClientPart } from "../types/message"
|
||||||
import type { MessageRecord } from "../stores/message-v2/types"
|
import type { MessageRecord } from "../stores/message-v2/types"
|
||||||
import { buildRecordDisplayData } from "../stores/message-v2/record-display-cache"
|
import { buildRecordDisplayData } from "../stores/message-v2/record-display-cache"
|
||||||
@@ -16,6 +16,7 @@ export interface TimelineSegment {
|
|||||||
interface MessageTimelineProps {
|
interface MessageTimelineProps {
|
||||||
segments: TimelineSegment[]
|
segments: TimelineSegment[]
|
||||||
onSegmentClick?: (segment: TimelineSegment) => void
|
onSegmentClick?: (segment: TimelineSegment) => void
|
||||||
|
activeMessageId?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
const SEGMENT_LABELS: Record<TimelineSegmentType, string> = {
|
const SEGMENT_LABELS: Record<TimelineSegmentType, string> = {
|
||||||
@@ -202,23 +203,57 @@ export function buildTimelineSegments(instanceId: string, record: MessageRecord)
|
|||||||
}
|
}
|
||||||
|
|
||||||
const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
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 (
|
return (
|
||||||
<div class="message-timeline" role="navigation" aria-label="Message timeline">
|
<div class="message-timeline" role="navigation" aria-label="Message timeline">
|
||||||
<For each={props.segments}>
|
<For each={props.segments}>
|
||||||
{(segment) => (
|
{(segment) => {
|
||||||
<button
|
onCleanup(() => buttonRefs.delete(segment.id))
|
||||||
type="button"
|
const isActive = () => props.activeMessageId === segment.messageId
|
||||||
class={`message-timeline-segment message-timeline-${segment.type}`}
|
return (
|
||||||
title={segment.tooltip}
|
<button
|
||||||
onClick={() => props.onSegmentClick?.(segment)}
|
ref={(el) => registerButtonRef(segment.id, el)}
|
||||||
>
|
type="button"
|
||||||
<span class="message-timeline-label message-timeline-label-full">{segment.label}</span>
|
class={`message-timeline-segment message-timeline-${segment.type} ${isActive() ? "message-timeline-segment-active" : ""}`}
|
||||||
<span class="message-timeline-label message-timeline-label-short">{SEGMENT_SHORT_LABELS[segment.type]}</span>
|
title={segment.tooltip}
|
||||||
</button>
|
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>
|
</For>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export default MessageTimeline
|
export default MessageTimeline
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
.message-layout {
|
.message-layout {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr);
|
grid-template-columns: minmax(0, 1fr);
|
||||||
gap: 0.5rem;
|
gap: 0rem;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
@@ -77,7 +77,15 @@
|
|||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.05em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
color: var(--text-primary);
|
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,
|
.message-timeline-segment:hover,
|
||||||
@@ -88,6 +96,15 @@
|
|||||||
transform: translateY(-1px);
|
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 {
|
.message-timeline-segment:focus-visible {
|
||||||
box-shadow: 0 0 0 2px var(--surface-base), 0 0 0 4px var(--accent-primary);
|
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);
|
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 {
|
.message-timeline-label {
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user