wip(app): custom scroll view
This commit is contained in:
@@ -225,7 +225,7 @@ export async function hoverSessionItem(page: Page, sessionID: string) {
|
|||||||
export async function openSessionMoreMenu(page: Page, sessionID: string) {
|
export async function openSessionMoreMenu(page: Page, sessionID: string) {
|
||||||
await expect(page).toHaveURL(new RegExp(`/session/${sessionID}(?:[/?#]|$)`))
|
await expect(page).toHaveURL(new RegExp(`/session/${sessionID}(?:[/?#]|$)`))
|
||||||
|
|
||||||
const scroller = page.locator(".session-scroller").first()
|
const scroller = page.locator(".scroll-view__viewport").first()
|
||||||
await expect(scroller).toBeVisible()
|
await expect(scroller).toBeVisible()
|
||||||
await expect(scroller.getByRole("heading", { level: 1 }).first()).toBeVisible({ timeout: 30_000 })
|
await expect(scroller.getByRole("heading", { level: 1 }).first()).toBeVisible({ timeout: 30_000 })
|
||||||
|
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ test("session can be renamed via header menu", async ({ page, sdk, gotoSession }
|
|||||||
const menu = await openSessionMoreMenu(page, session.id)
|
const menu = await openSessionMoreMenu(page, session.id)
|
||||||
await clickMenuItem(menu, /rename/i)
|
await clickMenuItem(menu, /rename/i)
|
||||||
|
|
||||||
const input = page.locator(".session-scroller").locator(inlineInputSelector).first()
|
const input = page.locator(".scroll-view__viewport").locator(inlineInputSelector).first()
|
||||||
await expect(input).toBeVisible()
|
await expect(input).toBeVisible()
|
||||||
await expect(input).toBeFocused()
|
await expect(input).toBeFocused()
|
||||||
await input.fill(renamedTitle)
|
await input.fill(renamedTitle)
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { Accordion } from "@opencode-ai/ui/accordion"
|
|||||||
import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header"
|
import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header"
|
||||||
import { Code } from "@opencode-ai/ui/code"
|
import { Code } from "@opencode-ai/ui/code"
|
||||||
import { Markdown } from "@opencode-ai/ui/markdown"
|
import { Markdown } from "@opencode-ai/ui/markdown"
|
||||||
|
import { ScrollView } from "@opencode-ai/ui/scroll-view"
|
||||||
import type { Message, Part, UserMessage } from "@opencode-ai/sdk/v2/client"
|
import type { Message, Part, UserMessage } from "@opencode-ai/sdk/v2/client"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { getSessionContextMetrics } from "./session-context-metrics"
|
import { getSessionContextMetrics } from "./session-context-metrics"
|
||||||
@@ -268,9 +269,9 @@ export function SessionContextTab() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<ScrollView
|
||||||
class="@container h-full overflow-y-auto no-scrollbar pb-10"
|
class="@container h-full pb-10"
|
||||||
ref={(el) => {
|
viewportRef={(el) => {
|
||||||
scroll = el
|
scroll = el
|
||||||
restoreScroll()
|
restoreScroll()
|
||||||
}}
|
}}
|
||||||
@@ -336,6 +337,6 @@ export function SessionContextTab() {
|
|||||||
</Accordion>
|
</Accordion>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</ScrollView>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
|||||||
const measure = () => {
|
const measure = () => {
|
||||||
if (!root) return
|
if (!root) return
|
||||||
|
|
||||||
const scroller = document.querySelector(".session-scroller")
|
const scroller = document.querySelector(".scroll-view__viewport")
|
||||||
const head = scroller instanceof HTMLElement ? scroller.firstElementChild : undefined
|
const head = scroller instanceof HTMLElement ? scroller.firstElementChild : undefined
|
||||||
const top =
|
const top =
|
||||||
head instanceof HTMLElement && head.classList.contains("sticky") ? head.getBoundingClientRect().bottom : 0
|
head instanceof HTMLElement && head.classList.contains("sticky") ? head.getBoundingClientRect().bottom : 0
|
||||||
@@ -95,7 +95,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
|||||||
window.addEventListener("resize", update)
|
window.addEventListener("resize", update)
|
||||||
|
|
||||||
const dock = root?.closest('[data-component="session-prompt-dock"]')
|
const dock = root?.closest('[data-component="session-prompt-dock"]')
|
||||||
const scroller = document.querySelector(".session-scroller")
|
const scroller = document.querySelector(".scroll-view__viewport")
|
||||||
const observer = new ResizeObserver(update)
|
const observer = new ResizeObserver(update)
|
||||||
if (dock instanceof HTMLElement) observer.observe(dock)
|
if (dock instanceof HTMLElement) observer.observe(dock)
|
||||||
if (scroller instanceof HTMLElement) observer.observe(scroller)
|
if (scroller instanceof HTMLElement) observer.observe(scroller)
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { showToast } from "@opencode-ai/ui/toast"
|
|||||||
import { LineComment as LineCommentView, LineCommentEditor } from "@opencode-ai/ui/line-comment"
|
import { LineComment as LineCommentView, LineCommentEditor } from "@opencode-ai/ui/line-comment"
|
||||||
import { Mark } from "@opencode-ai/ui/logo"
|
import { Mark } from "@opencode-ai/ui/logo"
|
||||||
import { Tabs } from "@opencode-ai/ui/tabs"
|
import { Tabs } from "@opencode-ai/ui/tabs"
|
||||||
|
import { ScrollView } from "@opencode-ai/ui/scroll-view"
|
||||||
import { useLayout } from "@/context/layout"
|
import { useLayout } from "@/context/layout"
|
||||||
import { selectionFromLines, useFile, type FileSelection, type SelectedLineRange } from "@/context/file"
|
import { selectionFromLines, useFile, type FileSelection, type SelectedLineRange } from "@/context/file"
|
||||||
import { useComments } from "@/context/comments"
|
import { useComments } from "@/context/comments"
|
||||||
@@ -509,51 +510,52 @@ export function FileTabContent(props: { tab: string }) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tabs.Content
|
<Tabs.Content value={props.tab} class="mt-3 relative h-full">
|
||||||
value={props.tab}
|
<ScrollView
|
||||||
class="mt-3 relative"
|
class="h-full"
|
||||||
ref={(el: HTMLDivElement) => {
|
viewportRef={(el: HTMLDivElement) => {
|
||||||
scroll = el
|
scroll = el
|
||||||
restoreScroll()
|
restoreScroll()
|
||||||
}}
|
}}
|
||||||
onScroll={handleScroll}
|
onScroll={handleScroll as any}
|
||||||
>
|
>
|
||||||
<Switch>
|
<Switch>
|
||||||
<Match when={state()?.loaded && isImage()}>
|
<Match when={state()?.loaded && isImage()}>
|
||||||
<div class="px-6 py-4 pb-40">
|
<div class="px-6 py-4 pb-40">
|
||||||
<img
|
<img
|
||||||
src={imageDataUrl()}
|
src={imageDataUrl()}
|
||||||
alt={path()}
|
alt={path()}
|
||||||
class="max-w-full"
|
class="max-w-full"
|
||||||
onLoad={() => requestAnimationFrame(restoreScroll)}
|
onLoad={() => requestAnimationFrame(restoreScroll)}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
</Match>
|
|
||||||
<Match when={state()?.loaded && isSvg()}>
|
|
||||||
<div class="flex flex-col gap-4 px-6 py-4">
|
|
||||||
{renderCode(svgContent() ?? "", "")}
|
|
||||||
<Show when={svgPreviewUrl()}>
|
|
||||||
<div class="flex justify-center pb-40">
|
|
||||||
<img src={svgPreviewUrl()} alt={path()} class="max-w-full max-h-96" />
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</Match>
|
|
||||||
<Match when={state()?.loaded && isBinary()}>
|
|
||||||
<div class="h-full px-6 pb-42 flex flex-col items-center justify-center text-center gap-6">
|
|
||||||
<Mark class="w-14 opacity-10" />
|
|
||||||
<div class="flex flex-col gap-2 max-w-md">
|
|
||||||
<div class="text-14-semibold text-text-strong truncate">{path()?.split("/").pop()}</div>
|
|
||||||
<div class="text-14-regular text-text-weak">{language.t("session.files.binaryContent")}</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Match>
|
||||||
</Match>
|
<Match when={state()?.loaded && isSvg()}>
|
||||||
<Match when={state()?.loaded}>{renderCode(contents(), "pb-40")}</Match>
|
<div class="flex flex-col gap-4 px-6 py-4">
|
||||||
<Match when={state()?.loading}>
|
{renderCode(svgContent() ?? "", "")}
|
||||||
<div class="px-6 py-4 text-text-weak">{language.t("common.loading")}...</div>
|
<Show when={svgPreviewUrl()}>
|
||||||
</Match>
|
<div class="flex justify-center pb-40">
|
||||||
<Match when={state()?.error}>{(err) => <div class="px-6 py-4 text-text-weak">{err()}</div>}</Match>
|
<img src={svgPreviewUrl()} alt={path()} class="max-w-full max-h-96" />
|
||||||
</Switch>
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</Match>
|
||||||
|
<Match when={state()?.loaded && isBinary()}>
|
||||||
|
<div class="h-full px-6 pb-42 flex flex-col items-center justify-center text-center gap-6">
|
||||||
|
<Mark class="w-14 opacity-10" />
|
||||||
|
<div class="flex flex-col gap-2 max-w-md">
|
||||||
|
<div class="text-14-semibold text-text-strong truncate">{path()?.split("/").pop()}</div>
|
||||||
|
<div class="text-14-regular text-text-weak">{language.t("session.files.binaryContent")}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Match>
|
||||||
|
<Match when={state()?.loaded}>{renderCode(contents(), "pb-40")}</Match>
|
||||||
|
<Match when={state()?.loading}>
|
||||||
|
<div class="px-6 py-4 text-text-weak">{language.t("common.loading")}...</div>
|
||||||
|
</Match>
|
||||||
|
<Match when={state()?.error}>{(err) => <div class="px-6 py-4 text-text-weak">{err()}</div>}</Match>
|
||||||
|
</Switch>
|
||||||
|
</ScrollView>
|
||||||
</Tabs.Content>
|
</Tabs.Content>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
|||||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||||
import { InlineInput } from "@opencode-ai/ui/inline-input"
|
import { InlineInput } from "@opencode-ai/ui/inline-input"
|
||||||
import { SessionTurn } from "@opencode-ai/ui/session-turn"
|
import { SessionTurn } from "@opencode-ai/ui/session-turn"
|
||||||
|
import { ScrollView } from "@opencode-ai/ui/scroll-view"
|
||||||
import type { UserMessage } from "@opencode-ai/sdk/v2"
|
import type { UserMessage } from "@opencode-ai/sdk/v2"
|
||||||
import { showToast } from "@opencode-ai/ui/toast"
|
import { showToast } from "@opencode-ai/ui/toast"
|
||||||
import { shouldMarkBoundaryGesture, normalizeWheelDelta } from "@/pages/session/message-gesture"
|
import { shouldMarkBoundaryGesture, normalizeWheelDelta } from "@/pages/session/message-gesture"
|
||||||
@@ -322,8 +323,8 @@ export function MessageTimeline(props: {
|
|||||||
<Icon name="arrow-down-to-line" />
|
<Icon name="arrow-down-to-line" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<ScrollView
|
||||||
ref={props.setScrollRef}
|
viewportRef={props.setScrollRef}
|
||||||
onWheel={(e) => {
|
onWheel={(e) => {
|
||||||
const root = e.currentTarget
|
const root = e.currentTarget
|
||||||
const delta = normalizeWheelDelta({
|
const delta = normalizeWheelDelta({
|
||||||
@@ -367,7 +368,7 @@ export function MessageTimeline(props: {
|
|||||||
if (props.isDesktop) props.onScrollSpyScroll()
|
if (props.isDesktop) props.onScrollSpyScroll()
|
||||||
}}
|
}}
|
||||||
onClick={props.onAutoScrollInteraction}
|
onClick={props.onAutoScrollInteraction}
|
||||||
class="relative min-w-0 w-full h-full overflow-y-auto session-scroller"
|
class="relative min-w-0 w-full h-full"
|
||||||
style={{
|
style={{
|
||||||
"--session-title-height": showHeader() ? "40px" : "0px",
|
"--session-title-height": showHeader() ? "40px" : "0px",
|
||||||
"--sticky-accordion-top": showHeader() ? "48px" : "0px",
|
"--sticky-accordion-top": showHeader() ? "48px" : "0px",
|
||||||
@@ -548,7 +549,7 @@ export function MessageTimeline(props: {
|
|||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</ScrollView>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -143,9 +143,9 @@ export function SessionReviewTab(props: SessionReviewTabProps) {
|
|||||||
open={props.view().review.open()}
|
open={props.view().review.open()}
|
||||||
onOpenChange={props.view().review.setOpen}
|
onOpenChange={props.view().review.setOpen}
|
||||||
classes={{
|
classes={{
|
||||||
root: props.classes?.root ?? "pb-6",
|
root: props.classes?.root ?? "pb-6 pr-3",
|
||||||
header: props.classes?.header ?? "px-3",
|
header: props.classes?.header ?? "px-3",
|
||||||
container: props.classes?.container ?? "px-3",
|
container: props.classes?.container ?? "pl-3",
|
||||||
}}
|
}}
|
||||||
diffs={props.diffs()}
|
diffs={props.diffs()}
|
||||||
diffStyle={props.diffStyle}
|
diffStyle={props.diffStyle}
|
||||||
|
|||||||
61
packages/ui/src/components/scroll-view.css
Normal file
61
packages/ui/src/components/scroll-view.css
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
.scroll-view {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scroll-view__viewport {
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
overflow-y: auto;
|
||||||
|
scrollbar-width: none;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scroll-view__viewport::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scroll-view__thumb {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
top: 0;
|
||||||
|
width: 16px;
|
||||||
|
transition: opacity 200ms ease;
|
||||||
|
cursor: default;
|
||||||
|
user-select: none;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scroll-view__thumb::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
right: 4px;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 6px;
|
||||||
|
border-radius: 9999px;
|
||||||
|
background-color: var(--border-weak-base);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
transition: background-color 150ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scroll-view__thumb:hover::after,
|
||||||
|
.scroll-view__thumb[data-dragging="true"]::after {
|
||||||
|
background-color: var(--border-strong-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .scroll-view__thumb::after,
|
||||||
|
[data-theme="dark"] .scroll-view__thumb::after {
|
||||||
|
background-color: var(--border-weak-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .scroll-view__thumb:hover::after,
|
||||||
|
[data-theme="dark"] .scroll-view__thumb:hover::after,
|
||||||
|
.dark .scroll-view__thumb[data-dragging="true"]::after,
|
||||||
|
[data-theme="dark"] .scroll-view__thumb[data-dragging="true"]::after {
|
||||||
|
background-color: var(--border-strong-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scroll-view__thumb[data-visible="true"] {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
217
packages/ui/src/components/scroll-view.tsx
Normal file
217
packages/ui/src/components/scroll-view.tsx
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
import { createSignal, onCleanup, onMount, splitProps, type ComponentProps, Show, mergeProps } from "solid-js"
|
||||||
|
|
||||||
|
export interface ScrollViewProps extends ComponentProps<"div"> {
|
||||||
|
viewportRef?: (el: HTMLDivElement) => void
|
||||||
|
orientation?: "vertical" | "horizontal" // currently only vertical is fully implemented for thumb
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ScrollView(props: ScrollViewProps) {
|
||||||
|
const merged = mergeProps({ orientation: "vertical" }, props)
|
||||||
|
const [local, events, rest] = splitProps(
|
||||||
|
merged,
|
||||||
|
["class", "children", "viewportRef", "orientation", "style"],
|
||||||
|
[
|
||||||
|
"onScroll",
|
||||||
|
"onWheel",
|
||||||
|
"onTouchStart",
|
||||||
|
"onTouchMove",
|
||||||
|
"onTouchEnd",
|
||||||
|
"onTouchCancel",
|
||||||
|
"onPointerDown",
|
||||||
|
"onClick",
|
||||||
|
"onKeyDown",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
let rootRef!: HTMLDivElement
|
||||||
|
let viewportRef!: HTMLDivElement
|
||||||
|
let thumbRef!: HTMLDivElement
|
||||||
|
|
||||||
|
const [isHovered, setIsHovered] = createSignal(false)
|
||||||
|
const [isDragging, setIsDragging] = createSignal(false)
|
||||||
|
|
||||||
|
const [thumbHeight, setThumbHeight] = createSignal(0)
|
||||||
|
const [thumbTop, setThumbTop] = createSignal(0)
|
||||||
|
const [showThumb, setShowThumb] = createSignal(false)
|
||||||
|
|
||||||
|
const updateThumb = () => {
|
||||||
|
if (!viewportRef) return
|
||||||
|
const { scrollTop, scrollHeight, clientHeight } = viewportRef
|
||||||
|
|
||||||
|
if (scrollHeight <= clientHeight || scrollHeight === 0) {
|
||||||
|
setShowThumb(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setShowThumb(true)
|
||||||
|
const trackPadding = 8
|
||||||
|
const trackHeight = clientHeight - trackPadding * 2
|
||||||
|
|
||||||
|
const minThumbHeight = 32
|
||||||
|
// Calculate raw thumb height based on ratio
|
||||||
|
let height = (clientHeight / scrollHeight) * trackHeight
|
||||||
|
height = Math.max(height, minThumbHeight)
|
||||||
|
|
||||||
|
const maxScrollTop = scrollHeight - clientHeight
|
||||||
|
const maxThumbTop = trackHeight - height
|
||||||
|
|
||||||
|
const top = maxScrollTop > 0 ? (scrollTop / maxScrollTop) * maxThumbTop : 0
|
||||||
|
|
||||||
|
// Ensure thumb stays within bounds (shouldn't be necessary due to math above, but good for safety)
|
||||||
|
const boundedTop = trackPadding + Math.max(0, Math.min(top, maxThumbTop))
|
||||||
|
|
||||||
|
setThumbHeight(height)
|
||||||
|
setThumbTop(boundedTop)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
if (local.viewportRef) {
|
||||||
|
local.viewportRef(viewportRef)
|
||||||
|
}
|
||||||
|
|
||||||
|
const observer = new ResizeObserver(() => {
|
||||||
|
updateThumb()
|
||||||
|
})
|
||||||
|
|
||||||
|
observer.observe(viewportRef)
|
||||||
|
// Also observe the first child if possible to catch content changes
|
||||||
|
if (viewportRef.firstElementChild) {
|
||||||
|
observer.observe(viewportRef.firstElementChild)
|
||||||
|
}
|
||||||
|
|
||||||
|
onCleanup(() => {
|
||||||
|
observer.disconnect()
|
||||||
|
})
|
||||||
|
|
||||||
|
updateThumb()
|
||||||
|
})
|
||||||
|
|
||||||
|
let startY = 0
|
||||||
|
let startScrollTop = 0
|
||||||
|
|
||||||
|
const onThumbPointerDown = (e: PointerEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
setIsDragging(true)
|
||||||
|
startY = e.clientY
|
||||||
|
startScrollTop = viewportRef.scrollTop
|
||||||
|
|
||||||
|
thumbRef.setPointerCapture(e.pointerId)
|
||||||
|
|
||||||
|
const onPointerMove = (e: PointerEvent) => {
|
||||||
|
const deltaY = e.clientY - startY
|
||||||
|
const { scrollHeight, clientHeight } = viewportRef
|
||||||
|
const maxScrollTop = scrollHeight - clientHeight
|
||||||
|
const maxThumbTop = clientHeight - thumbHeight()
|
||||||
|
|
||||||
|
if (maxThumbTop > 0) {
|
||||||
|
const scrollDelta = deltaY * (maxScrollTop / maxThumbTop)
|
||||||
|
viewportRef.scrollTop = startScrollTop + scrollDelta
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onPointerUp = (e: PointerEvent) => {
|
||||||
|
setIsDragging(false)
|
||||||
|
thumbRef.releasePointerCapture(e.pointerId)
|
||||||
|
thumbRef.removeEventListener("pointermove", onPointerMove)
|
||||||
|
thumbRef.removeEventListener("pointerup", onPointerUp)
|
||||||
|
}
|
||||||
|
|
||||||
|
thumbRef.addEventListener("pointermove", onPointerMove)
|
||||||
|
thumbRef.addEventListener("pointerup", onPointerUp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keybinds implementation
|
||||||
|
// We ensure the viewport has a tabindex so it can receive focus
|
||||||
|
// We can also explicitly catch PageUp/Down if we want smooth scroll or specific behavior,
|
||||||
|
// but native usually handles this perfectly. Let's explicitly ensure it behaves well.
|
||||||
|
const onKeyDown = (e: KeyboardEvent) => {
|
||||||
|
// If user is focused on an input inside the scroll view, don't hijack keys
|
||||||
|
if (document.activeElement && ["INPUT", "TEXTAREA", "SELECT"].includes(document.activeElement.tagName)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const scrollAmount = viewportRef.clientHeight * 0.8
|
||||||
|
const lineAmount = 40
|
||||||
|
|
||||||
|
switch (e.key) {
|
||||||
|
case "PageDown":
|
||||||
|
e.preventDefault()
|
||||||
|
viewportRef.scrollBy({ top: scrollAmount, behavior: "smooth" })
|
||||||
|
break
|
||||||
|
case "PageUp":
|
||||||
|
e.preventDefault()
|
||||||
|
viewportRef.scrollBy({ top: -scrollAmount, behavior: "smooth" })
|
||||||
|
break
|
||||||
|
case "Home":
|
||||||
|
e.preventDefault()
|
||||||
|
viewportRef.scrollTo({ top: 0, behavior: "smooth" })
|
||||||
|
break
|
||||||
|
case "End":
|
||||||
|
e.preventDefault()
|
||||||
|
viewportRef.scrollTo({ top: viewportRef.scrollHeight, behavior: "smooth" })
|
||||||
|
break
|
||||||
|
case "ArrowUp":
|
||||||
|
e.preventDefault()
|
||||||
|
viewportRef.scrollBy({ top: -lineAmount, behavior: "smooth" })
|
||||||
|
break
|
||||||
|
case "ArrowDown":
|
||||||
|
e.preventDefault()
|
||||||
|
viewportRef.scrollBy({ top: lineAmount, behavior: "smooth" })
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={rootRef}
|
||||||
|
class={`scroll-view ${local.class || ""}`}
|
||||||
|
style={local.style}
|
||||||
|
onPointerEnter={() => setIsHovered(true)}
|
||||||
|
onPointerLeave={() => setIsHovered(false)}
|
||||||
|
{...rest}
|
||||||
|
>
|
||||||
|
{/* Viewport */}
|
||||||
|
<div
|
||||||
|
ref={viewportRef}
|
||||||
|
class="scroll-view__viewport"
|
||||||
|
onScroll={(e) => {
|
||||||
|
updateThumb()
|
||||||
|
if (typeof events.onScroll === "function") events.onScroll(e as any)
|
||||||
|
}}
|
||||||
|
onWheel={events.onWheel as any}
|
||||||
|
onTouchStart={events.onTouchStart as any}
|
||||||
|
onTouchMove={events.onTouchMove as any}
|
||||||
|
onTouchEnd={events.onTouchEnd as any}
|
||||||
|
onTouchCancel={events.onTouchCancel as any}
|
||||||
|
onPointerDown={events.onPointerDown as any}
|
||||||
|
onClick={events.onClick as any}
|
||||||
|
tabIndex={0}
|
||||||
|
role="region"
|
||||||
|
aria-label="scrollable content"
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
onKeyDown(e)
|
||||||
|
if (typeof events.onKeyDown === "function") events.onKeyDown(e as any)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{local.children}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Thumb Overlay */}
|
||||||
|
<Show when={showThumb()}>
|
||||||
|
<div
|
||||||
|
ref={thumbRef}
|
||||||
|
onPointerDown={onThumbPointerDown}
|
||||||
|
class="scroll-view__thumb"
|
||||||
|
data-visible={isHovered() || isDragging()}
|
||||||
|
data-dragging={isDragging()}
|
||||||
|
style={{
|
||||||
|
height: `${thumbHeight()}px`,
|
||||||
|
transform: `translateY(${thumbTop()}px)`,
|
||||||
|
"z-index": 100, // ensure it displays over content
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import { Icon } from "./icon"
|
|||||||
import { LineComment, LineCommentEditor } from "./line-comment"
|
import { LineComment, LineCommentEditor } from "./line-comment"
|
||||||
import { StickyAccordionHeader } from "./sticky-accordion-header"
|
import { StickyAccordionHeader } from "./sticky-accordion-header"
|
||||||
import { Tooltip } from "./tooltip"
|
import { Tooltip } from "./tooltip"
|
||||||
|
import { ScrollView } from "./scroll-view"
|
||||||
import { useDiffComponent } from "../context/diff"
|
import { useDiffComponent } from "../context/diff"
|
||||||
import { useI18n } from "../context/i18n"
|
import { useI18n } from "../context/i18n"
|
||||||
import { getDirectory, getFilename } from "@opencode-ai/util/path"
|
import { getDirectory, getFilename } from "@opencode-ai/util/path"
|
||||||
@@ -274,13 +275,13 @@ export const SessionReview = (props: SessionReviewProps) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<ScrollView
|
||||||
data-component="session-review"
|
data-component="session-review"
|
||||||
ref={(el) => {
|
viewportRef={(el) => {
|
||||||
scroll = el
|
scroll = el
|
||||||
props.scrollRef?.(el)
|
props.scrollRef?.(el)
|
||||||
}}
|
}}
|
||||||
onScroll={props.onScroll}
|
onScroll={props.onScroll as any}
|
||||||
classList={{
|
classList={{
|
||||||
...(props.classList ?? {}),
|
...(props.classList ?? {}),
|
||||||
[props.classes?.root ?? ""]: !!props.classes?.root,
|
[props.classes?.root ?? ""]: !!props.classes?.root,
|
||||||
@@ -709,6 +710,6 @@ export const SessionReview = (props: SessionReviewProps) => {
|
|||||||
</Accordion>
|
</Accordion>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</ScrollView>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,6 +44,7 @@
|
|||||||
@import "../components/select.css" layer(components);
|
@import "../components/select.css" layer(components);
|
||||||
@import "../components/spinner.css" layer(components);
|
@import "../components/spinner.css" layer(components);
|
||||||
@import "../components/switch.css" layer(components);
|
@import "../components/switch.css" layer(components);
|
||||||
|
@import "../components/scroll-view.css" layer(components);
|
||||||
@import "../components/session-review.css" layer(components);
|
@import "../components/session-review.css" layer(components);
|
||||||
@import "../components/session-turn.css" layer(components);
|
@import "../components/session-turn.css" layer(components);
|
||||||
@import "../components/sticky-accordion-header.css" layer(components);
|
@import "../components/sticky-accordion-header.css" layer(components);
|
||||||
|
|||||||
@@ -8,34 +8,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@utility session-scroller {
|
|
||||||
&::-webkit-scrollbar {
|
|
||||||
width: 10px;
|
|
||||||
height: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&::-webkit-scrollbar-track {
|
|
||||||
background: transparent;
|
|
||||||
border-radius: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&::-webkit-scrollbar-thumb {
|
|
||||||
background: var(--border-weak-base);
|
|
||||||
border-radius: 5px;
|
|
||||||
border: 3px solid transparent;
|
|
||||||
background-clip: padding-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
&::-webkit-scrollbar-thumb:hover {
|
|
||||||
background: var(--border-weak-base);
|
|
||||||
}
|
|
||||||
|
|
||||||
& {
|
|
||||||
scrollbar-width: thin;
|
|
||||||
scrollbar-color: var(--border-weak-base) transparent;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@utility badge-mask {
|
@utility badge-mask {
|
||||||
-webkit-mask-image: radial-gradient(circle 5px at calc(100% - 4px) 4px, transparent 5px, black 5.5px);
|
-webkit-mask-image: radial-gradient(circle 5px at calc(100% - 4px) 4px, transparent 5px, black 5.5px);
|
||||||
mask-image: radial-gradient(circle 5px at calc(100% - 4px) 4px, transparent 5px, black 5.5px);
|
mask-image: radial-gradient(circle 5px at calc(100% - 4px) 4px, transparent 5px, black 5.5px);
|
||||||
|
|||||||
Reference in New Issue
Block a user