Reorganize AggregateKeyframeEditor

This commit is contained in:
Cole Lawrence 2022-05-30 08:59:08 -04:00
parent f222cc61dd
commit eb15229463
8 changed files with 659 additions and 381 deletions

View file

@ -1,373 +0,0 @@
import type {
Keyframe,
TrackData,
} from '@theatre/core/projects/store/types/SheetState_Historic'
import type {
DopeSheetSelection,
SequenceEditorPanelLayout,
} from '@theatre/studio/panels/SequenceEditorPanel/layout/layout'
import type {
SequenceEditorTree_PropWithChildren,
SequenceEditorTree_SheetObject,
} from '@theatre/studio/panels/SequenceEditorPanel/layout/tree'
import type {Pointer} from '@theatre/dataverse'
import {prism} from '@theatre/dataverse'
import {val} from '@theatre/dataverse'
import React from 'react'
import styled from 'styled-components'
import type {SequenceTrackId} from '@theatre/shared/utils/ids'
import {ConnectorLine} from '@theatre/studio/panels/SequenceEditorPanel/DopeSheet/Right/keyframeRowUI/ConnectorLine'
import {AggregateKeyframePositionIsSelected} from './AggregatedKeyframeTrack'
import type {KeyframeWithTrack} from '@theatre/studio/panels/SequenceEditorPanel/DopeSheet/Right/collectAggregateKeyframes'
import {DopeSnapHitZoneUI} from '@theatre/studio/panels/SequenceEditorPanel/RightOverlay/DopeSnapHitZoneUI'
import {absoluteDims} from '@theatre/studio/utils/absoluteDims'
import BasicPopover from '@theatre/studio/uiComponents/Popover/BasicPopover'
import {usePointerCapturing} from '@theatre/studio/UIRoot/PointerCapturing'
import usePopover from '@theatre/studio/uiComponents/Popover/usePopover'
import CurveEditorPopover, {
isConnectionEditingInCurvePopover,
} from '@theatre/studio/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/CurveEditorPopover/CurveEditorPopover'
import useRefAndState from '@theatre/studio/utils/useRefAndState'
import {usePrism} from '@theatre/react'
import {selectedKeyframeConnections} from '@theatre/studio/panels/SequenceEditorPanel/DopeSheet/selections'
import type {SheetObjectAddress} from '@theatre/shared/utils/addresses'
const POPOVER_MARGIN_PX = 5
const AggregateKeyframeEditorContainer = styled.div`
position: absolute;
`
const EasingPopoverWrapper = styled(BasicPopover)`
--popover-outer-stroke: transparent;
--popover-inner-stroke: rgba(26, 28, 30, 0.97);
`
const noConnector = <></>
export type IAggregateKeyframesAtPosition = {
position: number
/** all tracks have a keyframe for this position (otherwise, false means 'partial') */
allHere: boolean
selected: AggregateKeyframePositionIsSelected | undefined
keyframes: {
kf: Keyframe
track: {
id: SequenceTrackId
data: TrackData
}
}[]
}
type AggregatedKeyframeConnection = SheetObjectAddress & {
trackId: SequenceTrackId
left: Keyframe
right: Keyframe
}
export type IAggregateKeyframeEditorProps = {
index: number
aggregateKeyframes: IAggregateKeyframesAtPosition[]
layoutP: Pointer<SequenceEditorPanelLayout>
viewModel:
| SequenceEditorTree_PropWithChildren
| SequenceEditorTree_SheetObject
selection: undefined | DopeSheetSelection
}
/**
* TODO we're spending a lot of cycles on each render of each aggreagte keyframes.
*
* Each keyframe node is doing O(N) operations, N being the number of underlying
* keyframes it represetns.
*
* The biggest example is the `isConnectionEditingInCurvePopover()` call which is run
* for every underlying keyframe, every time this component is rendered.
*
* We can optimize this away by doing all of this work _once_ when a curve editor popover
* is open. This would require having some kind of stable identity for each aggregate row.
* Let's defer that work until other interactive keyframe editing PRs are merged in.
*/
const AggregateKeyframeEditor: React.VFC<IAggregateKeyframeEditorProps> = (
props,
) => {
const {cur, connected, isAggregateEditingInCurvePopover} =
useAggregateKeyframeEditorUtils(props)
const {isPointerBeingCaptured} = usePointerCapturing(
'AggregateKeyframeEditor Connector',
)
const [popoverNode, openPopover, closePopover] = usePopover(
() => {
const rightDims = val(props.layoutP.rightDims)
return {
debugName: 'Connector',
closeWhenPointerIsDistant: !isPointerBeingCaptured(),
constraints: {
minX: rightDims.screenX + POPOVER_MARGIN_PX,
maxX: rightDims.screenX + rightDims.width - POPOVER_MARGIN_PX,
},
}
},
() => {
return (
<AggregateCurveEditorPopover {...props} closePopover={closePopover} />
)
},
)
const [nodeRef, node] = useRefAndState<HTMLDivElement | null>(null)
return (
<AggregateKeyframeEditorContainer
style={{
top: `${props.viewModel.nodeHeight / 2}px`,
left: `calc(${val(
props.layoutP.scaledSpace.leftPadding,
)}px + calc(var(--unitSpaceToScaledSpaceMultiplier) * ${
cur.position
}px))`,
}}
>
<AggregateKeyframeDot
keyframes={cur.keyframes}
position={cur.position}
theme={{
isSelected: cur.selected,
}}
isAllHere={cur.allHere}
/>
{connected ? (
<ConnectorLine
ref={nodeRef}
connectorLengthInUnitSpace={connected.length}
isPopoverOpen={isAggregateEditingInCurvePopover}
// if all keyframe aggregates are selected
isSelected={connected.selected}
openPopover={(e) => {
if (node) openPopover(e, node)
}}
/>
) : (
noConnector
)}
{popoverNode}
</AggregateKeyframeEditorContainer>
)
}
function useAggregateKeyframeEditorUtils(
props: Pick<
IAggregateKeyframeEditorProps,
'index' | 'aggregateKeyframes' | 'selection' | 'viewModel'
>,
) {
const {index, aggregateKeyframes, selection} = props
const sheetObjectAddress = props.viewModel.sheetObject.address
return usePrism(() => {
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) => ({
...sheetObjectAddress,
trackId: track.id,
left: kf,
right: next.keyframes[i].kf,
}))
const {projectId, sheetId} = sheetObjectAddress
const selectedConnections = prism
.memo(
'selectedConnections',
() =>
selectedKeyframeConnections(
sheetObjectAddress.projectId,
sheetObjectAddress.sheetId,
selection,
),
[projectId, sheetId, selection],
)
.getValue()
const allConnections = [...aggregatedConnections, ...selectedConnections]
const isAggregateEditingInCurvePopover = aggregatedConnections.every(
(con) => isConnectionEditingInCurvePopover(con),
)
return {cur, connected, isAggregateEditingInCurvePopover, allConnections}
}, [index, aggregateKeyframes, selection, sheetObjectAddress])
}
const AggregateCurveEditorPopover: React.FC<
IAggregateKeyframeEditorProps & {closePopover: (reason: string) => void}
> = React.forwardRef((props, ref) => {
const {allConnections} = useAggregateKeyframeEditorUtils(props)
return (
<EasingPopoverWrapper
showPopoverEdgeTriangle={false}
// @ts-ignore @todo
ref={ref}
>
<CurveEditorPopover
curveConnection={allConnections[0]}
additionalConnections={allConnections}
onRequestClose={props.closePopover}
/>
</EasingPopoverWrapper>
)
})
const DOT_SIZE_PX = 16
const DOT_HOVER_SIZE_PX = DOT_SIZE_PX + 5
/** The keyframe diamond ◆ */
const DotContainer = styled.div`
position: absolute;
${absoluteDims(DOT_SIZE_PX)}
z-index: 1;
`
const HitZone = styled.div`
z-index: 2;
/* TEMP: Disabled until interactivity */
/* cursor: ew-resize; */
${DopeSnapHitZoneUI.CSS}
#pointer-root.draggingPositionInSequenceEditor & {
${DopeSnapHitZoneUI.CSS_WHEN_SOMETHING_DRAGGING}
}
/* TEMP: Disabled until interactivity */
/* &:hover + ${DotContainer}, */
#pointer-root.draggingPositionInSequenceEditor &:hover + ${DotContainer},
// notice "," css "or"
&.${DopeSnapHitZoneUI.BEING_DRAGGED_CLASS} + ${DotContainer} {
${absoluteDims(DOT_HOVER_SIZE_PX)}
}
`
const AggregateKeyframeDot = React.forwardRef(AggregateKeyframeDot_ref)
function AggregateKeyframeDot_ref(
props: React.PropsWithChildren<{
theme: IDotThemeValues
isAllHere: boolean
position: number
keyframes: KeyframeWithTrack[]
}>,
ref: React.ForwardedRef<HTMLDivElement>,
) {
return (
<>
<HitZone
ref={ref}
{...DopeSnapHitZoneUI.reactProps({
isDragging: false,
position: props.position,
})}
/>
<DotContainer>
{props.isAllHere ? (
<AggregateDotAllHereSvg {...props.theme} />
) : (
<AggregateDotSomeHereSvg {...props.theme} />
)}
</DotContainer>
</>
)
}
type IDotThemeValues = {
isSelected: AggregateKeyframePositionIsSelected | undefined
}
const SELECTED_COLOR = '#b8e4e2'
const DEFAULT_PRIMARY_COLOR = '#40AAA4'
const DEFAULT_SECONDARY_COLOR = '#45747C'
const selectionColorAll = (theme: IDotThemeValues) =>
theme.isSelected === AggregateKeyframePositionIsSelected.AllSelected
? SELECTED_COLOR
: theme.isSelected ===
AggregateKeyframePositionIsSelected.AtLeastOneUnselected
? DEFAULT_PRIMARY_COLOR
: DEFAULT_SECONDARY_COLOR
const selectionColorSome = (theme: IDotThemeValues) =>
theme.isSelected === AggregateKeyframePositionIsSelected.AllSelected
? SELECTED_COLOR
: theme.isSelected ===
AggregateKeyframePositionIsSelected.AtLeastOneUnselected
? DEFAULT_PRIMARY_COLOR
: DEFAULT_SECONDARY_COLOR
const AggregateDotAllHereSvg = (theme: IDotThemeValues) => (
<svg
width="100%"
height="100%"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<rect
x="4.46443"
y="10.0078"
width="5"
height="5"
transform="rotate(-45 4.46443 10.0078)"
fill="#212327" // background knockout fill
stroke={selectionColorSome(theme)}
/>
<rect
x="3.75732"
y="6.01953"
width="6"
height="6"
transform="rotate(-45 3.75732 6.01953)"
fill={selectionColorAll(theme)}
/>
</svg>
)
// when the aggregate keyframes are sparse across tracks at this position
const AggregateDotSomeHereSvg = (theme: IDotThemeValues) => (
<svg
width="100%"
height="100%"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<rect
x="4.46443"
y="8"
width="5"
height="5"
transform="rotate(-45 4.46443 8)"
fill="#23262B"
stroke={selectionColorAll(theme)}
/>
</svg>
)
export default AggregateKeyframeEditor

