From cfbb6ab0437fdf26888f1c37011d35e3d8be2e22 Mon Sep 17 00:00:00 2001 From: Elliot Date: Wed, 25 May 2022 15:22:41 -0400 Subject: [PATCH] multi-curve curve popover editing (#176) Co-authored-by: Aria Minaei --- .../KeyframeEditor/Connector.tsx | 17 ++- .../CurveEditorPopover/CurveEditorPopover.tsx | 140 +++++++++++++----- .../DopeSheet/selections.ts | 106 +++++++++++++ theatre/studio/src/store/stateEditors.ts | 58 +++++++- 4 files changed, 276 insertions(+), 45 deletions(-) create mode 100644 theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/selections.ts diff --git a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/Connector.tsx b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/Connector.tsx index 42dc23a..b86c572 100644 --- a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/Connector.tsx +++ b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/Connector.tsx @@ -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 = (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, } diff --git a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/CurveEditorPopover/CurveEditorPopover.tsx b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/CurveEditorPopover/CurveEditorPopover.tsx index 8f17179..cf102f6 100644 --- a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/CurveEditorPopover/CurveEditorPopover.tsx +++ b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/CurveEditorPopover/CurveEditorPopover.tsx @@ -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 = (props) => { * popover closes. */ const tempTransaction = useRef(null) - useEffect( - () => - // Clean-up function, called when this React component unmounts. - // When it unmounts, we want to commit edits that are outstanding - () => { - tempTransaction.current?.commit() - }, - [tempTransaction], - ) + 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]) ////// Keyframe and trackdata ////// const {index, trackData} = props @@ -198,8 +204,11 @@ const CurveEditorPopover: React.FC = (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 = (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 = (props) => { { - 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([]) + + 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} diff --git a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/selections.ts b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/selections.ts new file mode 100644 index 0000000..17dadc1 --- /dev/null +++ b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/selections.ts @@ -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> { + 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, +): 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 +}> { + 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 +} diff --git a/theatre/studio/src/store/stateEditors.ts b/theatre/studio/src/store/stateEditors.ts index 132638a..c6c743b 100644 --- a/theatre/studio/src/store/stateEditors.ts +++ b/theatre/studio/src/store/stateEditors.ts @@ -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 & { + 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 & { trackId: SequenceTrackId