Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion src/components/video-editor/timeline/TimelineEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1393,14 +1393,31 @@ export default function TimelineEditor({
return [...zooms, ...trims, ...annotations, ...blurs, ...speeds];
}, [zoomRegions, trimRegions, annotationRegions, blurRegions, speedRegions, t]);

// Flat list of all non-annotation region spans for neighbour-clamping during drag/resize
// Spans that participate in overlap resolution (clampToNeighbours).
// Excludes annotation/blur deliberately — those are allowed to overlap and
// must NOT act as hard constraints when a zoom/trim/speed drag is being
// resolved.
const allRegionSpans = useMemo(() => {
const zooms = zoomRegions.map((r) => ({ id: r.id, start: r.startMs, end: r.endMs }));
const trims = trimRegions.map((r) => ({ id: r.id, start: r.startMs, end: r.endMs }));
const speeds = speedRegions.map((r) => ({ id: r.id, start: r.startMs, end: r.endMs }));
return [...zooms, ...trims, ...speeds];
}, [zoomRegions, trimRegions, speedRegions]);

// Additional snap targets that are NOT clamping constraints. Their edges
// pull during snap, but they don't push anyone away.
const softSnapSpans = useMemo(() => {
const annotations = annotationRegions.map((r) => ({
id: r.id,
start: r.startMs,
end: r.endMs,
}));
const blurs = blurRegions.map((r) => ({ id: r.id, start: r.startMs, end: r.endMs }));
return [...annotations, ...blurs];
}, [annotationRegions, blurRegions]);

const keyframeTimesMs = useMemo(() => keyframes.map((kf) => kf.time), [keyframes]);

