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,107 @@
import type { Ref } from 'vue';
import { useEventListener } from '@robonen/vue';
import type { FlowContext } from '../context';
import type { XYPosition } from '../types';
import type { FlowApi } from './useViewportApi';
export interface KeyboardOptions {
/** Keys that delete the selection. @default ['Backspace', 'Delete'] */
deleteKeyCode?: string[];
/** Pixel step for arrow-key node nudging. @default 5 */
nudgeStep?: number;
}
const EDITABLE = /^(?:input|textarea|select)$/i;
/** True when focus is in a text field, so global shortcuts must stand down. */
function isTyping(): boolean {
const el = document.activeElement as HTMLElement | null;
return !!el && (EDITABLE.test(el.tagName) || el.isContentEditable);
}
/**
* Canvas keyboard layer (scoped to the pane, suppressed while typing): Delete /
* Backspace removes the selection, ⌘/Ctrl+A selects all, Arrows nudge selected
* nodes (Shift = ×4), ⌘/Ctrl +/-/0 zoom-in/out/fit, Escape clears selection and
* cancels an in-progress connection. Disabled via `disableKeyboardA11y`.
*/
export function useKeyboard(
target: Ref<HTMLElement | undefined>,
ctx: FlowContext,
api: FlowApi,
options: KeyboardOptions = {},
): void {
const deleteKeys = options.deleteKeyCode ?? ['Backspace', 'Delete'];
const step = options.nudgeStep ?? 5;
function nudge(dx: number, dy: number): void {
const sel = ctx.selection.value.nodes;
if (sel.size === 0) return;
const moves = new Map<string, XYPosition>();
for (const id of sel) {
const node = ctx.nodeLookup.value.get(id);
if (node && node.draggable !== false)
moves.set(id, { x: node.position.x + dx, y: node.position.y + dy });
}
if (moves.size === 0) return;
ctx.updateNodePositions(moves, false);
ctx.commitNodeDrag();
}
useEventListener(target, 'keydown', (event: KeyboardEvent) => {
if (ctx.disableKeyboardA11y.value || !ctx.interactive.value || isTyping()) return;
const mod = event.metaKey || event.ctrlKey;
if (deleteKeys.includes(event.key)) {
event.preventDefault();
ctx.removeSelected();
return;
}
if (mod && (event.key === 'a' || event.key === 'A')) {
event.preventDefault();
ctx.setSelection(
[...ctx.nodeLookup.value.keys()],
ctx.elementsSelectable.value ? [...ctx.edgeLookup.value.keys()] : [],
);
return;
}
if (event.key === 'Escape') {
ctx.endConnection();
ctx.clearSelection();
return;
}
if (mod) {
if (event.key === '=' || event.key === '+') {
event.preventDefault();
api.zoomIn();
return;
}
if (event.key === '-') {
event.preventDefault();
api.zoomOut();
return;
}
if (event.key === '0') {
event.preventDefault();
api.fitView();
return;
}
}
const dist = event.shiftKey ? step * 4 : step;
const arrows: Record<string, XYPosition> = {
ArrowUp: { x: 0, y: -dist },
ArrowDown: { x: 0, y: dist },
ArrowLeft: { x: -dist, y: 0 },
ArrowRight: { x: dist, y: 0 },
};
const delta = arrows[event.key];
if (delta) {
event.preventDefault();
nudge(delta.x, delta.y);
}
});
}