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,164 @@
|
||||
import type { MaybeRefOrGetter, Ref } from 'vue';
|
||||
import { onScopeDispose, shallowRef, toValue } from 'vue';
|
||||
import { useEventListener } from '@robonen/vue';
|
||||
import { clamp } from '@robonen/stdlib';
|
||||
import type { FlowContext } from '../context';
|
||||
import type { InternalNode, XYPosition } from '../types';
|
||||
import { snapPoint } from '../utils';
|
||||
import { capturePointer, releasePointer } from './dom';
|
||||
|
||||
/** Constrain a dragged position by a node's `extent` (parent box or explicit rect). */
|
||||
function applyExtent(pos: XYPosition, node: InternalNode, lookup: Map<string, InternalNode>): XYPosition {
|
||||
if (node.extent === 'parent' && node.parentId) {
|
||||
const parent = lookup.get(node.parentId);
|
||||
if (!parent) return pos;
|
||||
return {
|
||||
x: clamp(pos.x, 0, Math.max(0, parent.measured.width - node.measured.width)),
|
||||
y: clamp(pos.y, 0, Math.max(0, parent.measured.height - node.measured.height)),
|
||||
};
|
||||
}
|
||||
if (Array.isArray(node.extent)) {
|
||||
const [[x1, y1], [x2, y2]] = node.extent;
|
||||
return {
|
||||
x: clamp(pos.x, x1, x2 - node.measured.width),
|
||||
y: clamp(pos.y, y1, y2 - node.measured.height),
|
||||
};
|
||||
}
|
||||
return pos;
|
||||
}
|
||||
|
||||
export interface NodeDragOptions {
|
||||
/** Pixels the pointer must travel before a click becomes a drag. @default 1 */
|
||||
threshold?: number;
|
||||
}
|
||||
|
||||
/** Elements inside a node that must not initiate a drag. */
|
||||
const NO_DRAG_SELECTOR = 'input, textarea, select, button, [contenteditable="true"], [data-handleid], .nodrag';
|
||||
|
||||
/**
|
||||
* Pointer-capture node drag. Moves the node (and every co-selected node) by the
|
||||
* pointer delta converted to flow space (`delta / zoom`), optionally snapped to
|
||||
* the grid. Positions are written to the transient drag overlay every frame and
|
||||
* committed to the model once on pointerup — never 60×/sec.
|
||||
*
|
||||
* Stops propagation so the pane never pans while a node is grabbed. A pointer
|
||||
* that never crosses `threshold` stays a click (selection handled elsewhere).
|
||||
*/
|
||||
export function useNodeDrag(
|
||||
target: Ref<HTMLElement | undefined>,
|
||||
ctx: FlowContext,
|
||||
nodeId: MaybeRefOrGetter<string>,
|
||||
options: NodeDragOptions = {},
|
||||
): { dragging: Readonly<Ref<boolean>> } {
|
||||
const threshold = options.threshold ?? 1;
|
||||
const dragging = shallowRef(false);
|
||||
|
||||
let pointerId = -1;
|
||||
let startX = 0;
|
||||
let startY = 0;
|
||||
let started = false;
|
||||
let lastX = 0;
|
||||
let lastY = 0;
|
||||
let rafId: number | null = null;
|
||||
/** id → starting flow-space position, snapshot at pointerdown. */
|
||||
let snapshot = new Map<string, XYPosition>();
|
||||
/** Reused across RAF frames — only the container is pooled; entries are fresh. */
|
||||
const nextPositions = new Map<string, XYPosition>();
|
||||
|
||||
function eligible(): boolean {
|
||||
if (!ctx.interactive.value || !ctx.nodesDraggable.value) return false;
|
||||
const node = ctx.nodeLookup.value.get(toValue(nodeId));
|
||||
return !!node && node.draggable !== false;
|
||||
}
|
||||
|
||||
function collectDragSet(id: string): Map<string, XYPosition> {
|
||||
const set = new Map<string, XYPosition>();
|
||||
const selected = ctx.selection.value.nodes;
|
||||
const ids = selected.has(id) && selected.size > 1 ? selected : new Set([id]);
|
||||
for (const nid of ids) {
|
||||
const node = ctx.nodeLookup.value.get(nid);
|
||||
if (node && node.draggable !== false)
|
||||
set.set(nid, { x: node.position.x, y: node.position.y });
|
||||
}
|
||||
return set;
|
||||
}
|
||||
|
||||
function flush() {
|
||||
rafId = null;
|
||||
const dx = (lastX - startX) / ctx.viewport.value.zoom;
|
||||
const dy = (lastY - startY) / ctx.viewport.value.zoom;
|
||||
const snap = ctx.snapToGrid.value;
|
||||
const grid = ctx.snapGrid.value;
|
||||
const next = nextPositions;
|
||||
next.clear();
|
||||
for (const [id, start] of snapshot) {
|
||||
let pos: XYPosition = { x: start.x + dx, y: start.y + dy };
|
||||
if (snap) pos = snapPoint(pos, grid);
|
||||
const node = ctx.nodeLookup.value.get(id);
|
||||
if (node?.extent) pos = applyExtent(pos, node, ctx.nodeLookup.value);
|
||||
next.set(id, pos);
|
||||
}
|
||||
ctx.updateNodePositions(next, true);
|
||||
}
|
||||
|
||||
function schedule() {
|
||||
if (rafId === null) rafId = requestAnimationFrame(flush);
|
||||
}
|
||||
|
||||
onScopeDispose(() => {
|
||||
if (rafId !== null) cancelAnimationFrame(rafId);
|
||||
});
|
||||
|
||||
useEventListener(target, 'pointerdown', (event: PointerEvent) => {
|
||||
if (event.button !== 0) return;
|
||||
if (event.target instanceof Element && event.target.closest(NO_DRAG_SELECTOR)) return;
|
||||
// Always stop the pane from treating this as a pan, even when not draggable.
|
||||
event.stopPropagation();
|
||||
if (!eligible()) return;
|
||||
|
||||
const id = toValue(nodeId);
|
||||
snapshot = collectDragSet(id);
|
||||
if (snapshot.size === 0) return;
|
||||
|
||||
pointerId = event.pointerId;
|
||||
startX = lastX = event.clientX;
|
||||
startY = lastY = event.clientY;
|
||||
started = false;
|
||||
capturePointer(target.value, event.pointerId);
|
||||
});
|
||||
|
||||
useEventListener(target, 'pointermove', (event: PointerEvent) => {
|
||||
if (pointerId !== event.pointerId || snapshot.size === 0) return;
|
||||
lastX = event.clientX;
|
||||
lastY = event.clientY;
|
||||
|
||||
if (!started) {
|
||||
if (Math.abs(lastX - startX) < threshold && Math.abs(lastY - startY) < threshold) return;
|
||||
started = true;
|
||||
dragging.value = true;
|
||||
}
|
||||
schedule();
|
||||
});
|
||||
|
||||
function endDrag(event: PointerEvent) {
|
||||
if (pointerId !== event.pointerId) return;
|
||||
releasePointer(target.value, event.pointerId);
|
||||
if (rafId !== null) {
|
||||
cancelAnimationFrame(rafId);
|
||||
rafId = null;
|
||||
}
|
||||
if (started) {
|
||||
flush();
|
||||
ctx.commitNodeDrag();
|
||||
}
|
||||
pointerId = -1;
|
||||
started = false;
|
||||
dragging.value = false;
|
||||
snapshot = new Map();
|
||||
}
|
||||
|
||||
useEventListener(target, 'pointerup', endDrag);
|
||||
useEventListener(target, 'pointercancel', endDrag);
|
||||
|
||||
return { dragging };
|
||||
}
|
||||
Reference in New Issue
Block a user