Merge pull request #616 from webadderallorg/fix/hud-timeline-patch-1.3.3

fix(editor): patch HUD passthrough and timeline clip drag
This commit is contained in:
Phạm Thị Minh Hồng
2026-05-29 03:06:02 +07:00
committed by GitHub
6 changed files with 400 additions and 94 deletions
+1 -1
View File
@@ -232,7 +232,7 @@ function showHudOverlayFromTray() {
if (process.platform === "win32" && isHudOverlayMousePassthroughSupported()) {
hud.showInactive();
hud.moveTop();
reassertHudOverlayMouseState();
reassertHudOverlayMouseState({ interactiveGraceMs: 1200 });
return true;
}
+6 -2
View File
@@ -613,7 +613,11 @@ export function getHudOverlayWindow(): BrowserWindow | null {
* hover detection on the HUD is immediately restored without requiring the
* user to move their mouse over the bar.
*/
export function reassertHudOverlayMousePassthrough(): void {
export function reassertHudOverlayMousePassthrough({
interactiveGraceMs = 50,
}: {
interactiveGraceMs?: number;
} = {}): void {
if (process.platform !== "win32" || !isHudOverlayMousePassthroughSupported()) {
return;
}
@@ -639,7 +643,7 @@ export function reassertHudOverlayMousePassthrough(): void {
if (!hud.isDestroyed()) {
setHudOverlayMousePassthrough(hudOverlayIgnoringMouse);
}
}, 50);
}, interactiveGraceMs);
}
export function setHudOverlayRecordingActive(recording: boolean): void {
@@ -13,6 +13,23 @@ export function useLaunchHudInteractionState({
}) {
const isMouseOverHudRef = useRef(false);
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
const lastInteractiveReassertAtRef = useRef(0);
const setHudMouseInteractive = useCallback((force = false) => {
const now = performance.now();
if (
!force &&
isMouseOverHudRef.current &&
now - lastInteractiveReassertAtRef.current < 250
) {
return;
}
isMouseOverHudRef.current = true;
lastInteractiveReassertAtRef.current = now;
if (timeoutRef.current) clearTimeout(timeoutRef.current);
window.electronAPI?.hudOverlaySetIgnoreMouse?.(false);
}, []);
useEffect(() => {
if (openId !== null) {
@@ -28,7 +45,7 @@ export function useLaunchHudInteractionState({
}, [openId]);
useEffect(() => {
const handleMouseOver = (e: globalThis.MouseEvent) => {
const handleMouseTracking = (e: globalThis.MouseEvent) => {
const target = e.target as HTMLElement | null;
if (!target) return;
const isInteractive = !!target.closest(
@@ -36,9 +53,7 @@ export function useLaunchHudInteractionState({
);
if (isInteractive) {
isMouseOverHudRef.current = true;
if (timeoutRef.current) clearTimeout(timeoutRef.current);
window.electronAPI?.hudOverlaySetIgnoreMouse?.(false);
setHudMouseInteractive();
} else {
isMouseOverHudRef.current = false;
if (timeoutRef.current) clearTimeout(timeoutRef.current);
@@ -55,20 +70,26 @@ export function useLaunchHudInteractionState({
}
};
window.addEventListener("mouseover", handleMouseOver);
return () => window.removeEventListener("mouseover", handleMouseOver);
}, [isHudDraggingRef, isWebcamPreviewDraggingRef, webcamPreviewDragStartRef]);
window.addEventListener("mouseover", handleMouseTracking);
window.addEventListener("mousemove", handleMouseTracking);
return () => {
window.removeEventListener("mouseover", handleMouseTracking);
window.removeEventListener("mousemove", handleMouseTracking);
};
}, [
isHudDraggingRef,
isWebcamPreviewDraggingRef,
setHudMouseInteractive,
webcamPreviewDragStartRef,
]);
const beginInteractiveHudAction = useCallback(() => {
isMouseOverHudRef.current = true;
window.electronAPI?.hudOverlaySetIgnoreMouse?.(false);
}, []);
setHudMouseInteractive(true);
}, [setHudMouseInteractive]);
const handleHudMouseEnter = useCallback(() => {
isMouseOverHudRef.current = true;
if (timeoutRef.current) clearTimeout(timeoutRef.current);
window.electronAPI?.hudOverlaySetIgnoreMouse?.(false);
}, []);
setHudMouseInteractive(true);
}, [setHudMouseInteractive]);
const handleHudMouseLeave = useCallback(
(event: MouseEvent<HTMLDivElement>) => {
@@ -126,6 +126,30 @@ export default function TimelineWrapper({
[formatTooltipMs],
);
const resolveDragPreviewSpan = useCallback(
(event: DragMoveEvent, span: Span): Span | null => {
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 ?? null;
},
[allRegionSpans, hasOverlap, minItemDurationMs, resolveTargetRowId, totalMs],
);
const onDragStart = useCallback(
(event: DragStartEvent) => {
const span = event.active.data.current.getSpanFromDragEvent?.(event);
@@ -137,17 +161,22 @@ 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);
} else {
showTooltip(null);
}
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 +239,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}
>
<div className="relative h-full min-h-0">
@@ -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");
});
});
@@ -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<DndEngineConfig, "totalMs" | "minItemDurationMs">): Span {
export function clampSpanToBounds(
span: Span,
config: Pick<DndEngineConfig, "totalMs" | "minItemDurationMs">,
): 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<DndEngineConfig, "tot
return { start, end: start + duration };
}
export function clampRange(candidate: Range, config: Pick<DndEngineConfig, "totalMs" | "minVisibleRangeMs">): Range {
export function clampRange(
candidate: Range,
config: Pick<DndEngineConfig, "totalMs" | "minVisibleRangeMs">,
): 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<DndEngineConfig, "tota
return { start: finalStart, end: finalEnd };
}
export function getSiblingSpans(activeItemId: string, rowId: string | undefined, allRegionSpans: TimelineRegionSpan[]) {
export function getSiblingSpans(
activeItemId: string,
rowId: string | undefined,
allRegionSpans: TimelineRegionSpan[],
) {
const activeItem = allRegionSpans.find((region) => 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<DndEngineConfig, "allRegionSpans" | "minItemDurationMs" | "totalMs">): Span {
export function clampResizedSpanToNeighbours(
span: Span,
activeItemId: string,
config: Pick<DndEngineConfig, "allRegionSpans" | "minItemDurationMs" | "totalMs">,
): 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<DndEngineConfig, "allRegionSpans" | "minItemDurationMs" | "totalMs">): 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<DndEngineConfig, "allRegionSpans" | "minItemDurationMs" | "totalMs">,
): 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<DndEngineConfig, "totalMs" | "minItemDurationMs" | "allRegionSpans" | "hasOverlap">): 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<DndEngineConfig, "allRegionSpans" | "totalMs" | "minItemDurationMs" | "hasOverlap">,
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;