UNPKG

@pmndrs/uikit

Version:

Build performant 3D user interfaces with Three.js and yoga.

368 lines (367 loc) 17.8 kB
import { computed, signal } from '@preact/signals-core'; import { Box2, Matrix4, Vector2, Vector3 } from 'three'; import { abortableEffect, computedBorderInset } from './utils.js'; import { clamp } from 'three/src/math/MathUtils.js'; import { computedPanelMatrix } from './panel/instance/matrix.js'; import { setupInstancedPanel } from './panel/instance/setup.js'; import { ElementType, setupOrderInfo } from './order.js'; import { createPanelMaterialConfig } from './panel/material/config.js'; import { parseNumberValue, parseAbsoluteLengthValue, } from './properties/values.js'; const distanceHelper = new Vector3(); const localPointHelper = new Vector3(); export function computedGlobalScrollMatrix(properties, scrollPosition, globalMatrix) { return computed(() => { const global = globalMatrix.value; if (global == null) { return undefined; } const [scrollX, scrollY] = scrollPosition.value; const pixelSize = parseNumberValue(properties.value.pixelSize); return new Matrix4().makeTranslation(-scrollX * pixelSize, scrollY * pixelSize, 0).premultiply(global); }); } export function computedAnyAncestorScrollable(parentSignal) { return computed(() => { const parent = parentSignal.value; const [ancestorX, ancestorY] = parent?.anyAncestorScrollable?.value ?? [false, false]; const [x, y] = parent?.scrollable.value ?? [false, false]; return [ancestorX || x, ancestorY || y]; }); } export function setupScrollHandlers(target, container, abortSignal, updateScrollFrame) { const isScrollable = computed(() => container.scrollable.value.some((scrollable) => scrollable) ?? false); abortableEffect(() => { if (!isScrollable.value) { target.value = undefined; return; } const onPointerFinish = (event) => { if ('releasePointerCapture' in container && typeof container.releasePointerCapture === 'function' && event.pointerId != null) { container.releasePointerCapture(event.pointerId); } if (event.pointerId == null || !container.downPointerMap.delete(event.pointerId) || container.scrollPosition.value == null) { return; } event.stopImmediatePropagation?.(); if (container.downPointerMap.size > 0) { return; } //only request a render if the last pointer that was dragging stopped dragging and this panel is actually scrollable container.root.peek().requestRender?.(); updateScrollFrame(); }; target.value = { onPointerDown: (event) => { event.stopImmediatePropagation?.(); const localPoint = container.worldToLocal(event.point.clone()); const ponterIsMouse = event.nativeEvent != null && typeof event.nativeEvent === 'object' && 'pointerType' in event.nativeEvent && event.nativeEvent.pointerType === 'mouse'; const scrollbarAxisIndex = ponterIsMouse ? getIntersectedScrollbarIndex(localPoint, parseAbsoluteLengthValue(container.properties.peek().scrollbarWidth ?? 0), container.size.peek(), container.maxScrollPosition.peek(), container.borderInset.peek(), container.scrollPosition.peek()) : undefined; if (event.pointerId == null || (ponterIsMouse && scrollbarAxisIndex == null)) { return; } if ('setPointerCapture' in event.object && typeof event.object.setPointerCapture === 'function') { event.object.setPointerCapture(event.pointerId); } container.downPointerMap.set(event.pointerId, scrollbarAxisIndex != null ? { type: 'scroll-bar', localPoint, axisIndex: scrollbarAxisIndex, } : { type: 'scroll-panel', timestamp: performance.now(), localPoint, }); }, onPointerUp: onPointerFinish, onPointerLeave: onPointerFinish, onPointerCancel: onPointerFinish, onPointerMove: (event) => { if (event.pointerId == null) { return; } const prevInteraction = container.downPointerMap.get(event.pointerId); if (prevInteraction == null) { return; } event.stopImmediatePropagation?.(); container.worldToLocal(localPointHelper.copy(event.point)); distanceHelper.copy(localPointHelper).sub(prevInteraction.localPoint); distanceHelper.x *= container.size.peek()?.[0] ?? 0; distanceHelper.y *= container.size.peek()?.[1] ?? 0; prevInteraction.localPoint.copy(localPointHelper); if (prevInteraction.type === 'scroll-bar') { const size = container.size.peek(); if (size == null) { return; } //convert distanceHelper to (drag delta) * maxScrollPosition toScrollbarScrollDistance(distanceHelper, prevInteraction.axisIndex, size, container.borderInset.peek(), container.maxScrollPosition.peek(), parseAbsoluteLengthValue(container.properties.peek().scrollbarWidth ?? 0)); scroll(container, event, distanceHelper.x, -distanceHelper.y, undefined, false); updateScrollFrame(); return; } const timestamp = performance.now(); const deltaTime = timestamp - prevInteraction.timestamp; scroll(container, event, -distanceHelper.x, distanceHelper.y, deltaTime, true); updateScrollFrame(); prevInteraction.timestamp = timestamp; }, onWheel: (event) => { const { nativeEvent } = event; if (nativeEvent == null || typeof nativeEvent != 'object' || !('deltaX' in nativeEvent) || typeof nativeEvent.deltaX != 'number' || !('deltaY' in nativeEvent) || typeof nativeEvent.deltaY != 'number') { return; } scroll(container, event, nativeEvent.deltaX, nativeEvent.deltaY, undefined, false); updateScrollFrame(); }, }; }, abortSignal); } function scroll(container, event, deltaX, deltaY, deltaTime, enableRubberBand) { const scrollPosition = container.scrollPosition.value; if (scrollPosition == null) { return; } const [wasScrolledX, wasScrolledY] = event == null ? [false, false] : getWasScrolled(event.nativeEvent); if (wasScrolledX) { deltaX = 0; } if (wasScrolledY) { deltaY = 0; } const [x, y] = scrollPosition; const [maxX, maxY] = container.maxScrollPosition.value; let [newX, newY] = scrollPosition; const [ancestorScrollableX, ancestorScrollableY] = container.anyAncestorScrollable?.value ?? [false, false]; newX = computeScroll(x, maxX, deltaX, enableRubberBand && !ancestorScrollableX); newY = computeScroll(y, maxY, deltaY, enableRubberBand && !ancestorScrollableY); if (deltaTime != null && deltaTime > 0) { container.scrollVelocity.set(deltaX, deltaY).divideScalar(deltaTime); } if (event != null) { setWasScrolled(event.nativeEvent, wasScrolledX || Math.min(x, (maxX ?? 0) - x) > 5, wasScrolledY || Math.min(y, (maxY ?? 0) - y) > 5); } const preventScroll = container.properties.peek().onScroll?.(newX, newY, container.scrollPosition, event); if (preventScroll === false || (x === newX && y === newY)) { return; } container.scrollPosition.value = [newX, newY]; } export function setupScroll(container) { const scrollFrameNeeded = signal(false); const updateScrollFrame = () => { const scrollPosition = container.scrollPosition.value; const [maxX, maxY] = container.maxScrollPosition.value; const needed = scrollPosition != null && (container.scrollVelocity.x !== 0 || container.scrollVelocity.y !== 0 || Math.abs(outsideDistance(scrollPosition[0], 0, maxX ?? 0)) > 1 || Math.abs(outsideDistance(scrollPosition[1], 0, maxY ?? 0)) > 1); scrollFrameNeeded.value = needed; if (needed) { container.root.peek().requestFrame?.(); } }; const onFrame = (delta) => { if (container.downPointerMap.size > 0) { return; } const scrollPosition = container.scrollPosition.value; if (scrollPosition == null) { updateScrollFrame(); return; } let deltaX = 0; let deltaY = 0; const [x, y] = scrollPosition; const [maxX, maxY] = container.maxScrollPosition.value; const outsideDistanceX = outsideDistance(x, 0, maxX ?? 0); const outsideDistanceY = outsideDistance(y, 0, maxY ?? 0); deltaX += outsideDistanceX * -0.3; deltaY += outsideDistanceY * -0.3; deltaX += container.scrollVelocity.x * delta; deltaY += container.scrollVelocity.y * delta; container.scrollVelocity.multiplyScalar(0.9); //damping scroll factor if (Math.abs(container.scrollVelocity.x) < 0.01 /** 10 px per second */) { container.scrollVelocity.x = 0; } if (Math.abs(container.scrollVelocity.y) < 0.01 /** 10 px per second */) { container.scrollVelocity.y = 0; } if (deltaX !== 0 || deltaY !== 0) { scroll(container, undefined, deltaX, deltaY, undefined, true); } updateScrollFrame(); }; abortableEffect(updateScrollFrame, container.abortSignal); abortableEffect(() => { if (!scrollFrameNeeded.value) { return; } const root = container.root.value; root.onFrameSet.add(onFrame); root.requestFrame?.(); return () => root.onFrameSet.delete(onFrame); }, container.abortSignal); return updateScrollFrame; } const wasScrolledSymbol = Symbol('was-scrolled'); function getWasScrolled(event) { return event[wasScrolledSymbol] ?? [false, false]; } function setWasScrolled(event, x, y) { event[wasScrolledSymbol] = [x, y]; } function computeScroll(position, maxPosition, delta, enableRubberBand) { if (delta === 0) { return position; } const outside = outsideDistance(position, 0, maxPosition ?? 0); if (sign(delta) === sign(outside)) { delta *= Math.max(0, 1 - Math.abs(outside) / 100); } let newPosition = position + delta; if (enableRubberBand && maxPosition != null) { return newPosition; } return clamp(newPosition, 0, maxPosition ?? 0); } function sign(value) { return value >= 0; } function outsideDistance(value, min, max) { if (value < min) { return value - min; } if (value > max) { return value - max; } return 0; } const scrollbarBorderPropertyKeys = [ 'scrollbarBorderLeftWidth', 'scrollbarBorderRightWidth', 'scrollbarBorderTopWidth', 'scrollbarBorderBottomWidth', ]; export function setupScrollbars(container, parentClippingRect, prevOrderInfo, prevPanelDeps) { const scrollbarOrderInfo = signal(undefined); setupOrderInfo(scrollbarOrderInfo, container.properties, 'scrollbarZIndex', ElementType.Panel, prevPanelDeps, prevOrderInfo, container.abortSignal); const borderInset = computedBorderInset(container.properties, scrollbarBorderPropertyKeys); setupScrollbar(container, 0, parentClippingRect, scrollbarOrderInfo, prevPanelDeps, borderInset); setupScrollbar(container, 1, parentClippingRect, scrollbarOrderInfo, prevPanelDeps, borderInset); } let scrollbarMaterialConfig; function getScrollbarMaterialConfig() { scrollbarMaterialConfig ??= createPanelMaterialConfig({ backgroundColor: 'scrollbarColor', borderBottomLeftRadius: 'scrollbarBorderBottomLeftRadius', borderBottomRightRadius: 'scrollbarBorderBottomRightRadius', borderTopRightRadius: 'scrollbarBorderTopRightRadius', borderTopLeftRadius: 'scrollbarBorderTopLeftRadius', borderColor: 'scrollbarBorderColor', borderBend: 'scrollbarBorderBend', }, { backgroundColor: 0xffffff, }); return scrollbarMaterialConfig; } function setupScrollbar(container, primaryIndex, parentClippingRect, orderInfo, groupDeps, borderSize) { const scrollbarTransformation = computed(() => computeScrollbarTransformation(primaryIndex, parseAbsoluteLengthValue(container.properties.value.scrollbarWidth ?? 0), container.size.value, container.maxScrollPosition.value, container.borderInset.value, container.scrollPosition.value)); const scrollbarPosition = computed(() => (scrollbarTransformation.value?.slice(0, 2) ?? [0, 0])); const scrollbarSize = computed(() => (scrollbarTransformation.value?.slice(2, 4) ?? [0, 0])); const panelMatrix = computedPanelMatrix(container.properties, container.globalMatrix, scrollbarSize, scrollbarPosition); setupInstancedPanel(container.properties, container.root, orderInfo, groupDeps, panelMatrix, scrollbarSize, borderSize, parentClippingRect, container.isVisible, getScrollbarMaterialConfig(), container.abortSignal); } function computeScrollbarTransformation(primaryAxisIndex, secondaryScrollbarSize, size, maxScrollPosition, borderInset, scrollPosition) { if (size == null || borderInset == null || scrollPosition == null) { return undefined; } const primaryMaxScrollPosition = maxScrollPosition[primaryAxisIndex]; if (primaryMaxScrollPosition == null) { return undefined; } const result = [0, 0, 0, 0]; const endInsetIndex = 1 - primaryAxisIndex; const primarySizeWithoutBorder = size[primaryAxisIndex] - borderInset[endInsetIndex] - borderInset[endInsetIndex + 2]; const primaryScrollbarSize = computePrimaryScrollbarSize(primarySizeWithoutBorder, primaryMaxScrollPosition, secondaryScrollbarSize); const primaryMaxScrollbarPosition = primarySizeWithoutBorder - primaryScrollbarSize; const primaryScrollPosition = scrollPosition[primaryAxisIndex]; //position const invertedIndex = 1 - primaryAxisIndex; result[primaryAxisIndex] = size[primaryAxisIndex] * 0.5 - primaryScrollbarSize * 0.5 - borderInset[(primaryAxisIndex + 3) % 4] - primaryMaxScrollbarPosition * clamp(primaryScrollPosition / primaryMaxScrollPosition, 0, 1); result[invertedIndex] = size[invertedIndex] * 0.5 - secondaryScrollbarSize * 0.5 - borderInset[invertedIndex + 1]; if (primaryAxisIndex === 0) { result[0] *= -1; result[1] *= -1; } //size result[primaryAxisIndex + 2] = primaryScrollbarSize; result[endInsetIndex + 2] = secondaryScrollbarSize; return result; } function computePrimaryScrollbarSize(primarySizeWithoutBorder, primaryMaxScrollPosition, secondaryScrollbarSize) { return Math.max(secondaryScrollbarSize, (primarySizeWithoutBorder * primarySizeWithoutBorder) / (primaryMaxScrollPosition + primarySizeWithoutBorder)); } /** * @param target contains the delta movement in pixels and will receive the delta scroll distance in pixels */ function toScrollbarScrollDistance(target, primaryAxisIndex, size, borderInset, maxScrollPosition, secondaryScrollbarSize) { const primaryMaxScrollPosition = maxScrollPosition[primaryAxisIndex]; if (size == null || borderInset == null || primaryMaxScrollPosition == null) { return; } const delta = target.getComponent(primaryAxisIndex); const primarySizeWithoutBorder = size[primaryAxisIndex] - borderInset[1 - primaryAxisIndex] - borderInset[1 - primaryAxisIndex + 2]; const primaryScrollbarSize = computePrimaryScrollbarSize(primarySizeWithoutBorder, primaryMaxScrollPosition, secondaryScrollbarSize); const primaryMaxScrollbarPosition = primarySizeWithoutBorder - primaryScrollbarSize; target.setComponent(primaryAxisIndex, (delta / primaryMaxScrollbarPosition) * primaryMaxScrollPosition); target.setComponent(1 - primaryAxisIndex, 0); target.z = 0; } const box2Helper = new Box2(); const point2Helper = new Vector2(); function getIntersectedScrollbarIndex(point, secondaryScrollbarSize, size, maxScrollPosition, borderInset, scrollPosition) { if (size == null) { return undefined; } point2Helper.copy(point); point2Helper.x *= size[0]; point2Helper.y *= size[1]; for (let i = 0; i < 2; i++) { if (intersectsScrollbar(point2Helper, i, secondaryScrollbarSize, size, maxScrollPosition, borderInset, scrollPosition)) { return i; } } return undefined; } const centerHelper = new Vector2(); const sizeHelper = new Vector2(); function intersectsScrollbar(point, axisIndex, secondaryScrollbarSize, size, maxScrollPosition, borderInset, scrollPosition) { const result = computeScrollbarTransformation(axisIndex, secondaryScrollbarSize, size, maxScrollPosition, borderInset, scrollPosition); if (result == null) { return false; } box2Helper.setFromCenterAndSize(centerHelper.fromArray(result, 0), sizeHelper.fromArray(result, 2)); return box2Helper.containsPoint(point); }