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,230 @@
import type { MaybeRefOrGetter, Ref } from 'vue';
import { computed, onScopeDispose, ref, toValue } from 'vue';
import { createEventHook, useEventListener } from '@robonen/vue';
/**
* Decimal-safe add/subtract. Floating point makes `0.1 + 0.2 === 0.30000000000000004`,
* which is wrong for a stepper. We scale both operands to integers by the larger
* of their decimal lengths, operate, then scale back.
*/
export function handleDecimalOperation(operator: '+' | '-', a: number, b: number): number {
let result = operator === '+' ? a + b : a - b;
if (a % 1 !== 0 || b % 1 !== 0) {
const aDecimals = a.toString().split('.')[1]?.length ?? 0;
const bDecimals = b.toString().split('.')[1]?.length ?? 0;
const multiplier = 10 ** Math.max(aDecimals, bDecimals);
const aInt = Math.round(a * multiplier);
const bInt = Math.round(b * multiplier);
result = (operator === '+' ? aInt + bInt : aInt - bInt) / multiplier;
}
return result;
}
/**
* Round a value to the decimal precision implied by `step` (e.g. step `0.5` →
* one decimal place), correcting floating-point drift after snapping.
*/
export function roundToStepPrecision(value: number, step: number): number {
const stepString = step.toString();
const pointIndex = stepString.indexOf('.');
const precision = pointIndex >= 0 ? stepString.length - pointIndex - 1 : 0;
if (precision > 0) {
const pow = 10 ** precision;
return Math.round(value * pow) / pow;
}
return value;
}
/**
* Snap `value` to the nearest multiple of `step` within `[min, max]`. Mirrors the
* spinbutton semantics: when the value lands beyond a bound the snapped result is
* pulled back to the last reachable on-grid value inside the range.
*/
export function snapValueToStep(value: number, min: number | undefined, max: number | undefined, step: number): number {
const lo = min ?? Number.NaN;
const hi = max ?? Number.NaN;
const remainder = (value - (Number.isNaN(lo) ? 0 : lo)) % step;
let snapped = roundToStepPrecision(
Math.abs(remainder) * 2 >= step
? value + Math.sign(remainder) * (step - Math.abs(remainder))
: value - remainder,
step,
);
if (!Number.isNaN(lo)) {
if (snapped < lo)
snapped = lo;
else if (!Number.isNaN(hi) && snapped > hi)
snapped = lo + Math.floor(roundToStepPrecision((hi - lo) / step, step)) * step;
}
else if (!Number.isNaN(hi) && snapped > hi) {
snapped = Math.floor(roundToStepPrecision(hi / step, step)) * step;
}
return roundToStepPrecision(snapped, step);
}
export interface UsePressedHoldOptions {
/** Element the press starts on (pointer must go down on it). */
target: Ref<HTMLElement | undefined>;
/** When `true`, presses are ignored and any in-flight repeat stops. */
disabled: MaybeRefOrGetter<boolean>;
}
export interface UsePressedHoldReturn {
/** `true` while the pointer is held down on the target. */
isPressed: Ref<boolean>;
/** Register a callback fired once on press and then repeatedly while held. */
onTrigger: (fn: () => void) => void;
/**
* `true` for one `click` immediately after a real pointer press already fired
* the trigger. Lets a `@click` fallback (for programmatic / keyboard-activated
* clicks that emit no `pointerdown`) avoid double-firing.
*/
consumeClick: () => boolean;
}
/**
* Press-and-hold auto-repeat for stepper buttons: fires immediately on
* pointer-down, then repeats every 60ms after an initial 400ms delay until the
* pointer is released, cancelled, or leaves. Built on the toolkit's
* `useEventListener` (auto-cleaned on scope dispose) and `createEventHook`.
*/
export function usePressedHold(options: UsePressedHoldOptions): UsePressedHoldReturn {
const { target, disabled } = options;
const isPressed = ref(false);
const triggerHook = createEventHook<void>();
let timer: ReturnType<typeof setTimeout> | undefined;
// Set when a pointer press fired the trigger; the synthetic `click` that the
// browser dispatches afterwards must be swallowed by the `@click` fallback.
let pressDidTrigger = false;
function clearTimer(): void {
if (timer !== undefined) {
clearTimeout(timer);
timer = undefined;
}
}
function repeat(delay: number): void {
clearTimer();
if (toValue(disabled))
return;
triggerHook.trigger();
timer = setTimeout(() => repeat(60), delay);
}
function onPressStart(event: PointerEvent): void {
// Only the primary (left) button, and ignore re-entrant presses.
if (event.button !== 0 || isPressed.value || toValue(disabled))
return;
event.preventDefault();
isPressed.value = true;
pressDidTrigger = true;
repeat(400);
}
function onPressEnd(): void {
isPressed.value = false;
clearTimer();
}
function consumeClick(): boolean {
if (pressDidTrigger) {
pressDidTrigger = false;
return true;
}
return false;
}
useEventListener(target, 'pointerdown', onPressStart);
// Release/cancel are watched on `window` so lifting the pointer outside the
// button still ends the hold; leaving the button also ends it.
useEventListener(globalThis.window, 'pointerup', onPressEnd);
useEventListener(globalThis.window, 'pointercancel', onPressEnd);
useEventListener(target, 'pointerleave', onPressEnd);
// Clear any in-flight repeat when the owning component unmounts.
onScopeDispose(clearTimer);
return {
isPressed,
onTrigger: triggerHook.on,
consumeClick,
};
}
/**
* Lightweight, locale-aware number formatting/parsing on top of native
* `Intl.NumberFormat` (no external i18n dependency). `format` renders the value
* with grouping/currency/percent per `options`; `parse` strips locale-specific
* group and decimal separators (and currency/percent symbols) back to a plain
* `number`, returning `NaN` for unparseable input.
*/
export function createNumberFormat(locale: MaybeRefOrGetter<string>, options: MaybeRefOrGetter<Intl.NumberFormatOptions | undefined>) {
const formatter = computed(() => new Intl.NumberFormat(toValue(locale), toValue(options)));
const separators = computed(() => {
const parts = formatter.value.formatToParts(12345.6);
const group = parts.find(p => p.type === 'group')?.value ?? ',';
const decimal = parts.find(p => p.type === 'decimal')?.value ?? '.';
return { group, decimal };
});
const resolved = computed(() => formatter.value.resolvedOptions());
// The partial-validation pattern depends solely on the locale decimal
// separator, so rebuild it only when that separator changes rather than on
// every keystroke (isValidPartial runs from @beforeinput per character).
const partialRe = computed(
() => new RegExp(`^[+-]?[\\d${escapeRegExp(separators.value.decimal)}.,\\s\\u00A0%$€£¥]*$`),
);
function format(value: number): string {
return formatter.value.format(value);
}
function parse(raw: string): number {
const trimmed = raw.trim();
if (trimmed === '')
return Number.NaN;
const { group, decimal } = separators.value;
// Strip everything except digits, sign, and the locale decimal separator,
// then normalise the decimal separator to `.` for `Number`.
const cleaned = trimmed
.split(group).join('')
.split(decimal).join('.')
.replaceAll(/[^\d.\-+e]/gi, '');
// No digits at all → not a number (`Number('')` would coerce to 0).
if (!/\d/.test(cleaned))
return Number.NaN;
const n = Number(cleaned);
return Number.isFinite(n) ? n : Number.NaN;
}
function isValidPartial(raw: string): boolean {
if (raw === '' || raw === '-' || raw === '+')
return true;
return partialRe.value.test(raw);
}
return { format, parse, isValidPartial, resolved };
}
function escapeRegExp(value: string): string {
return value.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&');
}