multi-curve curve popover editing (#176)
Co-authored-by: Aria Minaei <aria.minaei@gmail.com>
This commit is contained in:
parent
0690a85ae2
commit
cfbb6ab043
4 changed files with 276 additions and 45 deletions
|
@ -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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
|
@ -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
|
||||||
|
|
Loading…
Reference in a new issue