Reorganize AggregateKeyframeEditor
This commit is contained in:
parent
f222cc61dd
commit
eb15229463
8 changed files with 659 additions and 381 deletions
|
@ -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
|
|
@ -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)
|
||||
}
|
|
@ -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]
|
||||
}
|
|
@ -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
|
|
@ -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>
|
||||
)
|
|
@ -0,0 +1,3 @@
|
|||
export function iif<F extends () => any>(fn: F): ReturnType<F> {
|
||||
return fn()
|
||||
}
|
|
@ -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])
|
||||
}
|
|
@ -14,8 +14,8 @@ import React from 'react'
|
|||
import styled from 'styled-components'
|
||||
import useContextMenu from '@theatre/studio/uiComponents/simpleContextMenu/useContextMenu'
|
||||
import useRefAndState from '@theatre/studio/utils/useRefAndState'
|
||||
import type {IAggregateKeyframesAtPosition} from './AggregateKeyframeEditor'
|
||||
import AggregateKeyframeEditor from './AggregateKeyframeEditor'
|
||||
import type {IAggregateKeyframesAtPosition} from './AggregateKeyframeEditor/AggregateKeyframeEditor'
|
||||
import AggregateKeyframeEditor from './AggregateKeyframeEditor/AggregateKeyframeEditor'
|
||||
import type {AggregatedKeyframes} from '@theatre/studio/panels/SequenceEditorPanel/DopeSheet/Right/collectAggregateKeyframes'
|
||||
import {useLogger} from '@theatre/studio/uiComponents/useLogger'
|
||||
|
||||
|
@ -66,12 +66,14 @@ function AggregatedKeyframeTrack_memo(props: IAggregatedKeyframeTracksProps) {
|
|||
...aggregatedKeyframes.byPosition.entries(),
|
||||
]
|
||||
.sort((a, b) => a[0] - b[0])
|
||||
.map(([position, keyframes]) => ({
|
||||
.map(
|
||||
([position, keyframes]): IAggregateKeyframesAtPosition => ({
|
||||
position,
|
||||
keyframes,
|
||||
selected: selectedPositions.get(position),
|
||||
allHere: keyframes.length === aggregatedKeyframes.tracks.length,
|
||||
}))
|
||||
}),
|
||||
)
|
||||
|
||||
const keyframeEditors = posKfs.map(({position, keyframes}, index) => (
|
||||
<AggregateKeyframeEditor
|
||||
|
|
Loading…
Reference in a new issue