Files
hyperframes/packages/studio/src/player/components/timelineCollision.ts
T

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 };
}