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 663b4b5a..60e4723e 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 @@ -64,6 +64,12 @@ export interface GraphEditorReadyState extends GraphEditorBaseSelectionState { */ allContexts: ScalarGraphKeyframeContext[]; cubicBezier: NormalizedCubicBezier; + /** + * Y-axis scale used for flat segments (where spanValue ≈ 0). Derived from + * the nearest non-flat adjacent segment so that handle positions correspond + * to a meaningful value range. Always positive. + */ + referenceSpanValue: number; } export type GraphEditorSelectionState = @@ -155,19 +161,35 @@ function getComponentLabel({ componentKey }: { componentKey: string }): string { } } -function isFlatSegment({ +/** + * Returns the absolute value span of the nearest non-flat adjacent segment, + * used as the Y-axis scale when editing a flat segment in the graph editor. + * Falls back to 1.0 if all surrounding segments are also flat. + */ +function getReferenceSpanValue({ context, }: { context: ScalarGraphKeyframeContext; -}): boolean { - if (!context.nextKey) { - return true; +}): number { + const sorted = [...context.channel.keys].sort((a, b) => a.time - b.time); + const leftIndex = sorted.findIndex((k) => k.id === context.keyframe.id); + const rightIndex = context.nextKey + ? sorted.findIndex((k) => k.id === context.nextKey?.id) + : -1; + + for (let i = leftIndex - 1; i >= 0; i--) { + const span = Math.abs(sorted[i + 1].value - sorted[i].value); + if (span > FLAT_VALUE_EPSILON) return span; } - return ( - Math.abs(context.nextKey.value - context.keyframe.value) <= - FLAT_VALUE_EPSILON - ); + if (rightIndex !== -1) { + for (let i = rightIndex; i < sorted.length - 1; i++) { + const span = Math.abs(sorted[i + 1].value - sorted[i].value); + if (span > FLAT_VALUE_EPSILON) return span; + } + } + + return 1.0; } function isLinearCurve({ @@ -328,35 +350,28 @@ export function resolveGraphEditorSelectionState({ }); } - if (isFlatSegment({ context: activeContext.context })) { - return createUnavailableState({ - reason: "selected-segment-is-flat", - message: "Flat segments are not graph-editable in this popover yet.", - componentOptions, - activeComponentKey: activeContext.option.key, - }); - } - if (activeContext.context.keyframe.segmentToNext === "step") { return createUnavailableState({ reason: "selected-segment-is-hold", - message: "Hold segments are not graph-editable in this popover yet.", + message: "Hold segments have a fixed value — easing has no effect here.", componentOptions, activeComponentKey: activeContext.option.key, }); } + const referenceSpanValue = getReferenceSpanValue({ context: activeContext.context }); const cubicBezier = activeContext.context.keyframe.segmentToNext === "linear" ? GRAPH_LINEAR_CURVE : getNormalizedCubicBezierForScalarSegment({ leftKey: activeContext.context.keyframe, rightKey: activeContext.context.nextKey, + referenceSpanValue, }); if (!cubicBezier) { return createUnavailableState({ reason: "selected-segment-is-flat", - message: "The selected segment cannot be represented in this graph view.", + message: "Cannot edit a segment where both keyframes are at the same time.", componentOptions, activeComponentKey: activeContext.option.key, }); @@ -375,15 +390,18 @@ export function resolveGraphEditorSelectionState({ context: activeContext.context, allContexts, cubicBezier, + referenceSpanValue, }; } export function buildGraphEditorCurvePatches({ context, cubicBezier, + referenceSpanValue, }: { context: ScalarGraphKeyframeContext; cubicBezier: NormalizedCubicBezier; + referenceSpanValue: number; }): GraphEditorCurvePatch[] | null { if (!context.nextKey) { return null; @@ -411,6 +429,7 @@ export function buildGraphEditorCurvePatches({ leftKey: context.keyframe, rightKey: context.nextKey, cubicBezier, + referenceSpanValue, }); if (!handles) { return null; @@ -437,14 +456,17 @@ export function applyGraphEditorCurvePreview({ animations, context, cubicBezier, + referenceSpanValue, }: { animations: ElementAnimations | undefined; context: ScalarGraphKeyframeContext; cubicBezier: NormalizedCubicBezier; + referenceSpanValue: number; }): ElementAnimations | undefined { const patches = buildGraphEditorCurvePatches({ context, cubicBezier, + referenceSpanValue, }); if (!patches) { return animations; 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 8baa3c43..cd4c792f 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,11 +96,16 @@ export function useGraphEditorController() { return; } - const nextAnimations = state.allContexts.reduce( - (animations, context) => - applyGraphEditorCurvePreview({ animations, context, cubicBezier: nextValue }), - state.element.animations, - ); + const nextAnimations = state.allContexts.reduce( + (animations, context) => + applyGraphEditorCurvePreview({ + animations, + context, + cubicBezier: nextValue, + referenceSpanValue: state.referenceSpanValue, + }), + state.element.animations, + ); editor.timeline.previewElements({ updates: [ { @@ -123,10 +128,11 @@ export function useGraphEditorController() { // 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, - }); + const patches = buildGraphEditorCurvePatches({ + context: state.context, + cubicBezier: nextValue, + referenceSpanValue: state.referenceSpanValue, + }); if (!patches) { return; } diff --git a/apps/web/src/lib/animation/curve-bridge.ts b/apps/web/src/lib/animation/curve-bridge.ts index 12103c3f..385fb448 100644 --- a/apps/web/src/lib/animation/curve-bridge.ts +++ b/apps/web/src/lib/animation/curve-bridge.ts @@ -17,16 +17,23 @@ function clamp01({ value }: { value: number }): number { export function getNormalizedCubicBezierForScalarSegment({ leftKey, rightKey, + referenceSpanValue, }: { leftKey: ScalarAnimationKey; rightKey: ScalarAnimationKey; + /** Fallback Y-axis scale used when the segment is flat (spanValue ≈ 0). */ + referenceSpanValue?: number; }): NormalizedCubicBezier | null { const spanTime = rightKey.time - leftKey.time; const spanValue = rightKey.value - leftKey.value; - if ( - spanTime === 0 || - Math.abs(spanValue) <= VALUE_EPSILON - ) { + const effectiveSpanValue = + Math.abs(spanValue) > VALUE_EPSILON + ? spanValue + : referenceSpanValue !== undefined && Math.abs(referenceSpanValue) > VALUE_EPSILON + ? referenceSpanValue + : null; + + if (spanTime === 0 || effectiveSpanValue === null) { return null; } @@ -37,9 +44,9 @@ export function getNormalizedCubicBezierForScalarSegment({ return [ clamp01({ value: rightHandle.dt / spanTime }), - rightHandle.dv / spanValue, + rightHandle.dv / effectiveSpanValue, clamp01({ value: 1 + leftHandle.dt / spanTime }), - 1 + leftHandle.dv / spanValue, + 1 + leftHandle.dv / effectiveSpanValue, ]; } @@ -47,20 +54,27 @@ export function getCurveHandlesForNormalizedCubicBezier({ leftKey, rightKey, cubicBezier, + referenceSpanValue, }: { leftKey: ScalarAnimationKey; rightKey: ScalarAnimationKey; cubicBezier: NormalizedCubicBezier; + /** Fallback Y-axis scale used when the segment is flat (spanValue ≈ 0). */ + referenceSpanValue?: number; }): { rightHandle: CurveHandle; leftHandle: CurveHandle; } | null { const spanTime = rightKey.time - leftKey.time; const spanValue = rightKey.value - leftKey.value; - if ( - spanTime === 0 || - Math.abs(spanValue) <= VALUE_EPSILON - ) { + const effectiveSpanValue = + Math.abs(spanValue) > VALUE_EPSILON + ? spanValue + : referenceSpanValue !== undefined && Math.abs(referenceSpanValue) > VALUE_EPSILON + ? referenceSpanValue + : null; + + if (spanTime === 0 || effectiveSpanValue === null) { return null; } @@ -71,11 +85,11 @@ export function getCurveHandlesForNormalizedCubicBezier({ return { rightHandle: { dt: spanTime * x1, - dv: spanValue * y1, + dv: effectiveSpanValue * y1, }, leftHandle: { dt: spanTime * (x2 - 1), - dv: spanValue * (y2 - 1), + dv: effectiveSpanValue * (y2 - 1), }, }; }