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. * 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) {

View file

@ -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(),
constraints: {
minX: rightDims.screenX + POPOVER_MARGIN_PX,
maxX: rightDims.screenX + rightDims.width - POPOVER_MARGIN_PX,
},
} }
: null },
() => {
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

View file

@ -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(
{ () => {
const rightDims = val(props.layoutP.rightDims)
return {
debugName: 'Connector', debugName: 'Connector',
closeWhenPointerIsDistant: !isPointerBeingCaptured(), closeWhenPointerIsDistant: !isPointerBeingCaptured(),
constraints: { constraints: {
minX: rightDims.screenX + POPOVER_MARGIN, minX: rightDims.screenX + POPOVER_MARGIN,
maxX: rightDims.screenX + rightDims.width - POPOVER_MARGIN, maxX: rightDims.screenX + rightDims.width - POPOVER_MARGIN,
}, },
}
}, },
() => { () => <SingleCurveEditorPopover {...props} closePopover={closePopover} />,
return (
<EasingPopover showPopoverEdgeTriangle={false}>
<CurveEditorPopover {...props} onRequestClose={closePopover} />
</EasingPopover>
)
},
) )
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,

View file

@ -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)
setTempValue(tempTransaction, props, cur, next, preview ?? edit ?? '')
}, [preview, edit])
////// selection stuff //////
let selectedConnections: Array<[Keyframe, Keyframe]> = useVal(
selectedKeyframeConnections(
props.leaf.sheetObject.address.projectId,
props.leaf.sheetObject.address.sheetId,
props.selection,
),
)
if ( if (
selectedConnections.some(areConnectedKeyframesTheSameAs([cur, next])) && textInputMode !== TextInputMode.init &&
textInputMode !== TextInputMode.multipleValues
)
setTempValue(tempTransaction, allConnections, preview ?? edit ?? '')
}, [preview, edit, textInputMode])
////// selection stuff //////
if (
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,
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, objectKey,
trackId, trackId,
keyframeIds, left,
handles, 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) 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(() => { isCurveEditorOpenD: prism(() => {
return idsOfOpenCurveEditors.derivation.getValue().length > 0 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 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)

View file

@ -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)

View file

@ -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<{

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( 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

View file

@ -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({
}, },
}) })
export default function usePopover( type Opts = {
opts: {
debugName: string debugName: string
closeWhenPointerIsDistant?: boolean closeWhenPointerIsDistant?: boolean
pointerDistanceThreshold?: number pointerDistanceThreshold?: number
closeOnClickOutside?: boolean closeOnClickOutside?: boolean
constraints?: AbsolutePlacementBoxConstraints constraints?: AbsolutePlacementBoxConstraints
}, }
export default function usePopover(
opts: Opts | (() => Opts),
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!,