Files
tools/vue/primitives/src/canvas/flow/composables/useKeyboard.ts
T
robonen eefd7abf83 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.
2026-06-15 16:54:29 +07:00

108 lines
3.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
}
});
}