Files
hyperframes/packages/studio/src/player/components/timelineMultiDragPreview.ts
T
Miguel Ángelandukimsanov e464b33bb3 fix(studio): stacking sync (#2270)
* feat(studio): timeline collision and placement model

What: new pure module timelineCollision — zone-aware drop placement
(clampTrackToZone, resolveZoneDropPlacement, resolveInsertRow,
resolvePlacement, lane/overlap predicates) with its full test suite.

Why: the no-overlap core of the NLE clip-drag engine; plain functions, no
DOM, no React, no store writes.

How: new files only; type-only imports from the existing playerStore.
First runtime consumer arrives with the drag-engine PRs.

Test plan: bunx vitest run timelineCollision.test.ts; tsc --noEmit; fallow
audit clean (all exports test-consumed).

* feat(studio): timeline magnetic snapping

What: new pure module timelineSnapping — snap-target collection and
pixel-threshold time snapping (collectTimelineSnapTargets, snapTimelineTime,
snapMoveToTargets) with tests.

Why: the magnet math for clip drags/trims, reviewable standalone.

How: new files only; type-only playerStore imports; consumers land with the
drag engine.

Test plan: bunx vitest run timelineSnapping.test.ts; tsc --noEmit; fallow
audit clean.

* feat(studio): multi-clip drag preview math

What: new pure module timelineMultiDragPreview — group-drag passenger
offsets and clamped group deltas (isMultiDragActive, multiDragDeltaSeconds,
multiDragPassengerOffsetPx, clampGroupMoveDelta) with tests.

Why: the group-drag math, standalone and DOM-free.

How: new files only; consumed later by TimelineLanes.

Test plan: bunx vitest run timelineMultiDragPreview.test.ts; tsc --noEmit;
fallow audit clean.

* feat(studio): timeline z-stacking sync model

What: new pure module timelineStackingSync — lane order ↔ z-index
reconciliation (laneIsAbove, computeStackingPatches) with tests.

Why: the single source of truth for how timeline lane order maps to canvas
stacking; the ordering rules and tie-breaks live here.

How: new files only; consumed later by timelineZones and the stacking-sync
hook.

Test plan: bunx vitest run timelineStackingSync.test.ts; tsc --noEmit;
fallow audit clean.

---------

Co-authored-by: ukimsanov <ular.kimsanov@heygen.com>
2026-07-11 22:56:17 -04:00

107 lines
4.9 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.
/**
* Pure geometry for the LIVE multi-selection drag preview.
*
* Visual model (matches main): while a selected clip is dragged, ALL selected
* clips move together LIVE as one rigid formation following the cursor. The
* GRABBED clip is drawn as the free-floating ghost; every OTHER selected member
* ("passenger") slides by the SAME time delta via a cheap compositor
* `translateX` (no re-layout). Passengers do NOT stay still, and they do NOT lag
* behind individually — the whole formation moves by one delta, spacing locked.
*
* That single delta is the GRABBED clip's `draggedPreviewStart draggedOriginStart`.
* The preview start is ALREADY group-clamped upstream (updateDraggedClipPreview
* runs clampGroupMoveDelta before setting it), so this delta is the clamped delta:
* the instant any member would cross 0 the grabbed clip stops and every passenger
* stops with it — the formation never deforms. On DROP the commit shifts every
* selected clip by this same delta (see timelineClipDragCommit / useTimelineClipDrag).
*
* Track changes apply to the grabbed clip only (mirroring the commit); passengers
* keep their lanes, so only their x moves.
*/
export interface MultiDragPreviewInput {
/** The drag is live (past the movement threshold). */
dragStarted: boolean;
/** Key of the clip under the pointer. */
draggedKey: string;
/** The dragged clip's committed start (pre-drag). */
draggedOriginStart: number;
/** The dragged clip's live preview start (already group-clamped upstream). */
draggedPreviewStart: number;
/** The current multi-selection (store.selectedElementIds). */
selectedKeys: ReadonlySet<string>;
}
/**
* Whether a live multi-selection drag is in effect: the drag started, and the
* dragged clip is itself part of a 2+ multi-selection. Below this, single-drag
* behavior is unchanged and there are no passengers.
*/
export function isMultiDragActive(input: MultiDragPreviewInput): boolean {
return (
input.dragStarted && input.selectedKeys.size > 1 && input.selectedKeys.has(input.draggedKey)
);
}
/**
* The single time delta the WHOLE formation shifts by — the grabbed clip's
* preview start minus its origin start. Because the preview start is already
* group-clamped, this is the clamped delta every member (ghost + passengers)
* moves by. Zero when the clip hasn't moved (or no multi-drag).
*/
export function multiDragDeltaSeconds(input: MultiDragPreviewInput): number {
if (!isMultiDragActive(input)) return 0;
return input.draggedPreviewStart - input.draggedOriginStart;
}
/**
* Whether a specific rendered clip is a passenger — a selected clip that is NOT
* the dragged clip and NOT the same clip key. Passengers get the translateX
* treatment; the dragged clip is drawn as the free-floating ghost instead.
*/
export function isMultiDragPassenger(clipKey: string, input: MultiDragPreviewInput): boolean {
return (
isMultiDragActive(input) && clipKey !== input.draggedKey && input.selectedKeys.has(clipKey)
);
}
/**
* The passenger's rendered x offset in PIXELS (delta seconds × pixels/second),
* to apply as `transform: translateX(...px)`. Every passenger uses the SAME
* formation delta, so the group moves rigidly. Returns 0 for non-passengers so
* callers can compute unconditionally and only branch on the elevated styling.
*/
export function multiDragPassengerOffsetPx(
clipKey: string,
pixelsPerSecond: number,
input: MultiDragPreviewInput,
): number {
if (!isMultiDragPassenger(clipKey, input)) return 0;
const pps = Number.isFinite(pixelsPerSecond) ? pixelsPerSecond : 0;
return multiDragDeltaSeconds(input) * pps;
}
/**
* Clamp a group move so the WHOLE selection moves as ONE rigid formation.
*
* The grabbed clip proposes a raw delta (its desired preview start minus its
* origin start, after its own snapping). Applied naively, a passenger could be
* pushed below 0 (or past any other member bound), and the commit's per-clip
* `Math.max(0, …)` would then deform the formation — the grabbed clip out-runs
* the group while a passenger sticks at the wall. This ports main's model
* (useTimelineClipGroupDrag / clampTimelineGroupMoveDelta): the applied delta is
* bounded by the MOST-CONSTRAINED member, so the grabbed clip STOPS the instant
* any member hits 0 and the formation never deforms.
*
* `memberStarts` are the pre-drag starts of every selected clip (the grabbed clip
* included). Only the lower bound (start ≥ 0) constrains a move; the timeline has
* no fixed right wall (the composition grows on commit).
*/
export function clampGroupMoveDelta(rawDelta: number, memberStarts: readonly number[]): number {
if (memberStarts.length === 0) return rawDelta;
// Leftmost member sets the floor: delta ≥ -min(start) keeps every start ≥ 0.
const minStart = Math.min(...memberStarts);
const minDelta = minStart === 0 ? 0 : -minStart; // avoid -0
return rawDelta < minDelta ? minDelta : rawDelta;
}