diff options
| author | Maze Winther <mazewinther@gmail.com> | 2026-04-13 04:41:10 +0200 |
|---|---|---|
| committer | Maze Winther <mazewinther@gmail.com> | 2026-04-13 05:08:29 +0200 |
| commit | 36efdf66e707c818b81009acf8a00630a0309ca1 (patch) | |
| tree | d3eb1a97429ab1554d05450d5c2242517054ee6e /apps/web/src/components/editor/panels/timeline/graph-editor | |
| parent | 2272598d420cf078aaa3928246bfa3a414f6eddb (diff) | |
| download | opencut-36efdf66e707c818b81009acf8a00630a0309ca1.tar.gz | |
feat: expandable keyframe lanes in timeline
Made-with: Cursor
Diffstat (limited to 'apps/web/src/components/editor/panels/timeline/graph-editor')
| -rw-r--r-- | apps/web/src/components/editor/panels/timeline/graph-editor/session.ts | 30 | ||||
| -rw-r--r-- | apps/web/src/components/editor/panels/timeline/graph-editor/use-controller.ts | 34 |
2 files changed, 42 insertions, 22 deletions
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; }, |
