summaryrefslogtreecommitdiff
path: root/apps/web/src
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web/src')
-rw-r--r--apps/web/src/components/editor/panels/timeline/expanded-layout.ts117
-rw-r--r--apps/web/src/components/editor/panels/timeline/graph-editor/session.ts30
-rw-r--r--apps/web/src/components/editor/panels/timeline/graph-editor/use-controller.ts34
-rw-r--r--apps/web/src/components/editor/panels/timeline/index.tsx193
-rw-r--r--apps/web/src/components/editor/panels/timeline/layout.ts4
-rw-r--r--apps/web/src/components/editor/panels/timeline/timeline-element.tsx399
-rw-r--r--apps/web/src/components/editor/panels/timeline/track-layout.ts27
-rw-r--r--apps/web/src/hooks/timeline/element/use-element-interaction.ts5
-rw-r--r--apps/web/src/hooks/timeline/element/use-keyframe-box-select.ts222
-rw-r--r--apps/web/src/hooks/timeline/element/use-keyframe-selection.ts458
10 files changed, 1119 insertions, 370 deletions
diff --git a/apps/web/src/components/editor/panels/timeline/expanded-layout.ts b/apps/web/src/components/editor/panels/timeline/expanded-layout.ts
new file mode 100644
index 00000000..6b9734a7
--- /dev/null
+++ b/apps/web/src/components/editor/panels/timeline/expanded-layout.ts
@@ -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;
+}
diff --git a/apps/web/src/components/editor/panels/timeline/graph-editor/session.ts b/apps/web/src/components/editor/panels/timeline/graph-editor/session.ts
index 15657bfb..663b4b5a 100644
--- a/apps/web/src/components/editor/panels/timeline/graph-editor/session.ts
+++ b/apps/web/src/components/editor/panels/timeline/graph-editor/session.ts
@@ -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,
};
}
diff --git a/apps/web/src/components/editor/panels/timeline/graph-editor/use-controller.ts b/apps/web/src/components/editor/panels/timeline/graph-editor/use-controller.ts
index d0303b6f..8baa3c43 100644
--- a/apps/web/src/components/editor/panels/timeline/graph-editor/use-controller.ts
+++ b/apps/web/src/components/editor/panels/timeline/graph-editor/use-controller.ts
@@ -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;
},
diff --git a/apps/web/src/components/editor/panels/timeline/index.tsx b/apps/web/src/components/editor/panels/timeline/index.tsx
index 883011d1..ae9e19c9 100644
--- a/apps/web/src/components/editor/panels/timeline/index.tsx
+++ b/apps/web/src/components/editor/panels/timeline/index.tsx
@@ -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>
+ );
+}
diff --git a/apps/web/src/components/editor/panels/timeline/layout.ts b/apps/web/src/components/editor/panels/timeline/layout.ts
index 88443797..c30b7877 100644
--- a/apps/web/src/components/editor/panels/timeline/layout.ts
+++ b/apps/web/src/components/editor/panels/timeline/layout.ts
@@ -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;
diff --git a/apps/web/src/components/editor/panels/timeline/timeline-element.tsx b/apps/web/src/components/editor/panels/timeline/timeline-element.tsx
index 1d3606e6..e47bcba0 100644
--- a/apps/web/src/components/editor/panels/timeline/timeline-element.tsx
+++ b/apps/web/src/components/editor/panels/timeline/timeline-element.tsx
@@ -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;
diff --git a/apps/web/src/components/editor/panels/timeline/track-layout.ts b/apps/web/src/components/editor/panels/timeline/track-layout.ts
index 89cc4d05..8f833a57 100644
--- a/apps/web/src/components/editor/panels/timeline/track-layout.ts
+++ b/apps/web/src/components/editor/panels/timeline/track-layout.ts
@@ -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;
diff --git a/apps/web/src/hooks/timeline/element/use-element-interaction.ts b/apps/web/src/hooks/timeline/element/use-element-interaction.ts
index efaacb6a..beac1879 100644
--- a/apps/web/src/hooks/timeline/element/use-element-interaction.ts
+++ b/apps/web/src/hooks/timeline/element/use-element-interaction.ts
@@ -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,
diff --git a/apps/web/src/hooks/timeline/element/use-keyframe-box-select.ts b/apps/web/src/hooks/timeline/element/use-keyframe-box-select.ts
new file mode 100644
index 00000000..0fc46372
--- /dev/null
+++ b/apps/web/src/hooks/timeline/element/use-keyframe-box-select.ts
@@ -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,
+ };
+}
diff --git a/apps/web/src/hooks/timeline/element/use-keyframe-selection.ts b/apps/web/src/hooks/timeline/element/use-keyframe-selection.ts
index 1a219d1f..2b5a0566 100644
--- a/apps/web/src/hooks/timeline/element/use-keyframe-selection.ts
+++ b/apps/web/src/hooks/timeline/element/use-keyframe-selection.ts
@@ -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,
+ };
+}