View file

@ -0,0 +1,183 @@
import {val} from '@theatre/dataverse'
import React, {useMemo, useRef} from 'react'
import {ConnectorLine} from '@theatre/studio/panels/SequenceEditorPanel/DopeSheet/Right/keyframeRowUI/ConnectorLine'
import {AggregateKeyframePositionIsSelected} from '@theatre/studio/panels/SequenceEditorPanel/DopeSheet/Right/AggregatedKeyframeTrack/AggregatedKeyframeTrack'
import usePopover from '@theatre/studio/uiComponents/Popover/usePopover'
import useRefAndState from '@theatre/studio/utils/useRefAndState'
import type {UseDragOpts} from '@theatre/studio/uiComponents/useDrag'
import type {CommitOrDiscard} from '@theatre/studio/StudioStore/StudioStore'
import getStudio from '@theatre/studio/getStudio'
import useDrag from '@theatre/studio/uiComponents/useDrag'
import type {IAggregateKeyframeEditorUtils} from './useAggregateKeyframeEditorUtils'
import CurveEditorPopover from '@theatre/studio/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/CurveEditorPopover/CurveEditorPopover'
import {useAggregateKeyframeEditorUtils} from './useAggregateKeyframeEditorUtils'
import type {IAggregateKeyframeEditorProps} from './AggregateKeyframeEditor'
import styled from 'styled-components'
import BasicPopover from '@theatre/studio/uiComponents/Popover/BasicPopover'
const POPOVER_MARGIN_PX = 5
const EasingPopoverWrapper = styled(BasicPopover)`
--popover-outer-stroke: transparent;
--popover-inner-stroke: rgba(26, 28, 30, 0.97);
`
export 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>
)
})
export const AggregateKeyframeConnector: React.VFC<IAggregateKeyframeConnectorProps> =
(props) => {
const [nodeRef, node] = useRefAndState<HTMLDivElement | null>(null)
const {editorProps} = props
const [isDragging] = useDragKeyframe(node, props.editorProps)
const [popoverNode, openPopover, closePopover] = usePopover(
() => {
const rightDims = val(editorProps.layoutP.rightDims)
return {
debugName: 'Connector',
constraints: {
minX: rightDims.screenX + POPOVER_MARGIN_PX,
maxX: rightDims.screenX + rightDims.width - POPOVER_MARGIN_PX,
},
}
},
() => {
return (
<AggregateCurveEditorPopover
{...editorProps}
closePopover={closePopover}
/>
)
},
)
const {connected, isAggregateEditingInCurvePopover} = props.utils
// We don't want to interrupt an existing drag, so in order to persist the dragged
// html node, we just set the connector length to 0, but we don't remove it yet.
return connected || isDragging ? (
<>
<ConnectorLine
ref={nodeRef}
connectorLengthInUnitSpace={connected ? connected.length : 0}
isSelected={connected ? connected.selected : false}
isPopoverOpen={isAggregateEditingInCurvePopover}
openPopover={(e) => {
if (node) openPopover(e, node)
}}
/>
{popoverNode}
</>
) : (
<></>
)
}
type IAggregateKeyframeConnectorProps = {
utils: IAggregateKeyframeEditorUtils
editorProps: IAggregateKeyframeEditorProps
}
function useDragKeyframe(
node: HTMLDivElement | null,
editorProps: IAggregateKeyframeEditorProps,
) {
const propsRef = useRef(editorProps)
propsRef.current = editorProps
const gestureHandlers = useMemo<UseDragOpts>(() => {
return {
debugName: 'useDragKeyframe',
lockCSSCursorTo: 'ew-resize',
onDragStart(event) {
const props = propsRef.current
let tempTransaction: CommitOrDiscard | undefined
const keyframes = props.aggregateKeyframes[props.index].keyframes
if (
props.selection &&
props.aggregateKeyframes[props.index].selected ===
AggregateKeyframePositionIsSelected.AllSelected
) {
const {selection, viewModel} = props
const {sheetObject} = viewModel
return selection
.getDragHandlers({
...sheetObject.address,
domNode: node!,
positionAtStartOfDrag:
props.aggregateKeyframes[props.index].position,
})
.onDragStart(event)
}
const propsAtStartOfDrag = props
const sequence = val(propsAtStartOfDrag.layoutP.sheet).getSequence()
const toUnitSpace = val(
propsAtStartOfDrag.layoutP.scaledSpace.toUnitSpace,
)
return {
onDrag(dx, dy, event) {
const delta = toUnitSpace(dx)
if (tempTransaction) {
tempTransaction.discard()
tempTransaction = undefined
}
tempTransaction = getStudio().tempTransaction(({stateEditors}) => {
for (const keyframe of keyframes) {
stateEditors.coreByProject.historic.sheetsById.sequence.transformKeyframes(
{
...propsAtStartOfDrag.viewModel.sheetObject.address,
trackId: keyframe.track.id,
keyframeIds: [
keyframe.kf.id,
keyframe.track.data.keyframes[
keyframe.track.data.keyframes.indexOf(keyframe.kf) + 1
].id,
],
translate: delta,
scale: 1,
origin: 0,
snappingFunction: sequence.closestGridPosition,
},
)
}
})
},
onDragEnd(dragHappened) {
if (dragHappened) {
if (tempTransaction) {
tempTransaction.commit()
}
} else {
if (tempTransaction) {
tempTransaction.discard()
}
}
},
}
},
}
}, [])
return useDrag(node, gestureHandlers)
}

