feat: layout, scroll sidebar
parent
457c246f10
commit
0beb8a0bf6
|
|
@ -66,6 +66,7 @@ import { DialogSelectDirectory } from "@/components/dialog-select-directory"
|
|||
import { DialogEditProject } from "@/components/dialog-edit-project"
|
||||
import { Titlebar } from "@/components/titlebar"
|
||||
import { useServer } from "@/context/server"
|
||||
import { ScrollReveal } from "@opencode-ai/ui/scroll-reveal"
|
||||
|
||||
export default function Layout(props: ParentProps) {
|
||||
const [store, setStore, , ready] = persisted(
|
||||
|
|
@ -187,7 +188,9 @@ export default function Layout(props: ParentProps) {
|
|||
onClick={stopPropagation}
|
||||
onTouchStart={stopPropagation}
|
||||
>
|
||||
{props.value()}
|
||||
<ScrollReveal>
|
||||
{props.value()}
|
||||
</ScrollReveal>
|
||||
</span>
|
||||
}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -1,52 +1,14 @@
|
|||
@property --reveal-ready {
|
||||
syntax: "<number>";
|
||||
inherits: true;
|
||||
initial-value: 0;
|
||||
}
|
||||
|
||||
[data-component="message-nav"] {
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
height: 100%;
|
||||
max-height: calc(100vh - 6rem);
|
||||
}
|
||||
|
||||
[data-slot="message-nav-list"] {
|
||||
--message-nav-item-height: 11px;
|
||||
--message-nav-item-width: 40px;
|
||||
--message-nav-expanded-width: 260px;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
padding-left: 0;
|
||||
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;
|
||||
transition-duration: 150ms;
|
||||
transition-delay: 100ms, 100ms, 0ms, 0ms, 0ms, 0ms;
|
||||
transition-timing-function: cubic-bezier(0.25, 0, 0.5, 1);
|
||||
|
||||
&:hover {
|
||||
--message-nav-line-opacity: 0;
|
||||
--message-nav-item-height: 23px;
|
||||
--message-nav-item-width: var(--message-nav-expanded-width);
|
||||
--message-nav-diff-opacity: 1;
|
||||
--message-nav-diff-delay: 300ms;
|
||||
--message-nav-title-mask: 0%;
|
||||
--message-nav-title-transition-duration: 300ms;
|
||||
--message-nav-title-transition-delay: 100ms;
|
||||
|
||||
transition-duration: 200ms, 200ms, 200ms, 300ms, 300ms, 200ms;
|
||||
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-size="normal"] {
|
||||
width: 240px;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
&[data-size="compact"] {
|
||||
|
|
@ -54,128 +16,107 @@
|
|||
}
|
||||
}
|
||||
|
||||
[data-slot="message-nav-item-button"] {
|
||||
--line-width: var(--message-nav-line-width);
|
||||
--line-expanded-width: calc(var(--message-nav-expanded-width) - 16px);
|
||||
[data-slot="message-nav-item"] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-self: stretch;
|
||||
justify-content: flex-end;
|
||||
|
||||
appearance: none;
|
||||
border: none;
|
||||
background: none;
|
||||
padding: 0 4px;
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
font-family: inherit;
|
||||
[data-component="message-nav"][data-size="normal"] & {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="message-nav-tick-button"] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
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;
|
||||
height: 12px;
|
||||
width: 24px;
|
||||
border: none;
|
||||
background: none;
|
||||
padding: 0;
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
display: block;
|
||||
flex-shrink: 0;
|
||||
width: 24px;
|
||||
height: 3px;
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
left: 4px;
|
||||
top: 50%;
|
||||
opacity: var(--message-nav-line-opacity, 1);
|
||||
transform: scaleX(var(--message-nav-line-scale, 0.66)) scaleY(.66) translateY(-50%);
|
||||
transform-origin: 0% 50%;
|
||||
background-color: var(--icon-weak-base);
|
||||
transition-property: opacity, background-color, transform;
|
||||
transition-duration: 200ms;
|
||||
transition-timing-function: cubic-bezier(0.32, 0, 0.15, 1);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: var(--surface-base-hover);
|
||||
}
|
||||
|
||||
&[data-active="true"] {
|
||||
--message-nav-line-scale: 1;
|
||||
|
||||
&::before {
|
||||
background-color: var(--icon-strong-base);
|
||||
}
|
||||
|
||||
[data-slot="message-nav-item-title-inner"] {
|
||||
color: var(--text-strong);
|
||||
}
|
||||
&[data-active] [data-slot="message-nav-tick-line"] {
|
||||
background-color: var(--icon-strong-base);
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="message-nav-item-title"] {
|
||||
position: relative;
|
||||
[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"] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
transition: mask-position 200ms cubic-bezier(0.25, 0, 0.5, 1);
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
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-item-title-inner"] {
|
||||
font-size: 12px;
|
||||
line-height: 23px;
|
||||
padding-left: 4px;
|
||||
color: var(--text-base);
|
||||
[data-slot="message-nav-title-preview"] {
|
||||
font-size: 14px; /* text-14-regular */
|
||||
color: var(--text-weak);
|
||||
white-space: nowrap;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
mask-image: linear-gradient(to right, black, black 30%, transparent 60%, transparent);
|
||||
mask-size: 300% 100%;
|
||||
mask-position: var(--message-nav-title-mask, 100%) 0%;
|
||||
transition: mask-position var(--message-nav-title-transition-duration, 200ms) cubic-bezier(0.25, 0, 0.5, 1) var(--message-nav-title-transition-delay, 0ms);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
min-width: 0;
|
||||
text-align: left;
|
||||
|
||||
&[data-active] {
|
||||
color: var(--text-strong);
|
||||
}
|
||||
}
|
||||
|
||||
[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;
|
||||
[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"]:active [data-slot="message-nav-title-preview"] {
|
||||
color: var(--text-base);
|
||||
}
|
||||
|
||||
[data-slot="message-nav-tooltip"] {
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
[data-slot="message-nav-tooltip-content"] {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
flex-shrink: 0;
|
||||
opacity: var(--message-nav-diff-opacity, 0);
|
||||
transition: opacity 200ms cubic-bezier(0.25, 0, 0.5, 1);
|
||||
transition-delay: var(--message-nav-diff-delay, 0ms);
|
||||
}
|
||||
padding: 4px 4px 6px 4px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface-raised-stronger-non-alpha);
|
||||
max-height: calc(100vh - 6rem);
|
||||
overflow-y: auto;
|
||||
|
||||
[data-slot="message-nav-item-additions"] {
|
||||
color: var(--text-diff-add-base);
|
||||
display: inline-block;
|
||||
vertical-align: top;
|
||||
/* 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);
|
||||
|
||||
&::before {
|
||||
content: "+";
|
||||
font: inherit;
|
||||
display: inline-block;
|
||||
* {
|
||||
margin: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
[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,103 +1,10 @@
|
|||
import {
|
||||
type ComponentProps,
|
||||
For,
|
||||
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 type MessageNavProps = ComponentProps<"nav"> & {
|
||||
messages: UserMessage[]
|
||||
current?: UserMessage
|
||||
size: "normal" | "compact"
|
||||
onMessageSelect: (message: UserMessage) => void
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
import { UserMessage } from "@opencode-ai/sdk/v2"
|
||||
import { ComponentProps, For, Match, Show, splitProps, Switch } from "solid-js"
|
||||
import { DiffChanges } from "./diff-changes"
|
||||
import { Tooltip } from "@kobalte/core/tooltip"
|
||||
|
||||
export function MessageNav(
|
||||
props: ComponentProps<"nav"> & {
|
||||
props: ComponentProps<"ul"> & {
|
||||
messages: UserMessage[]
|
||||
current?: UserMessage
|
||||
size: "normal" | "compact"
|
||||
|
|
@ -106,116 +13,70 @@ export function MessageNav(
|
|||
},
|
||||
) {
|
||||
const [local, others] = splitProps(props, ["messages", "current", "size", "onMessageSelect", "getLabel"])
|
||||
let navRef: HTMLElement | undefined
|
||||
let listRef: HTMLUListElement | undefined
|
||||
|
||||
const [portalTarget, setPortalTarget] = createSignal<HTMLElement | null>(null)
|
||||
const content = () => (
|
||||
<ul role="list" data-component="message-nav" data-size={local.size} {...others}>
|
||||
<For each={local.messages}>
|
||||
{(message) => {
|
||||
const handleClick = () => local.onMessageSelect(message)
|
||||
|
||||
onMount(() => {
|
||||
if (navRef) {
|
||||
setPortalTarget(navRef)
|
||||
}
|
||||
})
|
||||
const handleKeyPress = (event: KeyboardEvent) => {
|
||||
if (event.key !== "Enter" && event.key !== " ") return
|
||||
event.preventDefault()
|
||||
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}
|
||||
role="button"
|
||||
tabindex={0}
|
||||
onClick={handleClick}
|
||||
onKeyDown={handleKeyPress}
|
||||
>
|
||||
<div data-slot="message-nav-tick-line" />
|
||||
</div>
|
||||
</Match>
|
||||
<Match when={local.size === "normal"}>
|
||||
<button data-slot="message-nav-message-button" onClick={handleClick} onKeyDown={handleKeyPress}>
|
||||
<DiffChanges changes={message.summary?.diffs ?? []} variant="bars" />
|
||||
<div
|
||||
data-slot="message-nav-title-preview"
|
||||
data-active={message.id === local.current?.id || undefined}
|
||||
>
|
||||
<Show when={local.getLabel?.(message) ?? message.summary?.title} fallback="New message">
|
||||
{local.getLabel?.(message) ?? message.summary?.title}
|
||||
</Show>
|
||||
</div>
|
||||
</button>
|
||||
</Match>
|
||||
</Switch>
|
||||
</li>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</ul>
|
||||
)
|
||||
|
||||
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 }}
|
||||
>
|
||||
<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
|
||||
|
||||
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)
|
||||
}
|
||||
}, 300)
|
||||
}
|
||||
|
||||
const handleTitleMouseLeave = () => {
|
||||
if (hoverTimeout) {
|
||||
clearTimeout(hoverTimeout)
|
||||
hoverTimeout = undefined
|
||||
}
|
||||
stopScrollAnimation(scrollAnimationState, titleRef)
|
||||
scrollAnimationState = null
|
||||
}
|
||||
|
||||
onCleanup(() => {
|
||||
if (hoverTimeout) {
|
||||
clearTimeout(hoverTimeout)
|
||||
}
|
||||
|
||||
stopScrollAnimation(scrollAnimationState, titleRef)
|
||||
})
|
||||
|
||||
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">
|
||||
{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>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,13 @@
|
|||
[data-component="scroll-fade"] {
|
||||
overflow: auto;
|
||||
overscroll-behavior: contain;
|
||||
scrollbar-width: none;
|
||||
box-sizing: border-box;
|
||||
-ms-overflow-style: none;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&[data-direction="horizontal"] {
|
||||
overflow-x: auto;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,139 @@
|
|||
import { type JSX, onCleanup, splitProps } from "solid-js"
|
||||
import { ScrollFade, type ScrollFadeProps } from "./scroll-fade"
|
||||
|
||||
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 interface ScrollRevealProps extends Omit<ScrollFadeProps, "direction"> {
|
||||
/** Delay before scroll animation starts on hover (ms). Default: 300 */
|
||||
hoverDelay?: number
|
||||
}
|
||||
|
||||
export function ScrollReveal(props: ScrollRevealProps) {
|
||||
const [local, others] = splitProps(props, ["children", "hoverDelay", "ref"])
|
||||
|
||||
const hoverDelay = () => local.hoverDelay ?? 300
|
||||
|
||||
let containerRef: HTMLDivElement | undefined
|
||||
let hoverTimeout: ReturnType<typeof setTimeout> | undefined
|
||||
let scrollAnimationState: ScrollAnimationState | null = null
|
||||
|
||||
const handleMouseEnter: JSX.EventHandler<HTMLDivElement, MouseEvent> = () => {
|
||||
hoverTimeout = setTimeout(() => {
|
||||
if (!containerRef) return
|
||||
|
||||
containerRef.offsetHeight
|
||||
|
||||
const isScrollable = containerRef.scrollWidth > containerRef.clientWidth + 1
|
||||
|
||||
if (isScrollable) {
|
||||
stopScrollAnimation(scrollAnimationState, containerRef)
|
||||
scrollAnimationState = startScrollAnimation(containerRef)
|
||||
}
|
||||
}, hoverDelay())
|
||||
}
|
||||
|
||||
const handleMouseLeave: JSX.EventHandler<HTMLDivElement, MouseEvent> = () => {
|
||||
if (hoverTimeout) {
|
||||
clearTimeout(hoverTimeout)
|
||||
hoverTimeout = undefined
|
||||
}
|
||||
stopScrollAnimation(scrollAnimationState, containerRef)
|
||||
scrollAnimationState = null
|
||||
}
|
||||
|
||||
onCleanup(() => {
|
||||
if (hoverTimeout) {
|
||||
clearTimeout(hoverTimeout)
|
||||
}
|
||||
stopScrollAnimation(scrollAnimationState, containerRef)
|
||||
})
|
||||
|
||||
return (
|
||||
<ScrollFade
|
||||
ref={(el) => {
|
||||
containerRef = el
|
||||
local.ref?.(el)
|
||||
}}
|
||||
fadeStartSize={8}
|
||||
fadeEndSize={16}
|
||||
direction="horizontal"
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
{...others}
|
||||
>
|
||||
{local.children}
|
||||
</ScrollFade>
|
||||
)
|
||||
}
|
||||
Loading…
Reference in New Issue