feat: graph editor for flat keyframe segments
This commit is contained in:
parent
496f7d690d
commit
270e2951b1
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue