mirror of
https://github.com/siddharthvaddem/openscreen.git
synced 2026-08-31 02:04:58 +08:00
Merge pull request #515 from AbhinRustagi/feature/timeline-handle-snap
Add Timeline Snap Guides
This commit is contained in:
@@ -1517,7 +1517,10 @@ 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 }));
|
||||
@@ -1525,6 +1528,20 @@ export default function TimelineEditor({
|
||||
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
|
||||
@@ -1699,6 +1716,9 @@ export default function TimelineEditor({
|
||||
minVisibleRangeMs={timelineScale.minVisibleRangeMs}
|
||||
onItemSpanChange={handleItemSpanChange}
|
||||
allRegionSpans={allRegionSpans}
|
||||
softSnapSpans={softSnapSpans}
|
||||
currentTimeMs={currentTimeMs}
|
||||
keyframeTimesMs={keyframeTimesMs}
|
||||
>
|
||||
<KeyframeMarkers
|
||||
keyframes={keyframes}
|
||||
|
||||
@@ -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;
|
||||
@@ -21,9 +21,75 @@ 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-[2px] bg-[#fbbf24] shadow-[0_0_10px_rgba(251,191,36,0.85),0_0_2px_rgba(251,191,36,1)] pointer-events-none z-[55]"
|
||||
style={{ opacity: 0, transition: "opacity 0.08s" }}
|
||||
>
|
||||
<div
|
||||
className="absolute -top-[1px] left-1/2 -translate-x-1/2 w-0 h-0"
|
||||
style={{
|
||||
borderLeft: "4px solid transparent",
|
||||
borderRight: "4px solid transparent",
|
||||
borderTop: "6px solid #fbbf24",
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="absolute -bottom-[1px] left-1/2 -translate-x-1/2 w-0 h-0"
|
||||
style={{
|
||||
borderLeft: "4px solid transparent",
|
||||
borderRight: "4px solid transparent",
|
||||
borderBottom: "6px solid #fbbf24",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
SnapGuide.displayName = "SnapGuide";
|
||||
|
||||
export default function TimelineWrapper({
|
||||
children,
|
||||
range,
|
||||
@@ -35,6 +101,9 @@ export default function TimelineWrapper({
|
||||
gridSizeMs: _gridSizeMs,
|
||||
onItemSpanChange,
|
||||
allRegionSpans = [],
|
||||
softSnapSpans = [],
|
||||
currentTimeMs,
|
||||
keyframeTimesMs = [],
|
||||
}: TimelineWrapperProps) {
|
||||
const totalMs = Math.max(0, Math.round(videoDuration * 1000));
|
||||
|
||||
@@ -127,6 +196,142 @@ 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).
|
||||
// Returns null when the deltas are equal — including the common clamped
|
||||
// case where both are 0 — because we can't tell which handle the user
|
||||
// grabbed, and guessing wrong would snap the other edge.
|
||||
const inferResizeMode = useCallback(
|
||||
(activeItemId: string, span: Span): "resize-left" | "resize-right" | null => {
|
||||
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);
|
||||
if (startDelta === endDelta) return null;
|
||||
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);
|
||||
@@ -135,6 +340,11 @@ export default function TimelineWrapper({
|
||||
const activeItemId = event.active.id as string;
|
||||
let clampedSpan = clampSpanToBounds(updatedSpan);
|
||||
|
||||
const mode = inferResizeMode(activeItemId, clampedSpan);
|
||||
if (mode !== null) {
|
||||
clampedSpan = snapSpanToTargets(clampedSpan, activeItemId, mode).span;
|
||||
}
|
||||
|
||||
const effectiveMinDuration =
|
||||
totalMs > 0 ? Math.min(minItemDurationMs, totalMs) : minItemDurationMs;
|
||||
if (clampedSpan.end - clampedSpan.start < effectiveMinDuration) {
|
||||
@@ -156,8 +366,10 @@ export default function TimelineWrapper({
|
||||
clampSpanToBounds,
|
||||
clampToNeighbours,
|
||||
hasOverlap,
|
||||
inferResizeMode,
|
||||
minItemDurationMs,
|
||||
onItemSpanChange,
|
||||
snapSpanToTargets,
|
||||
totalMs,
|
||||
],
|
||||
);
|
||||
@@ -171,6 +383,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);
|
||||
@@ -181,7 +395,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)
|
||||
@@ -226,44 +440,63 @@ 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 } =
|
||||
mode !== null
|
||||
? snapSpanToTargets(clamped, activeItemId, mode)
|
||||
: { span: clamped, snapPoint: null };
|
||||
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(
|
||||
@@ -305,6 +538,7 @@ export default function TimelineWrapper({
|
||||
>
|
||||
<div className="relative">
|
||||
{children}
|
||||
<SnapGuide ref={snapGuideRef} />
|
||||
{/* Floating tooltip shown during drag/resize */}
|
||||
<div
|
||||
ref={tooltipRef}
|
||||
|
||||
Reference in New Issue
Block a user