UNPKG

@desto/scrollbar-js

Version:

A script which produces stylable scrollbars. It also allows styling of the resize handle.

579 lines (455 loc) 20.9 kB
// int = px, float = % // note however that 1.0 === 1, so it will be identified as an int. to say 100%, use something very close to it, e.g. 0.9999 type TDistance = number; interface IConfig { prefix ?: string; buttonDistance ?: TDistance; trackDistance ?: TDistance; delay ?: number; repeat ?: number; } interface IScrollbarProperties { nativeHeight : number; nativeWidth : number; nativeDisplaces : boolean; supportsWebkitStyling : boolean; supportsMsHiding : boolean; supportsScrollbarWidth : boolean; } const scrollbarjs = (function(){ /* TODO: min-width/height on thumbs!!! refactor global variables textareas right to left text some way to reliably add padding-right the viewport look for a way to get padding-bottom work without the ::after workaround. It just might resolve the padding-right problem as well. - `smooth: (boolean) [=true]` should the emulated scrolling be smoothed? - `easing: (function) [=easeInOutQuad]` https://gist.github.com/gre/1650294 probably more a more attractive demo site which also explains things in its scrollable boxes */ // don't forget to change your css accordingly let configPrefix = 'scrollbarjs'; // distance to scroll when clicking let configButtonDistance: TDistance = 60; // on a button let configTrackDistance: TDistance = 0.9; // somewhere on the track let configDelay = 200; // wait x milliseconds before repeating the action let configRepeat = 50; // repeat every x milliseconds // put some global things into local scope, so uglify can better compress the source const doc = document; const $head = doc.head as HTMLHeadElement; const $body = doc.body; const createElement = doc.createElement.bind( doc ) as ( tagName: string ) => HTMLElement; const appendChild = ( $parent: HTMLElement, $child: HTMLElement ) => { $parent.appendChild( $child ); } const addEventListener = <K extends keyof HTMLElementEventMap>( $target: HTMLElement, event: K, callback: (this: HTMLElement, ev: HTMLElementEventMap[K]) => any ) => { $target.addEventListener( event, callback ); } const addClass = ( $elem: Element, className: string ) => { $elem.classList.add ( className ); } const remClass = ( $elem: Element, className: string ) => { $elem.classList.remove( className ); } // not-booleans are usually more readable in their positive form, but since this is only checked in the negative form it should be ok const isNotLeftClick = ( event: MouseEvent ) => ( event.buttons & 1 ) !== 1; // this also works for mouseenter // getPrefixed( 'viewport', 'fit-parent' ) => "configPrefix-viewport configPrefix-fit-parent" const getPrefixed = ( ...args: string[] ) => { return args.map( arg => configPrefix + '-' + arg ).join(' '); } let wasInitialized = false; const init = ( config?: IConfig ): IScrollbarProperties => { if( wasInitialized ) throw new Error('scrollbarjs cannot be initialized more than once'); if( config !== undefined ){ if( config.prefix ) configPrefix = config.prefix; if( config.buttonDistance ) configButtonDistance = config.buttonDistance; if( config.trackDistance ) configTrackDistance = config.trackDistance; if( config.delay ) configDelay = config.delay; if( config.repeat ) configRepeat = config.repeat; } // inject required CSS into the page const $style = createElement('style'); $style.innerHTML = 'body.' + configPrefix + '{' + 'height:100vh;' + // min-height would gradually grow, max-height wouldn't show a scrollbar '}' + '.' + getPrefixed('drag') + '{' + // don't trigger selection when dragging the thumbs - https://stackoverflow.com/a/4407335 '-webkit-touch-callout:none;' + '-webkit-user-select:none;' + '-khtml-user-select:none;' + '-moz-user-select:none;' + '-ms-user-select:none;' + 'user-select:none;' + '}' + '.' + configPrefix + '{' + 'overflow:hidden;' + 'position:relative;' + '}' + '.' + getPrefixed('viewport') + '{' + 'overflow:scroll;' + 'margin-right:' + -nativeWidth + 'px;' + 'margin-bottom:' + -nativeHeight + 'px;' + '}' + '.' + getPrefixed('resizable') + '{' + // resizing is done in js 'resize:none!important;' + '}'; appendChild( $head, $style ); wasInitialized = true; return { nativeHeight : nativeHeight, nativeWidth : nativeWidth, nativeDisplaces : ( nativeHeight > 0 || nativeWidth > 0 ), supportsWebkitStyling : supportsWebkitStyling, supportsMsHiding : supportsMsHiding, supportsScrollbarWidth : supportsScrollbarWidth, }; } // below this point is calculated stuff // to get the native width and height of a scrollbar, we create an element with overflow:scroll // it will be removed right afterwards when we have the details we want let nativeWidth = 0; let nativeHeight = 0; // additionally, we check for some ways to hide the scrollbar natively (without killing scrolling like overflow:hidden does) // in these cases, putting a wrapper around to hide the scrollbars would not be required let supportsWebkitStyling = false; // note that these don't seem to work on the body in mobile view let supportsMsHiding = false; let supportsScrollbarWidth = false; (function(){ // scoping to pretent it didn't happen // add styles required for this to work const $style = createElement('style'); appendChild( $head, $style ); $style.innerHTML = '#scrollbarjs-sb-size{' + 'width:100%;' + // it needs to be bigger than the scrollbar 'height:100%;' + 'overflow:scroll;' + // show scrollbars 'position:absolute;' + // don't break the flow of the document in the process 'top:100%;' + // hide '}'; // get dimensions of native scrollbars const $size = createElement('div'); $size.id = 'scrollbarjs-sb-size'; appendChild( $body, $size ); nativeWidth = $size.offsetWidth - $size.clientWidth; nativeHeight = $size.offsetHeight - $size.clientHeight; // check if native tricks are available to hide scrollbars $style.innerHTML += '#scrollbarjs-sb-size::-webkit-scrollbar{display:none}' + // webkit '#scrollbarjs-sb-size{' + '-ms-overflow-style:none;' + // ie and edge 'scrollbar-width:none;' + // firefox '}'; try{ supportsWebkitStyling = getComputedStyle( $size, '::-webkit-scrollbar' ).display === 'none'; }catch(e){ // Firefox 56 throws when checking an unknown pseudo element (NS_ERROR_NOT_AVAILABLE) } const computedStyle = getComputedStyle( $size ); supportsMsHiding = computedStyle.msOverflowStyle === 'none'; // @ts-ignore property is still new supportsScrollbarWidth = computedStyle.scrollbarWidth === 'none'; $style.parentElement.removeChild( $style ); $size .parentElement.removeChild( $size ); }()); const enum DragMode { none, scroll, resize, } let dragMode = DragMode.none; enum Axis { none, ver, hor, both, } let dragAxis = Axis.none; let $dragTarget: HTMLElement | null; // the content being scrolled const reset = () => { clearInterval( IID ); clearTimeout ( TID ); dragMode = DragMode.none; remClass( $body, getPrefixed('drag') ); if( $dragTarget ){ remClass( $dragTarget.parentElement, getPrefixed('active') ); $dragTarget = null; } } addEventListener( $body, 'mouseup', reset ); addEventListener( $body, 'mouseenter', e => { // leaving browser window and going back in, check if left mouse button is "still" pressed if( isNotLeftClick( e ) ) reset(); } ); let lastX = 0; let lastY = 0; // last x y offset let dragDistance = 0; // distance to scroll in pixels for each pixel the thumb has been moved let viewportScrollPosition = 0; // the current x or y position as a double instead of an int, used to circumvent unprecise dragging addEventListener( $body, 'mousemove', e => { if( dragMode === DragMode.scroll ){ if( dragAxis === Axis.ver ){ viewportScrollPosition += dragDistance * ( e.clientY - lastY ); scrollViewport( $dragTarget, undefined, Math.round( viewportScrollPosition ), true ); } else { viewportScrollPosition += dragDistance * ( e.clientX - lastX ); scrollViewport( $dragTarget, Math.round( viewportScrollPosition ), undefined, true ); } } else if( dragMode === DragMode.resize && dragAxis !== Axis.none ){ // inconsistent behavior between browsers when resizing centered content // chrome's way makes more sense (resize by twice the amount when centered so it keeps following the cursor properly) // but firefox' is easier to implement, as I don't need to check for all the ways the content could be centered (margin: 0 auto, flexbox, text-align,..?) // and IE11 doesn't understand resize in the first place, so only benefits there! const x = ( e.clientX - lastX ); const y = ( e.clientY - lastY ); if( dragAxis !== Axis.hor ){ // VER or BOTH const h = $dragTarget.clientHeight; $dragTarget.style.height = h + y + 'px'; // verify if height is legit, e.g. in case max-height is set $dragTarget.style.height = getComputedStyle( $dragTarget ).height; } if( dragAxis !== Axis.ver ){ // HOR or BOTH const w = $dragTarget.clientWidth; $dragTarget.style.width = w + x + 'px'; $dragTarget.style.width = getComputedStyle( $dragTarget ).width; } } lastX = e.clientX; lastY = e.clientY; } ); // event listeners enum Direction { up, right, down, left, } const scrollInDirection = ( event: MouseEvent, $viewport: HTMLElement, direction: number, distance: number ) => { if( isNotLeftClick( event ) ) return; switch( direction ){ case Direction.up : scrollViewport( $viewport, 0, -distance ); break; case Direction.right : scrollViewport( $viewport, distance, 0 ); break; case Direction.down : scrollViewport( $viewport, 0, distance ); break; case Direction.left : scrollViewport( $viewport, -distance, 0 ); break; } } const clickThumb = ( event: MouseEvent, $viewport: HTMLElement, direction: number ) => { if( isNotLeftClick( event ) ) return; addClass( $body, getPrefixed('drag') ); addClass( $viewport.parentElement, getPrefixed('active') ); const $target = (event.target as HTMLElement).parentElement; dragMode = DragMode.scroll; dragAxis = direction; $dragTarget = $viewport; if( direction === Axis.ver ){ viewportScrollPosition = $viewport.scrollTop; dragDistance = $viewport.scrollHeight / $target.clientHeight; } else { viewportScrollPosition = $viewport.scrollLeft; dragDistance = $viewport.scrollWidth / $target.clientWidth; } } const clickResize = ( event: MouseEvent, $elem: HTMLElement, direction: number ) => { if( isNotLeftClick( event ) ) return; addClass( $body, getPrefixed('drag') ); dragMode = DragMode.resize; dragAxis = direction; $dragTarget = $elem; } // helper functions const CLASS_SCROLL_NONE = getPrefixed('scroll-none'); const CLASS_SCROLL_VER = getPrefixed('scroll-ver' ); const CLASS_SCROLL_HOR = getPrefixed('scroll-hor' ); const CLASS_SCROLL_BOTH = getPrefixed('scroll-both'); const switchScrollClass = ( $elem: HTMLElement, className: string ) => { if( $elem.classList.contains( className ) ) return; remClass( $elem, CLASS_SCROLL_NONE ); remClass( $elem, CLASS_SCROLL_VER ); remClass( $elem, CLASS_SCROLL_HOR ); remClass( $elem, CLASS_SCROLL_BOTH ); addClass( $elem, className ); } let TID: number; // contains the timeout id so we can stop it let IID: number; // interval id const scrollViewport = ( $viewport: HTMLElement, x?: number, y?: number, jump?: boolean, repeat?: boolean ) => { if( jump ){ if( x !== undefined ) $viewport.scrollLeft = x; if( y !== undefined ) $viewport.scrollTop = y; } else { if( x % 1 === 0 ) $viewport.scrollLeft += x; // px else $viewport.scrollLeft += $viewport.clientWidth * x; // % if( y % 1 === 0 ) $viewport.scrollTop += y; else $viewport.scrollTop += $viewport.clientHeight * y; } if( !repeat && !jump ){ // there's no need to repeat when jumping TID = setTimeout( () => { IID = setInterval( () => { scrollViewport( $viewport, x, y, jump, true ); }, configRepeat ); }, configDelay - configRepeat ); } } // contains all the containers to watch over so we don't have to read the entire DOM every time update is called just to find our elements const $ELEMS: HTMLElement[] = []; const add = ( $elem: HTMLElement ) => { if( !wasInitialized ) throw new Error('scrollbarjs has not been initialized yet'); // this is to prevent weird behavior caused by missing CSS rules let viewportTagName: string; if( $elem.tagName === 'BODY' ){ viewportTagName = 'div'; } else if( $elem.tagName === 'TEXTAREA' ){ // TODO: make textareas work viewportTagName = 'div'; } else { viewportTagName = $elem.tagName; } const buildElement = ( class1: string, class2?: string, tagName: string = 'div' ) => { const $element = createElement(tagName); $element.className = getPrefixed(class1, class2); return $element; } const $viewport = buildElement('viewport', undefined, viewportTagName); $viewport.dataset.scrollbarjs = ' '; // a space so I don't have to do additional checks in the update function $viewport.innerHTML = $elem.innerHTML; $elem.innerHTML = ''; appendChild( $elem, $viewport ); // naming similar to https://webkit.org/blog/363/styling-scrollbars/ const buildScrollbar = (axis: 'ver' | 'hor', dir1: 'up' | 'left', dir2: 'down' | 'right') => { const buildButton = ( direction: keyof typeof Direction ) => { const $button = buildElement('button', direction); addEventListener( $button, 'mousedown', e => scrollInDirection( e, $viewport, Direction[direction], configButtonDistance ) ); return $button; } const buildTrack = ( direction: keyof typeof Direction ) => { const $track = buildElement('track-piece', 'track-' + direction); addEventListener( $track, 'mousedown', e => scrollInDirection( e, $viewport, Direction[direction], configTrackDistance ) ); return $track; } const $trackAxis = buildElement('track', 'track-' + axis); const $thumbAxis = buildElement('thumb', 'thumb-' + axis); addEventListener( $thumbAxis, 'mousedown', e => clickThumb( e, $viewport, Axis[axis] ) ); appendChild( $trackAxis, buildTrack( dir1 ) ); appendChild( $trackAxis, $thumbAxis ); appendChild( $trackAxis, buildTrack( dir2 ) ); const $axis = buildElement('scrollbar', axis, 'aside'); appendChild( $axis, buildButton( dir1 ) ); appendChild( $axis, $trackAxis ); appendChild( $axis, buildButton( dir2 ) ); return $axis; } appendChild( $elem, buildScrollbar('ver', 'up' , 'down' ) ); appendChild( $elem, buildScrollbar('hor', 'left', 'right') ); // corner const $corner = buildElement('corner'); const resize = getComputedStyle( $elem ).resize; // cannot be detected in Edge if( resize === 'both' || resize === 'horizontal' || resize === 'vertical' ){ addClass( $elem, getPrefixed('resizable') ); let axis: keyof typeof Axis; switch( resize ){ case 'vertical': axis = 'ver'; break; case 'horizontal': axis = 'hor'; break; case 'both': axis = 'both'; break; } addClass( $corner, getPrefixed('resize-' + axis) ); addEventListener( $corner, 'mousedown', e => clickResize( e, $elem, Axis[axis] ) ); } appendChild( $elem, $corner ); addClass( $elem, configPrefix ); $ELEMS.push( $elem ); } const updateViewport = ( $elem: HTMLElement, $viewport: HTMLElement ) => { // height:100% doesn't work on elements whose parents don't have an explicit value set // this is a hacky way to achieve this anyways // Firefox won't remember the scrollTop position after a reload, but I think that's bearable ;) if( $elem.clientHeight < $viewport.scrollHeight ){ // check if the element can grow const y = $viewport.scrollTop; // keep scrolling distance $viewport.style.height = ''; // try to be as big as the content if( $elem.clientHeight < $viewport.scrollHeight ) // if that doesn't work, shrink to fit $viewport.style.height = $elem.clientHeight + nativeHeight + 1 + 'px'; // +1 so the element will expand once it can, trying this procedure again else // if it did work, remove that spare 1px line at the bottom; this will prevent flickering when resizing $viewport.style.height = $elem.clientHeight + nativeHeight + 'px'; $viewport.scrollTop = y; } else { // match the element $viewport.style.height = $elem.clientHeight + nativeHeight + 'px'; } } const CLASS_BUTTON_INACTIVE = getPrefixed('button-inactive'); const updateScrollbars = ( $elem: HTMLElement, $viewport: HTMLElement ) => { // in % from 0 to 1, how much of the viewport is visible const visibleHeight = $viewport.clientHeight / $viewport.scrollHeight; const visibleWidth = $viewport.clientWidth / $viewport.scrollWidth; // which scrollbars are active if( visibleHeight === 1 && visibleWidth === 1 ) switchScrollClass( $elem, CLASS_SCROLL_NONE ); else if( visibleHeight !== 1 && visibleWidth === 1 ) switchScrollClass( $elem, CLASS_SCROLL_VER ); else if( visibleHeight === 1 && visibleWidth !== 1 ) switchScrollClass( $elem, CLASS_SCROLL_HOR ); else if( visibleHeight !== 1 && visibleWidth !== 1 ) switchScrollClass( $elem, CLASS_SCROLL_BOTH ); const getChild = ( $parent: HTMLElement, child: number, grandChild?: number ) => { let $child = $parent.children[ child ]; if( grandChild !== undefined ) $child = $child.children[ grandChild ]; return $child as HTMLElement; } // apply extra classes to indicate if a button will do something const $buttonUp = getChild( $elem, 1, 0 ); const $buttonDown = getChild( $elem, 1, 2 ); const $buttonLeft = getChild( $elem, 2, 0 ); const $buttonRight = getChild( $elem, 2, 2 ); if( $viewport.scrollTop === 0 ) addClass( $buttonUp, CLASS_BUTTON_INACTIVE ); else remClass( $buttonUp, CLASS_BUTTON_INACTIVE ); if( $viewport.scrollLeft === 0 ) addClass( $buttonLeft, CLASS_BUTTON_INACTIVE ); else remClass( $buttonLeft, CLASS_BUTTON_INACTIVE ); if( ( $viewport.scrollTop + $viewport.clientHeight ) === $viewport.scrollHeight ) addClass( $buttonDown, CLASS_BUTTON_INACTIVE ); else remClass( $buttonDown, CLASS_BUTTON_INACTIVE ); if( ( $viewport.scrollLeft + $viewport.clientWidth ) === $viewport.scrollWidth ) addClass( $buttonRight, CLASS_BUTTON_INACTIVE ); else remClass( $buttonRight, CLASS_BUTTON_INACTIVE ); // calculate thumb size const $trackVer = getChild( $elem, 1, 1 ); const $trackUp = getChild( $trackVer, 0 ); const $thumbVer = getChild( $trackVer, 1 ); const $trackDown = getChild( $trackVer, 2 ); const $trackHor = getChild( $elem, 2, 1 ); const $trackLeft = getChild( $trackHor, 0 ); const $thumbHor = getChild( $trackHor, 1 ); const $trackRight = getChild( $trackHor, 2 ); const trackHeight = $trackVer.clientHeight; const trackWidth = $trackHor.clientWidth; // height/width of track-up/track-left const trackUpHeight = Math.floor( trackHeight * ( $viewport.scrollTop / $viewport.scrollHeight ) ); const trackLeftWidth = Math.floor( trackWidth * ( $viewport.scrollLeft / $viewport.scrollWidth ) ); $trackUp .style.height = trackUpHeight + 'px'; $trackLeft.style.width = trackLeftWidth + 'px'; // height/width of the thumb const thumbHeight = Math.ceil( trackHeight * visibleHeight ); const thumbWidth = Math.ceil( trackWidth * visibleWidth ); $thumbVer.style.height = thumbHeight + 'px'; $thumbHor.style.width = thumbWidth + 'px'; // height/width of track-down/track-right const trackDownHeight = trackHeight - trackUpHeight - thumbHeight; const trackRightWidth = trackWidth - trackLeftWidth - thumbWidth; $trackDown .style.height = trackDownHeight + 'px'; $trackRight.style.width = trackRightWidth + 'px'; } const update = ( $elem: HTMLElement ) => { const $viewport = $elem.children[0] as HTMLElement; const viewport = $elem.clientHeight + ',' + $elem.clientWidth + '-' + $viewport.scrollHeight + ',' + $viewport.scrollWidth; const scrollbar = $viewport.scrollTop + ',' + $viewport.scrollLeft; const dataOld = $viewport.dataset.scrollbarjs.split(' '); if( dataOld[0] !== viewport ){ // element or content size changed updateViewport( $elem, $viewport ); updateScrollbars( $elem, $viewport ); } else if( dataOld[1] !== scrollbar ) { // only scrollbar position changed updateScrollbars( $elem, $viewport ); } else { // nothing changed, so nothing to do return; } $viewport.dataset.scrollbarjs = viewport + ' ' + scrollbar; } // keep it updated requestAnimationFrame( function watch(){ $ELEMS.forEach( update ); requestAnimationFrame( watch ); } ); return { add : add, init : init } }());