Files
hyperframes/packages/studio/src/player/components/timelineLayerDrag.ts
T
Miguel Angel Simon Sierra 6b4870246c fix(studio): dropping an overlapping clip on a lane restacks it, not a no-op
A vertical drag onto a lane whose clips overlap the dragged clip in time
used to resolve to the "nearest valid lane", which included the clip's own
lane — so a small drag snapped straight back and felt like the editor
refused the move. Overlapping clips can't share a row (a row is 1-D in
time), but the drag should still restack, never reject.

Now a conflicting onto-drop converts to an edge insertion adjacent to the
target by drag direction (up -> above, down -> below): the clip lands on
its own lane just in front of / behind the target, with its time
unchanged. Non-overlapping drops still join the lane. Deletes the
nearest-valid-placement search (~90 lines) this replaces.
2026-07-08 23:46:27 -04:00

384 lines
13 KiB
TypeScript

import { resolveStackingContextKey } from "../lib/layerOrdering";
import { getTimelineElementIdentity } from "../lib/timelineElementHelpers";
import {
timelineElementsOverlap,
type StackingTimelineLayer,
type TimelineLayerId,
} from "./timelineTrackOrder";
import {
toStackingOrderItem,
type TimelineLayerDropPlacement,
type TimelineStackingElement,
type TimelineStackingReorderIntent,
type TimelineStackingZIndexChange,
} from "./timelineStacking";
const ONTO_ROW_THRESHOLD = 0.35;
export interface TimelineLayerStackingMoveResolution {
previewLayerId: TimelineLayerId;
previewLayerIndex: number;
stackingReorder: TimelineStackingReorderIntent | null;
}
function isAudioElement(element: TimelineStackingElement): boolean {
return element.tag?.toLowerCase() === "audio";
}
function layerContainsElement(layer: StackingTimelineLayer, key: string): boolean {
return layer.elements.some((element) => getTimelineElementIdentity(element) === key);
}
function layerConflictsWithElement(
layer: StackingTimelineLayer,
element: TimelineStackingElement,
draggedKey: string,
): boolean {
return layer.elements.some(
(candidate) =>
getTimelineElementIdentity(candidate) !== draggedKey &&
timelineElementsOverlap(candidate, element),
);
}
function addElementChange(
changes: TimelineStackingZIndexChange[],
element: TimelineStackingElement,
zIndex: number,
): void {
if ((element.zIndex ?? 0) === zIndex) return;
changes.push({
key: getTimelineElementIdentity(element),
zIndex,
domId: element.domId,
selector: element.selector,
selectorIndex: element.selectorIndex,
sourceFile: element.sourceFile,
});
}
function addLayerChanges(
changes: TimelineStackingZIndexChange[],
layer: StackingTimelineLayer,
zIndex: number,
excludedKey: string,
): number {
let count = 0;
for (const element of layer.elements) {
const key = getTimelineElementIdentity(element);
if (key === excludedKey) continue;
const before = changes.length;
addElementChange(changes, element, zIndex);
if (changes.length > before) count += 1;
}
return count;
}
function getContextLayers(
layers: readonly StackingTimelineLayer[],
contextKey: string,
): StackingTimelineLayer[] {
return layers.filter((layer) => layer.kind === "visual" && layer.contextKey === contextKey);
}
function findLayer(
layers: readonly StackingTimelineLayer[],
id: string,
): StackingTimelineLayer | null {
return layers.find((layer) => layer.id === id) ?? null;
}
function resolvePlacementZIndexChanges(input: {
element: TimelineStackingElement;
targetZIndex: number;
}): TimelineStackingZIndexChange[] {
const changes: TimelineStackingZIndexChange[] = [];
addElementChange(changes, input.element, input.targetZIndex);
return changes;
}
function buildPushUpCandidate(input: {
element: TimelineStackingElement;
layers: readonly StackingTimelineLayer[];
beforeIndex: number;
bottomZIndex: number;
}): { changes: TimelineStackingZIndexChange[]; siblingChanges: number } {
const draggedKey = getTimelineElementIdentity(input.element);
const draggedZIndex = input.bottomZIndex + 1;
const changes = resolvePlacementZIndexChanges({
element: input.element,
targetZIndex: draggedZIndex,
});
let siblingChanges = 0;
let requiredZIndex = draggedZIndex + 1;
for (let index = input.beforeIndex; index >= 0; index -= 1) {
const layer = input.layers[index];
if (!layer) continue;
const nextZIndex = layer.zIndex >= requiredZIndex ? layer.zIndex : requiredZIndex;
siblingChanges += addLayerChanges(changes, layer, nextZIndex, draggedKey);
requiredZIndex = nextZIndex + 1;
}
return { changes, siblingChanges };
}
function buildPushDownCandidate(input: {
element: TimelineStackingElement;
layers: readonly StackingTimelineLayer[];
afterIndex: number;
topZIndex: number;
}): { changes: TimelineStackingZIndexChange[]; siblingChanges: number } {
const draggedKey = getTimelineElementIdentity(input.element);
const draggedZIndex = input.topZIndex - 1;
const changes = resolvePlacementZIndexChanges({
element: input.element,
targetZIndex: draggedZIndex,
});
let siblingChanges = 0;
let requiredZIndex = draggedZIndex - 1;
for (let index = input.afterIndex; index < input.layers.length; index += 1) {
const layer = input.layers[index];
if (!layer) continue;
const nextZIndex = layer.zIndex <= requiredZIndex ? layer.zIndex : requiredZIndex;
siblingChanges += addLayerChanges(changes, layer, nextZIndex, draggedKey);
requiredZIndex = nextZIndex - 1;
}
return { changes, siblingChanges };
}
function resolveBetweenChanges(input: {
element: TimelineStackingElement;
layers: readonly StackingTimelineLayer[];
beforeLayer: StackingTimelineLayer;
afterLayer: StackingTimelineLayer;
}): TimelineStackingZIndexChange[] {
const topZIndex = input.beforeLayer.zIndex;
const bottomZIndex = input.afterLayer.zIndex;
if (topZIndex - bottomZIndex > 1) {
return resolvePlacementZIndexChanges({
element: input.element,
targetZIndex: Math.floor((topZIndex + bottomZIndex) / 2),
});
}
const beforeIndex = input.layers.findIndex((layer) => layer.id === input.beforeLayer.id);
const afterIndex = input.layers.findIndex((layer) => layer.id === input.afterLayer.id);
const pushUp = buildPushUpCandidate({
element: input.element,
layers: input.layers,
beforeIndex,
bottomZIndex,
});
const pushDown = buildPushDownCandidate({
element: input.element,
layers: input.layers,
afterIndex,
topZIndex,
});
return pushUp.siblingChanges <= pushDown.siblingChanges ? pushUp.changes : pushDown.changes;
}
function resolveOntoChanges(input: {
element: TimelineStackingElement;
layers: readonly StackingTimelineLayer[];
layerId: string;
}): TimelineStackingZIndexChange[] | null {
const target = findLayer(input.layers, input.layerId);
if (!target) return null;
if (layerConflictsWithElement(target, input.element, getTimelineElementIdentity(input.element))) {
return null;
}
return resolvePlacementZIndexChanges({
element: input.element,
targetZIndex: target.zIndex,
});
}
function resolveEdgeChanges(input: {
element: TimelineStackingElement;
layers: readonly StackingTimelineLayer[];
layerId: string;
offset: number;
}): TimelineStackingZIndexChange[] | null {
const target = findLayer(input.layers, input.layerId);
if (!target) return null;
return resolvePlacementZIndexChanges({
element: input.element,
targetZIndex: target.zIndex + input.offset,
});
}
function resolvePlacementChanges(input: {
element: TimelineStackingElement;
layers: readonly StackingTimelineLayer[];
placement: TimelineLayerDropPlacement;
}): TimelineStackingZIndexChange[] | null {
switch (input.placement.type) {
case "onto":
return resolveOntoChanges({
element: input.element,
layers: input.layers,
layerId: input.placement.layerId,
});
case "above":
return resolveEdgeChanges({
element: input.element,
layers: input.layers,
layerId: input.placement.layerId,
offset: 1,
});
case "below":
return resolveEdgeChanges({
element: input.element,
layers: input.layers,
layerId: input.placement.layerId,
offset: -1,
});
case "between": {
const beforeLayer = findLayer(input.layers, input.placement.beforeLayerId);
const afterLayer = findLayer(input.layers, input.placement.afterLayerId);
return beforeLayer && afterLayer
? resolveBetweenChanges({
element: input.element,
layers: input.layers,
beforeLayer,
afterLayer,
})
: null;
}
}
}
export function resolveTimelineLayerZIndexChanges(input: {
element: TimelineStackingElement;
layers: readonly StackingTimelineLayer[];
placement: TimelineLayerDropPlacement;
}): TimelineStackingReorderIntent | null {
if (isAudioElement(input.element)) return null;
const contextKey = resolveStackingContextKey(toStackingOrderItem(input.element));
const layers = getContextLayers(input.layers, contextKey);
const changes = resolvePlacementChanges({
element: input.element,
layers,
placement: input.placement,
});
return changes && changes.length > 0
? { contextKey, placement: input.placement, zIndexChanges: changes }
: null;
}
// fallow-ignore-next-line complexity
function resolveDragPlacement(
layers: readonly StackingTimelineLayer[],
targetPosition: number,
): TimelineLayerDropPlacement | null {
const first = layers[0];
const last = layers[layers.length - 1];
if (!first || !last) return null;
if (targetPosition < -ONTO_ROW_THRESHOLD) return { type: "above", layerId: first.id };
if (targetPosition > layers.length - 1 + ONTO_ROW_THRESHOLD) {
return { type: "below", layerId: last.id };
}
const nearestIndex = Math.round(targetPosition);
const nearest = layers[nearestIndex];
if (nearest && Math.abs(targetPosition - nearestIndex) <= ONTO_ROW_THRESHOLD) {
return { type: "onto", layerId: nearest.id };
}
const insertionIndex = Math.max(0, Math.min(layers.length, Math.ceil(targetPosition)));
if (insertionIndex <= 0) return { type: "above", layerId: first.id };
if (insertionIndex >= layers.length) return { type: "below", layerId: last.id };
const before = layers[insertionIndex - 1];
const after = layers[insertionIndex];
return before && after
? { type: "between", beforeLayerId: before.id, afterLayerId: after.id }
: null;
}
function resolveLaneAwareDragPlacement(input: {
layers: readonly StackingTimelineLayer[];
element: TimelineStackingElement;
draggedKey: string;
placement: TimelineLayerDropPlacement;
targetPosition: number;
currentIndex: number;
}): TimelineLayerDropPlacement | null {
if (input.placement.type !== "onto") return input.placement;
const target = findLayer(input.layers, input.placement.layerId);
if (!target) return null;
if (!layerConflictsWithElement(target, input.element, input.draggedKey)) return input.placement;
if (input.targetPosition < input.currentIndex) {
return { type: "above", layerId: target.id };
}
if (input.targetPosition > input.currentIndex) {
return { type: "below", layerId: target.id };
}
return input.placement;
}
function getPreviewLayerId(
draggedKey: string,
placement: TimelineLayerDropPlacement,
): TimelineLayerId {
if (placement.type === "onto") return placement.layerId;
if (placement.type === "between") {
return `preview:${draggedKey}:between:${placement.beforeLayerId}:${placement.afterLayerId}`;
}
return `preview:${draggedKey}:${placement.type}:${placement.layerId}`;
}
function getPreviewLayerIndex(
layerOrder: readonly TimelineLayerId[],
placement: TimelineLayerDropPlacement,
): number {
if (placement.type === "onto") return Math.max(0, layerOrder.indexOf(placement.layerId));
if (placement.type === "between") {
return Math.max(0, layerOrder.indexOf(placement.afterLayerId));
}
const targetIndex = Math.max(0, layerOrder.indexOf(placement.layerId));
return placement.type === "above" ? targetIndex : targetIndex + 1;
}
export function resolveTimelineLayerStackingMove(input: {
element: TimelineStackingElement;
layers: readonly StackingTimelineLayer[];
layerOrder: readonly TimelineLayerId[];
trackDeltaRaw: number;
}): TimelineLayerStackingMoveResolution | null {
if (isAudioElement(input.element)) return null;
const contextKey = resolveStackingContextKey(toStackingOrderItem(input.element));
const layerById = new Map(input.layers.map((layer) => [layer.id, layer]));
const contextLayers = input.layerOrder
.map((id) => layerById.get(id) ?? null)
.filter(
(layer): layer is StackingTimelineLayer =>
layer != null && layer.kind === "visual" && layer.contextKey === contextKey,
);
const draggedKey = getTimelineElementIdentity(input.element);
const currentIndex = contextLayers.findIndex((layer) => layerContainsElement(layer, draggedKey));
if (currentIndex < 0) return null;
const targetPosition = currentIndex + input.trackDeltaRaw;
const rawPlacement = resolveDragPlacement(contextLayers, targetPosition);
if (!rawPlacement) return null;
const placement = resolveLaneAwareDragPlacement({
layers: contextLayers,
element: input.element,
draggedKey,
placement: rawPlacement,
targetPosition,
currentIndex,
});
if (!placement) return null;
return {
previewLayerId: getPreviewLayerId(draggedKey, placement),
previewLayerIndex: getPreviewLayerIndex(input.layerOrder, placement),
stackingReorder: resolveTimelineLayerZIndexChanges({
element: input.element,
layers: contextLayers,
placement,
}),
};
}