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:
2026-06-15 16:54:29 +07:00
parent 661a55719e
commit eefd7abf83
1029 changed files with 65815 additions and 9449 deletions
@@ -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>