/** @module dom */
import { transformDashToCamelCase, isArray, isString, isObject, isFunction, shallowMerge, percentage } from './helpers.mjs'
import { encodeHtmlEntities, decodeHtmlEntities } from './parsers.mjs'
/**
* Checks if an element is empty
*
* @param {HTMLElement} element
* @returns boolean
* @example
* document.body.innerHTML = `
* <div id="empty-element"></div>
* <div id="non-empty-element1">foo</div>
* <div id="non-empty-element2"><br></div>`
*
* isEmptyElement(document.getElementById('empty-element')) // => true
* isEmptyElement(document.getElementById('non-empty-element1')) // => false
* isEmptyElement(document.getElementById('non-empty-element2')) // => false
*/
export function isEmptyElement(element) {
return element.innerHTML.trim() === ''
}
/**
* Removes all elements matching a selector from the DOM
*
* @param {string|HTMLElement|Element} selector The selector to select elements to remove
* @param {HTMLElement|Element} [from=document] The element to remove elements from
* @example
* document.body.innerHTML = `
* <div id="foo"></div>
* <div id="bar"></div>
* <div id="baz"></div>`
* `
* remove('#foo, #bar') // => removes #foo and #bar
*/
export function remove(selector, from = document) {
const elements = query(selector, from)
for (const element of elements) {
element.remove()
}
}
/**
* Queries the DOM for a single element and returns it. Substitutes for `document.querySelector(selector)` and JQuery's `$(selector).first()`
*
* @param {string|HTMLElement|Element|Array<HTMLElement|Element>|NodeList} selector The selector to select an element
* @param {HTMLElement|Element} [from=document] The element to query from
* @returns {HTMLElement|Element}
* @example
* document.body.innerHTML = `
* <div id="foo"></div>
* <div id="bar"></div>
* <div id="baz"></div>`
*
* querySingle('#foo') // => <div id="foo"></div>
* querySingle(document.getElementById('foo')) // => <div id="foo"></div>
* querySingle(document.querySelector('#foo')) // => <div id="foo"></div>
*/
export function querySingle(selector, from = document) {
if (selector instanceof Element) return selector
return from.querySelector(selector)
}
/**
* Queries the DOM for elements and returns them. Substitutes for `document.querySelectorAll(selector)` and JQuery's `$(selector)`
*
* @param {string|HTMLElement|Element|Array<HTMLElement|Element>|NodeList} selector The selector to select elements
* @param {HTMLElement|Element} [from=document] The element to query from
* @returns {Array<Element>|NodeList}
* @example
* document.body.innerHTML = `
* <div id="foo"></div>
* <div id="bar"></div>
* <div id="baz"></div>`
*
* query('#foo') // => [<div id="foo"></div>]
* query(document.getElementById('foo')) // => [<div id="foo"></div>]
* query('div') // => [<div id="foo"></div>, <div id="bar"></div>, <div id="baz"></div>]
*/
export function query(selector, from = document) {
if (selector instanceof Array || selector instanceof NodeList) return selector
if (selector instanceof Element) return [selector]
if (from instanceof Element || from instanceof Document) return from.querySelectorAll(selector)
if (isString(from)) from = query(from)
if (!from instanceof Array && !from instanceof NodeList) return []
const res = []
for (const element of from) {
res.push(...element.querySelectorAll(selector))
}
return res
}
/**
* Sets element styles from passed object of styles. Can also transform dash-case to camelCase for CSS properties
*
* @param {HTMLElement} element The element to set styles on
* @param {object} styles The object of styles to set
* @param {boolean} transform Whether to transform dash-case to camelCase for CSS properties
* @example
* css(document.getElementById('foo'), { 'background-color': 'red', 'font-size': '16px' }, true) // => sets background-color and font-size
* css(document.getElementById('foo'), { backgroundColor: 'red', fontSize: '16px' }) // => sets background-color and font-size
*/
export function css(element, styles, transform = false) {
if (!element || !styles) return
for (let property in styles) {
if (transform) property = transformDashToCamelCase(property)
element.style[property] = styles[property]
}
}
/**
* Decodes HTML entities in a string using the browser's DOMParser. If the DOMParser is not available, it uses a regular expression to decode the basic entities.
*
* @see {@link module:parsers.decodeHtmlEntities}
*
* @param {string} html The HTML string to decode
* @returns {string} The decoded HTML string
* @example
* decodeHTML('<div>foo</div>') // => '<div>foo</div>'
* decodeHTML('<div>foo</div><div>bar</div>') // => '<div>foo</div><div>bar</div>'
*/
export function decodeHTML(html) {
if (typeof document === 'undefined') return decodeHtmlEntities(html)
const txt = document.createElement('textarea')
txt.innerHTML = html
const res = txt.value
txt.remove()
return res
}
/**
* Encodes HTML entities in a string using the browser's DOMParser. If the DOMParser is not available, it uses a regular expression to encode the basic entities.
*
* @see {@link module:parsers.encodeHtmlEntities}
*
* @param {string} html The HTML string to encode
* @returns {string} The encoded HTML string
* @example
* encodeHTML('<div>foo</div>') // => '<div>foo</div>'
* encodeHTML('<div>foo</div><div>bar</div>') // => '<div>foo</div><div>bar</div>'
*/
export function encodeHTML(html) {
if (typeof document === 'undefined') return encodeHtmlEntities(html)
const txt = document.createElement('textarea')
txt.textContent = html
const res = txt.innerHTML
txt.remove()
return res
}
/**
* Inserts an element before another element
*
* @param {HTMLElement} targetElement The element to insert before
* @param {HTMLElement} newElement The element to insert
* @example
* const target = document.getElementById('target')
* const newElement = document.createElement('div')
* newElement.id = 'newElement'
* insertBeforeElement(target, newElement)
* // <div id="newElement"></div>
* // <div id="target"></div>
*/
export function insertBeforeElement(targetElement, newElement) {
if (!targetElement || !newElement) return
targetElement.parentNode.insertBefore(newElement, targetElement);
}
/**
* Toggles an attribute value on an element
*
* @param {HTMLElement} element The element to toggle the attribute on
* @param {string} attribute The attribute to toggle
* @param {string} on Default: 'true'
* @param {string} off Default: 'false'
* @example
* toggleAttributeValue(element, 'aria-expanded', 'true', 'false')
* toggleAttributeValue(element, 'aria-expanded')
*/
export function toggleAttributeValue(element, attribute, on = 'true', off = 'false') {
if (!element.hasAttribute(attribute)) return
if (element.getAttribute(attribute) === on) {
element.setAttribute(attribute, off)
} else {
element.setAttribute(attribute, on)
}
}
/**
* Converts a duration string to milliseconds integer
*
* @param {string} duration The duration string to convert, e.g. '1s', '100ms', '0.5s'
* @returns {number} The duration in milliseconds
* @example
* convertToMilliseconds('1s') // 1000
* convertToMilliseconds('100ms') // 100
* convertToMilliseconds('0.5s') // 500
* convertToMilliseconds('0.5') // 0
* convertToMilliseconds('foo') // 0
*/
export function cssTimeToMilliseconds(duration) {
const regExp = new RegExp('([0-9.]+)([a-z]+)', 'i')
const matches = regExp.exec(duration)
if (!matches) return 0
const unit = matches[2]
switch (unit) {
case 'ms':
return parseFloat(matches[1])
case 's':
return parseFloat(matches[1]) * 1000
default:
return 0
}
}
/**
* Returns a map of transition properties and durations
*
* @param {HTMLElement} element The element to get the transition properties and durations from
* @returns {object<string, number>} A map of transition properties and durations
* @example
* getTransitionDurations(element) // { height: 1000 } if transition in CSS is set to 'height 1s'
* getTransitionDurations(element) // { height: 500, opacity: 1000 } if transition in CSS is set to 'height 0.5s, opacity 1s'
*/
export function getTransitionDurations(element) {
if (!element) {}
const styles = getComputedStyle(element)
const transitionProperties = styles.getPropertyValue('transition-property').split(',')
const transitionDurations = styles.getPropertyValue('transition-duration').split(',')
const map = {}
for (let i = 0; i < transitionProperties.length; i++) {
const property = transitionProperties[i].trim()
map[property] = transitionDurations.hasOwnProperty(i) ? cssTimeToMilliseconds(transitionDurations[i].trim()) : null
}
return map
}
/**
* Check a list of elements if any of them matches a selector
*
* @param {Array<HTMLElement>|NodeList|HTMLElement} elements The elements to check
* @param {string} selector The selector to check
* @returns {boolean} True if any of the elements matches the selector, false otherwise
* @example
* document.body.innerHTML = `
* <div id="foo"></div>
* <div id="bar"></div>
* <div id="baz"></div>`
*
* matchesAny(document.querySelectorAll('div'), '#foo') // => true
* matchesAny(document.querySelectorAll('div'), '#qux') // => false
*/
export function matchesAny(elements, selector) {
if (!elements || !selector || !elements.length) return false
if (elements instanceof Element) elements = [elements]
if (isString(elements)) elements = query(elements)
for (const element of elements) {
if (element.matches(selector)) return true
}
return false
}
/**
* Check a list of elements if all of them matches a selector
*
* @param {Array<HTMLElement>|NodeList|HTMLElement} elements The elements to check
* @param {string} selector The selector to check
* @returns {boolean} True if all of the elements matches the selector, false otherwise
* @example
* document.body.innerHTML = `
* <div id="foo"></div>
* <div id="bar"></div>
* <div id="baz"></div>`
*
* matchesAll(document.querySelectorAll('div'), 'div') // => true
* matchesAll(document.querySelectorAll('div'), '#foo') // => false
*/
export function matchesAll(elements, selector) {
if (!elements || !selector || !elements.length) return false
if (elements instanceof Element) elements = [elements]
if (isString(elements)) elements = query(elements)
for (const element of elements) {
if (!element.matches(selector)) return false
}
return true
}
/**
* Detaches an element from the DOM and returns it
*
* @param {HTMLElement} element The element to detach
* @example
* detachElement(element)
* // => element
* console.log(element.parentNode) // => null
*/
export function detachElement(element) {
if (element && element.parentNode) {
element.parentNode.removeChild(element);
}
return element
}
/**
* Gets table data from a table element, a simple regular table element, or a table like structure.
* Useful for scraping data.
*
* @param {string} selector The selector to select the table element
* @param {Array<string>|string|null} headers The headers to use for the data. If 'auto' is passed, the row containing th or the first row will be used as headers
* @param {string} [rowSelector='tr'] The selector to select the rows
* @param {string} [cellSelector='td'] The selector to select the cells
* @returns {Array<object>} An array of objects with the properties as keys and the cell values as values
* @example
* document.body.innerHTML = `
* <table id="table">
* <thead>
* <tr>
* <th>Foo</th>
* <th>Bar</th>
* </tr>
* </thead>
* <tbody>
* <tr>
* <td>Foo 1</td>
* <td>Bar 1</td>
* </tr>
* <tr>
* <td>Foo 2</td>
* <td>Bar 2</td>
* </tr>
* </tbody>
* </table>`
*
* getTableData('#table', ['foo', 'bar'])
* // => [
* // { foo: 'Foo 1', bar: 'Bar 1' },
* // { foo: 'Foo 2', bar: 'Bar 2' }
* // ]
*/
export function getTableData(selector, headers, rowSelector = 'tr', cellSelector = 'td', headerCellSelector = 'th') {
const table = typeof selector === 'string' ? document.querySelector(selector) : selector
const res = []
const rows = table.querySelectorAll(rowSelector)
let start = 0
function iterateHeaders(arr) {
if (!arr || !arr.length) return
const res = []
for (let i = 0; i < arr.length; i++) {
res.push(arr[i].textContent.trim())
}
return res
}
if (headers && isString(headers) && headers === 'auto') {
let headerCells = table.querySelectorAll(headerCellSelector)
if (headerCells && headerCells.length) {
headers = iterateHeaders(headerCells)
} else {
headers = iterateHeaders(rows[0].querySelectorAll(cellSelector))
start = 1
}
}
for (let i = start; i < rows.length; i++) {
const row = rows[i]
const cells = row.querySelectorAll(cellSelector)
if (!cells || !cells.length) continue
let rowData = []
if (headers && isArray(headers) && headers.length) {
rowData = {}
for (let j = 0; j < headers.length; j++) {
rowData[headers[j]] = cells[j] ? cells[j].textContent.trim() : null
}
} else {
for (let j = 0; j < cells.length; j++) {
rowData.push(cells[j].textContent.trim())
}
}
res.push(rowData)
}
return res
}
/**
* Parses HTML string to a DOM Node
*
* @param {string} html The HTML string to parse
* @param {boolean} [allChildren=false] If true, all children of the body will be returned, otherwise only the first child
* @returns {Node} The parsed DOM Node
* @example
* parseDOM('<div>foo</div>') // => <div>foo</div>
* parseDOM('<div>foo</div><div>bar</div>', true) // => NodeList(2) [div, div]
* parseDOM(document.getElementById('foo')) // => <div id="foo"></div>
* parseDOM(document.querySelectorAll('div')) // => NodeList(2) [div, div]
*/
export function parseDOM(html, allChildren) {
if (html instanceof Element || html instanceof NodeList) return html
const parser = new DOMParser()
const doc = parser.parseFromString(html, 'text/html')
return !allChildren ? doc.body.firstChild : doc.body.childNodes
}
/**
* Loads an image form a provided source url and calls a callback when it's loaded
*
* @param {string} src The source url of the image
* @param {Function} [callback] The callback to call when the image is loaded
* @example
* loadImage('https://example.com/image.png', () => {
* console.log('Image loaded')
* })
*/
export function loadImage(src, callback) {
const img = new Image()
if (callback)
img.addEventListener('load', callback, false);
img.src = src
}
/**
* Delegate DOM events using MutationObserver with a fallback to document.addEventListener
*
* @param {string} selector The selector to select the elements to delegate the event to
* @param {string} eventType The event type to delegate, like `click`
* @param {Function} handler The handler to call when the event is triggered.
* @returns {MutationObserver | null} The MutationObserver instance
* @example
* delegateEvent('.foo', 'click', (e, target) => {
* console.log('Clicked on', target)
* })
*/
export function delegateEvent(selector, eventType, handler) {
if (typeof MutationObserver === 'undefined') {
document.addEventListener(eventType, (e) => {
const target = e.target.closest(selector)
if (target) handler(e, target)
})
return null
}
const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
for (const node of mutation.addedNodes) {
if (!(node instanceof HTMLElement)) continue
if (node.matches(selector)) {
node.addEventListener(eventType, (e) => {
handler(e, e.currentTarget)
})
continue
}
for (const child of node.querySelectorAll(selector)) {
child.addEventListener(eventType, (e) => {
handler(e, e.currentTarget)
})
}
}
}
})
for (const node of document.querySelectorAll(selector)) {
node.addEventListener(eventType, (e) => {
handler(e, e.currentTarget)
})
}
observer.observe(document.body, { childList: true, subtree: true })
return observer
}
/**
* Run a handler on selected elements and on elements added to the DOM with the same selector as a MutationObserver abstraction,
* or use it to delegate events as a `delegateEvent` alias
*
* @param {string} selector The selector to select the elements to run the handler on
* @param {string | Function} eventTypeOrHandler The event type to delegate, like `click`, or the handler to call on every element
* @param {Function} [handler] The handler to call when the event is triggered.
* @returns {MutationObserver | null} The MutationObserver instance
* @see delegateEvent
* @example
* on('.foo', (el) => {
* console.log('Element', el, 'added to the DOM')
* })
*
* on('.foo', 'click', (e, target) => {
* console.log('Clicked on', target)
* })
*/
export function on(selector, eventTypeOrHandler, handler) {
if (isString(eventTypeOrHandler)) {
return delegateEvent(selector, eventTypeOrHandler, handler)
}
const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
for (const node of mutation.addedNodes) {
if (!(node instanceof HTMLElement)) continue
if (node.matches(selector)) {
eventTypeOrHandler(node)
continue
}
for (const child of node.querySelectorAll(selector)) {
eventTypeOrHandler(child)
}
}
}
})
for (const node of document.querySelectorAll(selector)) {
eventTypeOrHandler(node)
}
observer.observe(document.body, { childList: true, subtree: true })
return observer
}
/**
* Adds one listener to multiple events
*
* @param {string|HTMLElement|NodeList} elements The elements or a selector for elements to add the event listeners to
* @param {string|Array<string>} events The event types to add the event listeners for, like `click mouseenter`
* @param {Function} handler The handler to call when the event is triggered.
* @param {object} [options] The options to pass to the event listeners
* @example
* addListenerForEvents('.foo', 'click mouseenter', (e) => { console.log(e.type) })
*/
export function addListenerForEvents(elements, events, handler, options) {
if (elements instanceof Element) elements = [elements]
if (typeof elements === 'string') elements = query(elements)
const eventTypes = isArray(events) ? events : events.split(' ')
for (const element of elements) {
for (const eventType of eventTypes) {
element.addEventListener(eventType, handler, options)
}
}
}
/**
* Removes one listener from multiple registered events
*
* @param {string|HTMLElement|NodeList} elements The elements or a selector for elements to remove the event listeners from
* @param {string|Array<string>} events The event types to remove the event listeners for, like `click mouseenter`
* @param {Function} handler The handler to remove
* @param {object} [options] The options to pass to the event listeners
* @example
* removeListenerForEvents('.foo', 'click mouseenter', (e) => { console.log(e.type) })
*/
export function removeListenerForEvents(elements, events, handler, options) {
if (elements instanceof Element) elements = [elements]
if (typeof elements === 'string') elements = query(elements)
const eventTypes = isArray(events) ? events : events.split(' ')
for (const element of elements) {
for (const eventType of eventTypes) {
element.removeEventListener(eventType, handler, options)
}
}
}
/**
* Resizes an element to cover its parent element while maintaining the aspect ratio
*
* @param {string|HTMLElement|NodeList} elements The elements or a selector for elements to resize
* @param {number} [ratio=1] The ratio to maintain
* @param {number} [offset=0] An offset to add to the parent element's width and height
* @example
* proportionalParentCoverResize('.foo', 16/9, 10)
*/
export function proportionalParentCoverResize(elements, ratio = 1, offset = 0) {
if (elements instanceof Element) elements = [elements]
if (typeof elements === 'string') elements = query(elements)
for (const element of elements) {
const h = element.parentNode.offsetHeight + offset
const w = element.parentNode.offsetWidth + offset
if (ratio > w/h) {
element.style.width = h*ratio + 'px'
element.style.height = h + 'px'
} else {
element.style.width = w + 'px'
element.style.height = w/ratio + 'px'
}
}
}
/**
* If provided element is visible. Checks if the element is not visibility hidden or display none, has no opacity, and has a width and height.
*
* @param {HTMLElement} element The element to check
* @returns {boolean} True if the element is visible, false otherwise
*
* @example
* isVisible(document.getElementById('foo'))
*/
export function isVisible(element) {
if (!element) return false;
const computedStyle = getComputedStyle(element);
if (computedStyle.getPropertyValue('display') === 'none') return false;
if (element.getAttribute('hidden') !== null || computedStyle.getPropertyValue('visibility') === 'hidden' || computedStyle.getPropertyValue('opacity') == "0") return false;
return !!(element.offsetWidth || element.offsetHeight || element.getClientRects().length)
}
/**
* Swipe event handler
*
* @param {HTMLElement} element The element to listen for swipe gestures on
* @param {object | Function} callback The callback to call when a swipe gesture is detected or the options object with the callback, threshold, and timeThreshold
* @param {number} [threshold=150] The threshold in pixels to trigger the callback.
* @param {number} [timeThreshold=0] The threshold in milliseconds to trigger the callback. Defaults to 0, which means the callback will be called regardless of the time it took to swipe.
* @returns {object} The destroy method to remove the event listeners
* @example
* swipe(document.getElementById('foo'), (e) => {
* console.log(e.direction)
* console.log(e.deltaX)
* console.log(e.deltaY)
* console.log(e.startX)
* console.log(e.startY)
* console.log(e.endX)
* console.log(e.endY)
* console.log(e.threshold)
* console.log(e.type)
* console.log(e.target)
* console.log(e.horizontal)
* console.log(e.vertical)
* console.log(e.horizontalDirection)
* console.log(e.verticalDirection)
* console.log(e.timeElapsed)
* console.log(e.timeThreshold)
* })
*
* element.addEventListener('swipe', (e) => { ... })
* element.addEventListener('swipestart', (e) => { ... })
* element.addEventListener('swipeend', (e) => { ... })
*/
export function swipe(element, callback, threshold = 150, timeThreshold = 0) {
let startX = 0
let startY = 0
let endX = 0
let endY = 0
let startTime = 0
let endTime = 0
if (isObject(callback)) {
const options = callback
callback = options.callback
threshold = options.threshold || threshold
timeThreshold = options.timeThreshold || timeThreshold
}
if (!element) return
if (element.getAttribute('swipe-enabled') === 'true') return
element.setAttribute('swipe-enabled', 'true')
const handleStart = function(e) {
const carrier = e.touches ? e.touches[0] : e
startX = carrier.clientX
startY = carrier.clientY
startTime = Date.now();
element.dispatchEvent(new CustomEvent('swipestart', { detail: { target: element, startX, startY, startTime } }))
}
const handleEnd = function(e) {
const carrier = e.changedTouches ? e.changedTouches[0] : e
endX = carrier.clientX
endY = carrier.clientY
endTime = Date.now();
handleSwipeGesture()
element.dispatchEvent(new CustomEvent('swipeend', { detail: { target: element, startX, startY, startTime, endX, endY, endTime } }))
}
const handleSwipeGesture = function() {
const deltaX = Math.abs(endX - startX)
const deltaY = Math.abs(endY - startY)
const horizontal = deltaX > threshold
const vertical = deltaY > threshold
const left = endX < startX
const up = endY < startY
const direction = []
const timeElapsed = endTime - startTime;
if (horizontal) direction.push(left ? 'left' : 'right')
if (vertical) direction.push(up ? 'up' : 'down')
let condition = direction.length && callback
if (timeThreshold) condition = condition && timeElapsed <= timeThreshold
if (condition) {
const res = {
target: element,
deltaX: deltaX,
deltaY: deltaY,
startX: startX,
startY: startY,
endX: endX,
endY: endY,
threshold: threshold,
horizontal: horizontal,
vertical: vertical,
horizontalDirection: left ? 'left' : 'right',
verticalDirection: up ? 'up' : 'down',
direction: direction.length === 1 ? direction[0] : direction,
timeElapsed: timeElapsed,
timeThreshold: timeThreshold
}
callback(res)
element.dispatchEvent(new CustomEvent('swipe', { detail: res }))
}
}
element.addEventListener('touchstart', handleStart)
element.addEventListener('touchend', handleEnd)
element.addEventListener('mousedown', handleStart)
element.addEventListener('mouseup', handleEnd)
return {
destroy: function() {
element.removeEventListener('touchstart', handleStart)
element.removeEventListener('touchend', handleEnd)
element.removeEventListener('mousedown', handleStart)
element.removeEventListener('mouseup', handleEnd)
}
}
}
/**
* Alias for swipe
*
* @see swipe
* @deprecated Use swipe instead
*/
export const onSwipe = swipe
/**
* Drag event handler
*
* @param {HTMLElement} element The element to listen for drag gestures on
* @param {object | Function} opts The options object or the callback to call when a drag gesture is detected
* @param {boolean} [opts.inertia=false] Whether to enable inertia
* @param {boolean} [opts.bounce=false] Whether to enable bounce when inertia is enabled
* @param {number} [opts.friction=0.9] The friction to apply when inertia is enabled
* @param {number} [opts.bounceFactor=0.2] The bounce factor to apply when bounce is enabled
* @param {boolean} [opts.preventDefaultTouch=true] Whether to prevent the default touch behavior
* @param {Function} [opts.callback] The callback to call when a drag gesture is detected
* @returns {object} The destroy method to remove the event listeners
* @example
* drag(document.getElementById('foo'), (e) => {
* console.log(e.x)
* console.log(e.y)
* console.log(e.relativeX)
* console.log(e.relativeY)
* console.log(e.xPercentage)
* console.log(e.yPercentage)
* console.log(e.velocityX)
* console.log(e.velocityY)
* console.log(e.prevX)
* console.log(e.prevY)
* })
*
* element.addEventListener('drag', (e) => { ... })
* element.addEventListener('dragstart', (e) => { ... })
* element.addEventListener('dragend', (e) => { ... })
* element.addEventListener('draginertia', (e) => { ... })
* element.addEventListener('draginertiaend', (e) => { ... })
*/
export function drag(element, opts) {
if (!element || !(element instanceof Element)) return
if (element.getAttribute('drag-enabled') === 'true') return
let x = 0
let y = 0
let prevX = 0
let prevY = 0
let velocityX = 0
let velocityY = 0
let dragging = false
let rect = null
let inertiaId = null
const options = {
inertia: false,
bounce: false,
friction: 0.9,
bounceFactor: 0.2,
callback: null,
preventDefaultTouch: true
}
if (isFunction(opts)) {
options.callback = opts
} else if (isObject(opts)) {
shallowMerge(options, opts)
}
options.friction = Math.abs(options.friction)
options.bounceFactor = Math.abs(options.bounceFactor)
element.setAttribute('drag-enabled', 'true')
element.setAttribute('dragging', 'false')
const calcPageRelativeRect = function() {
const origRect = element.getBoundingClientRect()
const rect = {
top: origRect.top + window.scrollY,
left: origRect.left + window.scrollX,
width: origRect.width,
height: origRect.height
}
return rect
}
rect = calcPageRelativeRect()
const handleStart = function(e) {
setXY(e)
dragging = true
rect = calcPageRelativeRect()
element.setAttribute('dragging', 'true')
if (inertiaId) {
cancelAnimationFrame(inertiaId)
inertiaId = null
}
const event = new CustomEvent('dragstart', { detail: getDetail() })
element.dispatchEvent(event)
}
const handleMove = function(e) {
if (!dragging) return
setXY(e)
velocityX = x - prevX
velocityY = y - prevY
const detail = getDetail()
if (options.callback) options.callback(detail)
const event = new CustomEvent('drag', { detail: detail })
element.dispatchEvent(event)
}
const handleEnd = function() {
dragging = false
element.setAttribute('dragging', 'false')
if (options.inertia) inertiaId = requestAnimationFrame(inertia)
const event = new CustomEvent('dragend', { detail: getDetail() })
element.dispatchEvent(event)
}
const setXY = function(e) {
const carrier = e.touches ? e.touches[0] : e
if (e.touches && options.preventDefaultTouch) e.preventDefault()
prevX = x
prevY = y
x = carrier.pageX
y = carrier.pageY
}
const getDetail = function() {
const relativeX = x - rect.left
const relativeY = y - rect.top
const xPercentage = percentage(relativeX, rect.width)
const yPercentage = percentage(relativeY, rect.height)
const detail = {
target: element,
x: x,
y: y,
relativeX: relativeX,
relativeY: relativeY,
xPercentage: xPercentage,
yPercentage: yPercentage,
velocityX: velocityX,
velocityY: velocityY,
prevX: prevX,
prevY: prevY
}
if (xPercentage < 0) detail.xPercentage = 0
if (xPercentage > 100) detail.xPercentage = 100
if (yPercentage < 0) detail.yPercentage = 0
if (yPercentage > 100) detail.yPercentage = 100
return detail
}
const inertia = function() {
x += velocityX
y += velocityY
velocityX *= options.friction
velocityY *= options.friction
if (options.bounce) {
if (x < rect.left) {
x = rect.left
velocityX *= -options.bounceFactor
}
if (x > rect.width + rect.left) {
x = rect.width + rect.left
velocityX *= -options.bounceFactor
}
if (y < rect.top) {
y = rect.top
velocityY *= -options.bounceFactor
}
if (y > rect.height + rect.top) {
y = rect.height + rect.top
velocityY *= -options.bounceFactor
}
}
if (Math.abs(velocityX) < 0.1) velocityX = 0
if (Math.abs(velocityY) < 0.1) velocityY = 0
const detail = getDetail()
if (velocityX !== 0 || velocityY !== 0) {
if (options.callback) options.callback(detail)
const event = new CustomEvent('draginertia', { detail: detail })
element.dispatchEvent(event)
inertiaId = requestAnimationFrame(inertia)
} else {
inertiaId = null
if (options.callback) options.callback(detail)
const event = new CustomEvent('draginertiaend', { detail: detail })
element.dispatchEvent(event)
}
}
element.addEventListener('mousedown', handleStart)
element.addEventListener('mousemove', handleMove)
element.addEventListener('mouseup', handleEnd)
element.addEventListener('touchstart', handleStart)
element.addEventListener('touchmove', handleMove)
element.addEventListener('touchend', handleEnd)
return {
//TODO: add manual start, move and end methods - for programmatic control
destroy: function() {
element.removeEventListener('mousedown', handleStart)
element.removeEventListener('mousemove', handleMove)
element.removeEventListener('mouseup', handleEnd)
element.removeEventListener('touchstart', handleStart)
element.removeEventListener('touchmove', handleMove)
element.removeEventListener('touchend', handleEnd)
if (inertiaId) {
cancelAnimationFrame(inertiaId)
inertiaId = null
}
}
}
}
/**
* Alias for drag
*
* @see drag
* @deprecated Use drag instead
*/
export const onDrag = drag