View file

@ -0,0 +1,173 @@
import {val} from '@theatre/dataverse'
import React, {useMemo, useRef} from 'react'
import {AggregateKeyframePositionIsSelected} from '@theatre/studio/panels/SequenceEditorPanel/DopeSheet/Right/AggregatedKeyframeTrack/AggregatedKeyframeTrack'
import {DopeSnapHitZoneUI} from '@theatre/studio/panels/SequenceEditorPanel/RightOverlay/DopeSnapHitZoneUI'
import useRefAndState from '@theatre/studio/utils/useRefAndState'
import type {UseDragOpts} from '@theatre/studio/uiComponents/useDrag'
import type {CommitOrDiscard} from '@theatre/studio/StudioStore/StudioStore'
import getStudio from '@theatre/studio/getStudio'
import useDrag from '@theatre/studio/uiComponents/useDrag'
import {useLogger} from '@theatre/studio/uiComponents/useLogger'
import useContextMenu from '@theatre/studio/uiComponents/simpleContextMenu/useContextMenu'
import {useLockFrameStampPosition} from '@theatre/studio/panels/SequenceEditorPanel/FrameStampPositionProvider'
import {useCssCursorLock} from '@theatre/studio/uiComponents/PointerEventsHandler'
import DopeSnap from '@theatre/studio/panels/SequenceEditorPanel/RightOverlay/DopeSnap'
import type {IAggregateKeyframeEditorProps} from './AggregateKeyframeEditor'
import type {IAggregateKeyframeEditorUtils} from './useAggregateKeyframeEditorUtils'
import {AggregateKeyframeVisualDot, HitZone} from './AggregateKeyframeVisualDot'
type IAggregateKeyframeDotProps = {
editorProps: IAggregateKeyframeEditorProps
utils: IAggregateKeyframeEditorUtils
}
export function AggregateKeyframeDot(
props: React.PropsWithChildren<IAggregateKeyframeDotProps>,
) {
const logger = useLogger('AggregateKeyframeDot')
const {cur} = props.utils
const [ref, node] = useRefAndState<HTMLDivElement | null>(null)
const [isDragging] = useDragForAggregateKeyframeDot(node, props, {
onClickFromDrag(dragStartEvent) {
// TODO Aggregate inline keyframe editor
// openEditor(dragStartEvent, ref.current!)
},
})
const [contextMenu] = useAggregateKeyframeContextMenu(node, () =>
logger._debug('Show Aggregate Keyframe', props),
)
return (
<>
<HitZone
ref={ref}
{...DopeSnapHitZoneUI.reactProps({
isDragging,
position: cur.position,
})}
/>
<AggregateKeyframeVisualDot
isAllHere={cur.allHere}
isSelected={cur.selected}
/>
{contextMenu}
</>
)
}
function useAggregateKeyframeContextMenu(
target: HTMLDivElement | null,
debugOnOpen: () => void,
) {
// TODO: missing features: delete, copy + paste
return useContextMenu(target, {
displayName: 'Aggregate Keyframe',
menuItems: () => {
return []
},
onOpen() {
debugOnOpen()
},
})
}
function useDragForAggregateKeyframeDot(
node: HTMLDivElement | null,
props: IAggregateKeyframeDotProps,
options: {
/**
* hmm: this is a hack so we can actually receive the
* {@link MouseEvent} from the drag event handler and use
* it for positioning the popup.
*/
onClickFromDrag(dragStartEvent: MouseEvent): void
},
): [isDragging: boolean] {
const propsRef = useRef(props.editorProps)
propsRef.current = props.editorProps
const keyframesRef = useRef(props.utils.cur.keyframes)
keyframesRef.current = props.utils.cur.keyframes
const useDragOpts = useMemo<UseDragOpts>(() => {
return {
debugName: 'AggregateKeyframeDot/useDragKeyframe',
onDragStart(event) {
const props = propsRef.current
const keyframes = keyframesRef.current
if (
props.selection &&
props.aggregateKeyframes[props.index].selected ===
AggregateKeyframePositionIsSelected.AllSelected
) {
const {selection, viewModel} = props
const {sheetObject} = viewModel
return selection
.getDragHandlers({
...sheetObject.address,
domNode: node!,
positionAtStartOfDrag: keyframes[0].kf.position,
})
.onDragStart(event)
}
const propsAtStartOfDrag = props
const toUnitSpace = val(
propsAtStartOfDrag.layoutP.scaledSpace.toUnitSpace,
)
let tempTransaction: CommitOrDiscard | undefined
return {
onDrag(dx, dy, event) {
const newPosition = Math.max(
// check if our event hoversover a [data-pos] element
DopeSnap.checkIfMouseEventSnapToPos(event, {
ignore: node,
}) ??
// if we don't find snapping target, check the distance dragged + original position
keyframes[0].kf.position + toUnitSpace(dx),
// sanitize to minimum of zero
0,
)
tempTransaction?.discard()
tempTransaction = undefined
tempTransaction = getStudio()!.tempTransaction(({stateEditors}) => {
for (const keyframe of keyframes) {
const original = keyframe.kf
stateEditors.coreByProject.historic.sheetsById.sequence.replaceKeyframes(
{
...propsAtStartOfDrag.viewModel.sheetObject.address,
trackId: keyframe.track.id,
keyframes: [{...original, position: newPosition}],
snappingFunction: val(
propsAtStartOfDrag.layoutP.sheet,
).getSequence().closestGridPosition,
},
)
}
})
},
onDragEnd(dragHappened) {
if (dragHappened) {
tempTransaction?.commit()
} else {
tempTransaction?.discard()
options.onClickFromDrag(event)
}
},
}
},
}
}, [])
const [isDragging] = useDrag(node, useDragOpts)
useLockFrameStampPosition(isDragging, props.utils.cur.position)
useCssCursorLock(isDragging, 'draggingPositionInSequenceEditor', 'ew-resize')
return [isDragging]
}

