Improved scrollbar control

This commit is contained in:
Igor Zinken
2026-03-01 18:19:54 +01:00
parent b9daa84f9a
commit 6a43e04db3

View File

@@ -1,7 +1,7 @@
/**
* The MIT License (MIT)
*
* Igor Zinken 2020-2025 - https://www.igorski.nl
* Igor Zinken 2020-2026 - https://www.igorski.nl
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
@@ -23,25 +23,25 @@
<template>
<div>
<div
v-if="canScrollHorizontally"
ref="horScroll"
class="scroll scroll--horizontal"
@mousedown="handlePointerDown"
@touchstart="handlePointerDown"
>
<div
v-if="canScrollHorizontally"
class="scroll__handle"
:style="horHandleStyle"
></div>
</div>
<div
v-if="canScrollVertically"
ref="verScroll"
class="scroll scroll--vertical"
@mousedown="handlePointerDown"
@touchstart="handlePointerDown"
>
<div
v-if="canScrollVertically"
class="scroll__handle"
:style="verHandleStyle"
></div>
@@ -51,10 +51,38 @@
<script lang="ts">
import { mapState } from "vuex";
import { type Coordinate } from "zcanvas";
const MOVE_EVENTS = [ "mousemove", "touchmove", "wheel" ];
const UP_EVENTS = [ "mouseup", "touchend", "touchcancel" ];
/**
* Wrapper to get uniform coordinates from a pointer / touch event
* regardless whether it was initiated by mouse pointer or on a touchscreen/pad.
* By use of preventDefault a side effect can be introduced to stop the default
* behaviour on touch events (as in the context of scrollbar panning it could
* trigger a page bounce effect)
*/
function getCoordinate( e: PointerEvent | TouchEvent, preventDefault = true ): Coordinate {
let x = e.clientX;
let y = e.clientY;
// touch event handling
const touches = e.touches || e.changedTouches;
if ( touches && touches.length ) {
x = touches[ 0 ].pageX;
y = touches[ 0 ].pageY;
if ( preventDefault ) {
e.preventDefault();
}
}
return { x, y };
}
function clamp( value: number ) {
return Math.min( 1, Math.max( 0, value ));
}
export default {
emits: [ "input" ],
props: {
@@ -86,8 +114,8 @@ export default {
horHandleSize: 0,
verHandlePos: 0,
verHandleSize: 0,
x: 0, // the "scrollLeft" position
y: 0, // the "scrollTop" position
x: 0.5, // the "scrollLeft" position, normalized 0 - 1 range
y: 0.5, // the "scrollTop" position, normalized 0 - 1 range
}),
computed: {
...mapState([
@@ -110,12 +138,26 @@ export default {
},
canScrollVertically(): boolean {
return this.scrollHeight > 0;
}
},
},
watch: {
windowSize(): void {
this.calcDimensions();
},
canScrollHorizontally( value: boolean ): void {
if ( value ) {
this.$nextTick(() => {
this.calcDimensions();
});
}
},
canScrollVertically( value: boolean ): void {
if ( value ) {
this.$nextTick(() => {
this.calcDimensions();
});
}
},
},
mounted(): void {
this.upHandler = this.handlePointerUp.bind( this );
@@ -128,19 +170,29 @@ export default {
},
methods: {
calcDimensions(): void {
this.trackWidth = this.$refs.horScroll.offsetWidth;
this.trackHeight = this.$refs.verScroll.offsetHeight;
this.trackWidth = this.$refs.horScroll?.offsetWidth ?? this.viewportWidth;
this.trackHeight = this.$refs.verScroll?.offsetHeight ?? this.viewportHeight;
this.scrollWidth = this.contentWidth - this.viewportWidth;
this.scrollHeight = this.contentHeight - this.viewportHeight;
this.horHandleSize = ( this.viewportWidth / this.contentWidth ) * this.trackWidth;
this.verHandleSize = ( this.viewportHeight / this.contentHeight ) * this.trackHeight;
this.$nextTick(() => {
this.positionHorizontalHandle();
this.positionVerticalHandle();
});
},
handlePointerDown( e: PointerEvent ): void {
this.hasHorizontalScroll = e.target === this.$refs.horScroll;
this.hasVerticalScroll = e.target === this.$refs.verScroll;
this.startOffset = getCoordinate( e );
this.xAtStart = Math.max( 0.05, this.x );
this.yAtStart = Math.max( 0.05, this.y );
this.hasScrolled = false;
UP_EVENTS.forEach( type => {
window.addEventListener( type, this.upHandler );
});
@@ -148,48 +200,57 @@ export default {
window.addEventListener( type, this.moveHandler, { passive: false });
});
},
handlePointerUp(): void {
this.hasHorizontalScroll = false;
this.hasVerticalScroll = false;
handlePointerUp( e: PointerEvent | TouchEvent ): void {
UP_EVENTS.forEach( type => {
window.removeEventListener( type, this.upHandler );
});
MOVE_EVENTS.forEach( type => {
window.removeEventListener( type, this.moveHandler );
});
if ( !this.hasScrolled && !( e instanceof TouchEvent )) {
if ( this.hasHorizontalScroll ) {
this.x = clamp( e.offsetX / this.trackWidth );
this.positionHorizontalHandle();
}
if ( this.hasVerticalScroll ) {
this.y = clamp( e.offsetY / this.trackHeight );
this.positionVerticalHandle();
}
this.$emit( "input", { left: this.x, top: this.y });
}
},
handleScroll( e: PointerEvent | TouchEvent ): void {
if ( e.target !== this.$refs.horScroll && e.target !== this.$refs.verScroll ) {
return;
}
let pointerX = e.offsetX;
let pointerY = e.offsetY;
const { x, y } = getCoordinate( e );
// touch event handling
const touches = e.touches || e.changedTouches;
if ( touches && touches.length ) {
pointerX = touches[ 0 ].pageX;
pointerY = touches[ 0 ].pageY;
e.preventDefault(); // prevent page bounce
}
// the coordinate values here are in normalized 0 - 1 range
let targetX = this.xAtStart / this.startOffset.x * x;
let targetY = this.yAtStart / this.startOffset.y * y;
if ( this.hasHorizontalScroll ) {
this.x = Math.max( 0, pointerX / this.trackWidth ); // is % of total
this.x = clamp( targetX );
this.positionHorizontalHandle();
}
if ( this.hasVerticalScroll ) {
this.y = Math.max( 0, pointerY / this.trackHeight ); // is % of total
this.y = clamp( targetY );
this.positionVerticalHandle();
}
this.hasScrolled = true;
this.$emit( "input", { left: this.x, top: this.y });
},
update( left: number, top: number ): void {
this.x = isNaN( left ) ? 0 : left;
this.y = isNaN( top ) ? 0 : top;
update( left: number, top: number, emit = false ): void {
this.x = isNaN( left ) ? 0 : clamp( left );
this.y = isNaN( top ) ? 0 : clamp( top );
this.positionHorizontalHandle();
this.positionVerticalHandle();
this.$nextTick(() => {
this.positionHorizontalHandle();
this.positionVerticalHandle();
if ( emit ) {
this.$emit( "input", { left: this.x, top: this.y });
}
});
},
positionHorizontalHandle(): void {
this.horHandlePos = ( this.trackWidth - this.horHandleSize ) * this.x;