diff --git a/src/components/video-editor/timeline/components/wrapper/TimelineWrapper.tsx b/src/components/video-editor/timeline/components/wrapper/TimelineWrapper.tsx
index 58e55c3a..82511b19 100644
--- a/src/components/video-editor/timeline/components/wrapper/TimelineWrapper.tsx
+++ b/src/components/video-editor/timeline/components/wrapper/TimelineWrapper.tsx
@@ -126,6 +126,30 @@ export default function TimelineWrapper({
[formatTooltipMs],
);
+ const resolveDragPreviewSpan = useCallback(
+ (event: DragMoveEvent, span: Span): Span => {
+ const proposedRowId = event.over?.id as string | undefined;
+ if (!proposedRowId) return span;
+
+ const activeItemId = event.active.id as string;
+ const resolved = resolveDragEnd(
+ activeItemId,
+ span,
+ proposedRowId,
+ {
+ allRegionSpans,
+ totalMs,
+ minItemDurationMs,
+ hasOverlap,
+ },
+ resolveTargetRowId,
+ );
+
+ return resolved?.span ?? span;
+ },
+ [allRegionSpans, hasOverlap, minItemDurationMs, resolveTargetRowId, totalMs],
+ );
+
const onDragStart = useCallback(
(event: DragStartEvent) => {
const span = event.active.data.current.getSpanFromDragEvent?.(event);
@@ -137,17 +161,18 @@ export default function TimelineWrapper({
const onDragMove = useCallback(
(event: DragMoveEvent) => {
const span = event.active.data.current.getSpanFromDragEvent?.(event);
+ const previewSpan = span ? resolveDragPreviewSpan(event, span) : null;
const screenX =
event.activatorEvent && "clientX" in event.activatorEvent
? (event.activatorEvent as PointerEvent).clientX + (event.delta?.x ?? 0)
: undefined;
- if (span) showTooltip(span, screenX);
+ if (previewSpan) showTooltip(previewSpan, screenX);
const moved = Math.hypot(event.delta?.x ?? 0, event.delta?.y ?? 0) > 0.01;
if (moved) {
- onLiveSpanPreviewChange?.(event.active.id as string, span ?? null);
+ onLiveSpanPreviewChange?.(event.active.id as string, previewSpan);
}
},
- [onLiveSpanPreviewChange, showTooltip],
+ [onLiveSpanPreviewChange, resolveDragPreviewSpan, showTooltip],
);
const onResizeMove = useCallback(
@@ -210,7 +235,13 @@ export default function TimelineWrapper({
onDragStart={onDragStart}
onDragMove={onDragMove}
onDragEnd={onDragEndWithTooltip}
- autoScroll={{ enabled: false }}
+ autoScroll={{
+ enabled: true,
+ threshold: { x: 0.08, y: 0.08 },
+ acceleration: 3,
+ interval: 16,
+ layoutShiftCompensation: { x: false, y: false },
+ }}
resizeHandleWidth={28}
>
diff --git a/src/components/video-editor/timeline/dnd/engine.test.ts b/src/components/video-editor/timeline/dnd/engine.test.ts
index 87d53580..c7879acb 100644
--- a/src/components/video-editor/timeline/dnd/engine.test.ts
+++ b/src/components/video-editor/timeline/dnd/engine.test.ts
@@ -16,51 +16,78 @@ const BASE_SPANS = [
{ id: "aud-1", start: 100, end: 500, rowId: "row-audio-0" },
];
+const hasBaseOverlap = (span: { start: number; end: number }, excludeId?: string, rowId?: string) =>
+ BASE_SPANS.some(
+ (region) =>
+ region.id !== excludeId &&
+ (!rowId || region.rowId === rowId) &&
+ span.start < region.end &&
+ span.end > region.start,
+ );
+
describe("timeline dnd engine", () => {
it("clamps item span to timeline bounds and min duration", () => {
- expect(clampSpanToBounds({ start: -100, end: 20 }, { totalMs: 5000, minItemDurationMs: 100 })).toEqual({ start: 0, end: 120 });
- expect(clampSpanToBounds({ start: 4900, end: 7000 }, { totalMs: 5000, minItemDurationMs: 100 })).toEqual({ start: 2900, end: 5000 });
+ expect(
+ clampSpanToBounds({ start: -100, end: 20 }, { totalMs: 5000, minItemDurationMs: 100 }),
+ ).toEqual({ start: 0, end: 120 });
+ expect(
+ clampSpanToBounds(
+ { start: 4900, end: 7000 },
+ { totalMs: 5000, minItemDurationMs: 100 },
+ ),
+ ).toEqual({ start: 2900, end: 5000 });
});
it("handles zero-duration timelines in span clamping", () => {
- expect(clampSpanToBounds({ start: -10, end: -5 }, { totalMs: 0, minItemDurationMs: 100 })).toEqual({ start: 0, end: 100 });
- expect(clampSpanToBounds({ start: 50, end: 60 }, { totalMs: 0, minItemDurationMs: 1 })).toEqual({ start: 50, end: 60 });
+ expect(
+ clampSpanToBounds({ start: -10, end: -5 }, { totalMs: 0, minItemDurationMs: 100 }),
+ ).toEqual({ start: 0, end: 100 });
+ expect(
+ clampSpanToBounds({ start: 50, end: 60 }, { totalMs: 0, minItemDurationMs: 1 }),
+ ).toEqual({ start: 50, end: 60 });
});
it("clamps visible range for bounded and unbounded timelines", () => {
- expect(clampRange({ start: 4900, end: 5200 }, { totalMs: 5000, minVisibleRangeMs: 300 })).toEqual({ start: 4700, end: 5000 });
- expect(clampRange({ start: -20, end: 50 }, { totalMs: 0, minVisibleRangeMs: 300 })).toEqual({ start: 0, end: 300 });
+ expect(
+ clampRange({ start: 4900, end: 5200 }, { totalMs: 5000, minVisibleRangeMs: 300 }),
+ ).toEqual({ start: 4700, end: 5000 });
+ expect(clampRange({ start: -20, end: 50 }, { totalMs: 0, minVisibleRangeMs: 300 })).toEqual(
+ { start: 0, end: 300 },
+ );
});
it("resolves siblings by row and active item", () => {
expect(getSiblingSpans("b", undefined, BASE_SPANS).map((s) => s.id)).toEqual(["a", "c"]);
- expect(getSiblingSpans("missing", "row-clip", BASE_SPANS).map((s) => s.id)).toEqual(["a", "b", "c"]);
+ expect(getSiblingSpans("missing", "row-clip", BASE_SPANS).map((s) => s.id)).toEqual([
+ "a",
+ "b",
+ "c",
+ ]);
expect(getSiblingSpans("missing", undefined, BASE_SPANS)).toEqual([]);
});
it("clamps resize against nearest neighbours and min duration", () => {
- const resizedRight = clampResizedSpanToNeighbours(
- { start: 900, end: 2000 },
- "a",
- { allRegionSpans: BASE_SPANS, minItemDurationMs: 100, totalMs: 5000 },
- );
+ const resizedRight = clampResizedSpanToNeighbours({ start: 900, end: 2000 }, "a", {
+ allRegionSpans: BASE_SPANS,
+ minItemDurationMs: 100,
+ totalMs: 5000,
+ });
expect(resizedRight.end).toBe(1500);
- const resizedLeft = clampResizedSpanToNeighbours(
- { start: 900, end: 2500 },
- "b",
- { allRegionSpans: BASE_SPANS, minItemDurationMs: 100, totalMs: 5000 },
- );
+ const resizedLeft = clampResizedSpanToNeighbours({ start: 900, end: 2500 }, "b", {
+ allRegionSpans: BASE_SPANS,
+ minItemDurationMs: 100,
+ totalMs: 5000,
+ });
expect(resizedLeft.start).toBe(1000);
});
it("keeps drag unchanged when already inside valid neighbour gap", () => {
- const dragged = clampDraggedSpanToNeighbours(
- { start: 1400, end: 2400 },
- "b",
- "row-clip",
- { allRegionSpans: BASE_SPANS, minItemDurationMs: 100, totalMs: 5000 },
- );
+ const dragged = clampDraggedSpanToNeighbours({ start: 1400, end: 2400 }, "b", "row-clip", {
+ allRegionSpans: BASE_SPANS,
+ minItemDurationMs: 100,
+ totalMs: 5000,
+ });
expect(dragged).toEqual({ start: 1400, end: 2400 });
});
@@ -74,7 +101,7 @@ describe("timeline dnd engine", () => {
expect(toLeftBoundary).toEqual({ start: 1000, end: 2000 });
const toRightBoundary = clampDraggedSpanToNeighbours(
- { start: 3500, end: 4500 },
+ { start: 2200, end: 3200 },
"b",
"row-clip",
{ allRegionSpans: BASE_SPANS, minItemDurationMs: 100, totalMs: 5000 },
@@ -82,6 +109,24 @@ describe("timeline dnd engine", () => {
expect(toRightBoundary).toEqual({ start: 2000, end: 3000 });
});
+ it("places a clip after the next neighbour once most of the dragged clip crosses its start", () => {
+ const dragged = clampDraggedSpanToNeighbours({ start: 2600, end: 3600 }, "b", "row-clip", {
+ allRegionSpans: BASE_SPANS,
+ minItemDurationMs: 100,
+ totalMs: 5000,
+ });
+ expect(dragged).toEqual({ start: 3600, end: 4600 });
+ });
+
+ it("allows the final clip drag to extend the timeline", () => {
+ const dragged = clampDraggedSpanToNeighbours({ start: 5200, end: 5800 }, "c", "row-clip", {
+ allRegionSpans: BASE_SPANS,
+ minItemDurationMs: 100,
+ totalMs: 5000,
+ });
+ expect(dragged).toEqual({ start: 5200, end: 5800 });
+ });
+
it("falls back to generic clamping when active drag item is unknown", () => {
const dragged = clampDraggedSpanToNeighbours(
{ start: -10, end: 20 },
@@ -93,22 +138,30 @@ describe("timeline dnd engine", () => {
});
it("resolves resize end with overlap fallback semantics", () => {
- const result = resolveResizeEnd("a", { start: 900, end: 2200 }, {
- totalMs: 5000,
- minItemDurationMs: 100,
- allRegionSpans: BASE_SPANS,
- hasOverlap: (span, id) => id === "a" && span.end > 1500,
- });
+ const result = resolveResizeEnd(
+ "a",
+ { start: 900, end: 2200 },
+ {
+ totalMs: 5000,
+ minItemDurationMs: 100,
+ allRegionSpans: BASE_SPANS,
+ hasOverlap: (span, id) => id === "a" && span.end > 1500,
+ },
+ );
expect(result).toEqual({ start: 900, end: 1500 });
});
it("returns null when resize still overlaps after neighbour clamp", () => {
- const result = resolveResizeEnd("a", { start: 900, end: 2200 }, {
- totalMs: 5000,
- minItemDurationMs: 100,
- allRegionSpans: BASE_SPANS,
- hasOverlap: () => true,
- });
+ const result = resolveResizeEnd(
+ "a",
+ { start: 900, end: 2200 },
+ {
+ totalMs: 5000,
+ minItemDurationMs: 100,
+ allRegionSpans: BASE_SPANS,
+ hasOverlap: () => true,
+ },
+ );
expect(result).toBeNull();
});
@@ -128,33 +181,63 @@ describe("timeline dnd engine", () => {
expect(result).toEqual({ rowId: "row-clip", span: { start: 1200, end: 2200 } });
});
+ it("resolves final clip drags beyond the current timeline duration", () => {
+ const result = resolveDragEnd("c", { start: 5200, end: 5800 }, "row-clip", {
+ allRegionSpans: BASE_SPANS,
+ totalMs: 5000,
+ minItemDurationMs: 100,
+ hasOverlap: () => false,
+ });
+ expect(result).toEqual({ rowId: "row-clip", span: { start: 5200, end: 5800 } });
+ });
+
+ it("resolves a middle clip drag after the final clip by extending the timeline", () => {
+ const result = resolveDragEnd("b", { start: 4200, end: 5200 }, "row-clip", {
+ allRegionSpans: BASE_SPANS,
+ totalMs: 5000,
+ minItemDurationMs: 100,
+ hasOverlap: () => false,
+ });
+ expect(result).toEqual({ rowId: "row-clip", span: { start: 4200, end: 5200 } });
+ });
+
+ it("resolves an overlapping clip drag as an after-neighbour reorder intent", () => {
+ const result = resolveDragEnd("b", { start: 3500, end: 4500 }, "row-clip", {
+ allRegionSpans: BASE_SPANS,
+ totalMs: 5000,
+ minItemDurationMs: 100,
+ hasOverlap: hasBaseOverlap,
+ });
+ expect(result).toEqual({ rowId: "row-clip", span: { start: 3600, end: 4600 } });
+ });
+
+ it("keeps non-clip drags bounded by the current timeline duration", () => {
+ const result = resolveDragEnd("aud-1", { start: 5200, end: 5600 }, "row-audio-0", {
+ allRegionSpans: BASE_SPANS,
+ totalMs: 5000,
+ minItemDurationMs: 100,
+ hasOverlap: () => false,
+ });
+ expect(result).toEqual({ rowId: "row-audio-0", span: { start: 4600, end: 5000 } });
+ });
+
it("returns null when drag still overlaps after neighbour clamp", () => {
- const result = resolveDragEnd(
- "b",
- { start: 1200, end: 1800 },
- "row-clip",
- {
- allRegionSpans: BASE_SPANS,
- totalMs: 5000,
- minItemDurationMs: 100,
- hasOverlap: () => true,
- },
- );
+ const result = resolveDragEnd("b", { start: 1200, end: 1800 }, "row-clip", {
+ allRegionSpans: BASE_SPANS,
+ totalMs: 5000,
+ minItemDurationMs: 100,
+ hasOverlap: () => true,
+ });
expect(result).toBeNull();
});
it("keeps proposed row when no target row resolver is provided", () => {
- const result = resolveDragEnd(
- "aud-1",
- { start: 700, end: 1000 },
- "row-audio-2",
- {
- allRegionSpans: BASE_SPANS,
- totalMs: 5000,
- minItemDurationMs: 100,
- hasOverlap: () => false,
- },
- );
+ const result = resolveDragEnd("aud-1", { start: 700, end: 1000 }, "row-audio-2", {
+ allRegionSpans: BASE_SPANS,
+ totalMs: 5000,
+ minItemDurationMs: 100,
+ hasOverlap: () => false,
+ });
expect(result?.rowId).toBe("row-audio-2");
});
});
diff --git a/src/components/video-editor/timeline/dnd/engine.ts b/src/components/video-editor/timeline/dnd/engine.ts
index 02be4523..84c9d99c 100644
--- a/src/components/video-editor/timeline/dnd/engine.ts
+++ b/src/components/video-editor/timeline/dnd/engine.ts
@@ -1,4 +1,5 @@
import type { Range, Span } from "dnd-timeline";
+import { CLIP_ROW_ID } from "../core/constants";
import type { TimelineRegionSpan } from "../core/timelineTypes";
export interface DndEngineConfig {
@@ -9,7 +10,10 @@ export interface DndEngineConfig {
hasOverlap: (newSpan: Span, excludeId?: string, rowId?: string) => boolean;
}
-export function clampSpanToBounds(span: Span, config: Pick): Span {
+export function clampSpanToBounds(
+ span: Span,
+ config: Pick,
+): Span {
const { totalMs, minItemDurationMs } = config;
const rawDuration = Math.max(span.end - span.start, 0);
const normalizedStart = Number.isFinite(span.start) ? span.start : 0;
@@ -27,7 +31,10 @@ export function clampSpanToBounds(span: Span, config: Pick): Range {
+export function clampRange(
+ candidate: Range,
+ config: Pick,
+): Range {
const { totalMs, minVisibleRangeMs } = config;
if (totalMs === 0) {
const minSpan = Math.max(minVisibleRangeMs, 1);
@@ -53,7 +60,11 @@ export function clampRange(candidate: Range, config: Pick region.id === activeItemId);
const resolvedRowId = rowId ?? activeItem?.rowId;
if (!resolvedRowId) {
@@ -65,7 +76,11 @@ export function getSiblingSpans(activeItemId: string, rowId: string | undefined,
.sort((left, right) => left.start - right.start);
}
-export function clampResizedSpanToNeighbours(span: Span, activeItemId: string, config: Pick): Span {
+export function clampResizedSpanToNeighbours(
+ span: Span,
+ activeItemId: string,
+ config: Pick,
+): Span {
const { allRegionSpans, minItemDurationMs, totalMs } = config;
const siblings = getSiblingSpans(activeItemId, undefined, allRegionSpans);
const activeItem = allRegionSpans.find((region) => region.id === activeItemId);
@@ -82,7 +97,9 @@ export function clampResizedSpanToNeighbours(span: Span, activeItemId: string, c
const minDur = Math.min(minItemDurationMs, totalMs || minItemDurationMs);
if (end - start < minDur) {
- const resizedLeft = Boolean(activeItem && span.start !== activeItem.start && span.end === activeItem.end);
+ const resizedLeft = Boolean(
+ activeItem && span.start !== activeItem.start && span.end === activeItem.end,
+ );
if (resizedLeft) {
start = end - minDur;
} else {
@@ -93,7 +110,111 @@ export function clampResizedSpanToNeighbours(span: Span, activeItemId: string, c
return { start: Math.max(0, start), end: Math.min(end, totalMs || end) };
}
-export function clampDraggedSpanToNeighbours(span: Span, activeItemId: string, rowId: string | undefined, config: Pick): Span {
+function getClipDragTotalMs(
+ activeItem: TimelineRegionSpan | undefined,
+ rowId: string | undefined,
+ span: Span,
+ totalMs: number,
+) {
+ if (activeItem?.rowId !== CLIP_ROW_ID || rowId !== CLIP_ROW_ID) {
+ return totalMs;
+ }
+
+ return Math.max(totalMs, Math.ceil(span.end));
+}
+
+function spansOverlap(left: Span, right: Span) {
+ return left.start < right.end && left.end > right.start;
+}
+
+function placeSpanAfterSibling(
+ siblings: TimelineRegionSpan[],
+ siblingIndex: number,
+ duration: number,
+): Span {
+ let start = siblings[siblingIndex].end;
+
+ for (let index = siblingIndex + 1; index < siblings.length; index += 1) {
+ const sibling = siblings[index];
+ if (start + duration <= sibling.start) {
+ break;
+ }
+ start = sibling.end;
+ }
+
+ return { start, end: start + duration };
+}
+
+function placeSpanBeforeSibling(
+ siblings: TimelineRegionSpan[],
+ siblingIndex: number,
+ duration: number,
+): Span | null {
+ let end = siblings[siblingIndex].start;
+
+ for (let index = siblingIndex - 1; index >= 0; index -= 1) {
+ const sibling = siblings[index];
+ if (end - duration >= sibling.end) {
+ break;
+ }
+ end = sibling.start;
+ }
+
+ const start = end - duration;
+ if (start < 0) {
+ return null;
+ }
+
+ return { start, end };
+}
+
+function resolveClipDragInsertionSpan(params: {
+ activeItem: TimelineRegionSpan;
+ siblings: TimelineRegionSpan[];
+ proposedStart: number;
+ duration: number;
+}): Span | null {
+ const { activeItem, siblings, proposedStart, duration } = params;
+ const proposedSpan = { start: proposedStart, end: proposedStart + duration };
+ const proposedCenter = proposedStart + duration / 2;
+ const delta = proposedStart - activeItem.start;
+
+ if (delta > 0) {
+ const nextIndex = siblings.findIndex(
+ (sibling) =>
+ sibling.start >= activeItem.end &&
+ spansOverlap(proposedSpan, sibling) &&
+ proposedCenter >= sibling.start,
+ );
+ if (nextIndex >= 0) {
+ return placeSpanAfterSibling(siblings, nextIndex, duration);
+ }
+
+ return null;
+ }
+
+ if (delta < 0) {
+ for (let index = siblings.length - 1; index >= 0; index -= 1) {
+ const sibling = siblings[index];
+ if (
+ sibling.end <= activeItem.start &&
+ spansOverlap(proposedSpan, sibling) &&
+ proposedCenter <= sibling.end
+ ) {
+ return placeSpanBeforeSibling(siblings, index, duration);
+ }
+ }
+ }
+
+ return null;
+}
+
+export function clampDraggedSpanToNeighbours(
+ span: Span,
+ activeItemId: string,
+ rowId: string | undefined,
+ config: Pick,
+): Span {
const { allRegionSpans, minItemDurationMs, totalMs } = config;
const activeItem = allRegionSpans.find((region) => region.id === activeItemId);
if (!activeItem) {
@@ -106,20 +227,56 @@ export function clampDraggedSpanToNeighbours(span: Span, activeItemId: string, r
Math.min(minItemDurationMs, totalMs || minItemDurationMs),
);
const proposedStart = Number.isFinite(span.start) ? span.start : activeItem.start;
+ const proposedSpan = { start: proposedStart, end: proposedStart + duration };
- const previousSibling = [...siblings].reverse().find((region) => region.end <= activeItem.start);
+ if (activeItem.rowId === CLIP_ROW_ID && rowId === CLIP_ROW_ID) {
+ const insertionSpan = resolveClipDragInsertionSpan({
+ activeItem,
+ siblings,
+ proposedStart,
+ duration,
+ });
+ if (insertionSpan) {
+ const insertionTotalMs = getClipDragTotalMs(activeItem, rowId, insertionSpan, totalMs);
+ return clampSpanToBounds(insertionSpan, {
+ totalMs: insertionTotalMs,
+ minItemDurationMs,
+ });
+ }
+ }
+
+ const effectiveTotalMs = getClipDragTotalMs(activeItem, rowId, proposedSpan, totalMs);
+
+ const previousSibling = [...siblings]
+ .reverse()
+ .find((region) => region.end <= activeItem.start);
const nextSibling = siblings.find((region) => region.start >= activeItem.end);
const minStart = previousSibling ? previousSibling.end : 0;
- const maxStart = nextSibling ? nextSibling.start - duration : totalMs > 0 ? totalMs - duration : proposedStart;
+ const maxStart = nextSibling
+ ? nextSibling.start - duration
+ : effectiveTotalMs > 0
+ ? effectiveTotalMs - duration
+ : proposedStart;
const start = Math.max(minStart, Math.min(proposedStart, maxStart));
- return clampSpanToBounds({ start, end: start + duration }, { totalMs, minItemDurationMs });
+ return clampSpanToBounds(
+ { start, end: start + duration },
+ { totalMs: effectiveTotalMs, minItemDurationMs },
+ );
}
-export function resolveResizeEnd(activeItemId: string, updatedSpan: Span, config: Pick): Span | null {
+export function resolveResizeEnd(
+ activeItemId: string,
+ updatedSpan: Span,
+ config: Pick<
+ DndEngineConfig,
+ "totalMs" | "minItemDurationMs" | "allRegionSpans" | "hasOverlap"
+ >,
+): Span | null {
const { totalMs, minItemDurationMs, allRegionSpans, hasOverlap } = config;
let clamped = clampSpanToBounds(updatedSpan, { totalMs, minItemDurationMs });
- const effectiveMinDuration = totalMs > 0 ? Math.min(minItemDurationMs, totalMs) : minItemDurationMs;
+ const effectiveMinDuration =
+ totalMs > 0 ? Math.min(minItemDurationMs, totalMs) : minItemDurationMs;
if (clamped.end - clamped.start < effectiveMinDuration) {
return null;
}
@@ -145,22 +302,28 @@ export function resolveDragEnd(
activeItemId: string,
updatedSpan: Span,
proposedRowId: string,
- config: Pick,
+ config: Pick<
+ DndEngineConfig,
+ "allRegionSpans" | "totalMs" | "minItemDurationMs" | "hasOverlap"
+ >,
resolveTargetRowId?: (id: string, proposedRowId: string) => string,
): { span: Span; rowId: string } | null {
const { allRegionSpans, totalMs, minItemDurationMs, hasOverlap } = config;
const resolvedRowId = resolveTargetRowId?.(activeItemId, proposedRowId) ?? proposedRowId;
const activeItem = allRegionSpans.find((r) => r.id === activeItemId);
- const originalDuration = activeItem ? activeItem.end - activeItem.start : updatedSpan.end - updatedSpan.start;
+ const originalDuration = activeItem
+ ? activeItem.end - activeItem.start
+ : updatedSpan.end - updatedSpan.start;
const dragSpan: Span = { start: updatedSpan.start, end: updatedSpan.start + originalDuration };
+ const effectiveTotalMs = getClipDragTotalMs(activeItem, resolvedRowId, dragSpan, totalMs);
- let clamped = clampSpanToBounds(dragSpan, { totalMs, minItemDurationMs });
+ let clamped = clampSpanToBounds(dragSpan, { totalMs: effectiveTotalMs, minItemDurationMs });
if (hasOverlap(clamped, activeItemId, resolvedRowId)) {
clamped = clampDraggedSpanToNeighbours(clamped, activeItemId, resolvedRowId, {
allRegionSpans,
minItemDurationMs,
- totalMs,
+ totalMs: effectiveTotalMs,
});
if (hasOverlap(clamped, activeItemId, resolvedRowId)) {
return null;