feat: message nav animation
parent
b6b3867325
commit
0296ab2cee
|
|
@ -1,118 +1,202 @@
|
|||
@property --reveal-ready {
|
||||
syntax: "<number>";
|
||||
inherits: true;
|
||||
initial-value: 0;
|
||||
}
|
||||
|
||||
[data-component="message-nav"] {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
padding-left: 0;
|
||||
list-style: none;
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
height: 100%;
|
||||
max-height: calc(100vh - 6rem);
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding: 8px 0 0 8px;
|
||||
}
|
||||
|
||||
&[data-size="normal"] {
|
||||
width: 240px;
|
||||
gap: 4px;
|
||||
[data-slot="message-nav-list"] {
|
||||
--is-expanded: 0;
|
||||
--reveal-ready: 0;
|
||||
--message-nav-item-height: 11px;
|
||||
--message-nav-item-width: 24px;
|
||||
--message-nav-expanded-width: 240px;
|
||||
--message-nav-line-width: 16px;
|
||||
|
||||
list-style: none;
|
||||
padding: 4px;
|
||||
width: var(--message-nav-item-width);
|
||||
box-sizing: border-box;
|
||||
display: grid;
|
||||
grid-gap: 4px;
|
||||
grid-template-rows: repeat(var(--message-nav-items), var(--message-nav-item-height));
|
||||
border-radius: var(--radius-md);
|
||||
transition-property: background-color, box-shadow, width, grid-template-rows, padding, --reveal-ready;
|
||||
transition-duration: 300ms, 300ms, 300ms, 300ms, 300ms, 1s;
|
||||
transition-delay: 0ms, 0ms, 0ms, 0ms, 0ms, 280ms;
|
||||
transition-timing-function: cubic-bezier(0.32, 0, 0.15, 1);
|
||||
|
||||
&:hover {
|
||||
--is-expanded: 1;
|
||||
--reveal-ready: 1;
|
||||
--message-nav-item-height: 25px;
|
||||
--message-nav-item-width: var(--message-nav-expanded-width);
|
||||
|
||||
background-color: var(--surface-raised-stronger-non-alpha);
|
||||
box-shadow:
|
||||
0 0 0 1px var(--border-weak-base, rgba(17, 0, 0, 0.12)),
|
||||
0 1px 2px -1px rgba(19, 16, 16, 0.04),
|
||||
0 1px 2px 0 rgba(19, 16, 16, 0.06),
|
||||
0 1px 3px 0 rgba(19, 16, 16, 0.08);
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="message-nav-item"] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-self: stretch;
|
||||
justify-content: flex-end;
|
||||
|
||||
[data-component="message-nav"][data-size="normal"] & {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
[data-slot="message-nav-tick-button"] {
|
||||
[data-slot="message-nav-item-button"] {
|
||||
--line-width: var(--message-nav-line-width);
|
||||
--line-expanded-width: calc(var(--message-nav-expanded-width) - 16px);
|
||||
|
||||
appearance: none;
|
||||
border: none;
|
||||
background: none;
|
||||
padding: 0 4px;
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
font-family: inherit;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
height: 12px;
|
||||
width: 24px;
|
||||
border: none;
|
||||
background: none;
|
||||
padding: 0;
|
||||
gap: 8px;
|
||||
box-sizing: border-box;
|
||||
width: calc(var(--message-nav-item-width) - 8px);
|
||||
height: 100%;
|
||||
border-radius: var(--radius-xs);
|
||||
transition: background-color 0.15s ease;
|
||||
|
||||
&[data-active] [data-slot="message-nav-tick-line"] {
|
||||
background-color: var(--icon-strong-base);
|
||||
&::before {
|
||||
content: "";
|
||||
display: block;
|
||||
flex-shrink: 0;
|
||||
/* Width expands first, then scaleX shrinks it to the right */
|
||||
width: 100%;
|
||||
height: 3px;
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
left: 4px;
|
||||
top: 50%;
|
||||
/* scaleX shrinks from 1 to 0, collapsing toward the right (revealing left side) */
|
||||
transform: scaleX(.66) translateY(-50%) scaleX(calc(1 - var(--reveal-ready)));
|
||||
transform-origin: 100% 50%; /* Anchor on right, line collapses to the right */
|
||||
background-color: var(--icon-weak-base);
|
||||
transition-property: width, background-color, transform;
|
||||
transition-duration: 300ms, 300ms, 400ms;
|
||||
transition-timing-function: cubic-bezier(0.32, 0, 0.15, 1);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: var(--surface-base-hover);
|
||||
}
|
||||
|
||||
&[data-active="true"] {
|
||||
--line-width: 24px;
|
||||
|
||||
&::before {
|
||||
background-color: var(--icon-strong-base);
|
||||
}
|
||||
|
||||
[data-slot="message-nav-item-title-inner"] {
|
||||
color: var(--text-strong);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="message-nav-tick-line"] {
|
||||
height: 1px;
|
||||
width: 16px;
|
||||
background-color: var(--icon-base);
|
||||
transition:
|
||||
width 0.2s,
|
||||
background-color 0.2s;
|
||||
}
|
||||
|
||||
[data-slot="message-nav-tick-button"]:hover [data-slot="message-nav-tick-line"] {
|
||||
width: 100%;
|
||||
background-color: var(--icon-strong-base);
|
||||
}
|
||||
|
||||
[data-slot="message-nav-message-button"] {
|
||||
[data-slot="message-nav-item-title"] {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-self: stretch;
|
||||
width: 100%;
|
||||
column-gap: 12px;
|
||||
cursor: default;
|
||||
border: none;
|
||||
background: none;
|
||||
padding: 4px 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
[data-slot="message-nav-title-preview"] {
|
||||
font-size: 14px; /* text-14-regular */
|
||||
color: var(--text-weak);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
min-width: 0;
|
||||
text-align: left;
|
||||
flex: 1;
|
||||
opacity: var(--reveal-ready);
|
||||
transition: opacity 200ms cubic-bezier(0.32, 0, 0.15, 1);
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
|
||||
&[data-active] {
|
||||
color: var(--text-strong);
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="message-nav-item"]:hover [data-slot="message-nav-message-button"] {
|
||||
background-color: var(--surface-base);
|
||||
}
|
||||
[data-slot="message-nav-item"]:active [data-slot="message-nav-message-button"] {
|
||||
background-color: var(--surface-base-active);
|
||||
}
|
||||
[data-slot="message-nav-item-title-inner"] {
|
||||
--factor: 5;
|
||||
--spacing: 4px;
|
||||
--avg-width: 12px;
|
||||
--total-width: 0px;
|
||||
--virtual-width: var(--total-width);
|
||||
--ramp-distance: calc(var(--avg-width) * var(--factor));
|
||||
--reveal-pos: calc(var(--reveal-ready) * var(--virtual-width));
|
||||
|
||||
[data-slot="message-nav-item"]:active [data-slot="message-nav-title-preview"] {
|
||||
perspective: 1000px;
|
||||
font-size: 12px;
|
||||
transform-style: preserve-3d;
|
||||
line-height: 25px;
|
||||
padding-right: 4px;
|
||||
color: var(--text-base);
|
||||
}
|
||||
|
||||
[data-slot="message-nav-tooltip"] {
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
[data-slot="message-nav-tooltip-content"] {
|
||||
display: flex;
|
||||
padding: 4px 4px 6px 4px;
|
||||
justify-content: center;
|
||||
white-space: nowrap;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface-raised-stronger-non-alpha);
|
||||
max-height: calc(100vh - 6rem);
|
||||
overflow-y: auto;
|
||||
transition: transform 0.4s cubic-bezier(0.32, 0, 0.15, 1);
|
||||
}
|
||||
|
||||
/* border/shadow-xs/base */
|
||||
box-shadow:
|
||||
0 0 0 1px var(--border-weak-base, rgba(17, 0, 0, 0.12)),
|
||||
0 1px 2px -1px rgba(19, 16, 16, 0.04),
|
||||
0 1px 2px 0 rgba(19, 16, 16, 0.06),
|
||||
0 1px 3px 0 rgba(19, 16, 16, 0.08);
|
||||
/* Character spans for rotate effect */
|
||||
[data-slot="message-nav-char"] {
|
||||
--progress: clamp(0, calc((var(--reveal-pos) - var(--char-left, 0px) - var(--spacing)) / var(--ramp-distance)), 1);
|
||||
|
||||
* {
|
||||
margin: 0 !important;
|
||||
display: inline-block;
|
||||
transition: transform 300ms cubic-bezier(0.32, 0, 0.15, 1);
|
||||
transform: rotateX(calc(-90deg * (1 - var(--progress)))) skewY(calc(15deg * (1 - var(--progress))));
|
||||
transform-origin: 50% 45%;
|
||||
}
|
||||
|
||||
[data-slot="message-nav-item-diff-changes"] {
|
||||
font-family: var(--font-family-mono);
|
||||
font-feature-settings: var(--font-family-mono--font-feature-settings);
|
||||
font-size: 11px;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
flex-shrink: 0;
|
||||
opacity: var(--reveal-ready);
|
||||
transition: opacity 200ms cubic-bezier(0.32, 0, 0.15, 1);
|
||||
transition-delay: calc(var(--reveal-ready) * 50ms);
|
||||
}
|
||||
|
||||
[data-slot="message-nav-item-additions"] {
|
||||
color: var(--text-diff-add-base);
|
||||
display: inline-block;
|
||||
vertical-align: top;
|
||||
|
||||
&::before {
|
||||
content: "+";
|
||||
font: inherit;
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="message-nav-item-deletions"] {
|
||||
color: var(--text-diff-delete-base);
|
||||
display: inline-block;
|
||||
vertical-align: top;
|
||||
|
||||
&::before {
|
||||
content: "-";
|
||||
display: inline-block;
|
||||
font: inherit;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,84 +1,396 @@
|
|||
import { Tooltip } from "@kobalte/core/tooltip"
|
||||
import { type ComponentProps, For, Match, Show, Switch, splitProps } from "solid-js"
|
||||
import {
|
||||
type ComponentProps,
|
||||
For,
|
||||
Index,
|
||||
Show,
|
||||
createMemo,
|
||||
createSignal,
|
||||
onCleanup,
|
||||
onMount,
|
||||
splitProps,
|
||||
} from "solid-js"
|
||||
import { Portal } from "solid-js/web"
|
||||
import type { UserMessage } from "@opencode-ai/sdk/v2"
|
||||
import { ScrollFade } from "./scroll-fade"
|
||||
import "./message-nav.css"
|
||||
|
||||
export function MessageNav(
|
||||
props: ComponentProps<"ul"> & {
|
||||
messages: UserMessage[]
|
||||
current?: UserMessage
|
||||
size: "normal" | "compact"
|
||||
onMessageSelect: (message: UserMessage) => void
|
||||
},
|
||||
) {
|
||||
const [local, others] = splitProps(props, ["messages", "current", "size", "onMessageSelect"])
|
||||
|
||||
const content = () => (
|
||||
<ul data-component="message-nav" data-size={local.size} {...others}>
|
||||
<For each={local.messages}>
|
||||
{(message) => {
|
||||
const handleClick = () => local.onMessageSelect(message)
|
||||
|
||||
return (
|
||||
<li data-slot="message-nav-item">
|
||||
<Switch>
|
||||
<Match when={local.size === "compact"}>
|
||||
<div data-slot="message-nav-tick-button" data-active={message.id === local.current?.id || undefined}>
|
||||
<div data-slot="message-nav-tick-line" />
|
||||
</div>
|
||||
</Match>
|
||||
<Match when={local.size === "normal"}>
|
||||
<button data-slot="message-nav-message-button" onClick={handleClick} type="button">
|
||||
<div
|
||||
data-slot="message-nav-title-preview"
|
||||
data-active={message.id === local.current?.id || undefined}
|
||||
>
|
||||
<Show when={message.summary?.title} fallback="New message">
|
||||
{message.summary?.title}
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={(message.summary?.diffs.reduce((acc, diff) => acc + diff.additions, 0) ?? 0) > 0}>
|
||||
<span data-slot="message-nav-diff-changes message-nav-diff-additions">
|
||||
{message.summary?.diffs.reduce((acc, diff) => acc + diff.additions, 0)}
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={(message.summary?.diffs.reduce((acc, diff) => acc + diff.deletions, 0) ?? 0) > 0}>
|
||||
<span data-slot="message-nav-diff-changes message-nav-diff-deletions">
|
||||
{message.summary?.diffs.reduce((acc, diff) => acc + diff.deletions, 0)}
|
||||
</span>
|
||||
</Show>
|
||||
<Show
|
||||
when={
|
||||
(message.summary?.diffs?.reduce((acc, diff) => acc + diff.additions, 0) ?? 0) <= 0 &&
|
||||
(message.summary?.diffs?.reduce((acc, diff) => acc + diff.deletions, 0) ?? 0) <= 0
|
||||
}
|
||||
>
|
||||
<span data-slot="message-nav-diff-changes message-nav-diff-neutral">0</span>
|
||||
</Show>
|
||||
</button>
|
||||
</Match>
|
||||
</Switch>
|
||||
</li>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</ul>
|
||||
)
|
||||
const CharacterSpans = (props: { text: string }) => {
|
||||
const characters = createMemo(() => props.text?.split("") ?? [])
|
||||
|
||||
return (
|
||||
<Switch>
|
||||
<Match when={local.size === "compact"}>
|
||||
<Tooltip openDelay={0} closeDelay={300} placement="right-start" gutter={-40} shift={-10} overlap>
|
||||
<Tooltip.Trigger as="div">{content()}</Tooltip.Trigger>
|
||||
<Tooltip.Portal>
|
||||
<Tooltip.Content data-slot="message-nav-tooltip">
|
||||
<div data-slot="message-nav-tooltip-content">
|
||||
<MessageNav {...props} size="normal" class="" />
|
||||
</div>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Portal>
|
||||
</Tooltip>
|
||||
</Match>
|
||||
<Match when={local.size === "normal"}>{content()}</Match>
|
||||
</Switch>
|
||||
<Index each={characters()}>
|
||||
{(char, index) => (
|
||||
<span data-slot="message-nav-char" style={{ "--char-index": index }}>
|
||||
{char() === " " ? "\u00A0" : char()}
|
||||
</span>
|
||||
)}
|
||||
</Index>
|
||||
)
|
||||
}
|
||||
|
||||
const setupRevealForTitle = (titleEl: HTMLElement) => {
|
||||
const innerEl = titleEl.querySelector<HTMLElement>("[data-slot='message-nav-item-title-inner']")
|
||||
if (!innerEl) return
|
||||
|
||||
const spans = innerEl.querySelectorAll<HTMLSpanElement>("span")
|
||||
if (spans.length === 0) return
|
||||
|
||||
innerEl.offsetHeight
|
||||
|
||||
const totalWidth = innerEl.scrollWidth
|
||||
const containerWidth = titleEl.clientWidth
|
||||
const numChars = spans.length
|
||||
const avgWidth = numChars > 0 ? totalWidth / numChars : 12
|
||||
|
||||
innerEl.style.setProperty("--total-width", `${totalWidth}px`)
|
||||
innerEl.style.setProperty("--avg-width", `${avgWidth}px`)
|
||||
|
||||
const liEl = titleEl.closest("[data-slot='message-nav-item']") as HTMLElement | null
|
||||
if (liEl) {
|
||||
const extraWidth = Math.max(0, totalWidth - containerWidth)
|
||||
liEl.style.setProperty("--item-extra-width", `-${extraWidth}px`)
|
||||
}
|
||||
|
||||
const style = getComputedStyle(innerEl)
|
||||
const factor = parseFloat(style.getPropertyValue("--factor")) || 5
|
||||
let spacing = parseFloat(style.getPropertyValue("--spacing"))
|
||||
if (isNaN(spacing)) spacing = 0
|
||||
|
||||
let virtualWidth = totalWidth
|
||||
if (spans.length > 0) {
|
||||
const lastSpan = spans[spans.length - 1]
|
||||
lastSpan.offsetHeight
|
||||
const lastLeft = lastSpan.offsetLeft
|
||||
|
||||
const ramp = avgWidth * factor
|
||||
const neededForLast = lastLeft + spacing + ramp
|
||||
virtualWidth = Math.max(totalWidth, neededForLast)
|
||||
}
|
||||
|
||||
innerEl.style.setProperty("--virtual-width", `${virtualWidth}px`)
|
||||
|
||||
spans.forEach((span) => {
|
||||
span.offsetHeight
|
||||
const left = span.offsetLeft
|
||||
span.style.setProperty("--char-left", `${left}px`)
|
||||
})
|
||||
}
|
||||
|
||||
export type MessageNavProps = ComponentProps<"nav"> & {
|
||||
messages: UserMessage[]
|
||||
current?: UserMessage
|
||||
size: "normal" | "compact"
|
||||
onMessageSelect: (message: UserMessage) => void
|
||||
}
|
||||
|
||||
const createCharacterSpans = (text: string): HTMLSpanElement[] => {
|
||||
return text.split("").map((char, index) => {
|
||||
const span = document.createElement("span")
|
||||
span.setAttribute("data-slot", "message-nav-char")
|
||||
span.style.setProperty("--char-index", String(index))
|
||||
span.textContent = char === " " ? "\u00A0" : char
|
||||
return span
|
||||
})
|
||||
}
|
||||
|
||||
const SCROLL_SPEED = 60
|
||||
const PAUSE_DURATION = 800
|
||||
|
||||
interface ScrollAnimationState {
|
||||
rafId: number | null
|
||||
startTime: number
|
||||
running: boolean
|
||||
}
|
||||
|
||||
const startScrollAnimation = (
|
||||
containerEl: HTMLElement,
|
||||
): ScrollAnimationState | null => {
|
||||
containerEl.offsetHeight
|
||||
|
||||
const extraWidth = containerEl.scrollWidth - containerEl.clientWidth
|
||||
if (extraWidth <= 0) return null
|
||||
|
||||
const scrollDuration = (extraWidth / SCROLL_SPEED) * 1000
|
||||
|
||||
const totalDuration = PAUSE_DURATION + scrollDuration + PAUSE_DURATION + scrollDuration + PAUSE_DURATION
|
||||
|
||||
const state: ScrollAnimationState = {
|
||||
rafId: null,
|
||||
startTime: performance.now(),
|
||||
running: true,
|
||||
}
|
||||
|
||||
const animate = (currentTime: number) => {
|
||||
if (!state.running) return
|
||||
|
||||
const elapsed = currentTime - state.startTime
|
||||
const progress = (elapsed % totalDuration) / totalDuration
|
||||
|
||||
const pausePercent = PAUSE_DURATION / totalDuration
|
||||
const scrollPercent = scrollDuration / totalDuration
|
||||
|
||||
const pauseEnd1 = pausePercent
|
||||
const scrollEnd1 = pauseEnd1 + scrollPercent
|
||||
const pauseEnd2 = scrollEnd1 + pausePercent
|
||||
const scrollEnd2 = pauseEnd2 + scrollPercent
|
||||
|
||||
let scrollPos = 0
|
||||
|
||||
if (progress < pauseEnd1) {
|
||||
scrollPos = 0
|
||||
} else if (progress < scrollEnd1) {
|
||||
const scrollProgress = (progress - pauseEnd1) / scrollPercent
|
||||
scrollPos = scrollProgress * extraWidth
|
||||
} else if (progress < pauseEnd2) {
|
||||
scrollPos = extraWidth
|
||||
} else if (progress < scrollEnd2) {
|
||||
const scrollProgress = (progress - pauseEnd2) / scrollPercent
|
||||
scrollPos = extraWidth * (1 - scrollProgress)
|
||||
} else {
|
||||
scrollPos = 0
|
||||
}
|
||||
|
||||
containerEl.scrollLeft = scrollPos
|
||||
state.rafId = requestAnimationFrame(animate)
|
||||
}
|
||||
|
||||
state.rafId = requestAnimationFrame(animate)
|
||||
return state
|
||||
}
|
||||
|
||||
const stopScrollAnimation = (state: ScrollAnimationState | null, containerEl?: HTMLElement) => {
|
||||
if (state) {
|
||||
state.running = false
|
||||
if (state.rafId !== null) {
|
||||
cancelAnimationFrame(state.rafId)
|
||||
}
|
||||
}
|
||||
if (containerEl) {
|
||||
containerEl.scrollLeft = 0
|
||||
}
|
||||
}
|
||||
|
||||
export const MessageNav = (props: MessageNavProps) => {
|
||||
const [local, others] = splitProps(props, ["messages", "current", "size", "onMessageSelect"])
|
||||
const titleRefs = new Map<string, HTMLElement>()
|
||||
const innerRefs = new Map<string, HTMLSpanElement>()
|
||||
const resetTrackers = new Map<string, () => void>()
|
||||
let navRef: HTMLElement | undefined
|
||||
let listRef: HTMLUListElement | undefined
|
||||
|
||||
const [portalTarget, setPortalTarget] = createSignal<HTMLElement | null>(null)
|
||||
const [originalText, setOriginalText] = createSignal<Record<string, string>>({})
|
||||
|
||||
const handleListMouseEnter = () => {
|
||||
for (const reset of resetTrackers.values()) {
|
||||
reset()
|
||||
}
|
||||
setTimeout(() => {
|
||||
for (const titleEl of titleRefs.values()) {
|
||||
setupRevealForTitle(titleEl)
|
||||
}
|
||||
}, 500)
|
||||
}
|
||||
|
||||
const handleListMouseLeave = () => {
|
||||
for (const [id, innerEl] of innerRefs.entries()) {
|
||||
const text = originalText()?.[id]
|
||||
if (!text || !innerEl) continue
|
||||
|
||||
const existingSpans = innerEl.querySelectorAll("[data-slot='message-nav-char']")
|
||||
if (existingSpans.length > 0) continue
|
||||
|
||||
innerEl.textContent = ""
|
||||
const spans = createCharacterSpans(text)
|
||||
spans.forEach((span) => innerEl.appendChild(span))
|
||||
|
||||
const titleEl = titleRefs.get(id)
|
||||
if (titleEl) {
|
||||
requestAnimationFrame(() => setupRevealForTitle(titleEl))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const setupAllReveal = () => {
|
||||
const original: Record<string, string> = {}
|
||||
|
||||
for (const [id, titleEl] of titleRefs.entries()) {
|
||||
const originalText = titleEl.textContent
|
||||
|
||||
original[id] = originalText ?? ""
|
||||
|
||||
setupRevealForTitle(titleEl)
|
||||
}
|
||||
|
||||
setOriginalText(original)
|
||||
}
|
||||
|
||||
const onTransitionEnd = (id: string, index: number) => {
|
||||
const text = originalText()?.[id]
|
||||
const innerEl = innerRefs.get(id)
|
||||
|
||||
if (text && innerEl) {
|
||||
innerEl.textContent = text
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if (navRef) {
|
||||
setPortalTarget(navRef)
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<nav ref={(el) => (navRef = el)} data-component="message-nav" data-size={local.size} {...others}>
|
||||
<Show when={portalTarget()}>
|
||||
<Portal mount={portalTarget()!}>
|
||||
<ul
|
||||
ref={(el) => (listRef = el)}
|
||||
data-slot="message-nav-list"
|
||||
style={{ "--message-nav-items": local.messages.length }}
|
||||
onMouseEnter={handleListMouseEnter}
|
||||
onMouseLeave={handleListMouseLeave}
|
||||
>
|
||||
<For each={local.messages}>
|
||||
{(message, index) => {
|
||||
let titleRef: HTMLElement | undefined
|
||||
let hoverTimeout: ReturnType<typeof setTimeout> | undefined
|
||||
let scrollAnimationState: ScrollAnimationState | null = null
|
||||
let innerRef: HTMLSpanElement | undefined
|
||||
let revealedCount = 0
|
||||
let totalSpans = 0
|
||||
let lastRevealTriggered = false
|
||||
|
||||
const handleSpanTransitionEnd = (e: TransitionEvent) => {
|
||||
if (e.propertyName !== "transform") return
|
||||
const target = e.target as HTMLElement
|
||||
if (target.getAttribute("data-slot") !== "message-nav-char") return
|
||||
|
||||
revealedCount++
|
||||
if (revealedCount >= totalSpans && !lastRevealTriggered) {
|
||||
lastRevealTriggered = true
|
||||
onTransitionEnd(message.id, index())
|
||||
}
|
||||
}
|
||||
|
||||
const setupTransitionTracking = () => {
|
||||
if (!innerRef) return
|
||||
const spans = innerRef.querySelectorAll<HTMLSpanElement>("[data-slot='message-nav-char']")
|
||||
totalSpans = spans.length
|
||||
revealedCount = 0
|
||||
lastRevealTriggered = false
|
||||
}
|
||||
|
||||
const handleClick = () => local.onMessageSelect(message)
|
||||
|
||||
const additions = createMemo(
|
||||
() => message.summary?.diffs.reduce((acc, diff) => acc + diff.additions, 0) ?? 0,
|
||||
)
|
||||
|
||||
const deletions = createMemo(
|
||||
() => message.summary?.diffs.reduce((acc, diff) => acc + diff.deletions, 0) ?? 0,
|
||||
)
|
||||
|
||||
const title = createMemo(() => message.summary?.title ?? "New message")
|
||||
|
||||
const handleTitleMouseEnter = () => {
|
||||
hoverTimeout = setTimeout(() => {
|
||||
if (!titleRef) return
|
||||
|
||||
titleRef.offsetHeight
|
||||
|
||||
const isScrollable = titleRef.scrollWidth > titleRef.clientWidth + 1
|
||||
|
||||
if (isScrollable) {
|
||||
stopScrollAnimation(scrollAnimationState, titleRef)
|
||||
scrollAnimationState = startScrollAnimation(titleRef)
|
||||
}
|
||||
}, 500)
|
||||
}
|
||||
|
||||
const handleTitleMouseLeave = () => {
|
||||
if (hoverTimeout) {
|
||||
clearTimeout(hoverTimeout)
|
||||
hoverTimeout = undefined
|
||||
}
|
||||
stopScrollAnimation(scrollAnimationState, titleRef)
|
||||
scrollAnimationState = null
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if (titleRef) {
|
||||
titleRefs.set(message.id, titleRef)
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
if (titleRef) {
|
||||
setupRevealForTitle(titleRef)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (innerRef) {
|
||||
innerRefs.set(message.id, innerRef)
|
||||
innerRef.addEventListener("transitionend", handleSpanTransitionEnd)
|
||||
}
|
||||
|
||||
resetTrackers.set(message.id, setupTransitionTracking)
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
titleRefs.delete(message.id)
|
||||
innerRefs.delete(message.id)
|
||||
resetTrackers.delete(message.id)
|
||||
|
||||
if (hoverTimeout) {
|
||||
clearTimeout(hoverTimeout)
|
||||
}
|
||||
|
||||
stopScrollAnimation(scrollAnimationState, titleRef)
|
||||
|
||||
if (innerRef) {
|
||||
innerRef.removeEventListener("transitionend", handleSpanTransitionEnd)
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<li data-slot="message-nav-item" style={{ "--item-index": index() }}>
|
||||
<button
|
||||
data-slot="message-nav-item-button"
|
||||
data-active={message.id === local.current?.id || undefined}
|
||||
type="button"
|
||||
onClick={handleClick}
|
||||
>
|
||||
<ScrollFade
|
||||
ref={(el) => (titleRef = el)}
|
||||
direction="horizontal"
|
||||
fadeStartSize={12}
|
||||
fadeEndSize={12}
|
||||
trackTransformSelector="[data-slot='message-nav-item-title-inner']"
|
||||
data-slot="message-nav-item-title"
|
||||
onMouseEnter={handleTitleMouseEnter}
|
||||
onMouseLeave={handleTitleMouseLeave}
|
||||
>
|
||||
<span ref={(el) => (innerRef = el)} data-slot="message-nav-item-title-inner">
|
||||
<CharacterSpans text={title()} />
|
||||
</span>
|
||||
</ScrollFade>
|
||||
|
||||
<span data-slot="message-nav-item-diff-changes">
|
||||
<Show when={additions() > 0}>
|
||||
<span data-slot="message-nav-item-additions">{additions()}</span>
|
||||
</Show>
|
||||
<Show when={deletions() > 0}>
|
||||
<span data-slot="message-nav-item-deletions">{deletions()}</span>
|
||||
</Show>
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</ul>
|
||||
</Portal>
|
||||
</Show>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue