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,63 @@
import { describe, expect, it } from 'vitest';
import { Comment, Fragment, createVNode, h } from 'vue';
import { getRawChildren } from '../getRawChildren';
describe(getRawChildren, () => {
it('returns empty array for empty input', () => {
expect(getRawChildren([])).toEqual([]);
});
it('returns element vnodes as-is', () => {
const div = h('div');
const span = h('span');
const result = getRawChildren([div, span]);
expect(result).toHaveLength(2);
expect(result[0]!.type).toBe('div');
expect(result[1]!.type).toBe('span');
});
it('filters out Comment vnodes', () => {
const div = h('div');
const comment = createVNode(Comment, null, 'comment');
const result = getRawChildren([comment, div, comment]);
expect(result).toHaveLength(1);
expect(result[0]!.type).toBe('div');
});
it('flattens Fragment children', () => {
const fragment = createVNode(Fragment, null, [h('a'), h('b')]);
const result = getRawChildren([fragment]);
expect(result).toHaveLength(2);
expect(result[0]!.type).toBe('a');
expect(result[1]!.type).toBe('b');
});
it('recursively flattens nested Fragment children', () => {
const innerFragment = createVNode(Fragment, null, [h('span')]);
const outerFragment = createVNode(Fragment, null, [innerFragment, h('div')]);
const result = getRawChildren([outerFragment]);
expect(result).toHaveLength(2);
expect(result[0]!.type).toBe('span');
expect(result[1]!.type).toBe('div');
});
it('filters comments inside fragments', () => {
const fragment = createVNode(Fragment, null, [
createVNode(Comment, null, 'skip'),
h('p'),
]);
const result = getRawChildren([fragment]);
expect(result).toHaveLength(1);
expect(result[0]!.type).toBe('p');
});
});
@@ -0,0 +1,40 @@
/**
* Value-equality helpers shared by the listbox/combobox-style selection
* primitives, which compare option values either by reference, by a property
* key, or with a custom comparator.
*/
/**
* Compare two (possibly undefined) values. `by` selects the comparison: omitted
* → strict `===`; a function → custom comparator; a string → compare that
* property key. Either value being `undefined` is never a match.
*/
export function compare<T>(
a: T | undefined,
b: T | undefined,
by?: string | ((a: T, b: T) => boolean),
): boolean {
if (a === undefined || b === undefined) return false;
if (by === undefined) return a === b;
if (typeof by === 'function') return by(a as T, b as T);
// string key lookup
return (a as Record<string, unknown>)?.[by] === (b as Record<string, unknown>)?.[by];
}
/**
* Whether `current` is contained in `value` (a single value or an array),
* using {@link compare} for each element.
*/
export function includes<T>(
value: T | T[] | undefined,
current: T,
by?: string | ((a: T, b: T) => boolean),
): boolean {
if (value === undefined) return false;
if (!Array.isArray(value)) return compare(value, current, by);
// manual loop avoids the per-call closure allocation of .some()
for (const v of value) {
if (compare(v, current, by)) return true;
}
return false;
}
@@ -0,0 +1,21 @@
export interface Point { x: number; y: number }
export type Polygon = Point[];
/**
* Ray-casting point-in-polygon test: returns `true` when `point` lies inside
* `polygon` (a list of vertices). Shared by the menu and hover-card grace-area
* logic that decide whether the pointer is still within a "safe" travel region.
*/
export function isPointInPolygon(point: Point, polygon: Polygon): boolean {
const { x, y } = point;
let inside = false;
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
const xi = polygon[i]!.x;
const yi = polygon[i]!.y;
const xj = polygon[j]!.x;
const yj = polygon[j]!.y;
const intersects = (yi > y) !== (yj > y) && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi;
if (intersects) inside = !inside;
}
return inside;
}
@@ -0,0 +1,42 @@
import type { VNode } from 'vue';
import { Comment, Fragment } from 'vue';
import { PatchFlags } from '@vue/shared';
/**
* Recursively extracts and flattens VNodes from potentially nested Fragments
* while filtering out Comment nodes.
*
* @param children - Array of VNodes to process
* @returns Flattened array of non-Comment VNodes
*/
export function getRawChildren(children: VNode[]): VNode[] {
const result: VNode[] = [];
flatten(children, result);
return result;
}
function flatten(children: VNode[], result: VNode[]): void {
let keyedFragmentCount = 0;
const startIdx = result.length;
for (let i = 0, len = children.length; i < len; i++) {
const child = children[i]!;
if (child.type === Fragment) {
if (child.patchFlag & PatchFlags.KEYED_FRAGMENT) {
keyedFragmentCount++;
}
flatten(child.children as VNode[], result);
}
else if (child.type !== Comment) {
result.push(child);
}
}
if (keyedFragmentCount > 1) {
for (let i = startIdx; i < result.length; i++) {
result[i]!.patchFlag = PatchFlags.BAIL;
}
}
}
@@ -0,0 +1,58 @@
/**
* Shared roving-focus helpers used by RadioGroup, Toolbar, ToggleGroup, etc.
* Items register themselves; Root owns the current "tabStop" index and responds
* to arrow/Home/End keys.
*/
export type RovingOrientation = 'horizontal' | 'vertical' | 'both';
export type RovingDirection = 'ltr' | 'rtl';
export interface RovingKeyOptions {
orientation?: RovingOrientation;
dir?: RovingDirection;
loop?: boolean;
}
/** Map a keyboard event to a direction delta (-1 / 0 / +1) or 'home'/'end'. */
export function rovingKeyToAction(
event: KeyboardEvent,
{ orientation = 'both', dir = 'ltr', loop = true }: RovingKeyOptions = {},
): { delta: number; absolute?: 'home' | 'end' } | null {
const horizontal = orientation === 'horizontal' || orientation === 'both';
const vertical = orientation === 'vertical' || orientation === 'both';
switch (event.key) {
case 'ArrowRight':
if (!horizontal) return null;
return { delta: dir === 'rtl' ? -1 : 1 };
case 'ArrowLeft':
if (!horizontal) return null;
return { delta: dir === 'rtl' ? 1 : -1 };
case 'ArrowDown':
if (!vertical) return null;
return { delta: 1 };
case 'ArrowUp':
if (!vertical) return null;
return { delta: -1 };
case 'Home':
return { delta: 0, absolute: 'home' };
case 'End':
return { delta: 0, absolute: 'end' };
default:
return null;
}
// loop used by caller to decide clamp vs wrap.
void loop;
}
/** Resolve next index given current, delta and items count with optional loop. */
export function resolveNextIndex(current: number, delta: number, count: number, loop: boolean): number {
if (count <= 0) return -1;
let next = current + delta;
if (loop) {
next = ((next % count) + count) % count;
}
else {
next = Math.max(0, Math.min(count - 1, next));
}
return next;
}
@@ -0,0 +1,204 @@
import { onScopeDispose, ref } from 'vue';
import type { Ref } from 'vue';
import { useEventListener } from '@robonen/vue';
import { isPointInPolygon } from './geometry';
interface Point { x: number; y: number }
type Polygon = Point[];
type Side = 'top' | 'right' | 'bottom' | 'left';
const POINTER_TRANSIT_TIMEOUT = 300;
/**
* Tracks pointer transit between a trigger and a floating container using
* a convex-hull "safe area" polygon so the floating content doesn't close
* when the pointer briefly leaves the trigger en route to it.
*
* Reference behavior: a hull is computed from the exit point (padded outward
* from the side the pointer leaves) plus the four corners of the hover target.
* While the pointer remains inside that hull, it's considered "in transit".
*/
export function useGraceArea(
triggerElement: Ref<HTMLElement | undefined>,
containerElement: Ref<HTMLElement | undefined>,
) {
const isPointerInTransit = ref(false);
const pointerGraceArea = ref<Polygon | null>(null);
let resetTimer = 0;
const exitListeners = new Set<() => void>();
function clearResetTimer() {
if (resetTimer) {
clearTimeout(resetTimer);
resetTimer = 0;
}
}
function scheduleTransitReset() {
clearResetTimer();
resetTimer = globalThis.setTimeout(() => {
isPointerInTransit.value = false;
resetTimer = 0;
}, POINTER_TRANSIT_TIMEOUT);
}
function handleRemoveGraceArea() {
pointerGraceArea.value = null;
isPointerInTransit.value = false;
clearResetTimer();
}
function handleCreateGraceArea(event: PointerEvent, hoverTarget: HTMLElement | undefined) {
if (!hoverTarget) return;
const currentTarget = event.currentTarget as HTMLElement;
const exitPoint = { x: event.clientX, y: event.clientY };
const exitSide = getExitSideFromRect(exitPoint, currentTarget.getBoundingClientRect());
const paddedExitPoints = getPaddedExitPoints(exitPoint, exitSide, 1);
const hoverTargetPoints = getPointsFromRect(hoverTarget.getBoundingClientRect());
pointerGraceArea.value = getHull([...paddedExitPoints, ...hoverTargetPoints]);
isPointerInTransit.value = true;
scheduleTransitReset();
}
// A pointerleave on the trigger starts a transit toward the container, and
// vice versa. `handleCreateGraceArea` no-ops when the other element is absent,
// so the listeners are safe to bind whenever their own element exists.
useEventListener(triggerElement, 'pointerleave', (event: PointerEvent) => handleCreateGraceArea(event, containerElement.value));
useEventListener(containerElement, 'pointerleave', (event: PointerEvent) => handleCreateGraceArea(event, triggerElement.value));
const onMove = (event: PointerEvent) => {
if (!pointerGraceArea.value || !(event.target instanceof Element)) return;
const target = event.target;
const point = { x: event.clientX, y: event.clientY };
const hasEnteredTarget
= triggerElement.value?.contains(target) || containerElement.value?.contains(target);
const outside = !isPointInPolygon(point, pointerGraceArea.value);
const isAnotherGraceTrigger = !!target.closest('[data-grace-area-trigger]');
if (hasEnteredTarget) {
handleRemoveGraceArea();
}
else if (outside || isAnotherGraceTrigger) {
handleRemoveGraceArea();
for (const fn of exitListeners) fn();
}
};
// Reactive target getter: the document `pointermove` listener attaches only
// while a grace area is active (and re-binds if the owning document changes),
// matching the previous conditional `watchEffect` — auto-removed on dispose.
useEventListener(
() => (pointerGraceArea.value ? (triggerElement.value?.ownerDocument ?? null) : null),
'pointermove',
onMove,
);
onScopeDispose(() => {
clearResetTimer();
isPointerInTransit.value = false;
exitListeners.clear();
});
return {
isPointerInTransit,
onPointerExit(fn: () => void) {
exitListeners.add(fn);
return () => exitListeners.delete(fn);
},
};
}
function getExitSideFromRect(point: Point, rect: DOMRect): Side {
const top = Math.abs(rect.top - point.y);
const bottom = Math.abs(rect.bottom - point.y);
const right = Math.abs(rect.right - point.x);
const left = Math.abs(rect.left - point.x);
const min = Math.min(top, bottom, right, left);
if (min === left) return 'left';
if (min === right) return 'right';
if (min === top) return 'top';
return 'bottom';
}
function getPaddedExitPoints(exitPoint: Point, exitSide: Side, padding = 5): Point[] {
switch (exitSide) {
case 'top':
return [
{ x: exitPoint.x - padding, y: exitPoint.y + padding },
{ x: exitPoint.x + padding, y: exitPoint.y + padding },
];
case 'bottom':
return [
{ x: exitPoint.x - padding, y: exitPoint.y - padding },
{ x: exitPoint.x + padding, y: exitPoint.y - padding },
];
case 'left':
return [
{ x: exitPoint.x + padding, y: exitPoint.y - padding },
{ x: exitPoint.x + padding, y: exitPoint.y + padding },
];
case 'right':
return [
{ x: exitPoint.x - padding, y: exitPoint.y - padding },
{ x: exitPoint.x - padding, y: exitPoint.y + padding },
];
}
}
function getPointsFromRect(rect: DOMRect): Point[] {
const { top, right, bottom, left } = rect;
return [
{ x: left, y: top },
{ x: right, y: top },
{ x: right, y: bottom },
{ x: left, y: bottom },
];
}
function getHull<P extends Point>(points: readonly P[]): P[] {
const sorted = points.slice().sort((a, b) => {
if (a.x !== b.x) return a.x - b.x;
return a.y - b.y;
});
return getHullPresorted(sorted);
}
function getHullPresorted<P extends Point>(points: readonly P[]): P[] {
if (points.length <= 1) return points.slice();
const upper: P[] = [];
for (const p of points) {
while (upper.length >= 2) {
const q = upper[upper.length - 1]!;
const r = upper[upper.length - 2]!;
if ((q.x - r.x) * (p.y - r.y) >= (q.y - r.y) * (p.x - r.x)) upper.pop();
else break;
}
upper.push(p);
}
upper.pop();
const lower: P[] = [];
for (let i = points.length - 1; i >= 0; i--) {
const p = points[i]!;
while (lower.length >= 2) {
const q = lower[lower.length - 1]!;
const r = lower[lower.length - 2]!;
if ((q.x - r.x) * (p.y - r.y) >= (q.y - r.y) * (p.x - r.x)) lower.pop();
else break;
}
lower.push(p);
}
lower.pop();
if (
upper.length === 1
&& lower.length === 1
&& upper[0]!.x === lower[0]!.x
&& upper[0]!.y === lower[0]!.y
) {
return upper;
}
return upper.concat(lower);
}
@@ -0,0 +1,59 @@
import type { MaybeComputedElementRef } from '@robonen/vue';
import { defaultWindow, tryOnScopeDispose, unrefElement } from '@robonen/vue';
import { hideOthers } from '@robonen/platform/browsers';
import { watch } from 'vue';
type Undo = () => void;
const CLOSED_POPOVER_SELECTOR = '[popover]:not(:popover-open)';
/**
* Isolated so the try/catch doesn't inhibit Turbofan optimization of the
* watcher callback. `:popover-open` throws in browsers that don't support it
* (e.g. Safari 18).
*/
function isInClosedPopover(el: Element): boolean {
try {
return el.closest(CLOSED_POPOVER_SELECTOR) !== null;
}
catch {
return false;
}
}
/**
* @name useHideOthers
* @category Browser
* @description Hides every sibling element of `target` from assistive technologies
* by setting `aria-hidden="true"`. Automatically restores the original state when
* the target is removed or the component scope is disposed. Skips elements living
* inside a closed native `[popover]` to avoid double-hiding.
*
* @param {MaybeComputedElementRef} target Element whose siblings should be aria-hidden
*
* @since 0.0.14
*/
export function useHideOthers(target: MaybeComputedElementRef): void {
if (!defaultWindow) return;
let undo: Undo | undefined;
watch(() => unrefElement(target), (raw) => {
if (undo) {
undo();
undo = undefined;
}
const el = raw as HTMLElement | undefined;
if (!el || isInClosedPopover(el)) return;
undo = hideOthers(el);
}, { flush: 'post' });
tryOnScopeDispose(() => {
if (undo) {
undo();
undo = undefined;
}
});
}