feat(primitives): media-editor components, category reorg, perf + type cleanup
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.
This commit is contained in:
@@ -0,0 +1,283 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user