|
<script lang="ts" context="module"> |
|
export type ScrollbarDirection = "Horizontal" | "Vertical"; |
|
</script> |
|
|
|
<script lang="ts"> |
|
import { createEventDispatcher } from "svelte"; |
|
|
|
import { PRESS_REPEAT_DELAY_MS, PRESS_REPEAT_INTERVAL_MS, PRESS_REPEAT_INTERVAL_RAPID_MS } from "@graphite/io-managers/input"; |
|
|
|
const ARROW_CLICK_DISTANCE = 0.05; |
|
const ARROW_REPEAT_DISTANCE = 0.01; |
|
|
|
|
|
|
|
const lerp = (a: number, b: number, t: number): number => a * (1 - t) + b * t; |
|
const thumbToTrack = (thumbLength: number, thumbPosition: number): number => lerp(thumbLength / 2, 1 - thumbLength / 2, thumbPosition); |
|
|
|
const pointerPosition = (e: PointerEvent): number => (direction === "Vertical" ? e.clientY : e.clientX); |
|
|
|
const clamp01 = (value: number): number => Math.min(Math.max(value, 0), 1); |
|
|
|
const dispatch = createEventDispatcher<{ trackShift: number; thumbPosition: number; thumbDragStart: undefined; thumbDragEnd: undefined; thumbDragAbort: undefined }>(); |
|
|
|
export let direction: ScrollbarDirection = "Vertical"; |
|
export let thumbPosition = 0.5; |
|
export let thumbLength = 0.5; |
|
|
|
let scrollTrack: HTMLDivElement | undefined; |
|
let dragging = false; |
|
let pressingTrack = false; |
|
let pressingArrow = false; |
|
let repeatTimeout: ReturnType<typeof setTimeout> | undefined = undefined; |
|
let pointerPositionLastFrame = 0; |
|
let thumbTop: string | undefined = undefined; |
|
let thumbBottom: string | undefined = undefined; |
|
let thumbLeft: string | undefined = undefined; |
|
let thumbRight: string | undefined = undefined; |
|
|
|
$: start = thumbToTrack(thumbLength, thumbPosition) - thumbLength / 2; |
|
$: end = 1 - thumbToTrack(thumbLength, thumbPosition) - thumbLength / 2; |
|
$: [thumbTop, thumbBottom, thumbLeft, thumbRight] = direction === "Vertical" ? [`${start * 100}%`, `${end * 100}%`, "0%", "0%"] : ["0%", "0%", `${start * 100}%`, `${end * 100}%`]; |
|
|
|
function trackLength(): number | undefined { |
|
if (scrollTrack === undefined) return undefined; |
|
return direction === "Vertical" ? scrollTrack.clientHeight - thumbLength : scrollTrack.clientWidth; |
|
} |
|
|
|
function trackOffset(): number | undefined { |
|
if (scrollTrack === undefined) return undefined; |
|
return direction === "Vertical" ? scrollTrack.getBoundingClientRect().top : scrollTrack.getBoundingClientRect().left; |
|
} |
|
|
|
function dragThumb(e: PointerEvent) { |
|
if (dragging) return; |
|
|
|
dragging = true; |
|
dispatch("thumbDragStart"); |
|
pointerPositionLastFrame = pointerPosition(e); |
|
|
|
addEvents(); |
|
} |
|
|
|
function pressArrow(direction: number) { |
|
const sendMove = () => { |
|
if (!pressingArrow) return; |
|
|
|
const distance = afterInitialDelay ? ARROW_REPEAT_DISTANCE : ARROW_CLICK_DISTANCE; |
|
dispatch("trackShift", -direction * distance); |
|
|
|
if (afterInitialDelay) repeatTimeout = setTimeout(sendMove, PRESS_REPEAT_INTERVAL_RAPID_MS); |
|
afterInitialDelay = true; |
|
}; |
|
|
|
pressingArrow = true; |
|
dispatch("thumbDragStart"); |
|
let afterInitialDelay = false; |
|
sendMove(); |
|
repeatTimeout = setTimeout(sendMove, PRESS_REPEAT_DELAY_MS); |
|
|
|
addEvents(); |
|
} |
|
|
|
function pressTrack(e: PointerEvent) { |
|
if (dragging) return; |
|
|
|
const length = trackLength(); |
|
const offset = trackOffset(); |
|
if (length === undefined || offset === undefined) return; |
|
|
|
const sendMove = () => { |
|
if (!pressingTrack) return; |
|
|
|
const oldPointer = thumbToTrack(thumbLength, thumbPosition) * length + offset; |
|
const newPointer = pointerPosition(e); |
|
|
|
|
|
const proposedThumbPosition = (newPointer - offset) / length; |
|
if (proposedThumbPosition >= start && proposedThumbPosition <= 1 - end) { |
|
|
|
pressingTrack = false; |
|
clearTimeout(repeatTimeout); |
|
|
|
|
|
dragging = true; |
|
pointerPositionLastFrame = newPointer; |
|
|
|
return; |
|
} |
|
|
|
const move = newPointer - oldPointer < 0 ? 1 : -1; |
|
dispatch("trackShift", move); |
|
|
|
if (afterInitialDelay) repeatTimeout = setTimeout(sendMove, PRESS_REPEAT_INTERVAL_MS); |
|
afterInitialDelay = true; |
|
}; |
|
|
|
dispatch("thumbDragStart"); |
|
pressingTrack = true; |
|
let afterInitialDelay = false; |
|
sendMove(); |
|
repeatTimeout = setTimeout(sendMove, PRESS_REPEAT_DELAY_MS); |
|
|
|
addEvents(); |
|
} |
|
|
|
function abortInteraction() { |
|
if (pressingTrack || pressingArrow) { |
|
pressingTrack = false; |
|
pressingArrow = false; |
|
clearTimeout(repeatTimeout); |
|
dispatch("thumbDragAbort"); |
|
} |
|
|
|
if (dragging) { |
|
dragging = false; |
|
dispatch("thumbDragAbort"); |
|
} |
|
} |
|
|
|
function onPointerUp() { |
|
if (dragging) dispatch("thumbDragEnd"); |
|
|
|
dragging = false; |
|
pressingTrack = false; |
|
pressingArrow = false; |
|
clearTimeout(repeatTimeout); |
|
removeEvents(); |
|
} |
|
|
|
function onPointerMove(e: PointerEvent) { |
|
if (pressingTrack) { |
|
return; |
|
} |
|
|
|
if (pressingArrow) { |
|
const target = e.target || undefined; |
|
if (!target || !(target instanceof Element)) return; |
|
if (!target?.closest?.("[data-scrollbar-arrow]")) { |
|
pressingArrow = false; |
|
clearTimeout(repeatTimeout); |
|
removeEvents(); |
|
} |
|
|
|
return; |
|
} |
|
|
|
if (dragging) { |
|
const length = trackLength(); |
|
if (length === undefined) return; |
|
|
|
const positionPositionThisFrame = pointerPosition(e); |
|
const dragDelta = positionPositionThisFrame - pointerPositionLastFrame; |
|
const movement = dragDelta / (length * (1 - thumbLength)); |
|
const newThumbPosition = clamp01(thumbPosition + movement); |
|
dispatch("thumbPosition", newThumbPosition); |
|
|
|
pointerPositionLastFrame = positionPositionThisFrame; |
|
|
|
return; |
|
} |
|
|
|
removeEvents(); |
|
} |
|
|
|
function onMouseDown(e: MouseEvent) { |
|
const BUTTONS_RIGHT = 0b0000_0010; |
|
if (e.buttons & BUTTONS_RIGHT) abortInteraction(); |
|
} |
|
|
|
function onKeyDown(e: KeyboardEvent) { |
|
if (e.key === "Escape") abortInteraction(); |
|
} |
|
|
|
function addEvents() { |
|
window.addEventListener("pointerup", onPointerUp); |
|
window.addEventListener("pointermove", onPointerMove); |
|
window.addEventListener("mousedown", onMouseDown); |
|
window.addEventListener("keydown", onKeyDown); |
|
} |
|
|
|
function removeEvents() { |
|
window.removeEventListener("pointerup", onPointerUp); |
|
window.removeEventListener("pointermove", onPointerMove); |
|
window.removeEventListener("mousedown", onMouseDown); |
|
window.removeEventListener("keydown", onKeyDown); |
|
} |
|
</script> |
|
|
|
<div class={`scrollbar-input ${direction.toLowerCase()}`}> |
|
<button class="arrow decrease" on:pointerdown={() => pressArrow(-1)} tabindex="-1" data-scrollbar-arrow></button> |
|
<div class="scroll-track" on:pointerdown={pressTrack} bind:this={scrollTrack}> |
|
<div class="scroll-thumb" on:pointerdown={dragThumb} class:dragging style:top={thumbTop} style:bottom={thumbBottom} style:left={thumbLeft} style:right={thumbRight} /> |
|
</div> |
|
<button class="arrow increase" on:pointerdown={() => pressArrow(1)} tabindex="-1" data-scrollbar-arrow></button> |
|
</div> |
|
|
|
<style lang="scss" global> |
|
.scrollbar-input { |
|
display: flex; |
|
flex: 1 1 100%; |
|
|
|
&.vertical { |
|
flex-direction: column; |
|
} |
|
|
|
&.horizontal { |
|
flex-direction: row; |
|
} |
|
|
|
.arrow { |
|
--arrow-color: var(--color-5-dullgray); |
|
flex: 0 0 auto; |
|
background: none; |
|
border: none; |
|
margin: 0; |
|
padding: 0; |
|
width: 16px; |
|
height: 16px; |
|
|
|
&:hover { |
|
--arrow-color: var(--color-6-lowergray); |
|
} |
|
|
|
&:hover:active { |
|
--arrow-color: var(--color-c-brightgray); |
|
} |
|
|
|
&::after { |
|
content: ""; |
|
display: block; |
|
border-style: solid; |
|
} |
|
} |
|
|
|
&.vertical .arrow.decrease::after { |
|
margin: 4px 3px; |
|
border-width: 0 5px 8px 5px; |
|
border-color: transparent transparent var(--arrow-color) transparent; |
|
} |
|
|
|
&.vertical .arrow.increase::after { |
|
margin: 4px 3px; |
|
border-width: 8px 5px 0 5px; |
|
border-color: var(--arrow-color) transparent transparent transparent; |
|
} |
|
|
|
&.horizontal .arrow.decrease::after { |
|
margin: 3px 4px; |
|
border-width: 5px 8px 5px 0; |
|
border-color: transparent var(--arrow-color) transparent transparent; |
|
} |
|
|
|
&.horizontal .arrow.increase::after { |
|
margin: 3px 4px; |
|
border-width: 5px 0 5px 8px; |
|
border-color: transparent transparent transparent var(--arrow-color); |
|
} |
|
|
|
.scroll-track { |
|
position: relative; |
|
flex: 1 1 100%; |
|
|
|
.scroll-thumb { |
|
position: absolute; |
|
border-radius: 4px; |
|
background: var(--color-5-dullgray); |
|
|
|
&:hover, |
|
&.dragging { |
|
background: var(--color-6-lowergray); |
|
} |
|
} |
|
} |
|
} |
|
</style> |
|
|