Single tween editor for aggregate rows (#178)

Co-authored-by: Cole Lawrence <cole@colelawrence.com>
Co-authored-by: Aria Minaei <aria.minaei@gmail.com>
This commit is contained in:
Elliot 2022-05-29 07:12:30 -04:00 committed by GitHub
parent 9b4aa4b0e0
commit 564e54c314
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 535 additions and 244 deletions

View file

@ -179,6 +179,31 @@ export default abstract class AbstractDerivation<V> implements IDerivation<V> {
* 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) {

View file

@ -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<IAggregateKeyframeEditorProps> = (
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 (
<AggregateCurveEditorPopover {...props} closePopover={closePopover} />
)
},
)
const [nodeRef, node] = useRefAndState<HTMLDivElement | null>(null)
return (
<AggregateKeyframeEditorContainer
@ -91,20 +141,103 @@ const AggregateKeyframeEditor: React.VFC<IAggregateKeyframeEditorProps> = (
/>
{connected ? (
<ConnectorLine
/* TEMP: Disabled until interactivity */
mvpIsInteractiveDisabled={true}
ref={nodeRef}
connectorLengthInUnitSpace={connected.length}
isPopoverOpen={false}
isPopoverOpen={isAggregateEditingInCurvePopover}
// if all keyframe aggregates are selected
isSelected={connected.selected}
openPopover={(e) => {
if (node) openPopover(e, node)
}}
/>
) : (
noConnector
)}
{popoverNode}
</AggregateKeyframeEditorContainer>
)
}
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 (
<EasingPopoverWrapper
showPopoverEdgeTriangle={false}
// @ts-ignore @todo
ref={ref}
>
<CurveEditorPopover
curveConnection={allConnections[0]}
additionalConnections={allConnections}
onRequestClose={props.closePopover}
/>
</EasingPopoverWrapper>
)
})
const DOT_SIZE_PX = 16
const DOT_HOVER_SIZE_PX = DOT_SIZE_PX + 5

View file

@ -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<IBasicKeyframeConnectorProps> = (
'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 (
<EasingPopover showPopoverEdgeTriangle={false}>
<CurveEditorPopover {...props} onRequestClose={closePopover} />
</EasingPopover>
)
const rightDims = val(props.layoutP.rightDims)
return {
debugName: 'Connector',
closeWhenPointerIsDistant: !isPointerBeingCaptured(),
constraints: {
minX: rightDims.screenX + POPOVER_MARGIN,
maxX: rightDims.screenX + rightDims.width - POPOVER_MARGIN,
},
}
},
() => <SingleCurveEditorPopover {...props} closePopover={closePopover} />,
)
const [contextMenu] = useConnectorContextMenu(
@ -79,26 +73,27 @@ const BasicKeyframeConnector: React.VFC<IBasicKeyframeConnectorProps> = (
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 (
<ConnectorLine
ref={nodeRef}
connectorLengthInUnitSpace={connectorLengthInUnitSpace}
isPopoverOpen={isPopoverOpen}
isSelected={!!props.selection}
{...themeValues}
openPopover={(e) => {
if (node) openPopover(e, node)
}}
@ -110,6 +105,48 @@ const BasicKeyframeConnector: React.VFC<IBasicKeyframeConnectorProps> = (
}
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 (
<EasingPopover
showPopoverEdgeTriangle={false}
// @ts-ignore @todo
ref={ref}
>
<CurveEditorPopover
curveConnection={curveConnection}
additionalConnections={selectedConnections}
onRequestClose={props.closePopover}
/>
</EasingPopover>
)
})
function useDragKeyframe(
node: HTMLDivElement | null,
props: IBasicKeyframeConnectorProps,

View file

@ -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<SequenceEditorPanelLayout>
type ICurveEditorPopoverProps = {
/**
* Called when user hits enter/escape
*/
onRequestClose: (reason: string) => void
} & ISingleKeyframeEditorProps
const CurveEditorPopover: React.FC<IProps> = (props) => {
curveConnection: KeyframeConnectionWithAddress
additionalConnections: Array<KeyframeConnectionWithAddress>
}
const CurveEditorPopover: React.VFC<ICurveEditorPopoverProps> = (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<IProps> = (props) => {
*/
const tempTransaction = useRef<CommitOrDiscard | null>(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<IProps> = (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<IProps> = (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<CSSCubicBezierArgsString | null>(
@ -220,21 +216,19 @@ const CurveEditorPopover: React.FC<IProps> = (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<IProps> = (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<IProps> = (props) => {
</OptionsContainer>
<CurveEditorContainer onClick={() => inputRef.current?.focus()}>
<CurveSegmentEditor
{...props}
curveConnection={props.curveConnection}
backgroundConnections={props.additionalConnections}
onCurveChange={onCurveChange}
onCancelCurveChange={onCancelCurveChange}
/>
@ -409,18 +404,19 @@ export default CurveEditorPopover
function setTempValue(
tempTransaction: React.MutableRefObject<CommitOrDiscard | null>,
props: IProps,
cur: Keyframe,
next: Keyframe,
newCurve: string,
keyframeConnections: Array<KeyframeConnectionWithAddress>,
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<KeyframeConnectionWithAddress>,
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<number[]>([])
const {isCurveEditorOpenD, isConnectionEditingInCurvePopover, getLock} =
(() => {
const connectionsInCurvePopoverEdit = new Box<
Array<KeyframeConnectionWithAddress>
>([])
return {
getLock() {
const id = lastId++
idsOfOpenCurveEditors.set([...idsOfOpenCurveEditors.get(), id])
return {
getLock(connections: Array<KeyframeConnectionWithAddress>) {
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}

View file

@ -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<typeof CurveEditorPopover>[0]
const CurveSegmentEditor: React.FC<IProps> = (props) => {
const {index, trackData} = props
const cur = trackData.keyframes[index]
const next = trackData.keyframes[index + 1]
curveConnection: KeyframeConnectionWithAddress
backgroundConnections: Array<KeyframeConnectionWithAddress>
}
const CurveSegmentEditor: React.VFC<ICurveSegmentEditorProps> = (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<IProps> = (props) => {
const [refLeft, nodeLeft] = useRefAndState<SVGCircleElement | null>(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<SVGCircleElement | null>(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 (
<svg
@ -173,8 +183,8 @@ ${next.handles[0]} ${toExtremumSpace(1 - next.handles[1])} 1 ${toExtremumSpace(
<line
x1={0}
y1={toExtremumSpace(1)}
x2={cur.handles[2]}
y2={toExtremumSpace(1 - cur.handles[3])}
x2={left.handles[2]}
y2={toExtremumSpace(1 - left.handles[3])}
stroke={CONTROL_COLOR}
strokeWidth="0.01"
/>
@ -182,26 +192,35 @@ ${next.handles[0]} ${toExtremumSpace(1 - next.handles[1])} 1 ${toExtremumSpace(
<line
x1={1}
y1={toExtremumSpace(0)}
x2={next.handles[0]}
y2={toExtremumSpace(1 - next.handles[1])}
x2={right.handles[0]}
y2={toExtremumSpace(1 - right.handles[1])}
stroke={CONTROL_COLOR}
strokeWidth="0.01"
/>
{/* Curve "shadow": the low-opacity filled area between the curve and the diagonal */}
<path
d={curvePathDAttrValue}
d={curvePathDAttrValue(props.curveConnection)}
stroke="none"
fill="url('#myGradient')"
opacity="0.1"
/>
{/* The background curves (e.g. multiple different values) */}
{backgroundConnections.map((connection, i) => (
<path
key={connection.objectKey + '/' + connection.left.id}
d={curvePathDAttrValue(connection)}
stroke={BACKGROUND_CURVE_COLORS[i % BACKGROUND_CURVE_COLORS.length]}
opacity={0.6}
strokeWidth="0.01"
/>
))}
{/* The curve */}
<path
d={curvePathDAttrValue}
d={curvePathDAttrValue(props.curveConnection)}
stroke="url('#myGradient')"
strokeWidth="0.02"
/>
{/* Right end of curve */}
<circle
cx={0}
@ -224,21 +243,24 @@ ${next.handles[0]} ${toExtremumSpace(1 - next.handles[1])} 1 ${toExtremumSpace(
{/* Right handle and hit zone */}
<HitZone
ref={refLeft}
cx={cur.handles[2]}
cy={toExtremumSpace(1 - cur.handles[3])}
cx={left.handles[2]}
cy={toExtremumSpace(1 - left.handles[3])}
fill={CURVE_START_COLOR}
opacity={0.2}
/>
<Circle cx={cur.handles[2]} cy={toExtremumSpace(1 - cur.handles[3])} />
<Circle cx={left.handles[2]} cy={toExtremumSpace(1 - left.handles[3])} />
{/* Left handle and hit zone */}
<HitZone
ref={refRight}
cx={next.handles[0]}
cy={toExtremumSpace(1 - next.handles[1])}
cx={right.handles[0]}
cy={toExtremumSpace(1 - right.handles[1])}
fill={CURVE_END_COLOR}
opacity={0.2}
/>
<Circle cx={next.handles[0]} cy={toExtremumSpace(1 - next.handles[1])} />
<Circle
cx={right.handles[0]}
cy={toExtremumSpace(1 - right.handles[1])}
/>
</svg>
)
}
@ -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<Parameters<typeof useDrag>[1]>(
@ -269,7 +291,7 @@ function useKeyframeDrag(
}
},
}),
[svgNode, props.trackData],
[svgNode, props.onCurveChange, props.onCancelCurveChange],
)
useDrag(node, handlers)

View file

@ -57,8 +57,6 @@ const Container = styled.div<IConnectorThemeValues>`
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)

View file

@ -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<Array<[left: Keyframe, right: Keyframe]>> {
): IDerivation<Array<KeyframeConnectionWithAddress>> {
return prism(() => {
if (selection === undefined) return []
let ckfs: Array<[Keyframe, Keyframe]> = []
let ckfs: Array<KeyframeConnectionWithAddress> = []
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<Keyframe>,
): 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<{

View file

@ -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<SheetObjectAddress> & {
@ -828,6 +838,34 @@ namespace stateEditors {
})
}
export function setHandlesForKeyframe(
p: WithoutSheetInstance<SheetObjectAddress> & {
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<SheetObjectAddress> & {
trackId: SequenceTrackId

View file

@ -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<State>({
const [stateRef, state] = useRefAndState<State>({
isOpen: false,
})
const optsRef = useRef(opts)
const close = useCallback<CloseFn>((reason: string): void => {
_debug(`closing due to "${reason}"`)
stateRef.current = {isOpen: false}
}, [])
const open = useCallback<OpenFn>((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<CloseFn>((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(
<TooltipWrapper
children={render}
target={state.target}
onClickOutside={onClickOutside}
onPointerOutside={onPointerOutside}
constraints={opts.constraints}
onClickOutside={state.onClickOutside}
onPointerOutside={state.onPointerOutside}
constraints={state.opts.constraints}
/>
</PopoverAutoCloseLock.Provider>,
portalLayer!,