Copy & pasting keyframes in aggregate tracks (#190)

This commit is contained in:
Elliot 2022-06-07 04:38:17 -04:00 committed by GitHub
parent e0359cb4b0
commit 25372d8bb0
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 708 additions and 153 deletions

View file

@ -111,3 +111,48 @@ export const getValueByPropPath = (
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)
}
}

View file

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

View file

@ -14,6 +14,13 @@ import {useAggregateKeyframeEditorUtils} from './useAggregateKeyframeEditorUtils
import type {IAggregateKeyframeEditorProps} from './AggregateKeyframeEditor'
import styled from 'styled-components'
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 EasingPopoverWrapper = styled(BasicPopover)`
@ -45,6 +52,7 @@ export const AggregateKeyframeConnector: React.VFC<IAggregateKeyframeConnectorPr
const [nodeRef, node] = useRefAndState<HTMLDivElement | null>(null)
const {editorProps} = props
const [contextMenu] = useConnectorContextMenu(props, node)
const [isDragging] = useDragKeyframe(node, props.editorProps)
const [popoverNode, openPopover, closePopover] = usePopover(
@ -85,6 +93,7 @@ export const AggregateKeyframeConnector: React.VFC<IAggregateKeyframeConnectorPr
}}
/>
{popoverNode}
{contextMenu}
</>
) : (
<></>
@ -181,3 +190,83 @@ function useDragKeyframe(
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,
},
)
}
})
}
},
},
]
},
})
}

View file

@ -15,6 +15,12 @@ import DopeSnap from '@theatre/studio/panels/SequenceEditorPanel/RightOverlay/Do
import type {IAggregateKeyframeEditorProps} from './AggregateKeyframeEditor'
import type {IAggregateKeyframeEditorUtils} from './useAggregateKeyframeEditorUtils'
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 = {
editorProps: IAggregateKeyframeEditorProps
@ -35,9 +41,7 @@ export function AggregateKeyframeDot(
},
})
const [contextMenu] = useAggregateKeyframeContextMenu(node, () =>
logger._debug('Show Aggregate Keyframe', props),
)
const [contextMenu] = useAggregateKeyframeContextMenu(props, node)
return (
<>
@ -58,17 +62,81 @@ export function AggregateKeyframeDot(
}
function useAggregateKeyframeContextMenu(
props: IAggregateKeyframeDotProps,
target: HTMLDivElement | null,
debugOnOpen: () => void,
) {
// TODO: missing features: delete, copy + paste
return useContextMenu(target, {
displayName: 'Aggregate Keyframe',
menuItems: () => {
return []
},
onOpen() {
debugOnOpen()
// 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,
)
})
}
},
},
{
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 {sheetObject} = viewModel
return selection
const hanlders = selection
.getDragHandlers({
...sheetObject.address,
domNode: node!,
positionAtStartOfDrag: keyframes[0].kf.position,
})
.onDragStart(event)
return hanlders && {...hanlders, onClick: options.onClickFromDrag}
}
const propsAtStartOfDrag = props
@ -156,9 +226,11 @@ function useDragForAggregateKeyframeDot(
tempTransaction?.commit()
} else {
tempTransaction?.discard()
options.onClickFromDrag(event)
}
},
onClick(ev) {
options.onClickFromDrag(ev)
},
}
},
}

View file

@ -9,15 +9,28 @@ import type {
import type {Keyframe} from '@theatre/core/projects/store/types/SheetState_Historic'
import {usePrism} from '@theatre/react'
import type {Pointer} from '@theatre/dataverse'
import {valueDerivation} from '@theatre/dataverse'
import {val} from '@theatre/dataverse'
import React from 'react'
import styled from 'styled-components'
import type {IContextMenuItem} from '@theatre/studio/uiComponents/simpleContextMenu/useContextMenu'
import useContextMenu from '@theatre/studio/uiComponents/simpleContextMenu/useContextMenu'
import useRefAndState from '@theatre/studio/utils/useRefAndState'
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'
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`
position: relative;
@ -186,7 +199,192 @@ function useAggregatedKeyframeTrackContextMenu(
onOpen: debugOnOpen,
displayName: 'Aggregate Keyframe Track',
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
}

View file

