New feature: Keyframe "hold" as a new easing type (#360)

This commit is contained in:
Colin Duffy 2022-12-31 11:20:57 -08:00 committed by GitHub
parent 00baa3ae22
commit 103c35737c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 367 additions and 174 deletions

View file

@ -71,6 +71,8 @@ export type HistoricPositionalSequence = {
*/ */
export type TrackData = BasicKeyframedTrack export type TrackData = BasicKeyframedTrack
export type KeyframeType = 'bezier' | 'hold'
export type Keyframe = { export type Keyframe = {
id: KeyframeId id: KeyframeId
/** The `value` is the raw value type such as `Rgba` or `number`. See {@link SerializableValue} */ /** The `value` is the raw value type such as `Rgba` or `number`. See {@link SerializableValue} */
@ -80,6 +82,8 @@ export type Keyframe = {
position: number position: number
handles: [leftX: number, leftY: number, rightX: number, rightY: number] handles: [leftX: number, leftY: number, rightX: number, rightY: number]
connectedRight: boolean connectedRight: boolean
// defaults to 'bezier' to support project states made with theatre0.5.1 or earlier
type?: KeyframeType
} }
type TrackDataCommon<TypeName extends string> = { type TrackDataCommon<TypeName extends string> = {

View file

@ -184,12 +184,6 @@ const states = {
} }
} }
const solver = new UnitBezier(
left.handles[2],
left.handles[3],
right.handles[0],
right.handles[1],
)
const globalProgressionToLocalProgression = ( const globalProgressionToLocalProgression = (
globalProgression: number, globalProgression: number,
): number => { ): number => {
@ -197,12 +191,41 @@ const states = {
(globalProgression - left.position) / (right.position - left.position) (globalProgression - left.position) / (right.position - left.position)
) )
} }
const der = prism(() => {
if (!left.type || left.type === 'bezier') {
const solver = new UnitBezier(
left.handles[2],
left.handles[3],
right.handles[0],
right.handles[1],
)
const bezierDer = prism(() => {
const progression = globalProgressionToLocalProgression(
progressionD.getValue(),
)
const valueProgression = solver.solveSimple(progression)
return {
left: left.value,
right: right.value,
progression: valueProgression,
}
})
return {
started: true,
validFrom: left.position,
validTo: right.position,
der: bezierDer,
}
}
const holdDer = prism(() => {
const progression = globalProgressionToLocalProgression( const progression = globalProgressionToLocalProgression(
progressionD.getValue(), progressionD.getValue(),
) )
const valueProgression = Math.floor(progression)
const valueProgression = solver.solveSimple(progression)
return { return {
left: left.value, left: left.value,
right: right.value, right: right.value,
@ -214,7 +237,7 @@ const states = {
started: true, started: true,
validFrom: left.position, validFrom: left.position,
validTo: right.position, validTo: right.position,
der, der: holdDer,
} }
}, },
error: { error: {

View file

@ -274,6 +274,7 @@ describe(`SheetObject`, () => {
position: 10, position: 10,
connectedRight: true, connectedRight: true,
handles: [0.5, 0.5, 0.5, 0.5], handles: [0.5, 0.5, 0.5, 0.5],
type: 'bezier',
value: 3, value: 3,
}, },
{ {
@ -281,6 +282,7 @@ describe(`SheetObject`, () => {
position: 20, position: 20,
connectedRight: false, connectedRight: false,
handles: [0.5, 0.5, 0.5, 0.5], handles: [0.5, 0.5, 0.5, 0.5],
type: 'bezier',
value: 6, value: 6,
}, },
], ],

View file

@ -144,6 +144,7 @@ export default function createTransactionPrivateApi(
position: seq.position, position: seq.position,
value: value as $FixMe, value: value as $FixMe,
snappingFunction: seq.closestGridPosition, snappingFunction: seq.closestGridPosition,
type: 'bezier',
}, },
) )
} else { } else {

View file

@ -514,6 +514,7 @@ function pasteKeyframesToMultipleTracks(
handles: keyframe.handles, handles: keyframe.handles,
value: keyframe.value, value: keyframe.value,
snappingFunction: sequence.closestGridPosition, snappingFunction: sequence.closestGridPosition,
type: keyframe.type,
}, },
) )
} }
@ -548,6 +549,7 @@ function pasteKeyframesToSpecificTracks(
handles: keyframe.handles, handles: keyframe.handles,
value: keyframe.value, value: keyframe.value,
snappingFunction: sequence.closestGridPosition, snappingFunction: sequence.closestGridPosition,
type: keyframe.type,
}, },
) )
} }

