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