UNPKG

reka-ui

Version:

Vue port for Radix UI Primitives.

328 lines (267 loc) 9.73 kB
import type { Ref } from 'vue' import type { Direction, ResizeEvent } from './types' import { getResizeEventCoordinates } from './events' import { intersects } from './rects' import { compare } from './stackingOrder' import { resetGlobalCursorStyle, setGlobalCursorStyle } from './style' export type ResizeHandlerAction = 'down' | 'move' | 'up' export type SetResizeHandlerState = ( action: ResizeHandlerAction, isActive: boolean, event: ResizeEvent, ) => void export type PointerHitAreaMargins = { coarse: number fine: number } export type ResizeHandlerData = { direction: Ref<Direction> element: HTMLElement hitAreaMargins: PointerHitAreaMargins nonce: Ref<string | undefined> setResizeHandlerState: SetResizeHandlerState } export const EXCEEDED_HORIZONTAL_MIN = 0b0001 export const EXCEEDED_HORIZONTAL_MAX = 0b0010 export const EXCEEDED_VERTICAL_MIN = 0b0100 export const EXCEEDED_VERTICAL_MAX = 0b1000 function getInputType(): 'coarse' | 'fine' | undefined { if (typeof matchMedia === 'function') return matchMedia('(pointer:coarse)').matches ? 'coarse' : 'fine' } const isCoarsePointer = getInputType() === 'coarse' const intersectingHandles: ResizeHandlerData[] = [] let isPointerDown = false const ownerDocumentCounts: Map<Document, number> = new Map() const panelConstraintFlags: Map<string, number> = new Map() const registeredResizeHandlers = new Set<ResizeHandlerData>() export function registerResizeHandle( resizeHandleId: string, element: HTMLElement, direction: Ref<Direction>, hitAreaMargins: PointerHitAreaMargins, nonce: Ref<string | undefined>, setResizeHandlerState: SetResizeHandlerState, ) { const { ownerDocument } = element const data: ResizeHandlerData = { direction, element, hitAreaMargins, nonce, setResizeHandlerState, } const count = ownerDocumentCounts.get(ownerDocument) ?? 0 ownerDocumentCounts.set(ownerDocument, count + 1) registeredResizeHandlers.add(data) updateListeners() return function unregisterResizeHandle() { panelConstraintFlags.delete(resizeHandleId) registeredResizeHandlers.delete(data) const count = ownerDocumentCounts.get(ownerDocument) ?? 1 ownerDocumentCounts.set(ownerDocument, count - 1) updateListeners() resetGlobalCursorStyle() if (count === 1) ownerDocumentCounts.delete(ownerDocument) } } function handlePointerDown(event: ResizeEvent) { const { target } = event const { x, y } = getResizeEventCoordinates(event) isPointerDown = true recalculateIntersectingHandles({ target, x, y }) updateListeners() if (intersectingHandles.length > 0) { updateResizeHandlerStates('down', event) event.preventDefault() } } function handlePointerMove(event: ResizeEvent) { const { x, y } = getResizeEventCoordinates(event) if (!isPointerDown) { const { target } = event // Recalculate intersecting handles whenever the pointer moves, except if it has already been pressed // at that point, the handles may not move with the pointer (depending on constraints) // but the same set of active handles should be locked until the pointer is released recalculateIntersectingHandles({ target, x, y }) } updateResizeHandlerStates('move', event) // Update cursor based on return value(s) from active handles updateCursor() if (intersectingHandles.length > 0) event.preventDefault() } function handlePointerUp(event: ResizeEvent) { const { target } = event const { x, y } = getResizeEventCoordinates(event) panelConstraintFlags.clear() isPointerDown = false if (intersectingHandles.length > 0) event.preventDefault() updateResizeHandlerStates('up', event) recalculateIntersectingHandles({ target, x, y }) updateCursor() updateListeners() } function recalculateIntersectingHandles({ target, x, y, }: { target: EventTarget | null x: number y: number }) { intersectingHandles.splice(0) let targetElement: HTMLElement | null = null if (target instanceof HTMLElement) targetElement = target registeredResizeHandlers.forEach((data) => { const { element: dragHandleElement, hitAreaMargins } = data const dragHandleRect = dragHandleElement.getBoundingClientRect() const { bottom, left, right, top } = dragHandleRect const margin = isCoarsePointer ? hitAreaMargins.coarse : hitAreaMargins.fine const eventIntersects = x >= left - margin && x <= right + margin && y >= top - margin && y <= bottom + margin if (eventIntersects) { // TRICKY // We listen for pointers events at the root in order to support hit area margins // (determining when the pointer is close enough to an element to be considered a "hit") // Clicking on an element "above" a handle (e.g. a modal) should prevent a hit though // so at this point we need to compare stacking order of a potentially intersecting drag handle, // and the element that was actually clicked/touched if ( targetElement !== null && dragHandleElement !== targetElement && !dragHandleElement.contains(targetElement) && !targetElement.contains(dragHandleElement) // Calculating stacking order has a cost, so we should avoid it if possible // That is why we only check potentially intersecting handles, // and why we skip if the event target is within the handle's DOM && compare(targetElement, dragHandleElement) > 0 ) { // If the target is above the drag handle, then we also need to confirm they overlap // If they are beside each other (e.g. a panel and its drag handle) then the handle is still interactive // // It's not enough to compare only the target // The target might be a small element inside of a larger container // (For example, a SPAN or a DIV inside of a larger modal dialog) let currentElement: HTMLElement | null = targetElement let didIntersect = false while (currentElement) { if (currentElement.contains(dragHandleElement)) { break } else if ( intersects( currentElement.getBoundingClientRect(), dragHandleRect, true, ) ) { didIntersect = true break } currentElement = currentElement.parentElement } if (didIntersect) return } intersectingHandles.push(data) } }) } export function reportConstraintsViolation( resizeHandleId: string, flag: number, ) { panelConstraintFlags.set(resizeHandleId, flag) } function updateCursor() { let intersectsHorizontal = false let intersectsVertical = false let nonce: string | undefined intersectingHandles.forEach((data) => { const { direction, nonce: _nonce } = data if (direction.value === 'horizontal') intersectsHorizontal = true else intersectsVertical = true nonce = _nonce.value }) let constraintFlags = 0 panelConstraintFlags.forEach((flag) => { constraintFlags |= flag }) if (intersectsHorizontal && intersectsVertical) setGlobalCursorStyle('intersection', constraintFlags, nonce) else if (intersectsHorizontal) setGlobalCursorStyle('horizontal', constraintFlags, nonce) else if (intersectsVertical) setGlobalCursorStyle('vertical', constraintFlags, nonce) else resetGlobalCursorStyle() } function updateListeners() { ownerDocumentCounts.forEach((_, ownerDocument) => { const { body } = ownerDocument body.removeEventListener('contextmenu', handlePointerUp) body.removeEventListener('mousedown', handlePointerDown) body.removeEventListener('mouseleave', handlePointerMove) body.removeEventListener('mousemove', handlePointerMove) body.removeEventListener('touchmove', handlePointerMove) body.removeEventListener('touchstart', handlePointerDown) }) window.removeEventListener('mouseup', handlePointerUp) window.removeEventListener('touchcancel', handlePointerUp) window.removeEventListener('touchend', handlePointerUp) if (registeredResizeHandlers.size > 0) { if (isPointerDown) { if (intersectingHandles.length > 0) { ownerDocumentCounts.forEach((count, ownerDocument) => { const { body } = ownerDocument if (count > 0) { body.addEventListener('contextmenu', handlePointerUp) body.addEventListener('mouseleave', handlePointerMove) body.addEventListener('mousemove', handlePointerMove) body.addEventListener('touchmove', handlePointerMove, { passive: false, }) } }) } window.addEventListener('mouseup', handlePointerUp) window.addEventListener('touchcancel', handlePointerUp) window.addEventListener('touchend', handlePointerUp) } else { ownerDocumentCounts.forEach((count, ownerDocument) => { const { body } = ownerDocument if (count > 0) { body.addEventListener('mousedown', handlePointerDown) body.addEventListener('mousemove', handlePointerMove) body.addEventListener('touchmove', handlePointerMove, { passive: false, }) body.addEventListener('touchstart', handlePointerDown) } }) } } } function updateResizeHandlerStates( action: ResizeHandlerAction, event: ResizeEvent, ) { registeredResizeHandlers.forEach((data) => { const { setResizeHandlerState } = data const isActive = intersectingHandles.includes(data) setResizeHandlerState(action, isActive, event) }) }