feat: graph editor for flat keyframe segments

This commit is contained in:
Maze Winther 2026-04-13 21:59:50 +02:00
parent 496f7d690d
commit 270e2951b1
3 changed files with 82 additions and 40 deletions

View File

@ -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;

View File

@ -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;
}

View File

@ -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),
},
};
}