import { createSignal, untrack } from "solid-js"; import { clamp } from "../../utils"; type PositionBarThickness = "narrow" | "normal"; // UI Scale changes the interaction surface, not the visual weight of the bar. const VERTICAL_FILL = { narrow: { collapsed: "w-8px", expanded: "w-16px", }, normal: { collapsed: "w-24px", expanded: "w-32px", }, } satisfies Record>; const HORIZONTAL_FILL = { narrow: "h-8px", normal: "h-24px", } satisfies Record; const POSITION_BAR_FILL = "bg-[var(--color-reader-scrollbar,var(--color-muted))]"; const POSITION_BAR_TRACK = "bg-[var(--color-reader-border,var(--color-border))]"; export function PositionBar(props: { ariaLabel: string; axis: "horizontal" | "vertical"; currentValue: number; expanded?: boolean; maxValue: number; minValue?: number; onCommit?: (value: number) => void; onInput: (value: number) => void; onPointerDown?: (event: PointerEvent) => void; pixelScale?: number; position?: "absolute" | "fixed"; reversed?: boolean; thickness?: PositionBarThickness; trackClickEnabled?: boolean; trackVisible?: boolean; visible?: boolean; visibleValueCount?: number; visibleRatio?: number; }) { const [dragging, setDragging] = createSignal(false); let track!: HTMLDivElement; let thumb!: HTMLDivElement; let dragOffset = 0; const axis = untrack(() => props.axis); const thickness = untrack(() => props.thickness ?? "normal"); const horizontal = axis === "horizontal"; const minValue = () => props.minValue ?? 1; const valueRange = () => Math.max(0, props.maxValue - minValue()); const expanded = () => Boolean(props.expanded) || dragging(); const visible = () => props.visible !== false || dragging(); const logicalPosition = () => valueRange() === 0 ? 0 : ((props.currentValue - minValue()) / valueRange()) * 100; const visualPosition = () => horizontal && props.reversed ? 100 - logicalPosition() : logicalPosition(); const thumbRatio = () => clamp( props.visibleRatio ?? (props.visibleValueCount ?? 1) / Math.max(1, valueRange() + 1), 0, 1, ); const draggable = () => valueRange() > 0 && thumbRatio() < 1; const coordinate = (event: PointerEvent): number => horizontal ? event.clientX : event.clientY; const valueAt = (pointerCoordinate: number): number => { const trackRect = track.getBoundingClientRect(); const trackStart = horizontal ? trackRect.left : trackRect.top; const trackLength = horizontal ? trackRect.width : trackRect.height; const thumbLength = horizontal ? thumb.offsetWidth : thumb.offsetHeight; const visualRatio = clamp( (pointerCoordinate - trackStart - dragOffset) / Math.max(1, trackLength - thumbLength), 0, 1, ); const ratio = horizontal && props.reversed ? 1 - visualRatio : visualRatio; return minValue() + ratio * valueRange(); }; const inputAt = (event: PointerEvent): number => { const value = valueAt(coordinate(event)); props.onInput(value); return value; }; const onPointerDown = (event: PointerEvent): void => { event.preventDefault(); event.stopPropagation(); if (!draggable()) { return; } const thumbPressed = event.target instanceof Node && thumb.contains(event.target); if (!thumbPressed && props.trackClickEnabled === false) { return; } setDragging(true); track.setPointerCapture(event.pointerId); const thumbRect = thumb.getBoundingClientRect(); dragOffset = thumbPressed ? coordinate(event) - (horizontal ? thumbRect.left : thumbRect.top) : (horizontal ? thumbRect.width : thumbRect.height) / 2; props.onPointerDown?.(event); inputAt(event); }; const onPointerMove = (event: PointerEvent): void => { if (dragging()) { inputAt(event); } }; const onPointerUp = (event: PointerEvent): void => { if (!dragging()) { return; } setDragging(false); const value = inputAt(event); track.releasePointerCapture(event.pointerId); props.onCommit?.(value); }; const onPointerCancel = (event: PointerEvent): void => { if (!dragging()) { return; } setDragging(false); track.releasePointerCapture(event.pointerId); props.onCommit?.(props.currentValue); }; const stopClick = (event: MouseEvent): void => event.stopPropagation(); const stopContextMenu = (event: MouseEvent): void => { event.preventDefault(); event.stopPropagation(); }; const stopWheel = (event: WheelEvent): void => event.stopPropagation(); const renderHorizontal = () => (
); const interactionSize = () => expanded() ? "ui-hit-w-sm" : "ui-w-xl"; const fillSize = () => expanded() ? VERTICAL_FILL[thickness].expanded : VERTICAL_FILL[thickness].collapsed; const renderVertical = () => (
); return <>{horizontal ? renderHorizontal() : renderVertical()}; }