View file

@ -187,6 +187,7 @@ function pasteKeyframesContextMenuItem(
handles: keyframe.handles, handles: keyframe.handles,
value: keyframe.value, value: keyframe.value,
snappingFunction: sequence.closestGridPosition, snappingFunction: sequence.closestGridPosition,
type: keyframe.type,
}, },
) )
} }

View file

@ -416,12 +416,14 @@ function setTempValue(
tempTransaction.current = null tempTransaction.current = null
const handles = handlesFromCssCubicBezierArgs(newCurveCssCubicBezier) const handles = handlesFromCssCubicBezierArgs(newCurveCssCubicBezier)
if (handles === null) return if (handles === null) {
tempTransaction.current = transactionSetHold(keyframeConnections)
tempTransaction.current = transactionSetCubicBezier( } else {
keyframeConnections, tempTransaction.current = transactionSetCubicBezier(
handles, keyframeConnections,
) handles,
)
}
} }
function discardTempValue( function discardTempValue(
@ -436,7 +438,7 @@ function transactionSetCubicBezier(
handles: CubicBezierHandles, handles: CubicBezierHandles,
): CommitOrDiscard { ): CommitOrDiscard {
return getStudio().tempTransaction(({stateEditors}) => { return getStudio().tempTransaction(({stateEditors}) => {
const {setHandlesForKeyframe} = const {setHandlesForKeyframe, setKeyframeType: setKeyframeType} =
stateEditors.coreByProject.historic.sheetsById.sequence stateEditors.coreByProject.historic.sheetsById.sequence
for (const { for (const {
@ -463,6 +465,40 @@ function transactionSetCubicBezier(
keyframeId: right.id, keyframeId: right.id,
end: [handles[2], handles[3]], end: [handles[2], handles[3]],
}) })
setKeyframeType({
projectId,
sheetId,
objectKey,
trackId,
keyframeId: left.id,
keyframeType: 'bezier',
})
}
})
}
function transactionSetHold(
keyframeConnections: Array<KeyframeConnectionWithAddress>,
): CommitOrDiscard {
return getStudio().tempTransaction(({stateEditors}) => {
const {setKeyframeType: setKeyframeType} =
stateEditors.coreByProject.historic.sheetsById.sequence
for (const {
projectId,
sheetId,
objectKey,
trackId,
left,
} of keyframeConnections) {
setKeyframeType({
projectId,
sheetId,
objectKey,
trackId,
keyframeId: left.id,
keyframeType: 'hold',
})
} }
}) })
} }

View file

