mirror of
https://github.com/OpenCut-app/OpenCut.git
synced 2026-07-13 21:52:53 +02:00
feat: expandable keyframe lanes in timeline
Made-with: Cursor
This commit is contained in:
@@ -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 {
|
import {
|
||||||
getCurveHandlesForNormalizedCubicBezier,
|
getCurveHandlesForNormalizedCubicBezier,
|
||||||
getEditableScalarChannels,
|
getEditableScalarChannels,
|
||||||
|
getEasingModeForKind,
|
||||||
getNormalizedCubicBezierForScalarSegment,
|
getNormalizedCubicBezierForScalarSegment,
|
||||||
getScalarKeyframeContext,
|
getScalarKeyframeContext,
|
||||||
updateScalarKeyframeCurve,
|
updateScalarKeyframeCurve,
|
||||||
@@ -54,7 +55,14 @@ export interface GraphEditorReadyState extends GraphEditorBaseSelectionState {
|
|||||||
propertyPath: SelectedKeyframeRef["propertyPath"];
|
propertyPath: SelectedKeyframeRef["propertyPath"];
|
||||||
keyframeId: string;
|
keyframeId: string;
|
||||||
element: TimelineElement;
|
element: TimelineElement;
|
||||||
|
/** Primary channel context, used for displaying the curve. */
|
||||||
context: ScalarGraphKeyframeContext;
|
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;
|
cubicBezier: NormalizedCubicBezier;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -234,16 +242,17 @@ export function resolveGraphEditorSelectionState({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const scalarChannels = getEditableScalarChannels({
|
const scalarResult = getEditableScalarChannels({
|
||||||
animations: selectedElement.element.animations,
|
animations: selectedElement.element.animations,
|
||||||
propertyPath: primaryKeyframe.propertyPath,
|
propertyPath: primaryKeyframe.propertyPath,
|
||||||
});
|
});
|
||||||
if (scalarChannels.length === 0) {
|
if (!scalarResult || scalarResult.channels.length === 0) {
|
||||||
return createUnavailableState({
|
return createUnavailableState({
|
||||||
reason: "selected-keyframe-has-no-scalar-channel",
|
reason: "selected-keyframe-has-no-scalar-channel",
|
||||||
message: "The selected keyframe has no editable graph 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
|
// 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.
|
// 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 contexts = scalarChannels.flatMap((channel) => {
|
||||||
const context = getScalarKeyframeContext({
|
const context = getScalarKeyframeContext({
|
||||||
animations: selectedElement.element.animations,
|
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,
|
({ context }) => context.nextKey !== null,
|
||||||
);
|
);
|
||||||
const preferredContext =
|
const preferredContext =
|
||||||
contexts.find(({ option }) => option.key === preferredComponentKey) ?? null;
|
visibleContexts.find(({ option }) => option.key === preferredComponentKey) ?? null;
|
||||||
const activeContext =
|
const activeContext =
|
||||||
preferredContext ?? nextSegmentContexts[0] ?? contexts[0];
|
preferredContext ?? nextSegmentContexts[0] ?? visibleContexts[0];
|
||||||
const componentOptions = contexts.map(({ option }) => option);
|
const componentOptions = visibleContexts.map(({ option }) => option);
|
||||||
|
|
||||||
if (!activeContext.context.nextKey) {
|
if (!activeContext.context.nextKey) {
|
||||||
return createUnavailableState({
|
return createUnavailableState({
|
||||||
@@ -356,6 +373,7 @@ export function resolveGraphEditorSelectionState({
|
|||||||
keyframeId: resolvedKeyframeId,
|
keyframeId: resolvedKeyframeId,
|
||||||
element: selectedElement.element,
|
element: selectedElement.element,
|
||||||
context: activeContext.context,
|
context: activeContext.context,
|
||||||
|
allContexts,
|
||||||
cubicBezier,
|
cubicBezier,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -96,19 +96,17 @@ export function useGraphEditorController() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextAnimations = applyGraphEditorCurvePreview({
|
const nextAnimations = state.allContexts.reduce(
|
||||||
animations: state.element.animations,
|
(animations, context) =>
|
||||||
context: state.context,
|
applyGraphEditorCurvePreview({ animations, context, cubicBezier: nextValue }),
|
||||||
cubicBezier: nextValue,
|
state.element.animations,
|
||||||
});
|
);
|
||||||
editor.timeline.previewElements({
|
editor.timeline.previewElements({
|
||||||
updates: [
|
updates: [
|
||||||
{
|
{
|
||||||
trackId: state.trackId,
|
trackId: state.trackId,
|
||||||
elementId: state.elementId,
|
elementId: state.elementId,
|
||||||
updates: {
|
updates: { animations: nextAnimations },
|
||||||
animations: nextAnimations,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
@@ -123,6 +121,8 @@ export function useGraphEditorController() {
|
|||||||
return;
|
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({
|
const patches = buildGraphEditorCurvePatches({
|
||||||
context: state.context,
|
context: state.context,
|
||||||
cubicBezier: nextValue,
|
cubicBezier: nextValue,
|
||||||
@@ -132,14 +132,16 @@ export function useGraphEditorController() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
editor.timeline.updateKeyframeCurves({
|
editor.timeline.updateKeyframeCurves({
|
||||||
keyframes: patches.map(({ keyframeId, patch }) => ({
|
keyframes: state.allContexts.flatMap((context) =>
|
||||||
trackId: state.trackId,
|
patches.map(({ keyframeId, patch }) => ({
|
||||||
elementId: state.elementId,
|
trackId: state.trackId,
|
||||||
propertyPath: state.propertyPath,
|
elementId: state.elementId,
|
||||||
componentKey: state.context.componentKey,
|
propertyPath: state.propertyPath,
|
||||||
keyframeId,
|
componentKey: context.componentKey,
|
||||||
patch,
|
keyframeId,
|
||||||
})),
|
patch,
|
||||||
|
})),
|
||||||
|
),
|
||||||
});
|
});
|
||||||
hasPreviewRef.current = false;
|
hasPreviewRef.current = false;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ import {
|
|||||||
TIMELINE_CONTENT_TOP_PADDING_PX,
|
TIMELINE_CONTENT_TOP_PADDING_PX,
|
||||||
TIMELINE_TRACK_GAP_PX,
|
TIMELINE_TRACK_GAP_PX,
|
||||||
TIMELINE_TRACK_LABELS_COLUMN_WIDTH_PX,
|
TIMELINE_TRACK_LABELS_COLUMN_WIDTH_PX,
|
||||||
|
KEYFRAME_LANE_HEIGHT_PX,
|
||||||
} from "./layout";
|
} from "./layout";
|
||||||
import { useElementInteraction } from "@/hooks/timeline/element/use-element-interaction";
|
import { useElementInteraction } from "@/hooks/timeline/element/use-element-interaction";
|
||||||
import {
|
import {
|
||||||
@@ -57,6 +58,13 @@ import {
|
|||||||
getTotalTracksHeight,
|
getTotalTracksHeight,
|
||||||
} from "./track-layout";
|
} from "./track-layout";
|
||||||
import { SELECTED_TRACK_ROW_CLASS } from "./theme";
|
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 { TIMELINE_HORIZONTAL_WHEEL_STEP_PX } from "./interaction";
|
||||||
import { TimelineToolbar } from "./timeline-toolbar";
|
import { TimelineToolbar } from "./timeline-toolbar";
|
||||||
import { useElementSelection } from "@/hooks/timeline/element/use-element-selection";
|
import { useElementSelection } from "@/hooks/timeline/element/use-element-selection";
|
||||||
@@ -169,6 +177,17 @@ export function Timeline() {
|
|||||||
rulerScrollRef,
|
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
|
// Stable refs so the wheel listener never goes stale
|
||||||
const setZoomLevelRef = useRef(setZoomLevel);
|
const setZoomLevelRef = useRef(setZoomLevel);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -417,6 +436,7 @@ export function Timeline() {
|
|||||||
trackLabelsScrollRef={trackLabelsScrollRef}
|
trackLabelsScrollRef={trackLabelsScrollRef}
|
||||||
timelineHeaderHeight={timelineHeaderHeight}
|
timelineHeaderHeight={timelineHeaderHeight}
|
||||||
hasHorizontalScrollbar={hasHorizontalScrollbar}
|
hasHorizontalScrollbar={hasHorizontalScrollbar}
|
||||||
|
getTrackExpansionHeight={getTrackExpansionHeight}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -493,7 +513,7 @@ export function Timeline() {
|
|||||||
TRACKS_CONTAINER_HEIGHT.min,
|
TRACKS_CONTAINER_HEIGHT.min,
|
||||||
Math.min(
|
Math.min(
|
||||||
TRACKS_CONTAINER_HEIGHT.max,
|
TRACKS_CONTAINER_HEIGHT.max,
|
||||||
getTotalTracksHeight({ tracks }),
|
getTotalTracksHeight({ tracks, getExtraHeight: getTrackExpansionHeight }),
|
||||||
),
|
),
|
||||||
) + TIMELINE_CONTENT_TOP_PADDING_PX
|
) + TIMELINE_CONTENT_TOP_PADDING_PX
|
||||||
}px`,
|
}px`,
|
||||||
@@ -513,9 +533,8 @@ export function Timeline() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{tracks.length > 0 && (
|
{tracks.length > 0 && (
|
||||||
<TimelineTrackRows
|
<TimelineTrackRows
|
||||||
dragElementId={dragState.elementId}
|
mainTrackId={mainTrackId}
|
||||||
mainTrackId={mainTrackId}
|
|
||||||
zoomLevel={zoomLevel}
|
zoomLevel={zoomLevel}
|
||||||
dragState={dragState}
|
dragState={dragState}
|
||||||
tracksScrollRef={tracksScrollRef}
|
tracksScrollRef={tracksScrollRef}
|
||||||
@@ -575,11 +594,13 @@ function TrackLabelsPanel({
|
|||||||
trackLabelsScrollRef,
|
trackLabelsScrollRef,
|
||||||
timelineHeaderHeight,
|
timelineHeaderHeight,
|
||||||
hasHorizontalScrollbar,
|
hasHorizontalScrollbar,
|
||||||
|
getTrackExpansionHeight,
|
||||||
}: {
|
}: {
|
||||||
trackLabelsRef: React.RefObject<HTMLDivElement | null>;
|
trackLabelsRef: React.RefObject<HTMLDivElement | null>;
|
||||||
trackLabelsScrollRef: React.RefObject<HTMLDivElement | null>;
|
trackLabelsScrollRef: React.RefObject<HTMLDivElement | null>;
|
||||||
timelineHeaderHeight: number;
|
timelineHeaderHeight: number;
|
||||||
hasHorizontalScrollbar: boolean;
|
hasHorizontalScrollbar: boolean;
|
||||||
|
getTrackExpansionHeight: (trackIndex: number) => number;
|
||||||
}) {
|
}) {
|
||||||
const editor = useEditor();
|
const editor = useEditor();
|
||||||
const scene = useEditor((e) => e.scenes.getActiveSceneOrNull());
|
const scene = useEditor((e) => e.scenes.getActiveSceneOrNull());
|
||||||
@@ -596,6 +617,15 @@ function TrackLabelsPanel({
|
|||||||
[selectedElements],
|
[selectedElements],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const expandedElementIds = useTimelineStore((s) => s.expandedElementIds);
|
||||||
|
const trackExpandedRowsMap = useMemo(
|
||||||
|
() =>
|
||||||
|
tracks.map((track) =>
|
||||||
|
getTrackExpandedRows({ track, expandedElementIds }),
|
||||||
|
),
|
||||||
|
[tracks, expandedElementIds],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="flex shrink-0 flex-col border-r"
|
className="flex shrink-0 flex-col border-r"
|
||||||
@@ -612,43 +642,62 @@ function TrackLabelsPanel({
|
|||||||
className="flex flex-col"
|
className="flex flex-col"
|
||||||
style={{ gap: `${TIMELINE_TRACK_GAP_PX}px` }}
|
style={{ gap: `${TIMELINE_TRACK_GAP_PX}px` }}
|
||||||
>
|
>
|
||||||
{tracks.map((track) => (
|
{tracks.map((track, index) => {
|
||||||
<div
|
const expandedRows = trackExpandedRowsMap[index];
|
||||||
key={track.id}
|
const baseHeight = getTrackHeight({ type: track.type });
|
||||||
className={cn(
|
|
||||||
"group flex items-center px-3",
|
return (
|
||||||
tracksWithSelection.has(track.id) &&
|
<div
|
||||||
SELECTED_TRACK_ROW_CLASS,
|
key={track.id}
|
||||||
)}
|
className={cn(
|
||||||
style={{
|
"group flex flex-col",
|
||||||
height: `${getTrackHeight({ type: track.type })}px`,
|
tracksWithSelection.has(track.id) &&
|
||||||
}}
|
SELECTED_TRACK_ROW_CLASS,
|
||||||
>
|
|
||||||
<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 })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
{canTrackBeHidden(track) && (
|
style={{
|
||||||
<TrackToggleIcon
|
height: `${baseHeight + getTrackExpansionHeight(index)}px`,
|
||||||
isOff={track.hidden}
|
}}
|
||||||
icons={{ on: ViewIcon, off: ViewOffSlashIcon }}
|
>
|
||||||
onClick={() =>
|
<div
|
||||||
editor.timeline.toggleTrackVisibility({
|
className="flex shrink-0 items-center justify-end gap-2 px-3"
|
||||||
trackId: track.id,
|
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>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -664,7 +713,6 @@ function TrackLabelsPanel({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function TimelineTrackRows({
|
function TimelineTrackRows({
|
||||||
dragElementId,
|
|
||||||
mainTrackId,
|
mainTrackId,
|
||||||
zoomLevel,
|
zoomLevel,
|
||||||
dragState,
|
dragState,
|
||||||
@@ -680,7 +728,6 @@ function TimelineTrackRows({
|
|||||||
isDragOver,
|
isDragOver,
|
||||||
dropTarget,
|
dropTarget,
|
||||||
}: {
|
}: {
|
||||||
dragElementId: string | null;
|
|
||||||
mainTrackId: string | null;
|
mainTrackId: string | null;
|
||||||
zoomLevel: number;
|
zoomLevel: number;
|
||||||
dragState: ElementDragState;
|
dragState: ElementDragState;
|
||||||
@@ -715,24 +762,34 @@ function TimelineTrackRows({
|
|||||||
[selectedElements],
|
[selectedElements],
|
||||||
);
|
);
|
||||||
|
|
||||||
const sortedTracks = useMemo(
|
const expandedElementIds = useTimelineStore((s) => s.expandedElementIds);
|
||||||
() =>
|
|
||||||
[...tracks]
|
const getTrackExpansionHeight = useCallback(
|
||||||
.map((track, index) => ({ track, index }))
|
(trackIndex: number) => {
|
||||||
.sort((a, b) => {
|
const track = tracks[trackIndex];
|
||||||
const aHasDragged = a.track.elements.some(
|
if (!track) return 0;
|
||||||
(el) => el.id === dragElementId,
|
return computeTrackExpansionHeight({ track, expandedElementIds });
|
||||||
);
|
},
|
||||||
const bHasDragged = b.track.elements.some(
|
[tracks, expandedElementIds],
|
||||||
(el) => el.id === dragElementId,
|
|
||||||
);
|
|
||||||
if (aHasDragged) return 1;
|
|
||||||
if (bHasDragged) return -1;
|
|
||||||
return 0;
|
|
||||||
}),
|
|
||||||
[tracks, dragElementId],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
{sortedTracks.map(({ track, index }) => (
|
{sortedTracks.map(({ track, index }) => (
|
||||||
@@ -745,8 +802,8 @@ function TimelineTrackRows({
|
|||||||
SELECTED_TRACK_ROW_CLASS,
|
SELECTED_TRACK_ROW_CLASS,
|
||||||
)}
|
)}
|
||||||
style={{
|
style={{
|
||||||
top: `${TIMELINE_CONTENT_TOP_PADDING_PX + getCumulativeHeightBefore({ tracks, trackIndex: index })}px`,
|
top: `${TIMELINE_CONTENT_TOP_PADDING_PX + getCumulativeHeightBefore({ tracks, trackIndex: index, getExtraHeight: getTrackExpansionHeight })}px`,
|
||||||
height: `${getTrackHeight({ type: track.type })}px`,
|
height: `${getTrackHeight({ type: track.type }) + getTrackExpansionHeight(index)}px`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<TimelineTrackContent
|
<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,
|
effect: 25,
|
||||||
} as const;
|
} 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_GAP_PX = 6;
|
||||||
export const TIMELINE_TRACK_LABELS_COLUMN_WIDTH_PX = 112;
|
export const TIMELINE_TRACK_LABELS_COLUMN_WIDTH_PX = 112;
|
||||||
export const TIMELINE_RULER_HEIGHT_PX = 22;
|
export const TIMELINE_RULER_HEIGHT_PX = 22;
|
||||||
|
|||||||
@@ -3,13 +3,14 @@
|
|||||||
import { useEditor } from "@/hooks/use-editor";
|
import { useEditor } from "@/hooks/use-editor";
|
||||||
import { useAssetsPanelStore } from "@/stores/assets-panel-store";
|
import { useAssetsPanelStore } from "@/stores/assets-panel-store";
|
||||||
import { AudioWaveform } from "./audio-waveform";
|
import { AudioWaveform } from "./audio-waveform";
|
||||||
import { useTimelineElementResize } from "@/hooks/timeline/element/use-element-resize";
|
import { useElementPreview } from "@/hooks/use-element-preview";
|
||||||
import {
|
import {
|
||||||
useKeyframeDrag,
|
useKeyframeDrag,
|
||||||
type KeyframeDragState,
|
type KeyframeDragState,
|
||||||
} from "@/hooks/timeline/element/use-keyframe-drag";
|
} from "@/hooks/timeline/element/use-keyframe-drag";
|
||||||
import { useKeyframeSelection } from "@/hooks/timeline/element/use-keyframe-selection";
|
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 { getElementKeyframes } from "@/lib/animation";
|
||||||
import {
|
import {
|
||||||
canElementHaveAudio,
|
canElementHaveAudio,
|
||||||
@@ -20,10 +21,7 @@ import {
|
|||||||
timelineTimeToSnappedPixels,
|
timelineTimeToSnappedPixels,
|
||||||
} from "@/lib/timeline";
|
} from "@/lib/timeline";
|
||||||
import { getTrackHeight } from "./track-layout";
|
import { getTrackHeight } from "./track-layout";
|
||||||
import {
|
import { getTimelineElementClassName, TIMELINE_TRACK_THEME } from "./theme";
|
||||||
getTimelineElementClassName,
|
|
||||||
TIMELINE_TRACK_THEME,
|
|
||||||
} from "./theme";
|
|
||||||
import {
|
import {
|
||||||
ContextMenu,
|
ContextMenu,
|
||||||
ContextMenuContent,
|
ContextMenuContent,
|
||||||
@@ -68,19 +66,25 @@ import {
|
|||||||
Search01Icon,
|
Search01Icon,
|
||||||
Exchange01Icon,
|
Exchange01Icon,
|
||||||
KeyframeIcon,
|
KeyframeIcon,
|
||||||
Link02Icon,
|
|
||||||
MagicWand05Icon,
|
MagicWand05Icon,
|
||||||
Unlink02Icon,
|
|
||||||
} from "@hugeicons/core-free-icons";
|
} from "@hugeicons/core-free-icons";
|
||||||
import { HugeiconsIcon } from "@hugeicons/react";
|
import { HugeiconsIcon } from "@hugeicons/react";
|
||||||
import { uppercase } from "@/utils/string";
|
import { uppercase } from "@/utils/string";
|
||||||
import type { ComponentProps, ReactNode } from "react";
|
import { useMemo, type ComponentProps, type ReactNode } from "react";
|
||||||
import type {
|
import type {
|
||||||
SelectedKeyframeRef,
|
SelectedKeyframeRef,
|
||||||
ElementKeyframe,
|
ElementKeyframe,
|
||||||
} from "@/lib/animation/types";
|
} from "@/lib/animation/types";
|
||||||
import { cn } from "@/utils/ui";
|
import { cn } from "@/utils/ui";
|
||||||
import { usePropertiesStore } from "@/components/editor/panels/properties/stores/properties-store";
|
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 KEYFRAME_INDICATOR_MIN_WIDTH_PX = 40;
|
||||||
const ELEMENT_RING_WIDTH_PX = 1.5;
|
const ELEMENT_RING_WIDTH_PX = 1.5;
|
||||||
@@ -191,8 +195,12 @@ interface TimelineElementProps {
|
|||||||
track: TimelineTrack;
|
track: TimelineTrack;
|
||||||
zoomLevel: number;
|
zoomLevel: number;
|
||||||
isSelected: boolean;
|
isSelected: boolean;
|
||||||
onSnapPointChange?: (snapPoint: SnapPoint | null) => void;
|
onResizeStart: (params: {
|
||||||
onResizeStateChange?: (params: { isResizing: boolean }) => void;
|
event: React.MouseEvent;
|
||||||
|
element: TimelineElementType;
|
||||||
|
track: TimelineTrack;
|
||||||
|
side: "left" | "right";
|
||||||
|
}) => void;
|
||||||
onElementMouseDown: (
|
onElementMouseDown: (
|
||||||
event: React.MouseEvent,
|
event: React.MouseEvent,
|
||||||
element: TimelineElementType,
|
element: TimelineElementType,
|
||||||
@@ -210,8 +218,7 @@ export function TimelineElement({
|
|||||||
track,
|
track,
|
||||||
zoomLevel,
|
zoomLevel,
|
||||||
isSelected,
|
isSelected,
|
||||||
onSnapPointChange,
|
onResizeStart,
|
||||||
onResizeStateChange,
|
|
||||||
onElementMouseDown,
|
onElementMouseDown,
|
||||||
onElementClick,
|
onElementClick,
|
||||||
dragState,
|
dragState,
|
||||||
@@ -220,6 +227,11 @@ export function TimelineElement({
|
|||||||
const mediaAssets = useEditor((e) => e.media.getAssets());
|
const mediaAssets = useEditor((e) => e.media.getAssets());
|
||||||
const { selectedElements } = useElementSelection();
|
const { selectedElements } = useElementSelection();
|
||||||
const requestRevealMedia = useAssetsPanelStore((s) => s.requestRevealMedia);
|
const requestRevealMedia = useAssetsPanelStore((s) => s.requestRevealMedia);
|
||||||
|
const { renderElement } = useElementPreview({
|
||||||
|
trackId: track.id,
|
||||||
|
elementId: element.id,
|
||||||
|
fallback: element,
|
||||||
|
});
|
||||||
|
|
||||||
let mediaAsset: MediaAsset | null = null;
|
let mediaAsset: MediaAsset | null = null;
|
||||||
|
|
||||||
@@ -230,31 +242,23 @@ export function TimelineElement({
|
|||||||
|
|
||||||
const hasAudio = mediaSupportsAudio({ media: mediaAsset });
|
const hasAudio = mediaSupportsAudio({ media: mediaAsset });
|
||||||
|
|
||||||
const { handleResizeStart, isResizing, currentStartTime, currentDuration } =
|
|
||||||
useTimelineElementResize({
|
|
||||||
element,
|
|
||||||
track,
|
|
||||||
zoomLevel,
|
|
||||||
onSnapPointChange,
|
|
||||||
onResizeStateChange,
|
|
||||||
});
|
|
||||||
|
|
||||||
const isCurrentElementSelected = selectedElements.some(
|
const isCurrentElementSelected = selectedElements.some(
|
||||||
(selected) =>
|
(selected) =>
|
||||||
selected.elementId === element.id && selected.trackId === track.id,
|
selected.elementId === element.id && selected.trackId === track.id,
|
||||||
);
|
);
|
||||||
|
|
||||||
const isBeingDragged = dragState.elementId === element.id;
|
const isBeingDragged = dragState.dragElementIds.includes(element.id);
|
||||||
const dragOffsetY =
|
const dragOffsetY =
|
||||||
isBeingDragged && dragState.isDragging
|
isBeingDragged && dragState.isDragging
|
||||||
? dragState.currentMouseY - dragState.startMouseY
|
? dragState.currentMouseY - dragState.startMouseY
|
||||||
: 0;
|
: 0;
|
||||||
|
const dragTimeOffset = dragState.dragTimeOffsets[element.id] ?? 0;
|
||||||
const elementStartTime =
|
const elementStartTime =
|
||||||
isBeingDragged && dragState.isDragging
|
isBeingDragged && dragState.isDragging
|
||||||
? dragState.currentTime
|
? dragState.currentTime + dragTimeOffset
|
||||||
: element.startTime;
|
: renderElement.startTime;
|
||||||
const displayedStartTime = isResizing ? currentStartTime : elementStartTime;
|
const displayedStartTime = elementStartTime;
|
||||||
const displayedDuration = isResizing ? currentDuration : element.duration;
|
const displayedDuration = renderElement.duration;
|
||||||
const elementWidth = timelineTimeToPixels({
|
const elementWidth = timelineTimeToPixels({
|
||||||
time: displayedDuration,
|
time: displayedDuration,
|
||||||
zoomLevel,
|
zoomLevel,
|
||||||
@@ -281,6 +285,41 @@ export function TimelineElement({
|
|||||||
handleKeyframeClick,
|
handleKeyframeClick,
|
||||||
getVisualOffsetPx,
|
getVisualOffsetPx,
|
||||||
} = useKeyframeDrag({ zoomLevel, element, displayedStartTime });
|
} = 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 }) => {
|
const handleRevealInMedia = ({ event }: { event: React.MouseEvent }) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
if (hasMediaId(element)) {
|
if (hasMediaId(element)) {
|
||||||
@@ -299,15 +338,44 @@ export function TimelineElement({
|
|||||||
: "Extract audio";
|
: "Extract audio";
|
||||||
const isElementSourceAudioSeparated =
|
const isElementSourceAudioSeparated =
|
||||||
element.type === "video" && isSourceAudioSeparated({ element });
|
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 (
|
return (
|
||||||
<ContextMenu>
|
<ContextMenu>
|
||||||
<ContextMenuTrigger asChild>
|
<ContextMenuTrigger asChild>
|
||||||
<div
|
<div
|
||||||
className="absolute top-0 h-full select-none"
|
className="absolute top-0 select-none"
|
||||||
style={{
|
style={{
|
||||||
left: `${elementLeft}px`,
|
left: `${elementLeft}px`,
|
||||||
width: `${elementWidth}px`,
|
width: `${elementWidth}px`,
|
||||||
|
height:
|
||||||
|
expandedRows.length > 0
|
||||||
|
? `${baseTrackHeight + expansionHeight}px`
|
||||||
|
: "100%",
|
||||||
transform:
|
transform:
|
||||||
isBeingDragged && dragState.isDragging
|
isBeingDragged && dragState.isDragging
|
||||||
? `translate3d(0, ${dragOffsetY}px, 0)`
|
? `translate3d(0, ${dragOffsetY}px, 0)`
|
||||||
@@ -318,13 +386,19 @@ export function TimelineElement({
|
|||||||
element={element}
|
element={element}
|
||||||
track={track}
|
track={track}
|
||||||
isSelected={isSelected}
|
isSelected={isSelected}
|
||||||
|
isExpanded={expandedRows.length > 0}
|
||||||
|
baseTrackHeight={baseTrackHeight}
|
||||||
|
expandedContent={expandedContent}
|
||||||
onElementClick={onElementClick}
|
onElementClick={onElementClick}
|
||||||
onElementMouseDown={onElementMouseDown}
|
onElementMouseDown={onElementMouseDown}
|
||||||
handleResizeStart={handleResizeStart}
|
onResizeStart={onResizeStart}
|
||||||
isDropTarget={isDropTarget}
|
isDropTarget={isDropTarget}
|
||||||
/>
|
/>
|
||||||
{isSelected && (
|
{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
|
<KeyframeIndicators
|
||||||
indicators={keyframeIndicators}
|
indicators={keyframeIndicators}
|
||||||
dragState={keyframeDragState}
|
dragState={keyframeDragState}
|
||||||
@@ -346,6 +420,14 @@ export function TimelineElement({
|
|||||||
Split
|
Split
|
||||||
</ActionMenuItem>
|
</ActionMenuItem>
|
||||||
<CopyMenuItem />
|
<CopyMenuItem />
|
||||||
|
{selectedElements.length === 1 && (
|
||||||
|
<ActionMenuItem
|
||||||
|
action="duplicate-selected"
|
||||||
|
icon={<HugeiconsIcon icon={Copy01Icon} />}
|
||||||
|
>
|
||||||
|
Duplicate
|
||||||
|
</ActionMenuItem>
|
||||||
|
)}
|
||||||
{canElementHaveAudio(element) && hasAudio && (
|
{canElementHaveAudio(element) && hasAudio && (
|
||||||
<MuteMenuItem
|
<MuteMenuItem
|
||||||
isMultipleSelected={selectedElements.length > 1}
|
isMultipleSelected={selectedElements.length > 1}
|
||||||
@@ -355,11 +437,11 @@ export function TimelineElement({
|
|||||||
)}
|
)}
|
||||||
{canToggleCurrentSourceAudio && (
|
{canToggleCurrentSourceAudio && (
|
||||||
<ContextMenuItem
|
<ContextMenuItem
|
||||||
icon={
|
icon={
|
||||||
<HugeiconsIcon
|
<HugeiconsIcon
|
||||||
icon={isElementSourceAudioSeparated ? Unlink02Icon : Link02Icon}
|
icon={isElementSourceAudioSeparated ? ScissorIcon : ScissorIcon}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
onClick={(event: React.MouseEvent) => {
|
onClick={(event: React.MouseEvent) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
invokeAction("toggle-source-audio");
|
invokeAction("toggle-source-audio");
|
||||||
@@ -375,13 +457,16 @@ export function TimelineElement({
|
|||||||
isCurrentElementSelected={isCurrentElementSelected}
|
isCurrentElementSelected={isCurrentElementSelected}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{selectedElements.length === 1 && (
|
{hasKeyframes && (
|
||||||
<ActionMenuItem
|
<ContextMenuItem
|
||||||
action="duplicate-selected"
|
icon={<HugeiconsIcon icon={KeyframeIcon} />}
|
||||||
icon={<HugeiconsIcon icon={Copy01Icon} />}
|
onClick={(event: React.MouseEvent) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
toggleElementExpanded(element.id);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
Duplicate
|
{isExpanded ? "Collapse keyframes" : "Expand keyframes"}
|
||||||
</ActionMenuItem>
|
</ContextMenuItem>
|
||||||
)}
|
)}
|
||||||
{selectedElements.length === 1 && hasMediaId(element) && (
|
{selectedElements.length === 1 && hasMediaId(element) && (
|
||||||
<>
|
<>
|
||||||
@@ -417,14 +502,20 @@ function ElementInner({
|
|||||||
element,
|
element,
|
||||||
track,
|
track,
|
||||||
isSelected,
|
isSelected,
|
||||||
|
isExpanded,
|
||||||
|
baseTrackHeight,
|
||||||
|
expandedContent,
|
||||||
onElementClick,
|
onElementClick,
|
||||||
onElementMouseDown,
|
onElementMouseDown,
|
||||||
handleResizeStart,
|
onResizeStart,
|
||||||
isDropTarget = false,
|
isDropTarget = false,
|
||||||
}: {
|
}: {
|
||||||
element: TimelineElementType;
|
element: TimelineElementType;
|
||||||
track: TimelineTrack;
|
track: TimelineTrack;
|
||||||
isSelected: boolean;
|
isSelected: boolean;
|
||||||
|
isExpanded: boolean;
|
||||||
|
baseTrackHeight: number;
|
||||||
|
expandedContent: React.ReactNode;
|
||||||
onElementClick: (
|
onElementClick: (
|
||||||
event: React.MouseEvent,
|
event: React.MouseEvent,
|
||||||
element: TimelineElementType,
|
element: TimelineElementType,
|
||||||
@@ -433,9 +524,10 @@ function ElementInner({
|
|||||||
event: React.MouseEvent,
|
event: React.MouseEvent,
|
||||||
element: TimelineElementType,
|
element: TimelineElementType,
|
||||||
) => void;
|
) => void;
|
||||||
handleResizeStart: (params: {
|
onResizeStart: (params: {
|
||||||
event: React.MouseEvent;
|
event: React.MouseEvent;
|
||||||
elementId: string;
|
element: TimelineElementType;
|
||||||
|
track: TimelineTrack;
|
||||||
side: "left" | "right";
|
side: "left" | "right";
|
||||||
}) => void;
|
}) => void;
|
||||||
isDropTarget?: boolean;
|
isDropTarget?: boolean;
|
||||||
@@ -463,8 +555,7 @@ function ElementInner({
|
|||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"absolute inset-0 overflow-hidden rounded-sm",
|
"absolute inset-0 overflow-hidden rounded-sm",
|
||||||
getTimelineElementClassName({ type: track.type }),
|
isExpanded && "bg-background",
|
||||||
isReducedOpacity && "opacity-50",
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
@@ -474,9 +565,23 @@ function ElementInner({
|
|||||||
onClick={(event) => onElementClick(event, element)}
|
onClick={(event) => onElementClick(event, element)}
|
||||||
onMouseDown={(event) => onElementMouseDown(event, element)}
|
onMouseDown={(event) => onElementMouseDown(event, element)}
|
||||||
>
|
>
|
||||||
<div className="flex flex-1 min-h-0 items-center overflow-hidden">
|
<div
|
||||||
<ElementContent element={element} track={track} />
|
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>
|
</div>
|
||||||
|
{expandedContent}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -485,13 +590,15 @@ function ElementInner({
|
|||||||
<>
|
<>
|
||||||
<ResizeHandle
|
<ResizeHandle
|
||||||
side="left"
|
side="left"
|
||||||
elementId={element.id}
|
element={element}
|
||||||
handleResizeStart={handleResizeStart}
|
track={track}
|
||||||
|
onResizeStart={onResizeStart}
|
||||||
/>
|
/>
|
||||||
<ResizeHandle
|
<ResizeHandle
|
||||||
side="right"
|
side="right"
|
||||||
elementId={element.id}
|
element={element}
|
||||||
handleResizeStart={handleResizeStart}
|
track={track}
|
||||||
|
onResizeStart={onResizeStart}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -501,14 +608,17 @@ function ElementInner({
|
|||||||
|
|
||||||
function ResizeHandle({
|
function ResizeHandle({
|
||||||
side,
|
side,
|
||||||
elementId,
|
element,
|
||||||
handleResizeStart,
|
track,
|
||||||
|
onResizeStart,
|
||||||
}: {
|
}: {
|
||||||
side: "left" | "right";
|
side: "left" | "right";
|
||||||
elementId: string;
|
element: TimelineElementType;
|
||||||
handleResizeStart: (params: {
|
track: TimelineTrack;
|
||||||
|
onResizeStart: (params: {
|
||||||
event: React.MouseEvent;
|
event: React.MouseEvent;
|
||||||
elementId: string;
|
element: TimelineElementType;
|
||||||
|
track: TimelineTrack;
|
||||||
side: "left" | "right";
|
side: "left" | "right";
|
||||||
}) => void;
|
}) => void;
|
||||||
}) {
|
}) {
|
||||||
@@ -520,7 +630,7 @@ function ResizeHandle({
|
|||||||
"absolute top-0 bottom-0 w-2",
|
"absolute top-0 bottom-0 w-2",
|
||||||
isLeft ? "-left-1 cursor-w-resize" : "-right-1 cursor-e-resize",
|
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()}
|
onClick={(event) => event.stopPropagation()}
|
||||||
aria-label={`${isLeft ? "Left" : "Right"} resize handle`}
|
aria-label={`${isLeft ? "Left" : "Right"} resize handle`}
|
||||||
></button>
|
></button>
|
||||||
@@ -582,7 +692,7 @@ function KeyframeIndicators({
|
|||||||
<button
|
<button
|
||||||
key={indicator.time}
|
key={indicator.time}
|
||||||
type="button"
|
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 }}
|
style={{ left: visualOffsetPx }}
|
||||||
onMouseDown={(event) =>
|
onMouseDown={(event) =>
|
||||||
onKeyframeMouseDown({ event, keyframes: indicator.keyframes })
|
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 {
|
interface ElementContentProps {
|
||||||
element: TimelineElementType;
|
element: TimelineElementType;
|
||||||
track: TimelineTrack;
|
track: TimelineTrack;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { TrackType } from "@/lib/timeline";
|
import type { TrackType } from "@/lib/timeline";
|
||||||
import {
|
import {
|
||||||
|
KEYFRAME_LANE_HEIGHT_PX,
|
||||||
TIMELINE_TRACK_GAP_PX,
|
TIMELINE_TRACK_GAP_PX,
|
||||||
TIMELINE_TRACK_HEIGHTS_PX,
|
TIMELINE_TRACK_HEIGHTS_PX,
|
||||||
} from "./layout";
|
} from "./layout";
|
||||||
@@ -8,28 +9,50 @@ export function getTrackHeight({ type }: { type: TrackType }): number {
|
|||||||
return TIMELINE_TRACK_HEIGHTS_PX[type];
|
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({
|
export function getCumulativeHeightBefore({
|
||||||
tracks,
|
tracks,
|
||||||
trackIndex,
|
trackIndex,
|
||||||
|
getExtraHeight,
|
||||||
}: {
|
}: {
|
||||||
tracks: Array<{ type: TrackType }>;
|
tracks: Array<{ type: TrackType }>;
|
||||||
trackIndex: number;
|
trackIndex: number;
|
||||||
|
getExtraHeight?: (trackIndex: number) => number;
|
||||||
}): number {
|
}): number {
|
||||||
return tracks
|
return tracks
|
||||||
.slice(0, trackIndex)
|
.slice(0, trackIndex)
|
||||||
.reduce(
|
.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,
|
0,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getTotalTracksHeight({
|
export function getTotalTracksHeight({
|
||||||
tracks,
|
tracks,
|
||||||
|
getExtraHeight,
|
||||||
}: {
|
}: {
|
||||||
tracks: Array<{ type: TrackType }>;
|
tracks: Array<{ type: TrackType }>;
|
||||||
|
getExtraHeight?: (trackIndex: number) => number;
|
||||||
}): number {
|
}): number {
|
||||||
const tracksHeight = tracks.reduce(
|
const tracksHeight = tracks.reduce(
|
||||||
(sum, track) => sum + getTrackHeight({ type: track.type }),
|
(sum, track, i) =>
|
||||||
|
sum + getTrackHeight({ type: track.type }) + (getExtraHeight?.(i) ?? 0),
|
||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
const gapsHeight = Math.max(0, tracks.length - 1) * TIMELINE_TRACK_GAP_PX;
|
const gapsHeight = Math.max(0, tracks.length - 1) * TIMELINE_TRACK_GAP_PX;
|
||||||
|
|||||||
@@ -41,6 +41,8 @@ const MOUSE_BUTTON_RIGHT = 2;
|
|||||||
const initialDragState: ElementDragState = {
|
const initialDragState: ElementDragState = {
|
||||||
isDragging: false,
|
isDragging: false,
|
||||||
elementId: null,
|
elementId: null,
|
||||||
|
dragElementIds: [],
|
||||||
|
dragTimeOffsets: {},
|
||||||
trackId: null,
|
trackId: null,
|
||||||
startMouseX: 0,
|
startMouseX: 0,
|
||||||
startMouseY: 0,
|
startMouseY: 0,
|
||||||
@@ -202,6 +204,8 @@ export function useElementInteraction({
|
|||||||
setDragState({
|
setDragState({
|
||||||
isDragging: true,
|
isDragging: true,
|
||||||
elementId,
|
elementId,
|
||||||
|
dragElementIds: elementId ? [elementId] : [],
|
||||||
|
dragTimeOffsets: {},
|
||||||
trackId,
|
trackId,
|
||||||
startMouseX,
|
startMouseX,
|
||||||
startMouseY,
|
startMouseY,
|
||||||
@@ -521,6 +525,7 @@ export function useElementInteraction({
|
|||||||
}, [
|
}, [
|
||||||
dragState.isDragging,
|
dragState.isDragging,
|
||||||
dragState.elementId,
|
dragState.elementId,
|
||||||
|
dragState.startElementTime,
|
||||||
dragState.startMouseY,
|
dragState.startMouseY,
|
||||||
dragState.trackId,
|
dragState.trackId,
|
||||||
dragState.currentTime,
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ import { useCallback, useSyncExternalStore } from "react";
|
|||||||
import { useEditor } from "@/hooks/use-editor";
|
import { useEditor } from "@/hooks/use-editor";
|
||||||
import type { SelectedKeyframeRef } from "@/lib/animation/types";
|
import type { SelectedKeyframeRef } from "@/lib/animation/types";
|
||||||
|
|
||||||
function getSelectedKeyframeId({
|
export function getSelectedKeyframeId({
|
||||||
keyframe,
|
keyframe,
|
||||||
}: {
|
}: {
|
||||||
keyframe: SelectedKeyframeRef;
|
keyframe: SelectedKeyframeRef;
|
||||||
|
|||||||
Reference in New Issue
Block a user