mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-09-24 06:46:09 +00:00
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:
+1
-1
@@ -232,7 +232,7 @@ function showHudOverlayFromTray() {
|
||||
if (process.platform === "win32" && isHudOverlayMousePassthroughSupported()) {
|
||||
hud.showInactive();
|
||||
hud.moveTop();
|
||||
reassertHudOverlayMouseState();
|
||||
reassertHudOverlayMouseState({ interactiveGraceMs: 1200 });
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
+6
-2
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user