diff --git a/electron/main.ts b/electron/main.ts index b403c6c5..68829e43 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -232,7 +232,7 @@ function showHudOverlayFromTray() { if (process.platform === "win32" && isHudOverlayMousePassthroughSupported()) { hud.showInactive(); hud.moveTop(); - reassertHudOverlayMouseState(); + reassertHudOverlayMouseState({ interactiveGraceMs: 1200 }); return true; } diff --git a/electron/windows.ts b/electron/windows.ts index 486f6d82..6a5abf67 100644 --- a/electron/windows.ts +++ b/electron/windows.ts @@ -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 { diff --git a/src/components/launch/hooks/useLaunchHudInteractionState.ts b/src/components/launch/hooks/useLaunchHudInteractionState.ts index 9d0160a7..d7c086b1 100644 --- a/src/components/launch/hooks/useLaunchHudInteractionState.ts +++ b/src/components/launch/hooks/useLaunchHudInteractionState.ts @@ -13,6 +13,23 @@ export function useLaunchHudInteractionState({ }) { const isMouseOverHudRef = useRef(false); const timeoutRef = useRef(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) => { diff --git a/src/components/video-editor/timeline/components/wrapper/TimelineWrapper.tsx b/src/components/video-editor/timeline/components/wrapper/TimelineWrapper.tsx index 58e55c3a..4e09f74e 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 | 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} >
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;