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 KeyframeType = 'bezier' | 'hold'
export type Keyframe = {
id: KeyframeId
/** The `value` is the raw value type such as `Rgba` or `number`. See {@link SerializableValue} */
@ -80,6 +82,8 @@ export type Keyframe = {
position: number
handles: [leftX: number, leftY: number, rightX: number, rightY: number]
connectedRight: boolean
// defaults to 'bezier' to support project states made with theatre0.5.1 or earlier
type?: KeyframeType
}
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 = (
globalProgression: number,
): number => {
@ -197,12 +191,21 @@ const states = {
(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,
@ -214,7 +217,27 @@ const states = {
started: true,
validFrom: left.position,
validTo: right.position,
der,
der: bezierDer,
}
}
const holdDer = prism(() => {
const progression = globalProgressionToLocalProgression(
progressionD.getValue(),
)
const valueProgression = Math.floor(progression)
return {
left: left.value,
right: right.value,
progression: valueProgression,
}
})
return {
started: true,
validFrom: left.position,
validTo: right.position,
der: holdDer,
}
},
error: {

View file

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

View file

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

View file

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

View file

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

View file

@ -416,13 +416,15 @@ function setTempValue(
tempTransaction.current = null
const handles = handlesFromCssCubicBezierArgs(newCurveCssCubicBezier)
if (handles === null) return
if (handles === null) {
tempTransaction.current = transactionSetHold(keyframeConnections)
} else {
tempTransaction.current = transactionSetCubicBezier(
keyframeConnections,
handles,
)
}
}
function discardTempValue(
tempTransaction: React.MutableRefObject<CommitOrDiscard | null>,
@ -436,7 +438,7 @@ function transactionSetCubicBezier(
handles: CubicBezierHandles,
): CommitOrDiscard {
return getStudio().tempTransaction(({stateEditors}) => {
const {setHandlesForKeyframe} =
const {setHandlesForKeyframe, setKeyframeType: setKeyframeType} =
stateEditors.coreByProject.historic.sheetsById.sequence
for (const {
@ -463,6 +465,40 @@ function transactionSetCubicBezier(
keyframeId: right.id,
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 ${toExtremumSpace(0)}`
const holdPointsAttrValue = `0,100 100,100 100,0`
return (
<svg
height="100%"
@ -178,6 +180,8 @@ const CurveSegmentEditor: React.VFC<ICurveSegmentEditorProps> = (props) => {
fill="url(#dot-background-pattern-2)"
/>
{!left.type || left.type === 'bezier' ? (
<>
{/* Line from right end of curve to right handle */}
<line
x1={0}
@ -196,7 +200,6 @@ const CurveSegmentEditor: React.VFC<ICurveSegmentEditorProps> = (props) => {
stroke={CONTROL_COLOR}
strokeWidth="0.01"
/>
{/* Curve "shadow": the low-opacity filled area between the curve and the diagonal */}
<path
d={curvePathDAttrValue(props.curveConnection)}
@ -209,7 +212,9 @@ const CurveSegmentEditor: React.VFC<ICurveSegmentEditorProps> = (props) => {
<path
key={connection.objectKey + '/' + connection.left.id}
d={curvePathDAttrValue(connection)}
stroke={BACKGROUND_CURVE_COLORS[i % BACKGROUND_CURVE_COLORS.length]}
stroke={
BACKGROUND_CURVE_COLORS[i % BACKGROUND_CURVE_COLORS.length]
}
opacity={0.6}
strokeWidth="0.01"
/>
@ -247,7 +252,10 @@ const CurveSegmentEditor: React.VFC<ICurveSegmentEditorProps> = (props) => {
fill={CURVE_START_COLOR}
opacity={0.2}
/>
<Circle cx={left.handles[2]} cy={toExtremumSpace(1 - left.handles[3])} />
<Circle
cx={left.handles[2]}
cy={toExtremumSpace(1 - left.handles[3])}
/>
{/* Left handle and hit zone */}
<HitZone
ref={refRight}
@ -260,6 +268,43 @@ const CurveSegmentEditor: React.VFC<ICurveSegmentEditorProps> = (props) => {
cx={right.handles[0]}
cy={toExtremumSpace(1 - right.handles[1])}
/>
</>
) : (
<>
<line
x1={0}
y1={toExtremumSpace(1)}
x2={1}
y2={toExtremumSpace(1)}
stroke={CONTROL_COLOR}
strokeWidth="0.01"
/>
<line
x1={1}
y1={toExtremumSpace(1)}
x2={1}
y2={0}
stroke={CONTROL_COLOR}
strokeWidth="0.01"
/>
<circle
cx={0}
cy={1}
r="0.025"
stroke={CURVE_END_COLOR}
strokeWidth="0.02"
fill={COLOR_BASE}
/>
<circle
cx={1}
cy={0}
r="0.025"
stroke={CURVE_END_COLOR}
strokeWidth="0.02"
fill={COLOR_BASE}
/>
</>
)}
</svg>
)
}

View file

@ -21,20 +21,17 @@ type IProps = {
const SVGCurveSegment: React.FC<IProps> = (props) => {
const {easing, isSelected} = props
if (!easing) return <></>
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 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,
// resulting in bottom right coordinate of 1.1,1.1
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%"
@ -101,4 +98,35 @@ const SVGCurveSegment: React.FC<IProps> = (props) => {
</svg>
)
}
// "Hold" SVG
return (
<svg
height="100%"
width="100%"
viewBox={SVG_VIEWBOX_ATTR}
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<line
x1="0"
y1="1"
x2={1}
y2={1}
stroke={curveColor}
strokeWidth="0.08"
/>
<line
x1="1"
y1="0"
x2={1}
y2={1}
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>
)
}
export default SVGCurveSegment

View file

@ -85,6 +85,7 @@ export const EASING_PRESETS = [
{label: 'linear', value: '0.5, 0.5, 0.5, 0.5'},
{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
simplify the choices */

View file

@ -74,6 +74,19 @@ const Diamond = styled.div<IDiamond>`
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}>`
z-index: 1;
cursor: ew-resize;
@ -121,6 +134,8 @@ const SingleKeyframeDot: React.VFC<ISingleKeyframeDotProps> = (props) => {
},
})
const showDiamond = !props.keyframe.type || props.keyframe.type === 'bezier'
return (
<>
<HitZone
@ -128,11 +143,19 @@ const SingleKeyframeDot: React.VFC<ISingleKeyframeDotProps> = (props) => {
isInlineEditorPopoverOpen={isInlineEditorPopoverOpen}
{...presence.attrs}
/>
{showDiamond ? (
<Diamond
isSelected={!!props.selection}
isInlineEditorPopoverOpen={isInlineEditorPopoverOpen}
flag={presence.flag}
/>
) : (
<Square
isSelected={!!props.selection}
isInlineEditorPopoverOpen={isInlineEditorPopoverOpen}
flag={presence.flag}
/>
)}
{inlineEditorPopover}
{contextMenu}
</>

View file

@ -15,6 +15,9 @@ const SVGPath = styled.path`
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 {index, trackData} = props
const cur = trackData.keyframes[index]
@ -56,7 +59,7 @@ const Curve: React.VFC<IProps> = (props) => {
<>
<SVGPath
ref={nodeRef}
d={pathD}
d={!cur.type || cur.type === 'bezier' ? pathD : pathForHoldType}
style={{
transform,
}}

View file

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

View file

@ -2,6 +2,7 @@ import type {
BasicKeyframedTrack,
HistoricPositionalSequence,
Keyframe,
KeyframeType,
SheetState_Historic,
} from '@theatre/core/projects/store/types/SheetState_Historic'
import type {Drafts} from '@theatre/studio/StudioStore/StudioStore'
@ -696,6 +697,17 @@ namespace stateEditors {
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.
* Any position snapping should be done by the caller.
@ -707,6 +719,7 @@ namespace stateEditors {
handles?: [number, number, number, number]
value: T
snappingFunction: SnappingFunction
type?: KeyframeType
},
) {
const position = p.snappingFunction(p.position)
@ -734,6 +747,7 @@ namespace stateEditors {
position,
connectedRight: true,
handles: p.handles || [0.5, 1, 0.5, 0],
type: p.type || 'bezier',
value: p.value,
})
return
@ -744,6 +758,7 @@ namespace stateEditors {
position,
connectedRight: leftKeyframe.connectedRight,
handles: p.handles || [0.5, 1, 0.5, 0],
type: p.type || 'bezier',
value: p.value,
})
}
@ -868,24 +883,15 @@ namespace stateEditors {
end?: [number, number]
},
) {
const track = _getTrack(p)
if (!track) return
track.keyframes = track.keyframes.map((kf) => {
if (kf.id === p.keyframeId) {
// Use given value or fallback to original value,
// allowing the caller to customize exactly which side
// of the curve they are editing.
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],
],
const keyframe = _getKeyframeById(p)
if (keyframe) {
keyframe.handles = [
p.end?.[0] ?? keyframe.handles[0],
p.end?.[1] ?? keyframe.handles[1],
p.start?.[0] ?? keyframe.handles[2],
p.start?.[1] ?? keyframe.handles[3],
]
}
} else return kf
})
}
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
// * 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.