@ -111,6 +111,8 @@ const CurveSegmentEditor: React.VFC<ICurveSegmentEditorProps> = (props) => {
1 - connection.right.handles[1], 1 - connection.right.handles[1],
)} 1 ${toExtremumSpace(0)}` )} 1 ${toExtremumSpace(0)}`
const holdPointsAttrValue = `0,100 100,100 100,0`
return ( return (
<svg <svg
height="100%" height="100%"
@ -178,88 +180,131 @@ const CurveSegmentEditor: React.VFC<ICurveSegmentEditorProps> = (props) => {
fill="url(#dot-background-pattern-2)" fill="url(#dot-background-pattern-2)"
/> />
{/* Line from right end of curve to right handle */} {!left.type || left.type === 'bezier' ? (
<line <>
x1={0} {/* Line from right end of curve to right handle */}
y1={toExtremumSpace(1)} <line
x2={left.handles[2]} x1={0}
y2={toExtremumSpace(1 - left.handles[3])} y1={toExtremumSpace(1)}
stroke={CONTROL_COLOR} x2={left.handles[2]}
strokeWidth="0.01" y2={toExtremumSpace(1 - left.handles[3])}
/> stroke={CONTROL_COLOR}
{/* Line from left end of curve to left handle */} strokeWidth="0.01"
<line />
x1={1} {/* Line from left end of curve to left handle */}
y1={toExtremumSpace(0)} <line
x2={right.handles[0]} x1={1}
y2={toExtremumSpace(1 - right.handles[1])} y1={toExtremumSpace(0)}
stroke={CONTROL_COLOR} x2={right.handles[0]}
strokeWidth="0.01" y2={toExtremumSpace(1 - right.handles[1])}
/> stroke={CONTROL_COLOR}
strokeWidth="0.01"
/>
{/* Curve "shadow": the low-opacity filled area between the curve and the diagonal */}
<path
d={curvePathDAttrValue(props.curveConnection)}
stroke="none"
fill="url('#myGradient')"
opacity="0.1"
/>
{/* The background curves (e.g. multiple different values) */}
{backgroundConnections.map((connection, i) => (
<path
key={connection.objectKey + '/' + connection.left.id}
d={curvePathDAttrValue(connection)}
stroke={
BACKGROUND_CURVE_COLORS[i % BACKGROUND_CURVE_COLORS.length]
}
opacity={0.6}
strokeWidth="0.01"
/>
))}
{/* The curve */}
<path
d={curvePathDAttrValue(props.curveConnection)}
stroke="url('#myGradient')"
strokeWidth="0.02"
/>
{/* Right end of curve */}
<circle
cx={0}
cy={toExtremumSpace(1)}
r="0.025"
stroke={CURVE_START_COLOR}
strokeWidth="0.02"
fill={COLOR_BASE}
/>
{/* Left end of curve */}
<circle
cx={1}
cy={toExtremumSpace(0)}
r="0.025"
stroke={CURVE_END_COLOR}
strokeWidth="0.02"
fill={COLOR_BASE}
/>
{/* Curve "shadow": the low-opacity filled area between the curve and the diagonal */} {/* Right handle and hit zone */}
<path <HitZone
d={curvePathDAttrValue(props.curveConnection)} ref={refLeft}
stroke="none" cx={left.handles[2]}
fill="url('#myGradient')" cy={toExtremumSpace(1 - left.handles[3])}
opacity="0.1" fill={CURVE_START_COLOR}
/> opacity={0.2}
{/* The background curves (e.g. multiple different values) */} />
{backgroundConnections.map((connection, i) => ( <Circle
<path cx={left.handles[2]}
key={connection.objectKey + '/' + connection.left.id} cy={toExtremumSpace(1 - left.handles[3])}
d={curvePathDAttrValue(connection)} />
stroke={BACKGROUND_CURVE_COLORS[i % BACKGROUND_CURVE_COLORS.length]} {/* Left handle and hit zone */}
opacity={0.6} <HitZone
strokeWidth="0.01" ref={refRight}
/> cx={right.handles[0]}
))} cy={toExtremumSpace(1 - right.handles[1])}
{/* The curve */} fill={CURVE_END_COLOR}
<path opacity={0.2}
d={curvePathDAttrValue(props.curveConnection)} />
stroke="url('#myGradient')" <Circle
strokeWidth="0.02" cx={right.handles[0]}
/> cy={toExtremumSpace(1 - right.handles[1])}
{/* Right end of curve */} />
<circle </>
cx={0} ) : (
cy={toExtremumSpace(1)} <>
r="0.025" <line
stroke={CURVE_START_COLOR} x1={0}
strokeWidth="0.02" y1={toExtremumSpace(1)}
fill={COLOR_BASE} x2={1}
/> y2={toExtremumSpace(1)}
{/* Left end of curve */} stroke={CONTROL_COLOR}
<circle strokeWidth="0.01"
cx={1} />
cy={toExtremumSpace(0)} <line
r="0.025" x1={1}
stroke={CURVE_END_COLOR} y1={toExtremumSpace(1)}
strokeWidth="0.02" x2={1}
fill={COLOR_BASE} y2={0}
/> stroke={CONTROL_COLOR}
strokeWidth="0.01"
{/* Right handle and hit zone */} />
<HitZone <circle
ref={refLeft} cx={0}
cx={left.handles[2]} cy={1}
cy={toExtremumSpace(1 - left.handles[3])} r="0.025"
fill={CURVE_START_COLOR} stroke={CURVE_END_COLOR}
opacity={0.2} strokeWidth="0.02"
/> fill={COLOR_BASE}
<Circle cx={left.handles[2]} cy={toExtremumSpace(1 - left.handles[3])} /> />
{/* Left handle and hit zone */} <circle
<HitZone cx={1}
ref={refRight} cy={0}
cx={right.handles[0]} r="0.025"
cy={toExtremumSpace(1 - right.handles[1])} stroke={CURVE_END_COLOR}
fill={CURVE_END_COLOR} strokeWidth="0.02"
opacity={0.2} fill={COLOR_BASE}
/> />
<Circle </>
cx={right.handles[0]} )}
cy={toExtremumSpace(1 - right.handles[1])}
/>
</svg> </svg>
) )
} }