View file

@ -0,0 +1,85 @@
import type {Keyframe} from '@theatre/core/projects/store/types/SheetState_Historic'
import type {
DopeSheetSelection,
SequenceEditorPanelLayout,
} from '@theatre/studio/panels/SequenceEditorPanel/layout/layout'
import type {
SequenceEditorTree_PropWithChildren,
SequenceEditorTree_SheetObject,
} from '@theatre/studio/panels/SequenceEditorPanel/layout/tree'
import type {Pointer} from '@theatre/dataverse'
import {val} from '@theatre/dataverse'
import React from 'react'
import styled from 'styled-components'
import type {SequenceTrackId} from '@theatre/shared/utils/ids'
import type {AggregateKeyframePositionIsSelected} from '@theatre/studio/panels/SequenceEditorPanel/DopeSheet/Right/AggregatedKeyframeTrack/AggregatedKeyframeTrack'
import type {KeyframeWithTrack} from '@theatre/studio/panels/SequenceEditorPanel/DopeSheet/Right/collectAggregateKeyframes'
import type {SheetObjectAddress} from '@theatre/shared/utils/addresses'
import {AggregateKeyframeConnector} from './AggregateKeyframeConnector'
import {useAggregateKeyframeEditorUtils} from './useAggregateKeyframeEditorUtils'
import {AggregateKeyframeDot} from './AggregateKeyframeDot'
const AggregateKeyframeEditorContainer = styled.div`
position: absolute;
`
export type IAggregateKeyframesAtPosition = {
position: number
/** all tracks have a keyframe for this position (otherwise, false means 'partial') */
allHere: boolean
selected: AggregateKeyframePositionIsSelected | undefined
keyframes: KeyframeWithTrack[]
}
export type AggregatedKeyframeConnection = SheetObjectAddress & {
trackId: SequenceTrackId
left: Keyframe
right: Keyframe
}
export type IAggregateKeyframeEditorProps = {
index: number
aggregateKeyframes: IAggregateKeyframesAtPosition[]
layoutP: Pointer<SequenceEditorPanelLayout>
viewModel:
| SequenceEditorTree_PropWithChildren
| SequenceEditorTree_SheetObject
selection: undefined | DopeSheetSelection
}
/**
* TODO we're spending a lot of cycles on each render of each aggreagte keyframes.
*
* Each keyframe node is doing O(N) operations, N being the number of underlying
* keyframes it represetns.
*
* The biggest example is the `isConnectionEditingInCurvePopover()` call which is run
* for every underlying keyframe, every time this component is rendered.
*
* We can optimize this away by doing all of this work _once_ when a curve editor popover
* is open. This would require having some kind of stable identity for each aggregate row.
* Let's defer that work until other interactive keyframe editing PRs are merged in.
*/
const AggregateKeyframeEditor: React.VFC<IAggregateKeyframeEditorProps> = (
props,
) => {
const utils = useAggregateKeyframeEditorUtils(props)
return (
<AggregateKeyframeEditorContainer
style={{
top: `${props.viewModel.nodeHeight / 2}px`,
left: `calc(${val(
props.layoutP.scaledSpace.leftPadding,
)}px + calc(var(--unitSpaceToScaledSpaceMultiplier) * ${
utils.cur.position
}px))`,
}}
>
<AggregateKeyframeDot editorProps={props} utils={utils} />
<AggregateKeyframeConnector editorProps={props} utils={utils} />
</AggregateKeyframeEditorContainer>
)
}
export default AggregateKeyframeEditor

