diff --git a/packages/core/build.config.ts b/packages/core/build.config.ts new file mode 100644 index 00000000..65eb64dd --- /dev/null +++ b/packages/core/build.config.ts @@ -0,0 +1,24 @@ +import { defineBuildConfig } from 'unbuild' +import pkg from './package.json' + +export default defineBuildConfig({ + entries: [ + { + input: 'src/index', + }, + { + input: 'src/style', + builder: 'mkdist', + }, + ], + declaration: true, + clean: true, + replace: { + __SCROLLBAR_VERSION__: JSON.stringify(pkg.version), + // eslint-disable-next-line @typescript-eslint/quotes + __DEV__: `(process.env.NODE_ENV !== 'production')`, + }, + rollup: { + emitCJS: true, + }, +}) diff --git a/packages/core/package.json b/packages/core/package.json new file mode 100644 index 00000000..52a58439 --- /dev/null +++ b/packages/core/package.json @@ -0,0 +1,44 @@ +{ + "name": "@smooth-scrollbar/core", + "version": "9.0.0", + "description": "Customize scrollbar in modern browsers with smooth scrolling experience.", + "author": "Dolphin Wood ", + "license": "MIT", + "homepage": "https://github.com/idiotWu/smooth-scrollbar#readme", + "repository": { + "type": "git", + "url": "git+https://github.com/idiotWu/smooth-scrollbar.git" + }, + "bugs": { + "url": "https://github.com/idiotWu/smooth-scrollbar/issues" + }, + "keywords": [ + "scrollbar", + "customize", + "acceleration", + "performance" + ], + "exports": { + ".": { + "types": "./dist/index.d.ts", + "require": "./dist/index.cjs", + "import": "./dist/index.mjs" + }, + "./style.css": "./dist/style.css", + "./package.json": "./package.json" + }, + "main": "./dist/index.cjs", + "module": "./dist/index.mjs", + "types": "./dist/index.d.ts", + "style": "./dist/style.css", + "scripts": { + "build": "unbuild", + "dev": "unbuild --stub" + }, + "dependencies": { + "@smooth-scrollbar/shared": "^9.0.0" + }, + "devDependencies": { + "tslib": "^2.5.0" + } +} diff --git a/src/decorators/boolean.ts b/packages/core/src/decorators/boolean.ts similarity index 71% rename from src/decorators/boolean.ts rename to packages/core/src/decorators/boolean.ts index fece1621..0fdffa3a 100644 --- a/src/decorators/boolean.ts +++ b/packages/core/src/decorators/boolean.ts @@ -1,19 +1,19 @@ export function boolean(proto: any, key: string) { - const alias = `_${key}`; + const alias = `_${key}` Object.defineProperty(proto, key, { get() { - return this[alias]; + return this[alias] }, - set(val?: boolean) { + set(value?: boolean) { Object.defineProperty(this, alias, { - value: !!val, + value: !!value, enumerable: false, writable: true, configurable: true, - }); + }) }, enumerable: true, configurable: true, - }); + }) } diff --git a/packages/core/src/decorators/debounce.ts b/packages/core/src/decorators/debounce.ts new file mode 100644 index 00000000..c039f3b3 --- /dev/null +++ b/packages/core/src/decorators/debounce.ts @@ -0,0 +1,20 @@ +import { debounce as $debounce } from '@smooth-scrollbar/shared' + +export function debounce(...options: any[]) { + return (_proto: any, key: string, descriptor: PropertyDescriptor) => { + const fn = descriptor.value + + return { + get(): any { + if (!Object.prototype.hasOwnProperty.call(this, key)) { + Object.defineProperty(this, key, { + value: $debounce(fn, ...options), + }) + } + + // @ts-expect-error ignore types + return this[key] + }, + } + } +} diff --git a/packages/core/src/decorators/index.ts b/packages/core/src/decorators/index.ts new file mode 100644 index 00000000..777d2f51 --- /dev/null +++ b/packages/core/src/decorators/index.ts @@ -0,0 +1,3 @@ +export * from './range' +export * from './boolean' +export * from './debounce' diff --git a/src/decorators/range.ts b/packages/core/src/decorators/range.ts similarity index 51% rename from src/decorators/range.ts rename to packages/core/src/decorators/range.ts index 5de5f1b2..511a2d0b 100644 --- a/src/decorators/range.ts +++ b/packages/core/src/decorators/range.ts @@ -1,23 +1,23 @@ -import { clamp } from '../utils'; +import { clamp } from '@smooth-scrollbar/shared' -export function range(min = -Infinity, max = Infinity) { +export function range(min = Number.NEGATIVE_INFINITY, max = Number.POSITIVE_INFINITY) { return (proto: any, key: string) => { - const alias = `_${key}`; + const alias = `_${key}` Object.defineProperty(proto, key, { get() { - return this[alias]; + return this[alias] }, - set(val: number) { + set(value: number) { Object.defineProperty(this, alias, { - value: clamp(val, min, max), + value: clamp(value, min, max), enumerable: false, writable: true, configurable: true, - }); + }) }, enumerable: true, configurable: true, - }); - }; + }) + } } diff --git a/packages/core/src/events/index.ts b/packages/core/src/events/index.ts new file mode 100644 index 00000000..2f6572fb --- /dev/null +++ b/packages/core/src/events/index.ts @@ -0,0 +1,6 @@ +export * from './keyboard' +export * from './mouse' +export * from './resize' +export * from './select' +export * from './touch' +export * from './wheel' diff --git a/packages/core/src/events/keyboard.ts b/packages/core/src/events/keyboard.ts new file mode 100644 index 00000000..4b8b9c5c --- /dev/null +++ b/packages/core/src/events/keyboard.ts @@ -0,0 +1,101 @@ +import type { Scrollbar } from '@smooth-scrollbar/shared' +import { eventScope } from '@smooth-scrollbar/shared' + +enum KEY { + TAB = 'Tab', + SPACE = ' ', + PAGE_UP = 'PageUp', + PAGE_DOWN = 'PageDown', + END = 'End', + HOME = 'Home', + LEFT = 'ArrowLeft', + UP = 'ArrowUp', + RIGHT = 'ArrowRight', + DOWN = 'ArrowDown', +} + +export function keyboardHandler(scrollbar: Scrollbar) { + const addEvent = eventScope(scrollbar) + const container = scrollbar.containerEl + + addEvent(container, 'keydown', (event: KeyboardEvent) => { + const { activeElement } = document + + if (activeElement !== container && !container.contains(activeElement)) + return + + if (isEditable(activeElement)) + return + + const delta = getKeyDelta(scrollbar, event.code) + + if (!delta) + return + + const [x, y] = delta + + scrollbar.addTransformableMomentum(x, y, event, (willScroll) => { + if (willScroll) { + event.preventDefault() + } + else { + scrollbar.containerEl.blur() + + if (scrollbar.parent) + scrollbar.parent.containerEl.focus() + } + }) + }) +} + +function getKeyDelta(scrollbar: Scrollbar, key: string) { + const { + size, + limit, + offset, + } = scrollbar + + switch (key) { + case KEY.TAB: + return handleTabKey(scrollbar) + case KEY.SPACE: + return [0, 200] + case KEY.PAGE_UP: + return [0, -size.container.height + 40] + case KEY.PAGE_DOWN: + return [0, size.container.height - 40] + case KEY.END: + return [0, limit.y - offset.y] + case KEY.HOME: + return [0, -offset.y] + case KEY.LEFT: + return [-40, 0] + case KEY.UP: + return [0, -40] + case KEY.RIGHT: + return [40, 0] + case KEY.DOWN: + return [0, 40] + } +} + +function handleTabKey(scrollbar: Scrollbar) { + // handle in next frame + requestAnimationFrame(() => { + scrollbar.scrollIntoView(document.activeElement as HTMLElement, { + offsetTop: scrollbar.size.container.height / 2, + offsetLeft: scrollbar.size.container.width / 2, + onlyScrollIfNeeded: true, + }) + }) +} + +function isEditable(element: any): boolean { + if (element.tagName === 'INPUT' + || element.tagName === 'SELECT' + || element.tagName === 'TEXTAREA' + || element.isContentEditable) + return !element.disabled + + return false +} diff --git a/packages/core/src/events/mouse.ts b/packages/core/src/events/mouse.ts new file mode 100644 index 00000000..a267adec --- /dev/null +++ b/packages/core/src/events/mouse.ts @@ -0,0 +1,137 @@ +import type { Scrollbar } from '@smooth-scrollbar/shared' +import { + clamp, + eventScope, + getPosition, + isOneOf, + setStyle, +} from '@smooth-scrollbar/shared' + +enum Direction { X, Y } + +export function mouseHandler(scrollbar: Scrollbar) { + const addEvent = eventScope(scrollbar) + const container = scrollbar.containerEl + const { xAxis, yAxis } = scrollbar.track + + function calcMomentum( + direction: Direction, + clickPosition: number, + ): number { + const { + size, + limit, + offset, + } = scrollbar + + if (direction === Direction.X) { + const totalWidth = size.container.width + (xAxis.thumb.realSize - xAxis.thumb.displaySize) + + return clamp(clickPosition / totalWidth * size.content.width, 0, limit.x) - offset.x + } + + if (direction === Direction.Y) { + const totalHeight = size.container.height + (yAxis.thumb.realSize - yAxis.thumb.displaySize) + + return clamp(clickPosition / totalHeight * size.content.height, 0, limit.y) - offset.y + } + + return 0 + } + + function getTrackDirection( + element: HTMLElement, + ): Direction | undefined { + if (isOneOf(element, [xAxis.element, xAxis.thumb.element])) + return Direction.X + + if (isOneOf(element, [yAxis.element, yAxis.thumb.element])) + return Direction.Y + + return undefined + } + + let isMouseDown: boolean + let isMouseMoving: boolean + let startOffsetToThumb: { x: number; y: number } + let trackDirection: Direction | undefined + let containerRect: DOMRect + + addEvent(container, 'click', (event: MouseEvent) => { + if (isMouseMoving || !isOneOf(event.target, [xAxis.element, yAxis.element])) + return + + const track = event.target as HTMLElement + const direction = getTrackDirection(track) + const rect = track.getBoundingClientRect() + const clickPos = getPosition(event) + + if (direction === Direction.X) { + const offsetOnTrack = clickPos.x - rect.left - xAxis.thumb.displaySize / 2 + scrollbar.setMomentum(calcMomentum(direction, offsetOnTrack), 0) + } + + if (direction === Direction.Y) { + const offsetOnTrack = clickPos.y - rect.top - yAxis.thumb.displaySize / 2 + scrollbar.setMomentum(0, calcMomentum(direction, offsetOnTrack)) + } + }) + + addEvent(container, 'mousedown', (event: MouseEvent) => { + if (!isOneOf(event.target, [xAxis.thumb.element, yAxis.thumb.element])) + return + + isMouseDown = true + + const thumb = event.target as HTMLElement + const cursorPos = getPosition(event) + const thumbRect = thumb.getBoundingClientRect() + + trackDirection = getTrackDirection(thumb) + + // pointer offset to thumb + startOffsetToThumb = { + x: cursorPos.x - thumbRect.left, + y: cursorPos.y - thumbRect.top, + } + + // container bounding rectangle + containerRect = container.getBoundingClientRect() + + // prevent selection, see: + // https://github.com/idiotWu/smooth-scrollbar/issues/48 + setStyle(scrollbar.containerEl, { + '-user-select': 'none', + }) + }) + + addEvent(window, 'mousemove', (event: MouseEvent) => { + if (!isMouseDown) + return + + isMouseMoving = true + + const cursorPos = getPosition(event) + + if (trackDirection === Direction.X) { + // get percentage of pointer position in track + // then tranform to px + // don't need easing + const offsetOnTrack = cursorPos.x - startOffsetToThumb.x - containerRect.left + scrollbar.setMomentum(calcMomentum(trackDirection, offsetOnTrack), 0) + } + + if (trackDirection === Direction.Y) { + const offsetOnTrack = cursorPos.y - startOffsetToThumb.y - containerRect.top + scrollbar.setMomentum(0, calcMomentum(trackDirection, offsetOnTrack)) + } + }) + + addEvent(window, 'mouseup blur', () => { + isMouseDown = isMouseMoving = false + + setStyle(scrollbar.containerEl, { + '-user-select': '', + }) + }) +} diff --git a/packages/core/src/events/resize.ts b/packages/core/src/events/resize.ts new file mode 100644 index 00000000..211660ac --- /dev/null +++ b/packages/core/src/events/resize.ts @@ -0,0 +1,15 @@ +import type { Scrollbar } from '@smooth-scrollbar/shared' +import { + debounce, + eventScope, +} from '@smooth-scrollbar/shared' + +export function resizeHandler(scrollbar: Scrollbar) { + const addEvent = eventScope(scrollbar) + + addEvent( + window, + 'resize', + debounce(scrollbar.update.bind(scrollbar), 300), + ) +} diff --git a/packages/core/src/events/select.ts b/packages/core/src/events/select.ts new file mode 100644 index 00000000..ffd03dd9 --- /dev/null +++ b/packages/core/src/events/select.ts @@ -0,0 +1,119 @@ +import type { Data2d, Scrollbar } from '@smooth-scrollbar/shared' +import { + clamp, + eventScope, + getPosition, +} from '@smooth-scrollbar/shared' + +export function selectHandler(scrollbar: Scrollbar) { + const addEvent = eventScope(scrollbar) + const { containerEl, contentEl } = scrollbar + + let isSelected = false + let isContextMenuOpened = false // flag to prevent selection when context menu is opened + let animationID: number + + function scroll({ x, y }: Data2d) { + if (!x && !y) + return + + const { offset, limit } = scrollbar + // DISALLOW delta transformation + scrollbar.setMomentum( + clamp(offset.x + x, 0, limit.x) - offset.x, + clamp(offset.y + y, 0, limit.y) - offset.y, + ) + + animationID = requestAnimationFrame(() => { + scroll({ x, y }) + }) + } + + addEvent(window, 'mousemove', (event: MouseEvent) => { + if (!isSelected) + return + + cancelAnimationFrame(animationID) + + const direction = calcMomentum(scrollbar, event) + + scroll(direction) + }) + + // prevent scrolling when context menu is opened + // NOTE: `contextmenu` event may be fired + // 1. BEFORE `selectstart`: when user right-clicks on the text content -> prevent future scrolling, + // 2. AFTER `selectstart`: when user right-clicks on the blank area -> cancel current scrolling, + // so we need to both set the flag and cancel current scrolling + addEvent(contentEl, 'contextmenu', () => { + // set the flag to prevent future scrolling + isContextMenuOpened = true + + // stop current scrolling + cancelAnimationFrame(animationID) + isSelected = false + }) + + // reset context menu flag on mouse down + // to ensure the scrolling is allowed in the next selection + addEvent(contentEl, 'mousedown', () => { + isContextMenuOpened = false + }) + + addEvent(contentEl, 'selectstart', () => { + if (isContextMenuOpened) + return + + cancelAnimationFrame(animationID) + + isSelected = true + }) + + addEvent(window, 'mouseup blur', () => { + cancelAnimationFrame(animationID) + + isSelected = false + isContextMenuOpened = false + }) + + // patch for touch devices + addEvent(containerEl, 'scroll', (event: Event) => { + event.preventDefault() + containerEl.scrollTop = containerEl.scrollLeft = 0 + }) +} + +function calcMomentum( + scrollbar: Scrollbar, + event: MouseEvent, +) { + const { top, right, bottom, left } = scrollbar.bounding + const { x, y } = getPosition(event) + + const momentum = { + x: 0, + y: 0, + } + + const padding = 20 + + if (x === 0 && y === 0) + return momentum + + if (x > right - padding) + momentum.x = (x - right + padding) + + else if (x < left + padding) + momentum.x = (x - left - padding) + + if (y > bottom - padding) + momentum.y = (y - bottom + padding) + + else if (y < top + padding) + momentum.y = (y - top - padding) + + momentum.x *= 2 + momentum.y *= 2 + + return momentum +} diff --git a/packages/core/src/events/touch.ts b/packages/core/src/events/touch.ts new file mode 100644 index 00000000..b5ef4ea2 --- /dev/null +++ b/packages/core/src/events/touch.ts @@ -0,0 +1,67 @@ +import type { Scrollbar } from '@smooth-scrollbar/shared' +import { + TouchRecord, + eventScope, +} from '@smooth-scrollbar/shared' + +let activeScrollbar: Scrollbar | undefined + +export function touchHandler(scrollbar: Scrollbar) { + const target = scrollbar.options.delegateTo || scrollbar.containerEl + const touchRecord = new TouchRecord() + const addEvent = eventScope(scrollbar) + + let damping: number + let pointerCount = 0 + + addEvent(target, 'touchstart', (event: TouchEvent) => { + // start records + touchRecord.track(event) + + // stop scrolling + scrollbar.setMomentum(0, 0) + + // save damping + if (pointerCount === 0) { + damping = scrollbar.options.damping + scrollbar.options.damping = Math.max(damping, 0.5) // less frames on touchmove + } + + pointerCount++ + }) + + addEvent(target, 'touchmove', (event: TouchEvent) => { + if (activeScrollbar && activeScrollbar !== scrollbar) + return + + touchRecord.update(event) + + const { x, y } = touchRecord.getDelta() + + scrollbar.addTransformableMomentum(x, y, event, (willScroll) => { + if (willScroll && event.cancelable) { + event.preventDefault() + activeScrollbar = scrollbar + } + }) + }) + + addEvent(target, 'touchcancel touchend', (event: TouchEvent) => { + const delta = touchRecord.getEasingDistance(damping) + + scrollbar.addTransformableMomentum( + delta.x, + delta.y, + event, + ) + + pointerCount-- + + // restore damping + if (pointerCount === 0) + scrollbar.options.damping = damping + + touchRecord.release(event) + activeScrollbar = undefined + }) +} diff --git a/packages/core/src/events/wheel.ts b/packages/core/src/events/wheel.ts new file mode 100644 index 00000000..4fa567d7 --- /dev/null +++ b/packages/core/src/events/wheel.ts @@ -0,0 +1,58 @@ +import type { Scrollbar } from '@smooth-scrollbar/shared' +import { + eventScope, +} from '@smooth-scrollbar/shared' + +export function wheelHandler(scrollbar: Scrollbar) { + const addEvent = eventScope(scrollbar) + + const target = scrollbar.options.delegateTo || scrollbar.containerEl + + const eventName = ('onwheel' in window || document.implementation.hasFeature('Events.wheel', '3.0')) ? 'wheel' : 'mousewheel' + + addEvent(target, eventName, (event: WheelEvent) => { + const { x, y } = normalizeDelta(event) + + scrollbar.addTransformableMomentum(x, y, event, (willScroll) => { + if (willScroll) + event.preventDefault() + }) + }) +} + +// Normalizing wheel delta + +const DELTA_SCALE = { + STANDARD: 1, + OTHERS: -3, +} + +const DELTA_MODE = [1, 28, 500] + +function getDeltaMode(mode: number) { + return DELTA_MODE[mode] || DELTA_MODE[0] +} + +function normalizeDelta(event: any) { + if ('deltaX' in event) { + const mode = getDeltaMode(event.deltaMode) + + return { + x: event.deltaX / DELTA_SCALE.STANDARD * mode, + y: event.deltaY / DELTA_SCALE.STANDARD * mode, + } + } + + if ('wheelDeltaX' in event) { + return { + x: event.wheelDeltaX / DELTA_SCALE.OTHERS, + y: event.wheelDeltaY / DELTA_SCALE.OTHERS, + } + } + + // ie with touchpad + return { + x: 0, + y: event.wheelDelta / DELTA_SCALE.OTHERS, + } +} diff --git a/packages/core/src/geometry/get-size.ts b/packages/core/src/geometry/get-size.ts new file mode 100644 index 00000000..65b5251f --- /dev/null +++ b/packages/core/src/geometry/get-size.ts @@ -0,0 +1,34 @@ +import type { Scrollbar, ScrollbarSize } from '@smooth-scrollbar/shared' + +export function getSize(scrollbar: Scrollbar): ScrollbarSize { + const { + containerEl, + contentEl, + } = scrollbar + + const containerStyles = getComputedStyle(containerEl) + + const paddings = [ + 'paddingTop', + 'paddingBottom', + 'paddingLeft', + 'paddingRight', + ].map((property: any) => { + return containerStyles[property] ? Number.parseFloat(containerStyles[property]) : 0 + }) + const verticalPadding = paddings[0] + paddings[1] + const horizontalPadding = paddings[2] + paddings[3] + + return { + container: { + // requires `overflow: hidden` + width: containerEl.clientWidth, + height: containerEl.clientHeight, + }, + content: { + // border width and paddings should be included + width: contentEl.offsetWidth - contentEl.clientWidth + contentEl.scrollWidth + +horizontalPadding, + height: contentEl.offsetHeight - contentEl.clientHeight + contentEl.scrollHeight + +verticalPadding, + }, + } +} diff --git a/packages/core/src/geometry/index.ts b/packages/core/src/geometry/index.ts new file mode 100644 index 00000000..8f4f7df2 --- /dev/null +++ b/packages/core/src/geometry/index.ts @@ -0,0 +1,3 @@ +export * from './get-size' +export * from './is-visible' +export * from './update' diff --git a/packages/core/src/geometry/is-visible.ts b/packages/core/src/geometry/is-visible.ts new file mode 100644 index 00000000..d1806bd1 --- /dev/null +++ b/packages/core/src/geometry/is-visible.ts @@ -0,0 +1,14 @@ +import type { Scrollbar } from '@smooth-scrollbar/shared' + +export function isVisible(scrollbar: Scrollbar, element: HTMLElement): boolean { + const { bounding } = scrollbar + const targetBounding = element.getBoundingClientRect() + + // check overlapping + const top = Math.max(bounding.top, targetBounding.top) + const left = Math.max(bounding.left, targetBounding.left) + const right = Math.min(bounding.right, targetBounding.right) + const bottom = Math.min(bounding.bottom, targetBounding.bottom) + + return top < bottom && left < right +} diff --git a/src/geometry/update.ts b/packages/core/src/geometry/update.ts similarity index 70% rename from src/geometry/update.ts rename to packages/core/src/geometry/update.ts index 2c20bc39..c7e4dd76 100644 --- a/src/geometry/update.ts +++ b/packages/core/src/geometry/update.ts @@ -1,33 +1,31 @@ -import { - Scrollbar, -} from '../interfaces/'; +import type { Scrollbar } from '@smooth-scrollbar/shared' export function update(scrollbar: Scrollbar) { - const newSize = scrollbar.getSize(); + const newSize = scrollbar.getSize() const limit = { x: Math.max(newSize.content.width - newSize.container.width, 0), y: Math.max(newSize.content.height - newSize.container.height, 0), - }; + } // metrics - const containerBounding = scrollbar.containerEl.getBoundingClientRect(); + const containerBounding = scrollbar.containerEl.getBoundingClientRect() const bounding = { top: Math.max(containerBounding.top, 0), right: Math.min(containerBounding.right, window.innerWidth), bottom: Math.min(containerBounding.bottom, window.innerHeight), left: Math.max(containerBounding.left, 0), - }; + } // assign props - scrollbar.size = newSize; - scrollbar.limit = limit; - scrollbar.bounding = bounding; + scrollbar.size = newSize + scrollbar.limit = limit + scrollbar.bounding = bounding // update tracks - scrollbar.track.update(); + scrollbar.track.update() // re-positioning - scrollbar.setPosition(); + scrollbar.setPosition() } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts new file mode 100644 index 00000000..ccd15e20 --- /dev/null +++ b/packages/core/src/index.ts @@ -0,0 +1,111 @@ +// import 'style/style.css' +import type { ScrollbarOptions as Options } from '@smooth-scrollbar/shared' + +import { + Scrollbar, + scrollbarMap, +} from './scrollbar' + +import { + ScrollbarPlugin, + addPlugins, +} from './plugin' + +type ScrollbarOptions = Partial + +export class SmoothScrollbar extends Scrollbar { + /** + * Return smooth-scrollbar version + * @static + */ + static version = __SCROLLBAR_VERSION__ + + /** + * @static + */ + static ScrollbarPlugin = ScrollbarPlugin + + /** + * Initializes a scrollbar on the given element. + * + * @param elem The DOM element that you want to initialize scrollbar to + * @param [options] Initial options + */ + static init(element: HTMLElement, options?: ScrollbarOptions) { + if (__DEV__) { + if (!element || element.nodeType !== 1) + throw new TypeError(`expect element to be DOM Element, but got ${element}`) + } + + if (scrollbarMap.has(element)) + return scrollbarMap.get(element) as Scrollbar + + return new Scrollbar(element, options) + } + + /** + * Automatically init scrollbar on all elements base on the selector `[data-scrollbar]` + * + * @param [options] Initial options + */ + static initAll(options?: ScrollbarOptions) { + return [...document.querySelectorAll('[data-scrollbar]')].map(element => SmoothScrollbar.init(element as HTMLElement, options)) + } + + /** + * Check if there is a scrollbar on given element + * + * @param element The DOM element that you want to checke + */ + static has(element: HTMLElement) { + return scrollbarMap.has(element) + } + + /** + * Gets scrollbar on the given element. + * If no scrollbar instance exsits, returns `undefined` + * + * @param element The DOM element that you want to check. + */ + static get(element: HTMLElement) { + return scrollbarMap.get(element) as Scrollbar + } + + /** + * Returns an array that contains all scrollbar instances + */ + static getAll() { + return [...scrollbarMap.values()] + } + + /** + * Removes scrollbar on the given element + */ + static destroy(element: HTMLElement) { + const scrollbar = scrollbarMap.get(element) + + if (scrollbar) + scrollbar.destroy() + } + + /** + * Removes all scrollbar instances from current document + */ + static destroyAll() { + for (const [_, scrollbar] of scrollbarMap) + scrollbar.destroy() + } + + /** + * Attaches plugins to scrollbars + * + * @param ...Plugins Scrollbar plugin classes + */ + static use(...Plugins: (typeof ScrollbarPlugin)[]) { + return addPlugins(...Plugins) + } +} + +export type { ScrollbarOptions } +export default SmoothScrollbar +export { ScrollbarPlugin } from './plugin' diff --git a/src/options.ts b/packages/core/src/options.ts similarity index 59% rename from src/options.ts rename to packages/core/src/options.ts index 54913f35..bd65aceb 100644 --- a/src/options.ts +++ b/packages/core/src/options.ts @@ -1,11 +1,8 @@ +import type { ScrollbarOptions } from '@smooth-scrollbar/shared' import { - range, boolean, -} from './decorators/'; - -import { - ScrollbarOptions, -} from './interfaces/'; + range, +} from './decorators' export class Options { /** @@ -14,60 +11,51 @@ export class Options { * (also the more paint frames). */ @range(0, 1) - damping = 0.1; + damping = 0.1 /** * Minimal size for scrollbar thumbs. */ - @range(0, Infinity) - thumbMinSize = 20; + @range(0, Number.POSITIVE_INFINITY) + thumbMinSize = 20 /** * Render every frame in integer pixel values * set to `true` to improve scrolling performance. */ @boolean - renderByPixels = true; + renderByPixels = true /** * Keep scrollbar tracks visible */ @boolean - alwaysShowTracks = false; + alwaysShowTracks = false /** * Set to `true` to allow outer scrollbars continue scrolling * when current scrollbar reaches edge. */ @boolean - continuousScrolling = true; + continuousScrolling = true /** * Delegate wheel events and touch events to the given element. * By default, the container element is used. * This option will be useful for dealing with fixed elements. */ - delegateTo: EventTarget | null = null; - - get wheelEventTarget() { - return this.delegateTo; - } - - set wheelEventTarget(el: EventTarget | null) { - console.warn('[smooth-scrollbar]: `options.wheelEventTarget` is deprecated and will be removed in the future, use `options.delegateTo` instead.'); - - this.delegateTo = el; - } + delegateTo: EventTarget | undefined = undefined /** * Options for plugins. Syntax: * plugins[pluginName] = pluginOptions: any */ - readonly plugins: any = {}; + readonly plugins: any = {} constructor(config: Partial = {}) { - Object.keys(config).forEach((prop) => { - this[prop] = config[prop]; - }); + for (const property of Object.keys(config)) { + // @ts-expect-error ignore types + this[property] = config[property] + } } } diff --git a/packages/core/src/plugin.ts b/packages/core/src/plugin.ts new file mode 100644 index 00000000..a50ee345 --- /dev/null +++ b/packages/core/src/plugin.ts @@ -0,0 +1,80 @@ +import type { Data2d, ScrollbarPlugin as IScrollbarPlugin } from '@smooth-scrollbar/shared' +import type { Scrollbar } from './scrollbar' // used as type annotations + +export class ScrollbarPlugin implements IScrollbarPlugin { + static pluginName = '' + static defaultOptions: any = {} + + readonly scrollbar: Scrollbar + readonly options: any + readonly name: string + + constructor( + scrollbar: Scrollbar, + options?: any, + ) { + this.scrollbar = scrollbar + this.name = new.target.pluginName + + this.options = { + ...new.target.defaultOptions, + ...options, + } + } + + onInit() {} + onDestroy() {} + + onUpdate() {} + onRender(_remainMomentum: Data2d) {} + + transformDelta(delta: Data2d, _event: Event): Data2d { + return { ...delta } + } +} + +export interface PluginMap { + order: Set + constructors: { + [name: string]: typeof ScrollbarPlugin + } +} + +export const globalPlugins: PluginMap = { + order: new Set(), + constructors: {}, +} + +export function addPlugins( + ...Plugins: (typeof ScrollbarPlugin)[] +): void { + for (const P of Plugins) { + const { pluginName } = P + + if (!pluginName) + throw new TypeError('plugin name is required') + + globalPlugins.order.add(pluginName) + globalPlugins.constructors[pluginName] = P + } +} + +export function initPlugins( + scrollbar: Scrollbar, + options: any, +): ScrollbarPlugin[] { + return [...globalPlugins.order] + .filter((pluginName: string) => { + return options[pluginName] !== false + }) + .map((pluginName: string) => { + const Plugin = globalPlugins.constructors[pluginName] + + const instance = new Plugin(scrollbar, options[pluginName]) + + // bind plugin options to `scrollbar.options` + options[pluginName] = instance.options + + return instance + }) +} diff --git a/packages/core/src/scrollbar.ts b/packages/core/src/scrollbar.ts new file mode 100644 index 00000000..8d63f9c9 --- /dev/null +++ b/packages/core/src/scrollbar.ts @@ -0,0 +1,501 @@ +import type { + AddTransformableMomentumCallback, + Scrollbar as IScrollbar, + ScrollIntoViewOptions, + ScrollListener, + ScrollToOptions, + ScrollbarOptions, + ScrollbarPlugin, + ScrollbarSize, + SetPositionOptions, +} from '@smooth-scrollbar/shared' + +import { + clamp, + clearEventsOn, + setStyle, +} from '@smooth-scrollbar/shared' + +import { Options } from './options' + +import { + debounce, +} from './decorators' + +import { + TrackController, +} from './track' + +import { + getSize, + isVisible, + update, +} from './geometry' + +import { + scrollIntoView, + scrollTo, + setPosition, +} from './scrolling' + +import { + initPlugins, +} from './plugin' + +import * as eventHandlers from './events' + +// DO NOT use WeakMap here +// .getAll() methods requires `scrollbarMap.values()` +export const scrollbarMap = new Map() + +export class Scrollbar implements IScrollbar { + /** + * Options for current scrollbar instancs + */ + readonly options: Options + + readonly track: TrackController + + /** + * The element that you initialized scrollbar to + */ + readonly containerEl: HTMLElement + + /** + * The wrapper element that contains your contents + */ + readonly contentEl: HTMLElement + + /** + * Geometry infomation for current scrollbar instance + */ + size: ScrollbarSize + + /** + * Current scrolling offsets + */ + offset = { + x: 0, + y: 0, + } + + /** + * Max-allowed scrolling offsets + */ + limit = { + x: Number.POSITIVE_INFINITY, + y: Number.POSITIVE_INFINITY, + } + + /** + * Container bounding rect + */ + bounding = { + top: 0, + right: 0, + bottom: 0, + left: 0, + } + + _renderID = 0 + + /** + * Parent scrollbar + */ + get parent(): any { + let element = this.containerEl.parentElement + + while (element) { + const parentScrollbar = scrollbarMap.get(element) + + if (parentScrollbar) + return parentScrollbar + + element = element.parentElement + } + + return undefined + } + + /** + * Gets or sets `scrollbar.offset.y` + */ + get scrollTop() { + return this.offset.y + } + + set scrollTop(y: number) { + this.setPosition(this.scrollLeft, y) + } + + /** + * Gets or sets `scrollbar.offset.x` + */ + get scrollLeft() { + return this.offset.x + } + + set scrollLeft(x: number) { + this.setPosition(x, this.scrollTop) + } + + private _observer: ResizeObserver + private _plugins: ScrollbarPlugin[] = [] + + private _momentum = { x: 0, y: 0 } + private _listeners = new Set() + + constructor( + containerElement: HTMLElement, + options?: Partial, + ) { + this.containerEl = containerElement + const contentElement = this.contentEl = document.createElement('div') + + this.options = new Options(options) + + // mark as a scroll element + containerElement.dataset.scrollbar = 'true' + + // make container focusable + containerElement.setAttribute('tabindex', '-1') + setStyle(containerElement, { + overflow: 'hidden', + outline: 'none', + }) + + // enable touch event capturing in IE, see: + // https://github.com/idiotWu/smooth-scrollbar/issues/39 + // TODO TouchActionCSS + // if (window.navigator.msPointerEnabled) { + // containerEl.style.msTouchAction = 'none'; + // } + + // attach track + this.track = new TrackController(this) + + // initial measuring + this.size = this.getSize() + + // init plugins + this._plugins = initPlugins(this, this.options.plugins) + + // preserve scroll offset + const { scrollLeft, scrollTop } = containerElement + containerElement.scrollLeft = containerElement.scrollTop = 0 + this.setPosition(scrollLeft, scrollTop, { + withoutCallbacks: true, + }) + + // observe + this._observer = new ResizeObserver(() => { + this.update() + }) + + this._observer.observe(contentElement) + + scrollbarMap.set(containerElement, this) + + // wait for DOM ready + requestAnimationFrame(() => { + this._init() + }) + } + + /** + * Returns the size of the scrollbar container element + * and the content wrapper element + */ + getSize(): ScrollbarSize { + return getSize(this) + } + + /** + * Forces scrollbar to update geometry infomation. + * + * By default, scrollbars are automatically updated with `100ms` debounce (or `MutationObserver` fires). + * You can call this method to force an update when you modified contents + */ + update() { + update(this) + + for (const plugin of this._plugins) + plugin.onUpdate() + } + + /** + * Checks if an element is visible in the current view area + */ + isVisible(element: HTMLElement): boolean { + return isVisible(this, element) + } + + /** + * Sets the scrollbar to the given offset without easing + */ + setPosition( + x = this.offset.x, + y = this.offset.y, + options: Partial = {}, + ) { + const status = setPosition(this, x, y) + + if (!status || options.withoutCallbacks) + return + + for (const function_ of this._listeners) + function_.call(this, status) + } + + /** + * Scrolls to given position with easing function + */ + scrollTo( + x = this.offset.x, + y = this.offset.y, + duration = 0, + options: Partial = {}, + ) { + scrollTo(this, x, y, duration, options) + } + + /** + * Scrolls the target element into visible area of scrollbar, + * likes the DOM method `element.scrollIntoView(). + */ + scrollIntoView( + element: HTMLElement, + options: Partial = {}, + ) { + scrollIntoView(this, element, options) + } + + /** + * Adds scrolling listener + */ + addListener(fn: ScrollListener) { + if (__DEV__) { + if (typeof fn !== 'function') + throw new TypeError('[smooth-scrollbar] scrolling listener should be a function') + } + + this._listeners.add(fn) + } + + /** + * Removes listener previously registered with `scrollbar.addListener()` + */ + removeListener(function_: ScrollListener) { + this._listeners.delete(function_) + } + + /** + * Adds momentum and applys delta transformers. + */ + addTransformableMomentum( + x: number, + y: number, + fromEvent: Event, + callback?: AddTransformableMomentumCallback, + ) { + this._updateDebounced() + + const finalDelta = this._plugins.reduce((delta, plugin) => { + return plugin.transformDelta(delta, fromEvent) || delta + }, { x, y }) + + const willScroll = !this._shouldPropagateMomentum(finalDelta.x, finalDelta.y) + + if (willScroll) + this.addMomentum(finalDelta.x, finalDelta.y) + + if (callback) + callback.call(this, willScroll) + } + + /** + * Increases scrollbar's momentum + */ + addMomentum(x: number, y: number) { + this.setMomentum( + this._momentum.x + x, + this._momentum.y + y, + ) + } + + /** + * Sets scrollbar's momentum to given value + */ + setMomentum(x: number, y: number) { + if (this.limit.x === 0) + x = 0 + + if (this.limit.y === 0) + y = 0 + + if (this.options.renderByPixels) { + x = Math.round(x) + y = Math.round(y) + } + + this._momentum.x = x + this._momentum.y = y + } + + /** + * Update options for specific plugin + * + * @param pluginName Name of the plugin + * @param [options] An object includes the properties that you want to update + */ + updatePluginOptions(pluginName: string, options?: any) { + for (const plugin of this._plugins) { + if (plugin.name === pluginName) + Object.assign(plugin.options, options) + } + } + + destroy() { + const { + containerEl, + _listeners, + _renderID, + _observer, + scrollTop, + scrollLeft, + _plugins, + } = this + + clearEventsOn(this) + _listeners.clear() + + this.setMomentum(0, 0) + cancelAnimationFrame(_renderID) + + if (_observer) + _observer.disconnect() + + scrollbarMap.delete(containerEl) + + // reset scroll position + setStyle(containerEl, { + overflow: '', + }) + + containerEl.scrollTop = scrollTop + containerEl.scrollLeft = scrollLeft + + // invoke plugin.onDestroy + for (const plugin of _plugins) + plugin.onDestroy() + + _plugins.length = 0 + } + + private _init() { + this.update() + + // init evet handlers + for (const property of Object.keys(eventHandlers)) { + // @ts-expect-error ignore types + eventHandlers[property](this) + } + + // invoke `plugin.onInit` + for (const plugin of this._plugins) + plugin.onInit() + + this._render() + } + + @debounce(100, true) + private _updateDebounced() { + this.update() + } + + // check whether to propagate monmentum to parent scrollbar + // the following situations are considered as `true`: + // 1. continuous scrolling is enabled (automatically disabled when overscroll is enabled) + // 2. scrollbar reaches one side and is not about to scroll on the other direction + private _shouldPropagateMomentum(deltaX = 0, deltaY = 0): boolean { + const { + options, + offset, + limit, + } = this + + if (!options.continuousScrolling) + return false + + // force an update when scrollbar is "unscrollable", see #106 + if (limit.x === 0 && limit.y === 0) + this._updateDebounced() + + const destinationX = clamp(deltaX + offset.x, 0, limit.x) + const destinationY = clamp(deltaY + offset.y, 0, limit.y) + let propagate = true + + // offsets are not about to change + // `&=` operator is not allowed for boolean types + propagate = propagate && (destinationX === offset.x) + propagate = propagate && (destinationY === offset.y) + + // current offsets are on the edge + propagate = propagate && (offset.x === limit.x || offset.x === 0 || offset.y === limit.y || offset.y === 0) + + return propagate + } + + private _render() { + const { + _momentum, + _plugins, + _render, + } = this + + if (_momentum.x || _momentum.y) { + const nextX = this._nextTick('x') + const nextY = this._nextTick('y') + + _momentum.x = nextX.momentum + _momentum.y = nextY.momentum + + this.setPosition(nextX.position, nextY.position) + } + + const remain = { ..._momentum } + + for (const plugin of _plugins) + plugin.onRender(remain) + + this._renderID = requestAnimationFrame(_render.bind(this)) + } + + private _nextTick(direction: 'x' | 'y'): { momentum: number; position: number } { + const { + options, + offset, + _momentum, + } = this + + const current = offset[direction] + const remain = _momentum[direction] + + if (Math.abs(remain) <= 0.1) { + return { + momentum: 0, + position: current + remain, + } + } + + let nextMomentum = remain * (1 - options.damping) + + if (options.renderByPixels) + nextMomentum = Math.trunc(nextMomentum) + + return { + momentum: nextMomentum, + position: current + remain - nextMomentum, + } + } +} diff --git a/packages/core/src/scrolling/index.ts b/packages/core/src/scrolling/index.ts new file mode 100644 index 00000000..800e87be --- /dev/null +++ b/packages/core/src/scrolling/index.ts @@ -0,0 +1,3 @@ +export * from './set-position' +export * from './scroll-to' +export * from './scroll-into-view' diff --git a/src/scrolling/scroll-into-view.ts b/packages/core/src/scrolling/scroll-into-view.ts similarity index 52% rename from src/scrolling/scroll-into-view.ts rename to packages/core/src/scrolling/scroll-into-view.ts index 4d65d572..4f530fc0 100644 --- a/src/scrolling/scroll-into-view.ts +++ b/packages/core/src/scrolling/scroll-into-view.ts @@ -1,35 +1,36 @@ -import { clamp } from '../utils'; - -import * as I from '../interfaces/'; +import type { ScrollIntoViewOptions, Scrollbar } from '@smooth-scrollbar/shared' +import { clamp } from '@smooth-scrollbar/shared' export function scrollIntoView( - scrollbar: I.Scrollbar, - elem: HTMLElement, + scrollbar: Scrollbar, + element: HTMLElement, { alignToTop = true, onlyScrollIfNeeded = false, offsetTop = 0, offsetLeft = 0, offsetBottom = 0, - }: Partial = {}, + }: Partial = {}, ) { const { containerEl, bounding, offset, limit, - } = scrollbar; + } = scrollbar - if (!elem || !containerEl.contains(elem)) return; + if (!element || !containerEl.contains(element)) + return - const targetBounding = elem.getBoundingClientRect(); + const targetBounding = element.getBoundingClientRect() - if (onlyScrollIfNeeded && scrollbar.isVisible(elem)) return; + if (onlyScrollIfNeeded && scrollbar.isVisible(element)) + return - const delta = alignToTop ? targetBounding.top - bounding.top - offsetTop : targetBounding.bottom - bounding.bottom + offsetBottom; + const delta = alignToTop ? targetBounding.top - bounding.top - offsetTop : targetBounding.bottom - bounding.bottom + offsetBottom scrollbar.setMomentum( targetBounding.left - bounding.left - offsetLeft, clamp(delta, -offset.y, limit.y - offset.y), - ); + ) } diff --git a/packages/core/src/scrolling/scroll-to.ts b/packages/core/src/scrolling/scroll-to.ts new file mode 100644 index 00000000..16880cfa --- /dev/null +++ b/packages/core/src/scrolling/scroll-to.ts @@ -0,0 +1,61 @@ +import type { ScrollToOptions, Scrollbar } from '@smooth-scrollbar/shared' +import { clamp } from '@smooth-scrollbar/shared' + +const animationIDStorage = new WeakMap() + +export function scrollTo( + scrollbar: Scrollbar, + x: number, + y: number, + duration = 0, + { easing = defaultEasing, callback }: Partial = {}, +) { + const { + options, + offset, + limit, + } = scrollbar + + if (options.renderByPixels) { + // ensure resolved with integer + x = Math.round(x) + y = Math.round(y) + } + + const startX = offset.x + const startY = offset.y + + const disX = clamp(x, 0, limit.x) - startX + const disY = clamp(y, 0, limit.y) - startY + + const start = Date.now() + + function scroll() { + const elapse = Date.now() - start + const progress = duration ? easing(Math.min(elapse / duration, 1)) : 1 + + scrollbar.setPosition( + startX + disX * progress, + startY + disY * progress, + ) + + if (elapse >= duration) { + if (typeof callback === 'function') + callback.call(scrollbar) + } + else { + const animationID = requestAnimationFrame(scroll) + animationIDStorage.set(scrollbar, animationID) + } + } + + cancelAnimationFrame(animationIDStorage.get(scrollbar) as number) + scroll() +} + +/** + * easeOutCubic + */ +function defaultEasing(t: number): number { + return (t - 1) ** 3 + 1 +} diff --git a/packages/core/src/scrolling/set-position.ts b/packages/core/src/scrolling/set-position.ts new file mode 100644 index 00000000..0fdb4330 --- /dev/null +++ b/packages/core/src/scrolling/set-position.ts @@ -0,0 +1,50 @@ +import type { ScrollStatus, Scrollbar } from '@smooth-scrollbar/shared' +import { clamp, setStyle } from '@smooth-scrollbar/shared' + +export function setPosition( + scrollbar: Scrollbar, + x: number, + y: number, +): ScrollStatus | undefined { + const { + options, + offset, + limit, + track, + contentEl, + } = scrollbar + + if (options.renderByPixels) { + x = Math.round(x) + y = Math.round(y) + } + + x = clamp(x, 0, limit.x) + y = clamp(y, 0, limit.y) + + // position changed -> show track for 300ms + if (x !== offset.x) + track.xAxis.show() + if (y !== offset.y) + track.yAxis.show() + + if (!options.alwaysShowTracks) + track.autoHideOnIdle() + + if (x === offset.x && y === offset.y) + return undefined + + offset.x = x + offset.y = y + + setStyle(contentEl, { + '-transform': `translate3d(${-x}px, ${-y}px, 0)`, + }) + + track.update() + + return { + offset: { ...offset }, + limit: { ...limit }, + } +} diff --git a/packages/core/src/style/style.css b/packages/core/src/style/style.css new file mode 100644 index 00000000..463301f6 --- /dev/null +++ b/packages/core/src/style/style.css @@ -0,0 +1,95 @@ +[data-scrollbar] { + --scrollbar-track-opacity: 0; + /* --scrollbar-track-always-shown: 1; */ + + --scrollbar-track-color: #ccc; + --scrollbar-track-width: 8px; + --scrollbar-track-z-index: 1; + --scrollbar-track-transition-duration: 0.5s; + --scrollbar-track-transition-delay: 0.5s; + --scrollbar-track-transition-timing-function: ease-out; + --scrollbar-track-transition: opacity var(--scrollbar-track-transition-duration) var(--scrollbar-track-transition-delay) var(--scrollbar-track-transition-timing-function); + + --scrollbar-thumb-color: #000; + --scrollbar-thumb-radius: 4px; + + --scrollbar-thumb-y-width: 8px; + /* --scrollbar-thumb-height: */ + + --scrollbar-thumb-x-height: 8px; + /* --scrollbar-thumb-width: */ + + + /* position values */ + --scrollbar-track-y-top: 0; + --scrollbar-track-y-right: 0; + --scrollbar-track-y-bottom: initial; + --scrollbar-track-y-left: initial; + + --scrollbar-track-x-bottom: 0; + --scrollbar-track-x-left: 0; + --scrollbar-track-x-top: initial; + --scrollbar-track-x-right: initial; +} + +.scrollbar-track, .scrollbar-thumb { + position: absolute; +} + +.scrollbar-track { + /* alwaysShowTracks https://github.com/idiotWu/smooth-scrollbar/blob/2c46e05e06cf86d0b4731563d8758af7f84ad52e/src/track/index.ts#L25-L28 */ + opacity: var(--scrollbar-track-always-shown, var(--scrollbar-track-opacity, 0)); + z-index: var(--scrollbar-track-z-index); + background-color: var(--scrollbar-track-color); + user-select: none; + transition: var(--scrollbar-track-transition); +} + +.scrollbar-track-y { + top: var(--scrollbar-track-y-top); /* inset-block-start: 0 */ + right: var(--scrollbar-track-y-right); /* inset-inline-end: 0 */ + bottom: var(--scrollbar-track-y-bottom); + left: var(--scrollbar-track-y-left); + + width: var(--scrollbar-thumb-y-width); + height: 100%; +} + +.scrollbar-track-x { + bottom: var(--scrollbar-track-x-bottom); /* inset-block-end: 0 */ + left: var(--scrollbar-track-x-left); /* inset-inline-start: 0 */ + top: var(--scrollbar-track-x-top); + right: var(--scrollbar-track-x-right); + + width: 100%; + height: var(--scrollbar-thumb-x-height); +} + +.scrollbar-thumb { + top: 0; /* inset-block-start: 0 */ + left: 0; /* inset-inline-end: 0 */ + width: 8px; + height: 8px; + background: var(--scrollbar-thumb-color); + border-radius: var(--scrollbar-thumb-radius); +} + +.scrollbar-thumb-x { + height: var(--scrollbar-thumb-x-height); +} + +.scrollbar-thumb-y { + width: var(--scrollbar-thumb-y-width); +} + +.scrollbar-track.show { + --scrollbar-track-opacity: 1; + transition-delay: 0s; +} + +@media (hover: hover) { + .scrollbar-track:hover { + --scrollbar-track-opacity: 1; + transition-delay: 0s; + } +} diff --git a/src/track/direction.ts b/packages/core/src/track/direction.ts similarity index 100% rename from src/track/direction.ts rename to packages/core/src/track/direction.ts diff --git a/packages/core/src/track/index.ts b/packages/core/src/track/index.ts new file mode 100644 index 00000000..9774c2c7 --- /dev/null +++ b/packages/core/src/track/index.ts @@ -0,0 +1,54 @@ +import type { TrackController as ITrackController, Scrollbar } from '@smooth-scrollbar/shared' + +import { + debounce, +} from '../decorators' +import { ScrollbarTrack } from './track' +import { TrackDirection } from './direction' + +export class TrackController implements ITrackController { + readonly xAxis: ScrollbarTrack + readonly yAxis: ScrollbarTrack + + constructor( + private _scrollbar: Scrollbar, + ) { + const thumbMinSize = _scrollbar.options.thumbMinSize + + this.xAxis = new ScrollbarTrack(TrackDirection.X, thumbMinSize) + this.yAxis = new ScrollbarTrack(TrackDirection.Y, thumbMinSize) + + this.xAxis.attachTo(_scrollbar.containerEl) + this.yAxis.attachTo(_scrollbar.containerEl) + + if (_scrollbar.options.alwaysShowTracks) { + this.xAxis.show() + this.yAxis.show() + } + } + + /** + * Updates track appearance + */ + update() { + const { + size, + offset, + } = this._scrollbar + + this.xAxis.update(offset.x, size.container.width, size.content.width) + this.yAxis.update(offset.y, size.container.height, size.content.height) + } + + /** + * Automatically hide tracks when scrollbar is in idle state + */ + @debounce(300) + autoHideOnIdle() { + if (this._scrollbar.options.alwaysShowTracks) + return + + this.xAxis.hide() + this.yAxis.hide() + } +} diff --git a/src/track/thumb.ts b/packages/core/src/track/thumb.ts similarity index 61% rename from src/track/thumb.ts rename to packages/core/src/track/thumb.ts index f3c97bca..3644f39a 100644 --- a/src/track/thumb.ts +++ b/packages/core/src/track/thumb.ts @@ -1,34 +1,34 @@ -import * as I from '../interfaces/'; -import { TrackDirection } from './direction'; -import { setStyle } from '../utils/'; +import type { ScrollbarThumb as IScrollbarThumb } from '@smooth-scrollbar/shared' +import { setStyle } from '@smooth-scrollbar/shared' +import { TrackDirection } from './direction' -export class ScrollbarThumb implements I.ScrollbarThumb { +export class ScrollbarThumb implements IScrollbarThumb { /** * Thumb element */ - readonly element = document.createElement('div'); + readonly element = document.createElement('div') /** * Display size of the thumb * will always be greater than `scrollbar.options.thumbMinSize` */ - displaySize = 0; + displaySize = 0 /** * Actual size of the thumb */ - realSize = 0; + realSize = 0 /** * Thumb offset to the top */ - offset = 0; + offset = 0 constructor( private _direction: TrackDirection, private _minSize = 0, ) { - this.element.className = `scrollbar-thumb scrollbar-thumb-${_direction}`; + this.element.className = `scrollbar-thumb scrollbar-thumb-${_direction}` } /** @@ -36,8 +36,8 @@ export class ScrollbarThumb implements I.ScrollbarThumb { * * @param trackEl Track element */ - attachTo(trackEl: HTMLElement) { - trackEl.appendChild(this.element); + attachTo(trackElement: HTMLElement) { + trackElement.append(this.element) } update( @@ -47,31 +47,28 @@ export class ScrollbarThumb implements I.ScrollbarThumb { ) { // calculate thumb size // pageSize > containerSize -> scrollable - this.realSize = Math.min(containerSize / pageSize, 1) * containerSize; - this.displaySize = Math.max(this.realSize, this._minSize); + this.realSize = Math.min(containerSize / pageSize, 1) * containerSize + this.displaySize = Math.max(this.realSize, this._minSize) // calculate thumb offset - this.offset = scrollOffset / pageSize * (containerSize + (this.realSize - this.displaySize)); + this.offset = scrollOffset / pageSize * (containerSize + (this.realSize - this.displaySize)) - setStyle(this.element, this._getStyle()); + setStyle(this.element, this._getStyle()) } private _getStyle() { switch (this._direction) { case TrackDirection.X: return { - width: `${this.displaySize}px`, + 'width': `${this.displaySize}px`, '-transform': `translate3d(${this.offset}px, 0, 0)`, - }; + } case TrackDirection.Y: return { - height: `${this.displaySize}px`, + 'height': `${this.displaySize}px`, '-transform': `translate3d(0, ${this.offset}px, 0)`, - }; - - default: - return null; + } } } } diff --git a/packages/core/src/track/track.ts b/packages/core/src/track/track.ts new file mode 100644 index 00000000..6b426d12 --- /dev/null +++ b/packages/core/src/track/track.ts @@ -0,0 +1,72 @@ +import type { ScrollbarTrack as IScrollbarTrack } from '@smooth-scrollbar/shared' +import { setStyle } from '@smooth-scrollbar/shared' +import type { TrackDirection } from './direction' +import { ScrollbarThumb } from './thumb' + +export class ScrollbarTrack implements IScrollbarTrack { + readonly thumb: ScrollbarThumb + + /** + * Track element + */ + readonly element = document.createElement('div') + + private _isShown = false + + constructor( + direction: TrackDirection, + thumbMinSize = 0, + ) { + this.element.className = `scrollbar-track scrollbar-track-${direction}` + + this.thumb = new ScrollbarThumb( + direction, + thumbMinSize, + ) + + this.thumb.attachTo(this.element) + } + + /** + * Attach to scrollbar container element + * + * @param scrollbarContainer Scrollbar container element + */ + attachTo(scrollbarContainer: HTMLElement) { + scrollbarContainer.append(this.element) + } + + /** + * Show track immediately + */ + show() { + if (this._isShown) + return + + this._isShown = true + this.element.classList.add('show') + } + + /** + * Hide track immediately + */ + hide() { + if (!this._isShown) + return + + this._isShown = false + this.element.classList.remove('show') + } + + update( + scrollOffset: number, + containerSize: number, + pageSize: number, + ) { + setStyle(this.element, { + display: pageSize <= containerSize ? 'none' : 'block', + }) + + this.thumb.update(scrollOffset, containerSize, pageSize) + } +} diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json new file mode 100644 index 00000000..f20245a4 --- /dev/null +++ b/packages/core/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "experimentalDecorators": true, + "importHelpers": true + } +} diff --git a/src/decorators/debounce.ts b/src/decorators/debounce.ts deleted file mode 100644 index beed53e0..00000000 --- a/src/decorators/debounce.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { debounce as $debounce } from '../utils'; - -export function debounce(...options) { - return (_proto: any, key: string, descriptor: PropertyDescriptor) => { - const fn = descriptor.value; - - return { - get() { - if (!this.hasOwnProperty(key)) { - Object.defineProperty(this, key, { - value: $debounce(fn, ...options), - }); - } - - return this[key]; - }, - }; - }; -} diff --git a/src/decorators/index.ts b/src/decorators/index.ts deleted file mode 100644 index 06d7ff49..00000000 --- a/src/decorators/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from './range'; -export * from './boolean'; -export * from './debounce'; diff --git a/src/events/index.ts b/src/events/index.ts deleted file mode 100644 index 90db25fc..00000000 --- a/src/events/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -export * from './keyboard'; -export * from './mouse'; -export * from './resize'; -export * from './select'; -export * from './touch'; -export * from './wheel'; diff --git a/src/events/keyboard.ts b/src/events/keyboard.ts deleted file mode 100644 index cbbd98f3..00000000 --- a/src/events/keyboard.ts +++ /dev/null @@ -1,110 +0,0 @@ -import * as I from '../interfaces/'; - -import { - eventScope, -} from '../utils/'; - -enum KEY_CODE { - TAB = 9, - SPACE = 32, - PAGE_UP, - PAGE_DOWN, - END, - HOME, - LEFT, - UP, - RIGHT, - DOWN, -} - -export function keyboardHandler(scrollbar: I.Scrollbar) { - const addEvent = eventScope(scrollbar); - const container = scrollbar.containerEl; - - addEvent(container, 'keydown', (evt: KeyboardEvent) => { - const { activeElement } = document; - - if (activeElement !== container && !container.contains(activeElement)) { - return; - } - - if (isEditable(activeElement)) { - return; - } - - const delta = getKeyDelta(scrollbar, evt.keyCode || evt.which); - - if (!delta) { - return; - } - - const [x, y] = delta; - - scrollbar.addTransformableMomentum(x, y, evt, (willScroll) => { - if (willScroll) { - evt.preventDefault(); - } else { - scrollbar.containerEl.blur(); - - if (scrollbar.parent) { - scrollbar.parent.containerEl.focus(); - } - } - }); - }); -} - -function getKeyDelta(scrollbar: I.Scrollbar, keyCode: number) { - const { - size, - limit, - offset, - } = scrollbar; - - switch (keyCode) { - case KEY_CODE.TAB: - return handleTabKey(scrollbar); - case KEY_CODE.SPACE: - return [0, 200]; - case KEY_CODE.PAGE_UP: - return [0, -size.container.height + 40]; - case KEY_CODE.PAGE_DOWN: - return [0, size.container.height - 40]; - case KEY_CODE.END: - return [0, limit.y - offset.y]; - case KEY_CODE.HOME: - return [0, -offset.y]; - case KEY_CODE.LEFT: - return [-40, 0]; - case KEY_CODE.UP: - return [0, -40]; - case KEY_CODE.RIGHT: - return [40, 0]; - case KEY_CODE.DOWN: - return [0, 40]; - default: - return null; - } -} - -function handleTabKey(scrollbar: I.Scrollbar) { - // handle in next frame - requestAnimationFrame(() => { - scrollbar.scrollIntoView(document.activeElement as HTMLElement, { - offsetTop: scrollbar.size.container.height / 2, - offsetLeft: scrollbar.size.container.width / 2, - onlyScrollIfNeeded: true, - }); - }); -} - -function isEditable(elem: any): boolean { - if (elem.tagName === 'INPUT' || - elem.tagName === 'SELECT' || - elem.tagName === 'TEXTAREA' || - elem.isContentEditable) { - return !elem.disabled; - } - - return false; -} diff --git a/src/events/mouse.ts b/src/events/mouse.ts deleted file mode 100644 index 854b73ea..00000000 --- a/src/events/mouse.ts +++ /dev/null @@ -1,141 +0,0 @@ -import { clamp } from '../utils'; -import * as I from '../interfaces/'; - -import { - isOneOf, - getPosition, - eventScope, - setStyle, -} from '../utils/'; - -enum Direction { X, Y } - -export function mouseHandler(scrollbar: I.Scrollbar) { - const addEvent = eventScope(scrollbar); - const container = scrollbar.containerEl; - const { xAxis, yAxis } = scrollbar.track; - - function calcMomentum( - direction: Direction, - clickPosition: number, - ): number { - const { - size, - limit, - offset, - } = scrollbar; - - if (direction === Direction.X) { - const totalWidth = size.container.width + (xAxis.thumb.realSize - xAxis.thumb.displaySize); - - return clamp(clickPosition / totalWidth * size.content.width, 0, limit.x) - offset.x; - } - - if (direction === Direction.Y) { - const totalHeight = size.container.height + (yAxis.thumb.realSize - yAxis.thumb.displaySize); - - return clamp(clickPosition / totalHeight * size.content.height, 0, limit.y) - offset.y; - } - - return 0; - } - - function getTrackDirection( - elem: HTMLElement, - ): Direction | undefined { - if (isOneOf(elem, [xAxis.element, xAxis.thumb.element])) { - return Direction.X; - } - - if (isOneOf(elem, [yAxis.element, yAxis.thumb.element])) { - return Direction.Y; - } - - return void 0; - } - - let isMouseDown: boolean; - let isMouseMoving: boolean; - let startOffsetToThumb: { x: number, y: number }; - let trackDirection: Direction | undefined; - let containerRect: ClientRect; - - addEvent(container, 'click', (evt: MouseEvent) => { - if (isMouseMoving || !isOneOf(evt.target, [xAxis.element, yAxis.element])) { - return; - } - - const track = evt.target as HTMLElement; - const direction = getTrackDirection(track); - const rect = track.getBoundingClientRect(); - const clickPos = getPosition(evt); - - if (direction === Direction.X) { - const offsetOnTrack = clickPos.x - rect.left - xAxis.thumb.displaySize / 2; - scrollbar.setMomentum(calcMomentum(direction, offsetOnTrack), 0); - } - - if (direction === Direction.Y) { - const offsetOnTrack = clickPos.y - rect.top - yAxis.thumb.displaySize / 2; - scrollbar.setMomentum(0, calcMomentum(direction, offsetOnTrack)); - } - }); - - addEvent(container, 'mousedown', (evt: MouseEvent) => { - if (!isOneOf(evt.target, [xAxis.thumb.element, yAxis.thumb.element])) { - return; - } - - isMouseDown = true; - - const thumb = evt.target as HTMLElement; - const cursorPos = getPosition(evt); - const thumbRect = thumb.getBoundingClientRect(); - - trackDirection = getTrackDirection(thumb); - - // pointer offset to thumb - startOffsetToThumb = { - x: cursorPos.x - thumbRect.left, - y: cursorPos.y - thumbRect.top, - }; - - // container bounding rectangle - containerRect = container.getBoundingClientRect(); - - // prevent selection, see: - // https://github.com/idiotWu/smooth-scrollbar/issues/48 - setStyle(scrollbar.containerEl, { - '-user-select': 'none', - }); - }); - - addEvent(window, 'mousemove', (evt) => { - if (!isMouseDown) return; - - isMouseMoving = true; - - const cursorPos = getPosition(evt); - - if (trackDirection === Direction.X) { - // get percentage of pointer position in track - // then tranform to px - // don't need easing - const offsetOnTrack = cursorPos.x - startOffsetToThumb.x - containerRect.left; - scrollbar.setMomentum(calcMomentum(trackDirection, offsetOnTrack), 0); - } - - if (trackDirection === Direction.Y) { - const offsetOnTrack = cursorPos.y - startOffsetToThumb.y - containerRect.top; - scrollbar.setMomentum(0, calcMomentum(trackDirection, offsetOnTrack)); - } - }); - - addEvent(window, 'mouseup blur', () => { - isMouseDown = isMouseMoving = false; - - setStyle(scrollbar.containerEl, { - '-user-select': '', - }); - }); -} diff --git a/src/events/resize.ts b/src/events/resize.ts deleted file mode 100644 index 0bbf0558..00000000 --- a/src/events/resize.ts +++ /dev/null @@ -1,16 +0,0 @@ -import * as I from '../interfaces/'; -import { debounce } from '../utils'; - -import { - eventScope, -} from '../utils/'; - -export function resizeHandler(scrollbar: I.Scrollbar) { - const addEvent = eventScope(scrollbar); - - addEvent( - window, - 'resize', - debounce(scrollbar.update.bind(scrollbar), 300), - ); -} diff --git a/src/events/select.ts b/src/events/select.ts deleted file mode 100644 index 116e614a..00000000 --- a/src/events/select.ts +++ /dev/null @@ -1,118 +0,0 @@ -import { clamp } from '../utils'; -import * as I from '../interfaces/'; - -import { - eventScope, - getPosition, -} from '../utils/'; - -export function selectHandler(scrollbar: I.Scrollbar) { - const addEvent = eventScope(scrollbar); - const { containerEl, contentEl } = scrollbar; - - let isSelected = false; - let isContextMenuOpened = false; // flag to prevent selection when context menu is opened - let animationID: number; - - function scroll({ x, y }) { - if (!x && !y) return; - - const { offset, limit } = scrollbar; - // DISALLOW delta transformation - scrollbar.setMomentum( - clamp(offset.x + x, 0, limit.x) - offset.x, - clamp(offset.y + y, 0, limit.y) - offset.y, - ); - - animationID = requestAnimationFrame(() => { - scroll({ x, y }); - }); - } - - addEvent(window, 'mousemove', (evt: MouseEvent) => { - if (!isSelected) return; - - cancelAnimationFrame(animationID); - - const dir = calcMomentum(scrollbar, evt); - - scroll(dir); - }); - - // prevent scrolling when context menu is opened - // NOTE: `contextmenu` event may be fired - // 1. BEFORE `selectstart`: when user right-clicks on the text content -> prevent future scrolling, - // 2. AFTER `selectstart`: when user right-clicks on the blank area -> cancel current scrolling, - // so we need to both set the flag and cancel current scrolling - addEvent(contentEl, 'contextmenu', () => { - // set the flag to prevent future scrolling - isContextMenuOpened = true; - - // stop current scrolling - cancelAnimationFrame(animationID); - isSelected = false; - }); - - // reset context menu flag on mouse down - // to ensure the scrolling is allowed in the next selection - addEvent(contentEl, 'mousedown', () => { - isContextMenuOpened = false; - }); - - addEvent(contentEl, 'selectstart', () => { - if (isContextMenuOpened) { - return; - } - - cancelAnimationFrame(animationID); - - isSelected = true; - }); - - addEvent(window, 'mouseup blur', () => { - cancelAnimationFrame(animationID); - - isSelected = false; - isContextMenuOpened = false; - }); - - // patch for touch devices - addEvent(containerEl, 'scroll', (evt: Event) => { - evt.preventDefault(); - containerEl.scrollTop = containerEl.scrollLeft = 0; - }); -} - -function calcMomentum( - scrollbar: I.Scrollbar, - evt: MouseEvent, -) { - const { top, right, bottom, left } = scrollbar.bounding; - const { x, y } = getPosition(evt); - - const res = { - x: 0, - y: 0, - }; - - const padding = 20; - - if (x === 0 && y === 0) return res; - - if (x > right - padding) { - res.x = (x - right + padding); - } else if (x < left + padding) { - res.x = (x - left - padding); - } - - if (y > bottom - padding) { - res.y = (y - bottom + padding); - } else if (y < top + padding) { - res.y = (y - top - padding); - } - - res.x *= 2; - res.y *= 2; - - return res; -} diff --git a/src/events/touch.ts b/src/events/touch.ts deleted file mode 100644 index e62e006e..00000000 --- a/src/events/touch.ts +++ /dev/null @@ -1,68 +0,0 @@ -import * as I from '../interfaces/'; - -import { - eventScope, - TouchRecord, -} from '../utils/'; - -let activeScrollbar: I.Scrollbar | null; - -export function touchHandler(scrollbar: I.Scrollbar) { - const target = scrollbar.options.delegateTo || scrollbar.containerEl; - const touchRecord = new TouchRecord(); - const addEvent = eventScope(scrollbar); - - let damping: number; - let pointerCount = 0; - - addEvent(target, 'touchstart', (evt: TouchEvent) => { - // start records - touchRecord.track(evt); - - // stop scrolling - scrollbar.setMomentum(0, 0); - - // save damping - if (pointerCount === 0) { - damping = scrollbar.options.damping; - scrollbar.options.damping = Math.max(damping, 0.5); // less frames on touchmove - } - - pointerCount++; - }); - - addEvent(target, 'touchmove', (evt: TouchEvent) => { - if (activeScrollbar && activeScrollbar !== scrollbar) return; - - touchRecord.update(evt); - - const { x, y } = touchRecord.getDelta(); - - scrollbar.addTransformableMomentum(x, y, evt, (willScroll) => { - if (willScroll && evt.cancelable) { - evt.preventDefault(); - activeScrollbar = scrollbar; - } - }); - }); - - addEvent(target, 'touchcancel touchend', (evt: TouchEvent) => { - const delta = touchRecord.getEasingDistance(damping); - - scrollbar.addTransformableMomentum( - delta.x, - delta.y, - evt, - ); - - pointerCount--; - - // restore damping - if (pointerCount === 0) { - scrollbar.options.damping = damping; - } - - touchRecord.release(evt); - activeScrollbar = null; - }); -} diff --git a/src/events/wheel.ts b/src/events/wheel.ts deleted file mode 100644 index 1bdf55bf..00000000 --- a/src/events/wheel.ts +++ /dev/null @@ -1,58 +0,0 @@ -import * as I from '../interfaces/'; - -import { - eventScope, -} from '../utils/'; - -export function wheelHandler(scrollbar: I.Scrollbar) { - const addEvent = eventScope(scrollbar); - - const target = scrollbar.options.delegateTo || scrollbar.containerEl; - - const eventName = ('onwheel' in window || document.implementation.hasFeature('Events.wheel', '3.0')) ? 'wheel' : 'mousewheel'; - - addEvent(target, eventName, (evt: WheelEvent) => { - const { x, y } = normalizeDelta(evt); - - scrollbar.addTransformableMomentum(x, y, evt, (willScroll) => { - if (willScroll) { - evt.preventDefault(); - } - }); - }); -} - -// Normalizing wheel delta - -const DELTA_SCALE = { - STANDARD: 1, - OTHERS: -3, -}; - -const DELTA_MODE = [1.0, 28.0, 500.0]; - -const getDeltaMode = (mode) => DELTA_MODE[mode] || DELTA_MODE[0]; - -function normalizeDelta(evt: any) { - if ('deltaX' in evt) { - const mode = getDeltaMode(evt.deltaMode); - - return { - x: evt.deltaX / DELTA_SCALE.STANDARD * mode, - y: evt.deltaY / DELTA_SCALE.STANDARD * mode, - }; - } - - if ('wheelDeltaX' in evt) { - return { - x: evt.wheelDeltaX / DELTA_SCALE.OTHERS, - y: evt.wheelDeltaY / DELTA_SCALE.OTHERS, - }; - } - - // ie with touchpad - return { - x: 0, - y: evt.wheelDelta / DELTA_SCALE.OTHERS, - }; -} diff --git a/src/geometry/get-size.ts b/src/geometry/get-size.ts deleted file mode 100644 index 633dd13b..00000000 --- a/src/geometry/get-size.ts +++ /dev/null @@ -1,33 +0,0 @@ -import * as I from '../interfaces/'; - -export function getSize(scrollbar: I.Scrollbar): I.ScrollbarSize { - const { - containerEl, - contentEl, - } = scrollbar; - - const containerStyles = getComputedStyle(containerEl); - const paddings = [ - 'paddingTop', - 'paddingBottom', - 'paddingLeft', - 'paddingRight', - ].map(prop => { - return containerStyles[prop] ? parseFloat(containerStyles[prop]) : 0; - }); - const verticalPadding = paddings[0] + paddings[1]; - const horizontalPadding = paddings[2] + paddings[3]; - - return { - container: { - // requires `overflow: hidden` - width: containerEl.clientWidth, - height: containerEl.clientHeight, - }, - content: { - // border width and paddings should be included - width: contentEl.offsetWidth - contentEl.clientWidth + contentEl.scrollWidth + horizontalPadding, - height: contentEl.offsetHeight - contentEl.clientHeight + contentEl.scrollHeight + verticalPadding, - }, - }; -} diff --git a/src/geometry/index.ts b/src/geometry/index.ts deleted file mode 100644 index d094451e..00000000 --- a/src/geometry/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from './get-size'; -export * from './is-visible'; -export * from './update'; diff --git a/src/geometry/is-visible.ts b/src/geometry/is-visible.ts deleted file mode 100644 index 3c352c6f..00000000 --- a/src/geometry/is-visible.ts +++ /dev/null @@ -1,14 +0,0 @@ -import * as I from '../interfaces/'; - -export function isVisible(scrollbar: I.Scrollbar, elem: HTMLElement): boolean { - const { bounding } = scrollbar; - const targetBounding = elem.getBoundingClientRect(); - - // check overlapping - const top = Math.max(bounding.top, targetBounding.top); - const left = Math.max(bounding.left, targetBounding.left); - const right = Math.min(bounding.right, targetBounding.right); - const bottom = Math.min(bounding.bottom, targetBounding.bottom); - - return top < bottom && left < right; -} diff --git a/src/index.ts b/src/index.ts deleted file mode 100644 index dbe2de69..00000000 --- a/src/index.ts +++ /dev/null @@ -1,140 +0,0 @@ -import './polyfills'; -import * as I from './interfaces/'; - -import { - scrollbarMap, - Scrollbar, -} from './scrollbar'; - -import { - addPlugins, - ScrollbarPlugin, -} from './plugin'; - -import { - attachStyle, - detachStyle, -} from './style'; - -export { ScrollbarPlugin }; - -declare var __SCROLLBAR_VERSION__: string; - -/** - * cast `I.Scrollbar` to `Scrollbar` to avoid error - * - * `I.Scrollbar` is not assignable to `Scrollbar`: - * "privateProp" is missing in `I.Scrollbar` - * - * @see https://github.com/Microsoft/TypeScript/issues/2672 - */ - -export default class SmoothScrollbar extends Scrollbar { - static version = __SCROLLBAR_VERSION__; - - static ScrollbarPlugin = ScrollbarPlugin; - - /** - * Initializes a scrollbar on the given element. - * - * @param elem The DOM element that you want to initialize scrollbar to - * @param [options] Initial options - */ - static init(elem: HTMLElement, options?: Partial): Scrollbar { - if (!elem || elem.nodeType !== 1) { - throw new TypeError(`expect element to be DOM Element, but got ${elem}`); - } - - // attach stylesheet - attachStyle(); - - if (scrollbarMap.has(elem)) { - return scrollbarMap.get(elem) as Scrollbar; - } - - return new Scrollbar(elem, options); - } - - /** - * Automatically init scrollbar on all elements base on the selector `[data-scrollbar]` - * - * @param options Initial options - */ - static initAll(options?: Partial): Scrollbar[] { - return Array.from(document.querySelectorAll('[data-scrollbar]'), (elem: HTMLElement) => { - return SmoothScrollbar.init(elem, options); - }); - } - - /** - * Check if there is a scrollbar on given element - * - * @param elem The DOM element that you want to check - */ - static has(elem: HTMLElement): boolean { - return scrollbarMap.has(elem); - } - - /** - * Gets scrollbar on the given element. - * If no scrollbar instance exsits, returns `undefined` - * - * @param elem The DOM element that you want to check. - */ - static get(elem: HTMLElement): Scrollbar | undefined { - return scrollbarMap.get(elem) as (Scrollbar | undefined); - } - - /** - * Returns an array that contains all scrollbar instances - */ - static getAll(): Scrollbar[] { - return Array.from(scrollbarMap.values()) as Scrollbar[]; - } - - /** - * Removes scrollbar on the given element - */ - static destroy(elem: HTMLElement) { - const scrollbar = scrollbarMap.get(elem); - - if (scrollbar) { - scrollbar.destroy(); - } - } - - /** - * Removes all scrollbar instances from current document - */ - static destroyAll() { - scrollbarMap.forEach((scrollbar) => { - scrollbar.destroy(); - }); - } - - /** - * Attaches plugins to scrollbars - * - * @param ...Plugins Scrollbar plugin classes - */ - static use(...Plugins: (typeof ScrollbarPlugin)[]) { - return addPlugins(...Plugins); - } - - /** - * Attaches default style sheets to current document. - * You don't need to call this method manually unless - * you removed the default styles via `Scrollbar.detachStyle()` - */ - static attachStyle() { - return attachStyle(); - } - - /** - * Removes default styles from current document. - * Use this method when you want to use your own css for scrollbars. - */ - static detachStyle() { - return detachStyle(); - } -} diff --git a/src/polyfills.ts b/src/polyfills.ts deleted file mode 100644 index 01f09bec..00000000 --- a/src/polyfills.ts +++ /dev/null @@ -1,5 +0,0 @@ -import 'core-js/es/map'; -import 'core-js/es/set'; -import 'core-js/es/weak-map'; -import 'core-js/es/array/from'; -import 'core-js/es/object/assign'; diff --git a/src/scrollbar.ts b/src/scrollbar.ts deleted file mode 100644 index 3c8309e2..00000000 --- a/src/scrollbar.ts +++ /dev/null @@ -1,516 +0,0 @@ -import { clamp } from './utils'; - -import { Options } from './options'; - -import { - setStyle, - clearEventsOn, -} from './utils/'; - -import { - debounce, -} from './decorators/'; - -import { - TrackController, -} from './track/'; - -import { - getSize, - update, - isVisible, -} from './geometry/'; - -import { - scrollTo, - setPosition, - scrollIntoView, -} from './scrolling/'; - -import { - initPlugins, -} from './plugin'; - -import * as eventHandlers from './events/'; - -import * as I from './interfaces/'; - -// DO NOT use WeakMap here -// .getAll() methods requires `scrollbarMap.values()` -export const scrollbarMap = new Map(); - -export class Scrollbar implements I.Scrollbar { - /** - * Options for current scrollbar instancs - */ - readonly options: Options; - - readonly track: TrackController; - - /** - * The element that you initialized scrollbar to - */ - readonly containerEl: HTMLElement; - - /** - * The wrapper element that contains your contents - */ - readonly contentEl: HTMLElement; - - /** - * Geometry infomation for current scrollbar instance - */ - size: I.ScrollbarSize; - - /** - * Current scrolling offsets - */ - offset = { - x: 0, - y: 0, - }; - - /** - * Max-allowed scrolling offsets - */ - limit = { - x: Infinity, - y: Infinity, - }; - - /** - * Container bounding rect - */ - bounding = { - top: 0, - right: 0, - bottom: 0, - left: 0, - }; - - /** - * Parent scrollbar - */ - get parent() { - let elem = this.containerEl.parentElement; - - while (elem) { - const parentScrollbar = scrollbarMap.get(elem); - - if (parentScrollbar) { - return parentScrollbar; - } - - elem = elem.parentElement; - } - - return null; - } - - /** - * Gets or sets `scrollbar.offset.y` - */ - get scrollTop() { - return this.offset.y; - } - set scrollTop(y: number) { - this.setPosition(this.scrollLeft, y); - } - - /** - * Gets or sets `scrollbar.offset.x` - */ - get scrollLeft() { - return this.offset.x; - } - set scrollLeft(x: number) { - this.setPosition(x, this.scrollTop); - } - - private _renderID: number; - private _observer: any; // FIXME: we need to update typescript version to support `ResizeObserver` - // private _observer: ResizeObserver; - private _plugins: I.ScrollbarPlugin[] = []; - - private _momentum = { x: 0, y: 0 }; - private _listeners = new Set(); - - constructor( - containerEl: HTMLElement, - options?: Partial, - ) { - this.containerEl = containerEl; - const contentEl = this.contentEl = document.createElement('div'); - - this.options = new Options(options); - - // mark as a scroll element - containerEl.setAttribute('data-scrollbar', 'true'); - - // make container focusable - containerEl.setAttribute('tabindex', '-1'); - setStyle(containerEl, { - overflow: 'hidden', - outline: 'none', - }); - - // enable touch event capturing in IE, see: - // https://github.com/idiotWu/smooth-scrollbar/issues/39 - if (window.navigator.msPointerEnabled) { - containerEl.style.msTouchAction = 'none'; - } - - // mount content - contentEl.className = 'scroll-content'; - - Array.from(containerEl.childNodes).forEach((node) => { - contentEl.appendChild(node); - }); - - containerEl.appendChild(contentEl); - - // attach track - this.track = new TrackController(this); - - // initial measuring - this.size = this.getSize(); - - // init plugins - this._plugins = initPlugins(this, this.options.plugins); - - // preserve scroll offset - const { scrollLeft, scrollTop } = containerEl; - containerEl.scrollLeft = containerEl.scrollTop = 0; - this.setPosition(scrollLeft, scrollTop, { - withoutCallbacks: true, - }); - - // FIXME: update typescript - const ResizeObserver = (window as any).ResizeObserver; - - // observe - if (typeof ResizeObserver === 'function') { - this._observer = new ResizeObserver(() => { - this.update(); - }); - - this._observer.observe(contentEl); - } - - scrollbarMap.set(containerEl, this); - - // wait for DOM ready - requestAnimationFrame(() => { - this._init(); - }); - } - - /** - * Returns the size of the scrollbar container element - * and the content wrapper element - */ - getSize(): I.ScrollbarSize { - return getSize(this); - } - - /** - * Forces scrollbar to update geometry infomation. - * - * By default, scrollbars are automatically updated with `100ms` debounce (or `MutationObserver` fires). - * You can call this method to force an update when you modified contents - */ - update() { - update(this); - - this._plugins.forEach((plugin) => { - plugin.onUpdate(); - }); - } - - /** - * Checks if an element is visible in the current view area - */ - isVisible(elem: HTMLElement): boolean { - return isVisible(this, elem); - } - - /** - * Sets the scrollbar to the given offset without easing - */ - setPosition( - x = this.offset.x, - y = this.offset.y, - options: Partial = {}, - ) { - const status = setPosition(this, x, y); - - if (!status || options.withoutCallbacks) { - return; - } - - this._listeners.forEach((fn) => { - fn.call(this, status); - }); - } - - /** - * Scrolls to given position with easing function - */ - scrollTo( - x = this.offset.x, - y = this.offset.y, - duration = 0, - options: Partial = {}, - ) { - scrollTo(this, x, y, duration, options); - } - - /** - * Scrolls the target element into visible area of scrollbar, - * likes the DOM method `element.scrollIntoView(). - */ - scrollIntoView( - elem: HTMLElement, - options: Partial = {}, - ) { - scrollIntoView(this, elem, options); - } - - /** - * Adds scrolling listener - */ - addListener(fn: I.ScrollListener) { - if (typeof fn !== 'function') { - throw new TypeError('[smooth-scrollbar] scrolling listener should be a function'); - } - - this._listeners.add(fn); - } - - /** - * Removes listener previously registered with `scrollbar.addListener()` - */ - removeListener(fn: I.ScrollListener) { - this._listeners.delete(fn); - } - - /** - * Adds momentum and applys delta transformers. - */ - addTransformableMomentum( - x: number, - y: number, - fromEvent: Event, - callback?: I.AddTransformableMomentumCallback, - ) { - this._updateDebounced(); - - const finalDelta = this._plugins.reduce((delta, plugin) => { - return plugin.transformDelta(delta, fromEvent) || delta; - }, { x, y }); - - const willScroll = !this._shouldPropagateMomentum(finalDelta.x, finalDelta.y); - - if (willScroll) { - this.addMomentum(finalDelta.x, finalDelta.y); - } - - if (callback) { - callback.call(this, willScroll); - } - } - - /** - * Increases scrollbar's momentum - */ - addMomentum(x: number, y: number) { - this.setMomentum( - this._momentum.x + x, - this._momentum.y + y, - ); - } - - /** - * Sets scrollbar's momentum to given value - */ - setMomentum(x: number, y: number) { - if (this.limit.x === 0) { - x = 0; - } - if (this.limit.y === 0) { - y = 0; - } - - if (this.options.renderByPixels) { - x = Math.round(x); - y = Math.round(y); - } - - this._momentum.x = x; - this._momentum.y = y; - } - - /** - * Update options for specific plugin - * - * @param pluginName Name of the plugin - * @param [options] An object includes the properties that you want to update - */ - updatePluginOptions(pluginName: string, options?: any) { - this._plugins.forEach((plugin) => { - if (plugin.name === pluginName) { - Object.assign(plugin.options, options); - } - }); - } - - destroy() { - const { - containerEl, - contentEl, - } = this; - - clearEventsOn(this); - this._listeners.clear(); - - this.setMomentum(0, 0); - cancelAnimationFrame(this._renderID); - - if (this._observer) { - this._observer.disconnect(); - } - - scrollbarMap.delete(this.containerEl); - - // restore contents - const childNodes = Array.from(contentEl.childNodes); - - while (containerEl.firstChild) { - containerEl.removeChild(containerEl.firstChild); - } - - childNodes.forEach((el) => { - containerEl.appendChild(el); - }); - - // reset scroll position - setStyle(containerEl, { - overflow: '', - }); - containerEl.scrollTop = this.scrollTop; - containerEl.scrollLeft = this.scrollLeft; - - // invoke plugin.onDestroy - this._plugins.forEach((plugin) => { - plugin.onDestroy(); - }); - this._plugins.length = 0; - } - - private _init() { - this.update(); - - // init evet handlers - Object.keys(eventHandlers).forEach((prop) => { - eventHandlers[prop](this); - }); - - // invoke `plugin.onInit` - this._plugins.forEach((plugin) => { - plugin.onInit(); - }); - - this._render(); - } - - @debounce(100, true) - private _updateDebounced() { - this.update(); - } - - // check whether to propagate monmentum to parent scrollbar - // the following situations are considered as `true`: - // 1. continuous scrolling is enabled (automatically disabled when overscroll is enabled) - // 2. scrollbar reaches one side and is not about to scroll on the other direction - private _shouldPropagateMomentum(deltaX = 0, deltaY = 0): boolean { - const { - options, - offset, - limit, - } = this; - - if (!options.continuousScrolling) return false; - - // force an update when scrollbar is "unscrollable", see #106 - if (limit.x === 0 && limit.y === 0) { - this._updateDebounced(); - } - - const destX = clamp(deltaX + offset.x, 0, limit.x); - const destY = clamp(deltaY + offset.y, 0, limit.y); - let res = true; - - // offsets are not about to change - // `&=` operator is not allowed for boolean types - res = res && (destX === offset.x); - res = res && (destY === offset.y); - - // current offsets are on the edge - res = res && (offset.x === limit.x || offset.x === 0 || offset.y === limit.y || offset.y === 0); - - return res; - } - - private _render() { - const { - _momentum, - } = this; - - if (_momentum.x || _momentum.y) { - const nextX = this._nextTick('x'); - const nextY = this._nextTick('y'); - - _momentum.x = nextX.momentum; - _momentum.y = nextY.momentum; - - this.setPosition(nextX.position, nextY.position); - } - - const remain = { ...this._momentum }; - - this._plugins.forEach((plugin) => { - plugin.onRender(remain); - }); - - this._renderID = requestAnimationFrame(this._render.bind(this)); - } - - private _nextTick(direction: 'x' | 'y'): { momentum: number, position: number } { - const { - options, - offset, - _momentum, - } = this; - - const current = offset[direction]; - const remain = _momentum[direction]; - - if (Math.abs(remain) <= 0.1) { - return { - momentum: 0, - position: current + remain, - }; - } - - let nextMomentum = remain * (1 - options.damping); - - if (options.renderByPixels) { - nextMomentum |= 0; - } - - return { - momentum: nextMomentum, - position: current + remain - nextMomentum, - }; - } -} diff --git a/src/scrolling/index.ts b/src/scrolling/index.ts deleted file mode 100644 index 62fd19ff..00000000 --- a/src/scrolling/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from './set-position'; -export * from './scroll-to'; -export * from './scroll-into-view'; diff --git a/src/scrolling/scroll-to.ts b/src/scrolling/scroll-to.ts deleted file mode 100644 index eb9ae7ea..00000000 --- a/src/scrolling/scroll-to.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { clamp } from '../utils'; - -import * as I from '../interfaces/'; - -const animationIDStorage = new WeakMap(); - -export function scrollTo( - scrollbar: I.Scrollbar, - x: number, - y: number, - duration = 0, - { easing = defaultEasing, callback }: Partial = {}, -) { - const { - options, - offset, - limit, - } = scrollbar; - - if (options.renderByPixels) { - // ensure resolved with integer - x = Math.round(x); - y = Math.round(y); - } - - const startX = offset.x; - const startY = offset.y; - - const disX = clamp(x, 0, limit.x) - startX; - const disY = clamp(y, 0, limit.y) - startY; - - const start = Date.now(); - - function scroll() { - const elapse = Date.now() - start; - const progress = duration ? easing(Math.min(elapse / duration, 1)) : 1; - - scrollbar.setPosition( - startX + disX * progress, - startY + disY * progress, - ); - - if (elapse >= duration) { - if (typeof callback === 'function') { - callback.call(scrollbar); - } - } else { - const animationID = requestAnimationFrame(scroll); - animationIDStorage.set(scrollbar, animationID); - } - } - - cancelAnimationFrame(animationIDStorage.get(scrollbar) as number); - scroll(); -} - -/** - * easeOutCubic - */ -function defaultEasing(t: number): number { - return (t - 1) ** 3 + 1; -} diff --git a/src/scrolling/set-position.ts b/src/scrolling/set-position.ts deleted file mode 100644 index d17fc908..00000000 --- a/src/scrolling/set-position.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { clamp } from '../utils'; -import * as I from '../interfaces/'; - -import { - setStyle, -} from '../utils/'; - -export function setPosition( - scrollbar: I.Scrollbar, - x: number, - y: number, -): I.ScrollStatus | null { - const { - options, - offset, - limit, - track, - contentEl, - } = scrollbar; - - if (options.renderByPixels) { - x = Math.round(x); - y = Math.round(y); - } - - x = clamp(x, 0, limit.x); - y = clamp(y, 0, limit.y); - - // position changed -> show track for 300ms - if (x !== offset.x) track.xAxis.show(); - if (y !== offset.y) track.yAxis.show(); - - if (!options.alwaysShowTracks) { - track.autoHideOnIdle(); - } - - if (x === offset.x && y === offset.y) { - return null; - } - - offset.x = x; - offset.y = y; - - setStyle(contentEl, { - '-transform': `translate3d(${-x}px, ${-y}px, 0)`, - }); - - track.update(); - - return { - offset: { ...offset }, - limit: { ...limit }, - }; -} diff --git a/src/style.ts b/src/style.ts deleted file mode 100644 index fea4df24..00000000 --- a/src/style.ts +++ /dev/null @@ -1,92 +0,0 @@ -const TRACK_BG = 'rgba(222, 222, 222, .75)'; -const THUMB_BG = 'rgba(0, 0, 0, .5)'; - -// sets content's display type to `flow-root` to suppress margin collapsing -const SCROLLBAR_STYLE = ` -[data-scrollbar] { - display: block; - position: relative; -} - -.scroll-content { - display: flow-root; - -webkit-transform: translate3d(0, 0, 0); - transform: translate3d(0, 0, 0); -} - -.scrollbar-track { - position: absolute; - opacity: 0; - z-index: 1; - background: ${TRACK_BG}; - -webkit-user-select: none; - -moz-user-select: none; - -ms-user-select: none; - user-select: none; - -webkit-transition: opacity 0.5s 0.5s ease-out; - transition: opacity 0.5s 0.5s ease-out; -} -.scrollbar-track.show, -.scrollbar-track:hover { - opacity: 1; - -webkit-transition-delay: 0s; - transition-delay: 0s; -} - -.scrollbar-track-x { - bottom: 0; - left: 0; - width: 100%; - height: 8px; -} -.scrollbar-track-y { - top: 0; - right: 0; - width: 8px; - height: 100%; -} -.scrollbar-thumb { - position: absolute; - top: 0; - left: 0; - width: 8px; - height: 8px; - background: ${THUMB_BG}; - border-radius: 4px; -} -`; - -const STYLE_ID = 'smooth-scrollbar-style'; -let isStyleAttached = false; - -export function attachStyle() { - if (isStyleAttached || typeof window === 'undefined') { - return; - } - - const styleEl = document.createElement('style'); - styleEl.id = STYLE_ID; - styleEl.textContent = SCROLLBAR_STYLE; - - if (document.head) { - document.head.appendChild(styleEl); - } - - isStyleAttached = true; -} - -export function detachStyle() { - if (!isStyleAttached || typeof window === 'undefined') { - return; - } - - const styleEl = document.getElementById(STYLE_ID); - - if (!styleEl || !styleEl.parentNode) { - return; - } - - styleEl.parentNode.removeChild(styleEl); - - isStyleAttached = false; -} diff --git a/src/track/index.ts b/src/track/index.ts deleted file mode 100644 index c3ab8bcd..00000000 --- a/src/track/index.ts +++ /dev/null @@ -1,56 +0,0 @@ -import * as I from '../interfaces/'; - -import { ScrollbarTrack } from './track'; -import { TrackDirection } from './direction'; - -import { - debounce, -} from '../decorators/'; - -export class TrackController implements I.TrackController { - readonly xAxis: ScrollbarTrack; - readonly yAxis: ScrollbarTrack; - - constructor( - private _scrollbar: I.Scrollbar, - ) { - const thumbMinSize = _scrollbar.options.thumbMinSize; - - this.xAxis = new ScrollbarTrack(TrackDirection.X, thumbMinSize); - this.yAxis = new ScrollbarTrack(TrackDirection.Y, thumbMinSize); - - this.xAxis.attachTo(_scrollbar.containerEl); - this.yAxis.attachTo(_scrollbar.containerEl); - - if (_scrollbar.options.alwaysShowTracks) { - this.xAxis.show(); - this.yAxis.show(); - } - } - - /** - * Updates track appearance - */ - update() { - const { - size, - offset, - } = this._scrollbar; - - this.xAxis.update(offset.x, size.container.width, size.content.width); - this.yAxis.update(offset.y, size.container.height, size.content.height); - } - - /** - * Automatically hide tracks when scrollbar is in idle state - */ - @debounce(300) - autoHideOnIdle() { - if (this._scrollbar.options.alwaysShowTracks) { - return; - } - - this.xAxis.hide(); - this.yAxis.hide(); - } -} diff --git a/src/track/track.ts b/src/track/track.ts deleted file mode 100644 index 701fa5d6..00000000 --- a/src/track/track.ts +++ /dev/null @@ -1,77 +0,0 @@ -import * as I from '../interfaces/'; -import { TrackDirection } from './direction'; -import { ScrollbarThumb } from './thumb'; - -import { - setStyle, -} from '../utils/'; - -export class ScrollbarTrack implements I.ScrollbarTrack { - readonly thumb: ScrollbarThumb; - - /** - * Track element - */ - readonly element = document.createElement('div'); - - private _isShown = false; - - constructor( - direction: TrackDirection, - thumbMinSize: number = 0, - ) { - this.element.className = `scrollbar-track scrollbar-track-${direction}`; - - this.thumb = new ScrollbarThumb( - direction, - thumbMinSize, - ); - - this.thumb.attachTo(this.element); - } - - /** - * Attach to scrollbar container element - * - * @param scrollbarContainer Scrollbar container element - */ - attachTo(scrollbarContainer: HTMLElement) { - scrollbarContainer.appendChild(this.element); - } - - /** - * Show track immediately - */ - show() { - if (this._isShown) { - return; - } - - this._isShown = true; - this.element.classList.add('show'); - } - - /** - * Hide track immediately - */ - hide() { - if (!this._isShown) { - return; - } - - this._isShown = false; - this.element.classList.remove('show'); - } - - update( - scrollOffset: number, - containerSize: number, - pageSize: number, - ) { - setStyle(this.element, { - display: pageSize <= containerSize ? 'none' : 'block', - }); - - this.thumb.update(scrollOffset, containerSize, pageSize); - } -}