mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-07 10:06:21 +00:00
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.
384 lines
13 KiB
TypeScript
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,
|
|
}),
|
|
};
|
|
}
|