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,284 @@
|
||||
<script lang="ts">
|
||||
import type { Align, Side } from './utils';
|
||||
import type { Middleware, Placement, ReferenceElement } from '@floating-ui/vue';
|
||||
import type { PrimitiveProps } from '../../internal/primitive';
|
||||
|
||||
/**
|
||||
* The floating element positioned against the anchor. This is the workhorse of
|
||||
* the Popper building block: it runs Floating UI (offset, flip, shift, size,
|
||||
* arrow, hide) to place itself on the chosen side/alignment, keeps the position
|
||||
* updated on scroll/resize/layout shift, avoids collisions with the viewport or
|
||||
* a custom boundary, and exposes `--popper-*` CSS variables plus `data-side` /
|
||||
* `data-align` attributes for styling and transform-origin. Use the Popper parts
|
||||
* to build any anchored overlay — tooltips, popovers, menus, selects — where
|
||||
* content must follow a trigger and stay on-screen. Place it inside a
|
||||
* `PopperRoot` (so it can read the registered anchor) and emit `placed` once the
|
||||
* first position settles.
|
||||
*/
|
||||
export interface PopperContentProps extends PrimitiveProps {
|
||||
/** Preferred side of the anchor. @default 'bottom' */
|
||||
side?: Side;
|
||||
/** Distance in pixels from the anchor. @default 0 */
|
||||
sideOffset?: number;
|
||||
/** Flip to the opposite side on collision. @default true */
|
||||
sideFlip?: boolean;
|
||||
/** Preferred alignment against the anchor. @default 'center' */
|
||||
align?: Align;
|
||||
/** Offset in pixels from the alignment edge. @default 0 */
|
||||
alignOffset?: number;
|
||||
/** Flip alignment on collision. @default true */
|
||||
alignFlip?: boolean;
|
||||
/** Reposition to prevent boundary overflow. @default true */
|
||||
avoidCollisions?: boolean;
|
||||
/** Collision boundary element(s). @default [] */
|
||||
collisionBoundary?: Array<Element | null> | Element | null;
|
||||
/** Distance from boundary for collision detection. @default 0 */
|
||||
collisionPadding?: number | Partial<Record<Side, number>>;
|
||||
/** Padding between arrow and content edges. @default 0 */
|
||||
arrowPadding?: number;
|
||||
/** Hide arrow when it can't be centered. @default true */
|
||||
hideShiftedArrow?: boolean;
|
||||
/** Sticky behavior on the align axis. @default 'partial' */
|
||||
sticky?: 'always' | 'partial';
|
||||
/** Hide when anchor is fully occluded. @default false */
|
||||
hideWhenDetached?: boolean;
|
||||
/** CSS position strategy. @default 'fixed' */
|
||||
positionStrategy?: 'absolute' | 'fixed';
|
||||
/** Position update strategy. @default 'optimized' */
|
||||
updatePositionStrategy?: 'always' | 'optimized';
|
||||
/** Disable layout-shift-based position update. @default false */
|
||||
disableUpdateOnLayoutShift?: boolean;
|
||||
/** Force content to stay within the viewport. @default false */
|
||||
prioritizePosition?: boolean;
|
||||
/** Custom reference element, overrides the anchor. */
|
||||
reference?: ReferenceElement;
|
||||
}
|
||||
|
||||
export interface PopperContentEmits {
|
||||
placed: [];
|
||||
}
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Primitive } from '../../internal/primitive';
|
||||
import {
|
||||
autoUpdate,
|
||||
flip,
|
||||
arrow as floatingUIArrow,
|
||||
hide,
|
||||
limitShift,
|
||||
offset,
|
||||
shift,
|
||||
size,
|
||||
useFloating,
|
||||
} from '@floating-ui/vue';
|
||||
import { computed, ref, shallowRef, useTemplateRef, watchEffect, watchPostEffect } from 'vue';
|
||||
import { getSideAndAlignFromPlacement, isNotNull, transformOrigin } from './utils';
|
||||
import { providePopperContentContext, usePopperRootContext } from './context';
|
||||
import { useForwardExpose, useResizeObserver } from '@robonen/vue';
|
||||
|
||||
defineOptions({ inheritAttrs: false });
|
||||
|
||||
const {
|
||||
side = 'bottom',
|
||||
sideOffset = 0,
|
||||
sideFlip = true,
|
||||
align = 'center',
|
||||
alignOffset = 0,
|
||||
alignFlip = true,
|
||||
avoidCollisions = true,
|
||||
collisionBoundary = [],
|
||||
collisionPadding: collisionPaddingProp = 0,
|
||||
arrowPadding = 0,
|
||||
hideShiftedArrow = true,
|
||||
sticky = 'partial',
|
||||
hideWhenDetached = false,
|
||||
positionStrategy = 'fixed',
|
||||
updatePositionStrategy = 'optimized',
|
||||
disableUpdateOnLayoutShift = false,
|
||||
prioritizePosition = false,
|
||||
reference: referenceProp,
|
||||
as,
|
||||
} = defineProps<PopperContentProps>();
|
||||
|
||||
const emit = defineEmits<PopperContentEmits>();
|
||||
|
||||
const rootContext = usePopperRootContext();
|
||||
const { forwardRef, currentElement: contentElement } = useForwardExpose();
|
||||
|
||||
const floatingRef = useTemplateRef<HTMLElement>('floatingRef');
|
||||
const arrow = shallowRef<HTMLElement>();
|
||||
|
||||
// Arrow size tracking via ResizeObserver (replaces useSize). The observer
|
||||
// re-targets when the arrow element changes and tears down on scope dispose.
|
||||
const arrowWidth = ref(0);
|
||||
const arrowHeight = ref(0);
|
||||
|
||||
useResizeObserver(arrow, ([entry]) => {
|
||||
if (!entry) return;
|
||||
const borderBox = entry.borderBoxSize[0];
|
||||
if (borderBox) {
|
||||
arrowWidth.value = borderBox.inlineSize;
|
||||
arrowHeight.value = borderBox.blockSize;
|
||||
}
|
||||
else {
|
||||
const rect = (entry.target as HTMLElement).getBoundingClientRect();
|
||||
arrowWidth.value = rect.width;
|
||||
arrowHeight.value = rect.height;
|
||||
}
|
||||
});
|
||||
|
||||
const desiredPlacement = computed<Placement>(
|
||||
() => (side + (align !== 'center' ? `-${align}` : '')) as Placement,
|
||||
);
|
||||
|
||||
const collisionPadding = computed(() => {
|
||||
return typeof collisionPaddingProp === 'number'
|
||||
? collisionPaddingProp
|
||||
: { top: 0, right: 0, bottom: 0, left: 0, ...collisionPaddingProp };
|
||||
});
|
||||
|
||||
const boundary = computed(() => {
|
||||
return Array.isArray(collisionBoundary)
|
||||
? collisionBoundary
|
||||
: [collisionBoundary];
|
||||
});
|
||||
|
||||
const detectOverflowOptions = computed(() => ({
|
||||
padding: collisionPadding.value,
|
||||
boundary: boundary.value.filter(isNotNull),
|
||||
altBoundary: boundary.value.length > 0,
|
||||
}));
|
||||
|
||||
const flipOptions = computed(() => ({
|
||||
mainAxis: sideFlip,
|
||||
crossAxis: alignFlip,
|
||||
}));
|
||||
|
||||
const computedMiddleware = computed<Middleware[]>(() => [
|
||||
offset({
|
||||
mainAxis: sideOffset + arrowHeight.value,
|
||||
alignmentAxis: alignOffset,
|
||||
}),
|
||||
prioritizePosition
|
||||
&& avoidCollisions
|
||||
&& flip({ ...detectOverflowOptions.value, ...flipOptions.value }),
|
||||
avoidCollisions
|
||||
&& shift({
|
||||
mainAxis: true,
|
||||
crossAxis: !!prioritizePosition,
|
||||
limiter: sticky === 'partial' ? limitShift() : undefined,
|
||||
...detectOverflowOptions.value,
|
||||
}),
|
||||
!prioritizePosition
|
||||
&& avoidCollisions
|
||||
&& flip({ ...detectOverflowOptions.value, ...flipOptions.value }),
|
||||
size({
|
||||
...detectOverflowOptions.value,
|
||||
apply: ({ elements, rects, availableWidth, availableHeight }) => {
|
||||
const { width: anchorWidth, height: anchorHeight } = rects.reference;
|
||||
const contentStyle = elements.floating.style;
|
||||
contentStyle.setProperty('--popper-available-width', `${availableWidth}px`);
|
||||
contentStyle.setProperty('--popper-available-height', `${availableHeight}px`);
|
||||
contentStyle.setProperty('--popper-anchor-width', `${anchorWidth}px`);
|
||||
contentStyle.setProperty('--popper-anchor-height', `${anchorHeight}px`);
|
||||
},
|
||||
}),
|
||||
arrow.value && floatingUIArrow({ element: arrow.value, padding: arrowPadding }),
|
||||
transformOrigin({ arrowWidth: arrowWidth.value, arrowHeight: arrowHeight.value }),
|
||||
hideWhenDetached && hide({ strategy: 'referenceHidden', ...detectOverflowOptions.value }),
|
||||
] as Middleware[]);
|
||||
|
||||
const reference = computed(() => referenceProp ?? rootContext.anchor.value);
|
||||
|
||||
const { floatingStyles, placement, isPositioned, middlewareData } = useFloating(
|
||||
reference,
|
||||
floatingRef,
|
||||
{
|
||||
strategy: positionStrategy,
|
||||
placement: desiredPlacement,
|
||||
whileElementsMounted: (...args) => {
|
||||
return autoUpdate(...args, {
|
||||
layoutShift: !disableUpdateOnLayoutShift,
|
||||
animationFrame: updatePositionStrategy === 'always',
|
||||
});
|
||||
},
|
||||
middleware: computedMiddleware,
|
||||
},
|
||||
);
|
||||
|
||||
const placedSide = computed(() => getSideAndAlignFromPlacement(placement.value)[0]);
|
||||
const placedAlign = computed(() => getSideAndAlignFromPlacement(placement.value)[1]);
|
||||
|
||||
watchPostEffect(() => {
|
||||
if (isPositioned.value) emit('placed');
|
||||
});
|
||||
|
||||
const shouldHideArrow = computed(() => {
|
||||
const cannotCenterArrow = middlewareData.value.arrow?.centerOffset !== 0;
|
||||
return hideShiftedArrow && cannotCenterArrow;
|
||||
});
|
||||
|
||||
const contentZIndex = shallowRef('');
|
||||
watchEffect(() => {
|
||||
if (contentElement.value) {
|
||||
contentZIndex.value = globalThis.getComputedStyle(contentElement.value).zIndex;
|
||||
}
|
||||
});
|
||||
|
||||
const arrowX = computed(() => middlewareData.value.arrow?.x ?? 0);
|
||||
const arrowY = computed(() => middlewareData.value.arrow?.y ?? 0);
|
||||
|
||||
// Memoize the floating wrapper style. The template re-renders on every position
|
||||
// update frame while open; a computed isolates the per-frame allocation to one
|
||||
// tracked site and avoids re-running the [x,y].join(' ') and conditional reads
|
||||
// when the render is triggered by an unrelated dep.
|
||||
const wrapperStyle = computed(() => {
|
||||
const md = middlewareData.value;
|
||||
const referenceHidden = md.hide?.referenceHidden;
|
||||
return {
|
||||
...floatingStyles.value,
|
||||
transform: isPositioned.value ? floatingStyles.value.transform : 'translate(0, -200%)',
|
||||
minWidth: 'max-content',
|
||||
zIndex: contentZIndex.value,
|
||||
'--popper-transform-origin': [
|
||||
md.transformOrigin?.x,
|
||||
md.transformOrigin?.y,
|
||||
].join(' '),
|
||||
// Always set both keys so V8 keeps a single hidden class for the style
|
||||
// object across renders, instead of allocating a new shape when the
|
||||
// conditional spread kicks in.
|
||||
visibility: referenceHidden ? ('hidden' as const) : undefined,
|
||||
pointerEvents: referenceHidden ? ('none' as const) : undefined,
|
||||
};
|
||||
});
|
||||
|
||||
providePopperContentContext({
|
||||
placedSide,
|
||||
onArrowChange: (element: HTMLElement | undefined) => { arrow.value = element; },
|
||||
arrowX,
|
||||
arrowY,
|
||||
shouldHideArrow,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="floatingRef"
|
||||
data-popper-content-wrapper=""
|
||||
:style="wrapperStyle"
|
||||
>
|
||||
<Primitive
|
||||
:ref="forwardRef"
|
||||
v-bind="$attrs"
|
||||
:as="as"
|
||||
:data-side="placedSide"
|
||||
:data-align="placedAlign"
|
||||
:style="{
|
||||
animation: isPositioned ? undefined : 'none',
|
||||
}"
|
||||
>
|
||||
<slot />
|
||||
</Primitive>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user