multi-curve curve popover editing (#176)

Co-authored-by: Aria Minaei <aria.minaei@gmail.com>
This commit is contained in:
Elliot 2022-05-25 15:22:41 -04:00 committed by Aria Minaei
parent 0690a85ae2
commit cfbb6ab043
4 changed files with 276 additions and 45 deletions

View file

@ -12,12 +12,16 @@ import {DOT_SIZE_PX} from './KeyframeDot'
import type KeyframeEditor from './KeyframeEditor' import type KeyframeEditor from './KeyframeEditor'
import usePopover from '@theatre/studio/uiComponents/Popover/usePopover' import usePopover from '@theatre/studio/uiComponents/Popover/usePopover'
import BasicPopover from '@theatre/studio/uiComponents/Popover/BasicPopover' import BasicPopover from '@theatre/studio/uiComponents/Popover/BasicPopover'
import CurveEditorPopover from './CurveEditorPopover/CurveEditorPopover' import CurveEditorPopover, {
isCurveEditorOpenD,
} from './CurveEditorPopover/CurveEditorPopover'
import selectedKeyframeIdsIfInSingleTrack from '@theatre/studio/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/selectedKeyframeIdsIfInSingleTrack' import selectedKeyframeIdsIfInSingleTrack from '@theatre/studio/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/selectedKeyframeIdsIfInSingleTrack'
import type {OpenFn} from '@theatre/studio/src/uiComponents/Popover/usePopover' import type {OpenFn} from '@theatre/studio/src/uiComponents/Popover/usePopover'
import type {Keyframe} from '@theatre/core/projects/store/types/SheetState_Historic' import type {Keyframe} from '@theatre/core/projects/store/types/SheetState_Historic'
import {usePointerCapturing} from '@theatre/studio/UIRoot/PointerCapturing' import {usePointerCapturing} from '@theatre/studio/UIRoot/PointerCapturing'
import {COLOR_POPOVER_BACK} from './CurveEditorPopover/colors' import {COLOR_POPOVER_BACK} from './CurveEditorPopover/colors'
import {useVal} from '@theatre/react'
import {isKeyframeConnectionInSelection} from '@theatre/studio/panels/SequenceEditorPanel/DopeSheet/selections'
const CONNECTOR_HEIGHT = DOT_SIZE_PX / 2 + 1 const CONNECTOR_HEIGHT = DOT_SIZE_PX / 2 + 1
const CONNECTOR_WIDTH_UNSCALED = 1000 const CONNECTOR_WIDTH_UNSCALED = 1000
@ -121,8 +125,17 @@ const Connector: React.FC<IProps> = (props) => {
const connectorLengthInUnitSpace = next.position - cur.position const connectorLengthInUnitSpace = next.position - cur.position
// The following two flags determine whether this connector
// is being edited as part of a selection using the curve
// editor popover
const isCurveEditorPopoverOpen = useVal(isCurveEditorOpenD)
const isInCurveEditorPopoverSelection =
isCurveEditorPopoverOpen &&
props.selection !== undefined &&
isKeyframeConnectionInSelection([cur, next], props.selection)
const themeValues: IConnectorThemeValues = { const themeValues: IConnectorThemeValues = {
isPopoverOpen, isPopoverOpen: isPopoverOpen || isInCurveEditorPopoverSelection || false,
isSelected: !!props.selection, isSelected: !!props.selection,
} }

View file

@ -1,5 +1,5 @@
import type {Pointer} from '@theatre/dataverse' import type {Pointer} from '@theatre/dataverse'
import {val} from '@theatre/dataverse' import {Box, prism} from '@theatre/dataverse'
import type {KeyboardEvent} from 'react' import type {KeyboardEvent} from 'react'
import React, { import React, {
useEffect, useEffect,
@ -27,6 +27,11 @@ import {COLOR_BASE, COLOR_POPOVER_BACK} from './colors'
import useRefAndState from '@theatre/studio/utils/useRefAndState' import useRefAndState from '@theatre/studio/utils/useRefAndState'
import type {Keyframe} from '@theatre/core/projects/store/types/SheetState_Historic' import type {Keyframe} from '@theatre/core/projects/store/types/SheetState_Historic'
import {useUIOptionGrid, Outcome} from './useUIOptionGrid' import {useUIOptionGrid, Outcome} from './useUIOptionGrid'
import {useVal} from '@theatre/react'
import {
flatSelectionTrackIds,
selectedKeyframeConnections,
} from '@theatre/studio/panels/SequenceEditorPanel/DopeSheet/selections'
const PRESET_COLUMNS = 3 const PRESET_COLUMNS = 3
const PRESET_SIZE = 53 const PRESET_SIZE = 53
@ -118,6 +123,7 @@ enum TextInputMode {
* a CSS cubic bezier args string to reflect the state of the curve. * a CSS cubic bezier args string to reflect the state of the curve.
*/ */
auto, auto,
multipleValues,
} }
type IProps = { type IProps = {
@ -137,15 +143,15 @@ const CurveEditorPopover: React.FC<IProps> = (props) => {
* popover closes. * popover closes.
*/ */
const tempTransaction = useRef<CommitOrDiscard | null>(null) const tempTransaction = useRef<CommitOrDiscard | null>(null)
useEffect( useEffect(() => {
() => const unlock = getLock()
// Clean-up function, called when this React component unmounts. // Clean-up function, called when this React component unmounts.
// When it unmounts, we want to commit edits that are outstanding // When it unmounts, we want to commit edits that are outstanding
() => { return () => {
tempTransaction.current?.commit() unlock()
}, tempTransaction.current?.commit()
[tempTransaction], }
) }, [tempTransaction])
////// Keyframe and trackdata ////// ////// Keyframe and trackdata //////
const {index, trackData} = props const {index, trackData} = props
@ -198,8 +204,11 @@ const CurveEditorPopover: React.FC<IProps> = (props) => {
TextInputMode.init, TextInputMode.init,
) )
useEffect(() => { useEffect(() => {
if (textInputMode === TextInputMode.auto) if (textInputMode === TextInputMode.auto) {
setInputValue(cssCubicBezierArgsFromHandles(easing)) setInputValue(cssCubicBezierArgsFromHandles(easing))
} else if (textInputMode === TextInputMode.multipleValues) {
if (inputValue !== '') setInputValue('')
}
}, [trackData]) }, [trackData])
// `edit` keeps track of the current edited state of the curve. // `edit` keeps track of the current edited state of the curve.
@ -211,12 +220,26 @@ const CurveEditorPopover: React.FC<IProps> = (props) => {
// When `preview` or `edit` change, use the `tempTransaction` to change the // When `preview` or `edit` change, use the `tempTransaction` to change the
// curve in Theate's data. // curve in Theate's data.
useMemo( useMemo(() => {
() => if (textInputMode !== TextInputMode.init)
setTempValue(tempTransaction, props, cur, next, preview ?? edit ?? ''), setTempValue(tempTransaction, props, cur, next, preview ?? edit ?? '')
[preview, edit], }, [preview, edit])
////// selection stuff //////
let selectedConnections: Array<[Keyframe, Keyframe]> = useVal(
selectedKeyframeConnections(
props.leaf.sheetObject.address.projectId,
props.leaf.sheetObject.address.sheetId,
props.selection,
),
) )
if (
selectedConnections.some(areConnectedKeyframesTheSameAs([cur, next])) &&
textInputMode === TextInputMode.init
) {
setTextInputMode(TextInputMode.multipleValues)
}
////// Curve editing reactivity ////// ////// Curve editing reactivity //////
const onCurveChange = (newHandles: CubicBezierHandles) => { const onCurveChange = (newHandles: CubicBezierHandles) => {
setTextInputMode(TextInputMode.auto) setTextInputMode(TextInputMode.auto)
@ -352,7 +375,11 @@ const CurveEditorPopover: React.FC<IProps> = (props) => {
<Grid> <Grid>
<SearchBox <SearchBox
value={inputValue} value={inputValue}
placeholder="Search presets..." placeholder={
textInputMode === TextInputMode.multipleValues
? 'Multiple easings selected'
: 'Search presets...'
}
onPaste={setTimeoutFunction(onInputChange)} onPaste={setTimeoutFunction(onInputChange)}
onChange={onInputChange} onChange={onInputChange}
ref={inputRef} ref={inputRef}
@ -407,38 +434,35 @@ function transactionSetCubicBezier(
props: IProps, props: IProps,
cur: Keyframe, cur: Keyframe,
next: Keyframe, next: Keyframe,
newHandles: CubicBezierHandles, handles: CubicBezierHandles,
): CommitOrDiscard { ): CommitOrDiscard {
return getStudio().tempTransaction(({stateEditors}) => { return getStudio().tempTransaction(({stateEditors}) => {
const {replaceKeyframes} = const {setTweenBetweenKeyframes} =
stateEditors.coreByProject.historic.sheetsById.sequence stateEditors.coreByProject.historic.sheetsById.sequence
replaceKeyframes({ // set easing for current connector
setTweenBetweenKeyframes({
...props.leaf.sheetObject.address, ...props.leaf.sheetObject.address,
snappingFunction: val(props.layoutP.sheet).getSequence()
.closestGridPosition,
trackId: props.leaf.trackId, trackId: props.leaf.trackId,
keyframes: [ keyframeIds: [cur.id, next.id],
{ handles,
...cur,
handles: [
cur.handles[0],
cur.handles[1],
newHandles[0],
newHandles[1],
],
},
{
...next,
handles: [
newHandles[2],
newHandles[3],
next.handles[2],
next.handles[3],
],
},
],
}) })
// set easings for selection
if (props.selection) {
for (const {objectKey, trackId, keyframeIds} of flatSelectionTrackIds(
props.selection,
)) {
setTweenBetweenKeyframes({
projectId: props.leaf.sheetObject.address.projectId,
sheetId: props.leaf.sheetObject.address.sheetId,
objectKey,
trackId,
keyframeIds,
handles,
})
}
}
}) })
} }
@ -454,3 +478,37 @@ export function mod(n: number, m: number) {
function setTimeoutFunction(f: Function, timeout?: number) { function setTimeoutFunction(f: Function, timeout?: number) {
return () => setTimeout(f, timeout) return () => setTimeout(f, timeout)
} }
function areConnectedKeyframesTheSameAs([kfcur1, kfnext1]: [
Keyframe,
Keyframe,
]) {
return ([kfcur2, kfnext2]: [Keyframe, Keyframe]) =>
kfcur1.handles[2] !== kfcur2.handles[2] ||
kfcur1.handles[3] !== kfcur2.handles[3] ||
kfnext1.handles[0] !== kfnext2.handles[0] ||
kfnext1.handles[1] !== kfnext2.handles[1]
}
const {isCurveEditorOpenD, getLock} = (() => {
let lastId = 0
const idsOfOpenCurveEditors = new Box<number[]>([])
return {
getLock() {
const id = lastId++
idsOfOpenCurveEditors.set([...idsOfOpenCurveEditors.get(), id])
return function unlock() {
idsOfOpenCurveEditors.set(
idsOfOpenCurveEditors.get().filter((cid) => cid !== id),
)
}
},
isCurveEditorOpenD: prism(() => {
return idsOfOpenCurveEditors.derivation.getValue().length > 0
}),
}
})()
export {isCurveEditorOpenD}

View file

@ -0,0 +1,106 @@
import type {IDerivation} from '@theatre/dataverse'
import {prism, val} from '@theatre/dataverse'
import type {
KeyframeId,
ObjectAddressKey,
ProjectId,
SequenceTrackId,
SheetId,
} from '@theatre/shared/utils/ids'
import getStudio from '@theatre/studio/getStudio'
import type {DopeSheetSelection} from '@theatre/studio/panels/SequenceEditorPanel/layout/layout'
import type {Keyframe} from '@theatre/core/projects/store/types/SheetState_Historic'
/**
* Keyframe connections are considered to be selected if the first
* keyframe in the connection is selected
*/
export function isKeyframeConnectionInSelection(
keyframeConnection: [Keyframe, Keyframe],
selection: DopeSheetSelection,
): boolean {
for (const {keyframeId} of flatSelectionKeyframeIds(selection)) {
if (keyframeConnection[0].id === keyframeId) return true
}
return false
}
export function selectedKeyframeConnections(
projectId: ProjectId,
sheetId: SheetId,
selection: DopeSheetSelection | undefined,
): IDerivation<Array<[Keyframe, Keyframe]>> {
return prism(() => {
if (selection === undefined) return []
let ckfs: Array<[Keyframe, Keyframe]> = []
for (const {objectKey, trackId} of flatSelectionTrackIds(selection)) {
const track = val(
getStudio().atomP.historic.coreByProject[projectId].sheetsById[sheetId]
.sequence.tracksByObject[objectKey].trackData[trackId],
)
if (track) {
ckfs = ckfs.concat(
keyframeConnections(track.keyframes).filter((kfc) =>
isKeyframeConnectionInSelection(kfc, selection),
),
)
}
}
return ckfs
})
}
export function keyframeConnections(
keyframes: Array<Keyframe>,
): Array<[Keyframe, Keyframe]> {
return keyframes
.map((kf, i) => [kf, keyframes[i + 1]] as [Keyframe, Keyframe])
.slice(0, -1) // remmove the last entry because it is [kf, undefined]
}
export function flatSelectionKeyframeIds(selection: DopeSheetSelection): Array<{
objectKey: ObjectAddressKey
trackId: SequenceTrackId
keyframeId: KeyframeId
}> {
const result = []
for (const [objectKey, maybeObjectRecord] of Object.entries(
selection?.byObjectKey ?? {},
)) {
for (const [trackId, maybeTrackRecord] of Object.entries(
maybeObjectRecord?.byTrackId ?? {},
)) {
for (const keyframeId of Object.keys(
maybeTrackRecord?.byKeyframeId ?? {},
)) {
result.push({objectKey, trackId, keyframeId})
}
}
}
return result
}
export function flatSelectionTrackIds(selection: DopeSheetSelection): Array<{
objectKey: ObjectAddressKey
trackId: SequenceTrackId
keyframeIds: Array<KeyframeId>
}> {
const result = []
for (const [objectKey, maybeObjectRecord] of Object.entries(
selection?.byObjectKey ?? {},
)) {
for (const [trackId, maybeTrackRecord] of Object.entries(
maybeObjectRecord?.byTrackId ?? {},
)) {
result.push({
objectKey,
trackId,
keyframeIds: Object.keys(maybeTrackRecord?.byKeyframeId ?? {}),
})
}
}
return result
}

View file

@ -755,8 +755,8 @@ namespace stateEditors {
if (!track) return if (!track) return
const initialKeyframes = current(track.keyframes) const initialKeyframes = current(track.keyframes)
const selectedKeyframes = initialKeyframes.filter( const selectedKeyframes = initialKeyframes.filter((kf) =>
(kf) => p.keyframeIds.indexOf(kf.id) !== -1, p.keyframeIds.includes(kf.id),
) )
const transformed = selectedKeyframes.map((untransformedKf) => { const transformed = selectedKeyframes.map((untransformedKf) => {
@ -770,6 +770,60 @@ namespace stateEditors {
replaceKeyframes({...p, keyframes: transformed}) replaceKeyframes({...p, keyframes: transformed})
} }
/**
* Sets the easing between two keyframes
*/
export function setTweenBetweenKeyframes(
p: WithoutSheetInstance<SheetObjectAddress> & {
trackId: SequenceTrackId
keyframeIds: KeyframeId[]
handles: [number, number, number, number]
},
) {
const track = _getTrack(p)
if (!track) return
track.keyframes = track.keyframes.map((kf, i) => {
const prevKf = track.keyframes[i - 1]
const isBeingEdited = p.keyframeIds.includes(kf.id)
const isAfterEditedKeyframe = p.keyframeIds.includes(prevKf?.id)
if (isBeingEdited && !isAfterEditedKeyframe) {
return {
...kf,
handles: [
kf.handles[0],
kf.handles[1],
p.handles[0],
p.handles[1],
],
}
} else if (isBeingEdited && isAfterEditedKeyframe) {
return {
...kf,
handles: [
p.handles[2],
p.handles[3],
p.handles[0],
p.handles[1],
],
}
} else if (isAfterEditedKeyframe) {
return {
...kf,
handles: [
p.handles[2],
p.handles[3],
kf.handles[2],
kf.handles[3],
],
}
} else {
return kf
}
})
}
export function deleteKeyframes( export function deleteKeyframes(
p: WithoutSheetInstance<SheetObjectAddress> & { p: WithoutSheetInstance<SheetObjectAddress> & {
trackId: SequenceTrackId trackId: SequenceTrackId