View file

@ -21,20 +21,85 @@ type IProps = {
const SVGCurveSegment: React.FC<IProps> = (props) => { const SVGCurveSegment: React.FC<IProps> = (props) => {
const {easing, isSelected} = props const {easing, isSelected} = props
if (!easing) return <></>
const curveColor = isSelected ? SELECTED_CURVE_COLOR : CURVE_COLOR const curveColor = isSelected ? SELECTED_CURVE_COLOR : CURVE_COLOR
const leftControlPoint = [easing[0], toVerticalSVGSpace(easing[1])]
const rightControlPoint = [easing[2], toVerticalSVGSpace(easing[3])]
// With a padding of 0, this results in a "unit viewbox" i.e. `0 0 1 1`. // With a padding of 0, this results in a "unit viewbox" i.e. `0 0 1 1`.
// With padding e.g. VIEWBOX_PADDING=0.1, this results in a viewbox of `-0.1 -0,1 1.2 1.2`, // With padding e.g. VIEWBOX_PADDING=0.1, this results in a viewbox of `-0.1 -0,1 1.2 1.2`,
// i.e. a viewbox with a top left coordinate of -0.1,-0.1 and a width and height of 1.2, // i.e. a viewbox with a top left coordinate of -0.1,-0.1 and a width and height of 1.2,
// resulting in bottom right coordinate of 1.1,1.1 // resulting in bottom right coordinate of 1.1,1.1
const SVG_VIEWBOX_ATTR = `${-VIEWBOX_PADDING} ${-VIEWBOX_PADDING} ${VIEWBOX_SIZE} ${VIEWBOX_SIZE}` const SVG_VIEWBOX_ATTR = `${-VIEWBOX_PADDING} ${-VIEWBOX_PADDING} ${VIEWBOX_SIZE} ${VIEWBOX_SIZE}`
// Bezier SVG
if (easing) {
const leftControlPoint = [easing[0], toVerticalSVGSpace(easing[1])]
const rightControlPoint = [easing[2], toVerticalSVGSpace(easing[3])]
return (
<svg
height="100%"
width="100%"
viewBox={SVG_VIEWBOX_ATTR}
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
{/* Control lines */}
<line
x1="0"
y1="1"
x2={leftControlPoint[0]}
y2={leftControlPoint[1]}
stroke={CONTROL_COLOR}
strokeWidth="0.1"
/>
<line
x1="1"
y1="0"
x2={rightControlPoint[0]}
y2={rightControlPoint[1]}
stroke={CONTROL_COLOR}
strokeWidth="0.1"
/>
{/* Control point hitzonecircles */}
<circle
cx={leftControlPoint[0]}
cy={leftControlPoint[1]}
r={0.1}
fill={CONTROL_HITZONE_COLOR}
/>
<circle
cx={rightControlPoint[0]}
cy={rightControlPoint[1]}
r={0.1}
fill={CONTROL_HITZONE_COLOR}
/>
{/* Control point circles */}
<circle
cx={leftControlPoint[0]}
cy={leftControlPoint[1]}
r={SVG_CIRCLE_RADIUS}
fill={CONTROL_COLOR}
/>
<circle
cx={rightControlPoint[0]}
cy={rightControlPoint[1]}
r={SVG_CIRCLE_RADIUS}
fill={CONTROL_COLOR}
/>
{/* Bezier curve */}
<path
d={`M0 1 C${leftControlPoint[0]} ${leftControlPoint[1]} ${rightControlPoint[0]}
${rightControlPoint[1]} 1 0`}
stroke={curveColor}
strokeWidth="0.08"
/>
<circle cx={0} cy={1} r={SVG_CIRCLE_RADIUS} fill={curveColor} />
<circle cx={1} cy={0} r={SVG_CIRCLE_RADIUS} fill={curveColor} />
</svg>
)
}
// "Hold" SVG
return ( return (
<svg <svg
height="100%" height="100%"
@ -43,56 +108,19 @@ const SVGCurveSegment: React.FC<IProps> = (props) => {
fill="none" fill="none"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
> >
{/* Control lines */}
<line <line
x1="0" x1="0"
y1="1" y1="1"
x2={leftControlPoint[0]} x2={1}
y2={leftControlPoint[1]} y2={1}
stroke={CONTROL_COLOR} stroke={curveColor}
strokeWidth="0.1" strokeWidth="0.08"
/> />
<line <line
x1="1" x1="1"
y1="0" y1="0"
x2={rightControlPoint[0]} x2={1}
y2={rightControlPoint[1]} y2={1}
stroke={CONTROL_COLOR}
strokeWidth="0.1"
/>
{/* Control point hitzonecircles */}
<circle
cx={leftControlPoint[0]}
cy={leftControlPoint[1]}
r={0.1}
fill={CONTROL_HITZONE_COLOR}
/>
<circle
cx={rightControlPoint[0]}
cy={rightControlPoint[1]}
r={0.1}
fill={CONTROL_HITZONE_COLOR}
/>
{/* Control point circles */}
<circle
cx={leftControlPoint[0]}
cy={leftControlPoint[1]}
r={SVG_CIRCLE_RADIUS}
fill={CONTROL_COLOR}
/>
<circle
cx={rightControlPoint[0]}
cy={rightControlPoint[1]}
r={SVG_CIRCLE_RADIUS}
fill={CONTROL_COLOR}
/>
{/* Bezier curve */}
<path
d={`M0 1 C${leftControlPoint[0]} ${leftControlPoint[1]} ${rightControlPoint[0]}
${rightControlPoint[1]} 1 0`}
stroke={curveColor} stroke={curveColor}
strokeWidth="0.08" strokeWidth="0.08"
/> />

View file

@ -85,6 +85,7 @@ export const EASING_PRESETS = [
{label: 'linear', value: '0.5, 0.5, 0.5, 0.5'}, {label: 'linear', value: '0.5, 0.5, 0.5, 0.5'},
{label: 'In Out', value: '0.42,0,0.58,1'}, {label: 'In Out', value: '0.42,0,0.58,1'},
{label: 'Hold', value: '0, 0, Infinity, Infinity'},
/* These easings are not being included initially in order to /* These easings are not being included initially in order to
simplify the choices */ simplify the choices */

View file

@ -74,6 +74,19 @@ const Diamond = styled.div<IDiamond>`
pointer-events: none; pointer-events: none;
` `
const Square = styled.div<IDiamond>`
position: absolute;
${absoluteDims(DOT_SIZE_PX * 1.5)}
background: ${(props) => selectBacgroundForDiamond(props)};
${(props) =>
props.flag === PresenceFlag.Primary ? 'outline: 2px solid white;' : ''};
z-index: 1;
pointer-events: none;
`
const HitZone = styled.div<{isInlineEditorPopoverOpen: boolean}>` const HitZone = styled.div<{isInlineEditorPopoverOpen: boolean}>`
z-index: 1; z-index: 1;
cursor: ew-resize; cursor: ew-resize;
@ -121,6 +134,8 @@ const SingleKeyframeDot: React.VFC<ISingleKeyframeDotProps> = (props) => {
}, },
}) })
const showDiamond = !props.keyframe.type || props.keyframe.type === 'bezier'
return ( return (
<> <>
<HitZone <HitZone
@ -128,11 +143,19 @@ const SingleKeyframeDot: React.VFC<ISingleKeyframeDotProps> = (props) => {
isInlineEditorPopoverOpen={isInlineEditorPopoverOpen} isInlineEditorPopoverOpen={isInlineEditorPopoverOpen}
{...presence.attrs} {...presence.attrs}
/> />
<Diamond {showDiamond ? (
isSelected={!!props.selection} <Diamond
isInlineEditorPopoverOpen={isInlineEditorPopoverOpen} isSelected={!!props.selection}
flag={presence.flag} isInlineEditorPopoverOpen={isInlineEditorPopoverOpen}
/> flag={presence.flag}
/>
) : (
<Square
isSelected={!!props.selection}
isInlineEditorPopoverOpen={isInlineEditorPopoverOpen}
flag={presence.flag}
/>
)}
{inlineEditorPopover} {inlineEditorPopover}
{contextMenu} {contextMenu}
</> </>

View file

@ -15,6 +15,9 @@ const SVGPath = styled.path`
type IProps = Parameters<typeof KeyframeEditor>[0] type IProps = Parameters<typeof KeyframeEditor>[0]
// for keyframe.type === 'hold'
const pathForHoldType = `M 0 0 L 1 0 L 1 1`
const Curve: React.VFC<IProps> = (props) => { const Curve: React.VFC<IProps> = (props) => {
const {index, trackData} = props const {index, trackData} = props
const cur = trackData.keyframes[index] const cur = trackData.keyframes[index]
@ -56,7 +59,7 @@ const Curve: React.VFC<IProps> = (props) => {
<> <>
<SVGPath <SVGPath
ref={nodeRef} ref={nodeRef}
d={pathD} d={!cur.type || cur.type === 'bezier' ? pathD : pathForHoldType}
style={{ style={{
transform, transform,
}} }}

View file

@ -55,8 +55,13 @@ const KeyframeEditor: React.VFC<IKeyframeEditorProps> = (props) => {
{shouldShowCurve ? ( {shouldShowCurve ? (
<> <>
<Curve {...props} /> <Curve {...props} />
<CurveHandle {...props} which="left" /> {!cur.type ||
<CurveHandle {...props} which="right" /> (cur.type === 'bezier' && (
<>
<CurveHandle {...props} which="left" />
<CurveHandle {...props} which="right" />
</>
))}
</> </>
) : ( ) : (
noConnector noConnector

View file

@ -2,6 +2,7 @@ import type {
BasicKeyframedTrack, BasicKeyframedTrack,
HistoricPositionalSequence, HistoricPositionalSequence,
Keyframe, Keyframe,
KeyframeType,
SheetState_Historic, SheetState_Historic,
} from '@theatre/core/projects/store/types/SheetState_Historic' } from '@theatre/core/projects/store/types/SheetState_Historic'
import type {Drafts} from '@theatre/studio/StudioStore/StudioStore' import type {Drafts} from '@theatre/studio/StudioStore/StudioStore'
@ -696,6 +697,17 @@ namespace stateEditors {
return _ensureTracksOfObject(p).trackData[p.trackId] return _ensureTracksOfObject(p).trackData[p.trackId]
} }
function _getKeyframeById(
p: WithoutSheetInstance<SheetObjectAddress> & {
trackId: SequenceTrackId
keyframeId: KeyframeId
},
): Keyframe | undefined {
const track = _getTrack(p)
if (!track) return
return track.keyframes.find((kf) => kf.id === p.keyframeId)
}
/** /**
* Sets a keyframe at the exact specified position. * Sets a keyframe at the exact specified position.
* Any position snapping should be done by the caller. * Any position snapping should be done by the caller.
@ -707,6 +719,7 @@ namespace stateEditors {
handles?: [number, number, number, number] handles?: [number, number, number, number]
value: T value: T
snappingFunction: SnappingFunction snappingFunction: SnappingFunction
type?: KeyframeType
}, },
) { ) {
const position = p.snappingFunction(p.position) const position = p.snappingFunction(p.position)
@ -734,6 +747,7 @@ namespace stateEditors {
position, position,
connectedRight: true, connectedRight: true,
handles: p.handles || [0.5, 1, 0.5, 0], handles: p.handles || [0.5, 1, 0.5, 0],
type: p.type || 'bezier',
value: p.value, value: p.value,
}) })
return return
@ -744,6 +758,7 @@ namespace stateEditors {
position, position,
connectedRight: leftKeyframe.connectedRight, connectedRight: leftKeyframe.connectedRight,
handles: p.handles || [0.5, 1, 0.5, 0], handles: p.handles || [0.5, 1, 0.5, 0],
type: p.type || 'bezier',
value: p.value, value: p.value,
}) })
} }
@ -868,24 +883,15 @@ namespace stateEditors {
end?: [number, number] end?: [number, number]
}, },
) { ) {
const track = _getTrack(p) const keyframe = _getKeyframeById(p)
if (!track) return if (keyframe) {
track.keyframes = track.keyframes.map((kf) => { keyframe.handles = [
if (kf.id === p.keyframeId) { p.end?.[0] ?? keyframe.handles[0],
// Use given value or fallback to original value, p.end?.[1] ?? keyframe.handles[1],
// allowing the caller to customize exactly which side p.start?.[0] ?? keyframe.handles[2],
// of the curve they are editing. p.start?.[1] ?? keyframe.handles[3],
return { ]
...kf, }
handles: [
p.end?.[0] ?? kf.handles[0],
p.end?.[1] ?? kf.handles[1],
p.start?.[0] ?? kf.handles[2],
p.start?.[1] ?? kf.handles[3],
],
}
} else return kf
})
} }
export function deleteKeyframes( export function deleteKeyframes(
@ -902,6 +908,19 @@ namespace stateEditors {
) )
} }
export function setKeyframeType(
p: WithoutSheetInstance<SheetObjectAddress> & {
trackId: SequenceTrackId
keyframeId: KeyframeId
keyframeType: KeyframeType
},
) {
const kf = _getKeyframeById(p)
if (kf) {
kf.type = p.keyframeType
}
}
// Future: consider whether a list of "partial" keyframes requiring `id` is possible to accept // Future: consider whether a list of "partial" keyframes requiring `id` is possible to accept
// * Consider how common this pattern is, as this sort of concept would best be encountered // * Consider how common this pattern is, as this sort of concept would best be encountered
// a few times to start to see an opportunity for improved ergonomics / crdt. // a few times to start to see an opportunity for improved ergonomics / crdt.