mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-11 14:50:02 +00:00
258 lines
10 KiB
TypeScript
258 lines
10 KiB
TypeScript
import type { TimelineElement } from "../store/playerStore";
|
|
import { INSERT_BOUNDARY_BAND } from "./timelineLayout";
|
|
|
|
/**
|
|
* Keep a landing track inside the dragged clip's kind-zone: visual clips stay in
|
|
* the rows ABOVE the first audio lane; audio clips stay AT/BELOW it. Prevents a
|
|
* clip from appearing to land in the wrong zone mid-drag (which normalizeToZones
|
|
* would then snap back). `audioRow` = index in `trackOrder` of the first audio
|
|
* lane, or -1 when there is no audio zone yet (then it's a no-op).
|
|
*/
|
|
export function clampTrackToZone(
|
|
targetTrack: number,
|
|
trackOrder: number[],
|
|
audioRow: number,
|
|
isAudio: boolean,
|
|
): number {
|
|
if (audioRow < 0) return targetTrack;
|
|
const row = trackOrder.indexOf(targetTrack);
|
|
if (row < 0) return targetTrack;
|
|
if (isAudio) return row >= audioRow ? targetTrack : (trackOrder[audioRow] ?? targetTrack);
|
|
return row < audioRow ? targetTrack : (trackOrder[audioRow - 1] ?? targetTrack);
|
|
}
|
|
|
|
/**
|
|
* Whether a new-track insert at boundary `insertRow` is allowed for a clip of the
|
|
* given kind. Visual clips may only insert visual lanes (boundary at/above the top
|
|
* of the audio zone); audio clips may only insert audio lanes (boundary at/below
|
|
* it) — so audio clips CAN create a new audio track, and neither kind inserts into
|
|
* the other's zone. `audioRow` = first audio lane row, or -1 (no audio zone) → any.
|
|
*/
|
|
export function isInsertAllowedForZone(
|
|
insertRow: number,
|
|
audioRow: number,
|
|
isAudio: boolean,
|
|
): boolean {
|
|
if (audioRow < 0) return true;
|
|
return isAudio ? insertRow >= audioRow : insertRow <= audioRow;
|
|
}
|
|
|
|
/**
|
|
* The full drop-placement decision for a dragged clip — one pure, testable unit.
|
|
* Enforces: NO time-overlap on a single track; a clip stays in its kind-zone;
|
|
* a new track is created only when needed. Order of resolution:
|
|
* 1. Deliberate boundary insert (pointer near a lane edge), if it's in the
|
|
* clip's own zone → create a new track there.
|
|
* 2. Otherwise land on a lane: clamp the aimed track to the clip's zone, take it
|
|
* if free at [start, start+duration), else the nearest FREE lane in the zone
|
|
* (prefer up), else auto-create a new track right below the aimed lane.
|
|
* `audioTracks` = the set of track indices that currently hold audio (so the fn
|
|
* needs no element-kind import). Returns the landing `track` and, when a new track
|
|
* should be created, the `insertRow` boundary (else null).
|
|
*
|
|
* `preferInsertAbove` biases the auto-created track (occupied-aim → new adjacent
|
|
* track) toward the boundary ABOVE the aimed row instead of below it, so the new
|
|
* lane opens on whichever side of the aimed clip the pointer is nearer (the drag
|
|
* preview passes the pointer's sub-row half). A clip whose aimed span is occupied
|
|
* never snaps back to its origin — it relocates to a free lane, or (none free)
|
|
* gets a fresh track next to the aim. Default (below) preserves prior behaviour.
|
|
*/
|
|
/**
|
|
* Insert-row boundary for an out-of-range aim — a `desired` track that isn't a
|
|
* real lane: the sentinel minTrack-1 an upward create-drag emits (#2214-adjacent
|
|
* repro) or a beyond-the-bottom index a downward one does. Anchors the new track
|
|
* to a boundary of the clip's OWN kind-zone so a visual insert can never land
|
|
* past the audio zone (the old below = order.length fallback dropped it BELOW the
|
|
* audio lanes). Above the zone (`desired` < the zone's min lane) → the zone's TOP
|
|
* boundary; otherwise → its BOTTOM boundary (for a visual clip, the top of the
|
|
* audio zone). `zoneTracks` = this kind's lanes, in `order` sequence.
|
|
*/
|
|
function outOfRangeZoneInsertRow(
|
|
order: number[],
|
|
zoneTracks: number[],
|
|
audioRow: number,
|
|
desired: number,
|
|
): number {
|
|
// No lane of this kind yet: fall to the split (audioRow) or the very top.
|
|
// A visual-only timeline has audioRow -1 (top); an all-audio one has it at 0.
|
|
if (zoneTracks.length === 0) return audioRow < 0 ? 0 : audioRow;
|
|
// zoneTracks preserves `order` sequence, so its ends map to the zone boundary
|
|
// rows: above the zone's min lane → its top boundary, else its bottom.
|
|
const zoneTop = order.indexOf(zoneTracks[0]);
|
|
const zoneBottom = order.indexOf(zoneTracks[zoneTracks.length - 1]) + 1;
|
|
return desired < Math.min(...zoneTracks) ? zoneTop : zoneBottom;
|
|
}
|
|
|
|
export function resolveZoneDropPlacement(input: {
|
|
order: number[];
|
|
audioTracks: ReadonlySet<number>;
|
|
elements: TimelineElement[];
|
|
desiredTrack: number;
|
|
deliberateInsertRow: number | null;
|
|
start: number;
|
|
duration: number;
|
|
dragKey: string;
|
|
isAudio: boolean;
|
|
preferInsertAbove?: boolean;
|
|
}): { track: number; insertRow: number | null } {
|
|
const { order, audioTracks, elements, desiredTrack, deliberateInsertRow } = input;
|
|
const { start, duration, dragKey, isAudio, preferInsertAbove } = input;
|
|
const audioRow = order.findIndex((t) => audioTracks.has(t));
|
|
|
|
if (
|
|
deliberateInsertRow !== null &&
|
|
isInsertAllowedForZone(deliberateInsertRow, audioRow, isAudio)
|
|
) {
|
|
return { track: desiredTrack, insertRow: deliberateInsertRow };
|
|
}
|
|
|
|
const desired = clampTrackToZone(desiredTrack, order, audioRow, isAudio);
|
|
const zoneTracks = order.filter((t) => audioTracks.has(t) === isAudio);
|
|
const placement = resolvePlacement({
|
|
elements,
|
|
desiredTrack: desired,
|
|
start,
|
|
duration,
|
|
trackOrder: zoneTracks,
|
|
excludeKey: dragKey,
|
|
});
|
|
const originTrack = elements.find((element) => (element.key ?? element.id) === dragKey)?.track;
|
|
const snappedBackToOrigin =
|
|
originTrack != null && desired !== originTrack && placement.track === originTrack;
|
|
if (placement.needsInsert || snappedBackToOrigin) {
|
|
const desiredRow = order.indexOf(desired);
|
|
if (desiredRow < 0) {
|
|
return {
|
|
track: desired,
|
|
insertRow: outOfRangeZoneInsertRow(order, zoneTracks, audioRow, desired),
|
|
};
|
|
}
|
|
// When collision fallback found only the origin lane, insert on the far side
|
|
// of the aimed lane so normalization cannot turn the gesture into a no-op.
|
|
// Otherwise prefer the gap nearest the pointer, preserving normal insertion.
|
|
const originRow = originTrack == null ? -1 : order.indexOf(originTrack);
|
|
const insertAbove = snappedBackToOrigin
|
|
? originRow > desiredRow
|
|
: preferInsertAbove && isInsertAllowedForZone(desiredRow, audioRow, isAudio);
|
|
const insertRow = insertAbove ? desiredRow : desiredRow + 1;
|
|
return { track: desired, insertRow };
|
|
}
|
|
return { track: placement.track, insertRow: null };
|
|
}
|
|
|
|
/**
|
|
* Decide whether a vertical drag is inserting a new track at a lane boundary.
|
|
* `rowFloat` is the pointer's position in track-height units from the top of the
|
|
* first lane (0 = top of lane 0). Returns the boundary row to insert at
|
|
* (0 = above the top lane, `trackCount` = below the bottom), or null when the
|
|
* pointer is over a lane's middle band (a normal move/target). The default band
|
|
* preserves collapsed-row behavior; production passes the concrete row's band.
|
|
*/
|
|
export function resolveInsertRow(
|
|
rowFloat: number,
|
|
trackCount: number,
|
|
band: number = INSERT_BOUNDARY_BAND,
|
|
): number | null {
|
|
if (trackCount === 0) return 0;
|
|
if (rowFloat <= 0) return 0;
|
|
if (rowFloat >= trackCount) return trackCount;
|
|
const lane = Math.floor(rowFloat);
|
|
const frac = rowFloat - lane;
|
|
if (frac < band) return lane;
|
|
if (frac > 1 - band) return lane + 1;
|
|
return null;
|
|
}
|
|
|
|
/** Half-open overlap test: [aStart, aEnd) intersects [bStart, bEnd). */
|
|
export function timeRangesOverlap(
|
|
aStart: number,
|
|
aEnd: number,
|
|
bStart: number,
|
|
bEnd: number,
|
|
): boolean {
|
|
return aStart < bEnd && bStart < aEnd;
|
|
}
|
|
|
|
/**
|
|
* True when no clip on `track` overlaps [start, end) — excluding the clip
|
|
* identified by `excludeKey` (the one being dragged).
|
|
*/
|
|
export function isLaneFree(
|
|
elements: TimelineElement[],
|
|
track: number,
|
|
start: number,
|
|
end: number,
|
|
excludeKey: string | null,
|
|
): boolean {
|
|
return !elements.some(
|
|
(el) =>
|
|
(el.key ?? el.id) !== excludeKey &&
|
|
el.track === track &&
|
|
timeRangesOverlap(start, end, el.start, el.start + el.duration),
|
|
);
|
|
}
|
|
|
|
export interface PlacementInput {
|
|
elements: TimelineElement[];
|
|
desiredTrack: number;
|
|
start: number;
|
|
duration: number;
|
|
trackOrder: number[];
|
|
excludeKey: string | null;
|
|
}
|
|
|
|
export interface PlacementResult {
|
|
/** The lane the clip should land on. */
|
|
track: number;
|
|
/**
|
|
* True when no existing lane was free and the caller should insert a new
|
|
* track instead of landing on `track` (which is then the desired lane as a
|
|
* last-resort fallback). Consumed in later stages (2b/2c); stage 2a ignores it.
|
|
*/
|
|
needsInsert: boolean;
|
|
}
|
|
|
|
/**
|
|
* Resolve where a dragged clip should land, avoiding overlap. If the desired
|
|
* lane is free, keep it. Otherwise search the nearest free lane, **preferring
|
|
* up** (all lanes above, nearest first), then down. If none is free, signal an
|
|
* insert and fall back to the desired lane.
|
|
*/
|
|
export function resolvePlacement({
|
|
elements,
|
|
desiredTrack,
|
|
start,
|
|
duration,
|
|
trackOrder,
|
|
excludeKey,
|
|
}: PlacementInput): PlacementResult {
|
|
const end = start + duration;
|
|
const idx = trackOrder.indexOf(desiredTrack);
|
|
// desiredTrack is not one of the zone's lanes — the clip's kind-zone has no lane
|
|
// yet (e.g. an audio clip dropped on a visual-only timeline). This MUST be checked
|
|
// BEFORE the isLaneFree short-circuit below: a free-aimed span on a foreign-zone
|
|
// lane (an audio clip aimed at an empty stretch of a visual-only timeline) is
|
|
// "free" only because that lane belongs to the wrong zone. Landing there would
|
|
// put the clip in the wrong kind-zone, so signal an insert to create the zone's
|
|
// first lane instead — regardless of whether the aimed span is occupied (#2195).
|
|
if (idx === -1) return { track: desiredTrack, needsInsert: true };
|
|
|
|
if (isLaneFree(elements, desiredTrack, start, end, excludeKey)) {
|
|
return { track: desiredTrack, needsInsert: false };
|
|
}
|
|
|
|
// Prefer up: nearest lane above first, then the rest above.
|
|
for (let up = idx - 1; up >= 0; up--) {
|
|
if (isLaneFree(elements, trackOrder[up], start, end, excludeKey)) {
|
|
return { track: trackOrder[up], needsInsert: false };
|
|
}
|
|
}
|
|
// Then down: nearest lane below first.
|
|
for (let down = idx + 1; down < trackOrder.length; down++) {
|
|
if (isLaneFree(elements, trackOrder[down], start, end, excludeKey)) {
|
|
return { track: trackOrder[down], needsInsert: false };
|
|
}
|
|
}
|
|
return { track: desiredTrack, needsInsert: true };
|
|
}
|