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,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;
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user