const handleItemSpanChange = useCallback(
(id: string, span: Span) => {
// Check if it's a zoom, trim, speed, or annotation item
Expand Down Expand Up @@ -1571,6 +1588,9 @@ export default function TimelineEditor({
minVisibleRangeMs={timelineScale.minVisibleRangeMs}
onItemSpanChange={handleItemSpanChange}
allRegionSpans={allRegionSpans}
softSnapSpans={softSnapSpans}
currentTimeMs={currentTimeMs}
keyframeTimesMs={keyframeTimesMs}
>
<KeyframeMarkers
keyframes={keyframes}
Expand Down
234 changes: 221 additions & 13 deletions src/components/video-editor/timeline/TimelineWrapper.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@ import type {
ResizeMoveEvent,
Span,
} from "dnd-timeline";
import { TimelineContext } from "dnd-timeline";
import { TimelineContext, useTimelineContext } from "dnd-timeline";
import type { Dispatch, ReactNode, SetStateAction } from "react";
import { useCallback, useRef } from "react";
import { forwardRef, useCallback, useImperativeHandle, useRef } from "react";

interface TimelineWrapperProps {
children: ReactNode;
Expand All @@ -21,9 +21,58 @@ interface TimelineWrapperProps {
minVisibleRangeMs: number;
gridSizeMs?: number;
onItemSpanChange: (id: string, span: Span) => void;
// Spans that act as hard overlap constraints (zoom/trim/speed). Used by
// clampToNeighbours AND as snap targets.
allRegionSpans?: { id: string; start: number; end: number }[];
// Spans that act ONLY as snap targets (annotation/blur). They never push
// other items away during overlap resolution.
softSnapSpans?: { id: string; start: number; end: number }[];
currentTimeMs?: number;
keyframeTimesMs?: number[];
}

interface SnapGuideHandle {
showAt: (timeMs: number) => void;
hide: () => void;
}

// Lives inside TimelineContext so it can read valueToPixels. Updates DOM
// directly via an imperative handle — same pattern as the drag tooltip — to
// avoid re-rendering the timeline on every pointer move.
const SnapGuide = forwardRef<SnapGuideHandle>((_, ref) => {
const { sidebarWidth, direction, range, valueToPixels } = useTimelineContext();
const elRef = useRef<HTMLDivElement>(null);
const sideProperty = direction === "rtl" ? "right" : "left";

useImperativeHandle(
ref,
() => ({
showAt(timeMs: number) {
const el = elRef.current;
if (!el) return;
const offset = valueToPixels(timeMs - range.start) + sidebarWidth;
el.style[sideProperty] = `${offset}px`;
el.style.opacity = "1";
},
hide() {
const el = elRef.current;
if (!el) return;
el.style.opacity = "0";
},
}),
[range.start, sidebarWidth, sideProperty, valueToPixels],
);

return (
<div
ref={elRef}
className="absolute top-0 bottom-0 w-[1px] bg-[#fbbf24] shadow-[0_0_6px_rgba(251,191,36,0.6)] pointer-events-none z-[55]"
style={{ opacity: 0, transition: "opacity 0.08s" }}
/>
);
});
SnapGuide.displayName = "SnapGuide";

export default function TimelineWrapper({
children,
range,
Expand All @@ -35,6 +84,9 @@ export default function TimelineWrapper({
gridSizeMs: _gridSizeMs,
onItemSpanChange,
allRegionSpans = [],
softSnapSpans = [],
currentTimeMs,
keyframeTimesMs = [],
}: TimelineWrapperProps) {
const totalMs = Math.max(0, Math.round(videoDuration * 1000));

Expand Down Expand Up @@ -127,6 +179,138 @@ export default function TimelineWrapper({
[allRegionSpans, minItemDurationMs, totalMs],
);

const snapGuideRef = useRef<SnapGuideHandle>(null);

// Pull the active span's edges to nearby region boundaries, timeline bounds,
// the playhead, and keyframes. Threshold scales with zoom (~1% of visible
// range, min 50ms) so snap feels right at any zoom level.
// Returns the snapped span plus the actual snap target used (for guide rendering).
const snapSpanToTargets = useCallback(
(
span: Span,
activeItemId: string,
mode: "drag" | "resize-left" | "resize-right",
): { span: Span; snapPoint: number | null } => {
if (totalMs === 0) return { span, snapPoint: null };

const visibleMs = Math.max(range.end - range.start, 1);
const thresholdMs = Math.max(50, Math.round(visibleMs / 100));

const targetSet = new Set<number>();
targetSet.add(0);
targetSet.add(totalMs);
for (const r of allRegionSpans) {
if (r.id === activeItemId) continue;
targetSet.add(r.start);
targetSet.add(r.end);
}
for (const r of softSnapSpans) {
if (r.id === activeItemId) continue;
targetSet.add(r.start);
targetSet.add(r.end);
}
if (currentTimeMs !== undefined) targetSet.add(currentTimeMs);
for (const kf of keyframeTimesMs) targetSet.add(kf);
const targets = Array.from(targetSet);

const findNearest = (value: number): number | null => {
let best: number | null = null;
let bestDistance = thresholdMs;
for (const target of targets) {
const distance = Math.abs(target - value);
if (distance <= bestDistance) {
best = target;
bestDistance = distance;
}
}
return best;
};

if (mode === "resize-left") {
const snap = findNearest(span.start);
if (snap === null || span.end - snap < minItemDurationMs) {
return { span, snapPoint: null };
}
return { span: { start: snap, end: span.end }, snapPoint: snap };
}

if (mode === "resize-right") {
const snap = findNearest(span.end);
if (snap === null || snap - span.start < minItemDurationMs) {
return { span, snapPoint: null };
}
return { span: { start: span.start, end: snap }, snapPoint: snap };
}

// Drag: preserve duration; snap whichever edge is closer to a target.
const startSnap = findNearest(span.start);
const endSnap = findNearest(span.end);
const startDelta = startSnap !== null ? Math.abs(startSnap - span.start) : Infinity;
const endDelta = endSnap !== null ? Math.abs(endSnap - span.end) : Infinity;

if (startDelta === Infinity && endDelta === Infinity) {
return { span, snapPoint: null };
}

const duration = span.end - span.start;
if (startDelta <= endDelta && startSnap !== null) {
return {
span: { start: startSnap, end: startSnap + duration },
snapPoint: startSnap,
};
}
if (endSnap !== null) {
return {
span: { start: endSnap - duration, end: endSnap },
snapPoint: endSnap,
};
}
return { span, snapPoint: null };
},
[
allRegionSpans,
softSnapSpans,
currentTimeMs,
keyframeTimesMs,
minItemDurationMs,
range.end,
range.start,
totalMs,
],
);

// dnd-timeline's resize event doesn't expose direction. Compare the live
// span to the committed one (committed spans only update on commit, so
// during a single resize they still reflect the pre-resize state).
const inferResizeMode = useCallback(
(activeItemId: string, span: Span): "resize-left" | "resize-right" => {
const old =
allRegionSpans.find((r) => r.id === activeItemId) ??
softSnapSpans.find((r) => r.id === activeItemId);
if (!old) return "resize-right";
const startDelta = Math.abs(old.start - span.start);
const endDelta = Math.abs(old.end - span.end);
return startDelta >= endDelta ? "resize-left" : "resize-right";
},
[allRegionSpans, softSnapSpans],
);

const updateSnapGuide = useCallback(
(snapPoint: number | null) => {
if (snapPoint === null) {
snapGuideRef.current?.hide();
return;
}
// Hide the amber guide when it would coincide with the green playhead.
if (currentTimeMs !== undefined && Math.abs(snapPoint - currentTimeMs) < 1) {
snapGuideRef.current?.hide();
return;
}
snapGuideRef.current?.showAt(snapPoint);
},
[currentTimeMs],
);

const onResizeEnd = useCallback(
(event: ResizeEndEvent) => {
const updatedSpan = event.active.data.current.getSpanFromResizeEvent?.(event);
Expand All @@ -135,6 +319,9 @@ export default function TimelineWrapper({
const activeItemId = event.active.id as string;
let clampedSpan = clampSpanToBounds(updatedSpan);

const mode = inferResizeMode(activeItemId, clampedSpan);
clampedSpan = snapSpanToTargets(clampedSpan, activeItemId, mode).span;

const effectiveMinDuration =
totalMs > 0 ? Math.min(minItemDurationMs, totalMs) : minItemDurationMs;
if (clampedSpan.end - clampedSpan.start < effectiveMinDuration) {
Expand All @@ -156,8 +343,10 @@ export default function TimelineWrapper({
clampSpanToBounds,
clampToNeighbours,
hasOverlap,
inferResizeMode,
minItemDurationMs,
onItemSpanChange,
snapSpanToTargets,
totalMs,
],
);
Expand All @@ -171,6 +360,8 @@ export default function TimelineWrapper({
const activeItemId = event.active.id as string;
let clampedSpan = clampSpanToBounds(updatedSpan);

clampedSpan = snapSpanToTargets(clampedSpan, activeItemId, "drag").span;

// Clamp to neighbour boundaries instead of rejecting
if (hasOverlap(clampedSpan, activeItemId)) {
clampedSpan = clampToNeighbours(clampedSpan, activeItemId);
Expand All @@ -181,7 +372,7 @@ export default function TimelineWrapper({

onItemSpanChange(activeItemId, clampedSpan);
},
[clampSpanToBounds, clampToNeighbours, hasOverlap, onItemSpanChange],
[clampSpanToBounds, clampToNeighbours, hasOverlap, onItemSpanChange, snapSpanToTargets],
);

// Drag/resize tooltip (direct DOM updates, no re-renders)
Expand Down Expand Up @@ -226,44 +417,60 @@ export default function TimelineWrapper({

const onDragMove = useCallback(
(event: DragMoveEvent) => {
const span = event.active.data.current.getSpanFromDragEvent?.(event);
const rawSpan = event.active.data.current.getSpanFromDragEvent?.(event);
if (!rawSpan) return;
const activeItemId = event.active.id as string;
const clamped = totalMs > 0 ? clampSpanToBounds(rawSpan) : rawSpan;
const { span, snapPoint } = snapSpanToTargets(clamped, activeItemId, "drag");
updateSnapGuide(snapPoint);
const screenX =
event.activatorEvent && "clientX" in event.activatorEvent
? (event.activatorEvent as PointerEvent).clientX + (event.delta?.x ?? 0)
: undefined;
if (span) showTooltip(span, screenX);
showTooltip(span, screenX);
},
[showTooltip],
[clampSpanToBounds, showTooltip, snapSpanToTargets, totalMs, updateSnapGuide],
);

const onResizeMove = useCallback(
(event: ResizeMoveEvent) => {
const span = event.active.data.current.getSpanFromResizeEvent?.(event);
const rawSpan = event.active.data.current.getSpanFromResizeEvent?.(event);
if (!rawSpan) return;
const activeItemId = event.active.id as string;
const clamped = totalMs > 0 ? clampSpanToBounds(rawSpan) : rawSpan;
const mode = inferResizeMode(activeItemId, clamped);
const { span, snapPoint } = snapSpanToTargets(clamped, activeItemId, mode);
updateSnapGuide(snapPoint);
const screenX =
event.activatorEvent && "clientX" in event.activatorEvent
? (event.activatorEvent as PointerEvent).clientX + (event.delta?.x ?? 0)
: undefined;
if (span) showTooltip(span, screenX);
showTooltip(span, screenX);
},
[showTooltip],
[clampSpanToBounds, inferResizeMode, showTooltip, snapSpanToTargets, totalMs, updateSnapGuide],
);

const hideTooltip = useCallback(() => showTooltip(null), [showTooltip]);

const hideOverlays = useCallback(() => {
hideTooltip();
snapGuideRef.current?.hide();
}, [hideTooltip]);

const onResizeEndWithTooltip = useCallback(
(event: ResizeEndEvent) => {
hideTooltip();
hideOverlays();
onResizeEnd(event);
},
[hideTooltip, onResizeEnd],
[hideOverlays, onResizeEnd],
);

const onDragEndWithTooltip = useCallback(
(event: DragEndEvent) => {
hideTooltip();
hideOverlays();
onDragEnd(event);
},
[hideTooltip, onDragEnd],
[hideOverlays, onDragEnd],
);

const handleRangeChange = useCallback(
Expand Down Expand Up @@ -305,6 +512,7 @@ export default function TimelineWrapper({
>
<div className="relative">
{children}
<SnapGuide ref={snapGuideRef} />
{/* Floating tooltip shown during drag/resize */}
<div
ref={tooltipRef}
Expand Down
Loading