// ============================================================================= // Boost.js | DOM Events // (c) Mathigon // ============================================================================= import {delay, Obj, words} from '@mathigon/core'; import {Point} from '@mathigon/euclid'; import {$, $body, CanvasView, ElementView, InputView, SVGParentView, SVGView} from './elements'; import {Browser} from './browser'; declare global { interface Window { IntersectionObserver?: IntersectionObserver; ResizeObserver: any; } interface Event { handled?: boolean; } } // ----------------------------------------------------------------------------- // Utilities export type ScreenEvent = PointerEvent|TouchEvent|MouseEvent; export type ScrollEvent = {top: number}; export type EventCallback = (e: any) => void; const touchSupport = ('ontouchstart' in window); const pointerSupport = ('onpointerdown' in window); /** Gets the pointer position from an event. */ export function pointerPosition(e: any) { if (e.touches) { const touches = e.targetTouches.length ? e.targetTouches : e.changedTouches; return new Point(touches[0].clientX, touches[0].clientY); } else { return new Point(e.clientX || 0, e.clientY || 0); } } function getTouches(e: any) { return e.touches || []; } /** * Gets the pointer position from an event triggered on an `` element, in * the coordinate system of the `` element. */ export function svgPointerPosn(event: ScreenEvent, $svg: SVGParentView) { const posn = pointerPosition(event); return posn.transform($svg.inverseTransformMatrix); } /** * Gets the pointer position from an event triggered on an `` element, * in the coordinate system of the `` element. */ export function canvasPointerPosition(event: ScreenEvent, $canvas: CanvasView) { const posn = pointerPosition(event); const bounds = $canvas.bounds; const x = (posn.x - bounds.left) * $canvas.canvasWidth / bounds.width; const y = (posn.y - bounds.top) * $canvas.canvasHeight / bounds.height; return new Point(x, y); } /** * Get the target element for an event, including for touch/pointer events * that started on a different element. */ export function getEventTarget(event: ScreenEvent) { if (event instanceof PointerEvent && event.pointerType === 'mouse') { // Only pointer mouse events update the target for move events that started // on a different element. return $(event.target as Element); } const posn = pointerPosition(event); return $(document.elementFromPoint(posn.x, posn.y) || undefined); } export function stopEvent(event: Event) { event.stopPropagation(); event.preventDefault(); } // ----------------------------------------------------------------------------- // Click Events function makeTapEvent($el: ElementView) { // TODO Support removing events. if ($el._data['tapEvent']) return; $el._data['tapEvent'] = true; let start: Point|undefined = undefined; $el.on('pointerdown', (e: ScreenEvent) => (start = pointerPosition(e))); $el.on('pointerup', (e: ScreenEvent) => { if (!start) return; const end = pointerPosition(e); if (Point.distance(start, end) < 6) $el.trigger('tap', e); start = undefined; }); $el.on('pointercancel', () => (start = undefined)); } function makeClickOutsideEvent($el: ElementView) { // TODO Support removing events. if ($el._data['clickOutsideEvent']) return; $el._data['clickOutsideEvent'] = true; $body.on('pointerdown', (e: ScreenEvent) => { // The .composedPath method ensures that this still works in shadow DOM. const target = (e.composedPath()[0] || e.target) as Node|null; if (!target || $el._el === target || $el._el.contains(target)) return; $el.trigger('clickOutside', e); }); } // ----------------------------------------------------------------------------- // Slide Events interface SlideEventOptions { down?: (p: Point) => void; start?: (p: Point) => void; move?: (p: Point, start: Point, last: Point) => void; end?: (last: Point, start: Point) => void; up?: (last: Point, start: Point) => void; click?: (p: Point) => void; justInside?: boolean; accessible?: boolean; $box?: ElementView; } export function slide($el: ElementView, fns: SlideEventOptions) { const $box = fns.$box || $el; let posn = pointerPosition; if ($box.type === 'svg') { posn = (e) => svgPointerPosn(e, ($box as SVGView).$ownerSVG); } else if ($box.type === 'canvas') { posn = (e) => canvasPointerPosition(e, $box as CanvasView); } const $parent = fns.justInside ? $el : $body; let startPosn: Point|undefined = undefined; let lastPosn: Point|undefined = undefined; let hasMoved = false; let pointerId = 0; if ($el.css('touch-action') === 'auto') $el.css('touch-action', 'none'); $el.addClass('noselect'); function start(e: ScreenEvent) { if (e.handled || getTouches(e).length > 1) return; e.preventDefault(); hasMoved = false; pointerId = (e as any).pointerId || 0; $parent.on('pointermove', move); $parent.on('pointerstop', end); startPosn = lastPosn = posn(e); if (fns.down) fns.down(startPosn); } function move(e: ScreenEvent) { if (!startPosn) return; if (pointerId && (e as any).pointerId !== pointerId) return; e.preventDefault(); const p = posn(e); if (Point.distance(p, lastPosn!) < 0.5) return; if (!hasMoved && fns.start) fns.start(startPosn!); if (fns.move) fns.move(p, startPosn!, lastPosn!); lastPosn = p; hasMoved = true; } function end(e: ScreenEvent, preventClick = false) { if (!startPosn) return; if (pointerId && (e as any).pointerId !== pointerId) return; e.preventDefault(); $parent.off('pointermove', move); $parent.off('pointerstop', end); if (fns.up) fns.up(lastPosn!, startPosn!); if (hasMoved && fns.end) fns.end(lastPosn!, startPosn!); if (!hasMoved && fns.click && !preventClick) fns.click(startPosn!); startPosn = undefined; } // Cancel running slide gestures when pressing escape. $body.onKey('Escape', () => { if (!startPosn) return; // Move the element back to the start position. if (hasMoved && fns.move) fns.move(startPosn, startPosn, lastPosn!); lastPosn = startPosn; const event = document.createEvent('MouseEvent'); (event as any).pointerId = pointerId; end(event, true); }); $el.on('pointerdown', start); if (fns.justInside) $el.on('mouseleave', end); if (fns.accessible) { $el.setAttr('tabindex', '0'); document.addEventListener('keydown', (e: KeyboardEvent) => { if (![37, 38, 39, 40].includes(e.keyCode)) return; if ($el !== Browser.getActiveInput()) return; const center = $el.boxCenter; const start = posn({clientX: center.x, clientY: center.y}); const dx = (e.keyCode === 37) ? -25 : (e.keyCode === 39) ? 25 : 0; const dy = (e.keyCode === 38) ? -25 : (e.keyCode === 40) ? 25 : 0; const end = start.shift(dx, dy); if (fns.down) fns.down(start); if (fns.start) fns.start(start); if (fns.move) fns.move(end, start, start); if (fns.end) fns.end(end, start); }); } } // ----------------------------------------------------------------------------- // Slide Events interface OverEventOptions { enter?: () => void; move?: (p: Point) => void; exit?: () => void; } export function pointerOver($el: ElementView, fns: OverEventOptions) { let posn = pointerPosition; if ($el.type === 'svg') { posn = (e) => svgPointerPosn(e, ($el as SVGView).$ownerSVG); } else if ($el.type === 'canvas') { posn = (e) => canvasPointerPosition(e, $el as CanvasView); } let over = false; $el.on('touchstart mouseenter', (e: Event) => { if (!over && fns.enter) fns.enter(); if (fns.move) fns.move(posn(e)); over = true; }, {passive: true}); $el.on('pointermove', (e: Event) => { if (over && fns.move) fns.move(posn(e)); }); $el.on('touchend mouseleave', () => { if (over && fns.exit) fns.exit(); over = false; }, {passive: true}); } // ----------------------------------------------------------------------------- // Scroll Events function makeScrollEvents($el: ElementView) { // TODO Support removing events. if ($el._data['scrollEvents']) return; $el._data['scrollEvents'] = true; let ticking = false; let top: number|undefined = undefined; function tick() { const newTop = $el.scrollTop; if (newTop === top) { ticking = false; return; } top = newTop; $el.trigger('scroll', {top}); // TODO Scroll should trigger mousemove events. window.requestAnimationFrame(tick); } function scroll() { if (!ticking) window.requestAnimationFrame(tick); ticking = true; } // Mouse Events const target = $el.type === 'window' ? window : $el._el; target.addEventListener('scroll', scroll); // Touch Events function touchStart() { window.addEventListener('touchmove', scroll); window.addEventListener('touchend', touchEnd); } function touchEnd() { window.removeEventListener('touchmove', scroll); window.removeEventListener('touchend', touchEnd); } $el._el.addEventListener('touchstart', function(e) { if (!e.handled) touchStart(); }); } // ----------------------------------------------------------------------------- // Hover Events interface HoverEventOptions { enter?: () => void; exit?: () => void; preventMouseover?: () => boolean; canFocusWithin?: boolean; delay?: number; exitDelay?: number; $clickTarget?: ElementView; } export function hover($el: ElementView, options: HoverEventOptions) { const $clickTarget = options.$clickTarget || $el; let timeout = 0; let active = false; let wasTriggeredByMouse = false; let wasTriggeredByFocus = false; function enter() { if (active) return; if (options.enter) options.enter(); active = true; } function exit() { if (!active) return; clearTimeout(timeout); if (options.exit) options.exit(); active = false; } $el.on('mouseover', () => { if (options.preventMouseover && options.preventMouseover()) return; clearTimeout(timeout); timeout = delay(() => { enter(); wasTriggeredByMouse = true; }, options.delay); }); $el.on('mouseout', () => { if (!wasTriggeredByMouse) return; clearTimeout(timeout); timeout = delay(exit, options.exitDelay || options.delay); }); $clickTarget.on('focus', () => { if (active || options.preventMouseover && options.preventMouseover()) return; clearTimeout(timeout); enter(); wasTriggeredByFocus = true; }); const onBlur = () => { if (!wasTriggeredByFocus) return; if (options.canFocusWithin) { // Special handling if the blur of the $clickTarget was caused by focussing // another child of $el (e.g. e