From 564e54c3144599bbdaac752a19cc0d5fee3fd367 Mon Sep 17 00:00:00 2001 From: Elliot Date: Sun, 29 May 2022 07:12:30 -0400 Subject: [PATCH] Single tween editor for aggregate rows (#178) Co-authored-by: Cole Lawrence Co-authored-by: Aria Minaei --- .../src/derivations/AbstractDerivation.ts | 25 +++ .../AggregateKeyframeEditor.tsx | 169 +++++++++++++-- .../KeyframeEditor/BasicKeyframeConnector.tsx | 101 ++++++--- .../CurveEditorPopover/CurveEditorPopover.tsx | 202 +++++++++--------- .../CurveEditorPopover/CurveSegmentEditor.tsx | 100 +++++---- .../Right/keyframeRowUI/ConnectorLine.tsx | 3 - .../DopeSheet/selections.ts | 40 +++- theatre/studio/src/store/stateEditors.ts | 40 +++- .../src/uiComponents/Popover/usePopover.tsx | 99 +++++---- 9 files changed, 535 insertions(+), 244 deletions(-) diff --git a/packages/dataverse/src/derivations/AbstractDerivation.ts b/packages/dataverse/src/derivations/AbstractDerivation.ts index dcce823..0a1c8b4 100644 --- a/packages/dataverse/src/derivations/AbstractDerivation.ts +++ b/packages/dataverse/src/derivations/AbstractDerivation.ts @@ -179,6 +179,31 @@ export default abstract class AbstractDerivation implements IDerivation { * Gets the current value of the derivation. If the value is stale, it causes the derivation to freshen. */ getValue(): V { + /** + * TODO We should prevent (or warn about) a common mistake users make, which is reading the value of + * a derivation in the body of a react component (e.g. `der.getValue()` (often via `val()`) instead of `useVal()` + * or `uesPrism()`). + * + * Although that's the most common example of this mistake, you can also find it outside of react components. + * Basically the user runs `der.getValue()` assuming the read is detected by a wrapping prism when it's not. + * + * Sometiems the derivation isn't even hot when the user assumes it is. + * + * We can fix this type of mistake by: + * 1. Warning the user when they call `getValue()` on a cold derivation. + * 2. Warning the user about calling `getValue()` on a hot-but-stale derivation + * if `getValue()` isn't called by a known mechanism like a `DerivationEmitter`. + * + * Design constraints: + * - This fix should not have a perf-penalty in production. Perhaps use a global flag + `process.env.NODE_ENV !== 'production'` + * to enable it. + * - In the case of `DerivationValuelessEmitter`, we don't control when the user calls + * `getValue()` (as opposed to `DerivationEmitter` which calls `getValue()` directly). + * Perhaps we can disable the check in that case. + * - Probably the best place to add this check is right here in this method plus some changes to `reportResulutionStart()`, + * which would have to be changed to let the caller know if there is an actual collector (a prism) + * present in its stack. + */ reportResolutionStart(this) if (!this._isFresh) { diff --git a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/AggregatedKeyframeTrack/AggregateKeyframeEditor.tsx b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/AggregatedKeyframeTrack/AggregateKeyframeEditor.tsx index 57aa6c3..ddad2e0 100644 --- a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/AggregatedKeyframeTrack/AggregateKeyframeEditor.tsx +++ b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/AggregatedKeyframeTrack/AggregateKeyframeEditor.tsx @@ -11,6 +11,7 @@ import type { SequenceEditorTree_SheetObject, } from '@theatre/studio/panels/SequenceEditorPanel/layout/tree' import type {Pointer} from '@theatre/dataverse' +import {prism} from '@theatre/dataverse' import {val} from '@theatre/dataverse' import React from 'react' import styled from 'styled-components' @@ -20,11 +21,28 @@ import {AggregateKeyframePositionIsSelected} from './AggregatedKeyframeTrack' import type {KeyframeWithTrack} from '@theatre/studio/panels/SequenceEditorPanel/DopeSheet/Right/collectAggregateKeyframes' import {DopeSnapHitZoneUI} from '@theatre/studio/panels/SequenceEditorPanel/RightOverlay/DopeSnapHitZoneUI' import {absoluteDims} from '@theatre/studio/utils/absoluteDims' +import BasicPopover from '@theatre/studio/uiComponents/Popover/BasicPopover' +import {usePointerCapturing} from '@theatre/studio/UIRoot/PointerCapturing' +import usePopover from '@theatre/studio/uiComponents/Popover/usePopover' +import CurveEditorPopover, { + isConnectionEditingInCurvePopover, +} from '@theatre/studio/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/CurveEditorPopover/CurveEditorPopover' +import useRefAndState from '@theatre/studio/utils/useRefAndState' +import {usePrism} from '@theatre/react' +import {selectedKeyframeConnections} from '@theatre/studio/panels/SequenceEditorPanel/DopeSheet/selections' +import type {SheetObjectAddress} from '@theatre/shared/utils/addresses' + +const POPOVER_MARGIN_PX = 5 const AggregateKeyframeEditorContainer = styled.div` position: absolute; ` +const EasingPopoverWrapper = styled(BasicPopover)` + --popover-outer-stroke: transparent; + --popover-inner-stroke: rgba(26, 28, 30, 0.97); +` + const noConnector = <> export type IAggregateKeyframesAtPosition = { @@ -41,6 +59,12 @@ export type IAggregateKeyframesAtPosition = { }[] } +type AggregatedKeyframeConnection = SheetObjectAddress & { + trackId: SequenceTrackId + left: Keyframe + right: Keyframe +} + export type IAggregateKeyframeEditorProps = { index: number aggregateKeyframes: IAggregateKeyframesAtPosition[] @@ -51,24 +75,50 @@ export type IAggregateKeyframeEditorProps = { selection: undefined | DopeSheetSelection } +/** + * TODO we're spending a lot of cycles on each render of each aggreagte keyframes. + * + * Each keyframe node is doing O(N) operations, N being the number of underlying + * keyframes it represetns. + * + * The biggest example is the `isConnectionEditingInCurvePopover()` call which is run + * for every underlying keyframe, every time this component is rendered. + * + * We can optimize this away by doing all of this work _once_ when a curve editor popover + * is open. This would require having some kind of stable identity for each aggregate row. + * Let's defer that work until other interactive keyframe editing PRs are merged in. + */ const AggregateKeyframeEditor: React.VFC = ( props, ) => { - const {index, aggregateKeyframes} = props - const cur = aggregateKeyframes[index] - const next = aggregateKeyframes[index + 1] - const connected = - next && cur.keyframes.length === next.keyframes.length - ? // all keyframes are same in the next position - cur.keyframes.every( - ({track}, ind) => next.keyframes[ind].track === track, - ) && { - length: next.position - cur.position, - selected: - cur.selected === AggregateKeyframePositionIsSelected.AllSelected && - next.selected === AggregateKeyframePositionIsSelected.AllSelected, - } - : null + const {cur, connected, isAggregateEditingInCurvePopover} = + useAggregateKeyframeEditorUtils(props) + + const {isPointerBeingCaptured} = usePointerCapturing( + 'AggregateKeyframeEditor Connector', + ) + + const [popoverNode, openPopover, closePopover] = usePopover( + () => { + const rightDims = val(props.layoutP.rightDims) + + return { + debugName: 'Connector', + closeWhenPointerIsDistant: !isPointerBeingCaptured(), + constraints: { + minX: rightDims.screenX + POPOVER_MARGIN_PX, + maxX: rightDims.screenX + rightDims.width - POPOVER_MARGIN_PX, + }, + } + }, + () => { + return ( + + ) + }, + ) + + const [nodeRef, node] = useRefAndState(null) return ( = ( /> {connected ? ( { + if (node) openPopover(e, node) + }} /> ) : ( noConnector )} + {popoverNode} ) } +function useAggregateKeyframeEditorUtils( + props: Pick< + IAggregateKeyframeEditorProps, + 'index' | 'aggregateKeyframes' | 'selection' | 'viewModel' + >, +) { + return usePrism(() => { + const {index, aggregateKeyframes} = props + + const cur = aggregateKeyframes[index] + const next = aggregateKeyframes[index + 1] + + const curAndNextAggregateKeyframesMatch = + next && + cur.keyframes.length === next.keyframes.length && + cur.keyframes.every(({track}, ind) => next.keyframes[ind].track === track) + + const connected = curAndNextAggregateKeyframesMatch + ? { + length: next.position - cur.position, + selected: + cur.selected === AggregateKeyframePositionIsSelected.AllSelected && + next.selected === AggregateKeyframePositionIsSelected.AllSelected, + } + : null + + const aggregatedConnections: AggregatedKeyframeConnection[] = !connected + ? [] + : cur.keyframes.map(({kf, track}, i) => ({ + ...props.viewModel.sheetObject.address, + trackId: track.id, + left: kf, + right: next.keyframes[i].kf, + })) + + const {projectId, sheetId} = props.viewModel.sheetObject.address + + const selectedConnections = prism + .memo( + 'selectedConnections', + () => + selectedKeyframeConnections( + props.viewModel.sheetObject.address.projectId, + props.viewModel.sheetObject.address.sheetId, + props.selection, + ), + [projectId, sheetId, props.selection], + ) + .getValue() + + const allConnections = [...aggregatedConnections, ...selectedConnections] + + const isAggregateEditingInCurvePopover = aggregatedConnections.every( + (con) => isConnectionEditingInCurvePopover(con), + ) + + return {cur, connected, isAggregateEditingInCurvePopover, allConnections} + }, []) +} + +const AggregateCurveEditorPopover: React.FC< + IAggregateKeyframeEditorProps & {closePopover: (reason: string) => void} +> = React.forwardRef((props, ref) => { + const {allConnections} = useAggregateKeyframeEditorUtils(props) + + return ( + + + + ) +}) + const DOT_SIZE_PX = 16 const DOT_HOVER_SIZE_PX = DOT_SIZE_PX + 5 diff --git a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/BasicKeyframeConnector.tsx b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/BasicKeyframeConnector.tsx index e0d3c1e..3e8be3a 100644 --- a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/BasicKeyframeConnector.tsx +++ b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/BasicKeyframeConnector.tsx @@ -9,7 +9,7 @@ import {useMemo, useRef} from 'react' import usePopover from '@theatre/studio/uiComponents/Popover/usePopover' import BasicPopover from '@theatre/studio/uiComponents/Popover/BasicPopover' import CurveEditorPopover, { - isCurveEditorOpenD, + isConnectionEditingInCurvePopover, } from './CurveEditorPopover/CurveEditorPopover' import selectedKeyframeIdsIfInSingleTrack from '@theatre/studio/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/selectedKeyframeIdsIfInSingleTrack' import type {OpenFn} from '@theatre/studio/src/uiComponents/Popover/usePopover' @@ -19,13 +19,11 @@ import type {ISingleKeyframeEditorProps} from './SingleKeyframeEditor' import type {IConnectorThemeValues} from '@theatre/studio/panels/SequenceEditorPanel/DopeSheet/Right/keyframeRowUI/ConnectorLine' import {ConnectorLine} from '@theatre/studio/panels/SequenceEditorPanel/DopeSheet/Right/keyframeRowUI/ConnectorLine' import {COLOR_POPOVER_BACK} from './CurveEditorPopover/colors' -import {useVal} from '@theatre/react' -import {isKeyframeConnectionInSelection} from '@theatre/studio/panels/SequenceEditorPanel/DopeSheet/selections' +import {usePrism} from '@theatre/react' +import type {KeyframeConnectionWithAddress} from '@theatre/studio/panels/SequenceEditorPanel/DopeSheet/selections' +import {selectedKeyframeConnections} from '@theatre/studio/panels/SequenceEditorPanel/DopeSheet/selections' -const CONNECTOR_HEIGHT = DOT_SIZE_PX / 2 + 1 -const CONNECTOR_WIDTH_UNSCALED = 1000 import styled from 'styled-components' -import {DOT_SIZE_PX} from './SingleKeyframeDot' const POPOVER_MARGIN = 5 @@ -49,23 +47,19 @@ const BasicKeyframeConnector: React.VFC = ( 'KeyframeEditor Connector', ) - const rightDims = val(props.layoutP.rightDims) const [popoverNode, openPopover, closePopover, isPopoverOpen] = usePopover( - { - debugName: 'Connector', - closeWhenPointerIsDistant: !isPointerBeingCaptured(), - constraints: { - minX: rightDims.screenX + POPOVER_MARGIN, - maxX: rightDims.screenX + rightDims.width - POPOVER_MARGIN, - }, - }, () => { - return ( - - - - ) + const rightDims = val(props.layoutP.rightDims) + return { + debugName: 'Connector', + closeWhenPointerIsDistant: !isPointerBeingCaptured(), + constraints: { + minX: rightDims.screenX + POPOVER_MARGIN, + maxX: rightDims.screenX + rightDims.width - POPOVER_MARGIN, + }, + } }, + () => , ) const [contextMenu] = useConnectorContextMenu( @@ -79,26 +73,27 @@ const BasicKeyframeConnector: React.VFC = ( 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 isInCurveEditorPopoverSelection = usePrism( + () => + isConnectionEditingInCurvePopover({ + ...props.leaf.sheetObject.address, + trackId: props.leaf.trackId, + left: cur, + right: next, + }), + [props.leaf.sheetObject.address, props.leaf.trackId, cur, next], + ) const themeValues: IConnectorThemeValues = { - isPopoverOpen: isPopoverOpen || isInCurveEditorPopoverSelection || false, - isSelected: !!props.selection, + isPopoverOpen: isInCurveEditorPopoverSelection, + isSelected: props.selection !== undefined, } return ( { if (node) openPopover(e, node) }} @@ -110,6 +105,48 @@ const BasicKeyframeConnector: React.VFC = ( } export default BasicKeyframeConnector +const SingleCurveEditorPopover: React.FC< + IBasicKeyframeConnectorProps & {closePopover: (reason: string) => void} +> = React.forwardRef((props, ref) => { + const {index, trackData, selection} = props + const cur = trackData.keyframes[index] + const next = trackData.keyframes[index + 1] + + const trackId = props.leaf.trackId + const address = props.leaf.sheetObject.address + + const selectedConnections = usePrism( + () => + selectedKeyframeConnections( + address.projectId, + address.sheetId, + selection, + ).getValue(), + [address, selection], + ) + + const curveConnection: KeyframeConnectionWithAddress = { + left: cur, + right: next, + trackId, + ...address, + } + + return ( + + + + ) +}) + function useDragKeyframe( node: HTMLDivElement | null, props: IBasicKeyframeConnectorProps, 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 f6d226a..dc33707 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,4 +1,3 @@ -import type {Pointer} from '@theatre/dataverse' import {Box, prism} from '@theatre/dataverse' import type {KeyboardEvent} from 'react' import React, { @@ -10,10 +9,8 @@ import React, { } from 'react' import styled from 'styled-components' import fuzzy from 'fuzzy' -import type {SequenceEditorPanelLayout} from '@theatre/studio/panels/SequenceEditorPanel/layout/layout' import getStudio from '@theatre/studio/getStudio' import type {CommitOrDiscard} from '@theatre/studio/StudioStore/StudioStore' -import type {ISingleKeyframeEditorProps} from '@theatre/studio/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/SingleKeyframeEditor' import CurveSegmentEditor from './CurveSegmentEditor' import EasingOption from './EasingOption' import type {CSSCubicBezierArgsString, CubicBezierHandles} from './shared' @@ -27,11 +24,7 @@ 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' +import type {KeyframeConnectionWithAddress} from '@theatre/studio/panels/SequenceEditorPanel/DopeSheet/selections' const PRESET_COLUMNS = 3 const PRESET_SIZE = 53 @@ -126,16 +119,22 @@ enum TextInputMode { multipleValues, } -type IProps = { - layoutP: Pointer - +type ICurveEditorPopoverProps = { /** * Called when user hits enter/escape */ onRequestClose: (reason: string) => void -} & ISingleKeyframeEditorProps -const CurveEditorPopover: React.FC = (props) => { + curveConnection: KeyframeConnectionWithAddress + additionalConnections: Array +} + +const CurveEditorPopover: React.VFC = (props) => { + const allConnections = useMemo( + () => [props.curveConnection, ...props.additionalConnections], + [props.curveConnection, ...props.additionalConnections], + ) + ////// `tempTransaction` ////// /* * `tempTransaction` is used for all edits in this popover. The transaction @@ -144,7 +143,7 @@ const CurveEditorPopover: React.FC = (props) => { */ const tempTransaction = useRef(null) useEffect(() => { - const unlock = getLock() + const unlock = getLock(allConnections) // Clean-up function, called when this React component unmounts. // When it unmounts, we want to commit edits that are outstanding return () => { @@ -154,14 +153,11 @@ const CurveEditorPopover: React.FC = (props) => { }, [tempTransaction]) ////// Keyframe and trackdata ////// - const {index, trackData} = props - const cur = trackData.keyframes[index] - const next = trackData.keyframes[index + 1] const easing: CubicBezierHandles = [ - trackData.keyframes[index].handles[2], - trackData.keyframes[index].handles[3], - trackData.keyframes[index + 1].handles[0], - trackData.keyframes[index + 1].handles[1], + props.curveConnection.left.handles[2], + props.curveConnection.left.handles[3], + props.curveConnection.right.handles[0], + props.curveConnection.right.handles[1], ] ////// Text input data and reactivity ////// @@ -209,7 +205,7 @@ const CurveEditorPopover: React.FC = (props) => { } else if (textInputMode === TextInputMode.multipleValues) { if (inputValue !== '') setInputValue('') } - }, [trackData]) + }, allConnections) // `edit` keeps track of the current edited state of the curve. const [edit, setEdit] = useState( @@ -220,21 +216,19 @@ const CurveEditorPopover: React.FC = (props) => { // When `preview` or `edit` change, use the `tempTransaction` to change the // curve in Theate's data. - 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, - ), - ) + useEffect(() => { + if ( + textInputMode !== TextInputMode.init && + textInputMode !== TextInputMode.multipleValues + ) + setTempValue(tempTransaction, allConnections, preview ?? edit ?? '') + }, [preview, edit, textInputMode]) + ////// selection stuff ////// if ( - selectedConnections.some(areConnectedKeyframesTheSameAs([cur, next])) && + allConnections.some( + areConnectedKeyframesTheSameAs(props.curveConnection), + ) && textInputMode === TextInputMode.init ) { setTextInputMode(TextInputMode.multipleValues) @@ -289,7 +283,7 @@ const CurveEditorPopover: React.FC = (props) => { setPreview(item.value) const onEasingOptionMouseOut = () => setPreview(null) const onSelectEasingOption = (item: {label: string; value: string}) => { - setTempValue(tempTransaction, props, cur, next, item.value) + setTempValue(tempTransaction, allConnections, item.value) props.onRequestClose('selected easing option') return Outcome.Handled @@ -396,7 +390,8 @@ const CurveEditorPopover: React.FC = (props) => { inputRef.current?.focus()}> @@ -409,18 +404,19 @@ export default CurveEditorPopover function setTempValue( tempTransaction: React.MutableRefObject, - props: IProps, - cur: Keyframe, - next: Keyframe, - newCurve: string, + keyframeConnections: Array, + newCurveCssCubicBezier: string, ): void { tempTransaction.current?.discard() tempTransaction.current = null - const handles = handlesFromCssCubicBezierArgs(newCurve) + const handles = handlesFromCssCubicBezierArgs(newCurveCssCubicBezier) if (handles === null) return - tempTransaction.current = transactionSetCubicBezier(props, cur, next, handles) + tempTransaction.current = transactionSetCubicBezier( + keyframeConnections, + handles, + ) } function discardTempValue( @@ -431,37 +427,37 @@ function discardTempValue( } function transactionSetCubicBezier( - props: IProps, - cur: Keyframe, - next: Keyframe, + keyframeConnections: Array, handles: CubicBezierHandles, ): CommitOrDiscard { return getStudio().tempTransaction(({stateEditors}) => { - const {setTweenBetweenKeyframes} = + const {setHandlesForKeyframe} = stateEditors.coreByProject.historic.sheetsById.sequence - // set easing for current connector - setTweenBetweenKeyframes({ - ...props.leaf.sheetObject.address, - trackId: props.leaf.trackId, - 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, - }) - } + for (const { + projectId, + sheetId, + objectKey, + trackId, + left, + right, + } of keyframeConnections) { + setHandlesForKeyframe({ + projectId, + sheetId, + objectKey, + trackId, + keyframeId: left.id, + start: [handles[0], handles[1]], + }) + setHandlesForKeyframe({ + projectId, + sheetId, + objectKey, + trackId, + keyframeId: right.id, + end: [handles[2], handles[3]], + }) } }) } @@ -479,36 +475,48 @@ 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] +function areConnectedKeyframesTheSameAs({ + left: left1, + right: right1, +}: { + left: Keyframe + right: Keyframe +}) { + return ({left: left2, right: right2}: {left: Keyframe; right: Keyframe}) => + left1.handles[2] !== left2.handles[2] || + left1.handles[3] !== left2.handles[3] || + right1.handles[0] !== right2.handles[0] || + right1.handles[1] !== right2.handles[1] } -const {isCurveEditorOpenD, getLock} = (() => { - let lastId = 0 - const idsOfOpenCurveEditors = new Box([]) +const {isCurveEditorOpenD, isConnectionEditingInCurvePopover, getLock} = + (() => { + const connectionsInCurvePopoverEdit = new Box< + Array + >([]) - return { - getLock() { - const id = lastId++ - idsOfOpenCurveEditors.set([...idsOfOpenCurveEditors.get(), id]) + return { + getLock(connections: Array) { + connectionsInCurvePopoverEdit.set(connections) - return function unlock() { - idsOfOpenCurveEditors.set( - idsOfOpenCurveEditors.get().filter((cid) => cid !== id), - ) - } - }, - isCurveEditorOpenD: prism(() => { - return idsOfOpenCurveEditors.derivation.getValue().length > 0 - }), - } -})() + return function unlock() { + connectionsInCurvePopoverEdit.set([]) + } + }, + isCurveEditorOpenD: prism(() => { + return connectionsInCurvePopoverEdit.derivation.getValue().length > 0 + }), + // must be run in a prism + isConnectionEditingInCurvePopover(con: KeyframeConnectionWithAddress) { + prism.ensurePrism() + return connectionsInCurvePopoverEdit.derivation + .getValue() + .some( + ({left, right}) => + con.left.id === left.id && con.right.id === right.id, + ) + }, + } + })() -export {isCurveEditorOpenD} +export {isCurveEditorOpenD, isConnectionEditingInCurvePopover} diff --git a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/CurveEditorPopover/CurveSegmentEditor.tsx b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/CurveEditorPopover/CurveSegmentEditor.tsx index b578873..f250002 100644 --- a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/CurveEditorPopover/CurveSegmentEditor.tsx +++ b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/CurveEditorPopover/CurveSegmentEditor.tsx @@ -2,12 +2,12 @@ import React from 'react' import useDrag from '@theatre/studio/uiComponents/useDrag' import useRefAndState from '@theatre/studio/utils/useRefAndState' import clamp from 'lodash-es/clamp' -import type CurveEditorPopover from './CurveEditorPopover' import styled from 'styled-components' import {pointerEventsAutoInNormalMode} from '@theatre/studio/css' import type {CubicBezierHandles} from './shared' import {useFreezableMemo} from './useFreezableMemo' import {COLOR_BASE} from './colors' +import type {KeyframeConnectionWithAddress} from '@theatre/studio/panels/SequenceEditorPanel/DopeSheet/selections' // Defines the dimensions of the SVG viewbox space const VIEWBOX_PADDING = 0.12 @@ -30,6 +30,13 @@ const CONTROL_COLOR = '#B3B3B3' const HANDLE_COLOR = '#3eaaa4' const HANDLE_HOVER_COLOR = '#67dfd8' +const BACKGROUND_CURVE_COLORS = [ + 'goldenrod', + 'cornflowerblue', + 'dodgerblue', + 'lawngreen', +] + const Circle = styled.circle` stroke-width: 0.1px; vector-effect: non-scaling-stroke; @@ -53,23 +60,26 @@ const HitZone = styled.circle` } ` -type IProps = { +type ICurveSegmentEditorProps = { onCurveChange: (newHandles: CubicBezierHandles) => void onCancelCurveChange: () => void -} & Parameters[0] - -const CurveSegmentEditor: React.FC = (props) => { - const {index, trackData} = props - const cur = trackData.keyframes[index] - const next = trackData.keyframes[index + 1] + curveConnection: KeyframeConnectionWithAddress + backgroundConnections: Array +} +const CurveSegmentEditor: React.VFC = (props) => { + const { + curveConnection, + curveConnection: {left, right}, + backgroundConnections, + } = props // Calculations towards keeping the handles in the viewbox. The extremum space // of this editor vertically scales to keep the handles in the viewbox of the // SVG. This produces a nice "stretching space" effect while you are dragging // the handles. // Demo: https://user-images.githubusercontent.com/11082236/164542544-f1f66de2-f62e-44dd-b4cb-05b5f6e73a52.mp4 - const minY = Math.min(0, 1 - next.handles[1], 1 - cur.handles[3]) - const maxY = Math.max(1, 1 - next.handles[1], 1 - cur.handles[3]) + const minY = Math.min(0, 1 - right.handles[1], 1 - left.handles[3]) + const maxY = Math.max(1, 1 - right.handles[1], 1 - left.handles[3]) const h = Math.max(1, maxY - minY) const toExtremumSpace = (y: number) => (y - minY) / h @@ -81,26 +91,26 @@ const CurveSegmentEditor: React.FC = (props) => { const [refLeft, nodeLeft] = useRefAndState(null) useKeyframeDrag(nodeSVG, nodeLeft, props, (dx, dy) => { - const handleX = clamp(cur.handles[2] + dx * viewboxToElWidthRatio, 0, 1) - const handleY = cur.handles[3] - dy * viewboxToElHeightRatio - - return [handleX, handleY, next.handles[0], next.handles[1]] + // TODO - document this + const handleX = clamp(left.handles[2] + dx * viewboxToElWidthRatio, 0, 1) + const handleY = left.handles[3] - dy * viewboxToElHeightRatio + return [handleX, handleY, right.handles[0], right.handles[1]] }) const [refRight, nodeRight] = useRefAndState(null) useKeyframeDrag(nodeSVG, nodeRight, props, (dx, dy) => { - const handleX = clamp(next.handles[0] + dx * viewboxToElWidthRatio, 0, 1) - const handleY = next.handles[1] - dy * viewboxToElHeightRatio - - return [cur.handles[2], cur.handles[3], handleX, handleY] + // TODO - document this + const handleX = clamp(right.handles[0] + dx * viewboxToElWidthRatio, 0, 1) + const handleY = right.handles[1] - dy * viewboxToElHeightRatio + return [left.handles[2], left.handles[3], handleX, handleY] }) - const curvePathDAttrValue = `M0 ${toExtremumSpace(1)} C${ - cur.handles[2] - } ${toExtremumSpace(1 - cur.handles[3])} -${next.handles[0]} ${toExtremumSpace(1 - next.handles[1])} 1 ${toExtremumSpace( - 0, - )}` + const curvePathDAttrValue = (connection: KeyframeConnectionWithAddress) => + `M0 ${toExtremumSpace(1)} C${connection.left.handles[2]} ${toExtremumSpace( + 1 - connection.left.handles[3], + )} ${connection.right.handles[0]} ${toExtremumSpace( + 1 - connection.right.handles[1], + )} 1 ${toExtremumSpace(0)}` return ( @@ -182,26 +192,35 @@ ${next.handles[0]} ${toExtremumSpace(1 - next.handles[1])} 1 ${toExtremumSpace( {/* Curve "shadow": the low-opacity filled area between the curve and the diagonal */} + {/* The background curves (e.g. multiple different values) */} + {backgroundConnections.map((connection, i) => ( + + ))} {/* The curve */} - {/* Right end of curve */} - + {/* Left handle and hit zone */} - + ) } @@ -247,7 +269,7 @@ export default CurveSegmentEditor function useKeyframeDrag( svgNode: SVGSVGElement | null, node: SVGCircleElement | null, - props: IProps, + props: ICurveSegmentEditorProps, setHandles: (dx: number, dy: number) => CubicBezierHandles, ): void { const handlers = useFreezableMemo[1]>( @@ -269,7 +291,7 @@ function useKeyframeDrag( } }, }), - [svgNode, props.trackData], + [svgNode, props.onCurveChange, props.onCancelCurveChange], ) useDrag(node, handlers) diff --git a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/keyframeRowUI/ConnectorLine.tsx b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/keyframeRowUI/ConnectorLine.tsx index 8d60a46..457725a 100644 --- a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/keyframeRowUI/ConnectorLine.tsx +++ b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/keyframeRowUI/ConnectorLine.tsx @@ -57,8 +57,6 @@ const Container = styled.div` type IConnectorLineProps = React.PropsWithChildren<{ isPopoverOpen: boolean - /** TEMP: Remove once interactivity is added for aggregate? */ - mvpIsInteractiveDisabled?: boolean openPopover?: (event: React.MouseEvent) => void isSelected: boolean connectorLengthInUnitSpace: number @@ -82,7 +80,6 @@ export const ConnectorLine = React.forwardRef< transform: `scaleX(calc(var(--unitSpaceToScaledSpaceMultiplier) * ${ props.connectorLengthInUnitSpace / CONNECTOR_WIDTH_UNSCALED }))`, - pointerEvents: props.mvpIsInteractiveDisabled ? 'none' : undefined, }} onClick={(e) => { props.openPopover?.(e) diff --git a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/selections.ts b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/selections.ts index 7cbe5b2..9da5660 100644 --- a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/selections.ts +++ b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/selections.ts @@ -16,29 +16,42 @@ import type {Keyframe} from '@theatre/core/projects/store/types/SheetState_Histo * keyframe in the connection is selected */ export function isKeyframeConnectionInSelection( - keyframeConnection: [Keyframe, Keyframe], + keyframeConnection: {left: Keyframe; right: Keyframe}, selection: DopeSheetSelection, ): boolean { for (const {keyframeId} of flatSelectionKeyframeIds(selection)) { - if (keyframeConnection[0].id === keyframeId) return true + if (keyframeConnection.left.id === keyframeId) return true } return false } +export type KeyframeConnectionWithAddress = { + projectId: ProjectId + sheetId: SheetId + objectKey: ObjectAddressKey + trackId: SequenceTrackId + left: Keyframe + right: Keyframe +} + /** * Returns an array of all the selected keyframes * that are connected to one another. Useful for changing * the tweening in between keyframes. + * + * TODO - rename to selectedKeyframeConnectionsD(), or better yet, + * make it a `prism.ensurePrism()` function, rather than returning + * a prism. */ export function selectedKeyframeConnections( projectId: ProjectId, sheetId: SheetId, selection: DopeSheetSelection | undefined, -): IDerivation> { +): IDerivation> { return prism(() => { if (selection === undefined) return [] - let ckfs: Array<[Keyframe, Keyframe]> = [] + let ckfs: Array = [] for (const {objectKey, trackId} of flatSelectionTrackIds(selection)) { const track = val( @@ -48,9 +61,16 @@ export function selectedKeyframeConnections( if (track) { ckfs = ckfs.concat( - keyframeConnections(track.keyframes).filter((kfc) => - isKeyframeConnectionInSelection(kfc, selection), - ), + keyframeConnections(track.keyframes) + .filter((kfc) => isKeyframeConnectionInSelection(kfc, selection)) + .map(({left, right}) => ({ + left, + right, + trackId, + objectKey, + sheetId, + projectId, + })), ) } } @@ -60,10 +80,10 @@ export function selectedKeyframeConnections( export function keyframeConnections( keyframes: Array, -): Array<[Keyframe, Keyframe]> { +): Array<{left: Keyframe; right: 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] + .map((kf, i) => ({left: kf, right: keyframes[i + 1]})) + .slice(0, -1) // remmove the last entry because it is { left: kf, right: undefined } } export function flatSelectionKeyframeIds(selection: DopeSheetSelection): Array<{ diff --git a/theatre/studio/src/store/stateEditors.ts b/theatre/studio/src/store/stateEditors.ts index d29ca6c..bde2f4d 100644 --- a/theatre/studio/src/store/stateEditors.ts +++ b/theatre/studio/src/store/stateEditors.ts @@ -775,7 +775,17 @@ namespace stateEditors { } /** - * Sets the easing between two keyframes + * Sets the easing between keyframes + * + * X = in keyframeIds + * * = not in keyframeIds + * + = modified handle + * ``` + * X- --- -*- --- -X + * X+ --- +*- --- -X+ + * ``` + * + * TODO - explain further */ export function setTweenBetweenKeyframes( p: WithoutSheetInstance & { @@ -828,6 +838,34 @@ namespace stateEditors { }) } + export function setHandlesForKeyframe( + p: WithoutSheetInstance & { + trackId: SequenceTrackId + keyframeId: KeyframeId + start?: [number, number] + 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], + ], + } + } else return kf + }) + } + export function deleteKeyframes( p: WithoutSheetInstance & { trackId: SequenceTrackId diff --git a/theatre/studio/src/uiComponents/Popover/usePopover.tsx b/theatre/studio/src/uiComponents/Popover/usePopover.tsx index dd52635..797faae 100644 --- a/theatre/studio/src/uiComponents/Popover/usePopover.tsx +++ b/theatre/studio/src/uiComponents/Popover/usePopover.tsx @@ -1,11 +1,5 @@ -import React, { - useCallback, - useContext, - useEffect, - useMemo, - useRef, - useState, -} from 'react' +import useRefAndState from '@theatre/studio/utils/useRefAndState' +import React, {useCallback, useContext, useEffect, useRef} from 'react' import {createPortal} from 'react-dom' import {PortalContext} from 'reakit' import type {AbsolutePlacementBoxConstraints} from './TooltipWrapper' @@ -25,6 +19,12 @@ type State = clientY: number } target: HTMLElement + opts: Opts + onPointerOutside?: { + threshold: number + callback: (e: MouseEvent) => void + } + onClickOutside: () => void } const PopoverAutoCloseLock = React.createContext({ @@ -37,33 +37,61 @@ const PopoverAutoCloseLock = React.createContext({ }, }) +type Opts = { + debugName: string + closeWhenPointerIsDistant?: boolean + pointerDistanceThreshold?: number + closeOnClickOutside?: boolean + constraints?: AbsolutePlacementBoxConstraints +} + export default function usePopover( - opts: { - debugName: string - closeWhenPointerIsDistant?: boolean - pointerDistanceThreshold?: number - closeOnClickOutside?: boolean - constraints?: AbsolutePlacementBoxConstraints - }, + opts: Opts | (() => Opts), render: () => React.ReactElement, ): [node: React.ReactNode, open: OpenFn, close: CloseFn, isOpen: boolean] { - const _debug = (...args: any) => {} // console.debug.bind(console, opts.debugName) + const _debug = (...args: any) => {} - const [state, setState] = useState({ + const [stateRef, state] = useRefAndState({ isOpen: false, }) + const optsRef = useRef(opts) + + const close = useCallback((reason: string): void => { + _debug(`closing due to "${reason}"`) + stateRef.current = {isOpen: false} + }, []) + const open = useCallback((e, target) => { - setState({ + const opts = + typeof optsRef.current === 'function' + ? optsRef.current() + : optsRef.current + + function onClickOutside(): void { + if (lock.childHasFocusRef.current) return + if (opts.closeOnClickOutside !== false) { + close('clicked outside popover') + } + } + + stateRef.current = { isOpen: true, clickPoint: {clientX: e.clientX, clientY: e.clientY}, target, - }) - }, []) - - const close = useCallback((reason) => { - _debug(`closing due to "${reason}"`) - setState({isOpen: false}) + opts, + onClickOutside: onClickOutside, + onPointerOutside: + opts.closeWhenPointerIsDistant === false + ? undefined + : { + threshold: opts.pointerDistanceThreshold ?? 100, + callback: () => { + if (lock.childHasFocusRef.current) return + close('pointer outside') + }, + }, + } }, []) /** @@ -76,24 +104,7 @@ export default function usePopover( state, }) - const onClickOutside = useCallback(() => { - if (lock.childHasFocusRef.current) return - if (opts.closeOnClickOutside !== false) { - close('clicked outside popover') - } - }, [opts.closeOnClickOutside]) - const portalLayer = useContext(PortalContext) - const onPointerOutside = useMemo(() => { - if (opts.closeWhenPointerIsDistant === false) return undefined - return { - threshold: opts.pointerDistanceThreshold ?? 100, - callback: () => { - if (lock.childHasFocusRef.current) return - close('pointer outside') - }, - } - }, [opts.closeWhenPointerIsDistant]) const node = state.isOpen ? ( createPortal( @@ -101,9 +112,9 @@ export default function usePopover( , portalLayer!,