Copy & pasting keyframes in aggregate tracks (#190)
This commit is contained in:
parent
e0359cb4b0
commit
25372d8bb0
15 changed files with 708 additions and 153 deletions
|
@ -111,3 +111,48 @@ export const getValueByPropPath = (
|
||||||
|
|
||||||
return cur
|
return cur
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function doesPathStartWith(
|
||||||
|
path: (string | number)[],
|
||||||
|
pathPrefix: (string | number)[],
|
||||||
|
) {
|
||||||
|
return pathPrefix.every((pathPart, i) => pathPart === path[i])
|
||||||
|
}
|
||||||
|
|
||||||
|
export function arePathsEqual(
|
||||||
|
pathToPropA: (string | number)[],
|
||||||
|
pathToPropB: (string | number)[],
|
||||||
|
) {
|
||||||
|
if (pathToPropA.length !== pathToPropB.length) return false
|
||||||
|
for (let i = 0; i < pathToPropA.length; i++) {
|
||||||
|
if (pathToPropA[i] !== pathToPropB[i]) return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* e.g.
|
||||||
|
* ```
|
||||||
|
* commonRootOfPathsToProps([
|
||||||
|
* ['a','b','c','d','e'],
|
||||||
|
* ['a','b','x','y','z'],
|
||||||
|
* ['a','b','c']
|
||||||
|
* ]) // = ['a','b']
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function commonRootOfPathsToProps(pathsToProps: (string | number)[][]) {
|
||||||
|
const commonPathToProp: (string | number)[] = []
|
||||||
|
while (true) {
|
||||||
|
const i = commonPathToProp.length
|
||||||
|
let candidatePathPart = pathsToProps[0]?.[i]
|
||||||
|
if (candidatePathPart === undefined) return commonPathToProp
|
||||||
|
|
||||||
|
for (const pathToProp of pathsToProps) {
|
||||||
|
if (candidatePathPart !== pathToProp[i]) {
|
||||||
|
return commonPathToProp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
commonPathToProp.push(candidatePathPart)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -0,0 +1,28 @@
|
||||||
|
## The keyframe copy/paste algorithm
|
||||||
|
|
||||||
|
```
|
||||||
|
copy algorithm: find the closest common acnestor for the tracks selected
|
||||||
|
|
||||||
|
- obj1.props.transform.position.x => simple
|
||||||
|
- obj1.props.transform.position.{x, z} => {x, z}
|
||||||
|
- obj1.props.transform.position.{x, z} + obj1.props.transform.rotation.z =>
|
||||||
|
{position: {x, z}, rotation: {z}}
|
||||||
|
|
||||||
|
paste:
|
||||||
|
|
||||||
|
- simple => simple => 1-1
|
||||||
|
- simple => {x, y} => {x: simple, y: simple} (distribute to all)
|
||||||
|
- compound => simple => compound[0] (the first simple property of the comopund,
|
||||||
|
recursively)
|
||||||
|
- compound => compound =>
|
||||||
|
- if they match perfectly, then we know what to do
|
||||||
|
- if they match partially, then we paste partially
|
||||||
|
- {x, y, z} => {x, z} => {x, z}
|
||||||
|
- {x, y} => {x, d} => {x}
|
||||||
|
- if they don't match at all
|
||||||
|
- {x, y} => {a, b} => nothing
|
||||||
|
- {x, y} => {transforms: {position: {x, y, z}}} => nothing
|
||||||
|
- {x, y} => {object(not a prop): {x, y}} => {x, y}
|
||||||
|
- What this means is that, in case of objects and sheets, we do a forEach
|
||||||
|
at each object, then try pasting onto its object.props
|
||||||
|
```
|
|
@ -14,6 +14,13 @@ import {useAggregateKeyframeEditorUtils} from './useAggregateKeyframeEditorUtils
|
||||||
import type {IAggregateKeyframeEditorProps} from './AggregateKeyframeEditor'
|
import type {IAggregateKeyframeEditorProps} from './AggregateKeyframeEditor'
|
||||||
import styled from 'styled-components'
|
import styled from 'styled-components'
|
||||||
import BasicPopover from '@theatre/studio/uiComponents/Popover/BasicPopover'
|
import BasicPopover from '@theatre/studio/uiComponents/Popover/BasicPopover'
|
||||||
|
import {
|
||||||
|
copyableKeyframesFromSelection,
|
||||||
|
keyframesWithPaths,
|
||||||
|
} from '@theatre/studio/panels/SequenceEditorPanel/DopeSheet/selections'
|
||||||
|
import useContextMenu from '@theatre/studio/uiComponents/simpleContextMenu/useContextMenu'
|
||||||
|
import {commonRootOfPathsToProps} from '@theatre/shared/utils/addresses'
|
||||||
|
import type {KeyframeWithPathToPropFromCommonRoot} from '@theatre/studio/store/types'
|
||||||
|
|
||||||
const POPOVER_MARGIN_PX = 5
|
const POPOVER_MARGIN_PX = 5
|
||||||
const EasingPopoverWrapper = styled(BasicPopover)`
|
const EasingPopoverWrapper = styled(BasicPopover)`
|
||||||
|
@ -45,6 +52,7 @@ export const AggregateKeyframeConnector: React.VFC<IAggregateKeyframeConnectorPr
|
||||||
const [nodeRef, node] = useRefAndState<HTMLDivElement | null>(null)
|
const [nodeRef, node] = useRefAndState<HTMLDivElement | null>(null)
|
||||||
const {editorProps} = props
|
const {editorProps} = props
|
||||||
|
|
||||||
|
const [contextMenu] = useConnectorContextMenu(props, node)
|
||||||
const [isDragging] = useDragKeyframe(node, props.editorProps)
|
const [isDragging] = useDragKeyframe(node, props.editorProps)
|
||||||
|
|
||||||
const [popoverNode, openPopover, closePopover] = usePopover(
|
const [popoverNode, openPopover, closePopover] = usePopover(
|
||||||
|
@ -85,6 +93,7 @@ export const AggregateKeyframeConnector: React.VFC<IAggregateKeyframeConnectorPr
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{popoverNode}
|
{popoverNode}
|
||||||
|
{contextMenu}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<></>
|
<></>
|
||||||
|
@ -181,3 +190,83 @@ function useDragKeyframe(
|
||||||
|
|
||||||
return useDrag(node, gestureHandlers)
|
return useDrag(node, gestureHandlers)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function useConnectorContextMenu(
|
||||||
|
props: IAggregateKeyframeConnectorProps,
|
||||||
|
node: HTMLDivElement | null,
|
||||||
|
) {
|
||||||
|
return useContextMenu(node, {
|
||||||
|
displayName: 'Aggregate Tween',
|
||||||
|
menuItems: () => {
|
||||||
|
// see AGGREGATE_COPY_PASTE.md for explanation of this
|
||||||
|
// code that makes some keyframes with paths for copying
|
||||||
|
// to clipboard
|
||||||
|
const kfs = props.utils.allConnections.reduce(
|
||||||
|
(acc, con) =>
|
||||||
|
acc.concat(
|
||||||
|
keyframesWithPaths({
|
||||||
|
...props.editorProps.viewModel.sheetObject.address,
|
||||||
|
trackId: con.trackId,
|
||||||
|
keyframeIds: [con.left.id, con.right.id],
|
||||||
|
}) ?? [],
|
||||||
|
),
|
||||||
|
[] as KeyframeWithPathToPropFromCommonRoot[],
|
||||||
|
)
|
||||||
|
|
||||||
|
const commonPath = commonRootOfPathsToProps(
|
||||||
|
kfs.map((kf) => kf.pathToProp),
|
||||||
|
)
|
||||||
|
|
||||||
|
const keyframesWithCommonRootPath = kfs.map(({keyframe, pathToProp}) => ({
|
||||||
|
keyframe,
|
||||||
|
pathToProp: pathToProp.slice(commonPath.length),
|
||||||
|
}))
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: 'Copy',
|
||||||
|
callback: () => {
|
||||||
|
if (props.editorProps.selection) {
|
||||||
|
const copyableKeyframes = copyableKeyframesFromSelection(
|
||||||
|
props.editorProps.viewModel.sheetObject.address.projectId,
|
||||||
|
props.editorProps.viewModel.sheetObject.address.sheetId,
|
||||||
|
props.editorProps.selection,
|
||||||
|
)
|
||||||
|
getStudio().transaction((api) => {
|
||||||
|
api.stateEditors.studio.ahistoric.setClipboardKeyframes(
|
||||||
|
copyableKeyframes,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
getStudio().transaction((api) => {
|
||||||
|
api.stateEditors.studio.ahistoric.setClipboardKeyframes(
|
||||||
|
keyframesWithCommonRootPath,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Delete',
|
||||||
|
callback: () => {
|
||||||
|
if (props.editorProps.selection) {
|
||||||
|
props.editorProps.selection.delete()
|
||||||
|
} else {
|
||||||
|
getStudio().transaction(({stateEditors}) => {
|
||||||
|
for (const con of props.utils.allConnections) {
|
||||||
|
stateEditors.coreByProject.historic.sheetsById.sequence.deleteKeyframes(
|
||||||
|
{
|
||||||
|
...props.editorProps.viewModel.sheetObject.address,
|
||||||
|
keyframeIds: [con.left.id, con.right.id],
|
||||||
|
trackId: con.trackId,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
|
@ -15,6 +15,12 @@ import DopeSnap from '@theatre/studio/panels/SequenceEditorPanel/RightOverlay/Do
|
||||||
import type {IAggregateKeyframeEditorProps} from './AggregateKeyframeEditor'
|
import type {IAggregateKeyframeEditorProps} from './AggregateKeyframeEditor'
|
||||||
import type {IAggregateKeyframeEditorUtils} from './useAggregateKeyframeEditorUtils'
|
import type {IAggregateKeyframeEditorUtils} from './useAggregateKeyframeEditorUtils'
|
||||||
import {AggregateKeyframeVisualDot, HitZone} from './AggregateKeyframeVisualDot'
|
import {AggregateKeyframeVisualDot, HitZone} from './AggregateKeyframeVisualDot'
|
||||||
|
import {
|
||||||
|
copyableKeyframesFromSelection,
|
||||||
|
keyframesWithPaths,
|
||||||
|
} from '@theatre/studio/panels/SequenceEditorPanel/DopeSheet/selections'
|
||||||
|
import type {KeyframeWithPathToPropFromCommonRoot} from '@theatre/studio/store/types/ahistoric'
|
||||||
|
import {commonRootOfPathsToProps} from '@theatre/shared/utils/addresses'
|
||||||
|
|
||||||
type IAggregateKeyframeDotProps = {
|
type IAggregateKeyframeDotProps = {
|
||||||
editorProps: IAggregateKeyframeEditorProps
|
editorProps: IAggregateKeyframeEditorProps
|
||||||
|
@ -35,9 +41,7 @@ export function AggregateKeyframeDot(
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const [contextMenu] = useAggregateKeyframeContextMenu(node, () =>
|
const [contextMenu] = useAggregateKeyframeContextMenu(props, node)
|
||||||
logger._debug('Show Aggregate Keyframe', props),
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
@ -58,17 +62,81 @@ export function AggregateKeyframeDot(
|
||||||
}
|
}
|
||||||
|
|
||||||
function useAggregateKeyframeContextMenu(
|
function useAggregateKeyframeContextMenu(
|
||||||
|
props: IAggregateKeyframeDotProps,
|
||||||
target: HTMLDivElement | null,
|
target: HTMLDivElement | null,
|
||||||
debugOnOpen: () => void,
|
|
||||||
) {
|
) {
|
||||||
// TODO: missing features: delete, copy + paste
|
|
||||||
return useContextMenu(target, {
|
return useContextMenu(target, {
|
||||||
displayName: 'Aggregate Keyframe',
|
displayName: 'Aggregate Keyframe',
|
||||||
menuItems: () => {
|
menuItems: () => {
|
||||||
return []
|
// see AGGREGATE_COPY_PASTE.md for explanation of this
|
||||||
|
// code that makes some keyframes with paths for copying
|
||||||
|
// to clipboard
|
||||||
|
const kfs = props.utils.cur.keyframes.reduce(
|
||||||
|
(acc, kfWithTrack) =>
|
||||||
|
acc.concat(
|
||||||
|
keyframesWithPaths({
|
||||||
|
...props.editorProps.viewModel.sheetObject.address,
|
||||||
|
trackId: kfWithTrack.track.id,
|
||||||
|
keyframeIds: [kfWithTrack.kf.id],
|
||||||
|
}) ?? [],
|
||||||
|
),
|
||||||
|
[] as KeyframeWithPathToPropFromCommonRoot[],
|
||||||
|
)
|
||||||
|
|
||||||
|
const commonPath = commonRootOfPathsToProps(
|
||||||
|
kfs.map((kf) => kf.pathToProp),
|
||||||
|
)
|
||||||
|
|
||||||
|
const keyframesWithCommonRootPath = kfs.map(({keyframe, pathToProp}) => ({
|
||||||
|
keyframe,
|
||||||
|
pathToProp: pathToProp.slice(commonPath.length),
|
||||||
|
}))
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: props.editorProps.selection ? 'Copy (selection)' : 'Copy',
|
||||||
|
callback: () => {
|
||||||
|
if (props.editorProps.selection) {
|
||||||
|
const copyableKeyframes = copyableKeyframesFromSelection(
|
||||||
|
props.editorProps.viewModel.sheetObject.address.projectId,
|
||||||
|
props.editorProps.viewModel.sheetObject.address.sheetId,
|
||||||
|
props.editorProps.selection,
|
||||||
|
)
|
||||||
|
getStudio().transaction((api) => {
|
||||||
|
api.stateEditors.studio.ahistoric.setClipboardKeyframes(
|
||||||
|
copyableKeyframes,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
getStudio().transaction((api) => {
|
||||||
|
api.stateEditors.studio.ahistoric.setClipboardKeyframes(
|
||||||
|
keyframesWithCommonRootPath,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onOpen() {
|
},
|
||||||
debugOnOpen()
|
{
|
||||||
|
label: props.editorProps.selection ? 'Delete (selection)' : 'Delete',
|
||||||
|
callback: () => {
|
||||||
|
if (props.editorProps.selection) {
|
||||||
|
props.editorProps.selection.delete()
|
||||||
|
} else {
|
||||||
|
getStudio().transaction(({stateEditors}) => {
|
||||||
|
for (const kfWithTrack of props.utils.cur.keyframes) {
|
||||||
|
stateEditors.coreByProject.historic.sheetsById.sequence.deleteKeyframes(
|
||||||
|
{
|
||||||
|
...props.editorProps.viewModel.sheetObject.address,
|
||||||
|
keyframeIds: [kfWithTrack.kf.id],
|
||||||
|
trackId: kfWithTrack.track.id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
@ -104,13 +172,15 @@ function useDragForAggregateKeyframeDot(
|
||||||
) {
|
) {
|
||||||
const {selection, viewModel} = props
|
const {selection, viewModel} = props
|
||||||
const {sheetObject} = viewModel
|
const {sheetObject} = viewModel
|
||||||
return selection
|
const hanlders = selection
|
||||||
.getDragHandlers({
|
.getDragHandlers({
|
||||||
...sheetObject.address,
|
...sheetObject.address,
|
||||||
domNode: node!,
|
domNode: node!,
|
||||||
positionAtStartOfDrag: keyframes[0].kf.position,
|
positionAtStartOfDrag: keyframes[0].kf.position,
|
||||||
})
|
})
|
||||||
.onDragStart(event)
|
.onDragStart(event)
|
||||||
|
|
||||||
|
return hanlders && {...hanlders, onClick: options.onClickFromDrag}
|
||||||
}
|
}
|
||||||
|
|
||||||
const propsAtStartOfDrag = props
|
const propsAtStartOfDrag = props
|
||||||
|
@ -156,9 +226,11 @@ function useDragForAggregateKeyframeDot(
|
||||||
tempTransaction?.commit()
|
tempTransaction?.commit()
|
||||||
} else {
|
} else {
|
||||||
tempTransaction?.discard()
|
tempTransaction?.discard()
|
||||||
options.onClickFromDrag(event)
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
onClick(ev) {
|
||||||
|
options.onClickFromDrag(ev)
|
||||||
|
},
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,15 +9,28 @@ import type {
|
||||||
import type {Keyframe} from '@theatre/core/projects/store/types/SheetState_Historic'
|
import type {Keyframe} from '@theatre/core/projects/store/types/SheetState_Historic'
|
||||||
import {usePrism} from '@theatre/react'
|
import {usePrism} from '@theatre/react'
|
||||||
import type {Pointer} from '@theatre/dataverse'
|
import type {Pointer} from '@theatre/dataverse'
|
||||||
|
import {valueDerivation} 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'
|
||||||
|
import type {IContextMenuItem} from '@theatre/studio/uiComponents/simpleContextMenu/useContextMenu'
|
||||||
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/AggregateKeyframeEditor'
|
import type {IAggregateKeyframesAtPosition} from './AggregateKeyframeEditor/AggregateKeyframeEditor'
|
||||||
import AggregateKeyframeEditor from './AggregateKeyframeEditor/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'
|
||||||
|
import getStudio from '@theatre/studio/getStudio'
|
||||||
|
import type {
|
||||||
|
SheetObjectAddress} from '@theatre/shared/utils/addresses';
|
||||||
|
import {
|
||||||
|
decodePathToProp,
|
||||||
|
doesPathStartWith,
|
||||||
|
encodePathToProp
|
||||||
|
} from '@theatre/shared/utils/addresses'
|
||||||
|
import type {SequenceTrackId} from '@theatre/shared/utils/ids'
|
||||||
|
import type Sequence from '@theatre/core/sequences/Sequence'
|
||||||
|
import type {KeyframeWithPathToPropFromCommonRoot} from '@theatre/studio/store/types'
|
||||||
|
|
||||||
const AggregatedKeyframeTrackContainer = styled.div`
|
const AggregatedKeyframeTrackContainer = styled.div`
|
||||||
position: relative;
|
position: relative;
|
||||||
|
@ -186,7 +199,192 @@ function useAggregatedKeyframeTrackContextMenu(
|
||||||
onOpen: debugOnOpen,
|
onOpen: debugOnOpen,
|
||||||
displayName: 'Aggregate Keyframe Track',
|
displayName: 'Aggregate Keyframe Track',
|
||||||
menuItems: () => {
|
menuItems: () => {
|
||||||
return []
|
const selectionKeyframes =
|
||||||
|
valueDerivation(
|
||||||
|
getStudio()!.atomP.ahistoric.clipboard.keyframesWithRelativePaths,
|
||||||
|
).getValue() ?? []
|
||||||
|
|
||||||
|
return [pasteKeyframesContextMenuItem(props, selectionKeyframes)]
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function pasteKeyframesContextMenuItem(
|
||||||
|
props: IAggregatedKeyframeTracksProps,
|
||||||
|
keyframes: KeyframeWithPathToPropFromCommonRoot[],
|
||||||
|
): IContextMenuItem {
|
||||||
|
return {
|
||||||
|
label: 'Paste Keyframes',
|
||||||
|
enabled: keyframes.length > 0,
|
||||||
|
callback: () => {
|
||||||
|
const sheet = val(props.layoutP.sheet)
|
||||||
|
const sequence = sheet.getSequence()
|
||||||
|
|
||||||
|
pasteKeyframes(props.viewModel, keyframes, sequence)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Given a list of keyframes that contain paths relative to a common root,
|
||||||
|
* (see `copyableKeyframesFromSelection`) this function pastes those keyframes
|
||||||
|
* into tracks on either the object (if viewModel.type === 'sheetObject') or
|
||||||
|
* the compound prop (if viewModel.type === 'propWithChildren').
|
||||||
|
*
|
||||||
|
* Our copy & paste behaviour is currently roughly described in AGGREGATE_COPY_PASTE.md
|
||||||
|
*
|
||||||
|
* @see StudioAhistoricState.clipboard
|
||||||
|
* @see setClipboardNestedKeyframes
|
||||||
|
*/
|
||||||
|
function pasteKeyframes(
|
||||||
|
viewModel:
|
||||||
|
| SequenceEditorTree_PropWithChildren
|
||||||
|
| SequenceEditorTree_SheetObject,
|
||||||
|
keyframes: KeyframeWithPathToPropFromCommonRoot[],
|
||||||
|
sequence: Sequence,
|
||||||
|
) {
|
||||||
|
const {projectId, sheetId, objectKey} = viewModel.sheetObject.address
|
||||||
|
|
||||||
|
const tracksByObject = valueDerivation(
|
||||||
|
getStudio().atomP.historic.coreByProject[projectId].sheetsById[sheetId]
|
||||||
|
.sequence.tracksByObject[objectKey],
|
||||||
|
).getValue()
|
||||||
|
|
||||||
|
const areKeyframesAllOnSingleTrack = keyframes.every(
|
||||||
|
({pathToProp}) => pathToProp.length === 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (areKeyframesAllOnSingleTrack) {
|
||||||
|
const trackIdsOnObject = Object.keys(tracksByObject?.trackData ?? {})
|
||||||
|
|
||||||
|
if (viewModel.type === 'sheetObject') {
|
||||||
|
pasteKeyframesToMultipleTracks(
|
||||||
|
viewModel.sheetObject.address,
|
||||||
|
trackIdsOnObject,
|
||||||
|
keyframes,
|
||||||
|
sequence,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
const trackIdByPropPath = tracksByObject?.trackIdByPropPath || {}
|
||||||
|
|
||||||
|
const trackIdsOnCompoundProp = Object.entries(trackIdByPropPath)
|
||||||
|
.filter(
|
||||||
|
([encodedPath, trackId]) =>
|
||||||
|
trackId !== undefined &&
|
||||||
|
doesPathStartWith(
|
||||||
|
// e.g. a track with path `['position', 'x']` is under the compound track with path `['position']`
|
||||||
|
decodePathToProp(encodedPath),
|
||||||
|
viewModel.pathToProp,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.map(([encodedPath, trackId]) => trackId) as SequenceTrackId[]
|
||||||
|
|
||||||
|
pasteKeyframesToMultipleTracks(
|
||||||
|
viewModel.sheetObject.address,
|
||||||
|
trackIdsOnCompoundProp,
|
||||||
|
keyframes,
|
||||||
|
sequence,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const trackIdByPropPath = tracksByObject?.trackIdByPropPath || {}
|
||||||
|
|
||||||
|
const rootPath =
|
||||||
|
viewModel.type === 'propWithChildren' ? viewModel.pathToProp : []
|
||||||
|
|
||||||
|
const placeableKeyframes = keyframes
|
||||||
|
.map(({keyframe, pathToProp: relativePathToProp}) => {
|
||||||
|
const pathToPropEncoded = encodePathToProp([
|
||||||
|
...rootPath,
|
||||||
|
...relativePathToProp,
|
||||||
|
])
|
||||||
|
|
||||||
|
const maybeTrackId = trackIdByPropPath[pathToPropEncoded]
|
||||||
|
|
||||||
|
return maybeTrackId
|
||||||
|
? {
|
||||||
|
keyframe,
|
||||||
|
trackId: maybeTrackId,
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
})
|
||||||
|
.filter((result) => result !== null) as {
|
||||||
|
keyframe: Keyframe
|
||||||
|
trackId: SequenceTrackId
|
||||||
|
}[]
|
||||||
|
|
||||||
|
pasteKeyframesToSpecificTracks(
|
||||||
|
viewModel.sheetObject.address,
|
||||||
|
placeableKeyframes,
|
||||||
|
sequence,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function pasteKeyframesToMultipleTracks(
|
||||||
|
address: SheetObjectAddress,
|
||||||
|
trackIds: SequenceTrackId[],
|
||||||
|
keyframes: KeyframeWithPathToPropFromCommonRoot[],
|
||||||
|
sequence: Sequence,
|
||||||
|
) {
|
||||||
|
sequence.position = sequence.closestGridPosition(sequence.position)
|
||||||
|
const keyframeOffset = earliestKeyframe(
|
||||||
|
keyframes.map(({keyframe}) => keyframe),
|
||||||
|
)?.position!
|
||||||
|
|
||||||
|
getStudio()!.transaction(({stateEditors}) => {
|
||||||
|
for (const trackId of trackIds) {
|
||||||
|
for (const {keyframe} of keyframes) {
|
||||||
|
stateEditors.coreByProject.historic.sheetsById.sequence.setKeyframeAtPosition(
|
||||||
|
{
|
||||||
|
...address,
|
||||||
|
trackId,
|
||||||
|
position: sequence.position + keyframe.position - keyframeOffset,
|
||||||
|
handles: keyframe.handles,
|
||||||
|
value: keyframe.value,
|
||||||
|
snappingFunction: sequence.closestGridPosition,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function pasteKeyframesToSpecificTracks(
|
||||||
|
address: SheetObjectAddress,
|
||||||
|
keyframesWithTracksToPlaceThemIn: {
|
||||||
|
keyframe: Keyframe
|
||||||
|
trackId: SequenceTrackId
|
||||||
|
}[],
|
||||||
|
sequence: Sequence,
|
||||||
|
) {
|
||||||
|
sequence.position = sequence.closestGridPosition(sequence.position)
|
||||||
|
const keyframeOffset = earliestKeyframe(
|
||||||
|
keyframesWithTracksToPlaceThemIn.map(({keyframe}) => keyframe),
|
||||||
|
)?.position!
|
||||||
|
|
||||||
|
getStudio()!.transaction(({stateEditors}) => {
|
||||||
|
for (const {keyframe, trackId} of keyframesWithTracksToPlaceThemIn) {
|
||||||
|
stateEditors.coreByProject.historic.sheetsById.sequence.setKeyframeAtPosition(
|
||||||
|
{
|
||||||
|
...address,
|
||||||
|
trackId,
|
||||||
|
position: sequence.position + keyframe.position - keyframeOffset,
|
||||||
|
handles: keyframe.handles,
|
||||||
|
value: keyframe.value,
|
||||||
|
snappingFunction: sequence.closestGridPosition,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function earliestKeyframe(keyframes: Keyframe[]) {
|
||||||
|
let curEarliest: Keyframe | null = null
|
||||||
|
for (const keyframe of keyframes) {
|
||||||
|
if (curEarliest === null || keyframe.position < curEarliest.position) {
|
||||||
|
curEarliest = keyframe
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return curEarliest
|
||||||
|
}
|
||||||
|
|
|
@ -12,6 +12,8 @@ import type {IContextMenuItem} from '@theatre/studio/uiComponents/simpleContextM
|
||||||
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 getStudio from '@theatre/studio/getStudio'
|
import getStudio from '@theatre/studio/getStudio'
|
||||||
|
import {arePathsEqual} from '@theatre/shared/utils/addresses'
|
||||||
|
import type {KeyframeWithPathToPropFromCommonRoot} from '@theatre/studio/store/types'
|
||||||
|
|
||||||
const Container = styled.div`
|
const Container = styled.div`
|
||||||
position: relative;
|
position: relative;
|
||||||
|
@ -92,7 +94,9 @@ function useBasicKeyframedTrackContextMenu(
|
||||||
displayName: 'Keyframe Track',
|
displayName: 'Keyframe Track',
|
||||||
menuItems: () => {
|
menuItems: () => {
|
||||||
const selectionKeyframes =
|
const selectionKeyframes =
|
||||||
val(getStudio()!.atomP.ahistoric.clipboard.keyframes) || []
|
val(
|
||||||
|
getStudio()!.atomP.ahistoric.clipboard.keyframesWithRelativePaths,
|
||||||
|
) ?? []
|
||||||
|
|
||||||
return [pasteKeyframesContextMenuItem(props, selectionKeyframes)]
|
return [pasteKeyframesContextMenuItem(props, selectionKeyframes)]
|
||||||
},
|
},
|
||||||
|
@ -101,7 +105,7 @@ function useBasicKeyframedTrackContextMenu(
|
||||||
|
|
||||||
function pasteKeyframesContextMenuItem(
|
function pasteKeyframesContextMenuItem(
|
||||||
props: BasicKeyframedTracksProps,
|
props: BasicKeyframedTracksProps,
|
||||||
keyframes: Keyframe[],
|
keyframes: KeyframeWithPathToPropFromCommonRoot[],
|
||||||
): IContextMenuItem {
|
): IContextMenuItem {
|
||||||
return {
|
return {
|
||||||
label: 'Paste Keyframes',
|
label: 'Paste Keyframes',
|
||||||
|
@ -110,11 +114,18 @@ function pasteKeyframesContextMenuItem(
|
||||||
const sheet = val(props.layoutP.sheet)
|
const sheet = val(props.layoutP.sheet)
|
||||||
const sequence = sheet.getSequence()
|
const sequence = sheet.getSequence()
|
||||||
|
|
||||||
|
const firstPath = keyframes[0]?.pathToProp
|
||||||
|
const singleTrackKeyframes = keyframes
|
||||||
|
.filter(({keyframe, pathToProp}) =>
|
||||||
|
arePathsEqual(firstPath, pathToProp),
|
||||||
|
)
|
||||||
|
.map(({keyframe, pathToProp}) => keyframe)
|
||||||
|
|
||||||
getStudio()!.transaction(({stateEditors}) => {
|
getStudio()!.transaction(({stateEditors}) => {
|
||||||
sequence.position = sequence.closestGridPosition(sequence.position)
|
sequence.position = sequence.closestGridPosition(sequence.position)
|
||||||
const keyframeOffset = earliestKeyframe(keyframes)?.position!
|
const keyframeOffset = earliestKeyframe(singleTrackKeyframes)?.position!
|
||||||
|
|
||||||
for (const keyframe of keyframes) {
|
for (const keyframe of singleTrackKeyframes) {
|
||||||
stateEditors.coreByProject.historic.sheetsById.sequence.setKeyframeAtPosition(
|
stateEditors.coreByProject.historic.sheetsById.sequence.setKeyframeAtPosition(
|
||||||
{
|
{
|
||||||
...props.leaf.sheetObject.address,
|
...props.leaf.sheetObject.address,
|
||||||
|
|
|
@ -11,8 +11,6 @@ import BasicPopover from '@theatre/studio/uiComponents/Popover/BasicPopover'
|
||||||
import CurveEditorPopover, {
|
import CurveEditorPopover, {
|
||||||
isConnectionEditingInCurvePopover,
|
isConnectionEditingInCurvePopover,
|
||||||
} from './CurveEditorPopover/CurveEditorPopover'
|
} from './CurveEditorPopover/CurveEditorPopover'
|
||||||
import selectedKeyframeIdsIfInSingleTrack from '@theatre/studio/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/selectedKeyframeIdsIfInSingleTrack'
|
|
||||||
import type {OpenFn} from '@theatre/studio/src/uiComponents/Popover/usePopover'
|
|
||||||
import type {Keyframe} from '@theatre/core/projects/store/types/SheetState_Historic'
|
import type {Keyframe} from '@theatre/core/projects/store/types/SheetState_Historic'
|
||||||
import type {ISingleKeyframeEditorProps} from './SingleKeyframeEditor'
|
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'
|
||||||
|
@ -20,6 +18,7 @@ import {ConnectorLine} from '@theatre/studio/panels/SequenceEditorPanel/DopeShee
|
||||||
import {COLOR_POPOVER_BACK} from './CurveEditorPopover/colors'
|
import {COLOR_POPOVER_BACK} from './CurveEditorPopover/colors'
|
||||||
import {usePrism} from '@theatre/react'
|
import {usePrism} from '@theatre/react'
|
||||||
import type {KeyframeConnectionWithAddress} from '@theatre/studio/panels/SequenceEditorPanel/DopeSheet/selections'
|
import type {KeyframeConnectionWithAddress} from '@theatre/studio/panels/SequenceEditorPanel/DopeSheet/selections'
|
||||||
|
import {copyableKeyframesFromSelection} from '@theatre/studio/panels/SequenceEditorPanel/DopeSheet/selections'
|
||||||
import {selectedKeyframeConnections} from '@theatre/studio/panels/SequenceEditorPanel/DopeSheet/selections'
|
import {selectedKeyframeConnections} from '@theatre/studio/panels/SequenceEditorPanel/DopeSheet/selections'
|
||||||
|
|
||||||
import styled from 'styled-components'
|
import styled from 'styled-components'
|
||||||
|
@ -56,13 +55,7 @@ const BasicKeyframeConnector: React.VFC<IBasicKeyframeConnectorProps> = (
|
||||||
() => <SingleCurveEditorPopover {...props} closePopover={closePopover} />,
|
() => <SingleCurveEditorPopover {...props} closePopover={closePopover} />,
|
||||||
)
|
)
|
||||||
|
|
||||||
const [contextMenu] = useConnectorContextMenu(
|
const [contextMenu] = useConnectorContextMenu(props, node, cur, next)
|
||||||
props,
|
|
||||||
node,
|
|
||||||
cur,
|
|
||||||
next,
|
|
||||||
openPopover,
|
|
||||||
)
|
|
||||||
useDragKeyframe(node, props)
|
useDragKeyframe(node, props)
|
||||||
|
|
||||||
const connectorLengthInUnitSpace = next.position - cur.position
|
const connectorLengthInUnitSpace = next.position - cur.position
|
||||||
|
@ -84,6 +77,7 @@ const BasicKeyframeConnector: React.VFC<IBasicKeyframeConnectorProps> = (
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<ConnectorLine
|
<ConnectorLine
|
||||||
ref={nodeRef}
|
ref={nodeRef}
|
||||||
connectorLengthInUnitSpace={connectorLengthInUnitSpace}
|
connectorLengthInUnitSpace={connectorLengthInUnitSpace}
|
||||||
|
@ -93,8 +87,11 @@ const BasicKeyframeConnector: React.VFC<IBasicKeyframeConnectorProps> = (
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{popoverNode}
|
{popoverNode}
|
||||||
{contextMenu}
|
|
||||||
</ConnectorLine>
|
</ConnectorLine>
|
||||||
|
{/* contextMenu is placed outside of the ConnectorLine so that clicking on
|
||||||
|
the contextMenu does not count as clicking on the ConnectorLine */}
|
||||||
|
{contextMenu}
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
export default BasicKeyframeConnector
|
export default BasicKeyframeConnector
|
||||||
|
@ -220,50 +217,51 @@ function useDragKeyframe(
|
||||||
|
|
||||||
useDrag(node, gestureHandlers)
|
useDrag(node, gestureHandlers)
|
||||||
}
|
}
|
||||||
|
|
||||||
function useConnectorContextMenu(
|
function useConnectorContextMenu(
|
||||||
props: IBasicKeyframeConnectorProps,
|
props: IBasicKeyframeConnectorProps,
|
||||||
node: HTMLDivElement | null,
|
node: HTMLDivElement | null,
|
||||||
cur: Keyframe,
|
cur: Keyframe,
|
||||||
next: Keyframe,
|
next: Keyframe,
|
||||||
openPopover: OpenFn,
|
|
||||||
) {
|
) {
|
||||||
const maybeKeyframeIds = selectedKeyframeIdsIfInSingleTrack(props.selection)
|
// TODO?: props.selection is undefined if only one of the connected keyframes is selected
|
||||||
|
|
||||||
return useContextMenu(node, {
|
return useContextMenu(node, {
|
||||||
|
displayName: 'Tween',
|
||||||
menuItems: () => {
|
menuItems: () => {
|
||||||
return [
|
const copyableKeyframes = copyableKeyframesFromSelection(
|
||||||
{
|
props.leaf.sheetObject.address.projectId,
|
||||||
label: maybeKeyframeIds ? 'Copy Selection' : 'Copy both Keyframes',
|
props.leaf.sheetObject.address.sheetId,
|
||||||
callback: () => {
|
props.selection,
|
||||||
if (maybeKeyframeIds) {
|
|
||||||
const keyframes = maybeKeyframeIds.map(
|
|
||||||
(keyframeId) =>
|
|
||||||
props.trackData.keyframes.find(
|
|
||||||
(keyframe) => keyframe.id === keyframeId,
|
|
||||||
)!,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
getStudio!().transaction((api) => {
|
return [
|
||||||
|
{
|
||||||
|
label: copyableKeyframes.length > 0 ? 'Copy (selection)' : 'Copy',
|
||||||
|
callback: () => {
|
||||||
|
if (copyableKeyframes.length > 0) {
|
||||||
|
getStudio().transaction((api) => {
|
||||||
api.stateEditors.studio.ahistoric.setClipboardKeyframes(
|
api.stateEditors.studio.ahistoric.setClipboardKeyframes(
|
||||||
keyframes,
|
copyableKeyframes,
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
getStudio!().transaction((api) => {
|
getStudio().transaction((api) => {
|
||||||
api.stateEditors.studio.ahistoric.setClipboardKeyframes([
|
api.stateEditors.studio.ahistoric.setClipboardKeyframes([
|
||||||
cur,
|
{keyframe: cur, pathToProp: props.leaf.pathToProp},
|
||||||
next,
|
{keyframe: next, pathToProp: props.leaf.pathToProp},
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: props.selection ? 'Delete Selection' : 'Delete both Keyframes',
|
label: props.selection ? 'Delete (selection)' : 'Delete',
|
||||||
callback: () => {
|
callback: () => {
|
||||||
|
getStudio().transaction(({stateEditors}) => {
|
||||||
if (props.selection) {
|
if (props.selection) {
|
||||||
props.selection.delete()
|
props.selection.delete()
|
||||||
} else {
|
} else {
|
||||||
getStudio()!.transaction(({stateEditors}) => {
|
|
||||||
stateEditors.coreByProject.historic.sheetsById.sequence.deleteKeyframes(
|
stateEditors.coreByProject.historic.sheetsById.sequence.deleteKeyframes(
|
||||||
{
|
{
|
||||||
...props.leaf.sheetObject.address,
|
...props.leaf.sheetObject.address,
|
||||||
|
@ -271,14 +269,8 @@ function useConnectorContextMenu(
|
||||||
trackId: props.leaf.trackId,
|
trackId: props.leaf.trackId,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
})
|
|
||||||
}
|
}
|
||||||
},
|
})
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Open Easing Palette',
|
|
||||||
callback: (e) => {
|
|
||||||
openPopover(e, node!)
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
|
@ -6,14 +6,12 @@ import last from 'lodash-es/last'
|
||||||
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 useContextMenu from '@theatre/studio/uiComponents/simpleContextMenu/useContextMenu'
|
import useContextMenu from '@theatre/studio/uiComponents/simpleContextMenu/useContextMenu'
|
||||||
import type {IContextMenuItem} from '@theatre/studio/uiComponents/simpleContextMenu/useContextMenu'
|
|
||||||
import useDrag from '@theatre/studio/uiComponents/useDrag'
|
import useDrag from '@theatre/studio/uiComponents/useDrag'
|
||||||
import type {UseDragOpts} from '@theatre/studio/uiComponents/useDrag'
|
import type {UseDragOpts} from '@theatre/studio/uiComponents/useDrag'
|
||||||
import useRefAndState from '@theatre/studio/utils/useRefAndState'
|
import useRefAndState from '@theatre/studio/utils/useRefAndState'
|
||||||
import {val} from '@theatre/dataverse'
|
import {val} from '@theatre/dataverse'
|
||||||
import {useLockFrameStampPosition} from '@theatre/studio/panels/SequenceEditorPanel/FrameStampPositionProvider'
|
import {useLockFrameStampPosition} from '@theatre/studio/panels/SequenceEditorPanel/FrameStampPositionProvider'
|
||||||
import {useCssCursorLock} from '@theatre/studio/uiComponents/PointerEventsHandler'
|
import {useCssCursorLock} from '@theatre/studio/uiComponents/PointerEventsHandler'
|
||||||
import selectedKeyframeIdsIfInSingleTrack from '@theatre/studio/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/selectedKeyframeIdsIfInSingleTrack'
|
|
||||||
import DopeSnap from '@theatre/studio/panels/SequenceEditorPanel/RightOverlay/DopeSnap'
|
import DopeSnap from '@theatre/studio/panels/SequenceEditorPanel/RightOverlay/DopeSnap'
|
||||||
import usePopover from '@theatre/studio/uiComponents/Popover/usePopover'
|
import usePopover from '@theatre/studio/uiComponents/Popover/usePopover'
|
||||||
|
|
||||||
|
@ -25,6 +23,7 @@ import {absoluteDims} from '@theatre/studio/utils/absoluteDims'
|
||||||
import {DopeSnapHitZoneUI} from '@theatre/studio/panels/SequenceEditorPanel/RightOverlay/DopeSnapHitZoneUI'
|
import {DopeSnapHitZoneUI} from '@theatre/studio/panels/SequenceEditorPanel/RightOverlay/DopeSnapHitZoneUI'
|
||||||
import {useLogger} from '@theatre/studio/uiComponents/useLogger'
|
import {useLogger} from '@theatre/studio/uiComponents/useLogger'
|
||||||
import type {ILogger} from '@theatre/shared/logger'
|
import type {ILogger} from '@theatre/shared/logger'
|
||||||
|
import {copyableKeyframesFromSelection} from '@theatre/studio/panels/SequenceEditorPanel/DopeSheet/selections'
|
||||||
|
|
||||||
export const DOT_SIZE_PX = 6
|
export const DOT_SIZE_PX = 6
|
||||||
const DOT_HOVER_SIZE_PX = DOT_SIZE_PX + 5
|
const DOT_HOVER_SIZE_PX = DOT_SIZE_PX + 5
|
||||||
|
@ -107,20 +106,54 @@ function useSingleKeyframeContextMenu(
|
||||||
logger: ILogger,
|
logger: ILogger,
|
||||||
props: ISingleKeyframeDotProps,
|
props: ISingleKeyframeDotProps,
|
||||||
) {
|
) {
|
||||||
const maybeSelectedKeyframeIds = selectedKeyframeIdsIfInSingleTrack(
|
|
||||||
props.selection,
|
|
||||||
)
|
|
||||||
|
|
||||||
const keyframeSelectionItem = maybeSelectedKeyframeIds
|
|
||||||
? copyKeyFrameContextMenuItem(props, maybeSelectedKeyframeIds)
|
|
||||||
: copyKeyFrameContextMenuItem(props, [props.keyframe.id])
|
|
||||||
|
|
||||||
const deleteItem = deleteSelectionOrKeyframeContextMenuItem(props)
|
|
||||||
|
|
||||||
return useContextMenu(target, {
|
return useContextMenu(target, {
|
||||||
displayName: 'Keyframe',
|
displayName: 'Keyframe',
|
||||||
menuItems: () => {
|
menuItems: () => {
|
||||||
return [keyframeSelectionItem, deleteItem]
|
const copyableKeyframes = copyableKeyframesFromSelection(
|
||||||
|
props.leaf.sheetObject.address.projectId,
|
||||||
|
props.leaf.sheetObject.address.sheetId,
|
||||||
|
props.selection,
|
||||||
|
)
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: copyableKeyframes.length > 0 ? 'Copy (selection)' : 'Copy',
|
||||||
|
callback: () => {
|
||||||
|
if (copyableKeyframes.length > 0) {
|
||||||
|
getStudio!().transaction((api) => {
|
||||||
|
api.stateEditors.studio.ahistoric.setClipboardKeyframes(
|
||||||
|
copyableKeyframes,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
getStudio!().transaction((api) => {
|
||||||
|
api.stateEditors.studio.ahistoric.setClipboardKeyframes([
|
||||||
|
{keyframe: props.keyframe, pathToProp: props.leaf.pathToProp},
|
||||||
|
])
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label:
|
||||||
|
props.selection !== undefined ? 'Delete (selection)' : 'Delete',
|
||||||
|
callback: () => {
|
||||||
|
if (props.selection) {
|
||||||
|
props.selection.delete()
|
||||||
|
} else {
|
||||||
|
getStudio()!.transaction(({stateEditors}) => {
|
||||||
|
stateEditors.coreByProject.historic.sheetsById.sequence.deleteKeyframes(
|
||||||
|
{
|
||||||
|
...props.leaf.sheetObject.address,
|
||||||
|
keyframeIds: [props.keyframe.id],
|
||||||
|
trackId: props.leaf.trackId,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
},
|
},
|
||||||
onOpen() {
|
onOpen() {
|
||||||
logger._debug('Show keyframe', props)
|
logger._debug('Show keyframe', props)
|
||||||
|
@ -183,7 +216,7 @@ function useDragForSingleKeyframeDot(
|
||||||
if (props.selection) {
|
if (props.selection) {
|
||||||
const {selection, leaf} = props
|
const {selection, leaf} = props
|
||||||
const {sheetObject} = leaf
|
const {sheetObject} = leaf
|
||||||
return selection
|
const handlers = selection
|
||||||
.getDragHandlers({
|
.getDragHandlers({
|
||||||
...sheetObject.address,
|
...sheetObject.address,
|
||||||
domNode: node!,
|
domNode: node!,
|
||||||
|
@ -191,6 +224,12 @@ function useDragForSingleKeyframeDot(
|
||||||
props.trackData.keyframes[props.index].position,
|
props.trackData.keyframes[props.index].position,
|
||||||
})
|
})
|
||||||
.onDragStart(event)
|
.onDragStart(event)
|
||||||
|
|
||||||
|
// this opens the regular inline keyframe editor on click.
|
||||||
|
// in the future, we may want to show an multi-editor, like in the
|
||||||
|
// single tween editor, so that selected keyframes' values can be changed
|
||||||
|
// together
|
||||||
|
return handlers && {...handlers, onClick: options.onClickFromDrag}
|
||||||
}
|
}
|
||||||
|
|
||||||
const propsAtStartOfDrag = props
|
const propsAtStartOfDrag = props
|
||||||
|
@ -235,9 +274,11 @@ function useDragForSingleKeyframeDot(
|
||||||
tempTransaction?.commit()
|
tempTransaction?.commit()
|
||||||
} else {
|
} else {
|
||||||
tempTransaction?.discard()
|
tempTransaction?.discard()
|
||||||
options.onClickFromDrag(event)
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
onClick(ev) {
|
||||||
|
options.onClickFromDrag(ev)
|
||||||
|
},
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
@ -253,47 +294,3 @@ function useDragForSingleKeyframeDot(
|
||||||
|
|
||||||
return [isDragging]
|
return [isDragging]
|
||||||
}
|
}
|
||||||
|
|
||||||
function deleteSelectionOrKeyframeContextMenuItem(
|
|
||||||
props: ISingleKeyframeDotProps,
|
|
||||||
): IContextMenuItem {
|
|
||||||
return {
|
|
||||||
label: props.selection ? 'Delete Selection' : 'Delete Keyframe',
|
|
||||||
callback: () => {
|
|
||||||
if (props.selection) {
|
|
||||||
props.selection.delete()
|
|
||||||
} else {
|
|
||||||
getStudio()!.transaction(({stateEditors}) => {
|
|
||||||
stateEditors.coreByProject.historic.sheetsById.sequence.deleteKeyframes(
|
|
||||||
{
|
|
||||||
...props.leaf.sheetObject.address,
|
|
||||||
keyframeIds: [props.keyframe.id],
|
|
||||||
trackId: props.leaf.trackId,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function copyKeyFrameContextMenuItem(
|
|
||||||
props: ISingleKeyframeDotProps,
|
|
||||||
keyframeIds: string[],
|
|
||||||
): IContextMenuItem {
|
|
||||||
return {
|
|
||||||
label: keyframeIds.length > 1 ? 'Copy Selection' : 'Copy Keyframe',
|
|
||||||
callback: () => {
|
|
||||||
const keyframes = keyframeIds.map(
|
|
||||||
(keyframeId) =>
|
|
||||||
props.trackData.keyframes.find(
|
|
||||||
(keyframe) => keyframe.id === keyframeId,
|
|
||||||
)!,
|
|
||||||
)
|
|
||||||
|
|
||||||
getStudio!().transaction((api) => {
|
|
||||||
api.stateEditors.studio.ahistoric.setClipboardKeyframes(keyframes)
|
|
||||||
})
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
@ -1,21 +0,0 @@
|
||||||
import type {DopeSheetSelection} from '@theatre/studio/panels/SequenceEditorPanel/layout/layout'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param selection - selection on the dope sheet, or undefined if there isn't a selection
|
|
||||||
* @returns If the selection exists and contains one or more keyframes only in a single track,
|
|
||||||
* then a list of those keyframe's ids; otherwise null
|
|
||||||
*/
|
|
||||||
export default function selectedKeyframeIdsIfInSingleTrack(
|
|
||||||
selection: DopeSheetSelection | undefined,
|
|
||||||
): string[] | null {
|
|
||||||
if (!selection) return null
|
|
||||||
const objectKeys = Object.keys(selection.byObjectKey)
|
|
||||||
if (objectKeys.length !== 1) return null
|
|
||||||
const object = selection.byObjectKey[objectKeys[0]]
|
|
||||||
if (!object) return null
|
|
||||||
const trackIds = Object.keys(object.byTrackId)
|
|
||||||
const firstTrack = object.byTrackId[trackIds[0]]
|
|
||||||
if (trackIds.length !== 1 && firstTrack) return null
|
|
||||||
|
|
||||||
return Object.keys(firstTrack!.byKeyframeId)
|
|
||||||
}
|
|
|
@ -10,6 +10,12 @@ import type {
|
||||||
import getStudio from '@theatre/studio/getStudio'
|
import getStudio from '@theatre/studio/getStudio'
|
||||||
import type {DopeSheetSelection} from '@theatre/studio/panels/SequenceEditorPanel/layout/layout'
|
import type {DopeSheetSelection} from '@theatre/studio/panels/SequenceEditorPanel/layout/layout'
|
||||||
import type {Keyframe} from '@theatre/core/projects/store/types/SheetState_Historic'
|
import type {Keyframe} from '@theatre/core/projects/store/types/SheetState_Historic'
|
||||||
|
import {
|
||||||
|
commonRootOfPathsToProps,
|
||||||
|
decodePathToProp,
|
||||||
|
} from '@theatre/shared/utils/addresses'
|
||||||
|
import type {StrictRecord} from '@theatre/shared/utils/types'
|
||||||
|
import type {KeyframeWithPathToPropFromCommonRoot} from '@theatre/studio/store/types'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Keyframe connections are considered to be selected if the first
|
* Keyframe connections are considered to be selected if the first
|
||||||
|
@ -78,6 +84,119 @@ export function selectedKeyframeConnections(
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Given a selection, returns a list of keyframes and paths
|
||||||
|
* that are relative to a common root path. For example, if
|
||||||
|
* the selection contains a keyframe on both the following tracks:
|
||||||
|
* - exObject.transform.position.x
|
||||||
|
* - exObject.transform.position.y
|
||||||
|
* then the result will be
|
||||||
|
* ```
|
||||||
|
* [{ keyframe, pathToProp: ['x']}, { keyframe, pathToProp: ['y']}]
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* If the selection contains a keyframe on
|
||||||
|
* all the following tracks:
|
||||||
|
* - exObject.transform.position.x
|
||||||
|
* - exObject.transform.position.y
|
||||||
|
* - exObject.transform.scale.x
|
||||||
|
* then the result will be
|
||||||
|
* ```
|
||||||
|
* [
|
||||||
|
* {keyframe, pathToProp: ['position', 'x']},
|
||||||
|
* {keyframe, pathToProp: ['position', 'y']},
|
||||||
|
* {keyframe, pathToProp: ['scale', 'x']},
|
||||||
|
* ]
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* TODO - we don't yet support copying/pasting keyframes from multiple objects to multiple objects.
|
||||||
|
* The main reason is that we don't yet have an aggregate track for several objects.
|
||||||
|
*/
|
||||||
|
export function copyableKeyframesFromSelection(
|
||||||
|
projectId: ProjectId,
|
||||||
|
sheetId: SheetId,
|
||||||
|
selection: DopeSheetSelection | undefined,
|
||||||
|
): KeyframeWithPathToPropFromCommonRoot[] {
|
||||||
|
if (selection === undefined) return []
|
||||||
|
|
||||||
|
let kfs: KeyframeWithPathToPropFromCommonRoot[] = []
|
||||||
|
|
||||||
|
for (const {objectKey, trackId, keyframeIds} of flatSelectionTrackIds(
|
||||||
|
selection,
|
||||||
|
)) {
|
||||||
|
kfs = kfs.concat(
|
||||||
|
keyframesWithPaths({
|
||||||
|
projectId,
|
||||||
|
sheetId,
|
||||||
|
objectKey,
|
||||||
|
trackId,
|
||||||
|
keyframeIds,
|
||||||
|
}) ?? [],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const commonPath = commonRootOfPathsToProps(kfs.map((kf) => kf.pathToProp))
|
||||||
|
|
||||||
|
const keyframesWithCommonRootPath = kfs.map(({keyframe, pathToProp}) => ({
|
||||||
|
keyframe,
|
||||||
|
pathToProp: pathToProp.slice(commonPath.length),
|
||||||
|
}))
|
||||||
|
|
||||||
|
return keyframesWithCommonRootPath
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @see copyableKeyframesFromSelection
|
||||||
|
*/
|
||||||
|
export function keyframesWithPaths({
|
||||||
|
projectId,
|
||||||
|
sheetId,
|
||||||
|
objectKey,
|
||||||
|
trackId,
|
||||||
|
keyframeIds,
|
||||||
|
}: {
|
||||||
|
projectId: ProjectId
|
||||||
|
sheetId: SheetId
|
||||||
|
objectKey: ObjectAddressKey
|
||||||
|
trackId: SequenceTrackId
|
||||||
|
keyframeIds: KeyframeId[]
|
||||||
|
}): KeyframeWithPathToPropFromCommonRoot[] | null {
|
||||||
|
const tracksByObject = val(
|
||||||
|
getStudio().atomP.historic.coreByProject[projectId].sheetsById[sheetId]
|
||||||
|
.sequence.tracksByObject[objectKey],
|
||||||
|
)
|
||||||
|
const track = tracksByObject?.trackData[trackId]
|
||||||
|
|
||||||
|
if (!track) return null
|
||||||
|
|
||||||
|
const propPathByTrackId = swapKeyAndValue(
|
||||||
|
tracksByObject?.trackIdByPropPath || {},
|
||||||
|
)
|
||||||
|
const encodedPropPath = propPathByTrackId[trackId]
|
||||||
|
|
||||||
|
if (!encodedPropPath) return null
|
||||||
|
const pathToProp = decodePathToProp(encodedPropPath)
|
||||||
|
|
||||||
|
return keyframeIds
|
||||||
|
.map((keyframeId) => ({
|
||||||
|
keyframe: track.keyframes.find((keyframe) => keyframe.id === keyframeId),
|
||||||
|
pathToProp,
|
||||||
|
}))
|
||||||
|
.filter(
|
||||||
|
({keyframe}) => keyframe !== undefined,
|
||||||
|
) as KeyframeWithPathToPropFromCommonRoot[]
|
||||||
|
}
|
||||||
|
|
||||||
|
function swapKeyAndValue<K extends string, V extends string>(
|
||||||
|
obj: StrictRecord<K, V>,
|
||||||
|
): StrictRecord<V, K> {
|
||||||
|
const result: StrictRecord<V, K> = {}
|
||||||
|
for (const [key, value] of Object.entries(obj)) {
|
||||||
|
result[value as V] = key
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
export function keyframeConnections(
|
export function keyframeConnections(
|
||||||
keyframes: Array<Keyframe>,
|
keyframes: Array<Keyframe>,
|
||||||
): Array<{left: Keyframe; right: Keyframe}> {
|
): Array<{left: Keyframe; right: Keyframe}> {
|
||||||
|
|
|
@ -61,7 +61,7 @@ export type DopeSheetSelection = {
|
||||||
positionAtStartOfDrag: number
|
positionAtStartOfDrag: number
|
||||||
domNode: Element
|
domNode: Element
|
||||||
},
|
},
|
||||||
): Parameters<typeof useDrag>[1]
|
): Omit<Parameters<typeof useDrag>[1], 'onClick'>
|
||||||
delete(): void
|
delete(): void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -12,6 +12,7 @@ import type {
|
||||||
SheetObjectAddress,
|
SheetObjectAddress,
|
||||||
WithoutSheetInstance,
|
WithoutSheetInstance,
|
||||||
} from '@theatre/shared/utils/addresses'
|
} from '@theatre/shared/utils/addresses'
|
||||||
|
import {commonRootOfPathsToProps} from '@theatre/shared/utils/addresses'
|
||||||
import {encodePathToProp} from '@theatre/shared/utils/addresses'
|
import {encodePathToProp} from '@theatre/shared/utils/addresses'
|
||||||
import type {
|
import type {
|
||||||
StudioSheetItemKey,
|
StudioSheetItemKey,
|
||||||
|
@ -39,6 +40,7 @@ import set from 'lodash-es/set'
|
||||||
import sortBy from 'lodash-es/sortBy'
|
import sortBy from 'lodash-es/sortBy'
|
||||||
import {graphEditorColors} from '@theatre/studio/panels/SequenceEditorPanel/GraphEditor/GraphEditor'
|
import {graphEditorColors} from '@theatre/studio/panels/SequenceEditorPanel/GraphEditor/GraphEditor'
|
||||||
import type {
|
import type {
|
||||||
|
KeyframeWithPathToPropFromCommonRoot,
|
||||||
OutlineSelectable,
|
OutlineSelectable,
|
||||||
OutlineSelectionState,
|
OutlineSelectionState,
|
||||||
PanelPosition,
|
PanelPosition,
|
||||||
|
@ -422,13 +424,28 @@ namespace stateEditors {
|
||||||
) {
|
) {
|
||||||
drafts().ahistoric.visibilityState = visibilityState
|
drafts().ahistoric.visibilityState = visibilityState
|
||||||
}
|
}
|
||||||
export function setClipboardKeyframes(keyframes: Keyframe[]) {
|
export function setClipboardKeyframes(
|
||||||
|
keyframes: KeyframeWithPathToPropFromCommonRoot[],
|
||||||
|
) {
|
||||||
|
const commonPath = commonRootOfPathsToProps(
|
||||||
|
keyframes.map((kf) => kf.pathToProp),
|
||||||
|
)
|
||||||
|
|
||||||
|
const keyframesWithCommonRootPath = keyframes.map(
|
||||||
|
({keyframe, pathToProp}) => ({
|
||||||
|
keyframe,
|
||||||
|
pathToProp: pathToProp.slice(commonPath.length),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
// save selection
|
||||||
const draft = drafts()
|
const draft = drafts()
|
||||||
if (draft.ahistoric.clipboard) {
|
if (draft.ahistoric.clipboard) {
|
||||||
draft.ahistoric.clipboard.keyframes = keyframes
|
draft.ahistoric.clipboard.keyframesWithRelativePaths =
|
||||||
|
keyframesWithCommonRootPath
|
||||||
} else {
|
} else {
|
||||||
draft.ahistoric.clipboard = {
|
draft.ahistoric.clipboard = {
|
||||||
keyframes,
|
keyframesWithRelativePaths: keyframesWithCommonRootPath,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,6 +9,11 @@ export type UpdateCheckerResponse =
|
||||||
| {hasUpdates: true; newVersion: string; releasePage: string}
|
| {hasUpdates: true; newVersion: string; releasePage: string}
|
||||||
| {hasUpdates: false}
|
| {hasUpdates: false}
|
||||||
|
|
||||||
|
export type KeyframeWithPathToPropFromCommonRoot = {
|
||||||
|
pathToProp: (string | number)[]
|
||||||
|
keyframe: Keyframe
|
||||||
|
}
|
||||||
|
|
||||||
export type StudioAhistoricState = {
|
export type StudioAhistoricState = {
|
||||||
/**
|
/**
|
||||||
* undefined means the outline menu is pinned
|
* undefined means the outline menu is pinned
|
||||||
|
@ -20,7 +25,7 @@ export type StudioAhistoricState = {
|
||||||
pinDetails?: boolean
|
pinDetails?: boolean
|
||||||
visibilityState: 'everythingIsHidden' | 'everythingIsVisible'
|
visibilityState: 'everythingIsHidden' | 'everythingIsVisible'
|
||||||
clipboard?: {
|
clipboard?: {
|
||||||
keyframes?: Keyframe[]
|
keyframesWithRelativePaths?: KeyframeWithPathToPropFromCommonRoot[]
|
||||||
// future clipboard data goes here
|
// future clipboard data goes here
|
||||||
}
|
}
|
||||||
theTrigger: {
|
theTrigger: {
|
||||||
|
|
|
@ -251,9 +251,6 @@ const BasicNumberInput: React.FC<{
|
||||||
if (!happened) {
|
if (!happened) {
|
||||||
propsRef.current.discardTemporaryValue()
|
propsRef.current.discardTemporaryValue()
|
||||||
stateRef.current = {mode: 'noFocus'}
|
stateRef.current = {mode: 'noFocus'}
|
||||||
|
|
||||||
inputRef.current!.focus()
|
|
||||||
inputRef.current!.setSelectionRange(0, 100)
|
|
||||||
} else {
|
} else {
|
||||||
if (valueBeforeDragging === valueDuringDragging) {
|
if (valueBeforeDragging === valueDuringDragging) {
|
||||||
propsRef.current.discardTemporaryValue()
|
propsRef.current.discardTemporaryValue()
|
||||||
|
@ -263,6 +260,10 @@ const BasicNumberInput: React.FC<{
|
||||||
stateRef.current = {mode: 'noFocus'}
|
stateRef.current = {mode: 'noFocus'}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
onClick() {
|
||||||
|
inputRef.current!.focus()
|
||||||
|
inputRef.current!.setSelectionRange(0, 100)
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -78,7 +78,9 @@ const ContextMenu: React.FC<{
|
||||||
|
|
||||||
const preferredAnchorPoint = {
|
const preferredAnchorPoint = {
|
||||||
left: rect.width / 2,
|
left: rect.width / 2,
|
||||||
top: itemHeight / 2,
|
// if there is a displayName, make sure to move the context menu up by one item,
|
||||||
|
// so that the first active item is the one the mouse is hovering over
|
||||||
|
top: itemHeight / 2 + (props.displayName ? itemHeight : 0),
|
||||||
}
|
}
|
||||||
|
|
||||||
const pos = {
|
const pos = {
|
||||||
|
|
Loading…
Reference in a new issue