View file

@ -0,0 +1,121 @@
import React from 'react'
import {AggregateKeyframePositionIsSelected} from '@theatre/studio/panels/SequenceEditorPanel/DopeSheet/Right/AggregatedKeyframeTrack/AggregatedKeyframeTrack'
import styled from 'styled-components'
import {absoluteDims} from '@theatre/studio/utils/absoluteDims'
import {DopeSnapHitZoneUI} from '@theatre/studio/panels/SequenceEditorPanel/RightOverlay/DopeSnapHitZoneUI'
const DOT_SIZE_PX = 16
const DOT_HOVER_SIZE_PX = DOT_SIZE_PX + 5
/** The keyframe diamond ◆ */
const DotContainer = styled.div`
position: absolute;
${absoluteDims(DOT_SIZE_PX)}
z-index: 1;
`
// hmm kinda weird to organize like this (exporting `HitZone`). Maybe there's a way to re-use
// this interpolation of `DotContainer` using something like extended components or something.
export const HitZone = styled.div`
z-index: 2;
cursor: ew-resize;
${DopeSnapHitZoneUI.CSS}
#pointer-root.draggingPositionInSequenceEditor & {
${DopeSnapHitZoneUI.CSS_WHEN_SOMETHING_DRAGGING}
}
&:hover + ${DotContainer},
#pointer-root.draggingPositionInSequenceEditor &:hover + ${DotContainer},
// notice "," css "or"
&.${DopeSnapHitZoneUI.BEING_DRAGGED_CLASS} + ${DotContainer} {
${absoluteDims(DOT_HOVER_SIZE_PX)}
}
`
export function AggregateKeyframeVisualDot(props: {
isSelected: AggregateKeyframePositionIsSelected | undefined
isAllHere: boolean
}) {
const theme: IDotThemeValues = {
isSelected: props.isSelected,
}
return (
<DotContainer>
{props.isAllHere ? (
<AggregateDotAllHereSvg {...theme} />
) : (
<AggregateDotSomeHereSvg {...theme} />
)}
</DotContainer>
)
}
type IDotThemeValues = {
isSelected: AggregateKeyframePositionIsSelected | undefined
}
const SELECTED_COLOR = '#b8e4e2'
const DEFAULT_PRIMARY_COLOR = '#40AAA4'
const DEFAULT_SECONDARY_COLOR = '#45747C'
const selectionColorAll = (theme: IDotThemeValues) =>
theme.isSelected === AggregateKeyframePositionIsSelected.AllSelected
? SELECTED_COLOR
: theme.isSelected ===
AggregateKeyframePositionIsSelected.AtLeastOneUnselected
? DEFAULT_PRIMARY_COLOR
: DEFAULT_SECONDARY_COLOR
const selectionColorSome = (theme: IDotThemeValues) =>
theme.isSelected === AggregateKeyframePositionIsSelected.AllSelected
? SELECTED_COLOR
: theme.isSelected ===
AggregateKeyframePositionIsSelected.AtLeastOneUnselected
? DEFAULT_PRIMARY_COLOR
: DEFAULT_SECONDARY_COLOR
const AggregateDotAllHereSvg = (theme: IDotThemeValues) => (
<svg
width="100%"
height="100%"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<rect
x="4.46443"
y="10.0078"
width="5"
height="5"
transform="rotate(-45 4.46443 10.0078)"
fill="#212327" // background knockout fill
stroke={selectionColorSome(theme)}
/>
<rect
x="3.75732"
y="6.01953"
width="6"
height="6"
transform="rotate(-45 3.75732 6.01953)"
fill={selectionColorAll(theme)}
/>
</svg>
)
// when the aggregate keyframes are sparse across tracks at this position
const AggregateDotSomeHereSvg = (theme: IDotThemeValues) => (
<svg
width="100%"
height="100%"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<rect
x="4.46443"
y="8"
width="5"
height="5"
transform="rotate(-45 4.46443 8)"
fill="#23262B"
stroke={selectionColorAll(theme)}
/>
</svg>
)

