mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
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.
This commit is contained in:
@@ -129,7 +129,7 @@ describe("resolveTimelineLayerZIndexChanges", () => {
|
||||
});
|
||||
|
||||
describe("resolveTimelineLayerStackingMove", () => {
|
||||
it("snaps a blocked onto-lane drop to the nearest new lane instead of committing the conflict", () => {
|
||||
it("places an upward onto-lane drop above the overlapping target lane", () => {
|
||||
const front = element({ id: "front", zIndex: 10, start: 0, duration: 2 });
|
||||
const dragged = element({ id: "dragged", zIndex: 1, start: 0.5, duration: 1 });
|
||||
const layers = [layer("front", 10, [front]), layer("dragged", 1, [dragged])];
|
||||
@@ -139,7 +139,7 @@ describe("resolveTimelineLayerStackingMove", () => {
|
||||
element: dragged,
|
||||
layers,
|
||||
layerOrder: layers.map((item) => item.id),
|
||||
trackDeltaRaw: -1,
|
||||
trackDeltaRaw: -0.8,
|
||||
}),
|
||||
).toEqual({
|
||||
previewLayerId: "preview:dragged:above:front",
|
||||
@@ -151,4 +151,50 @@ describe("resolveTimelineLayerStackingMove", () => {
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("places a downward onto-lane drop below the overlapping target lane", () => {
|
||||
const dragged = element({ id: "dragged", zIndex: 10, start: 0.5, duration: 1 });
|
||||
const back = element({ id: "back", zIndex: 1, start: 0, duration: 2 });
|
||||
const layers = [layer("dragged", 10, [dragged]), layer("back", 1, [back])];
|
||||
|
||||
expect(
|
||||
resolveTimelineLayerStackingMove({
|
||||
element: dragged,
|
||||
layers,
|
||||
layerOrder: layers.map((item) => item.id),
|
||||
trackDeltaRaw: 0.8,
|
||||
}),
|
||||
).toEqual({
|
||||
previewLayerId: "preview:dragged:below:back",
|
||||
previewLayerIndex: 2,
|
||||
stackingReorder: {
|
||||
contextKey: "root",
|
||||
placement: { type: "below", layerId: "back" },
|
||||
zIndexChanges: [{ key: "dragged", zIndex: 0 }],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps a non-overlapping onto-lane drop joined to the target lane", () => {
|
||||
const front = element({ id: "front", zIndex: 10, start: 0, duration: 1 });
|
||||
const dragged = element({ id: "dragged", zIndex: 1, start: 2, duration: 1 });
|
||||
const layers = [layer("front", 10, [front]), layer("dragged", 1, [dragged])];
|
||||
|
||||
expect(
|
||||
resolveTimelineLayerStackingMove({
|
||||
element: dragged,
|
||||
layers,
|
||||
layerOrder: layers.map((item) => item.id),
|
||||
trackDeltaRaw: -0.8,
|
||||
}),
|
||||
).toEqual({
|
||||
previewLayerId: "front",
|
||||
previewLayerIndex: 0,
|
||||
stackingReorder: {
|
||||
contextKey: "root",
|
||||
placement: { type: "onto", layerId: "front" },
|
||||
zIndexChanges: [{ key: "dragged", zIndex: 10 }],
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -296,96 +296,6 @@ function resolveDragPlacement(
|
||||
: null;
|
||||
}
|
||||
|
||||
function buildInsertionPlacement(
|
||||
layers: readonly StackingTimelineLayer[],
|
||||
insertionIndex: number,
|
||||
): TimelineLayerDropPlacement | null {
|
||||
const first = layers[0];
|
||||
const last = layers[layers.length - 1];
|
||||
if (!first || !last) return null;
|
||||
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;
|
||||
}
|
||||
|
||||
interface ValidPlacementCandidate {
|
||||
placement: TimelineLayerDropPlacement;
|
||||
position: number;
|
||||
kind: "onto" | "insert";
|
||||
}
|
||||
|
||||
function comparePlacementCandidates(input: {
|
||||
targetPosition: number;
|
||||
currentIndex: number;
|
||||
a: ValidPlacementCandidate;
|
||||
b: ValidPlacementCandidate;
|
||||
}): number {
|
||||
const distanceA = Math.abs(input.a.position - input.targetPosition);
|
||||
const distanceB = Math.abs(input.b.position - input.targetPosition);
|
||||
if (distanceA !== distanceB) return distanceA - distanceB;
|
||||
|
||||
const direction = Math.sign(input.targetPosition - input.currentIndex);
|
||||
if (direction !== 0) {
|
||||
const biasA =
|
||||
direction < 0
|
||||
? input.a.position <= input.targetPosition
|
||||
: input.a.position >= input.targetPosition;
|
||||
const biasB =
|
||||
direction < 0
|
||||
? input.b.position <= input.targetPosition
|
||||
: input.b.position >= input.targetPosition;
|
||||
if (biasA !== biasB) return biasA ? -1 : 1;
|
||||
}
|
||||
|
||||
if (input.a.kind !== input.b.kind) return input.a.kind === "onto" ? -1 : 1;
|
||||
return input.a.position - input.b.position;
|
||||
}
|
||||
|
||||
function resolveNearestValidPlacement(input: {
|
||||
layers: readonly StackingTimelineLayer[];
|
||||
element: TimelineStackingElement;
|
||||
draggedKey: string;
|
||||
targetPosition: number;
|
||||
currentIndex: number;
|
||||
}): TimelineLayerDropPlacement | null {
|
||||
const candidates: ValidPlacementCandidate[] = [];
|
||||
|
||||
input.layers.forEach((layer, index) => {
|
||||
if (!layerConflictsWithElement(layer, input.element, input.draggedKey)) {
|
||||
candidates.push({
|
||||
placement: { type: "onto", layerId: layer.id },
|
||||
position: index,
|
||||
kind: "onto",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
for (let insertionIndex = 0; insertionIndex <= input.layers.length; insertionIndex += 1) {
|
||||
const placement = buildInsertionPlacement(input.layers, insertionIndex);
|
||||
if (!placement) continue;
|
||||
candidates.push({
|
||||
placement,
|
||||
position: insertionIndex - 0.5,
|
||||
kind: "insert",
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
candidates.sort((a, b) =>
|
||||
comparePlacementCandidates({
|
||||
targetPosition: input.targetPosition,
|
||||
currentIndex: input.currentIndex,
|
||||
a,
|
||||
b,
|
||||
}),
|
||||
)[0]?.placement ?? null
|
||||
);
|
||||
}
|
||||
|
||||
function resolveLaneAwareDragPlacement(input: {
|
||||
layers: readonly StackingTimelineLayer[];
|
||||
element: TimelineStackingElement;
|
||||
@@ -398,7 +308,13 @@ function resolveLaneAwareDragPlacement(input: {
|
||||
const target = findLayer(input.layers, input.placement.layerId);
|
||||
if (!target) return null;
|
||||
if (!layerConflictsWithElement(target, input.element, input.draggedKey)) return input.placement;
|
||||
return resolveNearestValidPlacement(input);
|
||||
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(
|
||||
|
||||
Reference in New Issue
Block a user