New feature: Keyframe "hold" as a new easing type (#360)
This commit is contained in:
parent
00baa3ae22
commit
103c35737c
14 changed files with 367 additions and 174 deletions
|
@ -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> = {
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
],
|
||||
|
|
|
@ -144,6 +144,7 @@ export default function createTransactionPrivateApi(
|
|||
position: seq.position,
|
||||
value: value as $FixMe,
|
||||
snappingFunction: seq.closestGridPosition,
|
||||
type: 'bezier',
|
||||
},
|
||||
)
|
||||
} else {
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
|
@ -187,6 +187,7 @@ function pasteKeyframesContextMenuItem(
|
|||
handles: keyframe.handles,
|
||||
value: keyframe.value,
|
||||
snappingFunction: sequence.closestGridPosition,
|
||||
type: keyframe.type,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
|
@ -416,12 +416,14 @@ 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(
|
||||
|
@ -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',
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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%"
|
||||
|
@ -100,5 +97,36 @@ const SVGCurveSegment: React.FC<IProps> = (props) => {
|
|||
<circle cx={1} cy={0} r={SVG_CIRCLE_RADIUS} fill={curveColor} />
|
||||
</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
|
||||
|
|
|
@ -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 */
|
||||
|
|
|
@ -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}
|
||||
</>
|
||||
|
|
|
@ -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,
|
||||
}}
|
||||
|
|
|
@ -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
|
||||
)}
|
||||
|
|
|
@ -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.
|
||||
|
|
Loading…
Reference in a new issue