View file

@ -0,0 +1,3 @@
export function iif<F extends () => any>(fn: F): ReturnType<F> {
return fn()
}

View file

@ -0,0 +1,84 @@
import {prism} from '@theatre/dataverse'
import {AggregateKeyframePositionIsSelected} from '@theatre/studio/panels/SequenceEditorPanel/DopeSheet/Right/AggregatedKeyframeTrack/AggregatedKeyframeTrack'
import {isConnectionEditingInCurvePopover} from '@theatre/studio/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/CurveEditorPopover/CurveEditorPopover'
import {usePrism} from '@theatre/react'
import {selectedKeyframeConnections} from '@theatre/studio/panels/SequenceEditorPanel/DopeSheet/selections'
import type {
IAggregateKeyframeEditorProps,
AggregatedKeyframeConnection,
} from './AggregateKeyframeEditor'
import {iif} from './iif'
export type IAggregateKeyframeEditorUtils = ReturnType<
typeof useAggregateKeyframeEditorUtils
>
// I think this was pulled out for performance
// 1/10: Not sure this is properly split up
export function useAggregateKeyframeEditorUtils(
props: Pick<
IAggregateKeyframeEditorProps,
'index' | 'aggregateKeyframes' | 'selection' | 'viewModel'
>,
) {
const {index, aggregateKeyframes, selection} = props
const sheetObjectAddress = props.viewModel.sheetObject.address
return usePrism(() => {
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) => ({
...sheetObjectAddress,
trackId: track.id,
left: kf,
right: next.keyframes[i].kf,
}))
const allConnections = iif(() => {
const {projectId, sheetId} = sheetObjectAddress
const selectedConnections = prism
.memo(
'selectedConnections',
() =>
selectedKeyframeConnections(
sheetObjectAddress.projectId,
sheetObjectAddress.sheetId,
selection,
),
[projectId, sheetId, selection],
)
.getValue()
return [...aggregatedConnections, ...selectedConnections]
})
const isAggregateEditingInCurvePopover = aggregatedConnections.every(
(con) => isConnectionEditingInCurvePopover(con),
)
return {
cur,
connected,
isAggregateEditingInCurvePopover,
allConnections,
}
}, [index, aggregateKeyframes, selection, sheetObjectAddress])
}

