feat: expandable keyframe lanes in timeline
Made-with: Cursor
This commit is contained in:
parent
2272598d42
commit
36efdf66e7
|
|
@ -0,0 +1,117 @@
|
|||
import type {
|
||||
AnimationPath,
|
||||
ElementAnimations,
|
||||
} from "@/lib/animation/types";
|
||||
import type { TimelineTrack } from "@/lib/timeline";
|
||||
import { getElementKeyframes } from "@/lib/animation";
|
||||
import { KEYFRAME_LANE_HEIGHT_PX } from "./layout";
|
||||
|
||||
export interface ExpandedRow {
|
||||
propertyPath: AnimationPath;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface PropertyGroupDefinition {
|
||||
matchesPath: (path: AnimationPath) => boolean;
|
||||
}
|
||||
|
||||
const PROPERTY_GROUPS: PropertyGroupDefinition[] = [
|
||||
{ matchesPath: (path) => path.startsWith("transform.") || path === "opacity" },
|
||||
{ matchesPath: (path) => path === "volume" || path === "color" },
|
||||
{ matchesPath: (path) => path.startsWith("background.") },
|
||||
{ matchesPath: (path) => path.startsWith("params.") },
|
||||
{ matchesPath: (path) => path.startsWith("effects.") },
|
||||
];
|
||||
|
||||
const PROPERTY_LABELS: Partial<Record<string, string>> = {
|
||||
"transform.positionX": "Position X",
|
||||
"transform.positionY": "Position Y",
|
||||
"transform.scaleX": "Scale X",
|
||||
"transform.scaleY": "Scale Y",
|
||||
"transform.rotate": "Rotation",
|
||||
opacity: "Opacity",
|
||||
volume: "Volume",
|
||||
color: "Color",
|
||||
"background.color": "BG Color",
|
||||
"background.paddingX": "BG Pad X",
|
||||
"background.paddingY": "BG Pad Y",
|
||||
"background.offsetX": "BG Offset X",
|
||||
"background.offsetY": "BG Offset Y",
|
||||
"background.cornerRadius": "Corner Radius",
|
||||
};
|
||||
|
||||
export function getPropertyLabel(path: AnimationPath): string {
|
||||
if (PROPERTY_LABELS[path]) return PROPERTY_LABELS[path];
|
||||
if (path.startsWith("params.")) return path.slice("params.".length);
|
||||
if (path.startsWith("effects.")) {
|
||||
const parts = path.split(".");
|
||||
return parts[parts.length - 1];
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
export function getExpandedRows({
|
||||
animations,
|
||||
}: {
|
||||
animations: ElementAnimations | undefined;
|
||||
}): ExpandedRow[] {
|
||||
const keyframes = getElementKeyframes({ animations });
|
||||
const propertyPaths = [...new Set(keyframes.map((kf) => kf.propertyPath))];
|
||||
if (propertyPaths.length === 0) return [];
|
||||
|
||||
const rows: ExpandedRow[] = [];
|
||||
|
||||
for (const group of PROPERTY_GROUPS) {
|
||||
const groupPaths = propertyPaths.filter((path) =>
|
||||
group.matchesPath(path),
|
||||
);
|
||||
for (const path of groupPaths) {
|
||||
rows.push({ propertyPath: path, label: getPropertyLabel(path) });
|
||||
}
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
export function getExpansionHeight({ rows }: { rows: ExpandedRow[] }): number {
|
||||
return rows.length * KEYFRAME_LANE_HEIGHT_PX;
|
||||
}
|
||||
|
||||
export function computeTrackExpansionHeight({
|
||||
track,
|
||||
expandedElementIds,
|
||||
}: {
|
||||
track: TimelineTrack;
|
||||
expandedElementIds: Set<string>;
|
||||
}): number {
|
||||
let maxHeight = 0;
|
||||
for (const element of track.elements) {
|
||||
if (!expandedElementIds.has(element.id)) continue;
|
||||
const rows = getExpandedRows({ animations: element.animations });
|
||||
maxHeight = Math.max(maxHeight, getExpansionHeight({ rows }));
|
||||
}
|
||||
return maxHeight;
|
||||
}
|
||||
|
||||
export function getTrackExpandedRows({
|
||||
track,
|
||||
expandedElementIds,
|
||||
}: {
|
||||
track: TimelineTrack;
|
||||
expandedElementIds: Set<string>;
|
||||
}): ExpandedRow[] {
|
||||
let maxHeight = 0;
|
||||
let maxRows: ExpandedRow[] = [];
|
||||
|
||||
for (const element of track.elements) {
|
||||
if (!expandedElementIds.has(element.id)) continue;
|
||||
const rows = getExpandedRows({ animations: element.animations });
|
||||
const height = getExpansionHeight({ rows });
|
||||
if (height > maxHeight) {
|
||||
maxHeight = height;
|
||||
maxRows = rows;
|
||||
}
|
||||
}
|
||||
|
||||
return maxRows;
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import {
|
||||
getCurveHandlesForNormalizedCubicBezier,
|
||||
getEditableScalarChannels,
|
||||
getEasingModeForKind,
|
||||
getNormalizedCubicBezierForScalarSegment,
|
||||
getScalarKeyframeContext,
|
||||
updateScalarKeyframeCurve,
|
||||
|
|
@ -54,7 +55,14 @@ export interface GraphEditorReadyState extends GraphEditorBaseSelectionState {
|
|||
propertyPath: SelectedKeyframeRef["propertyPath"];
|
||||
keyframeId: string;
|
||||
element: TimelineElement;
|
||||
/** Primary channel context, used for displaying the curve. */
|
||||
context: ScalarGraphKeyframeContext;
|
||||
/**
|
||||
* All channel contexts that share this curve. For independent-easing bindings
|
||||
* this is [context]. For shared-easing bindings (e.g. color) this contains
|
||||
* all component contexts so patches can be applied to every channel at once.
|
||||
*/
|
||||
allContexts: ScalarGraphKeyframeContext[];
|
||||
cubicBezier: NormalizedCubicBezier;
|
||||
}
|
||||
|
||||
|
|
@ -234,16 +242,17 @@ export function resolveGraphEditorSelectionState({
|
|||
});
|
||||
}
|
||||
|
||||
const scalarChannels = getEditableScalarChannels({
|
||||
const scalarResult = getEditableScalarChannels({
|
||||
animations: selectedElement.element.animations,
|
||||
propertyPath: primaryKeyframe.propertyPath,
|
||||
});
|
||||
if (scalarChannels.length === 0) {
|
||||
if (!scalarResult || scalarResult.channels.length === 0) {
|
||||
return createUnavailableState({
|
||||
reason: "selected-keyframe-has-no-scalar-channel",
|
||||
message: "The selected keyframe has no editable graph channel.",
|
||||
});
|
||||
}
|
||||
const { binding: resolvedBinding, channels: scalarChannels } = scalarResult;
|
||||
|
||||
// When 2 keyframes are selected, resolve the earlier one as the outgoing-segment
|
||||
// anchor so the graph editor edits the curve between the two selected keyframes.
|
||||
|
|
@ -264,6 +273,8 @@ export function resolveGraphEditorSelectionState({
|
|||
}
|
||||
}
|
||||
|
||||
const easingMode = getEasingModeForKind(resolvedBinding.kind);
|
||||
|
||||
const contexts = scalarChannels.flatMap((channel) => {
|
||||
const context = getScalarKeyframeContext({
|
||||
animations: selectedElement.element.animations,
|
||||
|
|
@ -293,14 +304,20 @@ export function resolveGraphEditorSelectionState({
|
|||
});
|
||||
}
|
||||
|
||||
const nextSegmentContexts = contexts.filter(
|
||||
// For shared-easing bindings (e.g. color), all components always use the same
|
||||
// curve. Collapse to a single option so no per-component tabs are shown.
|
||||
const visibleContexts =
|
||||
easingMode === "shared" ? [contexts[0]] : contexts;
|
||||
const allContexts = contexts.map(({ context }) => context);
|
||||
|
||||
const nextSegmentContexts = visibleContexts.filter(
|
||||
({ context }) => context.nextKey !== null,
|
||||
);
|
||||
const preferredContext =
|
||||
contexts.find(({ option }) => option.key === preferredComponentKey) ?? null;
|
||||
visibleContexts.find(({ option }) => option.key === preferredComponentKey) ?? null;
|
||||
const activeContext =
|
||||
preferredContext ?? nextSegmentContexts[0] ?? contexts[0];
|
||||
const componentOptions = contexts.map(({ option }) => option);
|
||||
preferredContext ?? nextSegmentContexts[0] ?? visibleContexts[0];
|
||||
const componentOptions = visibleContexts.map(({ option }) => option);
|
||||
|
||||
if (!activeContext.context.nextKey) {
|
||||
return createUnavailableState({
|
||||
|
|
@ -356,6 +373,7 @@ export function resolveGraphEditorSelectionState({
|
|||
keyframeId: resolvedKeyframeId,
|
||||
element: selectedElement.element,
|
||||
context: activeContext.context,
|
||||
allContexts,
|
||||
cubicBezier,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -96,19 +96,17 @@ export function useGraphEditorController() {
|
|||
return;
|
||||
}
|
||||
|
||||
const nextAnimations = applyGraphEditorCurvePreview({
|
||||
animations: state.element.animations,
|
||||
context: state.context,
|
||||
cubicBezier: nextValue,
|
||||
});
|
||||
const nextAnimations = state.allContexts.reduce(
|
||||
(animations, context) =>
|
||||
applyGraphEditorCurvePreview({ animations, context, cubicBezier: nextValue }),
|
||||
state.element.animations,
|
||||
);
|
||||
editor.timeline.previewElements({
|
||||
updates: [
|
||||
{
|
||||
trackId: state.trackId,
|
||||
elementId: state.elementId,
|
||||
updates: {
|
||||
animations: nextAnimations,
|
||||
},
|
||||
updates: { animations: nextAnimations },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
|
@ -123,6 +121,8 @@ export function useGraphEditorController() {
|
|||
return;
|
||||
}
|
||||
|
||||
// Build patches from the primary context (all shared-easing channels have
|
||||
// the same keyframe IDs, so the same patches apply to each).
|
||||
const patches = buildGraphEditorCurvePatches({
|
||||
context: state.context,
|
||||
cubicBezier: nextValue,
|
||||
|
|
@ -132,14 +132,16 @@ export function useGraphEditorController() {
|
|||
}
|
||||
|
||||
editor.timeline.updateKeyframeCurves({
|
||||
keyframes: patches.map(({ keyframeId, patch }) => ({
|
||||
trackId: state.trackId,
|
||||
elementId: state.elementId,
|
||||
propertyPath: state.propertyPath,
|
||||
componentKey: state.context.componentKey,
|
||||
keyframeId,
|
||||
patch,
|
||||
})),
|
||||
keyframes: state.allContexts.flatMap((context) =>
|
||||
patches.map(({ keyframeId, patch }) => ({
|
||||
trackId: state.trackId,
|
||||
elementId: state.elementId,
|
||||
propertyPath: state.propertyPath,
|
||||
componentKey: context.componentKey,
|
||||
keyframeId,
|
||||
patch,
|
||||
})),
|
||||
),
|
||||
});
|
||||
hasPreviewRef.current = false;
|
||||
},
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ import {
|
|||
TIMELINE_CONTENT_TOP_PADDING_PX,
|
||||
TIMELINE_TRACK_GAP_PX,
|
||||
TIMELINE_TRACK_LABELS_COLUMN_WIDTH_PX,
|
||||
KEYFRAME_LANE_HEIGHT_PX,
|
||||
} from "./layout";
|
||||
import { useElementInteraction } from "@/hooks/timeline/element/use-element-interaction";
|
||||
import {
|
||||
|
|
@ -57,6 +58,13 @@ import {
|
|||
getTotalTracksHeight,
|
||||
} from "./track-layout";
|
||||
import { SELECTED_TRACK_ROW_CLASS } from "./theme";
|
||||
import {
|
||||
computeTrackExpansionHeight,
|
||||
getTrackExpandedRows,
|
||||
getExpansionHeight,
|
||||
getPropertyLabel,
|
||||
type ExpandedRow,
|
||||
} from "./expanded-layout";
|
||||
import { TIMELINE_HORIZONTAL_WHEEL_STEP_PX } from "./interaction";
|
||||
import { TimelineToolbar } from "./timeline-toolbar";
|
||||
import { useElementSelection } from "@/hooks/timeline/element/use-element-selection";
|
||||
|
|
@ -169,6 +177,17 @@ export function Timeline() {
|
|||
rulerScrollRef,
|
||||
});
|
||||
|
||||
const expandedElementIds = useTimelineStore((s) => s.expandedElementIds);
|
||||
|
||||
const getTrackExpansionHeight = useCallback(
|
||||
(trackIndex: number) => {
|
||||
const track = tracks[trackIndex];
|
||||
if (!track) return 0;
|
||||
return computeTrackExpansionHeight({ track, expandedElementIds });
|
||||
},
|
||||
[tracks, expandedElementIds],
|
||||
);
|
||||
|
||||
// Stable refs so the wheel listener never goes stale
|
||||
const setZoomLevelRef = useRef(setZoomLevel);
|
||||
useEffect(() => {
|
||||
|
|
@ -417,6 +436,7 @@ export function Timeline() {
|
|||
trackLabelsScrollRef={trackLabelsScrollRef}
|
||||
timelineHeaderHeight={timelineHeaderHeight}
|
||||
hasHorizontalScrollbar={hasHorizontalScrollbar}
|
||||
getTrackExpansionHeight={getTrackExpansionHeight}
|
||||
/>
|
||||
|
||||
<div
|
||||
|
|
@ -493,7 +513,7 @@ export function Timeline() {
|
|||
TRACKS_CONTAINER_HEIGHT.min,
|
||||
Math.min(
|
||||
TRACKS_CONTAINER_HEIGHT.max,
|
||||
getTotalTracksHeight({ tracks }),
|
||||
getTotalTracksHeight({ tracks, getExtraHeight: getTrackExpansionHeight }),
|
||||
),
|
||||
) + TIMELINE_CONTENT_TOP_PADDING_PX
|
||||
}px`,
|
||||
|
|
@ -513,9 +533,8 @@ export function Timeline() {
|
|||
}}
|
||||
>
|
||||
{tracks.length > 0 && (
|
||||
<TimelineTrackRows
|
||||
dragElementId={dragState.elementId}
|
||||
mainTrackId={mainTrackId}
|
||||
<TimelineTrackRows
|
||||
mainTrackId={mainTrackId}
|
||||
zoomLevel={zoomLevel}
|
||||
dragState={dragState}
|
||||
tracksScrollRef={tracksScrollRef}
|
||||
|
|
@ -575,11 +594,13 @@ function TrackLabelsPanel({
|
|||
trackLabelsScrollRef,
|
||||
timelineHeaderHeight,
|
||||
hasHorizontalScrollbar,
|
||||
getTrackExpansionHeight,
|
||||
}: {
|
||||
trackLabelsRef: React.RefObject<HTMLDivElement | null>;
|
||||
trackLabelsScrollRef: React.RefObject<HTMLDivElement | null>;
|
||||
timelineHeaderHeight: number;
|
||||
hasHorizontalScrollbar: boolean;
|
||||
getTrackExpansionHeight: (trackIndex: number) => number;
|
||||
}) {
|
||||
const editor = useEditor();
|
||||
const scene = useEditor((e) => e.scenes.getActiveSceneOrNull());
|
||||
|
|
@ -596,6 +617,15 @@ function TrackLabelsPanel({
|
|||
[selectedElements],
|
||||
);
|
||||
|
||||
const expandedElementIds = useTimelineStore((s) => s.expandedElementIds);
|
||||
const trackExpandedRowsMap = useMemo(
|
||||
() =>
|
||||
tracks.map((track) =>
|
||||
getTrackExpandedRows({ track, expandedElementIds }),
|
||||
),
|
||||
[tracks, expandedElementIds],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex shrink-0 flex-col border-r"
|
||||
|
|
@ -612,43 +642,62 @@ function TrackLabelsPanel({
|
|||
className="flex flex-col"
|
||||
style={{ gap: `${TIMELINE_TRACK_GAP_PX}px` }}
|
||||
>
|
||||
{tracks.map((track) => (
|
||||
<div
|
||||
key={track.id}
|
||||
className={cn(
|
||||
"group flex items-center px-3",
|
||||
tracksWithSelection.has(track.id) &&
|
||||
SELECTED_TRACK_ROW_CLASS,
|
||||
)}
|
||||
style={{
|
||||
height: `${getTrackHeight({ type: track.type })}px`,
|
||||
}}
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-center justify-end gap-2">
|
||||
{canTrackHaveAudio(track) && (
|
||||
<TrackToggleIcon
|
||||
isOff={track.muted}
|
||||
icons={{ on: VolumeHighIcon, off: VolumeOffIcon }}
|
||||
onClick={() =>
|
||||
editor.timeline.toggleTrackMute({ trackId: track.id })
|
||||
}
|
||||
/>
|
||||
{tracks.map((track, index) => {
|
||||
const expandedRows = trackExpandedRowsMap[index];
|
||||
const baseHeight = getTrackHeight({ type: track.type });
|
||||
|
||||
return (
|
||||
<div
|
||||
key={track.id}
|
||||
className={cn(
|
||||
"group flex flex-col",
|
||||
tracksWithSelection.has(track.id) &&
|
||||
SELECTED_TRACK_ROW_CLASS,
|
||||
)}
|
||||
{canTrackBeHidden(track) && (
|
||||
<TrackToggleIcon
|
||||
isOff={track.hidden}
|
||||
icons={{ on: ViewIcon, off: ViewOffSlashIcon }}
|
||||
onClick={() =>
|
||||
editor.timeline.toggleTrackVisibility({
|
||||
trackId: track.id,
|
||||
})
|
||||
}
|
||||
/>
|
||||
style={{
|
||||
height: `${baseHeight + getTrackExpansionHeight(index)}px`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="flex shrink-0 items-center justify-end gap-2 px-3"
|
||||
style={{ height: `${baseHeight}px` }}
|
||||
>
|
||||
{canTrackHaveAudio(track) && (
|
||||
<TrackToggleIcon
|
||||
isOff={track.muted}
|
||||
icons={{
|
||||
on: VolumeHighIcon,
|
||||
off: VolumeOffIcon,
|
||||
}}
|
||||
onClick={() =>
|
||||
editor.timeline.toggleTrackMute({
|
||||
trackId: track.id,
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{canTrackBeHidden(track) && (
|
||||
<TrackToggleIcon
|
||||
isOff={track.hidden}
|
||||
icons={{
|
||||
on: ViewIcon,
|
||||
off: ViewOffSlashIcon,
|
||||
}}
|
||||
onClick={() =>
|
||||
editor.timeline.toggleTrackVisibility({
|
||||
trackId: track.id,
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<TrackIcon track={track} />
|
||||
</div>
|
||||
{expandedRows.length > 0 && (
|
||||
<PropertyTree rows={expandedRows} />
|
||||
)}
|
||||
<TrackIcon track={track} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -664,7 +713,6 @@ function TrackLabelsPanel({
|
|||
}
|
||||
|
||||
function TimelineTrackRows({
|
||||
dragElementId,
|
||||
mainTrackId,
|
||||
zoomLevel,
|
||||
dragState,
|
||||
|
|
@ -680,7 +728,6 @@ function TimelineTrackRows({
|
|||
isDragOver,
|
||||
dropTarget,
|
||||
}: {
|
||||
dragElementId: string | null;
|
||||
mainTrackId: string | null;
|
||||
zoomLevel: number;
|
||||
dragState: ElementDragState;
|
||||
|
|
@ -715,24 +762,34 @@ function TimelineTrackRows({
|
|||
[selectedElements],
|
||||
);
|
||||
|
||||
const sortedTracks = useMemo(
|
||||
() =>
|
||||
[...tracks]
|
||||
.map((track, index) => ({ track, index }))
|
||||
.sort((a, b) => {
|
||||
const aHasDragged = a.track.elements.some(
|
||||
(el) => el.id === dragElementId,
|
||||
);
|
||||
const bHasDragged = b.track.elements.some(
|
||||
(el) => el.id === dragElementId,
|
||||
);
|
||||
if (aHasDragged) return 1;
|
||||
if (bHasDragged) return -1;
|
||||
return 0;
|
||||
}),
|
||||
[tracks, dragElementId],
|
||||
const expandedElementIds = useTimelineStore((s) => s.expandedElementIds);
|
||||
|
||||
const getTrackExpansionHeight = useCallback(
|
||||
(trackIndex: number) => {
|
||||
const track = tracks[trackIndex];
|
||||
if (!track) return 0;
|
||||
return computeTrackExpansionHeight({ track, expandedElementIds });
|
||||
},
|
||||
[tracks, expandedElementIds],
|
||||
);
|
||||
|
||||
const sortedTracks = useMemo(() => {
|
||||
const draggingElementIds = new Set(dragState.dragElementIds);
|
||||
return [...tracks]
|
||||
.map((track, index) => ({ track, index }))
|
||||
.sort((a, b) => {
|
||||
const aHasDragged = a.track.elements.some((element) =>
|
||||
draggingElementIds.has(element.id),
|
||||
);
|
||||
const bHasDragged = b.track.elements.some((element) =>
|
||||
draggingElementIds.has(element.id),
|
||||
);
|
||||
if (aHasDragged) return 1;
|
||||
if (bHasDragged) return -1;
|
||||
return 0;
|
||||
});
|
||||
}, [tracks, dragState.dragElementIds]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{sortedTracks.map(({ track, index }) => (
|
||||
|
|
@ -745,8 +802,8 @@ function TimelineTrackRows({
|
|||
SELECTED_TRACK_ROW_CLASS,
|
||||
)}
|
||||
style={{
|
||||
top: `${TIMELINE_CONTENT_TOP_PADDING_PX + getCumulativeHeightBefore({ tracks, trackIndex: index })}px`,
|
||||
height: `${getTrackHeight({ type: track.type })}px`,
|
||||
top: `${TIMELINE_CONTENT_TOP_PADDING_PX + getCumulativeHeightBefore({ tracks, trackIndex: index, getExtraHeight: getTrackExpansionHeight })}px`,
|
||||
height: `${getTrackHeight({ type: track.type }) + getTrackExpansionHeight(index)}px`,
|
||||
}}
|
||||
>
|
||||
<TimelineTrackContent
|
||||
|
|
@ -868,3 +925,23 @@ function TrackToggleIcon({
|
|||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function PropertyTree({ rows }: { rows: ExpandedRow[] }) {
|
||||
return (
|
||||
<div className="flex flex-col overflow-hidden">
|
||||
{rows.map((row, index) => (
|
||||
<div
|
||||
key={row.propertyPath}
|
||||
className={cn(
|
||||
"flex shrink-0 items-center px-3 bg-muted/50",
|
||||
)}
|
||||
style={{ height: `${KEYFRAME_LANE_HEIGHT_PX}px` }}
|
||||
>
|
||||
<span className="text-muted-foreground truncate text-xs leading-none">
|
||||
{getPropertyLabel(row.propertyPath)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,10 @@ export const TIMELINE_TRACK_HEIGHTS_PX: Record<TrackType, number> = {
|
|||
effect: 25,
|
||||
} as const;
|
||||
|
||||
export const KEYFRAME_LANE_HEIGHT_PX = 20;
|
||||
export const KEYFRAME_DIAMOND_SIZE_PX = 14;
|
||||
export const EXPANDED_GROUP_HEADER_HEIGHT_PX = 18;
|
||||
|
||||
export const TIMELINE_TRACK_GAP_PX = 6;
|
||||
export const TIMELINE_TRACK_LABELS_COLUMN_WIDTH_PX = 112;
|
||||
export const TIMELINE_RULER_HEIGHT_PX = 22;
|
||||
|
|
|
|||
|
|
@ -3,13 +3,14 @@
|
|||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { useAssetsPanelStore } from "@/stores/assets-panel-store";
|
||||
import { AudioWaveform } from "./audio-waveform";
|
||||
import { useTimelineElementResize } from "@/hooks/timeline/element/use-element-resize";
|
||||
import { useElementPreview } from "@/hooks/use-element-preview";
|
||||
import {
|
||||
useKeyframeDrag,
|
||||
type KeyframeDragState,
|
||||
} from "@/hooks/timeline/element/use-keyframe-drag";
|
||||
import { useKeyframeSelection } from "@/hooks/timeline/element/use-keyframe-selection";
|
||||
import type { SnapPoint } from "@/lib/timeline/snap-utils";
|
||||
import { useKeyframeBoxSelect } from "@/hooks/timeline/element/use-keyframe-box-select";
|
||||
import { SelectionBox } from "@/lib/selection/selection-box";
|
||||
import { getElementKeyframes } from "@/lib/animation";
|
||||
import {
|
||||
canElementHaveAudio,
|
||||
|
|
@ -20,10 +21,7 @@ import {
|
|||
timelineTimeToSnappedPixels,
|
||||
} from "@/lib/timeline";
|
||||
import { getTrackHeight } from "./track-layout";
|
||||
import {
|
||||
getTimelineElementClassName,
|
||||
TIMELINE_TRACK_THEME,
|
||||
} from "./theme";
|
||||
import { getTimelineElementClassName, TIMELINE_TRACK_THEME } from "./theme";
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
|
|
@ -68,19 +66,25 @@ import {
|
|||
Search01Icon,
|
||||
Exchange01Icon,
|
||||
KeyframeIcon,
|
||||
Link02Icon,
|
||||
MagicWand05Icon,
|
||||
Unlink02Icon,
|
||||
} from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { uppercase } from "@/utils/string";
|
||||
import type { ComponentProps, ReactNode } from "react";
|
||||
import { useMemo, type ComponentProps, type ReactNode } from "react";
|
||||
import type {
|
||||
SelectedKeyframeRef,
|
||||
ElementKeyframe,
|
||||
} from "@/lib/animation/types";
|
||||
import { cn } from "@/utils/ui";
|
||||
import { usePropertiesStore } from "@/components/editor/panels/properties/stores/properties-store";
|
||||
import { getTrackTypeForElementType } from "@/lib/timeline/placement/compatibility";
|
||||
import { useTimelineStore } from "@/stores/timeline-store";
|
||||
import { KEYFRAME_LANE_HEIGHT_PX } from "./layout";
|
||||
import {
|
||||
getExpandedRows,
|
||||
getExpansionHeight,
|
||||
type ExpandedRow,
|
||||
} from "./expanded-layout";
|
||||
|
||||
const KEYFRAME_INDICATOR_MIN_WIDTH_PX = 40;
|
||||
const ELEMENT_RING_WIDTH_PX = 1.5;
|
||||
|
|
@ -191,8 +195,12 @@ interface TimelineElementProps {
|
|||
track: TimelineTrack;
|
||||
zoomLevel: number;
|
||||
isSelected: boolean;
|
||||
onSnapPointChange?: (snapPoint: SnapPoint | null) => void;
|
||||
onResizeStateChange?: (params: { isResizing: boolean }) => void;
|
||||
onResizeStart: (params: {
|
||||
event: React.MouseEvent;
|
||||
element: TimelineElementType;
|
||||
track: TimelineTrack;
|
||||
side: "left" | "right";
|
||||
}) => void;
|
||||
onElementMouseDown: (
|
||||
event: React.MouseEvent,
|
||||
element: TimelineElementType,
|
||||
|
|
@ -210,8 +218,7 @@ export function TimelineElement({
|
|||
track,
|
||||
zoomLevel,
|
||||
isSelected,
|
||||
onSnapPointChange,
|
||||
onResizeStateChange,
|
||||
onResizeStart,
|
||||
onElementMouseDown,
|
||||
onElementClick,
|
||||
dragState,
|
||||
|
|
@ -220,6 +227,11 @@ export function TimelineElement({
|
|||
const mediaAssets = useEditor((e) => e.media.getAssets());
|
||||
const { selectedElements } = useElementSelection();
|
||||
const requestRevealMedia = useAssetsPanelStore((s) => s.requestRevealMedia);
|
||||
const { renderElement } = useElementPreview({
|
||||
trackId: track.id,
|
||||
elementId: element.id,
|
||||
fallback: element,
|
||||
});
|
||||
|
||||
let mediaAsset: MediaAsset | null = null;
|
||||
|
||||
|
|
@ -230,31 +242,23 @@ export function TimelineElement({
|
|||
|
||||
const hasAudio = mediaSupportsAudio({ media: mediaAsset });
|
||||
|
||||
const { handleResizeStart, isResizing, currentStartTime, currentDuration } =
|
||||
useTimelineElementResize({
|
||||
element,
|
||||
track,
|
||||
zoomLevel,
|
||||
onSnapPointChange,
|
||||
onResizeStateChange,
|
||||
});
|
||||
|
||||
const isCurrentElementSelected = selectedElements.some(
|
||||
(selected) =>
|
||||
selected.elementId === element.id && selected.trackId === track.id,
|
||||
);
|
||||
|
||||
const isBeingDragged = dragState.elementId === element.id;
|
||||
const isBeingDragged = dragState.dragElementIds.includes(element.id);
|
||||
const dragOffsetY =
|
||||
isBeingDragged && dragState.isDragging
|
||||
? dragState.currentMouseY - dragState.startMouseY
|
||||
: 0;
|
||||
const dragTimeOffset = dragState.dragTimeOffsets[element.id] ?? 0;
|
||||
const elementStartTime =
|
||||
isBeingDragged && dragState.isDragging
|
||||
? dragState.currentTime
|
||||
: element.startTime;
|
||||
const displayedStartTime = isResizing ? currentStartTime : elementStartTime;
|
||||
const displayedDuration = isResizing ? currentDuration : element.duration;
|
||||
? dragState.currentTime + dragTimeOffset
|
||||
: renderElement.startTime;
|
||||
const displayedStartTime = elementStartTime;
|
||||
const displayedDuration = renderElement.duration;
|
||||
const elementWidth = timelineTimeToPixels({
|
||||
time: displayedDuration,
|
||||
zoomLevel,
|
||||
|
|
@ -281,6 +285,41 @@ export function TimelineElement({
|
|||
handleKeyframeClick,
|
||||
getVisualOffsetPx,
|
||||
} = useKeyframeDrag({ zoomLevel, element, displayedStartTime });
|
||||
|
||||
const elementKeyframes = getElementKeyframes({
|
||||
animations: element.animations,
|
||||
});
|
||||
|
||||
const isExpanded = useTimelineStore((s) =>
|
||||
s.expandedElementIds.has(element.id),
|
||||
);
|
||||
const toggleElementExpanded = useTimelineStore(
|
||||
(s) => s.toggleElementExpanded,
|
||||
);
|
||||
const expandedRows = useMemo(
|
||||
() =>
|
||||
isExpanded
|
||||
? getExpandedRows({ animations: element.animations })
|
||||
: [],
|
||||
[isExpanded, element.animations],
|
||||
);
|
||||
|
||||
const {
|
||||
containerRef: expandedLanesRef,
|
||||
selectionBox: keyframeSelectionBox,
|
||||
isBoxSelecting: isKeyframeBoxSelecting,
|
||||
handleExpandedAreaMouseDown,
|
||||
handleExpandedAreaClick,
|
||||
} = useKeyframeBoxSelect({
|
||||
trackId: track.id,
|
||||
elementId: element.id,
|
||||
rows: expandedRows,
|
||||
keyframes: elementKeyframes,
|
||||
displayedStartTime,
|
||||
zoomLevel,
|
||||
elementLeft,
|
||||
});
|
||||
|
||||
const handleRevealInMedia = ({ event }: { event: React.MouseEvent }) => {
|
||||
event.stopPropagation();
|
||||
if (hasMediaId(element)) {
|
||||
|
|
@ -299,15 +338,44 @@ export function TimelineElement({
|
|||
: "Extract audio";
|
||||
const isElementSourceAudioSeparated =
|
||||
element.type === "video" && isSourceAudioSeparated({ element });
|
||||
const hasKeyframes = elementKeyframes.length > 0;
|
||||
const expansionHeight = getExpansionHeight({ rows: expandedRows });
|
||||
const baseTrackHeight = getTrackHeight({ type: track.type });
|
||||
|
||||
const expandedContent =
|
||||
isExpanded && expandedRows.length > 0 ? (
|
||||
<ExpandedKeyframeLanes
|
||||
rows={expandedRows}
|
||||
keyframes={elementKeyframes}
|
||||
trackId={track.id}
|
||||
elementId={element.id}
|
||||
displayedStartTime={displayedStartTime}
|
||||
zoomLevel={zoomLevel}
|
||||
elementLeft={elementLeft}
|
||||
keyframeDragState={keyframeDragState}
|
||||
onKeyframeMouseDown={handleKeyframeMouseDown}
|
||||
onKeyframeClick={handleKeyframeClick}
|
||||
getVisualOffsetPx={getVisualOffsetPx}
|
||||
containerRef={expandedLanesRef}
|
||||
onLaneMouseDown={handleExpandedAreaMouseDown}
|
||||
onLaneClick={handleExpandedAreaClick}
|
||||
selectionBox={keyframeSelectionBox}
|
||||
isBoxSelecting={isKeyframeBoxSelecting}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>
|
||||
<div
|
||||
className="absolute top-0 h-full select-none"
|
||||
className="absolute top-0 select-none"
|
||||
style={{
|
||||
left: `${elementLeft}px`,
|
||||
width: `${elementWidth}px`,
|
||||
height:
|
||||
expandedRows.length > 0
|
||||
? `${baseTrackHeight + expansionHeight}px`
|
||||
: "100%",
|
||||
transform:
|
||||
isBeingDragged && dragState.isDragging
|
||||
? `translate3d(0, ${dragOffsetY}px, 0)`
|
||||
|
|
@ -318,13 +386,19 @@ export function TimelineElement({
|
|||
element={element}
|
||||
track={track}
|
||||
isSelected={isSelected}
|
||||
isExpanded={expandedRows.length > 0}
|
||||
baseTrackHeight={baseTrackHeight}
|
||||
expandedContent={expandedContent}
|
||||
onElementClick={onElementClick}
|
||||
onElementMouseDown={onElementMouseDown}
|
||||
handleResizeStart={handleResizeStart}
|
||||
onResizeStart={onResizeStart}
|
||||
isDropTarget={isDropTarget}
|
||||
/>
|
||||
{isSelected && (
|
||||
<div className="pointer-events-none absolute inset-0 overflow-hidden">
|
||||
<div
|
||||
className="pointer-events-none absolute inset-x-0 top-0 overflow-hidden"
|
||||
style={{ height: `${baseTrackHeight}px` }}
|
||||
>
|
||||
<KeyframeIndicators
|
||||
indicators={keyframeIndicators}
|
||||
dragState={keyframeDragState}
|
||||
|
|
@ -346,6 +420,14 @@ export function TimelineElement({
|
|||
Split
|
||||
</ActionMenuItem>
|
||||
<CopyMenuItem />
|
||||
{selectedElements.length === 1 && (
|
||||
<ActionMenuItem
|
||||
action="duplicate-selected"
|
||||
icon={<HugeiconsIcon icon={Copy01Icon} />}
|
||||
>
|
||||
Duplicate
|
||||
</ActionMenuItem>
|
||||
)}
|
||||
{canElementHaveAudio(element) && hasAudio && (
|
||||
<MuteMenuItem
|
||||
isMultipleSelected={selectedElements.length > 1}
|
||||
|
|
@ -355,11 +437,11 @@ export function TimelineElement({
|
|||
)}
|
||||
{canToggleCurrentSourceAudio && (
|
||||
<ContextMenuItem
|
||||
icon={
|
||||
<HugeiconsIcon
|
||||
icon={isElementSourceAudioSeparated ? Unlink02Icon : Link02Icon}
|
||||
/>
|
||||
}
|
||||
icon={
|
||||
<HugeiconsIcon
|
||||
icon={isElementSourceAudioSeparated ? ScissorIcon : ScissorIcon}
|
||||
/>
|
||||
}
|
||||
onClick={(event: React.MouseEvent) => {
|
||||
event.stopPropagation();
|
||||
invokeAction("toggle-source-audio");
|
||||
|
|
@ -375,13 +457,16 @@ export function TimelineElement({
|
|||
isCurrentElementSelected={isCurrentElementSelected}
|
||||
/>
|
||||
)}
|
||||
{selectedElements.length === 1 && (
|
||||
<ActionMenuItem
|
||||
action="duplicate-selected"
|
||||
icon={<HugeiconsIcon icon={Copy01Icon} />}
|
||||
{hasKeyframes && (
|
||||
<ContextMenuItem
|
||||
icon={<HugeiconsIcon icon={KeyframeIcon} />}
|
||||
onClick={(event: React.MouseEvent) => {
|
||||
event.stopPropagation();
|
||||
toggleElementExpanded(element.id);
|
||||
}}
|
||||
>
|
||||
Duplicate
|
||||
</ActionMenuItem>
|
||||
{isExpanded ? "Collapse keyframes" : "Expand keyframes"}
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
{selectedElements.length === 1 && hasMediaId(element) && (
|
||||
<>
|
||||
|
|
@ -417,14 +502,20 @@ function ElementInner({
|
|||
element,
|
||||
track,
|
||||
isSelected,
|
||||
isExpanded,
|
||||
baseTrackHeight,
|
||||
expandedContent,
|
||||
onElementClick,
|
||||
onElementMouseDown,
|
||||
handleResizeStart,
|
||||
onResizeStart,
|
||||
isDropTarget = false,
|
||||
}: {
|
||||
element: TimelineElementType;
|
||||
track: TimelineTrack;
|
||||
isSelected: boolean;
|
||||
isExpanded: boolean;
|
||||
baseTrackHeight: number;
|
||||
expandedContent: React.ReactNode;
|
||||
onElementClick: (
|
||||
event: React.MouseEvent,
|
||||
element: TimelineElementType,
|
||||
|
|
@ -433,9 +524,10 @@ function ElementInner({
|
|||
event: React.MouseEvent,
|
||||
element: TimelineElementType,
|
||||
) => void;
|
||||
handleResizeStart: (params: {
|
||||
onResizeStart: (params: {
|
||||
event: React.MouseEvent;
|
||||
elementId: string;
|
||||
element: TimelineElementType;
|
||||
track: TimelineTrack;
|
||||
side: "left" | "right";
|
||||
}) => void;
|
||||
isDropTarget?: boolean;
|
||||
|
|
@ -463,8 +555,7 @@ function ElementInner({
|
|||
<div
|
||||
className={cn(
|
||||
"absolute inset-0 overflow-hidden rounded-sm",
|
||||
getTimelineElementClassName({ type: track.type }),
|
||||
isReducedOpacity && "opacity-50",
|
||||
isExpanded && "bg-background",
|
||||
)}
|
||||
>
|
||||
<button
|
||||
|
|
@ -474,9 +565,23 @@ function ElementInner({
|
|||
onClick={(event) => onElementClick(event, element)}
|
||||
onMouseDown={(event) => onElementMouseDown(event, element)}
|
||||
>
|
||||
<div className="flex flex-1 min-h-0 items-center overflow-hidden">
|
||||
<ElementContent element={element} track={track} />
|
||||
<div
|
||||
className={cn(
|
||||
"flex shrink-0 items-center overflow-hidden",
|
||||
getTimelineElementClassName({
|
||||
type: getTrackTypeForElementType({
|
||||
elementType: element.type,
|
||||
}),
|
||||
}),
|
||||
isReducedOpacity && "opacity-50",
|
||||
)}
|
||||
style={{ height: `${baseTrackHeight}px` }}
|
||||
>
|
||||
<div className="flex flex-1 min-h-0 items-center overflow-hidden">
|
||||
<ElementContent element={element} track={track} />
|
||||
</div>
|
||||
</div>
|
||||
{expandedContent}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -485,13 +590,15 @@ function ElementInner({
|
|||
<>
|
||||
<ResizeHandle
|
||||
side="left"
|
||||
elementId={element.id}
|
||||
handleResizeStart={handleResizeStart}
|
||||
element={element}
|
||||
track={track}
|
||||
onResizeStart={onResizeStart}
|
||||
/>
|
||||
<ResizeHandle
|
||||
side="right"
|
||||
elementId={element.id}
|
||||
handleResizeStart={handleResizeStart}
|
||||
element={element}
|
||||
track={track}
|
||||
onResizeStart={onResizeStart}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
|
@ -501,14 +608,17 @@ function ElementInner({
|
|||
|
||||
function ResizeHandle({
|
||||
side,
|
||||
elementId,
|
||||
handleResizeStart,
|
||||
element,
|
||||
track,
|
||||
onResizeStart,
|
||||
}: {
|
||||
side: "left" | "right";
|
||||
elementId: string;
|
||||
handleResizeStart: (params: {
|
||||
element: TimelineElementType;
|
||||
track: TimelineTrack;
|
||||
onResizeStart: (params: {
|
||||
event: React.MouseEvent;
|
||||
elementId: string;
|
||||
element: TimelineElementType;
|
||||
track: TimelineTrack;
|
||||
side: "left" | "right";
|
||||
}) => void;
|
||||
}) {
|
||||
|
|
@ -520,7 +630,7 @@ function ResizeHandle({
|
|||
"absolute top-0 bottom-0 w-2",
|
||||
isLeft ? "-left-1 cursor-w-resize" : "-right-1 cursor-e-resize",
|
||||
)}
|
||||
onMouseDown={(event) => handleResizeStart({ event, elementId, side })}
|
||||
onMouseDown={(event) => onResizeStart({ event, element, track, side })}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
aria-label={`${isLeft ? "Left" : "Right"} resize handle`}
|
||||
></button>
|
||||
|
|
@ -582,7 +692,7 @@ function KeyframeIndicators({
|
|||
<button
|
||||
key={indicator.time}
|
||||
type="button"
|
||||
className="pointer-events-auto absolute top-1/2 -translate-x-1/2 -translate-y-1/2 cursor-grab"
|
||||
className="pointer-events-auto absolute top-1/2 -translate-x-1/2 -translate-y-1/2 cursor-grab mr-0.5"
|
||||
style={{ left: visualOffsetPx }}
|
||||
onMouseDown={(event) =>
|
||||
onKeyframeMouseDown({ event, keyframes: indicator.keyframes })
|
||||
|
|
@ -610,6 +720,177 @@ function KeyframeIndicators({
|
|||
});
|
||||
}
|
||||
|
||||
function ExpandedKeyframeLanes({
|
||||
rows,
|
||||
keyframes,
|
||||
trackId,
|
||||
elementId,
|
||||
displayedStartTime,
|
||||
zoomLevel,
|
||||
elementLeft,
|
||||
keyframeDragState,
|
||||
onKeyframeMouseDown,
|
||||
onKeyframeClick,
|
||||
getVisualOffsetPx,
|
||||
containerRef,
|
||||
onLaneMouseDown,
|
||||
onLaneClick,
|
||||
selectionBox,
|
||||
isBoxSelecting,
|
||||
}: {
|
||||
rows: ExpandedRow[];
|
||||
keyframes: ElementKeyframe[];
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
displayedStartTime: number;
|
||||
zoomLevel: number;
|
||||
elementLeft: number;
|
||||
keyframeDragState: KeyframeDragState;
|
||||
onKeyframeMouseDown: (params: {
|
||||
event: React.MouseEvent;
|
||||
keyframes: SelectedKeyframeRef[];
|
||||
}) => void;
|
||||
containerRef: React.RefObject<HTMLDivElement | null>;
|
||||
onLaneMouseDown: (event: React.MouseEvent) => void;
|
||||
onLaneClick: (event: React.MouseEvent) => void;
|
||||
selectionBox: {
|
||||
startPos: { x: number; y: number };
|
||||
currentPos: { x: number; y: number };
|
||||
isActive: boolean;
|
||||
} | null;
|
||||
isBoxSelecting: boolean;
|
||||
onKeyframeClick: (params: {
|
||||
event: React.MouseEvent;
|
||||
keyframes: SelectedKeyframeRef[];
|
||||
orderedKeyframes: SelectedKeyframeRef[];
|
||||
indicatorTime: number;
|
||||
}) => void;
|
||||
getVisualOffsetPx: (params: {
|
||||
indicatorTime: number;
|
||||
indicatorOffsetPx: number;
|
||||
isBeingDragged: boolean;
|
||||
displayedStartTime: number;
|
||||
elementLeft: number;
|
||||
}) => number;
|
||||
}) {
|
||||
const { isKeyframeSelected } = useKeyframeSelection();
|
||||
|
||||
const orderedKeyframes = useMemo(
|
||||
() =>
|
||||
[...keyframes]
|
||||
.sort(
|
||||
(a, b) =>
|
||||
a.time - b.time ||
|
||||
a.propertyPath.localeCompare(b.propertyPath),
|
||||
)
|
||||
.map((kf) => ({
|
||||
trackId,
|
||||
elementId,
|
||||
propertyPath: kf.propertyPath,
|
||||
keyframeId: kf.id,
|
||||
})),
|
||||
[keyframes, trackId, elementId],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="relative flex flex-col"
|
||||
onMouseDown={onLaneMouseDown}
|
||||
onClick={onLaneClick}
|
||||
>
|
||||
{rows.map((row) => {
|
||||
const laneKeyframes = keyframes.filter(
|
||||
(kf) => kf.propertyPath === row.propertyPath,
|
||||
);
|
||||
return (
|
||||
<div
|
||||
key={row.propertyPath}
|
||||
className={cn(
|
||||
"relative flex items-center bg-muted/50",
|
||||
)}
|
||||
style={{ height: `${KEYFRAME_LANE_HEIGHT_PX}px` }}
|
||||
>
|
||||
{laneKeyframes.map((kf) => {
|
||||
const keyframeRef: SelectedKeyframeRef = {
|
||||
trackId,
|
||||
elementId,
|
||||
propertyPath: row.propertyPath,
|
||||
keyframeId: kf.id,
|
||||
};
|
||||
const isBeingDragged =
|
||||
keyframeDragState.draggingKeyframeIds.has(kf.id);
|
||||
const kfLeft = timelineTimeToSnappedPixels({
|
||||
time: displayedStartTime + kf.time,
|
||||
zoomLevel,
|
||||
});
|
||||
const offsetPx = kfLeft - elementLeft;
|
||||
const visualOffset = getVisualOffsetPx({
|
||||
indicatorTime: kf.time,
|
||||
indicatorOffsetPx: offsetPx,
|
||||
isBeingDragged,
|
||||
displayedStartTime,
|
||||
elementLeft,
|
||||
});
|
||||
const isSelected = isKeyframeSelected({
|
||||
keyframe: keyframeRef,
|
||||
});
|
||||
|
||||
return (
|
||||
<button
|
||||
key={kf.id}
|
||||
type="button"
|
||||
className={cn(
|
||||
"pointer-events-auto absolute top-1/2 -translate-x-1/2 -translate-y-1/2 cursor-grab",
|
||||
isBoxSelecting && "pointer-events-none",
|
||||
)}
|
||||
style={{ left: visualOffset }}
|
||||
onMouseDown={(event) => {
|
||||
event.stopPropagation();
|
||||
onKeyframeMouseDown({
|
||||
event,
|
||||
keyframes: [keyframeRef],
|
||||
});
|
||||
}}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onKeyframeClick({
|
||||
event,
|
||||
keyframes: [keyframeRef],
|
||||
orderedKeyframes,
|
||||
indicatorTime: kf.time,
|
||||
});
|
||||
}}
|
||||
aria-label="Select keyframe"
|
||||
>
|
||||
<HugeiconsIcon
|
||||
icon={KeyframeIcon}
|
||||
className={cn(
|
||||
"size-3.5 text-black mr-1",
|
||||
isSelected
|
||||
? "fill-primary"
|
||||
: "fill-white",
|
||||
)}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{selectionBox && (
|
||||
<SelectionBox
|
||||
startPos={selectionBox.startPos}
|
||||
currentPos={selectionBox.currentPos}
|
||||
containerRef={containerRef}
|
||||
isActive={selectionBox.isActive}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ElementContentProps {
|
||||
element: TimelineElementType;
|
||||
track: TimelineTrack;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import type { TrackType } from "@/lib/timeline";
|
||||
import {
|
||||
KEYFRAME_LANE_HEIGHT_PX,
|
||||
TIMELINE_TRACK_GAP_PX,
|
||||
TIMELINE_TRACK_HEIGHTS_PX,
|
||||
} from "./layout";
|
||||
|
|
@ -8,28 +9,50 @@ export function getTrackHeight({ type }: { type: TrackType }): number {
|
|||
return TIMELINE_TRACK_HEIGHTS_PX[type];
|
||||
}
|
||||
|
||||
export function getExpandedTrackHeight({
|
||||
type,
|
||||
expandedLaneCount,
|
||||
}: {
|
||||
type: TrackType;
|
||||
expandedLaneCount: number;
|
||||
}): number {
|
||||
return (
|
||||
TIMELINE_TRACK_HEIGHTS_PX[type] +
|
||||
expandedLaneCount * KEYFRAME_LANE_HEIGHT_PX
|
||||
);
|
||||
}
|
||||
|
||||
export function getCumulativeHeightBefore({
|
||||
tracks,
|
||||
trackIndex,
|
||||
getExtraHeight,
|
||||
}: {
|
||||
tracks: Array<{ type: TrackType }>;
|
||||
trackIndex: number;
|
||||
getExtraHeight?: (trackIndex: number) => number;
|
||||
}): number {
|
||||
return tracks
|
||||
.slice(0, trackIndex)
|
||||
.reduce(
|
||||
(sum, track) => sum + getTrackHeight({ type: track.type }) + TIMELINE_TRACK_GAP_PX,
|
||||
(sum, track, i) =>
|
||||
sum +
|
||||
getTrackHeight({ type: track.type }) +
|
||||
(getExtraHeight?.(i) ?? 0) +
|
||||
TIMELINE_TRACK_GAP_PX,
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
export function getTotalTracksHeight({
|
||||
tracks,
|
||||
getExtraHeight,
|
||||
}: {
|
||||
tracks: Array<{ type: TrackType }>;
|
||||
getExtraHeight?: (trackIndex: number) => number;
|
||||
}): number {
|
||||
const tracksHeight = tracks.reduce(
|
||||
(sum, track) => sum + getTrackHeight({ type: track.type }),
|
||||
(sum, track, i) =>
|
||||
sum + getTrackHeight({ type: track.type }) + (getExtraHeight?.(i) ?? 0),
|
||||
0,
|
||||
);
|
||||
const gapsHeight = Math.max(0, tracks.length - 1) * TIMELINE_TRACK_GAP_PX;
|
||||
|
|
|
|||
|
|
@ -41,6 +41,8 @@ const MOUSE_BUTTON_RIGHT = 2;
|
|||
const initialDragState: ElementDragState = {
|
||||
isDragging: false,
|
||||
elementId: null,
|
||||
dragElementIds: [],
|
||||
dragTimeOffsets: {},
|
||||
trackId: null,
|
||||
startMouseX: 0,
|
||||
startMouseY: 0,
|
||||
|
|
@ -202,6 +204,8 @@ export function useElementInteraction({
|
|||
setDragState({
|
||||
isDragging: true,
|
||||
elementId,
|
||||
dragElementIds: elementId ? [elementId] : [],
|
||||
dragTimeOffsets: {},
|
||||
trackId,
|
||||
startMouseX,
|
||||
startMouseY,
|
||||
|
|
@ -521,6 +525,7 @@ export function useElementInteraction({
|
|||
}, [
|
||||
dragState.isDragging,
|
||||
dragState.elementId,
|
||||
dragState.startElementTime,
|
||||
dragState.startMouseY,
|
||||
dragState.trackId,
|
||||
dragState.currentTime,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,222 @@
|
|||
import { useCallback, useRef, useMemo } from "react";
|
||||
import { useBoxSelect } from "@/lib/selection/hooks/use-box-select";
|
||||
import {
|
||||
useKeyframeSelection,
|
||||
getSelectedKeyframeId,
|
||||
} from "./use-keyframe-selection";
|
||||
import type {
|
||||
SelectedKeyframeRef,
|
||||
ElementKeyframe,
|
||||
} from "@/lib/animation/types";
|
||||
import type { ExpandedRow } from "@/components/editor/panels/timeline/expanded-layout";
|
||||
import { timelineTimeToSnappedPixels } from "@/lib/timeline";
|
||||
import {
|
||||
KEYFRAME_LANE_HEIGHT_PX,
|
||||
KEYFRAME_DIAMOND_SIZE_PX,
|
||||
} from "@/components/editor/panels/timeline/layout";
|
||||
|
||||
export function useKeyframeBoxSelect({
|
||||
trackId,
|
||||
elementId,
|
||||
rows,
|
||||
keyframes,
|
||||
displayedStartTime,
|
||||
zoomLevel,
|
||||
elementLeft,
|
||||
}: {
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
rows: ExpandedRow[];
|
||||
keyframes: ElementKeyframe[];
|
||||
displayedStartTime: number;
|
||||
zoomLevel: number;
|
||||
elementLeft: number;
|
||||
}) {
|
||||
const {
|
||||
selectedKeyframes,
|
||||
keyframeSelectionAnchor,
|
||||
setKeyframeSelection,
|
||||
clearKeyframeSelection,
|
||||
} = useKeyframeSelection();
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const initialKeyframesRef = useRef<SelectedKeyframeRef[]>([]);
|
||||
|
||||
const keyframeEntries = useMemo(() => {
|
||||
const entries: Array<{
|
||||
id: string;
|
||||
ref: SelectedKeyframeRef;
|
||||
rowIndex: number;
|
||||
offsetPx: number;
|
||||
}> = [];
|
||||
|
||||
for (const kf of keyframes) {
|
||||
const rowIndex = rows.findIndex(
|
||||
(r) => r.propertyPath === kf.propertyPath,
|
||||
);
|
||||
if (rowIndex === -1) continue;
|
||||
|
||||
const ref: SelectedKeyframeRef = {
|
||||
trackId,
|
||||
elementId,
|
||||
propertyPath: kf.propertyPath,
|
||||
keyframeId: kf.id,
|
||||
};
|
||||
|
||||
const kfLeft = timelineTimeToSnappedPixels({
|
||||
time: displayedStartTime + kf.time,
|
||||
zoomLevel,
|
||||
});
|
||||
|
||||
entries.push({
|
||||
id: getSelectedKeyframeId({ keyframe: ref }),
|
||||
ref,
|
||||
rowIndex,
|
||||
offsetPx: kfLeft - elementLeft,
|
||||
});
|
||||
}
|
||||
|
||||
return entries;
|
||||
}, [
|
||||
keyframes,
|
||||
rows,
|
||||
trackId,
|
||||
elementId,
|
||||
displayedStartTime,
|
||||
zoomLevel,
|
||||
elementLeft,
|
||||
]);
|
||||
|
||||
const idToRefMap = useMemo(() => {
|
||||
const map = new Map<string, SelectedKeyframeRef>();
|
||||
for (const entry of keyframeEntries) {
|
||||
map.set(entry.id, entry.ref);
|
||||
}
|
||||
return map;
|
||||
}, [keyframeEntries]);
|
||||
|
||||
const selectedIds = useMemo(
|
||||
() => selectedKeyframes.map((keyframe) => getSelectedKeyframeId({ keyframe })),
|
||||
[selectedKeyframes],
|
||||
);
|
||||
|
||||
const anchorId = useMemo(
|
||||
() =>
|
||||
keyframeSelectionAnchor
|
||||
? getSelectedKeyframeId({ keyframe: keyframeSelectionAnchor })
|
||||
: null,
|
||||
[keyframeSelectionAnchor],
|
||||
);
|
||||
|
||||
const resolveIntersections = useCallback(
|
||||
({
|
||||
startPos,
|
||||
currentPos,
|
||||
}: {
|
||||
startPos: { x: number; y: number };
|
||||
currentPos: { x: number; y: number };
|
||||
}) => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return [];
|
||||
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
|
||||
const sx = startPos.x - containerRect.left;
|
||||
const sy = startPos.y - containerRect.top;
|
||||
const cx = currentPos.x - containerRect.left;
|
||||
const cy = currentPos.y - containerRect.top;
|
||||
|
||||
const selLeft = Math.min(sx, cx);
|
||||
const selTop = Math.min(sy, cy);
|
||||
const selRight = Math.max(sx, cx);
|
||||
const selBottom = Math.max(sy, cy);
|
||||
|
||||
const halfHit = KEYFRAME_DIAMOND_SIZE_PX / 2;
|
||||
|
||||
return keyframeEntries
|
||||
.filter((entry) => {
|
||||
const kfX = entry.offsetPx;
|
||||
const kfY =
|
||||
entry.rowIndex * KEYFRAME_LANE_HEIGHT_PX +
|
||||
KEYFRAME_LANE_HEIGHT_PX / 2;
|
||||
|
||||
return !(
|
||||
kfX + halfHit < selLeft ||
|
||||
kfX - halfHit > selRight ||
|
||||
kfY + halfHit < selTop ||
|
||||
kfY - halfHit > selBottom
|
||||
);
|
||||
})
|
||||
.map((entry) => entry.id);
|
||||
},
|
||||
[keyframeEntries],
|
||||
);
|
||||
|
||||
const onSelectionChange = useCallback(
|
||||
({
|
||||
intersectedIds,
|
||||
isAdditive,
|
||||
}: {
|
||||
intersectedIds: string[];
|
||||
initialSelectedIds: string[];
|
||||
initialAnchorId: string | null;
|
||||
isAdditive: boolean;
|
||||
}) => {
|
||||
const intersectedRefs = intersectedIds
|
||||
.map((id) => idToRefMap.get(id))
|
||||
.filter((ref): ref is SelectedKeyframeRef => ref != null);
|
||||
|
||||
if (isAdditive) {
|
||||
setKeyframeSelection({
|
||||
keyframes: [
|
||||
...initialKeyframesRef.current,
|
||||
...intersectedRefs,
|
||||
],
|
||||
});
|
||||
} else {
|
||||
setKeyframeSelection({ keyframes: intersectedRefs });
|
||||
}
|
||||
},
|
||||
[idToRefMap, setKeyframeSelection],
|
||||
);
|
||||
|
||||
const {
|
||||
selectionBox,
|
||||
handleMouseDown: boxSelectMouseDown,
|
||||
isSelecting,
|
||||
shouldIgnoreClick,
|
||||
} = useBoxSelect<string>({
|
||||
containerRef,
|
||||
resolveIntersections,
|
||||
selectedIds,
|
||||
anchorId,
|
||||
onSelectionChange,
|
||||
});
|
||||
|
||||
const handleExpandedAreaMouseDown = useCallback(
|
||||
(event: React.MouseEvent) => {
|
||||
event.stopPropagation();
|
||||
initialKeyframesRef.current = selectedKeyframes;
|
||||
boxSelectMouseDown(event);
|
||||
},
|
||||
[boxSelectMouseDown, selectedKeyframes],
|
||||
);
|
||||
|
||||
const handleExpandedAreaClick = useCallback(
|
||||
(event: React.MouseEvent) => {
|
||||
event.stopPropagation();
|
||||
if (shouldIgnoreClick()) return;
|
||||
if (event.metaKey || event.ctrlKey || event.shiftKey) return;
|
||||
clearKeyframeSelection();
|
||||
},
|
||||
[shouldIgnoreClick, clearKeyframeSelection],
|
||||
);
|
||||
|
||||
return {
|
||||
containerRef,
|
||||
selectionBox,
|
||||
isBoxSelecting: isSelecting,
|
||||
handleExpandedAreaMouseDown,
|
||||
handleExpandedAreaClick,
|
||||
};
|
||||
}
|
||||
|
|
@ -1,229 +1,229 @@
|
|||
import { useCallback, useSyncExternalStore } from "react";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import type { SelectedKeyframeRef } from "@/lib/animation/types";
|
||||
|
||||
function getSelectedKeyframeId({
|
||||
keyframe,
|
||||
}: {
|
||||
keyframe: SelectedKeyframeRef;
|
||||
}): string {
|
||||
return `${keyframe.trackId}:${keyframe.elementId}:${keyframe.propertyPath}:${keyframe.keyframeId}`;
|
||||
}
|
||||
|
||||
function mergeUniqueKeyframes({
|
||||
keyframes,
|
||||
}: {
|
||||
keyframes: SelectedKeyframeRef[];
|
||||
}): SelectedKeyframeRef[] {
|
||||
const keyframesById = new Map<string, SelectedKeyframeRef>();
|
||||
for (const keyframe of keyframes) {
|
||||
keyframesById.set(getSelectedKeyframeId({ keyframe }), keyframe);
|
||||
}
|
||||
return [...keyframesById.values()];
|
||||
}
|
||||
|
||||
export function useKeyframeSelection() {
|
||||
const editor = useEditor();
|
||||
const selectedKeyframes = useSyncExternalStore(
|
||||
(listener) => editor.selection.subscribe(listener),
|
||||
() => editor.selection.getSelectedKeyframes(),
|
||||
);
|
||||
const keyframeSelectionAnchor = useSyncExternalStore(
|
||||
(listener) => editor.selection.subscribe(listener),
|
||||
() => editor.selection.getKeyframeSelectionAnchor(),
|
||||
);
|
||||
|
||||
const isKeyframeSelected = useCallback(
|
||||
({ keyframe }: { keyframe: SelectedKeyframeRef }) => {
|
||||
const keyframeId = getSelectedKeyframeId({ keyframe });
|
||||
return selectedKeyframes.some(
|
||||
(selectedKeyframe) =>
|
||||
getSelectedKeyframeId({ keyframe: selectedKeyframe }) === keyframeId,
|
||||
);
|
||||
},
|
||||
[selectedKeyframes],
|
||||
);
|
||||
|
||||
const setKeyframeSelection = useCallback(
|
||||
({
|
||||
keyframes,
|
||||
anchorKeyframe,
|
||||
}: {
|
||||
keyframes: SelectedKeyframeRef[];
|
||||
anchorKeyframe?: SelectedKeyframeRef;
|
||||
}) => {
|
||||
const uniqueKeyframes = mergeUniqueKeyframes({ keyframes });
|
||||
editor.selection.setSelectedKeyframes({
|
||||
keyframes: uniqueKeyframes,
|
||||
anchorKeyframe:
|
||||
anchorKeyframe ?? uniqueKeyframes[uniqueKeyframes.length - 1] ?? null,
|
||||
});
|
||||
},
|
||||
[editor],
|
||||
);
|
||||
|
||||
const addKeyframesToSelection = useCallback(
|
||||
({
|
||||
keyframes,
|
||||
anchorKeyframe,
|
||||
}: {
|
||||
keyframes: SelectedKeyframeRef[];
|
||||
anchorKeyframe?: SelectedKeyframeRef;
|
||||
}) => {
|
||||
const mergedKeyframes = mergeUniqueKeyframes({
|
||||
keyframes: [...selectedKeyframes, ...keyframes],
|
||||
});
|
||||
editor.selection.setSelectedKeyframes({
|
||||
keyframes: mergedKeyframes,
|
||||
anchorKeyframe:
|
||||
anchorKeyframe ?? mergedKeyframes[mergedKeyframes.length - 1] ?? null,
|
||||
});
|
||||
},
|
||||
[selectedKeyframes, editor],
|
||||
);
|
||||
|
||||
const removeKeyframesFromSelection = useCallback(
|
||||
({
|
||||
keyframes,
|
||||
anchorKeyframe,
|
||||
}: {
|
||||
keyframes: SelectedKeyframeRef[];
|
||||
anchorKeyframe?: SelectedKeyframeRef;
|
||||
}) => {
|
||||
const keyframeIdsToRemove = new Set(
|
||||
keyframes.map((keyframe) => getSelectedKeyframeId({ keyframe })),
|
||||
);
|
||||
const nextKeyframes = selectedKeyframes.filter(
|
||||
(selectedKeyframe) =>
|
||||
!keyframeIdsToRemove.has(
|
||||
getSelectedKeyframeId({ keyframe: selectedKeyframe }),
|
||||
),
|
||||
);
|
||||
editor.selection.setSelectedKeyframes({
|
||||
keyframes: nextKeyframes,
|
||||
anchorKeyframe:
|
||||
anchorKeyframe ?? nextKeyframes[nextKeyframes.length - 1] ?? null,
|
||||
});
|
||||
},
|
||||
[selectedKeyframes, editor],
|
||||
);
|
||||
|
||||
const clearKeyframeSelection = useCallback(() => {
|
||||
editor.selection.clearKeyframeSelection();
|
||||
}, [editor]);
|
||||
|
||||
const toggleKeyframeSelection = useCallback(
|
||||
({
|
||||
keyframes,
|
||||
isMultiKey,
|
||||
}: {
|
||||
keyframes: SelectedKeyframeRef[];
|
||||
isMultiKey: boolean;
|
||||
}) => {
|
||||
const anchorKeyframe = keyframes[0];
|
||||
const areAllKeyframesSelected = keyframes.every((keyframe) =>
|
||||
isKeyframeSelected({ keyframe }),
|
||||
);
|
||||
if (!isMultiKey) {
|
||||
setKeyframeSelection({ keyframes, anchorKeyframe });
|
||||
return;
|
||||
}
|
||||
|
||||
if (areAllKeyframesSelected) {
|
||||
removeKeyframesFromSelection({ keyframes, anchorKeyframe });
|
||||
return;
|
||||
}
|
||||
|
||||
addKeyframesToSelection({ keyframes, anchorKeyframe });
|
||||
},
|
||||
[
|
||||
setKeyframeSelection,
|
||||
isKeyframeSelected,
|
||||
removeKeyframesFromSelection,
|
||||
addKeyframesToSelection,
|
||||
],
|
||||
);
|
||||
|
||||
const selectKeyframeRange = useCallback(
|
||||
({
|
||||
orderedKeyframes,
|
||||
targetKeyframes,
|
||||
isAdditive,
|
||||
}: {
|
||||
orderedKeyframes: SelectedKeyframeRef[];
|
||||
targetKeyframes: SelectedKeyframeRef[];
|
||||
isAdditive: boolean;
|
||||
}) => {
|
||||
if (orderedKeyframes.length === 0 || targetKeyframes.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const anchorKeyframe =
|
||||
keyframeSelectionAnchor ??
|
||||
selectedKeyframes[selectedKeyframes.length - 1] ??
|
||||
targetKeyframes[0];
|
||||
if (!anchorKeyframe) {
|
||||
return;
|
||||
}
|
||||
|
||||
const targetKeyframeIds = new Set(
|
||||
targetKeyframes.map((keyframe) => getSelectedKeyframeId({ keyframe })),
|
||||
);
|
||||
const anchorId = getSelectedKeyframeId({ keyframe: anchorKeyframe });
|
||||
const anchorIndex = orderedKeyframes.findIndex(
|
||||
(keyframe) => getSelectedKeyframeId({ keyframe }) === anchorId,
|
||||
);
|
||||
if (anchorIndex === -1) {
|
||||
if (isAdditive) {
|
||||
addKeyframesToSelection({
|
||||
keyframes: targetKeyframes,
|
||||
anchorKeyframe,
|
||||
});
|
||||
return;
|
||||
}
|
||||
setKeyframeSelection({ keyframes: targetKeyframes, anchorKeyframe });
|
||||
return;
|
||||
}
|
||||
|
||||
const targetIndexes = orderedKeyframes
|
||||
.map((keyframe, index) => ({
|
||||
keyframeId: getSelectedKeyframeId({ keyframe }),
|
||||
index,
|
||||
}))
|
||||
.filter(({ keyframeId }) => targetKeyframeIds.has(keyframeId))
|
||||
.map(({ index }) => index);
|
||||
if (targetIndexes.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rangeStart = Math.min(anchorIndex, ...targetIndexes);
|
||||
const rangeEnd = Math.max(anchorIndex, ...targetIndexes);
|
||||
const rangeKeyframes = orderedKeyframes.slice(rangeStart, rangeEnd + 1);
|
||||
|
||||
if (isAdditive) {
|
||||
addKeyframesToSelection({ keyframes: rangeKeyframes, anchorKeyframe });
|
||||
return;
|
||||
}
|
||||
|
||||
setKeyframeSelection({ keyframes: rangeKeyframes, anchorKeyframe });
|
||||
},
|
||||
[
|
||||
keyframeSelectionAnchor,
|
||||
selectedKeyframes,
|
||||
addKeyframesToSelection,
|
||||
setKeyframeSelection,
|
||||
],
|
||||
);
|
||||
|
||||
return {
|
||||
selectedKeyframes,
|
||||
keyframeSelectionAnchor,
|
||||
isKeyframeSelected,
|
||||
setKeyframeSelection,
|
||||
addKeyframesToSelection,
|
||||
removeKeyframesFromSelection,
|
||||
clearKeyframeSelection,
|
||||
toggleKeyframeSelection,
|
||||
selectKeyframeRange,
|
||||
};
|
||||
}
|
||||
import { useCallback, useSyncExternalStore } from "react";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import type { SelectedKeyframeRef } from "@/lib/animation/types";
|
||||
|
||||
export function getSelectedKeyframeId({
|
||||
keyframe,
|
||||
}: {
|
||||
keyframe: SelectedKeyframeRef;
|
||||
}): string {
|
||||
return `${keyframe.trackId}:${keyframe.elementId}:${keyframe.propertyPath}:${keyframe.keyframeId}`;
|
||||
}
|
||||
|
||||
function mergeUniqueKeyframes({
|
||||
keyframes,
|
||||
}: {
|
||||
keyframes: SelectedKeyframeRef[];
|
||||
}): SelectedKeyframeRef[] {
|
||||
const keyframesById = new Map<string, SelectedKeyframeRef>();
|
||||
for (const keyframe of keyframes) {
|
||||
keyframesById.set(getSelectedKeyframeId({ keyframe }), keyframe);
|
||||
}
|
||||
return [...keyframesById.values()];
|
||||
}
|
||||
|
||||
export function useKeyframeSelection() {
|
||||
const editor = useEditor();
|
||||
const selectedKeyframes = useSyncExternalStore(
|
||||
(listener) => editor.selection.subscribe(listener),
|
||||
() => editor.selection.getSelectedKeyframes(),
|
||||
);
|
||||
const keyframeSelectionAnchor = useSyncExternalStore(
|
||||
(listener) => editor.selection.subscribe(listener),
|
||||
() => editor.selection.getKeyframeSelectionAnchor(),
|
||||
);
|
||||
|
||||
const isKeyframeSelected = useCallback(
|
||||
({ keyframe }: { keyframe: SelectedKeyframeRef }) => {
|
||||
const keyframeId = getSelectedKeyframeId({ keyframe });
|
||||
return selectedKeyframes.some(
|
||||
(selectedKeyframe) =>
|
||||
getSelectedKeyframeId({ keyframe: selectedKeyframe }) === keyframeId,
|
||||
);
|
||||
},
|
||||
[selectedKeyframes],
|
||||
);
|
||||
|
||||
const setKeyframeSelection = useCallback(
|
||||
({
|
||||
keyframes,
|
||||
anchorKeyframe,
|
||||
}: {
|
||||
keyframes: SelectedKeyframeRef[];
|
||||
anchorKeyframe?: SelectedKeyframeRef;
|
||||
}) => {
|
||||
const uniqueKeyframes = mergeUniqueKeyframes({ keyframes });
|
||||
editor.selection.setSelectedKeyframes({
|
||||
keyframes: uniqueKeyframes,
|
||||
anchorKeyframe:
|
||||
anchorKeyframe ?? uniqueKeyframes[uniqueKeyframes.length - 1] ?? null,
|
||||
});
|
||||
},
|
||||
[editor],
|
||||
);
|
||||
|
||||
const addKeyframesToSelection = useCallback(
|
||||
({
|
||||
keyframes,
|
||||
anchorKeyframe,
|
||||
}: {
|
||||
keyframes: SelectedKeyframeRef[];
|
||||
anchorKeyframe?: SelectedKeyframeRef;
|
||||
}) => {
|
||||
const mergedKeyframes = mergeUniqueKeyframes({
|
||||
keyframes: [...selectedKeyframes, ...keyframes],
|
||||
});
|
||||
editor.selection.setSelectedKeyframes({
|
||||
keyframes: mergedKeyframes,
|
||||
anchorKeyframe:
|
||||
anchorKeyframe ?? mergedKeyframes[mergedKeyframes.length - 1] ?? null,
|
||||
});
|
||||
},
|
||||
[selectedKeyframes, editor],
|
||||
);
|
||||
|
||||
const removeKeyframesFromSelection = useCallback(
|
||||
({
|
||||
keyframes,
|
||||
anchorKeyframe,
|
||||
}: {
|
||||
keyframes: SelectedKeyframeRef[];
|
||||
anchorKeyframe?: SelectedKeyframeRef;
|
||||
}) => {
|
||||
const keyframeIdsToRemove = new Set(
|
||||
keyframes.map((keyframe) => getSelectedKeyframeId({ keyframe })),
|
||||
);
|
||||
const nextKeyframes = selectedKeyframes.filter(
|
||||
(selectedKeyframe) =>
|
||||
!keyframeIdsToRemove.has(
|
||||
getSelectedKeyframeId({ keyframe: selectedKeyframe }),
|
||||
),
|
||||
);
|
||||
editor.selection.setSelectedKeyframes({
|
||||
keyframes: nextKeyframes,
|
||||
anchorKeyframe:
|
||||
anchorKeyframe ?? nextKeyframes[nextKeyframes.length - 1] ?? null,
|
||||
});
|
||||
},
|
||||
[selectedKeyframes, editor],
|
||||
);
|
||||
|
||||
const clearKeyframeSelection = useCallback(() => {
|
||||
editor.selection.clearKeyframeSelection();
|
||||
}, [editor]);
|
||||
|
||||
const toggleKeyframeSelection = useCallback(
|
||||
({
|
||||
keyframes,
|
||||
isMultiKey,
|
||||
}: {
|
||||
keyframes: SelectedKeyframeRef[];
|
||||
isMultiKey: boolean;
|
||||
}) => {
|
||||
const anchorKeyframe = keyframes[0];
|
||||
const areAllKeyframesSelected = keyframes.every((keyframe) =>
|
||||
isKeyframeSelected({ keyframe }),
|
||||
);
|
||||
if (!isMultiKey) {
|
||||
setKeyframeSelection({ keyframes, anchorKeyframe });
|
||||
return;
|
||||
}
|
||||
|
||||
if (areAllKeyframesSelected) {
|
||||
removeKeyframesFromSelection({ keyframes, anchorKeyframe });
|
||||
return;
|
||||
}
|
||||
|
||||
addKeyframesToSelection({ keyframes, anchorKeyframe });
|
||||
},
|
||||
[
|
||||
setKeyframeSelection,
|
||||
isKeyframeSelected,
|
||||
removeKeyframesFromSelection,
|
||||
addKeyframesToSelection,
|
||||
],
|
||||
);
|
||||
|
||||
const selectKeyframeRange = useCallback(
|
||||
({
|
||||
orderedKeyframes,
|
||||
targetKeyframes,
|
||||
isAdditive,
|
||||
}: {
|
||||
orderedKeyframes: SelectedKeyframeRef[];
|
||||
targetKeyframes: SelectedKeyframeRef[];
|
||||
isAdditive: boolean;
|
||||
}) => {
|
||||
if (orderedKeyframes.length === 0 || targetKeyframes.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const anchorKeyframe =
|
||||
keyframeSelectionAnchor ??
|
||||
selectedKeyframes[selectedKeyframes.length - 1] ??
|
||||
targetKeyframes[0];
|
||||
if (!anchorKeyframe) {
|
||||
return;
|
||||
}
|
||||
|
||||
const targetKeyframeIds = new Set(
|
||||
targetKeyframes.map((keyframe) => getSelectedKeyframeId({ keyframe })),
|
||||
);
|
||||
const anchorId = getSelectedKeyframeId({ keyframe: anchorKeyframe });
|
||||
const anchorIndex = orderedKeyframes.findIndex(
|
||||
(keyframe) => getSelectedKeyframeId({ keyframe }) === anchorId,
|
||||
);
|
||||
if (anchorIndex === -1) {
|
||||
if (isAdditive) {
|
||||
addKeyframesToSelection({
|
||||
keyframes: targetKeyframes,
|
||||
anchorKeyframe,
|
||||
});
|
||||
return;
|
||||
}
|
||||
setKeyframeSelection({ keyframes: targetKeyframes, anchorKeyframe });
|
||||
return;
|
||||
}
|
||||
|
||||
const targetIndexes = orderedKeyframes
|
||||
.map((keyframe, index) => ({
|
||||
keyframeId: getSelectedKeyframeId({ keyframe }),
|
||||
index,
|
||||
}))
|
||||
.filter(({ keyframeId }) => targetKeyframeIds.has(keyframeId))
|
||||
.map(({ index }) => index);
|
||||
if (targetIndexes.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rangeStart = Math.min(anchorIndex, ...targetIndexes);
|
||||
const rangeEnd = Math.max(anchorIndex, ...targetIndexes);
|
||||
const rangeKeyframes = orderedKeyframes.slice(rangeStart, rangeEnd + 1);
|
||||
|
||||
if (isAdditive) {
|
||||
addKeyframesToSelection({ keyframes: rangeKeyframes, anchorKeyframe });
|
||||
return;
|
||||
}
|
||||
|
||||
setKeyframeSelection({ keyframes: rangeKeyframes, anchorKeyframe });
|
||||
},
|
||||
[
|
||||
keyframeSelectionAnchor,
|
||||
selectedKeyframes,
|
||||
addKeyframesToSelection,
|
||||
setKeyframeSelection,
|
||||
],
|
||||
);
|
||||
|
||||
return {
|
||||
selectedKeyframes,
|
||||
keyframeSelectionAnchor,
|
||||
isKeyframeSelected,
|
||||
setKeyframeSelection,
|
||||
addKeyframesToSelection,
|
||||
removeKeyframesFromSelection,
|
||||
clearKeyframeSelection,
|
||||
toggleKeyframeSelection,
|
||||
selectKeyframeRange,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue