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:
parent
9b4aa4b0e0
commit
564e54c314
9 changed files with 535 additions and 244 deletions
|
@ -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.
|
* Gets the current value of the derivation. If the value is stale, it causes the derivation to freshen.
|
||||||
*/
|
*/
|
||||||
getValue(): V {
|
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)
|
reportResolutionStart(this)
|
||||||
|
|
||||||
if (!this._isFresh) {
|
if (!this._isFresh) {
|
||||||
|
|
|
@ -11,6 +11,7 @@ import type {
|
||||||
SequenceEditorTree_SheetObject,
|
SequenceEditorTree_SheetObject,
|
||||||
} from '@theatre/studio/panels/SequenceEditorPanel/layout/tree'
|
} from '@theatre/studio/panels/SequenceEditorPanel/layout/tree'
|
||||||
import type {Pointer} from '@theatre/dataverse'
|
import type {Pointer} from '@theatre/dataverse'
|
||||||
|
import {prism} from '@theatre/dataverse'
|
||||||
import {val} from '@theatre/dataverse'
|
import {val} from '@theatre/dataverse'
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import styled from 'styled-components'
|
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 type {KeyframeWithTrack} from '@theatre/studio/panels/SequenceEditorPanel/DopeSheet/Right/collectAggregateKeyframes'
|
||||||
import {DopeSnapHitZoneUI} from '@theatre/studio/panels/SequenceEditorPanel/RightOverlay/DopeSnapHitZoneUI'
|
import {DopeSnapHitZoneUI} from '@theatre/studio/panels/SequenceEditorPanel/RightOverlay/DopeSnapHitZoneUI'
|
||||||
import {absoluteDims} from '@theatre/studio/utils/absoluteDims'
|
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`
|
const AggregateKeyframeEditorContainer = styled.div`
|
||||||
position: absolute;
|
position: absolute;
|
||||||
`
|
`
|
||||||
|
|
||||||
|
const EasingPopoverWrapper = styled(BasicPopover)`
|
||||||
|
--popover-outer-stroke: transparent;
|
||||||
|
--popover-inner-stroke: rgba(26, 28, 30, 0.97);
|
||||||
|
`
|
||||||
|
|
||||||
const noConnector = <></>
|
const noConnector = <></>
|
||||||
|
|
||||||
export type IAggregateKeyframesAtPosition = {
|
export type IAggregateKeyframesAtPosition = {
|
||||||
|
@ -41,6 +59,12 @@ export type IAggregateKeyframesAtPosition = {
|
||||||
}[]
|
}[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type AggregatedKeyframeConnection = SheetObjectAddress & {
|
||||||
|
trackId: SequenceTrackId
|
||||||
|
left: Keyframe
|
||||||
|
right: Keyframe
|
||||||
|
}
|
||||||
|
|
||||||
export type IAggregateKeyframeEditorProps = {
|
export type IAggregateKeyframeEditorProps = {
|
||||||
index: number
|
index: number
|
||||||
aggregateKeyframes: IAggregateKeyframesAtPosition[]
|
aggregateKeyframes: IAggregateKeyframesAtPosition[]
|
||||||
|
@ -51,24 +75,50 @@ export type IAggregateKeyframeEditorProps = {
|
||||||
selection: undefined | DopeSheetSelection
|
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> = (
|
const AggregateKeyframeEditor: React.VFC<IAggregateKeyframeEditorProps> = (
|
||||||
props,
|
props,
|
||||||
) => {
|
) => {
|
||||||
const {index, aggregateKeyframes} = props
|
const {cur, connected, isAggregateEditingInCurvePopover} =
|
||||||
const cur = aggregateKeyframes[index]
|
useAggregateKeyframeEditorUtils(props)
|
||||||
const next = aggregateKeyframes[index + 1]
|
|
||||||
const connected =
|
const {isPointerBeingCaptured} = usePointerCapturing(
|
||||||
next && cur.keyframes.length === next.keyframes.length
|
'AggregateKeyframeEditor Connector',
|
||||||
? // all keyframes are same in the next position
|
)
|
||||||
cur.keyframes.every(
|
|
||||||
({track}, ind) => next.keyframes[ind].track === track,
|
const [popoverNode, openPopover, closePopover] = usePopover(
|
||||||
) && {
|
() => {
|
||||||
length: next.position - cur.position,
|
const rightDims = val(props.layoutP.rightDims)
|
||||||
selected:
|
|
||||||
cur.selected === AggregateKeyframePositionIsSelected.AllSelected &&
|
return {
|
||||||
next.selected === AggregateKeyframePositionIsSelected.AllSelected,
|
debugName: 'Connector',
|
||||||
}
|
closeWhenPointerIsDistant: !isPointerBeingCaptured(),
|
||||||
: null
|
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 (
|
return (
|
||||||
<AggregateKeyframeEditorContainer
|
<AggregateKeyframeEditorContainer
|
||||||
|
@ -91,20 +141,103 @@ const AggregateKeyframeEditor: React.VFC<IAggregateKeyframeEditorProps> = (
|
||||||
/>
|
/>
|
||||||
{connected ? (
|
{connected ? (
|
||||||
<ConnectorLine
|
<ConnectorLine
|
||||||
/* TEMP: Disabled until interactivity */
|
ref={nodeRef}
|
||||||
mvpIsInteractiveDisabled={true}
|
|
||||||
connectorLengthInUnitSpace={connected.length}
|
connectorLengthInUnitSpace={connected.length}
|
||||||
isPopoverOpen={false}
|
isPopoverOpen={isAggregateEditingInCurvePopover}
|
||||||
// if all keyframe aggregates are selected
|
// if all keyframe aggregates are selected
|
||||||
isSelected={connected.selected}
|
isSelected={connected.selected}
|
||||||
|
openPopover={(e) => {
|
||||||
|
if (node) openPopover(e, node)
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
noConnector
|
noConnector
|
||||||
)}
|
)}
|
||||||
|
{popoverNode}
|
||||||
</AggregateKeyframeEditorContainer>
|
</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_SIZE_PX = 16
|
||||||
const DOT_HOVER_SIZE_PX = DOT_SIZE_PX + 5
|
const DOT_HOVER_SIZE_PX = DOT_SIZE_PX + 5
|
||||||
|
|
||||||
|
|
|
@ -9,7 +9,7 @@ import {useMemo, useRef} from 'react'
|
||||||
import usePopover from '@theatre/studio/uiComponents/Popover/usePopover'
|
import usePopover from '@theatre/studio/uiComponents/Popover/usePopover'
|
||||||
import BasicPopover from '@theatre/studio/uiComponents/Popover/BasicPopover'
|
import BasicPopover from '@theatre/studio/uiComponents/Popover/BasicPopover'
|
||||||
import CurveEditorPopover, {
|
import CurveEditorPopover, {
|
||||||
isCurveEditorOpenD,
|
isConnectionEditingInCurvePopover,
|
||||||
} from './CurveEditorPopover/CurveEditorPopover'
|
} from './CurveEditorPopover/CurveEditorPopover'
|
||||||
import selectedKeyframeIdsIfInSingleTrack from '@theatre/studio/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/selectedKeyframeIdsIfInSingleTrack'
|
import selectedKeyframeIdsIfInSingleTrack from '@theatre/studio/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/selectedKeyframeIdsIfInSingleTrack'
|
||||||
import type {OpenFn} from '@theatre/studio/src/uiComponents/Popover/usePopover'
|
import type {OpenFn} from '@theatre/studio/src/uiComponents/Popover/usePopover'
|
||||||
|
@ -19,13 +19,11 @@ import type {ISingleKeyframeEditorProps} from './SingleKeyframeEditor'
|
||||||
import type {IConnectorThemeValues} from '@theatre/studio/panels/SequenceEditorPanel/DopeSheet/Right/keyframeRowUI/ConnectorLine'
|
import type {IConnectorThemeValues} from '@theatre/studio/panels/SequenceEditorPanel/DopeSheet/Right/keyframeRowUI/ConnectorLine'
|
||||||
import {ConnectorLine} 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 {COLOR_POPOVER_BACK} from './CurveEditorPopover/colors'
|
||||||
import {useVal} from '@theatre/react'
|
import {usePrism} from '@theatre/react'
|
||||||
import {isKeyframeConnectionInSelection} from '@theatre/studio/panels/SequenceEditorPanel/DopeSheet/selections'
|
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 styled from 'styled-components'
|
||||||
import {DOT_SIZE_PX} from './SingleKeyframeDot'
|
|
||||||
|
|
||||||
const POPOVER_MARGIN = 5
|
const POPOVER_MARGIN = 5
|
||||||
|
|
||||||
|
@ -49,23 +47,19 @@ const BasicKeyframeConnector: React.VFC<IBasicKeyframeConnectorProps> = (
|
||||||
'KeyframeEditor Connector',
|
'KeyframeEditor Connector',
|
||||||
)
|
)
|
||||||
|
|
||||||
const rightDims = val(props.layoutP.rightDims)
|
|
||||||
const [popoverNode, openPopover, closePopover, isPopoverOpen] = usePopover(
|
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)
|
||||||
<EasingPopover showPopoverEdgeTriangle={false}>
|
return {
|
||||||
<CurveEditorPopover {...props} onRequestClose={closePopover} />
|
debugName: 'Connector',
|
||||||
</EasingPopover>
|
closeWhenPointerIsDistant: !isPointerBeingCaptured(),
|
||||||
)
|
constraints: {
|
||||||
|
minX: rightDims.screenX + POPOVER_MARGIN,
|
||||||
|
maxX: rightDims.screenX + rightDims.width - POPOVER_MARGIN,
|
||||||
|
},
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
() => <SingleCurveEditorPopover {...props} closePopover={closePopover} />,
|
||||||
)
|
)
|
||||||
|
|
||||||
const [contextMenu] = useConnectorContextMenu(
|
const [contextMenu] = useConnectorContextMenu(
|
||||||
|
@ -79,26 +73,27 @@ const BasicKeyframeConnector: React.VFC<IBasicKeyframeConnectorProps> = (
|
||||||
|
|
||||||
const connectorLengthInUnitSpace = next.position - cur.position
|
const connectorLengthInUnitSpace = next.position - cur.position
|
||||||
|
|
||||||
// The following two flags determine whether this connector
|
const isInCurveEditorPopoverSelection = usePrism(
|
||||||
// is being edited as part of a selection using the curve
|
() =>
|
||||||
// editor popover
|
isConnectionEditingInCurvePopover({
|
||||||
const isCurveEditorPopoverOpen = useVal(isCurveEditorOpenD)
|
...props.leaf.sheetObject.address,
|
||||||
const isInCurveEditorPopoverSelection =
|
trackId: props.leaf.trackId,
|
||||||
isCurveEditorPopoverOpen &&
|
left: cur,
|
||||||
props.selection !== undefined &&
|
right: next,
|
||||||
isKeyframeConnectionInSelection([cur, next], props.selection)
|
}),
|
||||||
|
[props.leaf.sheetObject.address, props.leaf.trackId, cur, next],
|
||||||
|
)
|
||||||
|
|
||||||
const themeValues: IConnectorThemeValues = {
|
const themeValues: IConnectorThemeValues = {
|
||||||
isPopoverOpen: isPopoverOpen || isInCurveEditorPopoverSelection || false,
|
isPopoverOpen: isInCurveEditorPopoverSelection,
|
||||||
isSelected: !!props.selection,
|
isSelected: props.selection !== undefined,
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ConnectorLine
|
<ConnectorLine
|
||||||
ref={nodeRef}
|
ref={nodeRef}
|
||||||
connectorLengthInUnitSpace={connectorLengthInUnitSpace}
|
connectorLengthInUnitSpace={connectorLengthInUnitSpace}
|
||||||
isPopoverOpen={isPopoverOpen}
|
{...themeValues}
|
||||||
isSelected={!!props.selection}
|
|
||||||
openPopover={(e) => {
|
openPopover={(e) => {
|
||||||
if (node) openPopover(e, node)
|
if (node) openPopover(e, node)
|
||||||
}}
|
}}
|
||||||
|
@ -110,6 +105,48 @@ const BasicKeyframeConnector: React.VFC<IBasicKeyframeConnectorProps> = (
|
||||||
}
|
}
|
||||||
export default BasicKeyframeConnector
|
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(
|
function useDragKeyframe(
|
||||||
node: HTMLDivElement | null,
|
node: HTMLDivElement | null,
|
||||||
props: IBasicKeyframeConnectorProps,
|
props: IBasicKeyframeConnectorProps,
|
||||||
|
|
|
@ -1,4 +1,3 @@
|
||||||
import type {Pointer} from '@theatre/dataverse'
|
|
||||||
import {Box, prism} from '@theatre/dataverse'
|
import {Box, prism} from '@theatre/dataverse'
|
||||||
import type {KeyboardEvent} from 'react'
|
import type {KeyboardEvent} from 'react'
|
||||||
import React, {
|
import React, {
|
||||||
|
@ -10,10 +9,8 @@ import React, {
|
||||||
} from 'react'
|
} from 'react'
|
||||||
import styled from 'styled-components'
|
import styled from 'styled-components'
|
||||||
import fuzzy from 'fuzzy'
|
import fuzzy from 'fuzzy'
|
||||||
import type {SequenceEditorPanelLayout} from '@theatre/studio/panels/SequenceEditorPanel/layout/layout'
|
|
||||||
import getStudio from '@theatre/studio/getStudio'
|
import getStudio from '@theatre/studio/getStudio'
|
||||||
import type {CommitOrDiscard} from '@theatre/studio/StudioStore/StudioStore'
|
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 CurveSegmentEditor from './CurveSegmentEditor'
|
||||||
import EasingOption from './EasingOption'
|
import EasingOption from './EasingOption'
|
||||||
import type {CSSCubicBezierArgsString, CubicBezierHandles} from './shared'
|
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 useRefAndState from '@theatre/studio/utils/useRefAndState'
|
||||||
import type {Keyframe} from '@theatre/core/projects/store/types/SheetState_Historic'
|
import type {Keyframe} from '@theatre/core/projects/store/types/SheetState_Historic'
|
||||||
import {useUIOptionGrid, Outcome} from './useUIOptionGrid'
|
import {useUIOptionGrid, Outcome} from './useUIOptionGrid'
|
||||||
import {useVal} from '@theatre/react'
|
import type {KeyframeConnectionWithAddress} from '@theatre/studio/panels/SequenceEditorPanel/DopeSheet/selections'
|
||||||
import {
|
|
||||||
flatSelectionTrackIds,
|
|
||||||
selectedKeyframeConnections,
|
|
||||||
} from '@theatre/studio/panels/SequenceEditorPanel/DopeSheet/selections'
|
|
||||||
|
|
||||||
const PRESET_COLUMNS = 3
|
const PRESET_COLUMNS = 3
|
||||||
const PRESET_SIZE = 53
|
const PRESET_SIZE = 53
|
||||||
|
@ -126,16 +119,22 @@ enum TextInputMode {
|
||||||
multipleValues,
|
multipleValues,
|
||||||
}
|
}
|
||||||
|
|
||||||
type IProps = {
|
type ICurveEditorPopoverProps = {
|
||||||
layoutP: Pointer<SequenceEditorPanelLayout>
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Called when user hits enter/escape
|
* Called when user hits enter/escape
|
||||||
*/
|
*/
|
||||||
onRequestClose: (reason: string) => void
|
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` //////
|
||||||
/*
|
/*
|
||||||
* `tempTransaction` is used for all edits in this popover. The transaction
|
* `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)
|
const tempTransaction = useRef<CommitOrDiscard | null>(null)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const unlock = getLock()
|
const unlock = getLock(allConnections)
|
||||||
// Clean-up function, called when this React component unmounts.
|
// Clean-up function, called when this React component unmounts.
|
||||||
// When it unmounts, we want to commit edits that are outstanding
|
// When it unmounts, we want to commit edits that are outstanding
|
||||||
return () => {
|
return () => {
|
||||||
|
@ -154,14 +153,11 @@ const CurveEditorPopover: React.FC<IProps> = (props) => {
|
||||||
}, [tempTransaction])
|
}, [tempTransaction])
|
||||||
|
|
||||||
////// Keyframe and trackdata //////
|
////// Keyframe and trackdata //////
|
||||||
const {index, trackData} = props
|
|
||||||
const cur = trackData.keyframes[index]
|
|
||||||
const next = trackData.keyframes[index + 1]
|
|
||||||
const easing: CubicBezierHandles = [
|
const easing: CubicBezierHandles = [
|
||||||
trackData.keyframes[index].handles[2],
|
props.curveConnection.left.handles[2],
|
||||||
trackData.keyframes[index].handles[3],
|
props.curveConnection.left.handles[3],
|
||||||
trackData.keyframes[index + 1].handles[0],
|
props.curveConnection.right.handles[0],
|
||||||
trackData.keyframes[index + 1].handles[1],
|
props.curveConnection.right.handles[1],
|
||||||
]
|
]
|
||||||
|
|
||||||
////// Text input data and reactivity //////
|
////// Text input data and reactivity //////
|
||||||
|
@ -209,7 +205,7 @@ const CurveEditorPopover: React.FC<IProps> = (props) => {
|
||||||
} else if (textInputMode === TextInputMode.multipleValues) {
|
} else if (textInputMode === TextInputMode.multipleValues) {
|
||||||
if (inputValue !== '') setInputValue('')
|
if (inputValue !== '') setInputValue('')
|
||||||
}
|
}
|
||||||
}, [trackData])
|
}, allConnections)
|
||||||
|
|
||||||
// `edit` keeps track of the current edited state of the curve.
|
// `edit` keeps track of the current edited state of the curve.
|
||||||
const [edit, setEdit] = useState<CSSCubicBezierArgsString | null>(
|
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
|
// When `preview` or `edit` change, use the `tempTransaction` to change the
|
||||||
// curve in Theate's data.
|
// curve in Theate's data.
|
||||||
useMemo(() => {
|
useEffect(() => {
|
||||||
if (textInputMode !== TextInputMode.init)
|
if (
|
||||||
setTempValue(tempTransaction, props, cur, next, preview ?? edit ?? '')
|
textInputMode !== TextInputMode.init &&
|
||||||
}, [preview, edit])
|
textInputMode !== TextInputMode.multipleValues
|
||||||
////// selection stuff //////
|
)
|
||||||
let selectedConnections: Array<[Keyframe, Keyframe]> = useVal(
|
setTempValue(tempTransaction, allConnections, preview ?? edit ?? '')
|
||||||
selectedKeyframeConnections(
|
}, [preview, edit, textInputMode])
|
||||||
props.leaf.sheetObject.address.projectId,
|
|
||||||
props.leaf.sheetObject.address.sheetId,
|
|
||||||
props.selection,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
////// selection stuff //////
|
||||||
if (
|
if (
|
||||||
selectedConnections.some(areConnectedKeyframesTheSameAs([cur, next])) &&
|
allConnections.some(
|
||||||
|
areConnectedKeyframesTheSameAs(props.curveConnection),
|
||||||
|
) &&
|
||||||
textInputMode === TextInputMode.init
|
textInputMode === TextInputMode.init
|
||||||
) {
|
) {
|
||||||
setTextInputMode(TextInputMode.multipleValues)
|
setTextInputMode(TextInputMode.multipleValues)
|
||||||
|
@ -289,7 +283,7 @@ const CurveEditorPopover: React.FC<IProps> = (props) => {
|
||||||
setPreview(item.value)
|
setPreview(item.value)
|
||||||
const onEasingOptionMouseOut = () => setPreview(null)
|
const onEasingOptionMouseOut = () => setPreview(null)
|
||||||
const onSelectEasingOption = (item: {label: string; value: string}) => {
|
const onSelectEasingOption = (item: {label: string; value: string}) => {
|
||||||
setTempValue(tempTransaction, props, cur, next, item.value)
|
setTempValue(tempTransaction, allConnections, item.value)
|
||||||
props.onRequestClose('selected easing option')
|
props.onRequestClose('selected easing option')
|
||||||
|
|
||||||
return Outcome.Handled
|
return Outcome.Handled
|
||||||
|
@ -396,7 +390,8 @@ const CurveEditorPopover: React.FC<IProps> = (props) => {
|
||||||
</OptionsContainer>
|
</OptionsContainer>
|
||||||
<CurveEditorContainer onClick={() => inputRef.current?.focus()}>
|
<CurveEditorContainer onClick={() => inputRef.current?.focus()}>
|
||||||
<CurveSegmentEditor
|
<CurveSegmentEditor
|
||||||
{...props}
|
curveConnection={props.curveConnection}
|
||||||
|
backgroundConnections={props.additionalConnections}
|
||||||
onCurveChange={onCurveChange}
|
onCurveChange={onCurveChange}
|
||||||
onCancelCurveChange={onCancelCurveChange}
|
onCancelCurveChange={onCancelCurveChange}
|
||||||
/>
|
/>
|
||||||
|
@ -409,18 +404,19 @@ export default CurveEditorPopover
|
||||||
|
|
||||||
function setTempValue(
|
function setTempValue(
|
||||||
tempTransaction: React.MutableRefObject<CommitOrDiscard | null>,
|
tempTransaction: React.MutableRefObject<CommitOrDiscard | null>,
|
||||||
props: IProps,
|
keyframeConnections: Array<KeyframeConnectionWithAddress>,
|
||||||
cur: Keyframe,
|
newCurveCssCubicBezier: string,
|
||||||
next: Keyframe,
|
|
||||||
newCurve: string,
|
|
||||||
): void {
|
): void {
|
||||||
tempTransaction.current?.discard()
|
tempTransaction.current?.discard()
|
||||||
tempTransaction.current = null
|
tempTransaction.current = null
|
||||||
|
|
||||||
const handles = handlesFromCssCubicBezierArgs(newCurve)
|
const handles = handlesFromCssCubicBezierArgs(newCurveCssCubicBezier)
|
||||||
if (handles === null) return
|
if (handles === null) return
|
||||||
|
|
||||||
tempTransaction.current = transactionSetCubicBezier(props, cur, next, handles)
|
tempTransaction.current = transactionSetCubicBezier(
|
||||||
|
keyframeConnections,
|
||||||
|
handles,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function discardTempValue(
|
function discardTempValue(
|
||||||
|
@ -431,37 +427,37 @@ function discardTempValue(
|
||||||
}
|
}
|
||||||
|
|
||||||
function transactionSetCubicBezier(
|
function transactionSetCubicBezier(
|
||||||
props: IProps,
|
keyframeConnections: Array<KeyframeConnectionWithAddress>,
|
||||||
cur: Keyframe,
|
|
||||||
next: Keyframe,
|
|
||||||
handles: CubicBezierHandles,
|
handles: CubicBezierHandles,
|
||||||
): CommitOrDiscard {
|
): CommitOrDiscard {
|
||||||
return getStudio().tempTransaction(({stateEditors}) => {
|
return getStudio().tempTransaction(({stateEditors}) => {
|
||||||
const {setTweenBetweenKeyframes} =
|
const {setHandlesForKeyframe} =
|
||||||
stateEditors.coreByProject.historic.sheetsById.sequence
|
stateEditors.coreByProject.historic.sheetsById.sequence
|
||||||
|
|
||||||
// set easing for current connector
|
for (const {
|
||||||
setTweenBetweenKeyframes({
|
projectId,
|
||||||
...props.leaf.sheetObject.address,
|
sheetId,
|
||||||
trackId: props.leaf.trackId,
|
objectKey,
|
||||||
keyframeIds: [cur.id, next.id],
|
trackId,
|
||||||
handles,
|
left,
|
||||||
})
|
right,
|
||||||
|
} of keyframeConnections) {
|
||||||
// set easings for selection
|
setHandlesForKeyframe({
|
||||||
if (props.selection) {
|
projectId,
|
||||||
for (const {objectKey, trackId, keyframeIds} of flatSelectionTrackIds(
|
sheetId,
|
||||||
props.selection,
|
objectKey,
|
||||||
)) {
|
trackId,
|
||||||
setTweenBetweenKeyframes({
|
keyframeId: left.id,
|
||||||
projectId: props.leaf.sheetObject.address.projectId,
|
start: [handles[0], handles[1]],
|
||||||
sheetId: props.leaf.sheetObject.address.sheetId,
|
})
|
||||||
objectKey,
|
setHandlesForKeyframe({
|
||||||
trackId,
|
projectId,
|
||||||
keyframeIds,
|
sheetId,
|
||||||
handles,
|
objectKey,
|
||||||
})
|
trackId,
|
||||||
}
|
keyframeId: right.id,
|
||||||
|
end: [handles[2], handles[3]],
|
||||||
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
@ -479,36 +475,48 @@ function setTimeoutFunction(f: Function, timeout?: number) {
|
||||||
return () => setTimeout(f, timeout)
|
return () => setTimeout(f, timeout)
|
||||||
}
|
}
|
||||||
|
|
||||||
function areConnectedKeyframesTheSameAs([kfcur1, kfnext1]: [
|
function areConnectedKeyframesTheSameAs({
|
||||||
Keyframe,
|
left: left1,
|
||||||
Keyframe,
|
right: right1,
|
||||||
]) {
|
}: {
|
||||||
return ([kfcur2, kfnext2]: [Keyframe, Keyframe]) =>
|
left: Keyframe
|
||||||
kfcur1.handles[2] !== kfcur2.handles[2] ||
|
right: Keyframe
|
||||||
kfcur1.handles[3] !== kfcur2.handles[3] ||
|
}) {
|
||||||
kfnext1.handles[0] !== kfnext2.handles[0] ||
|
return ({left: left2, right: right2}: {left: Keyframe; right: Keyframe}) =>
|
||||||
kfnext1.handles[1] !== kfnext2.handles[1]
|
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} = (() => {
|
const {isCurveEditorOpenD, isConnectionEditingInCurvePopover, getLock} =
|
||||||
let lastId = 0
|
(() => {
|
||||||
const idsOfOpenCurveEditors = new Box<number[]>([])
|
const connectionsInCurvePopoverEdit = new Box<
|
||||||
|
Array<KeyframeConnectionWithAddress>
|
||||||
|
>([])
|
||||||
|
|
||||||
return {
|
return {
|
||||||
getLock() {
|
getLock(connections: Array<KeyframeConnectionWithAddress>) {
|
||||||
const id = lastId++
|
connectionsInCurvePopoverEdit.set(connections)
|
||||||
idsOfOpenCurveEditors.set([...idsOfOpenCurveEditors.get(), id])
|
|
||||||
|
|
||||||
return function unlock() {
|
return function unlock() {
|
||||||
idsOfOpenCurveEditors.set(
|
connectionsInCurvePopoverEdit.set([])
|
||||||
idsOfOpenCurveEditors.get().filter((cid) => cid !== id),
|
}
|
||||||
)
|
},
|
||||||
}
|
isCurveEditorOpenD: prism(() => {
|
||||||
},
|
return connectionsInCurvePopoverEdit.derivation.getValue().length > 0
|
||||||
isCurveEditorOpenD: prism(() => {
|
}),
|
||||||
return idsOfOpenCurveEditors.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}
|
||||||
|
|
|
@ -2,12 +2,12 @@ import React from 'react'
|
||||||
import useDrag from '@theatre/studio/uiComponents/useDrag'
|
import useDrag from '@theatre/studio/uiComponents/useDrag'
|
||||||
import useRefAndState from '@theatre/studio/utils/useRefAndState'
|
import useRefAndState from '@theatre/studio/utils/useRefAndState'
|
||||||
import clamp from 'lodash-es/clamp'
|
import clamp from 'lodash-es/clamp'
|
||||||
import type CurveEditorPopover from './CurveEditorPopover'
|
|
||||||
import styled from 'styled-components'
|
import styled from 'styled-components'
|
||||||
import {pointerEventsAutoInNormalMode} from '@theatre/studio/css'
|
import {pointerEventsAutoInNormalMode} from '@theatre/studio/css'
|
||||||
import type {CubicBezierHandles} from './shared'
|
import type {CubicBezierHandles} from './shared'
|
||||||
import {useFreezableMemo} from './useFreezableMemo'
|
import {useFreezableMemo} from './useFreezableMemo'
|
||||||
import {COLOR_BASE} from './colors'
|
import {COLOR_BASE} from './colors'
|
||||||
|
import type {KeyframeConnectionWithAddress} from '@theatre/studio/panels/SequenceEditorPanel/DopeSheet/selections'
|
||||||
|
|
||||||
// Defines the dimensions of the SVG viewbox space
|
// Defines the dimensions of the SVG viewbox space
|
||||||
const VIEWBOX_PADDING = 0.12
|
const VIEWBOX_PADDING = 0.12
|
||||||
|
@ -30,6 +30,13 @@ const CONTROL_COLOR = '#B3B3B3'
|
||||||
const HANDLE_COLOR = '#3eaaa4'
|
const HANDLE_COLOR = '#3eaaa4'
|
||||||
const HANDLE_HOVER_COLOR = '#67dfd8'
|
const HANDLE_HOVER_COLOR = '#67dfd8'
|
||||||
|
|
||||||
|
const BACKGROUND_CURVE_COLORS = [
|
||||||
|
'goldenrod',
|
||||||
|
'cornflowerblue',
|
||||||
|
'dodgerblue',
|
||||||
|
'lawngreen',
|
||||||
|
]
|
||||||
|
|
||||||
const Circle = styled.circle`
|
const Circle = styled.circle`
|
||||||
stroke-width: 0.1px;
|
stroke-width: 0.1px;
|
||||||
vector-effect: non-scaling-stroke;
|
vector-effect: non-scaling-stroke;
|
||||||
|
@ -53,23 +60,26 @@ const HitZone = styled.circle`
|
||||||
}
|
}
|
||||||
`
|
`
|
||||||
|
|
||||||
type IProps = {
|
type ICurveSegmentEditorProps = {
|
||||||
onCurveChange: (newHandles: CubicBezierHandles) => void
|
onCurveChange: (newHandles: CubicBezierHandles) => void
|
||||||
onCancelCurveChange: () => void
|
onCancelCurveChange: () => void
|
||||||
} & Parameters<typeof CurveEditorPopover>[0]
|
curveConnection: KeyframeConnectionWithAddress
|
||||||
|
backgroundConnections: Array<KeyframeConnectionWithAddress>
|
||||||
const CurveSegmentEditor: React.FC<IProps> = (props) => {
|
}
|
||||||
const {index, trackData} = props
|
|
||||||
const cur = trackData.keyframes[index]
|
|
||||||
const next = trackData.keyframes[index + 1]
|
|
||||||
|
|
||||||
|
const CurveSegmentEditor: React.VFC<ICurveSegmentEditorProps> = (props) => {
|
||||||
|
const {
|
||||||
|
curveConnection,
|
||||||
|
curveConnection: {left, right},
|
||||||
|
backgroundConnections,
|
||||||
|
} = props
|
||||||
// Calculations towards keeping the handles in the viewbox. The extremum space
|
// 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
|
// 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
|
// SVG. This produces a nice "stretching space" effect while you are dragging
|
||||||
// the handles.
|
// the handles.
|
||||||
// Demo: https://user-images.githubusercontent.com/11082236/164542544-f1f66de2-f62e-44dd-b4cb-05b5f6e73a52.mp4
|
// 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 minY = Math.min(0, 1 - right.handles[1], 1 - left.handles[3])
|
||||||
const maxY = Math.max(1, 1 - next.handles[1], 1 - cur.handles[3])
|
const maxY = Math.max(1, 1 - right.handles[1], 1 - left.handles[3])
|
||||||
const h = Math.max(1, maxY - minY)
|
const h = Math.max(1, maxY - minY)
|
||||||
|
|
||||||
const toExtremumSpace = (y: number) => (y - minY) / h
|
const toExtremumSpace = (y: number) => (y - minY) / h
|
||||||
|
@ -81,26 +91,26 @@ const CurveSegmentEditor: React.FC<IProps> = (props) => {
|
||||||
|
|
||||||
const [refLeft, nodeLeft] = useRefAndState<SVGCircleElement | null>(null)
|
const [refLeft, nodeLeft] = useRefAndState<SVGCircleElement | null>(null)
|
||||||
useKeyframeDrag(nodeSVG, nodeLeft, props, (dx, dy) => {
|
useKeyframeDrag(nodeSVG, nodeLeft, props, (dx, dy) => {
|
||||||
const handleX = clamp(cur.handles[2] + dx * viewboxToElWidthRatio, 0, 1)
|
// TODO - document this
|
||||||
const handleY = cur.handles[3] - dy * viewboxToElHeightRatio
|
const handleX = clamp(left.handles[2] + dx * viewboxToElWidthRatio, 0, 1)
|
||||||
|
const handleY = left.handles[3] - dy * viewboxToElHeightRatio
|
||||||
return [handleX, handleY, next.handles[0], next.handles[1]]
|
return [handleX, handleY, right.handles[0], right.handles[1]]
|
||||||
})
|
})
|
||||||
|
|
||||||
const [refRight, nodeRight] = useRefAndState<SVGCircleElement | null>(null)
|
const [refRight, nodeRight] = useRefAndState<SVGCircleElement | null>(null)
|
||||||
useKeyframeDrag(nodeSVG, nodeRight, props, (dx, dy) => {
|
useKeyframeDrag(nodeSVG, nodeRight, props, (dx, dy) => {
|
||||||
const handleX = clamp(next.handles[0] + dx * viewboxToElWidthRatio, 0, 1)
|
// TODO - document this
|
||||||
const handleY = next.handles[1] - dy * viewboxToElHeightRatio
|
const handleX = clamp(right.handles[0] + dx * viewboxToElWidthRatio, 0, 1)
|
||||||
|
const handleY = right.handles[1] - dy * viewboxToElHeightRatio
|
||||||
return [cur.handles[2], cur.handles[3], handleX, handleY]
|
return [left.handles[2], left.handles[3], handleX, handleY]
|
||||||
})
|
})
|
||||||
|
|
||||||
const curvePathDAttrValue = `M0 ${toExtremumSpace(1)} C${
|
const curvePathDAttrValue = (connection: KeyframeConnectionWithAddress) =>
|
||||||
cur.handles[2]
|
`M0 ${toExtremumSpace(1)} C${connection.left.handles[2]} ${toExtremumSpace(
|
||||||
} ${toExtremumSpace(1 - cur.handles[3])}
|
1 - connection.left.handles[3],
|
||||||
${next.handles[0]} ${toExtremumSpace(1 - next.handles[1])} 1 ${toExtremumSpace(
|
)} ${connection.right.handles[0]} ${toExtremumSpace(
|
||||||
0,
|
1 - connection.right.handles[1],
|
||||||
)}`
|
)} 1 ${toExtremumSpace(0)}`
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<svg
|
<svg
|
||||||
|
@ -173,8 +183,8 @@ ${next.handles[0]} ${toExtremumSpace(1 - next.handles[1])} 1 ${toExtremumSpace(
|
||||||
<line
|
<line
|
||||||
x1={0}
|
x1={0}
|
||||||
y1={toExtremumSpace(1)}
|
y1={toExtremumSpace(1)}
|
||||||
x2={cur.handles[2]}
|
x2={left.handles[2]}
|
||||||
y2={toExtremumSpace(1 - cur.handles[3])}
|
y2={toExtremumSpace(1 - left.handles[3])}
|
||||||
stroke={CONTROL_COLOR}
|
stroke={CONTROL_COLOR}
|
||||||
strokeWidth="0.01"
|
strokeWidth="0.01"
|
||||||
/>
|
/>
|
||||||
|
@ -182,26 +192,35 @@ ${next.handles[0]} ${toExtremumSpace(1 - next.handles[1])} 1 ${toExtremumSpace(
|
||||||
<line
|
<line
|
||||||
x1={1}
|
x1={1}
|
||||||
y1={toExtremumSpace(0)}
|
y1={toExtremumSpace(0)}
|
||||||
x2={next.handles[0]}
|
x2={right.handles[0]}
|
||||||
y2={toExtremumSpace(1 - next.handles[1])}
|
y2={toExtremumSpace(1 - right.handles[1])}
|
||||||
stroke={CONTROL_COLOR}
|
stroke={CONTROL_COLOR}
|
||||||
strokeWidth="0.01"
|
strokeWidth="0.01"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Curve "shadow": the low-opacity filled area between the curve and the diagonal */}
|
{/* Curve "shadow": the low-opacity filled area between the curve and the diagonal */}
|
||||||
<path
|
<path
|
||||||
d={curvePathDAttrValue}
|
d={curvePathDAttrValue(props.curveConnection)}
|
||||||
stroke="none"
|
stroke="none"
|
||||||
fill="url('#myGradient')"
|
fill="url('#myGradient')"
|
||||||
opacity="0.1"
|
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 */}
|
{/* The curve */}
|
||||||
<path
|
<path
|
||||||
d={curvePathDAttrValue}
|
d={curvePathDAttrValue(props.curveConnection)}
|
||||||
stroke="url('#myGradient')"
|
stroke="url('#myGradient')"
|
||||||
strokeWidth="0.02"
|
strokeWidth="0.02"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Right end of curve */}
|
{/* Right end of curve */}
|
||||||
<circle
|
<circle
|
||||||
cx={0}
|
cx={0}
|
||||||
|
@ -224,21 +243,24 @@ ${next.handles[0]} ${toExtremumSpace(1 - next.handles[1])} 1 ${toExtremumSpace(
|
||||||
{/* Right handle and hit zone */}
|
{/* Right handle and hit zone */}
|
||||||
<HitZone
|
<HitZone
|
||||||
ref={refLeft}
|
ref={refLeft}
|
||||||
cx={cur.handles[2]}
|
cx={left.handles[2]}
|
||||||
cy={toExtremumSpace(1 - cur.handles[3])}
|
cy={toExtremumSpace(1 - left.handles[3])}
|
||||||
fill={CURVE_START_COLOR}
|
fill={CURVE_START_COLOR}
|
||||||
opacity={0.2}
|
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 */}
|
{/* Left handle and hit zone */}
|
||||||
<HitZone
|
<HitZone
|
||||||
ref={refRight}
|
ref={refRight}
|
||||||
cx={next.handles[0]}
|
cx={right.handles[0]}
|
||||||
cy={toExtremumSpace(1 - next.handles[1])}
|
cy={toExtremumSpace(1 - right.handles[1])}
|
||||||
fill={CURVE_END_COLOR}
|
fill={CURVE_END_COLOR}
|
||||||
opacity={0.2}
|
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>
|
</svg>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -247,7 +269,7 @@ export default CurveSegmentEditor
|
||||||
function useKeyframeDrag(
|
function useKeyframeDrag(
|
||||||
svgNode: SVGSVGElement | null,
|
svgNode: SVGSVGElement | null,
|
||||||
node: SVGCircleElement | null,
|
node: SVGCircleElement | null,
|
||||||
props: IProps,
|
props: ICurveSegmentEditorProps,
|
||||||
setHandles: (dx: number, dy: number) => CubicBezierHandles,
|
setHandles: (dx: number, dy: number) => CubicBezierHandles,
|
||||||
): void {
|
): void {
|
||||||
const handlers = useFreezableMemo<Parameters<typeof useDrag>[1]>(
|
const handlers = useFreezableMemo<Parameters<typeof useDrag>[1]>(
|
||||||
|
@ -269,7 +291,7 @@ function useKeyframeDrag(
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
[svgNode, props.trackData],
|
[svgNode, props.onCurveChange, props.onCancelCurveChange],
|
||||||
)
|
)
|
||||||
|
|
||||||
useDrag(node, handlers)
|
useDrag(node, handlers)
|
||||||
|
|
|
@ -57,8 +57,6 @@ const Container = styled.div<IConnectorThemeValues>`
|
||||||
|
|
||||||
type IConnectorLineProps = React.PropsWithChildren<{
|
type IConnectorLineProps = React.PropsWithChildren<{
|
||||||
isPopoverOpen: boolean
|
isPopoverOpen: boolean
|
||||||
/** TEMP: Remove once interactivity is added for aggregate? */
|
|
||||||
mvpIsInteractiveDisabled?: boolean
|
|
||||||
openPopover?: (event: React.MouseEvent) => void
|
openPopover?: (event: React.MouseEvent) => void
|
||||||
isSelected: boolean
|
isSelected: boolean
|
||||||
connectorLengthInUnitSpace: number
|
connectorLengthInUnitSpace: number
|
||||||
|
@ -82,7 +80,6 @@ export const ConnectorLine = React.forwardRef<
|
||||||
transform: `scaleX(calc(var(--unitSpaceToScaledSpaceMultiplier) * ${
|
transform: `scaleX(calc(var(--unitSpaceToScaledSpaceMultiplier) * ${
|
||||||
props.connectorLengthInUnitSpace / CONNECTOR_WIDTH_UNSCALED
|
props.connectorLengthInUnitSpace / CONNECTOR_WIDTH_UNSCALED
|
||||||
}))`,
|
}))`,
|
||||||
pointerEvents: props.mvpIsInteractiveDisabled ? 'none' : undefined,
|
|
||||||
}}
|
}}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
props.openPopover?.(e)
|
props.openPopover?.(e)
|
||||||
|
|
|
@ -16,29 +16,42 @@ import type {Keyframe} from '@theatre/core/projects/store/types/SheetState_Histo
|
||||||
* keyframe in the connection is selected
|
* keyframe in the connection is selected
|
||||||
*/
|
*/
|
||||||
export function isKeyframeConnectionInSelection(
|
export function isKeyframeConnectionInSelection(
|
||||||
keyframeConnection: [Keyframe, Keyframe],
|
keyframeConnection: {left: Keyframe; right: Keyframe},
|
||||||
selection: DopeSheetSelection,
|
selection: DopeSheetSelection,
|
||||||
): boolean {
|
): boolean {
|
||||||
for (const {keyframeId} of flatSelectionKeyframeIds(selection)) {
|
for (const {keyframeId} of flatSelectionKeyframeIds(selection)) {
|
||||||
if (keyframeConnection[0].id === keyframeId) return true
|
if (keyframeConnection.left.id === keyframeId) return true
|
||||||
}
|
}
|
||||||
return false
|
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
|
* Returns an array of all the selected keyframes
|
||||||
* that are connected to one another. Useful for changing
|
* that are connected to one another. Useful for changing
|
||||||
* the tweening in between keyframes.
|
* 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(
|
export function selectedKeyframeConnections(
|
||||||
projectId: ProjectId,
|
projectId: ProjectId,
|
||||||
sheetId: SheetId,
|
sheetId: SheetId,
|
||||||
selection: DopeSheetSelection | undefined,
|
selection: DopeSheetSelection | undefined,
|
||||||
): IDerivation<Array<[left: Keyframe, right: Keyframe]>> {
|
): IDerivation<Array<KeyframeConnectionWithAddress>> {
|
||||||
return prism(() => {
|
return prism(() => {
|
||||||
if (selection === undefined) return []
|
if (selection === undefined) return []
|
||||||
|
|
||||||
let ckfs: Array<[Keyframe, Keyframe]> = []
|
let ckfs: Array<KeyframeConnectionWithAddress> = []
|
||||||
|
|
||||||
for (const {objectKey, trackId} of flatSelectionTrackIds(selection)) {
|
for (const {objectKey, trackId} of flatSelectionTrackIds(selection)) {
|
||||||
const track = val(
|
const track = val(
|
||||||
|
@ -48,9 +61,16 @@ export function selectedKeyframeConnections(
|
||||||
|
|
||||||
if (track) {
|
if (track) {
|
||||||
ckfs = ckfs.concat(
|
ckfs = ckfs.concat(
|
||||||
keyframeConnections(track.keyframes).filter((kfc) =>
|
keyframeConnections(track.keyframes)
|
||||||
isKeyframeConnectionInSelection(kfc, selection),
|
.filter((kfc) => isKeyframeConnectionInSelection(kfc, selection))
|
||||||
),
|
.map(({left, right}) => ({
|
||||||
|
left,
|
||||||
|
right,
|
||||||
|
trackId,
|
||||||
|
objectKey,
|
||||||
|
sheetId,
|
||||||
|
projectId,
|
||||||
|
})),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -60,10 +80,10 @@ export function selectedKeyframeConnections(
|
||||||
|
|
||||||
export function keyframeConnections(
|
export function keyframeConnections(
|
||||||
keyframes: Array<Keyframe>,
|
keyframes: Array<Keyframe>,
|
||||||
): Array<[Keyframe, Keyframe]> {
|
): Array<{left: Keyframe; right: Keyframe}> {
|
||||||
return keyframes
|
return keyframes
|
||||||
.map((kf, i) => [kf, keyframes[i + 1]] as [Keyframe, Keyframe])
|
.map((kf, i) => ({left: kf, right: keyframes[i + 1]}))
|
||||||
.slice(0, -1) // remmove the last entry because it is [kf, undefined]
|
.slice(0, -1) // remmove the last entry because it is { left: kf, right: undefined }
|
||||||
}
|
}
|
||||||
|
|
||||||
export function flatSelectionKeyframeIds(selection: DopeSheetSelection): Array<{
|
export function flatSelectionKeyframeIds(selection: DopeSheetSelection): Array<{
|
||||||
|
|
|
@ -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(
|
export function setTweenBetweenKeyframes(
|
||||||
p: WithoutSheetInstance<SheetObjectAddress> & {
|
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(
|
export function deleteKeyframes(
|
||||||
p: WithoutSheetInstance<SheetObjectAddress> & {
|
p: WithoutSheetInstance<SheetObjectAddress> & {
|
||||||
trackId: SequenceTrackId
|
trackId: SequenceTrackId
|
||||||
|
|
|
@ -1,11 +1,5 @@
|
||||||
import React, {
|
import useRefAndState from '@theatre/studio/utils/useRefAndState'
|
||||||
useCallback,
|
import React, {useCallback, useContext, useEffect, useRef} from 'react'
|
||||||
useContext,
|
|
||||||
useEffect,
|
|
||||||
useMemo,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
} from 'react'
|
|
||||||
import {createPortal} from 'react-dom'
|
import {createPortal} from 'react-dom'
|
||||||
import {PortalContext} from 'reakit'
|
import {PortalContext} from 'reakit'
|
||||||
import type {AbsolutePlacementBoxConstraints} from './TooltipWrapper'
|
import type {AbsolutePlacementBoxConstraints} from './TooltipWrapper'
|
||||||
|
@ -25,6 +19,12 @@ type State =
|
||||||
clientY: number
|
clientY: number
|
||||||
}
|
}
|
||||||
target: HTMLElement
|
target: HTMLElement
|
||||||
|
opts: Opts
|
||||||
|
onPointerOutside?: {
|
||||||
|
threshold: number
|
||||||
|
callback: (e: MouseEvent) => void
|
||||||
|
}
|
||||||
|
onClickOutside: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const PopoverAutoCloseLock = React.createContext({
|
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(
|
export default function usePopover(
|
||||||
opts: {
|
opts: Opts | (() => Opts),
|
||||||
debugName: string
|
|
||||||
closeWhenPointerIsDistant?: boolean
|
|
||||||
pointerDistanceThreshold?: number
|
|
||||||
closeOnClickOutside?: boolean
|
|
||||||
constraints?: AbsolutePlacementBoxConstraints
|
|
||||||
},
|
|
||||||
render: () => React.ReactElement,
|
render: () => React.ReactElement,
|
||||||
): [node: React.ReactNode, open: OpenFn, close: CloseFn, isOpen: boolean] {
|
): [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,
|
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) => {
|
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,
|
isOpen: true,
|
||||||
clickPoint: {clientX: e.clientX, clientY: e.clientY},
|
clickPoint: {clientX: e.clientX, clientY: e.clientY},
|
||||||
target,
|
target,
|
||||||
})
|
opts,
|
||||||
}, [])
|
onClickOutside: onClickOutside,
|
||||||
|
onPointerOutside:
|
||||||
const close = useCallback<CloseFn>((reason) => {
|
opts.closeWhenPointerIsDistant === false
|
||||||
_debug(`closing due to "${reason}"`)
|
? undefined
|
||||||
setState({isOpen: false})
|
: {
|
||||||
|
threshold: opts.pointerDistanceThreshold ?? 100,
|
||||||
|
callback: () => {
|
||||||
|
if (lock.childHasFocusRef.current) return
|
||||||
|
close('pointer outside')
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -76,24 +104,7 @@ export default function usePopover(
|
||||||
state,
|
state,
|
||||||
})
|
})
|
||||||
|
|
||||||
const onClickOutside = useCallback(() => {
|
|
||||||
if (lock.childHasFocusRef.current) return
|
|
||||||
if (opts.closeOnClickOutside !== false) {
|
|
||||||
close('clicked outside popover')
|
|
||||||
}
|
|
||||||
}, [opts.closeOnClickOutside])
|
|
||||||
|
|
||||||
const portalLayer = useContext(PortalContext)
|
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 ? (
|
const node = state.isOpen ? (
|
||||||
createPortal(
|
createPortal(
|
||||||
|
@ -101,9 +112,9 @@ export default function usePopover(
|
||||||
<TooltipWrapper
|
<TooltipWrapper
|
||||||
children={render}
|
children={render}
|
||||||
target={state.target}
|
target={state.target}
|
||||||
onClickOutside={onClickOutside}
|
onClickOutside={state.onClickOutside}
|
||||||
onPointerOutside={onPointerOutside}
|
onPointerOutside={state.onPointerOutside}
|
||||||
constraints={opts.constraints}
|
constraints={state.opts.constraints}
|
||||||
/>
|
/>
|
||||||
</PopoverAutoCloseLock.Provider>,
|
</PopoverAutoCloseLock.Provider>,
|
||||||
portalLayer!,
|
portalLayer!,
|
||||||
|
|
Loading…
Reference in a new issue