View file

@ -14,8 +14,8 @@ import React from 'react'
import styled from 'styled-components' import styled from 'styled-components'
import useContextMenu from '@theatre/studio/uiComponents/simpleContextMenu/useContextMenu' import useContextMenu from '@theatre/studio/uiComponents/simpleContextMenu/useContextMenu'
import useRefAndState from '@theatre/studio/utils/useRefAndState' import useRefAndState from '@theatre/studio/utils/useRefAndState'
import type {IAggregateKeyframesAtPosition} from './AggregateKeyframeEditor' import type {IAggregateKeyframesAtPosition} from './AggregateKeyframeEditor/AggregateKeyframeEditor'
import AggregateKeyframeEditor from './AggregateKeyframeEditor' import AggregateKeyframeEditor from './AggregateKeyframeEditor/AggregateKeyframeEditor'
import type {AggregatedKeyframes} from '@theatre/studio/panels/SequenceEditorPanel/DopeSheet/Right/collectAggregateKeyframes' import type {AggregatedKeyframes} from '@theatre/studio/panels/SequenceEditorPanel/DopeSheet/Right/collectAggregateKeyframes'
import {useLogger} from '@theatre/studio/uiComponents/useLogger' import {useLogger} from '@theatre/studio/uiComponents/useLogger'
@ -66,12 +66,14 @@ function AggregatedKeyframeTrack_memo(props: IAggregatedKeyframeTracksProps) {
...aggregatedKeyframes.byPosition.entries(), ...aggregatedKeyframes.byPosition.entries(),
] ]
.sort((a, b) => a[0] - b[0]) .sort((a, b) => a[0] - b[0])
.map(([position, keyframes]) => ({ .map(
position, ([position, keyframes]): IAggregateKeyframesAtPosition => ({
keyframes, position,
selected: selectedPositions.get(position), keyframes,
allHere: keyframes.length === aggregatedKeyframes.tracks.length, selected: selectedPositions.get(position),
})) allHere: keyframes.length === aggregatedKeyframes.tracks.length,
}),
)
const keyframeEditors = posKfs.map(({position, keyframes}, index) => ( const keyframeEditors = posKfs.map(({position, keyframes}, index) => (
<AggregateKeyframeEditor <AggregateKeyframeEditor