mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-10 22:20:14 +00:00
Merge pull request #2068 from heygen-com/worktree-fix-timeline-zindex-reorder
feat(studio): lane-model timeline — vertical drag restacks via z-index
This commit is contained in:
@@ -28,13 +28,17 @@ export const BeatBackgroundLines = memo(function BeatBackgroundLines({
|
||||
beatTimes: number[] | undefined;
|
||||
beatStrengths: number[] | undefined;
|
||||
pps: number;
|
||||
/** Beat time a dragged clip will snap to — drawn as a bright neon line. */
|
||||
/** Snap guide time — drawn as a bright line even when it is not a beat. */
|
||||
highlightTime?: number | null;
|
||||
}) {
|
||||
if (!beatTimes || beatsTooDense(beatTimes, pps)) return null;
|
||||
const visibleBeatTimes = beatTimes && !beatsTooDense(beatTimes, pps) ? beatTimes : null;
|
||||
const highlightIsBeat =
|
||||
highlightTime != null &&
|
||||
visibleBeatTimes?.some((t) => Math.abs(t - highlightTime) < 1e-3) === true;
|
||||
if (!visibleBeatTimes && highlightTime == null) return null;
|
||||
return (
|
||||
<div className="absolute inset-0 pointer-events-none" style={{ zIndex: 0 }}>
|
||||
{beatTimes.map((t, i) => {
|
||||
{visibleBeatTimes?.map((t, i) => {
|
||||
const isHighlight = highlightTime != null && Math.abs(t - highlightTime) < 1e-3;
|
||||
const strength = Math.pow(Math.min(1, beatStrengths?.[i] ?? 0.5), 2.2);
|
||||
const opacity = isHighlight ? 1 : 0.06 + strength * 0.16;
|
||||
@@ -52,6 +56,18 @@ export const BeatBackgroundLines = memo(function BeatBackgroundLines({
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{highlightTime != null && !highlightIsBeat && (
|
||||
<div
|
||||
className="absolute top-0 bottom-0"
|
||||
style={{
|
||||
left: highlightTime * pps,
|
||||
width: 2,
|
||||
background: "rgba(34,197,94,1)",
|
||||
boxShadow: "0 0 6px rgba(34,197,94,0.9)",
|
||||
zIndex: 1,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -117,6 +117,10 @@ describe("Timeline provider boundary", () => {
|
||||
);
|
||||
});
|
||||
|
||||
// Flush passive effects (ResizeObserver-driven layout) so the gutter row is
|
||||
// mounted before we query it.
|
||||
act(() => {});
|
||||
|
||||
const button = host.querySelector<HTMLButtonElement>('button[aria-label="Show track 0"]');
|
||||
expect(button).not.toBeNull();
|
||||
if (!button) throw new Error("Expected a track visibility toggle");
|
||||
|
||||
@@ -23,11 +23,15 @@ import {
|
||||
import { useTimelineClipDrag } from "./useTimelineClipDrag";
|
||||
import { ClipContextMenu } from "./ClipContextMenu";
|
||||
import { TimelineShortcutHint } from "./TimelineShortcutHint";
|
||||
import { buildStackingTimelineLayers, insertPreviewTrackOrder } from "./timelineTrackOrder";
|
||||
import { getTimelineLayerGroupHeaderTotalHeight } from "./TimelineLayerGroupHeader";
|
||||
import {
|
||||
GUTTER,
|
||||
generateTicks,
|
||||
generateVisibleTicks,
|
||||
getTimelineCanvasHeight,
|
||||
shouldShowTimelineShortcutHint,
|
||||
computeTimelineBasisDuration,
|
||||
computeTimelineEffectiveDuration,
|
||||
} from "./timelineLayout";
|
||||
import { useResolvedTimelineEditCallbacks } from "./useResolvedTimelineEditCallbacks";
|
||||
import type { TimelineProps } from "./TimelineTypes";
|
||||
@@ -178,39 +182,29 @@ export const Timeline = memo(function Timeline({
|
||||
if (shortcutHintRafRef.current) cancelAnimationFrame(shortcutHintRafRef.current);
|
||||
});
|
||||
|
||||
const effectiveDuration = useMemo(() => {
|
||||
const safeDur = Number.isFinite(duration) ? duration : 0;
|
||||
if (rawElements.length === 0) return safeDur;
|
||||
const maxEnd = Math.max(...rawElements.map((el) => el.start + el.duration));
|
||||
const result = Math.max(safeDur, maxEnd);
|
||||
return Number.isFinite(result) ? result : safeDur;
|
||||
}, [rawElements, duration]);
|
||||
|
||||
const tracks = useMemo(() => {
|
||||
const map = new Map<number, typeof expandedElements>();
|
||||
for (const el of expandedElements) {
|
||||
const list = map.get(el.track) ?? [];
|
||||
list.push(el);
|
||||
map.set(el.track, list);
|
||||
}
|
||||
return Array.from(map.entries()).sort(([a], [b]) => a - b);
|
||||
}, [expandedElements]);
|
||||
const tracks = useMemo(
|
||||
() => buildStackingTimelineLayers(expandedElements).rows,
|
||||
[expandedElements],
|
||||
);
|
||||
|
||||
const trackStyles = useMemo(() => {
|
||||
const map = new Map<number, TrackVisualStyle>();
|
||||
for (const [trackNum, els] of tracks) {
|
||||
map.set(trackNum, getTrackStyle(els[0]?.tag ?? ""));
|
||||
const map = new Map<string, TrackVisualStyle>();
|
||||
for (const layer of tracks) {
|
||||
map.set(layer.id, getTrackStyle(layer.elements[0]?.tag ?? ""));
|
||||
}
|
||||
return map;
|
||||
}, [tracks]);
|
||||
|
||||
const trackOrder = useMemo(() => tracks.map(([trackNum]) => trackNum), [tracks]);
|
||||
const trackOrder = useMemo(() => tracks.map((layer) => layer.id), [tracks]);
|
||||
const trackOrderRef = useRef(trackOrder);
|
||||
trackOrderRef.current = trackOrder;
|
||||
const timelineLayersRef = useRef(tracks);
|
||||
timelineLayersRef.current = tracks;
|
||||
const expandedElementsRef = useRef(expandedElements);
|
||||
expandedElementsRef.current = expandedElements;
|
||||
|
||||
const ppsRef = useRef(100);
|
||||
const durationRef = useRef(effectiveDuration);
|
||||
durationRef.current = effectiveDuration;
|
||||
const durationRef = useRef(Number.isFinite(duration) ? duration : 0);
|
||||
|
||||
// Stable ref so useTimelineClipDrag can clear rangeSelection without circular dep
|
||||
const setRangeSelectionRef = useRef<((sel: null) => void) | null>(null);
|
||||
@@ -226,8 +220,9 @@ export const Timeline = memo(function Timeline({
|
||||
} = useTimelineClipDrag({
|
||||
scrollRef,
|
||||
ppsRef,
|
||||
durationRef,
|
||||
trackOrderRef,
|
||||
timelineLayersRef,
|
||||
timelineElementsRef: expandedElementsRef,
|
||||
onMoveElement,
|
||||
onResizeElement,
|
||||
onBlockedEditAttempt,
|
||||
@@ -235,17 +230,42 @@ export const Timeline = memo(function Timeline({
|
||||
setRangeSelectionRef,
|
||||
});
|
||||
|
||||
// basis drives the zoom (committed); effective adds the live preview (see timelineLayout).
|
||||
const basisDuration = useMemo(
|
||||
() =>
|
||||
computeTimelineBasisDuration(
|
||||
duration,
|
||||
rawElements.map((el) => el.start + el.duration),
|
||||
),
|
||||
[rawElements, duration],
|
||||
);
|
||||
const effectiveDuration = useMemo(
|
||||
() =>
|
||||
computeTimelineEffectiveDuration(basisDuration, [
|
||||
draggedClip?.started ? draggedClip.previewStart + draggedClip.element.duration : null,
|
||||
resizingClip?.started ? resizingClip.previewStart + resizingClip.previewDuration : null,
|
||||
]),
|
||||
[basisDuration, draggedClip, resizingClip],
|
||||
);
|
||||
durationRef.current = effectiveDuration;
|
||||
|
||||
const displayTrackOrder = useMemo(() => {
|
||||
if (
|
||||
!draggedClip?.started ||
|
||||
trackOrder.length === 0 ||
|
||||
trackOrder.includes(draggedClip.previewTrack)
|
||||
trackOrder.includes(draggedClip.previewLayerId)
|
||||
)
|
||||
return trackOrder;
|
||||
return [...trackOrder, draggedClip.previewTrack].sort((a, b) => a - b);
|
||||
return insertPreviewTrackOrder(
|
||||
trackOrder,
|
||||
draggedClip.previewLayerId,
|
||||
draggedClip.previewLayerIndex,
|
||||
);
|
||||
}, [draggedClip, trackOrder]);
|
||||
|
||||
const totalH = getTimelineCanvasHeight(displayTrackOrder.length);
|
||||
const totalH =
|
||||
getTimelineCanvasHeight(displayTrackOrder.length) +
|
||||
getTimelineLayerGroupHeaderTotalHeight(displayTrackOrder, tracks);
|
||||
const keyframeCache = usePlayerStore((s) => s.keyframeCache);
|
||||
const selectedKeyframes = usePlayerStore((s) => s.selectedKeyframes);
|
||||
const toggleSelectedKeyframe = usePlayerStore((s) => s.toggleSelectedKeyframe);
|
||||
@@ -258,9 +278,10 @@ export const Timeline = memo(function Timeline({
|
||||
const selectedElementRef = useRef<TimelineElement | null>(selectedElement);
|
||||
selectedElementRef.current = selectedElement;
|
||||
|
||||
// Fit to basisDuration, not effectiveDuration, so a live drag can't rezoom.
|
||||
const fitPps =
|
||||
viewportWidth > GUTTER && effectiveDuration > 0
|
||||
? (viewportWidth - GUTTER - 2) / effectiveDuration
|
||||
viewportWidth > GUTTER && basisDuration > 0
|
||||
? (viewportWidth - GUTTER - 2) / basisDuration
|
||||
: 100;
|
||||
const pps = getTimelinePixelsPerSecond(fitPps, zoomMode, manualZoomPercent);
|
||||
ppsRef.current = pps;
|
||||
@@ -341,8 +362,8 @@ export const Timeline = memo(function Timeline({
|
||||
});
|
||||
|
||||
const { major, minor } = useMemo(
|
||||
() => generateTicks(effectiveDuration, pps),
|
||||
[effectiveDuration, pps],
|
||||
() => generateVisibleTicks(effectiveDuration, pps, viewportWidth, GUTTER),
|
||||
[effectiveDuration, pps, viewportWidth],
|
||||
);
|
||||
const majorTickInterval = major.length >= 2 ? major[1] - major[0] : effectiveDuration;
|
||||
|
||||
@@ -373,6 +394,7 @@ export const Timeline = memo(function Timeline({
|
||||
ppsRef,
|
||||
durationRef,
|
||||
trackOrderRef,
|
||||
timelineLayersRef,
|
||||
onFileDrop,
|
||||
onAssetDrop,
|
||||
onBlockDrop,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { memo, type ReactNode } from "react";
|
||||
import { Eye, EyeSlash } from "@phosphor-icons/react";
|
||||
import { Fragment, memo, type ReactNode } from "react";
|
||||
import { BeatStrip, BeatBackgroundLines } from "./BeatStrip";
|
||||
import { TimelineClip } from "./TimelineClip";
|
||||
import { TimelineClipDiamonds } from "./TimelineClipDiamonds";
|
||||
@@ -20,10 +19,19 @@ import {
|
||||
} from "../store/playerStore";
|
||||
import type { DraggedClipState, ResizingClipState, BlockedClipState } from "./useTimelineClipDrag";
|
||||
import type { TrackVisualStyle } from "./timelineIcons";
|
||||
import type { StackingTimelineLayer, TimelineLayerId } from "./timelineTrackOrder";
|
||||
import { STUDIO_KEYFRAMES_ENABLED } from "../../components/editor/manualEditingAvailability";
|
||||
import { SPLIT_BOUNDARY_EPSILON_S } from "../../utils/timelineElementSplit";
|
||||
import { useTimelineEditContextOptional } from "../../contexts/TimelineEditContext";
|
||||
import { isMusicTrack } from "../../utils/timelineInspector";
|
||||
import { TimelineLayerGutter } from "./TimelineLayerGutter";
|
||||
import {
|
||||
shouldShowTimelineLayerGroupHeader,
|
||||
TimelineLayerGroupHeader,
|
||||
} from "./TimelineLayerGroupHeader";
|
||||
import { resolveTimelineDropIndicator } from "./timelineDropIndicator";
|
||||
import { TimelineDropInsertionLine } from "./TimelineDropInsertionLine";
|
||||
import { TimelineDragGhost } from "./TimelineDragGhost";
|
||||
|
||||
function ClipLintDot({ element }: { element: TimelineElement }) {
|
||||
const lint = usePlayerStore((s) => s.lintFindingsByElement.get(element.key ?? element.id));
|
||||
@@ -47,10 +55,10 @@ interface TimelineCanvasProps {
|
||||
majorTickInterval: number;
|
||||
rangeSelection: TimelineRangeSelection | null;
|
||||
theme: TimelineTheme;
|
||||
displayTrackOrder: number[];
|
||||
trackOrder: number[];
|
||||
tracks: [number, TimelineElement[]][];
|
||||
trackStyles: Map<number, TrackVisualStyle>;
|
||||
displayTrackOrder: TimelineLayerId[];
|
||||
trackOrder: TimelineLayerId[];
|
||||
tracks: StackingTimelineLayer[];
|
||||
trackStyles: Map<TimelineLayerId, TrackVisualStyle>;
|
||||
selectedElementId: string | null;
|
||||
hoveredClip: string | null;
|
||||
draggedClip: DraggedClipState | null;
|
||||
@@ -112,7 +120,7 @@ export const TimelineCanvas = memo(function TimelineCanvas({
|
||||
selectedElementId,
|
||||
hoveredClip,
|
||||
draggedClip,
|
||||
resizingClip: _resizingClip,
|
||||
resizingClip,
|
||||
blockedClipRef,
|
||||
suppressClickRef,
|
||||
scrollRef,
|
||||
@@ -141,9 +149,20 @@ export const TimelineCanvas = memo(function TimelineCanvas({
|
||||
onContextMenuClip,
|
||||
beatAnalysis,
|
||||
}: TimelineCanvasProps) {
|
||||
const { onResizeElement, onMoveElement, onToggleTrackHidden, onRazorSplit, onRazorSplitAll } =
|
||||
useTimelineEditContextOptional();
|
||||
const {
|
||||
onResizeElement,
|
||||
onMoveElement,
|
||||
onToggleTrackHidden,
|
||||
onToggleElementHidden,
|
||||
onRazorSplit,
|
||||
onRazorSplitAll,
|
||||
} = useTimelineEditContextOptional();
|
||||
const beatDragging = usePlayerStore((s) => s.beatDragging);
|
||||
const activeSnapGuideTime = draggedClip?.started
|
||||
? (draggedClip.snapBeatTime ?? draggedClip.snapGuideTime)
|
||||
: resizingClip?.started
|
||||
? resizingClip.snapGuideTime
|
||||
: null;
|
||||
const draggedElement = draggedClip?.element ?? null;
|
||||
const activeDraggedElement =
|
||||
draggedClip?.started === true && draggedElement
|
||||
@@ -181,9 +200,16 @@ export const TimelineCanvas = memo(function TimelineCanvas({
|
||||
)}
|
||||
</>
|
||||
);
|
||||
const activeDropPlacement =
|
||||
draggedClip?.started === true ? (draggedClip.previewStackingReorder?.placement ?? null) : null;
|
||||
|
||||
return (
|
||||
<div className="relative" style={{ height: totalH, width: GUTTER + trackContentWidth }}>
|
||||
// minWidth:100% makes the lanes and ruler fill the panel when the composition
|
||||
// is narrower than the viewport (zoomed out); content stays in time coords.
|
||||
<div
|
||||
className="relative"
|
||||
style={{ height: totalH, width: GUTTER + trackContentWidth, minWidth: "100%" }}
|
||||
>
|
||||
<TimelineRuler
|
||||
major={major}
|
||||
minor={minor}
|
||||
@@ -198,13 +224,35 @@ export const TimelineCanvas = memo(function TimelineCanvas({
|
||||
|
||||
{
|
||||
// fallow-ignore-next-line complexity
|
||||
displayTrackOrder.map((trackNum) => {
|
||||
const els = tracks.find(([t]) => t === trackNum)?.[1] ?? [];
|
||||
const ts = trackStyles.get(trackNum) ?? getTrackStyle("");
|
||||
displayTrackOrder.map((layerId, rowIndex) => {
|
||||
const layer = tracks.find((item) => item.id === layerId) ?? null;
|
||||
const previousLayerId = displayTrackOrder[rowIndex - 1];
|
||||
const previousLayer = previousLayerId
|
||||
? (tracks.find((item) => item.id === previousLayerId) ?? null)
|
||||
: null;
|
||||
const els = layer?.elements ?? [];
|
||||
const ts = trackStyles.get(layerId) ?? getTrackStyle("");
|
||||
const isAudioLayer = layer?.kind === "audio";
|
||||
const isFirstAudioLayer = isAudioLayer && previousLayer?.kind !== "audio";
|
||||
const isPendingTrack =
|
||||
draggedClip?.started === true && !trackOrder.includes(trackNum) && els.length === 0;
|
||||
draggedClip?.started === true && !trackOrder.includes(layerId) && els.length === 0;
|
||||
const baseRowBackground = rowIndex % 2 === 0 ? theme.rowBackground : "#0D0E12";
|
||||
const dropIndicator = resolveTimelineDropIndicator({
|
||||
placement: activeDropPlacement,
|
||||
layerId,
|
||||
layerOrder: displayTrackOrder,
|
||||
});
|
||||
const rowBackground =
|
||||
displayTrackOrder.indexOf(trackNum) % 2 === 0 ? theme.rowBackground : "#0D0E12";
|
||||
dropIndicator?.kind === "onto"
|
||||
? theme.clipBackgroundActive
|
||||
: isAudioLayer
|
||||
? `linear-gradient(90deg, ${theme.gutterBackground} 0, ${baseRowBackground} 74px)`
|
||||
: baseRowBackground;
|
||||
const showGroupHeader = shouldShowTimelineLayerGroupHeader(
|
||||
layer?.contextKey ?? "",
|
||||
previousLayer?.contextKey ?? "",
|
||||
);
|
||||
const rowTrack = layer?.placementTrack ?? els[0]?.track ?? 0;
|
||||
// The beat-dot strip occupies the top of this track's lane (active track,
|
||||
// or the music track when nothing is selected). When shown, keyframe
|
||||
// diamonds shrink + drop to the bottom half so they don't collide with it.
|
||||
@@ -215,300 +263,301 @@ export const TimelineCanvas = memo(function TimelineCanvas({
|
||||
: els.some(isMusicTrack));
|
||||
const isTrackHidden = els.length > 0 && els.every((element) => element.hidden === true);
|
||||
return (
|
||||
<div
|
||||
key={trackNum}
|
||||
className="relative flex"
|
||||
style={{
|
||||
height: TRACK_H,
|
||||
background: rowBackground,
|
||||
borderBottom: `1px solid ${theme.rowBorder}`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="sticky left-0 z-[12] flex-shrink-0 flex items-center justify-center"
|
||||
style={{
|
||||
width: GUTTER,
|
||||
background: theme.gutterBackground,
|
||||
borderRight: `1px solid ${theme.gutterBorder}`,
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={isTrackHidden ? `Show track ${trackNum}` : `Hide track ${trackNum}`}
|
||||
title={isTrackHidden ? `Show track ${trackNum}` : `Hide track ${trackNum}`}
|
||||
className={`flex h-6 w-6 items-center justify-center rounded border-0 bg-transparent p-0 transition-colors focus-visible:outline focus-visible:outline-1 focus-visible:outline-offset-[-1px] focus-visible:outline-[#3CE6AC] ${
|
||||
isTrackHidden
|
||||
? "text-[#3CE6AC] hover:text-white"
|
||||
: "text-white/35 hover:text-white/75"
|
||||
}`}
|
||||
onPointerDown={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
void onToggleTrackHidden?.(trackNum, !isTrackHidden);
|
||||
}}
|
||||
>
|
||||
{isTrackHidden ? (
|
||||
<EyeSlash size={14} weight="bold" aria-hidden="true" />
|
||||
) : (
|
||||
<Eye size={14} weight="bold" aria-hidden="true" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
width: trackContentWidth,
|
||||
opacity: isTrackHidden ? 0.35 : 1,
|
||||
transition: "opacity 120ms ease",
|
||||
}}
|
||||
className="relative"
|
||||
>
|
||||
{/* Faint beat lines in every track's background (behind the clips);
|
||||
the active move-snap target is highlighted. */}
|
||||
<BeatBackgroundLines
|
||||
beatTimes={beatAnalysis?.beatTimes}
|
||||
beatStrengths={beatAnalysis?.beatStrengths}
|
||||
pps={pps}
|
||||
highlightTime={draggedClip?.started ? draggedClip.snapBeatTime : null}
|
||||
<Fragment key={layerId}>
|
||||
{showGroupHeader && layer && (
|
||||
<TimelineLayerGroupHeader
|
||||
contextKey={layer.contextKey}
|
||||
trackContentWidth={trackContentWidth}
|
||||
theme={theme}
|
||||
accentColor={ts.accent}
|
||||
/>
|
||||
{/* Beat dots on the active track (the one holding the selection),
|
||||
falling back to the music track when nothing is selected. */}
|
||||
{beatStripOnTrack && (
|
||||
<BeatStrip
|
||||
)}
|
||||
<div
|
||||
className="relative flex"
|
||||
style={{
|
||||
height: TRACK_H,
|
||||
background: rowBackground,
|
||||
borderTop: isFirstAudioLayer ? `2px solid ${theme.rulerBorder}` : undefined,
|
||||
borderBottom: `1px solid ${theme.rowBorder}`,
|
||||
boxShadow:
|
||||
dropIndicator?.kind === "onto" ? `inset 0 0 0 1px ${ts.accent}` : undefined,
|
||||
}}
|
||||
>
|
||||
<TimelineLayerGutter
|
||||
isAudio={isAudioLayer}
|
||||
isTrackHidden={isTrackHidden}
|
||||
rowTrack={rowTrack}
|
||||
theme={theme}
|
||||
onToggleHidden={() => {
|
||||
if (onToggleElementHidden && els.length > 0) {
|
||||
for (const element of els) {
|
||||
void onToggleElementHidden(element.key ?? element.id, !isTrackHidden);
|
||||
}
|
||||
return;
|
||||
}
|
||||
void onToggleTrackHidden?.(rowTrack, !isTrackHidden);
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
width: trackContentWidth,
|
||||
opacity: isTrackHidden ? 0.35 : 1,
|
||||
transition: "opacity 120ms ease",
|
||||
}}
|
||||
className="relative"
|
||||
>
|
||||
{layer?.contextKey && (
|
||||
<span
|
||||
className="absolute bottom-0 top-0 pointer-events-none"
|
||||
style={{
|
||||
left: 0,
|
||||
width: 2,
|
||||
background: ts.accent,
|
||||
opacity: 0.45,
|
||||
zIndex: 2,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{dropIndicator?.kind === "line" && (
|
||||
<TimelineDropInsertionLine edge={dropIndicator.edge} accentColor={ts.accent} />
|
||||
)}
|
||||
{/* Faint beat lines in every track's background (behind the clips);
|
||||
the active snap target is highlighted. */}
|
||||
<BeatBackgroundLines
|
||||
beatTimes={beatAnalysis?.beatTimes}
|
||||
beatStrengths={beatAnalysis?.beatStrengths}
|
||||
pps={pps}
|
||||
highlightTime={activeSnapGuideTime}
|
||||
/>
|
||||
)}
|
||||
{isPendingTrack && (
|
||||
<div
|
||||
className="absolute inset-0 flex items-center"
|
||||
style={{
|
||||
paddingLeft: 16,
|
||||
color: ts.label,
|
||||
fontSize: 11,
|
||||
letterSpacing: "0.06em",
|
||||
textTransform: "uppercase",
|
||||
opacity: 0.5,
|
||||
}}
|
||||
>
|
||||
New track
|
||||
</div>
|
||||
)}
|
||||
{
|
||||
// fallow-ignore-next-line complexity
|
||||
els.map((el) => {
|
||||
const clipStyle = getTrackStyle(el.tag);
|
||||
const elementKey = el.key ?? el.id;
|
||||
const capabilities = getTimelineEditCapabilities(el);
|
||||
const isSelected = selectedElementId === elementKey;
|
||||
const isComposition = !!el.compositionSrc;
|
||||
// elementKey (el.key ?? el.id) is already unique per clip; do NOT
|
||||
// fold in the map index, or a splice/reorder remounts every clip
|
||||
// at/after the change (DOM flash, drag interruption).
|
||||
const clipKey = elementKey;
|
||||
const isDraggingClip =
|
||||
draggedClip?.started === true &&
|
||||
(draggedElement?.key ?? draggedElement?.id) === elementKey;
|
||||
if (isDraggingClip) return null;
|
||||
const previewElement = getPreviewElement(el);
|
||||
return (
|
||||
<TimelineClip
|
||||
key={clipKey}
|
||||
onContextMenu={(e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
onContextMenuClip?.(e, el);
|
||||
}}
|
||||
el={previewElement}
|
||||
pps={pps}
|
||||
clipY={CLIP_Y}
|
||||
isSelected={isSelected}
|
||||
isHovered={hoveredClip === clipKey}
|
||||
isDragging={false}
|
||||
hasCustomContent={!!renderClipContent}
|
||||
capabilities={capabilities}
|
||||
theme={theme}
|
||||
isComposition={isComposition}
|
||||
onHoverStart={() => setHoveredClip(clipKey)}
|
||||
onHoverEnd={() => setHoveredClip(null)}
|
||||
onResizeStart={(edge, e) => {
|
||||
if (e.button !== 0 || e.shiftKey || !onResizeElement) return;
|
||||
if (edge === "start" && !capabilities.canTrimStart) return;
|
||||
if (edge === "end" && !capabilities.canTrimEnd) return;
|
||||
e.stopPropagation();
|
||||
blockedClipRef.current = null;
|
||||
setShowPopover(false);
|
||||
setRangeSelection(null);
|
||||
setResizingClip({
|
||||
element: el,
|
||||
edge,
|
||||
originClientX: e.clientX,
|
||||
previewStart: el.start,
|
||||
previewDuration: el.duration,
|
||||
previewPlaybackStart: el.playbackStart,
|
||||
started: false,
|
||||
});
|
||||
}}
|
||||
onPointerDown={
|
||||
// fallow-ignore-next-line complexity
|
||||
(e) => {
|
||||
if (e.button !== 0) return;
|
||||
if (usePlayerStore.getState().activeTool === "razor") return;
|
||||
if (e.shiftKey) {
|
||||
shiftClickClipRef.current = {
|
||||
element: el,
|
||||
anchorX: e.clientX,
|
||||
anchorY: e.clientY,
|
||||
};
|
||||
return;
|
||||
}
|
||||
const target = e.currentTarget as HTMLElement;
|
||||
const rect = target.getBoundingClientRect();
|
||||
const blockedIntent = resolveBlockedTimelineEditIntent({
|
||||
width: rect.width,
|
||||
offsetX: e.clientX - rect.left,
|
||||
handleWidth: CLIP_HANDLE_W,
|
||||
capabilities,
|
||||
});
|
||||
if (
|
||||
blockedIntent &&
|
||||
((blockedIntent === "move" && onMoveElement) ||
|
||||
(blockedIntent !== "move" && onResizeElement))
|
||||
) {
|
||||
blockedClipRef.current = {
|
||||
element: el,
|
||||
intent: blockedIntent,
|
||||
originClientX: e.clientX,
|
||||
originClientY: e.clientY,
|
||||
started: false,
|
||||
};
|
||||
return;
|
||||
}
|
||||
if (!onMoveElement || !capabilities.canMove) return;
|
||||
{/* Beat dots on the active track (the one holding the selection),
|
||||
falling back to the music track when nothing is selected. */}
|
||||
{beatStripOnTrack && (
|
||||
<BeatStrip
|
||||
beatTimes={beatAnalysis?.beatTimes}
|
||||
beatStrengths={beatAnalysis?.beatStrengths}
|
||||
pps={pps}
|
||||
/>
|
||||
)}
|
||||
{isPendingTrack && (
|
||||
<div
|
||||
className="absolute inset-0 flex items-center"
|
||||
style={{
|
||||
paddingLeft: 16,
|
||||
color: ts.label,
|
||||
fontSize: 11,
|
||||
letterSpacing: 0,
|
||||
textTransform: "uppercase",
|
||||
opacity: 0.5,
|
||||
}}
|
||||
>
|
||||
New track
|
||||
</div>
|
||||
)}
|
||||
{
|
||||
// fallow-ignore-next-line complexity
|
||||
els.map((el) => {
|
||||
const clipStyle = getTrackStyle(el.tag);
|
||||
const elementKey = el.key ?? el.id;
|
||||
const capabilities = getTimelineEditCapabilities(el);
|
||||
const isSelected = selectedElementId === elementKey;
|
||||
const isComposition = !!el.compositionSrc;
|
||||
// elementKey (el.key ?? el.id) is already unique per clip; do NOT
|
||||
// fold in the map index, or a splice/reorder remounts every clip
|
||||
// at/after the change (DOM flash, drag interruption).
|
||||
const clipKey = elementKey;
|
||||
const isDraggingClip =
|
||||
draggedClip?.started === true &&
|
||||
(draggedElement?.key ?? draggedElement?.id) === elementKey;
|
||||
if (isDraggingClip) return null;
|
||||
const previewElement = getPreviewElement(el);
|
||||
return (
|
||||
<TimelineClip
|
||||
key={clipKey}
|
||||
onContextMenu={(e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
onContextMenuClip?.(e, el);
|
||||
}}
|
||||
el={previewElement}
|
||||
pps={pps}
|
||||
clipY={CLIP_Y}
|
||||
isSelected={isSelected}
|
||||
isHovered={hoveredClip === clipKey}
|
||||
isDragging={false}
|
||||
hasCustomContent={!!renderClipContent}
|
||||
capabilities={capabilities}
|
||||
theme={theme}
|
||||
isComposition={isComposition}
|
||||
onHoverStart={() => setHoveredClip(clipKey)}
|
||||
onHoverEnd={() => setHoveredClip(null)}
|
||||
onResizeStart={(edge, e) => {
|
||||
if (e.button !== 0 || e.shiftKey || !onResizeElement) return;
|
||||
if (edge === "start" && !capabilities.canTrimStart) return;
|
||||
if (edge === "end" && !capabilities.canTrimEnd) return;
|
||||
e.stopPropagation();
|
||||
blockedClipRef.current = null;
|
||||
setShowPopover(false);
|
||||
setRangeSelection(null);
|
||||
setDraggedClip({
|
||||
setResizingClip({
|
||||
element: el,
|
||||
edge,
|
||||
originClientX: e.clientX,
|
||||
originClientY: e.clientY,
|
||||
originScrollLeft: scrollRef.current?.scrollLeft ?? 0,
|
||||
originScrollTop: scrollRef.current?.scrollTop ?? 0,
|
||||
pointerClientX: e.clientX,
|
||||
pointerClientY: e.clientY,
|
||||
pointerOffsetX: e.clientX - rect.left,
|
||||
pointerOffsetY: e.clientY - rect.top,
|
||||
previewStart: el.start,
|
||||
previewTrack: el.track,
|
||||
snapBeatTime: null,
|
||||
previewDuration: el.duration,
|
||||
previewPlaybackStart: el.playbackStart,
|
||||
snapGuideTime: null,
|
||||
snapGuideKind: null,
|
||||
started: false,
|
||||
});
|
||||
syncClipDragAutoScroll(e.clientX, e.clientY);
|
||||
}
|
||||
}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (suppressClickRef.current) return;
|
||||
const { activeTool } = usePlayerStore.getState();
|
||||
if (activeTool === "razor" && onRazorSplit) {
|
||||
const clipRect = (
|
||||
e.currentTarget as HTMLElement
|
||||
).getBoundingClientRect();
|
||||
const clickOffsetX = e.clientX - clipRect.left;
|
||||
const splitTime = previewElement.start + clickOffsetX / pps;
|
||||
const clampedTime = Math.max(
|
||||
previewElement.start + SPLIT_BOUNDARY_EPSILON_S,
|
||||
Math.min(
|
||||
previewElement.start +
|
||||
previewElement.duration -
|
||||
SPLIT_BOUNDARY_EPSILON_S,
|
||||
splitTime,
|
||||
),
|
||||
);
|
||||
if (e.shiftKey && onRazorSplitAll) {
|
||||
onRazorSplitAll(clampedTime);
|
||||
} else {
|
||||
onRazorSplit(el, clampedTime);
|
||||
}}
|
||||
onPointerDown={
|
||||
// fallow-ignore-next-line complexity
|
||||
(e) => {
|
||||
if (e.button !== 0) return;
|
||||
if (usePlayerStore.getState().activeTool === "razor") return;
|
||||
if (e.shiftKey) {
|
||||
shiftClickClipRef.current = {
|
||||
element: el,
|
||||
anchorX: e.clientX,
|
||||
anchorY: e.clientY,
|
||||
};
|
||||
return;
|
||||
}
|
||||
const target = e.currentTarget as HTMLElement;
|
||||
const rect = target.getBoundingClientRect();
|
||||
const blockedIntent = resolveBlockedTimelineEditIntent({
|
||||
width: rect.width,
|
||||
offsetX: e.clientX - rect.left,
|
||||
handleWidth: CLIP_HANDLE_W,
|
||||
capabilities,
|
||||
});
|
||||
if (
|
||||
blockedIntent &&
|
||||
((blockedIntent === "move" && onMoveElement) ||
|
||||
(blockedIntent !== "move" && onResizeElement))
|
||||
) {
|
||||
blockedClipRef.current = {
|
||||
element: el,
|
||||
intent: blockedIntent,
|
||||
originClientX: e.clientX,
|
||||
originClientY: e.clientY,
|
||||
started: false,
|
||||
};
|
||||
return;
|
||||
}
|
||||
if (!onMoveElement || !capabilities.canMove) return;
|
||||
blockedClipRef.current = null;
|
||||
setShowPopover(false);
|
||||
setRangeSelection(null);
|
||||
setDraggedClip({
|
||||
element: el,
|
||||
originClientX: e.clientX,
|
||||
originClientY: e.clientY,
|
||||
originScrollLeft: scrollRef.current?.scrollLeft ?? 0,
|
||||
originScrollTop: scrollRef.current?.scrollTop ?? 0,
|
||||
pointerClientX: e.clientX,
|
||||
pointerClientY: e.clientY,
|
||||
pointerOffsetX: e.clientX - rect.left,
|
||||
pointerOffsetY: e.clientY - rect.top,
|
||||
previewStart: el.start,
|
||||
previewTrack: el.track,
|
||||
previewLayerId: layerId,
|
||||
previewLayerIndex: rowIndex,
|
||||
previewStackingReorder: null,
|
||||
snapBeatTime: null,
|
||||
snapGuideTime: null,
|
||||
snapGuideKind: null,
|
||||
started: false,
|
||||
});
|
||||
syncClipDragAutoScroll(e.clientX, e.clientY);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const nextElement = isSelected ? null : el;
|
||||
setSelectedElementId(nextElement ? elementKey : null);
|
||||
onSelectElement?.(nextElement);
|
||||
}}
|
||||
onDoubleClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (suppressClickRef.current) return;
|
||||
if (isComposition && onDrillDown) onDrillDown(el);
|
||||
}}
|
||||
>
|
||||
{renderClipChildren(previewElement, clipStyle)}
|
||||
{STUDIO_KEYFRAMES_ENABLED && keyframeCache?.get(elementKey) && (
|
||||
<TimelineClipDiamonds
|
||||
keyframesData={keyframeCache.get(elementKey)!}
|
||||
clipWidthPx={Math.max(previewElement.duration * pps, 4)}
|
||||
clipHeightPx={TRACK_H - 2 * CLIP_Y}
|
||||
beatsActive={beatStripOnTrack}
|
||||
accentColor={clipStyle.accent}
|
||||
isSelected={isSelected}
|
||||
currentPercentage={
|
||||
previewElement.duration > 0
|
||||
? ((currentTime - previewElement.start) / previewElement.duration) *
|
||||
100
|
||||
: 0
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (suppressClickRef.current) return;
|
||||
const { activeTool } = usePlayerStore.getState();
|
||||
if (activeTool === "razor" && onRazorSplit) {
|
||||
const clipRect = (
|
||||
e.currentTarget as HTMLElement
|
||||
).getBoundingClientRect();
|
||||
const clickOffsetX = e.clientX - clipRect.left;
|
||||
const splitTime = previewElement.start + clickOffsetX / pps;
|
||||
const clampedTime = Math.max(
|
||||
previewElement.start + SPLIT_BOUNDARY_EPSILON_S,
|
||||
Math.min(
|
||||
previewElement.start +
|
||||
previewElement.duration -
|
||||
SPLIT_BOUNDARY_EPSILON_S,
|
||||
splitTime,
|
||||
),
|
||||
);
|
||||
if (e.shiftKey && onRazorSplitAll) {
|
||||
onRazorSplitAll(clampedTime);
|
||||
} else {
|
||||
onRazorSplit(el, clampedTime);
|
||||
}
|
||||
return;
|
||||
}
|
||||
elementId={elementKey}
|
||||
selectedKeyframes={selectedKeyframes}
|
||||
onClickKeyframe={(pct) => onClickKeyframe?.(previewElement, pct)}
|
||||
onShiftClickKeyframe={onShiftClickKeyframe}
|
||||
onContextMenuKeyframe={onContextMenuKeyframe}
|
||||
onMoveKeyframe={onMoveKeyframe}
|
||||
suppressClickRef={suppressClickRef}
|
||||
/>
|
||||
)}
|
||||
</TimelineClip>
|
||||
);
|
||||
})
|
||||
}
|
||||
const nextElement = isSelected ? null : el;
|
||||
setSelectedElementId(nextElement ? elementKey : null);
|
||||
onSelectElement?.(nextElement);
|
||||
}}
|
||||
onDoubleClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (suppressClickRef.current) return;
|
||||
if (isComposition && onDrillDown) onDrillDown(el);
|
||||
}}
|
||||
>
|
||||
{renderClipChildren(previewElement, clipStyle)}
|
||||
{STUDIO_KEYFRAMES_ENABLED && keyframeCache?.get(elementKey) && (
|
||||
<TimelineClipDiamonds
|
||||
keyframesData={keyframeCache.get(elementKey)!}
|
||||
clipWidthPx={Math.max(previewElement.duration * pps, 4)}
|
||||
clipHeightPx={TRACK_H - 2 * CLIP_Y}
|
||||
beatsActive={beatStripOnTrack}
|
||||
beatTimes={beatAnalysis?.beatTimes}
|
||||
clipStart={previewElement.start}
|
||||
clipDurationSeconds={previewElement.duration}
|
||||
pixelsPerSecond={pps}
|
||||
accentColor={clipStyle.accent}
|
||||
isSelected={isSelected}
|
||||
currentPercentage={
|
||||
previewElement.duration > 0
|
||||
? ((currentTime - previewElement.start) /
|
||||
previewElement.duration) *
|
||||
100
|
||||
: 0
|
||||
}
|
||||
elementId={elementKey}
|
||||
selectedKeyframes={selectedKeyframes}
|
||||
onClickKeyframe={(pct) => onClickKeyframe?.(previewElement, pct)}
|
||||
onShiftClickKeyframe={onShiftClickKeyframe}
|
||||
onContextMenuKeyframe={onContextMenuKeyframe}
|
||||
onMoveKeyframe={onMoveKeyframe}
|
||||
suppressClickRef={suppressClickRef}
|
||||
/>
|
||||
)}
|
||||
</TimelineClip>
|
||||
);
|
||||
})
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Fragment>
|
||||
);
|
||||
})
|
||||
}
|
||||
|
||||
{/* Drag ghost */}
|
||||
{activeDraggedElement && activeDraggedPosition && (
|
||||
<div
|
||||
className="absolute pointer-events-none"
|
||||
style={{
|
||||
top: activeDraggedPosition.top,
|
||||
left: activeDraggedPosition.left,
|
||||
width: Math.max(activeDraggedElement.duration * pps, 4),
|
||||
height: TRACK_H - CLIP_Y * 2,
|
||||
zIndex: 40,
|
||||
}}
|
||||
<TimelineDragGhost
|
||||
element={activeDraggedElement}
|
||||
position={activeDraggedPosition}
|
||||
pps={pps}
|
||||
selectedElementId={selectedElementId}
|
||||
hasCustomContent={!!renderClipContent}
|
||||
theme={theme}
|
||||
>
|
||||
<TimelineClip
|
||||
el={{ ...activeDraggedElement, start: 0 }}
|
||||
pps={pps}
|
||||
clipY={0}
|
||||
isSelected={selectedElementId === (activeDraggedElement.key ?? activeDraggedElement.id)}
|
||||
isHovered={false}
|
||||
isDragging={true}
|
||||
hasCustomContent={!!renderClipContent}
|
||||
capabilities={getTimelineEditCapabilities(activeDraggedElement)}
|
||||
theme={theme}
|
||||
isComposition={!!activeDraggedElement.compositionSrc}
|
||||
onHoverStart={() => {}}
|
||||
onHoverEnd={() => {}}
|
||||
onResizeStart={() => {}}
|
||||
onClick={() => {}}
|
||||
onDoubleClick={() => {}}
|
||||
>
|
||||
{renderClipChildren(activeDraggedElement, getTrackStyle(activeDraggedElement.tag))}
|
||||
</TimelineClip>
|
||||
</div>
|
||||
{renderClipChildren(activeDraggedElement, getTrackStyle(activeDraggedElement.tag))}
|
||||
</TimelineDragGhost>
|
||||
)}
|
||||
|
||||
{/* Range highlight */}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { memo, useRef, useState } from "react";
|
||||
import { BEAT_BAND_H } from "./BeatStrip";
|
||||
import {
|
||||
clampToNeighbors,
|
||||
KEYFRAME_DRAG_THRESHOLD_PX,
|
||||
previewClipPct,
|
||||
resolveKeyframeDrag,
|
||||
} from "../../components/editor/keyframeDrag";
|
||||
import { snapKeyframePctToBeat } from "./timelineEditing";
|
||||
|
||||
interface KeyframeEntry {
|
||||
percentage: number;
|
||||
@@ -28,6 +30,14 @@ interface TimelineClipDiamondsProps {
|
||||
/** Beat-dot strip is shown on this track → shrink diamonds + drop them into
|
||||
* the bottom half so they clear the strip at the top. */
|
||||
beatsActive?: boolean;
|
||||
/** Composition-time beat positions (same source the beat strip renders from).
|
||||
* When present and `beatsActive`, a dragged keyframe snaps to the nearest beat. */
|
||||
beatTimes?: number[];
|
||||
/** Clip start / duration (seconds) + pixels-per-second, needed to map a
|
||||
* dragged keyframe's clip-% to composition time for beat snapping. */
|
||||
clipStart?: number;
|
||||
clipDurationSeconds?: number;
|
||||
pixelsPerSecond?: number;
|
||||
accentColor: string;
|
||||
isSelected: boolean;
|
||||
currentPercentage: number;
|
||||
@@ -71,6 +81,10 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
|
||||
clipWidthPx,
|
||||
clipHeightPx,
|
||||
beatsActive,
|
||||
beatTimes,
|
||||
clipStart = 0,
|
||||
clipDurationSeconds = 0,
|
||||
pixelsPerSecond = 1,
|
||||
accentColor,
|
||||
isSelected,
|
||||
currentPercentage,
|
||||
@@ -121,6 +135,20 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
|
||||
const baseOpacity = isSelected ? 0.4 : 0.25;
|
||||
const canDrag = isSelected && !!onMoveKeyframe;
|
||||
|
||||
// Snap a dragged keyframe's clip-% to the nearest beat (within ~8px), then
|
||||
// re-clamp to neighbours so the snap can't cross a sibling keyframe. No-op
|
||||
// when the beat strip isn't active for this track or no beats are loaded.
|
||||
const snapClipPctToBeat = (clipPct: number, draggedIndex: number): number => {
|
||||
if (!beatsActive || !beatTimes || beatTimes.length === 0) return clipPct;
|
||||
const snapped = snapKeyframePctToBeat(
|
||||
{ start: clipStart, duration: clipDurationSeconds },
|
||||
clipPct,
|
||||
beatTimes,
|
||||
pixelsPerSecond,
|
||||
);
|
||||
return clampToNeighbors(snapped, sortedClipPcts, draggedIndex);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
@@ -195,14 +223,17 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
|
||||
if (d.moved) {
|
||||
setPreview({
|
||||
kfKey,
|
||||
clipPct: previewClipPct({
|
||||
pointerDownX: d.startX,
|
||||
pointerMoveX: e.clientX,
|
||||
clipWidthPx,
|
||||
draggedClipPct: d.fromClipPct,
|
||||
draggedIndex: i,
|
||||
sortedClipPcts,
|
||||
}),
|
||||
clipPct: snapClipPctToBeat(
|
||||
previewClipPct({
|
||||
pointerDownX: d.startX,
|
||||
pointerMoveX: e.clientX,
|
||||
clipWidthPx,
|
||||
draggedClipPct: d.fromClipPct,
|
||||
draggedIndex: i,
|
||||
sortedClipPcts,
|
||||
}),
|
||||
i,
|
||||
),
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -238,7 +269,7 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
|
||||
if (e.shiftKey) onShiftClickKeyframe?.(elementId, kf.percentage);
|
||||
else onClickKeyframe?.(kf.percentage);
|
||||
} else if (res.kind === "move" && res.toClipPct != null) {
|
||||
onMoveKeyframe?.(elementId, d.fromClipPct, res.toClipPct);
|
||||
onMoveKeyframe?.(elementId, d.fromClipPct, snapClipPctToBeat(res.toClipPct, i));
|
||||
// A retime still targeted this exact diamond — park/select it at its
|
||||
// new position, same as a plain click, or a drag that actually moved
|
||||
// something looks identical to one that silently did nothing.
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { TimelineClip } from "./TimelineClip";
|
||||
import { getTimelineEditCapabilities } from "./timelineEditing";
|
||||
import { CLIP_Y, TRACK_H } from "./timelineLayout";
|
||||
import type { TimelineTheme } from "./timelineTheme";
|
||||
import type { TimelineElement } from "../store/playerStore";
|
||||
|
||||
interface TimelineDragGhostProps {
|
||||
element: TimelineElement;
|
||||
position: { left: number; top: number };
|
||||
pps: number;
|
||||
selectedElementId: string | null;
|
||||
hasCustomContent: boolean;
|
||||
theme: TimelineTheme;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function TimelineDragGhost({
|
||||
element,
|
||||
position,
|
||||
pps,
|
||||
selectedElementId,
|
||||
hasCustomContent,
|
||||
theme,
|
||||
children,
|
||||
}: TimelineDragGhostProps) {
|
||||
return (
|
||||
<div
|
||||
className="absolute pointer-events-none"
|
||||
style={{
|
||||
top: position.top,
|
||||
left: position.left,
|
||||
width: Math.max(element.duration * pps, 4),
|
||||
height: TRACK_H - CLIP_Y * 2,
|
||||
zIndex: 40,
|
||||
}}
|
||||
>
|
||||
<TimelineClip
|
||||
el={{ ...element, start: 0 }}
|
||||
pps={pps}
|
||||
clipY={0}
|
||||
isSelected={selectedElementId === (element.key ?? element.id)}
|
||||
isHovered={false}
|
||||
isDragging={true}
|
||||
hasCustomContent={hasCustomContent}
|
||||
capabilities={getTimelineEditCapabilities(element)}
|
||||
theme={theme}
|
||||
isComposition={!!element.compositionSrc}
|
||||
onHoverStart={() => {}}
|
||||
onHoverEnd={() => {}}
|
||||
onResizeStart={() => {}}
|
||||
onClick={() => {}}
|
||||
onDoubleClick={() => {}}
|
||||
>
|
||||
{children}
|
||||
</TimelineClip>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
interface TimelineDropInsertionLineProps {
|
||||
edge: "top" | "bottom";
|
||||
accentColor: string;
|
||||
}
|
||||
|
||||
export function TimelineDropInsertionLine({ edge, accentColor }: TimelineDropInsertionLineProps) {
|
||||
return (
|
||||
<div
|
||||
className="absolute left-0 right-0 pointer-events-none"
|
||||
style={{
|
||||
top: edge === "top" ? -1 : undefined,
|
||||
bottom: edge === "bottom" ? -1 : undefined,
|
||||
height: 2,
|
||||
background: accentColor,
|
||||
boxShadow: `0 0 8px ${accentColor}`,
|
||||
zIndex: 30,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="absolute rounded-full"
|
||||
style={{
|
||||
left: 0,
|
||||
top: -3,
|
||||
width: 8,
|
||||
height: 8,
|
||||
background: accentColor,
|
||||
boxShadow: `0 0 8px ${accentColor}`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import type { TimelineTheme } from "./timelineTheme";
|
||||
import { GUTTER } from "./timelineLayout";
|
||||
import type { StackingTimelineLayer, TimelineLayerId } from "./timelineTrackOrder";
|
||||
|
||||
const TIMELINE_LAYER_GROUP_HEADER_H = 18;
|
||||
|
||||
export function shouldShowTimelineLayerGroupHeader(
|
||||
contextKey: string,
|
||||
previousContextKey: string,
|
||||
): boolean {
|
||||
return contextKey !== "" && contextKey !== previousContextKey;
|
||||
}
|
||||
|
||||
export function getTimelineLayerGroupHeaderTotalHeight(
|
||||
layerOrder: readonly TimelineLayerId[],
|
||||
layers: readonly StackingTimelineLayer[],
|
||||
): number {
|
||||
const layerById = new Map<TimelineLayerId, StackingTimelineLayer>();
|
||||
for (const layer of layers) layerById.set(layer.id, layer);
|
||||
|
||||
let previousContextKey = "";
|
||||
let count = 0;
|
||||
for (const layerId of layerOrder) {
|
||||
const contextKey = layerById.get(layerId)?.contextKey ?? "";
|
||||
if (shouldShowTimelineLayerGroupHeader(contextKey, previousContextKey)) count += 1;
|
||||
previousContextKey = contextKey;
|
||||
}
|
||||
return count * TIMELINE_LAYER_GROUP_HEADER_H;
|
||||
}
|
||||
|
||||
interface TimelineLayerGroupHeaderProps {
|
||||
contextKey: string;
|
||||
trackContentWidth: number;
|
||||
theme: TimelineTheme;
|
||||
accentColor: string;
|
||||
}
|
||||
|
||||
export function TimelineLayerGroupHeader({
|
||||
contextKey,
|
||||
trackContentWidth,
|
||||
theme,
|
||||
accentColor,
|
||||
}: TimelineLayerGroupHeaderProps) {
|
||||
return (
|
||||
<div
|
||||
className="relative"
|
||||
style={{
|
||||
height: TIMELINE_LAYER_GROUP_HEADER_H,
|
||||
// Fill the full canvas width (min 100% of the panel) so the context
|
||||
// header spans the timeline at any zoom; minWidth preserves the intrinsic
|
||||
// composition width when zoomed in and scrolling.
|
||||
width: "100%",
|
||||
minWidth: GUTTER + trackContentWidth,
|
||||
background: theme.gutterBackground,
|
||||
borderBottom: `1px solid ${theme.rowBorder}`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="sticky left-0 z-[13] flex h-full items-center"
|
||||
style={{
|
||||
width: Math.min(GUTTER + 220, GUTTER + trackContentWidth),
|
||||
background: theme.gutterBackground,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="relative h-full flex-shrink-0"
|
||||
style={{
|
||||
width: GUTTER,
|
||||
borderRight: `1px solid ${theme.gutterBorder}`,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="absolute bottom-0 top-0"
|
||||
style={{
|
||||
left: 8,
|
||||
width: 2,
|
||||
background: accentColor,
|
||||
opacity: 0.72,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0 px-2">
|
||||
<span
|
||||
className="block truncate font-mono uppercase leading-none"
|
||||
style={{
|
||||
color: theme.textSecondary,
|
||||
fontSize: 10,
|
||||
letterSpacing: 0,
|
||||
}}
|
||||
>
|
||||
Inside: {contextKey}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { Eye, EyeSlash } from "@phosphor-icons/react";
|
||||
import { Music } from "../../icons/SystemIcons";
|
||||
import type { TimelineTheme } from "./timelineTheme";
|
||||
import { GUTTER } from "./timelineLayout";
|
||||
|
||||
interface TimelineLayerGutterProps {
|
||||
isAudio: boolean;
|
||||
isTrackHidden: boolean;
|
||||
rowTrack: number;
|
||||
theme: TimelineTheme;
|
||||
onToggleHidden: () => void;
|
||||
}
|
||||
|
||||
export function TimelineLayerGutter({
|
||||
isAudio,
|
||||
isTrackHidden,
|
||||
rowTrack,
|
||||
theme,
|
||||
onToggleHidden,
|
||||
}: TimelineLayerGutterProps) {
|
||||
return (
|
||||
<div
|
||||
className="sticky left-0 z-[12] flex-shrink-0 flex flex-col items-center justify-center gap-0.5"
|
||||
style={{
|
||||
width: GUTTER,
|
||||
background: theme.gutterBackground,
|
||||
borderRight: `1px solid ${theme.gutterBorder}`,
|
||||
}}
|
||||
>
|
||||
{isAudio && (
|
||||
<Music
|
||||
size={12}
|
||||
weight="fill"
|
||||
aria-hidden="true"
|
||||
style={{ color: theme.textSecondary, opacity: 0.7 }}
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
aria-label={isTrackHidden ? `Show track ${rowTrack}` : `Hide track ${rowTrack}`}
|
||||
title={isTrackHidden ? `Show track ${rowTrack}` : `Hide track ${rowTrack}`}
|
||||
className={`flex h-6 w-6 items-center justify-center rounded border-0 bg-transparent p-0 transition-colors focus-visible:outline focus-visible:outline-1 focus-visible:outline-offset-[-1px] focus-visible:outline-[#3CE6AC] ${
|
||||
isTrackHidden ? "text-[#3CE6AC] hover:text-white" : "text-white/35 hover:text-white/75"
|
||||
}`}
|
||||
onPointerDown={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggleHidden();
|
||||
}}
|
||||
>
|
||||
{isTrackHidden ? (
|
||||
<EyeSlash size={14} weight="bold" aria-hidden="true" />
|
||||
) : (
|
||||
<Eye size={14} weight="bold" aria-hidden="true" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -79,13 +79,15 @@ export const TimelineRuler = memo(function TimelineRuler({
|
||||
})}
|
||||
</svg>
|
||||
|
||||
{/* Ruler */}
|
||||
{/* Ruler. The bar fills the full panel width (canvas is min 100% wide);
|
||||
calc(100% - GUTTER) equals trackContentWidth when zoomed in and extends
|
||||
past the content when zoomed out. Ticks stay at composition coordinates. */}
|
||||
<div
|
||||
className="relative overflow-hidden"
|
||||
style={{
|
||||
height: RULER_H,
|
||||
marginLeft: GUTTER,
|
||||
width: trackContentWidth,
|
||||
width: `calc(100% - ${GUTTER}px)`,
|
||||
background: theme.gutterBackground,
|
||||
borderBottom: `1px solid ${theme.rulerBorder}`,
|
||||
}}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// fallow-ignore-file code-duplication
|
||||
// fallow-ignore-file dead-code
|
||||
import type { TimelineElement } from "../store/playerStore";
|
||||
import type { BlockedTimelineEditIntent } from "./timelineEditing";
|
||||
import type { BlockedTimelineEditIntent, TimelineStackingReorderIntent } from "./timelineEditing";
|
||||
|
||||
/**
|
||||
* Shared callback signatures for timeline editing operations.
|
||||
@@ -26,13 +26,16 @@ export interface TimelineDropCallbacks {
|
||||
export interface TimelineEditCallbacks {
|
||||
onMoveElement?: (
|
||||
element: TimelineElement,
|
||||
updates: Pick<TimelineElement, "start" | "track">,
|
||||
updates: Pick<TimelineElement, "start" | "track"> & {
|
||||
stackingReorder?: TimelineStackingReorderIntent | null;
|
||||
},
|
||||
) => Promise<void> | void;
|
||||
onResizeElement?: (
|
||||
element: TimelineElement,
|
||||
updates: Pick<TimelineElement, "start" | "duration" | "playbackStart">,
|
||||
) => Promise<void> | void;
|
||||
onToggleTrackHidden?: (track: number, hidden: boolean) => Promise<void> | void;
|
||||
onToggleElementHidden?: (elementKey: string, hidden: boolean) => Promise<void> | void;
|
||||
onBlockedEditAttempt?: (element: TimelineElement, intent: BlockedTimelineEditIntent) => void;
|
||||
onSplitElement?: (element: TimelineElement, splitTime: number) => Promise<void> | void;
|
||||
onRazorSplit?: (element: TimelineElement, splitTime: number) => Promise<void> | void;
|
||||
|
||||
@@ -2,12 +2,14 @@ import { useCallback, useState, type RefObject } from "react";
|
||||
import { TIMELINE_ASSET_MIME, TIMELINE_BLOCK_MIME } from "../../utils/timelineAssetDrop";
|
||||
import { TRACK_H, resolveTimelineAssetDrop } from "./timelineLayout";
|
||||
import type { TimelineDropCallbacks } from "./timelineCallbacks";
|
||||
import type { StackingTimelineLayer, TimelineLayerId } from "./timelineTrackOrder";
|
||||
|
||||
interface UseTimelineAssetDropOptions extends TimelineDropCallbacks {
|
||||
scrollRef: RefObject<HTMLDivElement | null>;
|
||||
ppsRef: RefObject<number>;
|
||||
durationRef: RefObject<number>;
|
||||
trackOrderRef: RefObject<number[]>;
|
||||
trackOrderRef: RefObject<TimelineLayerId[]>;
|
||||
timelineLayersRef: RefObject<StackingTimelineLayer[]>;
|
||||
}
|
||||
|
||||
export function useTimelineAssetDrop({
|
||||
@@ -15,6 +17,7 @@ export function useTimelineAssetDrop({
|
||||
ppsRef,
|
||||
durationRef,
|
||||
trackOrderRef,
|
||||
timelineLayersRef,
|
||||
onFileDrop,
|
||||
onAssetDrop,
|
||||
onBlockDrop,
|
||||
@@ -39,6 +42,10 @@ export function useTimelineAssetDrop({
|
||||
setIsDragOver(false);
|
||||
const scroll = scrollRef.current;
|
||||
const rect = scroll?.getBoundingClientRect();
|
||||
const layerById = new Map(timelineLayersRef.current.map((layer) => [layer.id, layer]));
|
||||
const trackOrder = trackOrderRef.current
|
||||
.map((id) => layerById.get(id)?.placementTrack)
|
||||
.filter((track): track is number => track != null);
|
||||
const dropInput = {
|
||||
rectLeft: rect?.left ?? 0,
|
||||
rectTop: rect?.top ?? 0,
|
||||
@@ -47,7 +54,7 @@ export function useTimelineAssetDrop({
|
||||
pixelsPerSecond: ppsRef.current,
|
||||
duration: durationRef.current,
|
||||
trackHeight: TRACK_H,
|
||||
trackOrder: trackOrderRef.current,
|
||||
trackOrder,
|
||||
};
|
||||
if (onFileDrop && e.dataTransfer.files.length > 0) {
|
||||
void onFileDrop(
|
||||
@@ -84,7 +91,16 @@ export function useTimelineAssetDrop({
|
||||
}
|
||||
}
|
||||
},
|
||||
[onAssetDrop, onBlockDrop, onFileDrop, scrollRef, ppsRef, durationRef, trackOrderRef],
|
||||
[
|
||||
onAssetDrop,
|
||||
onBlockDrop,
|
||||
onFileDrop,
|
||||
scrollRef,
|
||||
ppsRef,
|
||||
durationRef,
|
||||
trackOrderRef,
|
||||
timelineLayersRef,
|
||||
],
|
||||
);
|
||||
|
||||
return { isDragOver, setIsDragOver, handleAssetDragOver, handleAssetDrop };
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { TimelineLayerDropPlacement } from "./timelineStacking";
|
||||
import { resolveTimelineDropIndicator } from "./timelineDropIndicator";
|
||||
|
||||
const layerOrder = ["front", "middle", "back"];
|
||||
|
||||
describe("resolveTimelineDropIndicator", () => {
|
||||
it("highlights only the row targeted by an onto placement", () => {
|
||||
const placement: TimelineLayerDropPlacement = { type: "onto", layerId: "middle" };
|
||||
|
||||
expect(resolveTimelineDropIndicator({ placement, layerId: "front", layerOrder })).toBeNull();
|
||||
expect(resolveTimelineDropIndicator({ placement, layerId: "middle", layerOrder })).toEqual({
|
||||
kind: "onto",
|
||||
});
|
||||
});
|
||||
|
||||
it("renders a single insertion line at the bottom edge of the before row", () => {
|
||||
const placement: TimelineLayerDropPlacement = {
|
||||
type: "between",
|
||||
beforeLayerId: "front",
|
||||
afterLayerId: "middle",
|
||||
};
|
||||
|
||||
expect(resolveTimelineDropIndicator({ placement, layerId: "front", layerOrder })).toEqual({
|
||||
kind: "line",
|
||||
edge: "bottom",
|
||||
});
|
||||
expect(resolveTimelineDropIndicator({ placement, layerId: "middle", layerOrder })).toBeNull();
|
||||
});
|
||||
|
||||
it("renders above placements on the target row top edge", () => {
|
||||
const placement: TimelineLayerDropPlacement = { type: "above", layerId: "front" };
|
||||
|
||||
expect(resolveTimelineDropIndicator({ placement, layerId: "front", layerOrder })).toEqual({
|
||||
kind: "line",
|
||||
edge: "top",
|
||||
});
|
||||
});
|
||||
|
||||
it("renders below placements on the target row bottom edge", () => {
|
||||
const placement: TimelineLayerDropPlacement = { type: "below", layerId: "back" };
|
||||
|
||||
expect(resolveTimelineDropIndicator({ placement, layerId: "back", layerOrder })).toEqual({
|
||||
kind: "line",
|
||||
edge: "bottom",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { TimelineLayerDropPlacement } from "./timelineStacking";
|
||||
import type { TimelineLayerId } from "./timelineTrackOrder";
|
||||
|
||||
export type TimelineDropIndicator = { kind: "onto" } | { kind: "line"; edge: "top" | "bottom" };
|
||||
|
||||
interface ResolveTimelineDropIndicatorInput {
|
||||
placement: TimelineLayerDropPlacement | null;
|
||||
layerId: TimelineLayerId;
|
||||
layerOrder: readonly TimelineLayerId[];
|
||||
}
|
||||
|
||||
export function resolveTimelineDropIndicator({
|
||||
placement,
|
||||
layerId,
|
||||
layerOrder,
|
||||
}: ResolveTimelineDropIndicatorInput): TimelineDropIndicator | null {
|
||||
if (!placement || !layerOrder.includes(layerId)) return null;
|
||||
|
||||
switch (placement.type) {
|
||||
case "onto":
|
||||
return placement.layerId === layerId ? { kind: "onto" } : null;
|
||||
case "between":
|
||||
if (placement.beforeLayerId === layerId) return { kind: "line", edge: "bottom" };
|
||||
if (!layerOrder.includes(placement.beforeLayerId) && placement.afterLayerId === layerId) {
|
||||
return { kind: "line", edge: "top" };
|
||||
}
|
||||
return null;
|
||||
case "above":
|
||||
return placement.layerId === layerId ? { kind: "line", edge: "top" } : null;
|
||||
case "below":
|
||||
return placement.layerId === layerId ? { kind: "line", edge: "bottom" } : null;
|
||||
}
|
||||
}
|
||||
@@ -10,8 +10,10 @@ import {
|
||||
resolveTimelineAutoScroll,
|
||||
resolveTimelineMove,
|
||||
resolveTimelineResize,
|
||||
snapKeyframePctToBeat,
|
||||
type TimelinePromptElement,
|
||||
} from "./timelineEditing";
|
||||
import { buildStackingTimelineLayers } from "./timelineTrackOrder";
|
||||
|
||||
describe("resolveTimelineMove", () => {
|
||||
it("moves timing based on horizontal drag and snaps to centiseconds", () => {
|
||||
@@ -155,6 +157,67 @@ describe("resolveTimelineMove", () => {
|
||||
),
|
||||
).toEqual({ start: 2, track: 2 });
|
||||
});
|
||||
|
||||
it("snaps conflicting vertical stacking movement to a new lane without changing data-track-index", () => {
|
||||
const stackingElements = [
|
||||
{
|
||||
id: "root-front",
|
||||
tag: "div",
|
||||
start: 0,
|
||||
duration: 2,
|
||||
track: 0,
|
||||
zIndex: 2,
|
||||
hasExplicitZIndex: true,
|
||||
stackingContextId: "root",
|
||||
parentCompositionId: null,
|
||||
compositionAncestors: ["root"],
|
||||
},
|
||||
{
|
||||
id: "root-back",
|
||||
tag: "div",
|
||||
start: 0,
|
||||
duration: 2,
|
||||
track: 1,
|
||||
zIndex: 1,
|
||||
hasExplicitZIndex: true,
|
||||
stackingContextId: "root",
|
||||
parentCompositionId: null,
|
||||
compositionAncestors: ["root"],
|
||||
},
|
||||
];
|
||||
const layers = buildStackingTimelineLayers(stackingElements).rows;
|
||||
const result = resolveTimelineMove(
|
||||
{
|
||||
start: 0,
|
||||
track: 1,
|
||||
duration: 2,
|
||||
originClientX: 0,
|
||||
originClientY: 0,
|
||||
pixelsPerSecond: 100,
|
||||
trackHeight: 72,
|
||||
maxStart: 8,
|
||||
trackOrder: [0, 1],
|
||||
layerOrder: layers.map((layer) => layer.id),
|
||||
timelineLayers: layers,
|
||||
stackingElement: stackingElements[1],
|
||||
stackingElements,
|
||||
},
|
||||
0,
|
||||
-72,
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
start: 0,
|
||||
track: 1,
|
||||
previewLayerId: `preview:root-back:above:${layers[0]!.id}`,
|
||||
previewLayerIndex: 0,
|
||||
stackingReorder: {
|
||||
contextKey: "root",
|
||||
placement: { type: "above", layerId: layers[0]!.id },
|
||||
zIndexChanges: [{ key: "root-back", zIndex: 3 }],
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasPatchableTimelineTarget", () => {
|
||||
@@ -613,3 +676,34 @@ describe("buildPromptCopyText", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("snapKeyframePctToBeat", () => {
|
||||
// el spans 0–10s, so clip-% maps to composition time as pct * 0.1s.
|
||||
// At pps=100 the snap window is 8 / 100 = 0.08s.
|
||||
const el = { start: 0, duration: 10 };
|
||||
const beats = [2, 5, 8];
|
||||
|
||||
it("snaps a keyframe within ~8px of a beat exactly onto it", () => {
|
||||
// pct 50.5 → 5.05s, 0.05s from the beat at 5s (inside 0.08s window) → 50%.
|
||||
expect(snapKeyframePctToBeat(el, 50.5, beats, 100)).toBe(50);
|
||||
});
|
||||
|
||||
it("leaves a keyframe unchanged when no beat is within the window", () => {
|
||||
// pct 55 → 5.5s, 0.5s from the nearest beat → free.
|
||||
expect(snapKeyframePctToBeat(el, 55, beats, 100)).toBe(55);
|
||||
});
|
||||
|
||||
it("is a no-op when there are no beats", () => {
|
||||
expect(snapKeyframePctToBeat(el, 50.5, [], 100)).toBe(50.5);
|
||||
expect(snapKeyframePctToBeat(el, 50.5, undefined, 100)).toBe(50.5);
|
||||
});
|
||||
|
||||
it("is a no-op for a zero-duration clip", () => {
|
||||
expect(snapKeyframePctToBeat({ start: 0, duration: 0 }, 50.5, beats, 100)).toBe(50.5);
|
||||
});
|
||||
|
||||
it("widens the snap window as zoom (pps) decreases", () => {
|
||||
// pct 53 → 5.3s, 0.3s from the beat at 5s. At pps=20 the window is 0.4s → snaps to 50%.
|
||||
expect(snapKeyframePctToBeat(el, 53, beats, 20)).toBe(50);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import { formatTime } from "../lib/time";
|
||||
import { roundToCenti } from "../../utils/rounding";
|
||||
import type { StackingTimelineLayer, TimelineLayerId } from "./timelineTrackOrder";
|
||||
import { resolveTimelineLayerStackingMove } from "./timelineLayerDrag";
|
||||
import type { TimelineStackingElement, TimelineStackingReorderIntent } from "./timelineStacking";
|
||||
|
||||
export {
|
||||
type TimelineStackingElement,
|
||||
type TimelineStackingReorderIntent,
|
||||
} from "./timelineStacking";
|
||||
|
||||
const roundToCentiseconds = roundToCenti;
|
||||
|
||||
@@ -25,6 +33,12 @@ export interface TimelineMoveInput {
|
||||
trackHeight: number;
|
||||
maxStart: number;
|
||||
trackOrder: number[];
|
||||
layerOrder?: TimelineLayerId[];
|
||||
timelineLayers?: StackingTimelineLayer[];
|
||||
/** When provided, vertical movement is resolved as a z-index stacking reorder
|
||||
* within `stackingElement`'s context instead of a raw track change. */
|
||||
stackingElement?: TimelineStackingElement;
|
||||
stackingElements?: TimelineStackingElement[];
|
||||
}
|
||||
|
||||
export interface TimelineResizeInput {
|
||||
@@ -73,7 +87,13 @@ export function resolveTimelineMove(
|
||||
input: TimelineMoveInput,
|
||||
clientX: number,
|
||||
clientY: number,
|
||||
): { start: number; track: number } {
|
||||
): {
|
||||
start: number;
|
||||
track: number;
|
||||
previewLayerId?: TimelineLayerId;
|
||||
previewLayerIndex?: number;
|
||||
stackingReorder?: TimelineStackingReorderIntent | null;
|
||||
} {
|
||||
const scrollDeltaX = (input.currentScrollLeft ?? 0) - (input.originScrollLeft ?? 0);
|
||||
const scrollDeltaY = (input.currentScrollTop ?? 0) - (input.originScrollTop ?? 0);
|
||||
const deltaTime =
|
||||
@@ -81,6 +101,36 @@ export function resolveTimelineMove(
|
||||
const trackDeltaRaw =
|
||||
(clientY - input.originClientY + scrollDeltaY) / Math.max(input.trackHeight, 1);
|
||||
const deltaTrack = Math.round(trackDeltaRaw);
|
||||
const nextStart = clamp(
|
||||
roundToCentiseconds(input.start + deltaTime),
|
||||
0,
|
||||
Math.max(0, input.maxStart),
|
||||
);
|
||||
|
||||
// Stacking mode: the two axes never fight. Horizontal movement writes time
|
||||
// (nextStart); vertical movement writes z-index. Lane/overlap resolution
|
||||
// uses the clip's authored time span, NOT the dragged start — otherwise a
|
||||
// diagonal drag that drifts the clip out of overlap silently flips the
|
||||
// placement from "restack" to "join lane" and cancels the reorder.
|
||||
if (input.stackingElement) {
|
||||
const layerMove =
|
||||
input.timelineLayers && input.layerOrder
|
||||
? resolveTimelineLayerStackingMove({
|
||||
element: { ...input.stackingElement, duration: input.duration },
|
||||
layers: input.timelineLayers,
|
||||
layerOrder: input.layerOrder,
|
||||
trackDeltaRaw,
|
||||
})
|
||||
: null;
|
||||
return {
|
||||
start: nextStart,
|
||||
track: input.track,
|
||||
previewLayerId: layerMove?.previewLayerId,
|
||||
previewLayerIndex: layerMove?.previewLayerIndex,
|
||||
stackingReorder: layerMove?.stackingReorder ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
const currentTrackIndex = Math.max(0, input.trackOrder.indexOf(input.track));
|
||||
const desiredTrackIndex = currentTrackIndex + deltaTrack;
|
||||
const nextTrackIndex = clamp(desiredTrackIndex, 0, Math.max(0, input.trackOrder.length - 1));
|
||||
@@ -106,7 +156,7 @@ export function resolveTimelineMove(
|
||||
}
|
||||
|
||||
return {
|
||||
start: clamp(roundToCentiseconds(input.start + deltaTime), 0, Math.max(0, input.maxStart)),
|
||||
start: nextStart,
|
||||
track: nextTrack,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { TimelineElement } from "../store/playerStore";
|
||||
import type { StackingTimelineLayer } from "./timelineTrackOrder";
|
||||
import {
|
||||
resolveTimelineLayerStackingMove,
|
||||
resolveTimelineLayerZIndexChanges,
|
||||
} from "./timelineLayerDrag";
|
||||
|
||||
function element(input: {
|
||||
id: string;
|
||||
zIndex: number;
|
||||
tag?: string;
|
||||
start?: number;
|
||||
duration?: number;
|
||||
}): TimelineElement {
|
||||
return {
|
||||
id: input.id,
|
||||
tag: input.tag ?? "div",
|
||||
start: input.start ?? 0,
|
||||
duration: input.duration ?? 1,
|
||||
track: 0,
|
||||
zIndex: input.zIndex,
|
||||
hasExplicitZIndex: true,
|
||||
stackingContextId: "root",
|
||||
parentCompositionId: null,
|
||||
compositionAncestors: ["root"],
|
||||
};
|
||||
}
|
||||
|
||||
function layer(
|
||||
id: string,
|
||||
zIndex: number,
|
||||
elements: TimelineElement[] = [element({ id, zIndex })],
|
||||
): StackingTimelineLayer {
|
||||
return {
|
||||
id,
|
||||
kind: "visual",
|
||||
contextKey: "root",
|
||||
zIndex,
|
||||
placementTrack: 0,
|
||||
elements,
|
||||
};
|
||||
}
|
||||
|
||||
describe("resolveTimelineLayerZIndexChanges", () => {
|
||||
it("joins an existing lane by assigning the dragged clip that lane's z-index", () => {
|
||||
const dragged = element({ id: "dragged", zIndex: 1, start: 2, duration: 1 });
|
||||
const front = element({ id: "front", zIndex: 10, start: 0, duration: 1 });
|
||||
|
||||
expect(
|
||||
resolveTimelineLayerZIndexChanges({
|
||||
element: dragged,
|
||||
layers: [layer("front", 10, [front]), layer("back", 1)],
|
||||
placement: { type: "onto", layerId: "front" },
|
||||
})?.zIndexChanges,
|
||||
).toEqual([{ key: "dragged", zIndex: 10 }]);
|
||||
});
|
||||
|
||||
it("rejects an onto-lane join when the dragged clip would overlap that lane", () => {
|
||||
const dragged = element({ id: "dragged", zIndex: 1, start: 0.5, duration: 1 });
|
||||
const front = element({ id: "front", zIndex: 10, start: 0, duration: 1 });
|
||||
|
||||
expect(
|
||||
resolveTimelineLayerZIndexChanges({
|
||||
element: dragged,
|
||||
layers: [layer("front", 10, [front]), layer("back", 1)],
|
||||
placement: { type: "onto", layerId: "front" },
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("interpolates a new integer z-index strictly between neighboring layers", () => {
|
||||
const dragged = element({ id: "dragged", zIndex: 1 });
|
||||
|
||||
expect(
|
||||
resolveTimelineLayerZIndexChanges({
|
||||
element: dragged,
|
||||
layers: [layer("front", 10), layer("back", 4)],
|
||||
placement: { type: "between", beforeLayerId: "front", afterLayerId: "back" },
|
||||
})?.zIndexChanges,
|
||||
).toEqual([{ key: "dragged", zIndex: 7 }]);
|
||||
});
|
||||
|
||||
it("renumbers the minimum sibling set when adjacent layers leave no integer gap", () => {
|
||||
const dragged = element({ id: "dragged", zIndex: 0 });
|
||||
|
||||
expect(
|
||||
resolveTimelineLayerZIndexChanges({
|
||||
element: dragged,
|
||||
layers: [layer("front", 2), layer("back", 1), layer("lower", 0)],
|
||||
placement: { type: "between", beforeLayerId: "front", afterLayerId: "back" },
|
||||
})?.zIndexChanges,
|
||||
).toEqual([
|
||||
{ key: "dragged", zIndex: 2 },
|
||||
{ key: "front", zIndex: 3 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("assigns new extreme z-index values above the top and below the bottom layer", () => {
|
||||
const dragged = element({ id: "dragged", zIndex: 0 });
|
||||
const layers = [layer("front", 10), layer("back", -2)];
|
||||
|
||||
expect(
|
||||
resolveTimelineLayerZIndexChanges({
|
||||
element: dragged,
|
||||
layers,
|
||||
placement: { type: "above", layerId: "front" },
|
||||
})?.zIndexChanges,
|
||||
).toEqual([{ key: "dragged", zIndex: 11 }]);
|
||||
|
||||
expect(
|
||||
resolveTimelineLayerZIndexChanges({
|
||||
element: dragged,
|
||||
layers,
|
||||
placement: { type: "below", layerId: "back" },
|
||||
})?.zIndexChanges,
|
||||
).toEqual([{ key: "dragged", zIndex: -3 }]);
|
||||
});
|
||||
|
||||
it("does not resolve stacking z-index changes for audio clips", () => {
|
||||
expect(
|
||||
resolveTimelineLayerZIndexChanges({
|
||||
element: element({ id: "music", zIndex: 0, tag: "audio" }),
|
||||
layers: [layer("front", 10)],
|
||||
placement: { type: "onto", layerId: "front" },
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveTimelineLayerStackingMove", () => {
|
||||
it("places an upward onto-lane drop above the overlapping target lane", () => {
|
||||
const front = element({ id: "front", zIndex: 10, start: 0, duration: 2 });
|
||||
const dragged = element({ id: "dragged", zIndex: 1, start: 0.5, duration: 1 });
|
||||
const layers = [layer("front", 10, [front]), layer("dragged", 1, [dragged])];
|
||||
|
||||
expect(
|
||||
resolveTimelineLayerStackingMove({
|
||||
element: dragged,
|
||||
layers,
|
||||
layerOrder: layers.map((item) => item.id),
|
||||
trackDeltaRaw: -0.8,
|
||||
}),
|
||||
).toEqual({
|
||||
previewLayerId: "preview:dragged:above:front",
|
||||
previewLayerIndex: 0,
|
||||
stackingReorder: {
|
||||
contextKey: "root",
|
||||
placement: { type: "above", layerId: "front" },
|
||||
zIndexChanges: [{ key: "dragged", zIndex: 11 }],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("places a downward onto-lane drop below the overlapping target lane", () => {
|
||||
const dragged = element({ id: "dragged", zIndex: 10, start: 0.5, duration: 1 });
|
||||
const back = element({ id: "back", zIndex: 1, start: 0, duration: 2 });
|
||||
const layers = [layer("dragged", 10, [dragged]), layer("back", 1, [back])];
|
||||
|
||||
expect(
|
||||
resolveTimelineLayerStackingMove({
|
||||
element: dragged,
|
||||
layers,
|
||||
layerOrder: layers.map((item) => item.id),
|
||||
trackDeltaRaw: 0.8,
|
||||
}),
|
||||
).toEqual({
|
||||
previewLayerId: "preview:dragged:below:back",
|
||||
previewLayerIndex: 2,
|
||||
stackingReorder: {
|
||||
contextKey: "root",
|
||||
placement: { type: "below", layerId: "back" },
|
||||
zIndexChanges: [{ key: "dragged", zIndex: 0 }],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps a non-overlapping onto-lane drop joined to the target lane", () => {
|
||||
const front = element({ id: "front", zIndex: 10, start: 0, duration: 1 });
|
||||
const dragged = element({ id: "dragged", zIndex: 1, start: 2, duration: 1 });
|
||||
const layers = [layer("front", 10, [front]), layer("dragged", 1, [dragged])];
|
||||
|
||||
expect(
|
||||
resolveTimelineLayerStackingMove({
|
||||
element: dragged,
|
||||
layers,
|
||||
layerOrder: layers.map((item) => item.id),
|
||||
trackDeltaRaw: -0.8,
|
||||
}),
|
||||
).toEqual({
|
||||
previewLayerId: "front",
|
||||
previewLayerIndex: 0,
|
||||
stackingReorder: {
|
||||
contextKey: "root",
|
||||
placement: { type: "onto", layerId: "front" },
|
||||
zIndexChanges: [{ key: "dragged", zIndex: 10 }],
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,383 @@
|
||||
import { resolveStackingContextKey } from "../lib/layerOrdering";
|
||||
import { getTimelineElementIdentity } from "../lib/timelineElementHelpers";
|
||||
import {
|
||||
timelineElementsOverlap,
|
||||
type StackingTimelineLayer,
|
||||
type TimelineLayerId,
|
||||
} from "./timelineTrackOrder";
|
||||
import {
|
||||
toStackingOrderItem,
|
||||
type TimelineLayerDropPlacement,
|
||||
type TimelineStackingElement,
|
||||
type TimelineStackingReorderIntent,
|
||||
type TimelineStackingZIndexChange,
|
||||
} from "./timelineStacking";
|
||||
|
||||
const ONTO_ROW_THRESHOLD = 0.35;
|
||||
|
||||
export interface TimelineLayerStackingMoveResolution {
|
||||
previewLayerId: TimelineLayerId;
|
||||
previewLayerIndex: number;
|
||||
stackingReorder: TimelineStackingReorderIntent | null;
|
||||
}
|
||||
|
||||
function isAudioElement(element: TimelineStackingElement): boolean {
|
||||
return element.tag?.toLowerCase() === "audio";
|
||||
}
|
||||
|
||||
function layerContainsElement(layer: StackingTimelineLayer, key: string): boolean {
|
||||
return layer.elements.some((element) => getTimelineElementIdentity(element) === key);
|
||||
}
|
||||
|
||||
function layerConflictsWithElement(
|
||||
layer: StackingTimelineLayer,
|
||||
element: TimelineStackingElement,
|
||||
draggedKey: string,
|
||||
): boolean {
|
||||
return layer.elements.some(
|
||||
(candidate) =>
|
||||
getTimelineElementIdentity(candidate) !== draggedKey &&
|
||||
timelineElementsOverlap(candidate, element),
|
||||
);
|
||||
}
|
||||
|
||||
function addElementChange(
|
||||
changes: TimelineStackingZIndexChange[],
|
||||
element: TimelineStackingElement,
|
||||
zIndex: number,
|
||||
): void {
|
||||
if ((element.zIndex ?? 0) === zIndex) return;
|
||||
changes.push({
|
||||
key: getTimelineElementIdentity(element),
|
||||
zIndex,
|
||||
domId: element.domId,
|
||||
selector: element.selector,
|
||||
selectorIndex: element.selectorIndex,
|
||||
sourceFile: element.sourceFile,
|
||||
});
|
||||
}
|
||||
|
||||
function addLayerChanges(
|
||||
changes: TimelineStackingZIndexChange[],
|
||||
layer: StackingTimelineLayer,
|
||||
zIndex: number,
|
||||
excludedKey: string,
|
||||
): number {
|
||||
let count = 0;
|
||||
for (const element of layer.elements) {
|
||||
const key = getTimelineElementIdentity(element);
|
||||
if (key === excludedKey) continue;
|
||||
const before = changes.length;
|
||||
addElementChange(changes, element, zIndex);
|
||||
if (changes.length > before) count += 1;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
function getContextLayers(
|
||||
layers: readonly StackingTimelineLayer[],
|
||||
contextKey: string,
|
||||
): StackingTimelineLayer[] {
|
||||
return layers.filter((layer) => layer.kind === "visual" && layer.contextKey === contextKey);
|
||||
}
|
||||
|
||||
function findLayer(
|
||||
layers: readonly StackingTimelineLayer[],
|
||||
id: string,
|
||||
): StackingTimelineLayer | null {
|
||||
return layers.find((layer) => layer.id === id) ?? null;
|
||||
}
|
||||
|
||||
function resolvePlacementZIndexChanges(input: {
|
||||
element: TimelineStackingElement;
|
||||
targetZIndex: number;
|
||||
}): TimelineStackingZIndexChange[] {
|
||||
const changes: TimelineStackingZIndexChange[] = [];
|
||||
addElementChange(changes, input.element, input.targetZIndex);
|
||||
return changes;
|
||||
}
|
||||
|
||||
function buildPushUpCandidate(input: {
|
||||
element: TimelineStackingElement;
|
||||
layers: readonly StackingTimelineLayer[];
|
||||
beforeIndex: number;
|
||||
bottomZIndex: number;
|
||||
}): { changes: TimelineStackingZIndexChange[]; siblingChanges: number } {
|
||||
const draggedKey = getTimelineElementIdentity(input.element);
|
||||
const draggedZIndex = input.bottomZIndex + 1;
|
||||
const changes = resolvePlacementZIndexChanges({
|
||||
element: input.element,
|
||||
targetZIndex: draggedZIndex,
|
||||
});
|
||||
let siblingChanges = 0;
|
||||
let requiredZIndex = draggedZIndex + 1;
|
||||
for (let index = input.beforeIndex; index >= 0; index -= 1) {
|
||||
const layer = input.layers[index];
|
||||
if (!layer) continue;
|
||||
const nextZIndex = layer.zIndex >= requiredZIndex ? layer.zIndex : requiredZIndex;
|
||||
siblingChanges += addLayerChanges(changes, layer, nextZIndex, draggedKey);
|
||||
requiredZIndex = nextZIndex + 1;
|
||||
}
|
||||
return { changes, siblingChanges };
|
||||
}
|
||||
|
||||
function buildPushDownCandidate(input: {
|
||||
element: TimelineStackingElement;
|
||||
layers: readonly StackingTimelineLayer[];
|
||||
afterIndex: number;
|
||||
topZIndex: number;
|
||||
}): { changes: TimelineStackingZIndexChange[]; siblingChanges: number } {
|
||||
const draggedKey = getTimelineElementIdentity(input.element);
|
||||
const draggedZIndex = input.topZIndex - 1;
|
||||
const changes = resolvePlacementZIndexChanges({
|
||||
element: input.element,
|
||||
targetZIndex: draggedZIndex,
|
||||
});
|
||||
let siblingChanges = 0;
|
||||
let requiredZIndex = draggedZIndex - 1;
|
||||
for (let index = input.afterIndex; index < input.layers.length; index += 1) {
|
||||
const layer = input.layers[index];
|
||||
if (!layer) continue;
|
||||
const nextZIndex = layer.zIndex <= requiredZIndex ? layer.zIndex : requiredZIndex;
|
||||
siblingChanges += addLayerChanges(changes, layer, nextZIndex, draggedKey);
|
||||
requiredZIndex = nextZIndex - 1;
|
||||
}
|
||||
return { changes, siblingChanges };
|
||||
}
|
||||
|
||||
function resolveBetweenChanges(input: {
|
||||
element: TimelineStackingElement;
|
||||
layers: readonly StackingTimelineLayer[];
|
||||
beforeLayer: StackingTimelineLayer;
|
||||
afterLayer: StackingTimelineLayer;
|
||||
}): TimelineStackingZIndexChange[] {
|
||||
const topZIndex = input.beforeLayer.zIndex;
|
||||
const bottomZIndex = input.afterLayer.zIndex;
|
||||
if (topZIndex - bottomZIndex > 1) {
|
||||
return resolvePlacementZIndexChanges({
|
||||
element: input.element,
|
||||
targetZIndex: Math.floor((topZIndex + bottomZIndex) / 2),
|
||||
});
|
||||
}
|
||||
|
||||
const beforeIndex = input.layers.findIndex((layer) => layer.id === input.beforeLayer.id);
|
||||
const afterIndex = input.layers.findIndex((layer) => layer.id === input.afterLayer.id);
|
||||
const pushUp = buildPushUpCandidate({
|
||||
element: input.element,
|
||||
layers: input.layers,
|
||||
beforeIndex,
|
||||
bottomZIndex,
|
||||
});
|
||||
const pushDown = buildPushDownCandidate({
|
||||
element: input.element,
|
||||
layers: input.layers,
|
||||
afterIndex,
|
||||
topZIndex,
|
||||
});
|
||||
return pushUp.siblingChanges <= pushDown.siblingChanges ? pushUp.changes : pushDown.changes;
|
||||
}
|
||||
|
||||
function resolveOntoChanges(input: {
|
||||
element: TimelineStackingElement;
|
||||
layers: readonly StackingTimelineLayer[];
|
||||
layerId: string;
|
||||
}): TimelineStackingZIndexChange[] | null {
|
||||
const target = findLayer(input.layers, input.layerId);
|
||||
if (!target) return null;
|
||||
if (layerConflictsWithElement(target, input.element, getTimelineElementIdentity(input.element))) {
|
||||
return null;
|
||||
}
|
||||
return resolvePlacementZIndexChanges({
|
||||
element: input.element,
|
||||
targetZIndex: target.zIndex,
|
||||
});
|
||||
}
|
||||
|
||||
function resolveEdgeChanges(input: {
|
||||
element: TimelineStackingElement;
|
||||
layers: readonly StackingTimelineLayer[];
|
||||
layerId: string;
|
||||
offset: number;
|
||||
}): TimelineStackingZIndexChange[] | null {
|
||||
const target = findLayer(input.layers, input.layerId);
|
||||
if (!target) return null;
|
||||
return resolvePlacementZIndexChanges({
|
||||
element: input.element,
|
||||
targetZIndex: target.zIndex + input.offset,
|
||||
});
|
||||
}
|
||||
|
||||
function resolvePlacementChanges(input: {
|
||||
element: TimelineStackingElement;
|
||||
layers: readonly StackingTimelineLayer[];
|
||||
placement: TimelineLayerDropPlacement;
|
||||
}): TimelineStackingZIndexChange[] | null {
|
||||
switch (input.placement.type) {
|
||||
case "onto":
|
||||
return resolveOntoChanges({
|
||||
element: input.element,
|
||||
layers: input.layers,
|
||||
layerId: input.placement.layerId,
|
||||
});
|
||||
case "above":
|
||||
return resolveEdgeChanges({
|
||||
element: input.element,
|
||||
layers: input.layers,
|
||||
layerId: input.placement.layerId,
|
||||
offset: 1,
|
||||
});
|
||||
case "below":
|
||||
return resolveEdgeChanges({
|
||||
element: input.element,
|
||||
layers: input.layers,
|
||||
layerId: input.placement.layerId,
|
||||
offset: -1,
|
||||
});
|
||||
case "between": {
|
||||
const beforeLayer = findLayer(input.layers, input.placement.beforeLayerId);
|
||||
const afterLayer = findLayer(input.layers, input.placement.afterLayerId);
|
||||
return beforeLayer && afterLayer
|
||||
? resolveBetweenChanges({
|
||||
element: input.element,
|
||||
layers: input.layers,
|
||||
beforeLayer,
|
||||
afterLayer,
|
||||
})
|
||||
: null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveTimelineLayerZIndexChanges(input: {
|
||||
element: TimelineStackingElement;
|
||||
layers: readonly StackingTimelineLayer[];
|
||||
placement: TimelineLayerDropPlacement;
|
||||
}): TimelineStackingReorderIntent | null {
|
||||
if (isAudioElement(input.element)) return null;
|
||||
const contextKey = resolveStackingContextKey(toStackingOrderItem(input.element));
|
||||
const layers = getContextLayers(input.layers, contextKey);
|
||||
const changes = resolvePlacementChanges({
|
||||
element: input.element,
|
||||
layers,
|
||||
placement: input.placement,
|
||||
});
|
||||
|
||||
return changes && changes.length > 0
|
||||
? { contextKey, placement: input.placement, zIndexChanges: changes }
|
||||
: null;
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
function resolveDragPlacement(
|
||||
layers: readonly StackingTimelineLayer[],
|
||||
targetPosition: number,
|
||||
): TimelineLayerDropPlacement | null {
|
||||
const first = layers[0];
|
||||
const last = layers[layers.length - 1];
|
||||
if (!first || !last) return null;
|
||||
if (targetPosition < -ONTO_ROW_THRESHOLD) return { type: "above", layerId: first.id };
|
||||
if (targetPosition > layers.length - 1 + ONTO_ROW_THRESHOLD) {
|
||||
return { type: "below", layerId: last.id };
|
||||
}
|
||||
|
||||
const nearestIndex = Math.round(targetPosition);
|
||||
const nearest = layers[nearestIndex];
|
||||
if (nearest && Math.abs(targetPosition - nearestIndex) <= ONTO_ROW_THRESHOLD) {
|
||||
return { type: "onto", layerId: nearest.id };
|
||||
}
|
||||
|
||||
const insertionIndex = Math.max(0, Math.min(layers.length, Math.ceil(targetPosition)));
|
||||
if (insertionIndex <= 0) return { type: "above", layerId: first.id };
|
||||
if (insertionIndex >= layers.length) return { type: "below", layerId: last.id };
|
||||
const before = layers[insertionIndex - 1];
|
||||
const after = layers[insertionIndex];
|
||||
return before && after
|
||||
? { type: "between", beforeLayerId: before.id, afterLayerId: after.id }
|
||||
: null;
|
||||
}
|
||||
|
||||
function resolveLaneAwareDragPlacement(input: {
|
||||
layers: readonly StackingTimelineLayer[];
|
||||
element: TimelineStackingElement;
|
||||
draggedKey: string;
|
||||
placement: TimelineLayerDropPlacement;
|
||||
targetPosition: number;
|
||||
currentIndex: number;
|
||||
}): TimelineLayerDropPlacement | null {
|
||||
if (input.placement.type !== "onto") return input.placement;
|
||||
const target = findLayer(input.layers, input.placement.layerId);
|
||||
if (!target) return null;
|
||||
if (!layerConflictsWithElement(target, input.element, input.draggedKey)) return input.placement;
|
||||
if (input.targetPosition < input.currentIndex) {
|
||||
return { type: "above", layerId: target.id };
|
||||
}
|
||||
if (input.targetPosition > input.currentIndex) {
|
||||
return { type: "below", layerId: target.id };
|
||||
}
|
||||
return input.placement;
|
||||
}
|
||||
|
||||
function getPreviewLayerId(
|
||||
draggedKey: string,
|
||||
placement: TimelineLayerDropPlacement,
|
||||
): TimelineLayerId {
|
||||
if (placement.type === "onto") return placement.layerId;
|
||||
if (placement.type === "between") {
|
||||
return `preview:${draggedKey}:between:${placement.beforeLayerId}:${placement.afterLayerId}`;
|
||||
}
|
||||
return `preview:${draggedKey}:${placement.type}:${placement.layerId}`;
|
||||
}
|
||||
|
||||
function getPreviewLayerIndex(
|
||||
layerOrder: readonly TimelineLayerId[],
|
||||
placement: TimelineLayerDropPlacement,
|
||||
): number {
|
||||
if (placement.type === "onto") return Math.max(0, layerOrder.indexOf(placement.layerId));
|
||||
if (placement.type === "between") {
|
||||
return Math.max(0, layerOrder.indexOf(placement.afterLayerId));
|
||||
}
|
||||
const targetIndex = Math.max(0, layerOrder.indexOf(placement.layerId));
|
||||
return placement.type === "above" ? targetIndex : targetIndex + 1;
|
||||
}
|
||||
|
||||
export function resolveTimelineLayerStackingMove(input: {
|
||||
element: TimelineStackingElement;
|
||||
layers: readonly StackingTimelineLayer[];
|
||||
layerOrder: readonly TimelineLayerId[];
|
||||
trackDeltaRaw: number;
|
||||
}): TimelineLayerStackingMoveResolution | null {
|
||||
if (isAudioElement(input.element)) return null;
|
||||
const contextKey = resolveStackingContextKey(toStackingOrderItem(input.element));
|
||||
const layerById = new Map(input.layers.map((layer) => [layer.id, layer]));
|
||||
const contextLayers = input.layerOrder
|
||||
.map((id) => layerById.get(id) ?? null)
|
||||
.filter(
|
||||
(layer): layer is StackingTimelineLayer =>
|
||||
layer != null && layer.kind === "visual" && layer.contextKey === contextKey,
|
||||
);
|
||||
const draggedKey = getTimelineElementIdentity(input.element);
|
||||
const currentIndex = contextLayers.findIndex((layer) => layerContainsElement(layer, draggedKey));
|
||||
if (currentIndex < 0) return null;
|
||||
|
||||
const targetPosition = currentIndex + input.trackDeltaRaw;
|
||||
const rawPlacement = resolveDragPlacement(contextLayers, targetPosition);
|
||||
if (!rawPlacement) return null;
|
||||
const placement = resolveLaneAwareDragPlacement({
|
||||
layers: contextLayers,
|
||||
element: input.element,
|
||||
draggedKey,
|
||||
placement: rawPlacement,
|
||||
targetPosition,
|
||||
currentIndex,
|
||||
});
|
||||
if (!placement) return null;
|
||||
return {
|
||||
previewLayerId: getPreviewLayerId(draggedKey, placement),
|
||||
previewLayerIndex: getPreviewLayerIndex(input.layerOrder, placement),
|
||||
stackingReorder: resolveTimelineLayerZIndexChanges({
|
||||
element: input.element,
|
||||
layers: contextLayers,
|
||||
placement,
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { computeTimelineBasisDuration, computeTimelineEffectiveDuration } from "./timelineLayout";
|
||||
|
||||
describe("computeTimelineBasisDuration", () => {
|
||||
it("uses the root duration when it exceeds every clip end", () => {
|
||||
expect(computeTimelineBasisDuration(12, [4, 6, 9])).toBe(12);
|
||||
});
|
||||
|
||||
it("grows to the furthest committed clip end past the root duration", () => {
|
||||
expect(computeTimelineBasisDuration(12, [4, 18, 9])).toBe(18);
|
||||
});
|
||||
|
||||
it("falls back to the root duration with no clips / non-finite ends", () => {
|
||||
expect(computeTimelineBasisDuration(10, [])).toBe(10);
|
||||
expect(computeTimelineBasisDuration(Number.NaN, [])).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeTimelineEffectiveDuration", () => {
|
||||
it("returns the basis when there is no active preview", () => {
|
||||
expect(computeTimelineEffectiveDuration(12, [null, null])).toBe(12);
|
||||
});
|
||||
|
||||
it("extends to a drag/resize preview end beyond the basis", () => {
|
||||
expect(computeTimelineEffectiveDuration(12, [20, null])).toBe(20);
|
||||
expect(computeTimelineEffectiveDuration(12, [null, 16])).toBe(16);
|
||||
});
|
||||
|
||||
it("never shrinks below the basis for a preview inside the current length", () => {
|
||||
// The invariant behind the jump fix: the basis (which drives zoom) is
|
||||
// independent of the preview, and a smaller preview end can't reduce it.
|
||||
expect(computeTimelineEffectiveDuration(12, [8])).toBe(12);
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,37 @@ export const CLIP_Y = 3;
|
||||
export const CLIP_HANDLE_W = 18;
|
||||
const TIMELINE_SCROLL_BUFFER = 20;
|
||||
|
||||
/* ── Timeline duration ─────────────────────────────────────────────── */
|
||||
|
||||
// Committed timeline length: root duration or the furthest committed clip end,
|
||||
// with NO live drag/resize preview. This drives the zoom (fit-to-width pps) so
|
||||
// the pixels-per-second mapping stays fixed while you drag — otherwise a clip
|
||||
// dragged past the end grows the duration, shrinks pps, and jumps under the
|
||||
// pointer (a positive-feedback loop). The zoom re-fits once on drop.
|
||||
export function computeTimelineBasisDuration(
|
||||
rootDuration: number,
|
||||
clipEnds: readonly number[],
|
||||
): number {
|
||||
const safeDur = Number.isFinite(rootDuration) ? rootDuration : 0;
|
||||
if (clipEnds.length === 0) return safeDur;
|
||||
const maxEnd = Math.max(safeDur, ...clipEnds);
|
||||
return Number.isFinite(maxEnd) ? maxEnd : safeDur;
|
||||
}
|
||||
|
||||
// Displayed length: the basis plus any active drag/resize preview end, so the
|
||||
// ruler and track width grow to follow a clip dragged past the current end
|
||||
// (with the zoom held fixed, the extra length becomes scrollable content).
|
||||
export function computeTimelineEffectiveDuration(
|
||||
basisDuration: number,
|
||||
previewEnds: readonly (number | null)[],
|
||||
): number {
|
||||
let maxEnd = basisDuration;
|
||||
for (const end of previewEnds) {
|
||||
if (end != null && Number.isFinite(end)) maxEnd = Math.max(maxEnd, end);
|
||||
}
|
||||
return maxEnd;
|
||||
}
|
||||
|
||||
/* ── Tick generation ──────────────────────────────────────────────── */
|
||||
function getMajorTickInterval(duration: number, pixelsPerSecond?: number): number {
|
||||
const zoomIntervals = [0.02, 0.05, 0.1, 0.2, 0.5, 1, 2, 5, 10, 15, 30, 60, 120, 300, 600];
|
||||
@@ -56,6 +87,22 @@ export function generateTicks(
|
||||
return { major, minor };
|
||||
}
|
||||
|
||||
/**
|
||||
* Ticks spanning the full visible ruler width, not just the composition, so a
|
||||
* zoomed-out ruler stays filled with labels instead of ending mid-panel. The
|
||||
* major/minor interval is driven by pixelsPerSecond (pixel spacing), so widening
|
||||
* the range keeps spacing identical — it only adds ticks past the content end.
|
||||
*/
|
||||
export function generateVisibleTicks(
|
||||
effectiveDuration: number,
|
||||
pixelsPerSecond: number,
|
||||
viewportWidth: number,
|
||||
gutter: number,
|
||||
): { major: number[]; minor: number[] } {
|
||||
const visible = viewportWidth > gutter ? (viewportWidth - gutter) / pixelsPerSecond : 0;
|
||||
return generateTicks(Math.max(effectiveDuration, visible), pixelsPerSecond);
|
||||
}
|
||||
|
||||
export function formatTimelineTickLabel(time: number, duration: number, majorInterval: number) {
|
||||
if (!Number.isFinite(time)) return "0:00";
|
||||
const safeTime = Math.max(0, time);
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { TimelineElement } from "../store/playerStore";
|
||||
import {
|
||||
buildTimelineSnapTargets,
|
||||
snapEdgesToTargets,
|
||||
snapResizeEdgeToTargets,
|
||||
} from "./timelineSnapTargets";
|
||||
|
||||
function timelineElement(input: {
|
||||
id: string;
|
||||
key?: string;
|
||||
start: number;
|
||||
duration: number;
|
||||
}): TimelineElement {
|
||||
return {
|
||||
id: input.id,
|
||||
key: input.key,
|
||||
tag: "div",
|
||||
start: input.start,
|
||||
duration: input.duration,
|
||||
track: 0,
|
||||
};
|
||||
}
|
||||
|
||||
describe("buildTimelineSnapTargets", () => {
|
||||
it("excludes the dragged clip's own edges", () => {
|
||||
const dragged = timelineElement({
|
||||
id: "dragged-id",
|
||||
key: "dragged-key",
|
||||
start: 1,
|
||||
duration: 2,
|
||||
});
|
||||
const other = timelineElement({ id: "other", start: 4, duration: 2 });
|
||||
|
||||
const targets = buildTimelineSnapTargets({
|
||||
elements: [dragged, other],
|
||||
draggedKey: "dragged-key",
|
||||
playhead: 8,
|
||||
compDuration: 10,
|
||||
beats: [1.5],
|
||||
});
|
||||
|
||||
const times = targets.map((target) => target.time);
|
||||
expect(times).not.toContain(1);
|
||||
expect(times).not.toContain(3);
|
||||
expect(times).toContain(4);
|
||||
expect(times).toContain(6);
|
||||
});
|
||||
|
||||
it("dedupes near-equal times from different sources", () => {
|
||||
const dragged = timelineElement({ id: "dragged", start: 2, duration: 2 });
|
||||
const other = timelineElement({ id: "other", start: 0.0004, duration: 10 });
|
||||
|
||||
const targets = buildTimelineSnapTargets({
|
||||
elements: [dragged, other],
|
||||
draggedKey: "dragged",
|
||||
playhead: 5,
|
||||
compDuration: 10,
|
||||
beats: [0.0002, 10.0002],
|
||||
});
|
||||
|
||||
expect(targets.filter((target) => Math.abs(target.time) < 0.001)).toHaveLength(1);
|
||||
expect(targets.filter((target) => Math.abs(target.time - 10) < 0.001)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("snapEdgesToTargets", () => {
|
||||
it("snaps the start edge to another clip's end", () => {
|
||||
const snap = snapEdgesToTargets(3.95, 2, [{ time: 4, kind: "edge" }], 100);
|
||||
|
||||
expect(snap).toEqual({ start: 4, snapTime: 4, snapKind: "edge" });
|
||||
});
|
||||
|
||||
it("snaps the end edge to another clip's start", () => {
|
||||
const snap = snapEdgesToTargets(2.96, 2, [{ time: 5, kind: "edge" }], 100);
|
||||
|
||||
expect(snap).toEqual({ start: 3, snapTime: 5, snapKind: "edge" });
|
||||
});
|
||||
|
||||
it("snaps to the playhead", () => {
|
||||
const snap = snapEdgesToTargets(2.94, 1, [{ time: 3, kind: "playhead" }], 100);
|
||||
|
||||
expect(snap).toEqual({ start: 3, snapTime: 3, snapKind: "playhead" });
|
||||
});
|
||||
|
||||
it("snaps the start edge to the lower composition bound", () => {
|
||||
const snap = snapEdgesToTargets(0.04, 1, [{ time: 0, kind: "bound" }], 100);
|
||||
|
||||
expect(snap).toEqual({ start: 0, snapTime: 0, snapKind: "bound" });
|
||||
});
|
||||
|
||||
it("snaps the end edge to the upper composition bound", () => {
|
||||
const snap = snapEdgesToTargets(7.96, 2, [{ time: 10, kind: "bound" }], 100);
|
||||
|
||||
expect(snap).toEqual({ start: 8, snapTime: 10, snapKind: "bound" });
|
||||
});
|
||||
|
||||
it("does not snap when targets are beyond the pixel threshold", () => {
|
||||
const snap = snapEdgesToTargets(3.9, 1, [{ time: 4, kind: "edge" }], 100);
|
||||
|
||||
expect(snap).toEqual({ start: 3.9, snapTime: null, snapKind: null });
|
||||
});
|
||||
});
|
||||
|
||||
describe("snapResizeEdgeToTargets", () => {
|
||||
it("does not apply end-edge snaps past maxEnd or below minDuration", () => {
|
||||
expect(
|
||||
snapResizeEdgeToTargets("end", 4, 2.95, [{ time: 7.01, kind: "edge" }], 100, {
|
||||
minDuration: 0.05,
|
||||
maxEnd: 7,
|
||||
}),
|
||||
).toEqual({ start: 4, duration: 2.95, snapTime: null, snapKind: null });
|
||||
|
||||
expect(
|
||||
snapResizeEdgeToTargets("end", 4, 0.1, [{ time: 4.03, kind: "edge" }], 100, {
|
||||
minDuration: 0.05,
|
||||
maxEnd: 10,
|
||||
}),
|
||||
).toEqual({ start: 4, duration: 0.1, snapTime: null, snapKind: null });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,163 @@
|
||||
import type { TimelineElement } from "../store/playerStore";
|
||||
|
||||
export type TimelineSnapKind = "beat" | "edge" | "playhead" | "bound";
|
||||
|
||||
export interface TimelineSnapTarget {
|
||||
time: number;
|
||||
kind: TimelineSnapKind;
|
||||
}
|
||||
|
||||
interface NearestSnap {
|
||||
target: TimelineSnapTarget;
|
||||
distance: number;
|
||||
}
|
||||
|
||||
const SNAP_PX = 8;
|
||||
const DEDUPE_EPSILON_SECONDS = 0.001;
|
||||
const ROUND_FACTOR = 1000;
|
||||
const KIND_PRIORITY: Record<TimelineSnapKind, number> = {
|
||||
bound: 0,
|
||||
playhead: 1,
|
||||
edge: 2,
|
||||
beat: 3,
|
||||
};
|
||||
|
||||
function roundToMillis(value: number): number {
|
||||
return Math.round(value * ROUND_FACTOR) / ROUND_FACTOR;
|
||||
}
|
||||
|
||||
function addTarget(targets: TimelineSnapTarget[], candidate: TimelineSnapTarget) {
|
||||
if (!Number.isFinite(candidate.time)) return;
|
||||
const existingIndex = targets.findIndex(
|
||||
(target) => Math.abs(target.time - candidate.time) < DEDUPE_EPSILON_SECONDS,
|
||||
);
|
||||
if (existingIndex === -1) {
|
||||
targets.push(candidate);
|
||||
return;
|
||||
}
|
||||
|
||||
const existing = targets[existingIndex];
|
||||
if (!existing || KIND_PRIORITY[candidate.kind] >= KIND_PRIORITY[existing.kind]) return;
|
||||
targets[existingIndex] = candidate;
|
||||
}
|
||||
|
||||
export function buildTimelineSnapTargets(input: {
|
||||
elements: TimelineElement[];
|
||||
draggedKey: string;
|
||||
playhead: number;
|
||||
compDuration: number;
|
||||
beats: number[];
|
||||
}): TimelineSnapTarget[] {
|
||||
const targets: TimelineSnapTarget[] = [];
|
||||
|
||||
addTarget(targets, { time: 0, kind: "bound" });
|
||||
addTarget(targets, { time: Math.max(0, input.compDuration), kind: "bound" });
|
||||
addTarget(targets, { time: Math.max(0, input.playhead), kind: "playhead" });
|
||||
|
||||
for (const element of input.elements) {
|
||||
const elementKey = element.key ?? element.id;
|
||||
if (elementKey === input.draggedKey || element.id === input.draggedKey) continue;
|
||||
addTarget(targets, { time: element.start, kind: "edge" });
|
||||
addTarget(targets, { time: element.start + element.duration, kind: "edge" });
|
||||
}
|
||||
|
||||
for (const beat of input.beats) {
|
||||
addTarget(targets, { time: beat, kind: "beat" });
|
||||
}
|
||||
|
||||
return targets.sort((a, b) => a.time - b.time || KIND_PRIORITY[a.kind] - KIND_PRIORITY[b.kind]);
|
||||
}
|
||||
|
||||
function nearestSnap(
|
||||
time: number,
|
||||
targets: TimelineSnapTarget[],
|
||||
thresholdSeconds: number,
|
||||
): NearestSnap | null {
|
||||
let best: NearestSnap | null = null;
|
||||
let bestDistance = thresholdSeconds;
|
||||
for (const target of targets) {
|
||||
if (target.time === time) continue;
|
||||
const distance = Math.abs(target.time - time);
|
||||
if (distance < bestDistance) {
|
||||
bestDistance = distance;
|
||||
best = { target, distance };
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
export function snapEdgesToTargets(
|
||||
start: number,
|
||||
duration: number,
|
||||
targets: TimelineSnapTarget[],
|
||||
pixelsPerSecond: number,
|
||||
options?: { maxStart?: number },
|
||||
): { start: number; snapTime: number | null; snapKind: TimelineSnapKind | null } {
|
||||
const thresholdSeconds = SNAP_PX / Math.max(pixelsPerSecond, 1);
|
||||
const startSnap = nearestSnap(start, targets, thresholdSeconds);
|
||||
const endSnap = nearestSnap(start + duration, targets, thresholdSeconds);
|
||||
|
||||
let candidate = start;
|
||||
let snapTarget: TimelineSnapTarget | null = null;
|
||||
if (startSnap && (!endSnap || startSnap.distance <= endSnap.distance)) {
|
||||
candidate = startSnap.target.time;
|
||||
snapTarget = startSnap.target;
|
||||
} else if (endSnap) {
|
||||
candidate = endSnap.target.time - duration;
|
||||
snapTarget = endSnap.target;
|
||||
}
|
||||
|
||||
const maxStart = options?.maxStart ?? Number.POSITIVE_INFINITY;
|
||||
const upperStart = Number.isFinite(maxStart) ? Math.max(0, maxStart) : Number.POSITIVE_INFINITY;
|
||||
const clamped = Math.max(0, Math.min(upperStart, roundToMillis(candidate)));
|
||||
if (snapTarget && Math.abs(clamped - candidate) > 1e-6) {
|
||||
return { start: clamped, snapTime: null, snapKind: null };
|
||||
}
|
||||
return {
|
||||
start: clamped,
|
||||
snapTime: snapTarget?.time ?? null,
|
||||
snapKind: snapTarget?.kind ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export function snapResizeEdgeToTargets(
|
||||
edge: "start" | "end",
|
||||
start: number,
|
||||
duration: number,
|
||||
targets: TimelineSnapTarget[],
|
||||
pixelsPerSecond: number,
|
||||
limits: { minDuration: number; maxEnd: number; maxLeftDelta?: number },
|
||||
): { start: number; duration: number; snapTime: number | null; snapKind: TimelineSnapKind | null } {
|
||||
const thresholdSeconds = SNAP_PX / Math.max(pixelsPerSecond, 1);
|
||||
|
||||
if (edge === "end") {
|
||||
const snap = nearestSnap(start + duration, targets, thresholdSeconds);
|
||||
if (!snap) return { start, duration, snapTime: null, snapKind: null };
|
||||
const snappedDuration = roundToMillis(snap.target.time - start);
|
||||
if (snap.target.time > limits.maxEnd + 1e-6 || snappedDuration < limits.minDuration) {
|
||||
return { start, duration, snapTime: null, snapKind: null };
|
||||
}
|
||||
return {
|
||||
start,
|
||||
duration: snappedDuration,
|
||||
snapTime: snap.target.time,
|
||||
snapKind: snap.target.kind,
|
||||
};
|
||||
}
|
||||
|
||||
const snap = nearestSnap(start, targets, thresholdSeconds);
|
||||
if (!snap) return { start, duration, snapTime: null, snapKind: null };
|
||||
const snappedStart = roundToMillis(snap.target.time);
|
||||
const delta = start - snappedStart;
|
||||
const snappedDuration = roundToMillis(duration + delta);
|
||||
const maxLeftDelta = limits.maxLeftDelta ?? Number.POSITIVE_INFINITY;
|
||||
if (snappedStart < 0 || delta > maxLeftDelta + 1e-6 || snappedDuration < limits.minDuration) {
|
||||
return { start, duration, snapTime: null, snapKind: null };
|
||||
}
|
||||
return {
|
||||
start: snappedStart,
|
||||
duration: snappedDuration,
|
||||
snapTime: snap.target.time,
|
||||
snapKind: snap.target.kind,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { getTimelineElementIdentity } from "../lib/timelineElementHelpers";
|
||||
|
||||
export interface TimelineStackingElement {
|
||||
id: string;
|
||||
key?: string;
|
||||
tag?: string;
|
||||
start: number;
|
||||
duration: number;
|
||||
track: number;
|
||||
zIndex?: number;
|
||||
stackingContextId?: string | null;
|
||||
parentCompositionId?: string | null;
|
||||
compositionAncestors?: string[];
|
||||
// Locator for resolving the live element at commit time (sub-comp children
|
||||
// aren't in the top-level element list, so the reorder intent must be
|
||||
// self-contained rather than re-looked-up by identity).
|
||||
domId?: string;
|
||||
selector?: string;
|
||||
selectorIndex?: number;
|
||||
sourceFile?: string;
|
||||
}
|
||||
|
||||
export interface TimelineStackingOrderItem {
|
||||
key: string;
|
||||
track: number;
|
||||
zIndex: number;
|
||||
stackingContextId: string | null;
|
||||
parentCompositionId: string | null;
|
||||
compositionAncestors: readonly string[];
|
||||
}
|
||||
|
||||
export type TimelineLayerDropPlacement =
|
||||
| { type: "onto"; layerId: string }
|
||||
| { type: "between"; beforeLayerId: string; afterLayerId: string }
|
||||
| { type: "above"; layerId: string }
|
||||
| { type: "below"; layerId: string };
|
||||
|
||||
export interface TimelineStackingZIndexChange {
|
||||
key: string;
|
||||
zIndex: number;
|
||||
domId?: string;
|
||||
selector?: string;
|
||||
selectorIndex?: number;
|
||||
sourceFile?: string;
|
||||
}
|
||||
|
||||
export interface TimelineStackingReorderIntent {
|
||||
contextKey: string;
|
||||
placement: TimelineLayerDropPlacement;
|
||||
zIndexChanges: TimelineStackingZIndexChange[];
|
||||
}
|
||||
|
||||
export function toStackingOrderItem(element: TimelineStackingElement): TimelineStackingOrderItem {
|
||||
return {
|
||||
key: getTimelineElementIdentity(element),
|
||||
track: element.track,
|
||||
zIndex: element.zIndex ?? 0,
|
||||
stackingContextId: element.stackingContextId ?? null,
|
||||
parentCompositionId: element.parentCompositionId ?? null,
|
||||
compositionAncestors: element.compositionAncestors ?? [],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { TimelineElement } from "../store/playerStore";
|
||||
import { buildStackingTimelineLayers, insertPreviewTrackOrder } from "./timelineTrackOrder";
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
function rowElement(input: {
|
||||
id: string;
|
||||
track?: number;
|
||||
zIndex?: number;
|
||||
hasExplicitZIndex?: boolean;
|
||||
start?: number;
|
||||
duration?: number;
|
||||
tag?: string;
|
||||
stackingContextId?: string | null;
|
||||
parentCompositionId?: string | null;
|
||||
compositionAncestors?: string[];
|
||||
}): TimelineElement {
|
||||
return {
|
||||
id: input.id,
|
||||
tag: input.tag ?? "div",
|
||||
start: input.start ?? 0,
|
||||
duration: input.duration ?? 1,
|
||||
track: input.track ?? 0,
|
||||
zIndex: input.zIndex ?? 0,
|
||||
hasExplicitZIndex: input.hasExplicitZIndex ?? true,
|
||||
stackingContextId: input.stackingContextId ?? "root",
|
||||
parentCompositionId: input.parentCompositionId ?? null,
|
||||
compositionAncestors: input.compositionAncestors ?? ["root"],
|
||||
};
|
||||
}
|
||||
|
||||
function rowIds(rows: readonly { elements: readonly TimelineElement[] }[]): string[][] {
|
||||
return rows.map((row) => row.elements.map((element) => element.id));
|
||||
}
|
||||
|
||||
describe("buildStackingTimelineLayers", () => {
|
||||
it("splits non-overlapping clips into separate lanes when their z-index differs", () => {
|
||||
// A lane is a z-band: differing z must land on different rows even when the
|
||||
// clips don't overlap in time, so a vertical (z) restack actually moves the
|
||||
// clip's row. Rows are ordered by descending z.
|
||||
const result = buildStackingTimelineLayers([
|
||||
rowElement({ id: "back", zIndex: 1, start: 0, duration: 1 }),
|
||||
rowElement({ id: "front", zIndex: 10, start: 1, duration: 1 }),
|
||||
]);
|
||||
|
||||
expect(rowIds(result.visualLayers)).toEqual([["front"], ["back"]]);
|
||||
expect(result.visualLayers[0]?.zIndex).toBe(10);
|
||||
});
|
||||
|
||||
it("packs non-overlapping clips into one lane when they share a z-index", () => {
|
||||
const result = buildStackingTimelineLayers([
|
||||
rowElement({ id: "back", zIndex: 5, start: 0, duration: 1 }),
|
||||
rowElement({ id: "front", zIndex: 5, start: 1, duration: 1 }),
|
||||
]);
|
||||
|
||||
expect(rowIds(result.visualLayers)).toEqual([["back", "front"]]);
|
||||
expect(result.visualLayers[0]?.zIndex).toBe(5);
|
||||
});
|
||||
|
||||
it("splits clips into separate lanes when they overlap in time", () => {
|
||||
const result = buildStackingTimelineLayers([
|
||||
rowElement({ id: "front", zIndex: 10, start: 0, duration: 2 }),
|
||||
rowElement({ id: "back", zIndex: 1, start: 1, duration: 2 }),
|
||||
]);
|
||||
|
||||
expect(rowIds(result.visualLayers)).toEqual([["front"], ["back"]]);
|
||||
});
|
||||
|
||||
it("uses DOM order to break stacking ties before lane packing", () => {
|
||||
const result = buildStackingTimelineLayers([
|
||||
rowElement({ id: "first", track: 2, zIndex: 5, start: 0, duration: 2 }),
|
||||
rowElement({ id: "second", track: 0, zIndex: 5, start: 1, duration: 2 }),
|
||||
]);
|
||||
|
||||
expect(rowIds(result.visualLayers)).toEqual([["first"], ["second"]]);
|
||||
});
|
||||
|
||||
it("packs auto-z clips by time instead of forcing one row per clip", () => {
|
||||
const result = buildStackingTimelineLayers([
|
||||
rowElement({ id: "a", zIndex: 0, hasExplicitZIndex: false, start: 0, duration: 1 }),
|
||||
rowElement({ id: "b", zIndex: 0, hasExplicitZIndex: false, start: 1, duration: 1 }),
|
||||
]);
|
||||
|
||||
expect(rowIds(result.visualLayers)).toEqual([["a", "b"]]);
|
||||
});
|
||||
|
||||
it("does not merge equal z-index clips across stacking contexts", () => {
|
||||
const result = buildStackingTimelineLayers([
|
||||
rowElement({ id: "root", zIndex: 4, start: 0, duration: 1 }),
|
||||
rowElement({
|
||||
id: "nested",
|
||||
zIndex: 4,
|
||||
start: 1,
|
||||
duration: 1,
|
||||
stackingContextId: "scene",
|
||||
parentCompositionId: "scene",
|
||||
compositionAncestors: ["root", "scene"],
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(rowIds(result.visualLayers)).toEqual([["root"], ["nested"]]);
|
||||
});
|
||||
|
||||
it("returns audio clips as separate bottom rows without merging them into z layers", () => {
|
||||
const result = buildStackingTimelineLayers([
|
||||
rowElement({ id: "front", zIndex: 10 }),
|
||||
rowElement({ id: "music-a", tag: "audio", track: 4 }),
|
||||
rowElement({ id: "music-b", tag: "audio", track: 2 }),
|
||||
]);
|
||||
|
||||
expect(rowIds(result.visualLayers)).toEqual([["front"]]);
|
||||
expect(rowIds(result.audioLayers)).toEqual([["music-a"], ["music-b"]]);
|
||||
expect(rowIds(result.rows)).toEqual([["front"], ["music-a"], ["music-b"]]);
|
||||
});
|
||||
|
||||
it("orders rows by descending z-index with auto-z clips ranked at computed zero", () => {
|
||||
const result = buildStackingTimelineLayers([
|
||||
rowElement({ id: "auto-a", zIndex: 0, hasExplicitZIndex: false }),
|
||||
rowElement({ id: "back", zIndex: -1 }),
|
||||
rowElement({ id: "front", zIndex: 10 }),
|
||||
rowElement({ id: "auto-b", zIndex: 0, hasExplicitZIndex: false }),
|
||||
]);
|
||||
|
||||
expect(rowIds(result.visualLayers)).toEqual([["front"], ["auto-a"], ["auto-b"], ["back"]]);
|
||||
});
|
||||
|
||||
it("keeps a row key stable when a clip's z-index changes but membership does not", () => {
|
||||
const before = buildStackingTimelineLayers([rowElement({ id: "hero", zIndex: 1 })]);
|
||||
const after = buildStackingTimelineLayers([rowElement({ id: "hero", zIndex: 20 })]);
|
||||
|
||||
expect(after.visualLayers[0]?.id).toBe(before.visualLayers[0]?.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe("insertPreviewTrackOrder", () => {
|
||||
it("inserts preview layer ids by target row index", () => {
|
||||
expect(insertPreviewTrackOrder(["a", "b", "c"], "preview", 1)).toEqual([
|
||||
"a",
|
||||
"preview",
|
||||
"b",
|
||||
"c",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,173 @@
|
||||
import { type TimelineElement } from "../store/playerStore";
|
||||
import { resolveContextOrder, resolveStackingContextKey } from "../lib/layerOrdering";
|
||||
import { getTimelineElementIdentity } from "../lib/timelineElementHelpers";
|
||||
import { toStackingOrderItem, type TimelineStackingOrderItem } from "./timelineStacking";
|
||||
|
||||
export type TimelineLayerId = string;
|
||||
|
||||
export interface StackingTimelineLayer {
|
||||
id: TimelineLayerId;
|
||||
kind: "visual" | "audio";
|
||||
contextKey: string;
|
||||
zIndex: number;
|
||||
placementTrack: number;
|
||||
elements: TimelineElement[];
|
||||
}
|
||||
|
||||
export interface StackingTimelineLayerGroups {
|
||||
visualLayers: StackingTimelineLayer[];
|
||||
audioLayers: StackingTimelineLayer[];
|
||||
rows: StackingTimelineLayer[];
|
||||
}
|
||||
|
||||
type TimelineLayerOrderItem = TimelineStackingOrderItem & {
|
||||
start: number;
|
||||
duration: number;
|
||||
index: number;
|
||||
element: TimelineElement;
|
||||
};
|
||||
|
||||
type BuildLayer = Omit<StackingTimelineLayer, "id">;
|
||||
|
||||
function toTimelineLayerOrderItem(element: TimelineElement, index: number): TimelineLayerOrderItem {
|
||||
return {
|
||||
...toStackingOrderItem(element),
|
||||
start: element.start,
|
||||
duration: element.duration,
|
||||
index,
|
||||
element,
|
||||
};
|
||||
}
|
||||
|
||||
export function timelineElementsOverlap(
|
||||
a: Pick<TimelineElement, "start" | "duration">,
|
||||
b: Pick<TimelineElement, "start" | "duration">,
|
||||
): boolean {
|
||||
return a.start < b.start + b.duration && b.start < a.start + a.duration;
|
||||
}
|
||||
|
||||
function compareLayerItems(a: TimelineLayerOrderItem, b: TimelineLayerOrderItem): number {
|
||||
if (a.zIndex !== b.zIndex) return b.zIndex - a.zIndex;
|
||||
return a.index - b.index;
|
||||
}
|
||||
|
||||
function buildElementLayerId(
|
||||
prefix: string,
|
||||
contextKey: string,
|
||||
element: TimelineElement,
|
||||
): TimelineLayerId {
|
||||
return `${prefix}:${contextKey}:${getTimelineElementIdentity(element)}`;
|
||||
}
|
||||
|
||||
function buildLaneId(
|
||||
prefix: string,
|
||||
contextKey: string,
|
||||
elements: TimelineElement[],
|
||||
): TimelineLayerId {
|
||||
const memberKey = elements.map(getTimelineElementIdentity).sort().join("|");
|
||||
return `${prefix}:${contextKey}:${memberKey}`;
|
||||
}
|
||||
|
||||
function getOrderedContextKeys(items: readonly TimelineLayerOrderItem[]): string[] {
|
||||
const keys: string[] = [];
|
||||
for (const item of resolveContextOrder(items)) {
|
||||
const key = resolveStackingContextKey(item);
|
||||
if (!keys.includes(key)) keys.push(key);
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
function canJoinLayer(layer: BuildLayer, item: TimelineLayerOrderItem): boolean {
|
||||
// A row IS a z-band: clips only share a lane when they carry the same z-index
|
||||
// (and don't overlap in time). Without the z-index gate a non-overlapping clip
|
||||
// greedily packs into the first time-compatible row — the topmost one — so a
|
||||
// vertical restack changes z but the row never follows it.
|
||||
return (
|
||||
layer.contextKey === resolveStackingContextKey(item) &&
|
||||
layer.zIndex === item.zIndex &&
|
||||
layer.elements.every((element) => !timelineElementsOverlap(element, item.element))
|
||||
);
|
||||
}
|
||||
|
||||
function compareElementsByStart(a: TimelineElement, b: TimelineElement): number {
|
||||
if (a.start !== b.start) return a.start - b.start;
|
||||
return getTimelineElementIdentity(a).localeCompare(getTimelineElementIdentity(b));
|
||||
}
|
||||
|
||||
function buildVisualLayerRows(items: readonly TimelineLayerOrderItem[]): StackingTimelineLayer[] {
|
||||
const byContext = new Map<string, TimelineLayerOrderItem[]>();
|
||||
for (const item of items) {
|
||||
const key = resolveStackingContextKey(item);
|
||||
const list = byContext.get(key);
|
||||
if (list) list.push(item);
|
||||
else byContext.set(key, [item]);
|
||||
}
|
||||
|
||||
const rows: StackingTimelineLayer[] = [];
|
||||
for (const contextKey of getOrderedContextKeys(items)) {
|
||||
const contextRows: BuildLayer[] = [];
|
||||
const contextItems = [...(byContext.get(contextKey) ?? [])].sort(compareLayerItems);
|
||||
for (const item of contextItems) {
|
||||
const existing = contextRows.find((row) => canJoinLayer(row, item));
|
||||
if (existing) {
|
||||
existing.elements.push(item.element);
|
||||
existing.zIndex = Math.max(existing.zIndex, item.zIndex);
|
||||
continue;
|
||||
}
|
||||
contextRows.push({
|
||||
kind: "visual",
|
||||
contextKey,
|
||||
zIndex: item.zIndex,
|
||||
placementTrack: item.element.track,
|
||||
elements: [item.element],
|
||||
});
|
||||
}
|
||||
rows.push(
|
||||
...contextRows.map((row) => {
|
||||
const elements = [...row.elements].sort(compareElementsByStart);
|
||||
return {
|
||||
...row,
|
||||
id: buildLaneId("lane", contextKey, elements),
|
||||
elements,
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
function buildAudioLayerRows(items: readonly TimelineLayerOrderItem[]): StackingTimelineLayer[] {
|
||||
return items.map((item) => ({
|
||||
id: buildElementLayerId("audio", resolveStackingContextKey(item), item.element),
|
||||
kind: "audio",
|
||||
contextKey: resolveStackingContextKey(item),
|
||||
zIndex: item.zIndex,
|
||||
placementTrack: item.element.track,
|
||||
elements: [item.element],
|
||||
}));
|
||||
}
|
||||
|
||||
export function buildStackingTimelineLayers(
|
||||
elements: readonly TimelineElement[],
|
||||
): StackingTimelineLayerGroups {
|
||||
const items = elements.map(toTimelineLayerOrderItem);
|
||||
const visualItems = items.filter((item) => item.element.tag !== "audio");
|
||||
const audioItems = items.filter((item) => item.element.tag === "audio");
|
||||
const visualLayers = buildVisualLayerRows(visualItems);
|
||||
const audioLayers = buildAudioLayerRows(audioItems);
|
||||
return {
|
||||
visualLayers,
|
||||
audioLayers,
|
||||
rows: [...visualLayers, ...audioLayers],
|
||||
};
|
||||
}
|
||||
|
||||
export function insertPreviewTrackOrder(
|
||||
layerOrder: readonly TimelineLayerId[],
|
||||
previewLayerId: TimelineLayerId,
|
||||
previewIndex: number,
|
||||
): TimelineLayerId[] {
|
||||
if (layerOrder.includes(previewLayerId)) return [...layerOrder];
|
||||
const index = Math.max(0, Math.min(layerOrder.length, Math.round(previewIndex)));
|
||||
return [...layerOrder.slice(0, index), previewLayerId, ...layerOrder.slice(index)];
|
||||
}
|
||||
@@ -78,6 +78,26 @@ describe("updateTimelineActiveClipClasses", () => {
|
||||
expect(previous).toEqual(new Set());
|
||||
});
|
||||
|
||||
it("re-applies data-active to a fresh DOM node that stayed active across a re-render", () => {
|
||||
// A clip that moves lanes on a reorder remounts as a new element. It stays
|
||||
// in the previous active set, so the plain diff would skip it and leave the
|
||||
// new node without data-active. syncAll must force the attribute on.
|
||||
const container = document.createElement("div");
|
||||
appendClip(container, "hero", "0", "5");
|
||||
const previous = new Set<string>();
|
||||
updateTimelineActiveClipClasses(container, previous, 2);
|
||||
expect(previous).toEqual(new Set(["hero"]));
|
||||
|
||||
// Simulate a remount: replace the hero clip's DOM node (no data-active).
|
||||
container.replaceChildren();
|
||||
const heroReborn = appendClip(container, "hero", "0", "5");
|
||||
expect(heroReborn.hasAttribute("data-active")).toBe(false);
|
||||
|
||||
// Diff-only would skip it (still active → unchanged); syncAll re-applies.
|
||||
updateTimelineActiveClipClasses(container, previous, 2, true);
|
||||
expect(heroReborn.hasAttribute("data-active")).toBe(true);
|
||||
});
|
||||
|
||||
it("ignores clips with invalid timing data", () => {
|
||||
const container = document.createElement("div");
|
||||
const missingId = appendClip(container, "", "0", "2");
|
||||
|
||||
@@ -65,13 +65,23 @@ function setsMatch(left: Set<string>, right: Set<string>): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
function applyActiveClipDiff(records: ActiveClipRecord[], previous: Set<string>, time: number) {
|
||||
function applyActiveClipDiff(
|
||||
records: ActiveClipRecord[],
|
||||
previous: Set<string>,
|
||||
time: number,
|
||||
// Force every record's attribute to match its active state instead of only
|
||||
// touching clips whose active-state changed. Required whenever `records` were
|
||||
// freshly re-queried after a render: a clip that stayed active but got a new
|
||||
// DOM node (e.g. moved lanes on a reorder) would otherwise be skipped by the
|
||||
// diff and render without `data-active` despite still being at the playhead.
|
||||
syncAll = false,
|
||||
) {
|
||||
const next = getActiveClipIds(records, time);
|
||||
const changed = !setsMatch(previous, next);
|
||||
for (const record of records) {
|
||||
const wasActive = previous.has(record.id);
|
||||
const isActive = next.has(record.id);
|
||||
if (wasActive === isActive) continue;
|
||||
if (!syncAll && wasActive === isActive) continue;
|
||||
record.element.toggleAttribute("data-active", isActive);
|
||||
}
|
||||
previous.clear();
|
||||
@@ -83,8 +93,9 @@ export function updateTimelineActiveClipClasses(
|
||||
container: HTMLElement,
|
||||
previous: Set<string>,
|
||||
time: number,
|
||||
syncAll = false,
|
||||
) {
|
||||
applyActiveClipDiff(collectTimelineClipRecords(container), previous, time);
|
||||
applyActiveClipDiff(collectTimelineClipRecords(container), previous, time, syncAll);
|
||||
}
|
||||
|
||||
export function useTimelineActiveClips({
|
||||
@@ -107,7 +118,7 @@ export function useTimelineActiveClips({
|
||||
}
|
||||
recordsRef.current = collectTimelineClipRecords(scroll);
|
||||
recordsByIdRef.current = indexClipRecordsById(recordsRef.current);
|
||||
applyActiveClipDiff(recordsRef.current, previousActiveIdsRef.current, time);
|
||||
applyActiveClipDiff(recordsRef.current, previousActiveIdsRef.current, time, true);
|
||||
},
|
||||
[scrollRef],
|
||||
);
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import React, { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { TimelineElement } from "../store/playerStore";
|
||||
import { usePlayerStore } from "../store/playerStore";
|
||||
import { TRACK_H } from "./timelineLayout";
|
||||
import { buildStackingTimelineLayers } from "./timelineTrackOrder";
|
||||
import type { DraggedClipState, ResizingClipState } from "./useTimelineClipDrag";
|
||||
import { useTimelineClipDrag } from "./useTimelineClipDrag";
|
||||
|
||||
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
function timelineElement(input: {
|
||||
id: string;
|
||||
track: number;
|
||||
zIndex: number;
|
||||
start?: number;
|
||||
duration?: number;
|
||||
sourceDuration?: number;
|
||||
}): TimelineElement {
|
||||
return {
|
||||
id: input.id,
|
||||
domId: input.id,
|
||||
tag: "div",
|
||||
start: input.start ?? 0,
|
||||
duration: input.duration ?? 2,
|
||||
sourceDuration: input.sourceDuration,
|
||||
track: input.track,
|
||||
zIndex: input.zIndex,
|
||||
stackingContextId: "root",
|
||||
parentCompositionId: null,
|
||||
compositionAncestors: ["root"],
|
||||
sourceFile: "index.html",
|
||||
timingSource: "authored",
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = "";
|
||||
usePlayerStore.getState().reset();
|
||||
});
|
||||
|
||||
function renderDragHarness(elements: TimelineElement[]) {
|
||||
const layers = buildStackingTimelineLayers(elements).rows;
|
||||
const scroll = document.createElement("div");
|
||||
document.body.append(scroll);
|
||||
const onMoveElement = vi.fn();
|
||||
const onResizeElement = vi.fn();
|
||||
let setDraggedClip: ((state: DraggedClipState | null) => void) | null = null;
|
||||
let setResizingClip: ((state: ResizingClipState | null) => void) | null = null;
|
||||
|
||||
function Harness() {
|
||||
const hook = useTimelineClipDrag({
|
||||
scrollRef: { current: scroll },
|
||||
ppsRef: { current: 100 },
|
||||
trackOrderRef: { current: layers.map((layer) => layer.id) },
|
||||
timelineLayersRef: { current: layers },
|
||||
timelineElementsRef: { current: elements },
|
||||
onMoveElement,
|
||||
onResizeElement,
|
||||
onBlockedEditAttempt: vi.fn(),
|
||||
setShowPopover: vi.fn(),
|
||||
setRangeSelectionRef: { current: vi.fn() },
|
||||
});
|
||||
setDraggedClip = hook.setDraggedClip;
|
||||
setResizingClip = hook.setResizingClip;
|
||||
return null;
|
||||
}
|
||||
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
const root = createRoot(host);
|
||||
act(() => {
|
||||
root.render(<Harness />);
|
||||
});
|
||||
if (!setDraggedClip) throw new Error("Expected drag setter");
|
||||
if (!setResizingClip) throw new Error("Expected resize setter");
|
||||
const applyDraggedClip: (state: DraggedClipState | null) => void = setDraggedClip;
|
||||
const applyResizingClip: (state: ResizingClipState | null) => void = setResizingClip;
|
||||
|
||||
return {
|
||||
layers,
|
||||
onMoveElement,
|
||||
onResizeElement,
|
||||
startDrag(element: TimelineElement, layerIndex: number) {
|
||||
act(() => {
|
||||
applyDraggedClip({
|
||||
element,
|
||||
originClientX: 0,
|
||||
originClientY: 0,
|
||||
originScrollLeft: 0,
|
||||
originScrollTop: 0,
|
||||
pointerClientX: 0,
|
||||
pointerClientY: 0,
|
||||
pointerOffsetX: 0,
|
||||
pointerOffsetY: 0,
|
||||
previewStart: element.start,
|
||||
previewTrack: element.track,
|
||||
previewLayerId: layers[layerIndex]!.id,
|
||||
previewLayerIndex: layerIndex,
|
||||
previewStackingReorder: null,
|
||||
snapBeatTime: null,
|
||||
snapGuideTime: null,
|
||||
snapGuideKind: null,
|
||||
started: false,
|
||||
});
|
||||
});
|
||||
},
|
||||
startResize(element: TimelineElement, edge: "start" | "end") {
|
||||
act(() => {
|
||||
applyResizingClip({
|
||||
element,
|
||||
edge,
|
||||
originClientX: 0,
|
||||
previewStart: element.start,
|
||||
previewDuration: element.duration,
|
||||
previewPlaybackStart: element.playbackStart,
|
||||
snapGuideTime: null,
|
||||
snapGuideKind: null,
|
||||
started: false,
|
||||
});
|
||||
});
|
||||
},
|
||||
movePointer(clientX: number, clientY: number) {
|
||||
act(() => {
|
||||
window.dispatchEvent(
|
||||
new MouseEvent("pointermove", {
|
||||
bubbles: true,
|
||||
clientX,
|
||||
clientY,
|
||||
}),
|
||||
);
|
||||
});
|
||||
},
|
||||
async dropPointer() {
|
||||
await act(async () => {
|
||||
window.dispatchEvent(new MouseEvent("pointerup", { bubbles: true }));
|
||||
});
|
||||
},
|
||||
unmount() {
|
||||
act(() => root.unmount());
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("useTimelineClipDrag", () => {
|
||||
it("allows moving a clip past the current composition duration", async () => {
|
||||
const clip = timelineElement({ id: "clip", track: 0, zIndex: 1 });
|
||||
const harness = renderDragHarness([clip]);
|
||||
|
||||
harness.startDrag(clip, 0);
|
||||
harness.movePointer(1100, 0);
|
||||
await harness.dropPointer();
|
||||
|
||||
expect(harness.onMoveElement).toHaveBeenCalledWith(
|
||||
clip,
|
||||
expect.objectContaining({ start: 11 }),
|
||||
);
|
||||
|
||||
harness.unmount();
|
||||
});
|
||||
|
||||
it("allows right-edge resize past the current composition duration", async () => {
|
||||
const clip = timelineElement({ id: "clip", track: 0, zIndex: 1, start: 6, duration: 2 });
|
||||
const harness = renderDragHarness([clip]);
|
||||
|
||||
harness.startResize(clip, "end");
|
||||
harness.movePointer(400, 0);
|
||||
await harness.dropPointer();
|
||||
|
||||
expect(harness.onResizeElement).toHaveBeenCalledWith(
|
||||
clip,
|
||||
expect.objectContaining({ start: 6, duration: 6 }),
|
||||
);
|
||||
|
||||
harness.unmount();
|
||||
});
|
||||
|
||||
it("passes a new-lane stacking intent when a vertical drag targets an overlapping lane", async () => {
|
||||
const front = timelineElement({ id: "front", track: 0, zIndex: 3 });
|
||||
const middle = timelineElement({ id: "middle", track: 1, zIndex: 2 });
|
||||
const back = timelineElement({ id: "back", track: 2, zIndex: 1 });
|
||||
const harness = renderDragHarness([front, middle, back]);
|
||||
|
||||
harness.startDrag(back, 2);
|
||||
harness.movePointer(0, -2 * TRACK_H);
|
||||
await harness.dropPointer();
|
||||
|
||||
expect(harness.onMoveElement).toHaveBeenCalledTimes(1);
|
||||
expect(harness.onMoveElement.mock.calls[0]![1]).toMatchObject({
|
||||
start: 0,
|
||||
track: 2,
|
||||
stackingReorder: {
|
||||
contextKey: "root",
|
||||
placement: { type: "above", layerId: harness.layers[0]!.id },
|
||||
zIndexChanges: [{ key: "back", zIndex: 4 }],
|
||||
},
|
||||
});
|
||||
|
||||
harness.unmount();
|
||||
});
|
||||
|
||||
it("resolves lane stacking from the authored time span, independent of horizontal drag", async () => {
|
||||
const front = timelineElement({ id: "front", track: 0, zIndex: 3 });
|
||||
const back = timelineElement({ id: "back", track: 1, zIndex: 1 });
|
||||
back.start = 0;
|
||||
front.start = 0;
|
||||
const harness = renderDragHarness([front, back]);
|
||||
|
||||
// Drag up one row AND rightward in time. The horizontal drift moves the
|
||||
// clip out of overlap, but the two axes never fight: the vertical restack
|
||||
// is resolved from the authored (overlapping) span, so it still inserts
|
||||
// above the target lane rather than silently joining it.
|
||||
harness.startDrag(back, 1);
|
||||
harness.movePointer(200, -TRACK_H);
|
||||
await harness.dropPointer();
|
||||
|
||||
expect(harness.onMoveElement).toHaveBeenCalledTimes(1);
|
||||
expect(harness.onMoveElement.mock.calls[0]![1]).toMatchObject({
|
||||
start: 2,
|
||||
track: 1,
|
||||
stackingReorder: {
|
||||
contextKey: "root",
|
||||
placement: { type: "above", layerId: harness.layers[0]!.id },
|
||||
zIndexChanges: [{ key: "back", zIndex: 4 }],
|
||||
},
|
||||
});
|
||||
|
||||
harness.unmount();
|
||||
});
|
||||
});
|
||||
@@ -5,69 +5,23 @@ import {
|
||||
resolveTimelineResize,
|
||||
resolveTimelineAutoScroll,
|
||||
type BlockedTimelineEditIntent,
|
||||
type TimelineStackingReorderIntent,
|
||||
} from "./timelineEditing";
|
||||
import { usePlayerStore } from "../store/playerStore";
|
||||
import type { TimelineElement } from "../store/playerStore";
|
||||
import { TRACK_H } from "./timelineLayout";
|
||||
import { isMusicTrack } from "../../utils/timelineInspector";
|
||||
import { mergeUserBeats } from "../../utils/beatEditing";
|
||||
import type { StackingTimelineLayer, TimelineLayerId } from "./timelineTrackOrder";
|
||||
import {
|
||||
buildTimelineSnapTargets,
|
||||
snapEdgesToTargets,
|
||||
snapResizeEdgeToTargets,
|
||||
type TimelineSnapKind,
|
||||
} from "./timelineSnapTargets";
|
||||
|
||||
const BEAT_SNAP_PX = 8;
|
||||
const EMPTY_BEAT_TIMES: number[] = [];
|
||||
|
||||
function snapToNearestBeat(time: number, beatTimes: number[], thresholdSecs: number): number {
|
||||
let best = time;
|
||||
let bestDist = thresholdSecs;
|
||||
for (const bt of beatTimes) {
|
||||
const d = Math.abs(bt - time);
|
||||
if (d < bestDist) {
|
||||
bestDist = d;
|
||||
best = bt;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/**
|
||||
* Snap a moved clip so whichever edge (start or end) is nearest a beat lands on
|
||||
* it, keeping the duration fixed. Returns the (clamped) start plus the beat time
|
||||
* it snapped to (for the grid-line highlight), or `beat: null` when no edge is
|
||||
* within threshold.
|
||||
*/
|
||||
function snapMoveStartToBeat(
|
||||
start: number,
|
||||
duration: number,
|
||||
beatTimes: number[],
|
||||
pixelsPerSecond: number,
|
||||
timelineDuration: number,
|
||||
): { start: number; beat: number | null } {
|
||||
if (beatTimes.length === 0) return { start, beat: null };
|
||||
const snapSecs = BEAT_SNAP_PX / Math.max(pixelsPerSecond, 1);
|
||||
const snappedStart = snapToNearestBeat(start, beatTimes, snapSecs);
|
||||
const snappedEnd = snapToNearestBeat(start + duration, beatTimes, snapSecs);
|
||||
const startMoved = snappedStart !== start;
|
||||
const endMoved = snappedEnd !== start + duration;
|
||||
|
||||
let candidate = start;
|
||||
let beat: number | null = null;
|
||||
if (
|
||||
startMoved &&
|
||||
(!endMoved || Math.abs(snappedStart - start) <= Math.abs(snappedEnd - (start + duration)))
|
||||
) {
|
||||
candidate = snappedStart;
|
||||
beat = snappedStart;
|
||||
} else if (endMoved) {
|
||||
candidate = snappedEnd - duration;
|
||||
beat = snappedEnd;
|
||||
}
|
||||
|
||||
const maxStart = Math.max(0, timelineDuration - duration);
|
||||
const clamped = Math.max(0, Math.min(maxStart, Math.round(candidate * 1000) / 1000));
|
||||
// If clamping pulled the clip off the snap target, drop the highlight.
|
||||
if (beat != null && Math.abs(clamped - candidate) > 1e-6) beat = null;
|
||||
return { start: clamped, beat };
|
||||
}
|
||||
|
||||
/* ── Shared state types ─────────────────────────────────────────── */
|
||||
export interface DraggedClipState {
|
||||
element: TimelineElement;
|
||||
@@ -81,8 +35,14 @@ export interface DraggedClipState {
|
||||
pointerOffsetY: number;
|
||||
previewStart: number;
|
||||
previewTrack: number;
|
||||
previewLayerId: TimelineLayerId;
|
||||
previewLayerIndex: number;
|
||||
/** Beat time the clip will snap to on drop, for the grid-line highlight. */
|
||||
snapBeatTime: number | null;
|
||||
snapGuideTime: number | null;
|
||||
snapGuideKind: TimelineSnapKind | null;
|
||||
/** Sibling-scoped z-index reorder intent resolved from the vertical drag. */
|
||||
previewStackingReorder: TimelineStackingReorderIntent | null;
|
||||
started: boolean;
|
||||
}
|
||||
|
||||
@@ -93,6 +53,8 @@ export interface ResizingClipState {
|
||||
previewStart: number;
|
||||
previewDuration: number;
|
||||
previewPlaybackStart?: number;
|
||||
snapGuideTime: number | null;
|
||||
snapGuideKind: TimelineSnapKind | null;
|
||||
started: boolean;
|
||||
}
|
||||
|
||||
@@ -108,11 +70,14 @@ export interface BlockedClipState {
|
||||
interface UseTimelineClipDragInput {
|
||||
scrollRef: React.RefObject<HTMLDivElement | null>;
|
||||
ppsRef: React.RefObject<number>;
|
||||
durationRef: React.RefObject<number>;
|
||||
trackOrderRef: React.RefObject<number[]>;
|
||||
trackOrderRef: React.RefObject<TimelineLayerId[]>;
|
||||
timelineLayersRef: React.RefObject<StackingTimelineLayer[]>;
|
||||
timelineElementsRef: React.RefObject<TimelineElement[]>;
|
||||
onMoveElement?: (
|
||||
element: TimelineElement,
|
||||
updates: Pick<TimelineElement, "start" | "track">,
|
||||
updates: Pick<TimelineElement, "start" | "track"> & {
|
||||
stackingReorder?: TimelineStackingReorderIntent | null;
|
||||
},
|
||||
) => Promise<void> | void;
|
||||
onResizeElement?: (
|
||||
element: TimelineElement,
|
||||
@@ -127,8 +92,9 @@ interface UseTimelineClipDragInput {
|
||||
export function useTimelineClipDrag({
|
||||
scrollRef,
|
||||
ppsRef,
|
||||
durationRef,
|
||||
trackOrderRef,
|
||||
timelineLayersRef,
|
||||
timelineElementsRef,
|
||||
onMoveElement,
|
||||
onResizeElement,
|
||||
onBlockedEditAttempt,
|
||||
@@ -139,6 +105,8 @@ export function useTimelineClipDrag({
|
||||
const rawBeatTimes = usePlayerStore((s) => s.beatAnalysis?.beatTimes ?? EMPTY_BEAT_TIMES);
|
||||
const rawBeatStrengths = usePlayerStore((s) => s.beatAnalysis?.beatStrengths ?? EMPTY_BEAT_TIMES);
|
||||
const beatEdits = usePlayerStore((s) => s.beatEdits);
|
||||
const playhead = usePlayerStore((s) => s.currentTime);
|
||||
const compositionDuration = usePlayerStore((s) => s.duration);
|
||||
const musicStart = usePlayerStore((s) => s.elements.find(isMusicTrack)?.start ?? 0);
|
||||
const musicPlaybackStart = usePlayerStore(
|
||||
(s) => s.elements.find(isMusicTrack)?.playbackStart ?? 0,
|
||||
@@ -166,6 +134,22 @@ export function useTimelineClipDrag({
|
||||
|
||||
const beatTimesRef = useRef<number[]>([]);
|
||||
beatTimesRef.current = adjustedBeatTimes;
|
||||
const playheadRef = useRef(0);
|
||||
playheadRef.current = playhead;
|
||||
const compositionDurationRef = useRef(0);
|
||||
compositionDurationRef.current = compositionDuration;
|
||||
|
||||
const buildSnapTargets = useCallback(
|
||||
(element: TimelineElement) =>
|
||||
buildTimelineSnapTargets({
|
||||
elements: timelineElementsRef.current,
|
||||
draggedKey: element.key ?? element.id,
|
||||
playhead: playheadRef.current,
|
||||
compDuration: compositionDurationRef.current,
|
||||
beats: isMusicTrack(element) ? EMPTY_BEAT_TIMES : beatTimesRef.current,
|
||||
}),
|
||||
[timelineElementsRef],
|
||||
);
|
||||
|
||||
const [draggedClip, setDraggedClip] = useState<DraggedClipState | null>(null);
|
||||
const draggedClipRef = useRef<DraggedClipState | null>(null);
|
||||
@@ -202,22 +186,23 @@ export function useTimelineClipDrag({
|
||||
currentScrollTop: scroll?.scrollTop ?? drag.originScrollTop,
|
||||
pixelsPerSecond: ppsRef.current,
|
||||
trackHeight: TRACK_H,
|
||||
maxStart: Math.max(0, durationRef.current - drag.element.duration),
|
||||
trackOrder: trackOrderRef.current,
|
||||
maxStart: Number.POSITIVE_INFINITY,
|
||||
trackOrder: timelineLayersRef.current.map((layer) => layer.placementTrack),
|
||||
layerOrder: trackOrderRef.current,
|
||||
timelineLayers: timelineLayersRef.current,
|
||||
stackingElement: drag.element,
|
||||
stackingElements: timelineElementsRef.current,
|
||||
},
|
||||
clientX,
|
||||
clientY,
|
||||
);
|
||||
// The music track defines the beats, so it must not snap to itself.
|
||||
const snap = isMusicTrack(drag.element)
|
||||
? { start: nextMove.start, beat: null }
|
||||
: snapMoveStartToBeat(
|
||||
nextMove.start,
|
||||
drag.element.duration,
|
||||
beatTimesRef.current,
|
||||
ppsRef.current,
|
||||
durationRef.current,
|
||||
);
|
||||
const snap = snapEdgesToTargets(
|
||||
nextMove.start,
|
||||
drag.element.duration,
|
||||
buildSnapTargets(drag.element),
|
||||
ppsRef.current,
|
||||
{ maxStart: Number.POSITIVE_INFINITY },
|
||||
);
|
||||
return {
|
||||
...drag,
|
||||
started: true,
|
||||
@@ -225,10 +210,15 @@ export function useTimelineClipDrag({
|
||||
pointerClientY: clientY,
|
||||
previewStart: snap.start,
|
||||
previewTrack: nextMove.track,
|
||||
snapBeatTime: snap.beat,
|
||||
previewLayerId: nextMove.previewLayerId ?? drag.previewLayerId,
|
||||
previewLayerIndex: nextMove.previewLayerIndex ?? drag.previewLayerIndex,
|
||||
previewStackingReorder: nextMove.stackingReorder ?? null,
|
||||
snapBeatTime: snap.snapKind === "beat" ? snap.snapTime : null,
|
||||
snapGuideTime: snap.snapTime,
|
||||
snapGuideKind: snap.snapKind,
|
||||
};
|
||||
},
|
||||
[scrollRef, ppsRef, durationRef, trackOrderRef],
|
||||
[scrollRef, ppsRef, trackOrderRef, timelineLayersRef, timelineElementsRef, buildSnapTargets],
|
||||
);
|
||||
|
||||
const stopClipDragAutoScroll = useCallback(() => {
|
||||
@@ -299,6 +289,7 @@ export function useTimelineClipDrag({
|
||||
});
|
||||
};
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
const handleWindowPointerMove = (e: PointerEvent) => {
|
||||
const drag = draggedClipRef.current;
|
||||
const resize = resizingClipRef.current;
|
||||
@@ -322,7 +313,7 @@ export function useTimelineClipDrag({
|
||||
const normalizedTag = resize.element.tag.toLowerCase();
|
||||
const canSeedPlaybackStart = normalizedTag === "audio" || normalizedTag === "video";
|
||||
const playbackRate = Math.max(resize.element.playbackRate ?? 1, 0.1);
|
||||
const maxEnd = Math.min(durationRef.current, resize.element.start + sourceRemaining);
|
||||
const maxEnd = resize.element.start + sourceRemaining;
|
||||
let nextResize = resolveTimelineResize(
|
||||
{
|
||||
start: resize.element.start,
|
||||
@@ -341,51 +332,32 @@ export function useTimelineClipDrag({
|
||||
e.clientX,
|
||||
);
|
||||
|
||||
// Snap edge to beat grid when beat analysis is available. The snap must
|
||||
// stay inside the same limits resolveTimelineResize enforces, or it would
|
||||
// push the edge past the available source media / composition end.
|
||||
// The music track defines the beats, so it must not snap to itself.
|
||||
const beatTimes = beatTimesRef.current;
|
||||
if (beatTimes.length > 0 && !isMusicTrack(resize.element)) {
|
||||
const snapSecs = BEAT_SNAP_PX / Math.max(ppsRef.current, 1);
|
||||
if (resize.edge === "end") {
|
||||
const edgeTime = nextResize.start + nextResize.duration;
|
||||
const snapped = snapToNearestBeat(edgeTime, beatTimes, snapSecs);
|
||||
// Stay within [start+minDuration, maxEnd] so the snap can't create a
|
||||
// degenerate clip or run past the source/composition limit.
|
||||
const snappedDuration = Math.round((snapped - nextResize.start) * 1000) / 1000;
|
||||
if (snapped !== edgeTime && snapped <= maxEnd + 1e-6 && snappedDuration >= 0.05) {
|
||||
nextResize = { ...nextResize, duration: snappedDuration };
|
||||
}
|
||||
} else {
|
||||
const snapped = snapToNearestBeat(nextResize.start, beatTimes, snapSecs);
|
||||
const delta = nextResize.start - snapped; // >0 when snapping left
|
||||
// Leftward snap reveals more source; cap so playbackStart can't go < 0.
|
||||
const maxLeftDelta =
|
||||
nextResize.playbackStart != null
|
||||
const snap = snapResizeEdgeToTargets(
|
||||
resize.edge,
|
||||
nextResize.start,
|
||||
nextResize.duration,
|
||||
buildSnapTargets(resize.element),
|
||||
ppsRef.current,
|
||||
{
|
||||
minDuration: 0.05,
|
||||
maxEnd,
|
||||
maxLeftDelta:
|
||||
resize.edge === "start" && nextResize.playbackStart != null
|
||||
? nextResize.playbackStart / playbackRate
|
||||
: Number.POSITIVE_INFINITY;
|
||||
// Also require the resulting duration to stay >= minDuration so a
|
||||
// rightward snap (delta < 0) can't collapse the clip to zero/negative.
|
||||
const snappedDuration = Math.round((nextResize.duration + delta) * 1000) / 1000;
|
||||
if (
|
||||
snapped !== nextResize.start &&
|
||||
snapped >= 0 &&
|
||||
delta <= maxLeftDelta + 1e-6 &&
|
||||
snappedDuration >= 0.05
|
||||
) {
|
||||
nextResize = {
|
||||
...nextResize,
|
||||
start: snapped,
|
||||
duration: snappedDuration,
|
||||
playbackStart:
|
||||
nextResize.playbackStart != null
|
||||
? Math.round(
|
||||
Math.max(0, nextResize.playbackStart - delta * playbackRate) * 1000,
|
||||
) / 1000
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
: Number.POSITIVE_INFINITY,
|
||||
},
|
||||
);
|
||||
if (snap.snapTime != null) {
|
||||
const unsnappedStart = nextResize.start;
|
||||
nextResize = { ...nextResize, start: snap.start, duration: snap.duration };
|
||||
if (resize.edge === "start" && nextResize.playbackStart != null) {
|
||||
const delta = unsnappedStart - snap.start;
|
||||
nextResize = {
|
||||
...nextResize,
|
||||
playbackStart:
|
||||
Math.round(Math.max(0, nextResize.playbackStart - delta * playbackRate) * 1000) /
|
||||
1000,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -397,6 +369,8 @@ export function useTimelineClipDrag({
|
||||
previewStart: nextResize.start,
|
||||
previewDuration: nextResize.duration,
|
||||
previewPlaybackStart: nextResize.playbackStart,
|
||||
snapGuideTime: snap.snapTime,
|
||||
snapGuideKind: snap.snapKind,
|
||||
}
|
||||
: prev,
|
||||
);
|
||||
@@ -434,6 +408,7 @@ export function useTimelineClipDrag({
|
||||
syncClipDragAutoScrollRef.current(e.clientX, e.clientY);
|
||||
};
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
const handleWindowPointerUp = () => {
|
||||
stopClipDragAutoScrollRef.current();
|
||||
|
||||
@@ -492,24 +467,24 @@ export function useTimelineClipDrag({
|
||||
suppressClickRef.current = true;
|
||||
clearSuppressedClick();
|
||||
|
||||
const hasChanged =
|
||||
drag.previewStart !== drag.element.start || drag.previewTrack !== drag.element.track;
|
||||
const hasStackingReorder =
|
||||
drag.previewStackingReorder != null && drag.previewStackingReorder.zIndexChanges.length > 0;
|
||||
const hasChanged = drag.previewStart !== drag.element.start || hasStackingReorder;
|
||||
if (!hasChanged) return;
|
||||
|
||||
updateElement(drag.element.key ?? drag.element.id, {
|
||||
start: drag.previewStart,
|
||||
track: drag.previewTrack,
|
||||
});
|
||||
|
||||
Promise.resolve(
|
||||
onMoveElementRef.current?.(drag.element, {
|
||||
start: drag.previewStart,
|
||||
track: drag.previewTrack,
|
||||
track: drag.element.track,
|
||||
stackingReorder: drag.previewStackingReorder,
|
||||
}),
|
||||
).catch((error) => {
|
||||
updateElement(drag.element.key ?? drag.element.id, {
|
||||
start: drag.element.start,
|
||||
track: drag.element.track,
|
||||
});
|
||||
console.error("[Timeline] Failed to persist clip move", error);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user