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 usePopover from '@theatre/studio/uiComponents/Popover/usePopover'
|
||||
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 type {OpenFn} from '@theatre/studio/src/uiComponents/Popover/usePopover'
|
||||
import type {Keyframe} from '@theatre/core/projects/store/types/SheetState_Historic'
|
||||
import {usePointerCapturing} from '@theatre/studio/UIRoot/PointerCapturing'
|
||||
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_WIDTH_UNSCALED = 1000
|
||||
|
@ -121,8 +125,17 @@ const Connector: React.FC<IProps> = (props) => {
|
|||
|
||||
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 = {
|
||||
isPopoverOpen,
|
||||
isPopoverOpen: isPopoverOpen || isInCurveEditorPopoverSelection || false,
|
||||
isSelected: !!props.selection,
|
||||
}
|
||||
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import type {Pointer} from '@theatre/dataverse'
|
||||
import {val} from '@theatre/dataverse'
|
||||
import {Box, prism} from '@theatre/dataverse'
|
||||
import type {KeyboardEvent} from 'react'
|
||||
import React, {
|
||||
useEffect,
|
||||
|
@ -27,6 +27,11 @@ import {COLOR_BASE, COLOR_POPOVER_BACK} from './colors'
|
|||
import useRefAndState from '@theatre/studio/utils/useRefAndState'
|
||||
import type {Keyframe} from '@theatre/core/projects/store/types/SheetState_Historic'
|
||||
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_SIZE = 53
|
||||
|
@ -118,6 +123,7 @@ enum TextInputMode {
|
|||
* a CSS cubic bezier args string to reflect the state of the curve.
|
||||
*/
|
||||
auto,
|
||||
multipleValues,
|
||||
}
|
||||
|
||||
type IProps = {
|
||||
|
@ -137,15 +143,15 @@ const CurveEditorPopover: React.FC<IProps> = (props) => {
|
|||
* popover closes.
|
||||
*/
|
||||
const tempTransaction = useRef<CommitOrDiscard | null>(null)
|
||||
useEffect(
|
||||
() =>
|
||||
useEffect(() => {
|
||||
const unlock = getLock()
|
||||
// Clean-up function, called when this React component unmounts.
|
||||
// When it unmounts, we want to commit edits that are outstanding
|
||||
() => {
|
||||
return () => {
|
||||
unlock()
|
||||
tempTransaction.current?.commit()
|
||||
},
|
||||
[tempTransaction],
|
||||
)
|
||||
}
|
||||
}, [tempTransaction])
|
||||
|
||||
////// Keyframe and trackdata //////
|
||||
const {index, trackData} = props
|
||||
|
@ -198,8 +204,11 @@ const CurveEditorPopover: React.FC<IProps> = (props) => {
|
|||
TextInputMode.init,
|
||||
)
|
||||
useEffect(() => {
|
||||
if (textInputMode === TextInputMode.auto)
|
||||
if (textInputMode === TextInputMode.auto) {
|
||||
setInputValue(cssCubicBezierArgsFromHandles(easing))
|
||||
} else if (textInputMode === TextInputMode.multipleValues) {
|
||||
if (inputValue !== '') setInputValue('')
|
||||
}
|
||||
}, [trackData])
|
||||
|
||||
// `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
|
||||
// curve in Theate's data.
|
||||
useMemo(
|
||||
() =>
|
||||
setTempValue(tempTransaction, props, cur, next, preview ?? edit ?? ''),
|
||||
[preview, edit],
|
||||
useMemo(() => {
|
||||
if (textInputMode !== TextInputMode.init)
|
||||
setTempValue(tempTransaction, props, cur, next, 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 //////
|
||||
const onCurveChange = (newHandles: CubicBezierHandles) => {
|
||||
setTextInputMode(TextInputMode.auto)
|
||||
|
@ -352,7 +375,11 @@ const CurveEditorPopover: React.FC<IProps> = (props) => {
|
|||
<Grid>
|
||||
<SearchBox
|
||||
value={inputValue}
|
||||
placeholder="Search presets..."
|
||||
placeholder={
|
||||
textInputMode === TextInputMode.multipleValues
|
||||
? 'Multiple easings selected'
|
||||
: 'Search presets...'
|
||||
}
|
||||
onPaste={setTimeoutFunction(onInputChange)}
|
||||
onChange={onInputChange}
|
||||
ref={inputRef}
|
||||
|
@ -407,38 +434,35 @@ function transactionSetCubicBezier(
|
|||
props: IProps,
|
||||
cur: Keyframe,
|
||||
next: Keyframe,
|
||||
newHandles: CubicBezierHandles,
|
||||
handles: CubicBezierHandles,
|
||||
): CommitOrDiscard {
|
||||
return getStudio().tempTransaction(({stateEditors}) => {
|
||||
const {replaceKeyframes} =
|
||||
const {setTweenBetweenKeyframes} =
|
||||
stateEditors.coreByProject.historic.sheetsById.sequence
|
||||
|
||||
replaceKeyframes({
|
||||
// set easing for current connector
|
||||
setTweenBetweenKeyframes({
|
||||
...props.leaf.sheetObject.address,
|
||||
snappingFunction: val(props.layoutP.sheet).getSequence()
|
||||
.closestGridPosition,
|
||||
trackId: props.leaf.trackId,
|
||||
keyframes: [
|
||||
{
|
||||
...cur,
|
||||
handles: [
|
||||
cur.handles[0],
|
||||
cur.handles[1],
|
||||
newHandles[0],
|
||||
newHandles[1],
|
||||
],
|
||||
},
|
||||
{
|
||||
...next,
|
||||
handles: [
|
||||
newHandles[2],
|
||||
newHandles[3],
|
||||
next.handles[2],
|
||||
next.handles[3],
|
||||
],
|
||||
},
|
||||
],
|
||||
keyframeIds: [cur.id, next.id],
|
||||
handles,
|
||||
})
|
||||
|
||||
// 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) {
|
||||
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
|
||||
const initialKeyframes = current(track.keyframes)
|
||||
|
||||
const selectedKeyframes = initialKeyframes.filter(
|
||||
(kf) => p.keyframeIds.indexOf(kf.id) !== -1,
|
||||
const selectedKeyframes = initialKeyframes.filter((kf) =>
|
||||
p.keyframeIds.includes(kf.id),
|
||||
)
|
||||
|
||||
const transformed = selectedKeyframes.map((untransformedKf) => {
|
||||
|
@ -770,6 +770,60 @@ namespace stateEditors {
|
|||
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(
|
||||
p: WithoutSheetInstance<SheetObjectAddress> & {
|
||||
trackId: SequenceTrackId
|
||||
|
|
Loading…
Reference in a new issue