eefd7abf83
Reorganize components into category folders (forms/canvas/overlays/etc.); add the media-editor headless family (timeline, curve-editor, waveform, crop, color picker, etc.); apply perf fixes (O(1) collection lookups, plain-object drag state, gesture-leak teardown, shallowRef color state, rect caching) and replace source `any` with proper types.
284 lines
9.8 KiB
TypeScript
284 lines
9.8 KiB
TypeScript
import type { Ref } from 'vue';
|
|
import { computed, nextTick, ref, watch } from 'vue';
|
|
import { setStyle } from '@robonen/platform/browsers';
|
|
import { useEventListener } from '@robonen/vue';
|
|
import { isVertical } from './helpers';
|
|
import { TRANSITIONS, VELOCITY_THRESHOLD } from './constants';
|
|
import type { DrawerDirection } from './types';
|
|
|
|
interface UseSnapPointsProps {
|
|
activeSnapPoint: Ref<number | string | null | undefined>;
|
|
snapPoints: Ref<Array<number | string> | undefined>;
|
|
fadeFromIndex: Ref<number | undefined>;
|
|
drawerRef: Ref<HTMLElement | undefined>;
|
|
overlayRef: Ref<HTMLElement | undefined>;
|
|
onSnapPointChange: (activeSnapPointIndex: number, snapPointsOffset: number[]) => void;
|
|
direction: Ref<DrawerDirection>;
|
|
}
|
|
|
|
const transition = (property: 'transform' | 'opacity') =>
|
|
`${property} ${TRANSITIONS.DURATION}s cubic-bezier(${TRANSITIONS.EASE.join(',')})`;
|
|
|
|
/**
|
|
* Drag/release maths for drawers configured with snap points: resolves each
|
|
* snap point to a pixel offset, animates the drawer between them, and decides
|
|
* which point to settle on (or whether to close) based on drag distance and
|
|
* velocity.
|
|
*/
|
|
export function useSnapPoints({
|
|
activeSnapPoint,
|
|
snapPoints,
|
|
drawerRef,
|
|
overlayRef,
|
|
fadeFromIndex,
|
|
onSnapPointChange,
|
|
direction,
|
|
}: UseSnapPointsProps) {
|
|
const windowDimensions = ref(globalThis.window !== undefined
|
|
? { innerWidth: window.innerWidth, innerHeight: window.innerHeight }
|
|
: undefined);
|
|
|
|
function onResize() {
|
|
const innerWidth = window.innerWidth;
|
|
const innerHeight = window.innerHeight;
|
|
const cur = windowDimensions.value;
|
|
// Skip the ref write (and the snapPointsOffset recompute it would trigger)
|
|
// when dimensions are unchanged — some resize events report identical sizes.
|
|
if (!cur || cur.innerWidth !== innerWidth || cur.innerHeight !== innerHeight)
|
|
windowDimensions.value = { innerWidth, innerHeight };
|
|
}
|
|
|
|
// Defaults to `defaultWindow` (SSR-safe) and auto-removes on scope dispose.
|
|
useEventListener('resize', onResize);
|
|
|
|
const isLastSnapPoint = computed(
|
|
() => (snapPoints.value && activeSnapPoint.value === snapPoints.value[snapPoints.value.length - 1]) ?? null,
|
|
);
|
|
|
|
const shouldFade = computed(
|
|
() =>
|
|
(snapPoints.value
|
|
&& snapPoints.value.length > 0
|
|
&& (fadeFromIndex?.value || fadeFromIndex?.value === 0)
|
|
&& !Number.isNaN(fadeFromIndex?.value)
|
|
&& snapPoints.value[fadeFromIndex?.value ?? -1] === activeSnapPoint.value)
|
|
|| !snapPoints.value,
|
|
);
|
|
|
|
const activeSnapPointIndex = computed(
|
|
() => snapPoints.value?.indexOf(activeSnapPoint.value) ?? null,
|
|
);
|
|
|
|
const snapPointsOffset = computed(
|
|
() =>
|
|
snapPoints.value?.map((snapPoint) => {
|
|
const isPx = typeof snapPoint === 'string';
|
|
let snapPointAsNumber = 0;
|
|
|
|
if (isPx)
|
|
snapPointAsNumber = Number.parseInt(snapPoint, 10);
|
|
|
|
if (isVertical(direction.value)) {
|
|
const height = isPx
|
|
? snapPointAsNumber
|
|
: windowDimensions.value
|
|
? (snapPoint as number) * windowDimensions.value.innerHeight
|
|
: 0;
|
|
|
|
if (windowDimensions.value)
|
|
return direction.value === 'bottom' ? windowDimensions.value.innerHeight - height : -windowDimensions.value.innerHeight + height;
|
|
|
|
return height;
|
|
}
|
|
|
|
const width = isPx
|
|
? snapPointAsNumber
|
|
: windowDimensions.value
|
|
? (snapPoint as number) * windowDimensions.value.innerWidth
|
|
: 0;
|
|
|
|
if (windowDimensions.value)
|
|
return direction.value === 'right' ? windowDimensions.value.innerWidth - width : -windowDimensions.value.innerWidth + width;
|
|
|
|
return width;
|
|
}) ?? [],
|
|
);
|
|
|
|
const activeSnapPointOffset = computed(() =>
|
|
activeSnapPointIndex.value !== null ? snapPointsOffset.value?.[activeSnapPointIndex.value] : null,
|
|
);
|
|
|
|
function snapToPoint(dimension: number) {
|
|
const newSnapPointIndex = snapPointsOffset.value?.indexOf(dimension) ?? null;
|
|
|
|
// Wait for the element to be mounted before transforming it.
|
|
nextTick(() => {
|
|
onSnapPointChange(newSnapPointIndex, snapPointsOffset.value);
|
|
setStyle(drawerRef.value, {
|
|
transition: transition('transform'),
|
|
transform: isVertical(direction.value) ? `translate3d(0, ${dimension}px, 0)` : `translate3d(${dimension}px, 0, 0)`,
|
|
});
|
|
});
|
|
|
|
if (
|
|
snapPointsOffset.value
|
|
&& newSnapPointIndex !== snapPointsOffset.value.length - 1
|
|
&& newSnapPointIndex !== fadeFromIndex?.value
|
|
) {
|
|
setStyle(overlayRef.value, { transition: transition('opacity'), opacity: '0' });
|
|
}
|
|
else {
|
|
setStyle(overlayRef.value, { transition: transition('opacity'), opacity: '1' });
|
|
}
|
|
|
|
activeSnapPoint.value = newSnapPointIndex !== null ? snapPoints.value?.[newSnapPointIndex] ?? null : null;
|
|
}
|
|
|
|
watch(
|
|
[activeSnapPoint, snapPointsOffset, snapPoints],
|
|
() => {
|
|
if (activeSnapPoint.value) {
|
|
const newIndex = snapPoints.value?.indexOf(activeSnapPoint.value) ?? -1;
|
|
|
|
if (snapPointsOffset.value && newIndex !== -1 && typeof snapPointsOffset.value[newIndex] === 'number')
|
|
snapToPoint(snapPointsOffset.value[newIndex]);
|
|
}
|
|
},
|
|
{ immediate: true },
|
|
);
|
|
|
|
function onRelease({
|
|
draggedDistance,
|
|
closeDrawer,
|
|
velocity,
|
|
dismissible,
|
|
}: {
|
|
draggedDistance: number;
|
|
closeDrawer: () => void;
|
|
velocity: number;
|
|
dismissible: boolean;
|
|
}) {
|
|
if (fadeFromIndex.value === undefined)
|
|
return;
|
|
|
|
const currentPosition
|
|
= direction.value === 'bottom' || direction.value === 'right'
|
|
? (activeSnapPointOffset.value ?? 0) - draggedDistance
|
|
: (activeSnapPointOffset.value ?? 0) + draggedDistance;
|
|
const isOverlaySnapPoint = activeSnapPointIndex.value === fadeFromIndex.value - 1;
|
|
const isFirst = activeSnapPointIndex.value === 0;
|
|
const hasDraggedUp = draggedDistance > 0;
|
|
|
|
if (isOverlaySnapPoint)
|
|
setStyle(overlayRef.value, { transition: transition('opacity') });
|
|
|
|
if (velocity > 2 && !hasDraggedUp) {
|
|
if (dismissible)
|
|
closeDrawer();
|
|
else
|
|
snapToPoint(snapPointsOffset.value[0]); // snap to initial point
|
|
return;
|
|
}
|
|
|
|
if (velocity > 2 && hasDraggedUp && snapPointsOffset.value && snapPoints.value) {
|
|
snapToPoint(snapPointsOffset.value[snapPoints.value.length - 1]);
|
|
return;
|
|
}
|
|
|
|
// Settle on the snap point closest to where the drag ended.
|
|
const closestSnapPoint = snapPointsOffset.value?.reduce((prev, curr) => {
|
|
if (typeof prev !== 'number' || typeof curr !== 'number')
|
|
return prev;
|
|
|
|
return Math.abs(curr - currentPosition) < Math.abs(prev - currentPosition) ? curr : prev;
|
|
});
|
|
|
|
const dim = isVertical(direction.value) ? window.innerHeight : window.innerWidth;
|
|
if (velocity > VELOCITY_THRESHOLD && Math.abs(draggedDistance) < dim * 0.4) {
|
|
const dragDirection = hasDraggedUp ? 1 : -1; // 1 = up, -1 = down
|
|
|
|
// Ignore an upward flick while already on the last snap point.
|
|
if (dragDirection > 0 && isLastSnapPoint.value) {
|
|
snapToPoint(snapPointsOffset.value[(snapPoints.value?.length ?? 0) - 1]);
|
|
return;
|
|
}
|
|
|
|
if (isFirst && dragDirection < 0 && dismissible)
|
|
closeDrawer();
|
|
|
|
if (activeSnapPointIndex.value === null)
|
|
return;
|
|
|
|
snapToPoint(snapPointsOffset.value[activeSnapPointIndex.value + dragDirection]);
|
|
return;
|
|
}
|
|
|
|
snapToPoint(closestSnapPoint);
|
|
}
|
|
|
|
function onDrag({ draggedDistance }: { draggedDistance: number }) {
|
|
if (activeSnapPointOffset.value === null)
|
|
return;
|
|
|
|
const newValue
|
|
= direction.value === 'bottom' || direction.value === 'right'
|
|
? (activeSnapPointOffset.value ?? 0) - draggedDistance
|
|
: (activeSnapPointOffset.value ?? 0) + draggedDistance;
|
|
|
|
// Don't drag past the last (largest) snap point.
|
|
if ((direction.value === 'bottom' || direction.value === 'right') && newValue < snapPointsOffset.value[snapPointsOffset.value.length - 1])
|
|
return;
|
|
|
|
if ((direction.value === 'top' || direction.value === 'left') && newValue > snapPointsOffset.value[snapPointsOffset.value.length - 1])
|
|
return;
|
|
|
|
setStyle(drawerRef.value, {
|
|
transform: isVertical(direction.value) ? `translate3d(0, ${newValue}px, 0)` : `translate3d(${newValue}px, 0, 0)`,
|
|
});
|
|
}
|
|
|
|
function getPercentageDragged(absDraggedDistance: number, isDraggingDown: boolean) {
|
|
if (
|
|
!snapPoints.value
|
|
|| typeof activeSnapPointIndex.value !== 'number'
|
|
|| !snapPointsOffset.value
|
|
|| fadeFromIndex.value === undefined
|
|
)
|
|
return null;
|
|
|
|
// Whether we're dragging toward a snap point that should show the overlay.
|
|
const isOverlaySnapPoint = activeSnapPointIndex.value === fadeFromIndex.value - 1;
|
|
const isOverlaySnapPointOrHigher = activeSnapPointIndex.value >= fadeFromIndex.value;
|
|
|
|
if (isOverlaySnapPointOrHigher && isDraggingDown)
|
|
return 0;
|
|
|
|
// Don't animate, but still use this one when dragging away from the overlay snap point.
|
|
if (isOverlaySnapPoint && !isDraggingDown)
|
|
return 1;
|
|
if (!shouldFade.value && !isOverlaySnapPoint)
|
|
return null;
|
|
|
|
const targetSnapPointIndex = isOverlaySnapPoint ? activeSnapPointIndex.value + 1 : activeSnapPointIndex.value - 1;
|
|
|
|
// Distance between the overlay snap point and its neighbour, used to scale opacity.
|
|
const snapPointDistance = isOverlaySnapPoint
|
|
? snapPointsOffset.value[targetSnapPointIndex] - snapPointsOffset.value[targetSnapPointIndex - 1]
|
|
: snapPointsOffset.value[targetSnapPointIndex + 1] - snapPointsOffset.value[targetSnapPointIndex];
|
|
|
|
const percentageDragged = absDraggedDistance / Math.abs(snapPointDistance);
|
|
|
|
return isOverlaySnapPoint ? 1 - percentageDragged : percentageDragged;
|
|
}
|
|
|
|
return {
|
|
isLastSnapPoint,
|
|
shouldFade,
|
|
getPercentageDragged,
|
|
activeSnapPointIndex,
|
|
onRelease,
|
|
onDrag,
|
|
snapPointsOffset,
|
|
};
|
|
}
|