@ -12,6 +12,8 @@ import type {IContextMenuItem} from '@theatre/studio/uiComponents/simpleContextM
import useContextMenu from '@theatre/studio/uiComponents/simpleContextMenu/useContextMenu'
import useRefAndState from '@theatre/studio/utils/useRefAndState'
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`
position: relative;
@ -92,7 +94,9 @@ function useBasicKeyframedTrackContextMenu(
displayName: 'Keyframe Track',
menuItems: () => {
const selectionKeyframes =
val(getStudio()!.atomP.ahistoric.clipboard.keyframes) || []
val(
getStudio()!.atomP.ahistoric.clipboard.keyframesWithRelativePaths,
) ?? []
return [pasteKeyframesContextMenuItem(props, selectionKeyframes)]
},
@ -101,7 +105,7 @@ function useBasicKeyframedTrackContextMenu(
function pasteKeyframesContextMenuItem(
props: BasicKeyframedTracksProps,
keyframes: Keyframe[],
keyframes: KeyframeWithPathToPropFromCommonRoot[],
): IContextMenuItem {
return {
label: 'Paste Keyframes',
@ -110,11 +114,18 @@ function pasteKeyframesContextMenuItem(
const sheet = val(props.layoutP.sheet)
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}) => {
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(
{
...props.leaf.sheetObject.address,

View file

@ -11,8 +11,6 @@ import BasicPopover from '@theatre/studio/uiComponents/Popover/BasicPopover'
import CurveEditorPopover, {
isConnectionEditingInCurvePopover,
} 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 {ISingleKeyframeEditorProps} from './SingleKeyframeEditor'
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 {usePrism} from '@theatre/react'
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 styled from 'styled-components'
@ -56,13 +55,7 @@ const BasicKeyframeConnector: React.VFC<IBasicKeyframeConnectorProps> = (
() => <SingleCurveEditorPopover {...props} closePopover={closePopover} />,
)
const [contextMenu] = useConnectorContextMenu(
props,
node,
cur,
next,
openPopover,
)
const [contextMenu] = useConnectorContextMenu(props, node, cur, next)
useDragKeyframe(node, props)
const connectorLengthInUnitSpace = next.position - cur.position
@ -84,17 +77,21 @@ const BasicKeyframeConnector: React.VFC<IBasicKeyframeConnectorProps> = (
}
return (
<ConnectorLine
ref={nodeRef}
connectorLengthInUnitSpace={connectorLengthInUnitSpace}
{...themeValues}
openPopover={(e) => {
if (node) openPopover(e, node)
}}
>
{popoverNode}
<>
<ConnectorLine
ref={nodeRef}
connectorLengthInUnitSpace={connectorLengthInUnitSpace}
{...themeValues}
openPopover={(e) => {
if (node) openPopover(e, node)
}}
>
{popoverNode}
</ConnectorLine>
{/* contextMenu is placed outside of the ConnectorLine so that clicking on
the contextMenu does not count as clicking on the ConnectorLine */}
{contextMenu}
</ConnectorLine>
</>
)
}
export default BasicKeyframeConnector
@ -220,50 +217,51 @@ function useDragKeyframe(
useDrag(node, gestureHandlers)
}
function useConnectorContextMenu(
props: IBasicKeyframeConnectorProps,
node: HTMLDivElement | null,
cur: 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, {
displayName: 'Tween',
menuItems: () => {
const copyableKeyframes = copyableKeyframesFromSelection(
props.leaf.sheetObject.address.projectId,
props.leaf.sheetObject.address.sheetId,
props.selection,
)
return [
{
label: maybeKeyframeIds ? 'Copy Selection' : 'Copy both Keyframes',
label: copyableKeyframes.length > 0 ? 'Copy (selection)' : 'Copy',
callback: () => {
if (maybeKeyframeIds) {
const keyframes = maybeKeyframeIds.map(
(keyframeId) =>
props.trackData.keyframes.find(
(keyframe) => keyframe.id === keyframeId,
)!,
)
getStudio!().transaction((api) => {
if (copyableKeyframes.length > 0) {
getStudio().transaction((api) => {
api.stateEditors.studio.ahistoric.setClipboardKeyframes(
keyframes,
copyableKeyframes,
)
})
} else {
getStudio!().transaction((api) => {
getStudio().transaction((api) => {
api.stateEditors.studio.ahistoric.setClipboardKeyframes([
cur,
next,
{keyframe: cur, pathToProp: props.leaf.pathToProp},
{keyframe: next, pathToProp: props.leaf.pathToProp},
])
})
}
},
},
{
label: props.selection ? 'Delete Selection' : 'Delete both Keyframes',
label: props.selection ? 'Delete (selection)' : 'Delete',
callback: () => {
if (props.selection) {
props.selection.delete()
} else {
getStudio()!.transaction(({stateEditors}) => {
getStudio().transaction(({stateEditors}) => {
if (props.selection) {
props.selection.delete()
} else {
stateEditors.coreByProject.historic.sheetsById.sequence.deleteKeyframes(
{
...props.leaf.sheetObject.address,
@ -271,14 +269,8 @@ function useConnectorContextMenu(
trackId: props.leaf.trackId,
},
)
})
}
},
},
{
label: 'Open Easing Palette',
callback: (e) => {
openPopover(e, node!)
}
})
},
},
]

View file

@ -6,14 +6,12 @@ import last from 'lodash-es/last'
import getStudio from '@theatre/studio/getStudio'
import type {CommitOrDiscard} from '@theatre/studio/StudioStore/StudioStore'
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 type {UseDragOpts} from '@theatre/studio/uiComponents/useDrag'
import useRefAndState from '@theatre/studio/utils/useRefAndState'
import {val} from '@theatre/dataverse'
import {useLockFrameStampPosition} from '@theatre/studio/panels/SequenceEditorPanel/FrameStampPositionProvider'
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 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 {useLogger} from '@theatre/studio/uiComponents/useLogger'
import type {ILogger} from '@theatre/shared/logger'
import {copyableKeyframesFromSelection} from '@theatre/studio/panels/SequenceEditorPanel/DopeSheet/selections'
export const DOT_SIZE_PX = 6
const DOT_HOVER_SIZE_PX = DOT_SIZE_PX + 5
@ -107,20 +106,54 @@ function useSingleKeyframeContextMenu(
logger: ILogger,
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, {
displayName: 'Keyframe',
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() {
logger._debug('Show keyframe', props)
@ -183,7 +216,7 @@ function useDragForSingleKeyframeDot(
if (props.selection) {
const {selection, leaf} = props
const {sheetObject} = leaf
return selection
const handlers = selection
.getDragHandlers({
...sheetObject.address,
domNode: node!,
@ -191,6 +224,12 @@ function useDragForSingleKeyframeDot(
props.trackData.keyframes[props.index].position,
})
.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
@ -235,9 +274,11 @@ function useDragForSingleKeyframeDot(
tempTransaction?.commit()
} else {
tempTransaction?.discard()
options.onClickFromDrag(event)
}
},
onClick(ev) {
options.onClickFromDrag(ev)
},
}
},
}
@ -253,47 +294,3 @@ function useDragForSingleKeyframeDot(
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)
})
},
}
}

View file

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

View file

@ -10,6 +10,12 @@ import type {
import getStudio from '@theatre/studio/getStudio'
import type {DopeSheetSelection} from '@theatre/studio/panels/SequenceEditorPanel/layout/layout'
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
@ -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(
keyframes: Array<Keyframe>,
): Array<{left: Keyframe; right: Keyframe}> {

View file

@ -61,7 +61,7 @@ export type DopeSheetSelection = {
positionAtStartOfDrag: number
domNode: Element
},
): Parameters<typeof useDrag>[1]
): Omit<Parameters<typeof useDrag>[1], 'onClick'>
delete(): void
}

View file

@ -12,6 +12,7 @@ import type {
SheetObjectAddress,
WithoutSheetInstance,
} from '@theatre/shared/utils/addresses'
import {commonRootOfPathsToProps} from '@theatre/shared/utils/addresses'
import {encodePathToProp} from '@theatre/shared/utils/addresses'
import type {
StudioSheetItemKey,
@ -39,6 +40,7 @@ import set from 'lodash-es/set'
import sortBy from 'lodash-es/sortBy'
import {graphEditorColors} from '@theatre/studio/panels/SequenceEditorPanel/GraphEditor/GraphEditor'
import type {
KeyframeWithPathToPropFromCommonRoot,
OutlineSelectable,
OutlineSelectionState,
PanelPosition,
@ -422,13 +424,28 @@ namespace stateEditors {
) {
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()
if (draft.ahistoric.clipboard) {
draft.ahistoric.clipboard.keyframes = keyframes
draft.ahistoric.clipboard.keyframesWithRelativePaths =
keyframesWithCommonRootPath
} else {
draft.ahistoric.clipboard = {
keyframes,
keyframesWithRelativePaths: keyframesWithCommonRootPath,
}
}
}

View file

@ -9,6 +9,11 @@ export type UpdateCheckerResponse =
| {hasUpdates: true; newVersion: string; releasePage: string}
| {hasUpdates: false}
export type KeyframeWithPathToPropFromCommonRoot = {
pathToProp: (string | number)[]
keyframe: Keyframe
}
export type StudioAhistoricState = {
/**
* undefined means the outline menu is pinned
@ -20,7 +25,7 @@ export type StudioAhistoricState = {
pinDetails?: boolean
visibilityState: 'everythingIsHidden' | 'everythingIsVisible'
clipboard?: {
keyframes?: Keyframe[]
keyframesWithRelativePaths?: KeyframeWithPathToPropFromCommonRoot[]
// future clipboard data goes here
}
theTrigger: {

View file

@ -251,9 +251,6 @@ const BasicNumberInput: React.FC<{
if (!happened) {
propsRef.current.discardTemporaryValue()
stateRef.current = {mode: 'noFocus'}
inputRef.current!.focus()
inputRef.current!.setSelectionRange(0, 100)
} else {
if (valueBeforeDragging === valueDuringDragging) {
propsRef.current.discardTemporaryValue()
@ -263,6 +260,10 @@ const BasicNumberInput: React.FC<{
stateRef.current = {mode: 'noFocus'}
}
},
onClick() {
inputRef.current!.focus()
inputRef.current!.setSelectionRange(0, 100)
},
}
}

View file

@ -78,7 +78,9 @@ const ContextMenu: React.FC<{
const preferredAnchorPoint = {
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 = {