reka-ui
Version:
Vue port for Radix UI Primitives.
328 lines (267 loc) • 9.73 kB
text/typescript
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)
})
}