@weft-js/scroll 0.0.0-stage → 0.1.5-nightly.20261111.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,91 @@
1
+ // Autoscroll: holding a text selection past the edge, or dragging something over the edge, scrolls toward it, faster
2
+ // the further out the pointer is. The selection follows the content as it moves, as it does natively.
3
+ import type { Engine } from './engine'
4
+ import type { Axis } from './types'
5
+
6
+ /** px per ms for a pointer `out` px past an edge. */
7
+ export const autoSpeed = (out: number) =>
8
+ out ? Math.sign(out) * Math.min(4, Math.max(0.15, Math.abs(out) / 25)) : 0
9
+
10
+ /** How far `p` is outside `[lo, hi]`, signed. */
11
+ const outside = (p: number, lo: number, hi: number) => (p < lo ? p - lo : p > hi ? p - hi : 0)
12
+
13
+ type CaretDoc = Document & {
14
+ caretPositionFromPoint?: (x: number, y: number) => { offsetNode: Node; offset: number } | null
15
+ caretRangeFromPoint?: (x: number, y: number) => Range | null
16
+ }
17
+
18
+ /** The caret under a point: Chrome and Firefox have the standard call, Safari the older one. */
19
+ function caretAt(x: number, y: number) {
20
+ const doc = document as CaretDoc
21
+ const pos = doc.caretPositionFromPoint?.(x, y)
22
+ if (pos) return pos
23
+ const r = doc.caretRangeFromPoint?.(x, y)
24
+ return r ? { offsetNode: r.startContainer, offset: r.startOffset } : null
25
+ }
26
+
27
+ /** Listen for held selections and drags over the viewport. Returns the cleanup. */
28
+ export function autoscroll(e: Engine) {
29
+ let point: { x: number; y: number } | null = null
30
+ const set = (axis: Axis, v: number) => e.auto(axis, e.o.autoscroll ? v : 0)
31
+ const halt = () => {
32
+ point = null
33
+ set('x', 0)
34
+ set('y', 0)
35
+ }
36
+ e.afterAuto = () => {
37
+ if (!point) return
38
+ const r = e.vp.getBoundingClientRect()
39
+ const x = Math.min(r.right - 2, Math.max(r.left + 2, point.x))
40
+ const y = Math.min(r.bottom - 2, Math.max(r.top + 2, point.y))
41
+ const sel = getSelection()
42
+ const at = caretAt(x, y)
43
+ if (sel && at && sel.rangeCount && e.vp.contains(at.offsetNode))
44
+ sel.extend(at.offsetNode, at.offset)
45
+ }
46
+
47
+ const move = (ev: PointerEvent) => {
48
+ if (!(ev.buttons & 1)) return up()
49
+ const sel = getSelection()
50
+ if (!sel || sel.isCollapsed) return (set('x', 0), set('y', 0))
51
+ const r = e.vp.getBoundingClientRect()
52
+ point = { x: ev.clientX, y: ev.clientY }
53
+ set('x', autoSpeed(outside(ev.clientX, r.left, r.right)))
54
+ set('y', autoSpeed(outside(ev.clientY, r.top, r.bottom)))
55
+ }
56
+ const up = () => {
57
+ halt()
58
+ removeEventListener('pointermove', move)
59
+ removeEventListener('pointerup', up)
60
+ }
61
+ const down = (ev: PointerEvent) => {
62
+ if (ev.pointerType !== 'mouse' || ev.button !== 0 || e.o.grab) return
63
+ addEventListener('pointermove', move)
64
+ addEventListener('pointerup', up)
65
+ }
66
+
67
+ let dragTimer: ReturnType<typeof setTimeout> | undefined
68
+ const over = (ev: DragEvent) => {
69
+ const r = e.vp.getBoundingClientRect()
70
+ const band = Math.min(40, r.height / 4, r.width / 4)
71
+ const edge = (p: number, lo: number, hi: number) =>
72
+ p < lo + band ? p - lo - band : p > hi - band ? p - hi + band : 0
73
+ point = null
74
+ set('x', autoSpeed(edge(ev.clientX, r.left, r.right)))
75
+ set('y', autoSpeed(edge(ev.clientY, r.top, r.bottom)))
76
+ clearTimeout(dragTimer)
77
+ dragTimer = setTimeout(halt, 150)
78
+ }
79
+
80
+ e.vp.addEventListener('pointerdown', down)
81
+ e.vp.addEventListener('dragover', over)
82
+ e.vp.addEventListener('drop', halt)
83
+ return () => {
84
+ clearTimeout(dragTimer)
85
+ up()
86
+ e.afterAuto = null
87
+ e.vp.removeEventListener('pointerdown', down)
88
+ e.vp.removeEventListener('dragover', over)
89
+ e.vp.removeEventListener('drop', halt)
90
+ }
91
+ }
package/src/axis.ts ADDED
@@ -0,0 +1,59 @@
1
+ // One axis of a scroller: where it is, how far it can go, what is animating it, and its bar's geometry.
2
+ import type { Axis, Easing } from './types'
3
+
4
+ export type Anim =
5
+ | { kind: 'tween'; from: number; to: number; start: number; dur: number; ease: Easing }
6
+ | { kind: 'coast'; v: number }
7
+ | { kind: 'spring'; x0: number; v0: number; start: number }
8
+ | { kind: 'auto'; v: number }
9
+
10
+ /** One axis: positions are physical (`scrollLeft` runs negative in right-to-left content). */
11
+ export interface AxisState {
12
+ axis: Axis
13
+ on: boolean
14
+ pos: number
15
+ written: number
16
+ min: number
17
+ max: number
18
+ view: number
19
+ total: number
20
+ over: number
21
+ v: number
22
+ anim: Anim | null
23
+ track: HTMLElement | null
24
+ trackLen: number
25
+ trackCross: number
26
+ thumbOff: number
27
+ thumbLen: number
28
+ grow: number
29
+ hover: boolean
30
+ active: boolean
31
+ drawn: string
32
+ aria: number
33
+ points: number[]
34
+ }
35
+
36
+ export const axisState = (axis: Axis): AxisState => ({
37
+ axis,
38
+ on: true,
39
+ pos: 0,
40
+ written: 0,
41
+ min: 0,
42
+ max: 0,
43
+ view: 0,
44
+ total: 0,
45
+ over: 0,
46
+ v: 0,
47
+ anim: null,
48
+ track: null,
49
+ trackLen: 0,
50
+ trackCross: 0,
51
+ thumbOff: 0,
52
+ thumbLen: 0,
53
+ grow: 0,
54
+ hover: false,
55
+ active: false,
56
+ drawn: '',
57
+ aria: -1,
58
+ points: [],
59
+ })
@@ -0,0 +1,104 @@
1
+ // Pointer input on the bars: drag the thumb, press the track to page (held, it repeats until the thumb reaches the
2
+ // pointer) or to jump, and hover. Hit-testing uses the engine's geometry, so a canvas bar works the same as a DOM one.
3
+ import { dragRatio, progressAt } from './bar'
4
+ import type { AxisState, Engine } from './engine'
5
+ import type { Visibility } from './render'
6
+ import { pageStep } from './wheel'
7
+
8
+ /** Listen on each bar's track. Returns the cleanup. */
9
+ export function barInput(e: Engine, vis: Visibility) {
10
+ const offs: (() => void)[] = []
11
+ for (const a of e.axes) {
12
+ const track = a.track
13
+ if (!track) continue
14
+ const along = (ev: PointerEvent) => {
15
+ const r = track.getBoundingClientRect()
16
+ return a.axis === 'y' ? ev.clientY - r.top : ev.clientX - r.left
17
+ }
18
+ const hover = (on: boolean) => () => {
19
+ a.hover = on
20
+ vis.apply()
21
+ e.wake()
22
+ }
23
+ const enter = hover(true)
24
+ const leave = hover(false)
25
+ const down = (ev: PointerEvent) => {
26
+ if (ev.button !== 0) return
27
+ ev.preventDefault()
28
+ const at = along(ev)
29
+ const c = { e, vis, a, track, along }
30
+ if (at >= a.thumbOff && at <= a.thumbOff + a.thumbLen) drag(c, ev, at)
31
+ else press(c, ev, at)
32
+ }
33
+ track.addEventListener('pointerenter', enter)
34
+ track.addEventListener('pointerleave', leave)
35
+ track.addEventListener('pointerdown', down)
36
+ offs.push(() => {
37
+ track.removeEventListener('pointerenter', enter)
38
+ track.removeEventListener('pointerleave', leave)
39
+ track.removeEventListener('pointerdown', down)
40
+ })
41
+ }
42
+ return () => offs.forEach((f) => f())
43
+ }
44
+
45
+ /** One bar's pointer handling: the engine, its axis, the track and where a pointer is along it. */
46
+ interface Bar {
47
+ e: Engine
48
+ vis: Visibility
49
+ a: AxisState
50
+ track: HTMLElement
51
+ along: (ev: PointerEvent) => number
52
+ }
53
+
54
+ function drag({ e, vis, a, track, along }: Bar, ev: PointerEvent, at: number) {
55
+ const start = a.pos
56
+ const ratio = dragRatio(a.trackLen, a.thumbLen, a.max - a.min)
57
+ track.setPointerCapture?.(ev.pointerId)
58
+ a.active = true
59
+ vis.apply()
60
+ const move = (m: PointerEvent) => e.moveTo(a.axis, start + (along(m) - at) * ratio, 'bar')
61
+ const up = () => {
62
+ a.active = false
63
+ vis.apply()
64
+ e.settle(a.axis)
65
+ e.wake()
66
+ track.removeEventListener('pointermove', move)
67
+ track.removeEventListener('pointerup', up)
68
+ track.removeEventListener('pointercancel', up)
69
+ }
70
+ track.addEventListener('pointermove', move)
71
+ track.addEventListener('pointerup', up)
72
+ track.addEventListener('pointercancel', up)
73
+ }
74
+
75
+ function press({ e, a, track, along }: Bar, ev: PointerEvent, at: number) {
76
+ const jump = (e.o.bar.trackClick === 'jump') !== ev.altKey
77
+ if (jump) {
78
+ const p = progressAt(a.trackLen, a.thumbLen, at - e.o.bar.inset)
79
+ return e.tweenTo(a.axis, a.min + p * (a.max - a.min), 'bar')
80
+ }
81
+ let pointer = at
82
+ const dir = at < a.thumbOff ? -1 : 1
83
+ const page = () => {
84
+ const before = pointer < a.thumbOff ? -1 : pointer > a.thumbOff + a.thumbLen ? 1 : 0
85
+ if (before === dir) e.tweenBy(a.axis, dir * pageStep(a.view), 'bar')
86
+ else stop()
87
+ }
88
+ let timer: ReturnType<typeof setTimeout> = setTimeout(function repeat() {
89
+ page()
90
+ timer = setTimeout(repeat, 60)
91
+ }, 350)
92
+ track.setPointerCapture?.(ev.pointerId)
93
+ const move = (m: PointerEvent) => (pointer = along(m))
94
+ const stop = () => {
95
+ clearTimeout(timer)
96
+ track.removeEventListener('pointermove', move)
97
+ track.removeEventListener('pointerup', stop)
98
+ track.removeEventListener('pointercancel', stop)
99
+ }
100
+ track.addEventListener('pointermove', move)
101
+ track.addEventListener('pointerup', stop)
102
+ track.addEventListener('pointercancel', stop)
103
+ e.tweenBy(a.axis, dir * pageStep(a.view), 'bar')
104
+ }
package/src/bar.ts ADDED
@@ -0,0 +1,29 @@
1
+ // Scrollbar geometry: how long the thumb is and where it sits, and the inverse for a drag. Pure.
2
+ import type { ThumbOptions } from './types'
3
+
4
+ /** The thumb's length for a `track` px track over a `view` px window into `total` px of content. */
5
+ export function thumbLength(track: number, view: number, total: number, o: ThumbOptions = {}) {
6
+ if (track <= 0) return 0
7
+ if (o.size !== undefined) return Math.min(track, Math.max(0, o.size))
8
+ const min = Math.min(track, o.min ?? 24)
9
+ const max = Math.min(track, o.max ?? track)
10
+ const proportional = total > 0 ? (track * view) / total : track
11
+ return Math.max(min, Math.min(max, proportional))
12
+ }
13
+
14
+ /** The thumb's offset along the track for scroll `progress` 0..1. */
15
+ export function thumbOffset(track: number, length: number, progress: number) {
16
+ return Math.max(0, track - length) * Math.min(1, Math.max(0, progress))
17
+ }
18
+
19
+ /** How many px of scroll one px of thumb drag moves. */
20
+ export function dragRatio(track: number, length: number, max: number) {
21
+ const room = track - length
22
+ return room > 0 ? max / room : 0
23
+ }
24
+
25
+ /** The progress that centres the thumb on `at` px along the track: where a jump-click lands. */
26
+ export function progressAt(track: number, length: number, at: number) {
27
+ const room = track - length
28
+ return room > 0 ? Math.min(1, Math.max(0, (at - length / 2) / room)) : 0
29
+ }
package/src/canvas.ts ADDED
@@ -0,0 +1,111 @@
1
+ // The canvas renderer: each bar paints into its own canvas, sized to the track at the device's pixel ratio. Colours
2
+ // may be any CSS colour, `color-mix()` and `currentColor` included: they are resolved through the track's style.
3
+ import { BAR_VARS } from './options'
4
+ import type { BarFrame, Renderer } from './types'
5
+
6
+ /** Paints one bar. `paint` is the default look, for a draw that adds to it rather than replacing it. */
7
+ export type DrawBar = (
8
+ ctx: CanvasRenderingContext2D,
9
+ frame: BarFrame,
10
+ paint: (ctx: CanvasRenderingContext2D, frame: BarFrame) => void,
11
+ ) => void
12
+
13
+ /** Options for the canvas renderer. */
14
+ export interface CanvasOptions {
15
+ /** Your own painting. Default: rounded track and thumb in the bar's colours. */
16
+ draw?: DrawBar
17
+ }
18
+
19
+ interface Painted {
20
+ canvas: HTMLCanvasElement
21
+ ctx: CanvasRenderingContext2D
22
+ bar: BarFrame['bar'] | null
23
+ dark: boolean
24
+ colors: BarFrame['bar'] | null
25
+ }
26
+ const canvases = new WeakMap<HTMLElement, Painted>()
27
+ const COLORS = ['color', 'hoverColor', 'activeColor', 'track', 'hoverTrack'] as const
28
+ let scheme: MediaQueryList | null | undefined
29
+ const dark = () =>
30
+ !!(scheme ??=
31
+ typeof matchMedia === 'function' ? matchMedia('(prefers-color-scheme: dark)') : null)?.matches
32
+
33
+ /** A CSS colour as the canvas can take it: `color-mix()`, `currentColor` and `var()` resolve against `el`. */
34
+ export function cssColor(el: HTMLElement, css: string) {
35
+ const probe = el.ownerDocument.createElement('i')
36
+ probe.style.cssText = `position:absolute;display:none`
37
+ probe.style.color = css
38
+ el.append(probe)
39
+ const out = getComputedStyle(probe).color || css
40
+ probe.remove()
41
+ return out
42
+ }
43
+
44
+ /** The bar's colours resolved once per options and colour scheme; the stylesheet's custom properties win. */
45
+ function colors(track: HTMLElement, p: Painted, bar: BarFrame['bar']) {
46
+ if (p.colors && p.bar === bar && p.dark === dark()) return p.colors
47
+ const out = { ...bar }
48
+ for (const k of COLORS) out[k] = cssColor(track, `var(${BAR_VARS[k]}, ${bar[k]})`)
49
+ p.bar = bar
50
+ p.dark = dark()
51
+ p.colors = out
52
+ return out
53
+ }
54
+
55
+ /** The default look: the track (when it has a colour) and a rounded thumb that thickens on hover. */
56
+ export function paintBar(ctx: CanvasRenderingContext2D, f: BarFrame) {
57
+ const { bar, thumb } = f
58
+ ctx.fillStyle = f.hover || f.active ? bar.hoverTrack : bar.track
59
+ ctx.fillRect(0, 0, f.track.width, f.track.height)
60
+ ctx.fillStyle = f.active ? bar.activeColor : f.hover ? bar.hoverColor : bar.color
61
+ const r = bar.radius * (Math.min(thumb.width, thumb.height) / bar.width)
62
+ ctx.beginPath()
63
+ ctx.roundRect(
64
+ thumb.x,
65
+ thumb.y,
66
+ thumb.width,
67
+ thumb.height,
68
+ Math.max(0, Math.min(r, thumb.width / 2, thumb.height / 2)),
69
+ )
70
+ ctx.fill()
71
+ }
72
+
73
+ /** A renderer that paints the bars on canvas. Pass it as `renderer`. */
74
+ export function canvas(o: CanvasOptions = {}): Renderer {
75
+ return {
76
+ animates: true,
77
+ mount(track) {
78
+ const c = document.createElement('canvas')
79
+ c.dataset.part = 'canvas'
80
+ c.setAttribute('aria-hidden', 'true')
81
+ c.style.cssText =
82
+ 'position:absolute;inset:0;inline-size:100%;block-size:100%;pointer-events:none'
83
+ track.querySelector('[data-part="thumb"]')?.setAttribute('hidden', '')
84
+ track.append(c)
85
+ const ctx = c.getContext('2d')
86
+ if (ctx) canvases.set(track, { canvas: c, ctx, bar: null, dark: false, colors: null })
87
+ },
88
+ draw(track, f) {
89
+ const got = canvases.get(track)
90
+ if (!got) return
91
+ const { canvas: c, ctx } = got
92
+ const frame = { ...f, bar: colors(track, got, f.bar) }
93
+ const dpr = globalThis.devicePixelRatio || 1
94
+ const w = Math.round(f.track.width * dpr)
95
+ const h = Math.round(f.track.height * dpr)
96
+ if (c.width !== w || c.height !== h) {
97
+ c.width = w
98
+ c.height = h
99
+ }
100
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
101
+ ctx.clearRect(0, 0, f.track.width, f.track.height)
102
+ if (o.draw) o.draw(ctx, frame, paintBar)
103
+ else paintBar(ctx, frame)
104
+ },
105
+ unmount(track) {
106
+ canvases.get(track)?.canvas.remove()
107
+ canvases.delete(track)
108
+ track.querySelector('[data-part="thumb"]')?.removeAttribute('hidden')
109
+ },
110
+ }
111
+ }