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
|
||||
}
|
||||
|
||||
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 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,
|
||||
},
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
},
|
||||
]
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
|
@ -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 []
|
||||
// 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 {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)
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,6 +77,7 @@ const BasicKeyframeConnector: React.VFC<IBasicKeyframeConnectorProps> = (
|
|||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<ConnectorLine
|
||||
ref={nodeRef}
|
||||
connectorLengthInUnitSpace={connectorLengthInUnitSpace}
|
||||
|
@ -93,8 +87,11 @@ const BasicKeyframeConnector: React.VFC<IBasicKeyframeConnectorProps> = (
|
|||
}}
|
||||
>
|
||||
{popoverNode}
|
||||
{contextMenu}
|
||||
</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
|
||||
|
@ -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: () => {
|
||||
return [
|
||||
{
|
||||
label: maybeKeyframeIds ? 'Copy Selection' : 'Copy both Keyframes',
|
||||
callback: () => {
|
||||
if (maybeKeyframeIds) {
|
||||
const keyframes = maybeKeyframeIds.map(
|
||||
(keyframeId) =>
|
||||
props.trackData.keyframes.find(
|
||||
(keyframe) => keyframe.id === keyframeId,
|
||||
)!,
|
||||
const copyableKeyframes = copyableKeyframesFromSelection(
|
||||
props.leaf.sheetObject.address.projectId,
|
||||
props.leaf.sheetObject.address.sheetId,
|
||||
props.selection,
|
||||
)
|
||||
|
||||
getStudio!().transaction((api) => {
|
||||
return [
|
||||
{
|
||||
label: copyableKeyframes.length > 0 ? 'Copy (selection)' : 'Copy',
|
||||
callback: () => {
|
||||
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: () => {
|
||||
getStudio().transaction(({stateEditors}) => {
|
||||
if (props.selection) {
|
||||
props.selection.delete()
|
||||
} else {
|
||||
getStudio()!.transaction(({stateEditors}) => {
|
||||
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!)
|
||||
})
|
||||
},
|
||||
},
|
||||
]
|
||||
|
|
|
@ -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)
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 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}> {
|
||||
|
|
|
@ -61,7 +61,7 @@ export type DopeSheetSelection = {
|
|||
positionAtStartOfDrag: number
|
||||
domNode: Element
|
||||
},
|
||||
): Parameters<typeof useDrag>[1]
|
||||
): Omit<Parameters<typeof useDrag>[1], 'onClick'>
|
||||
delete(): void
|
||||
}
|
||||
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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 = {
|
||||
|
|
Loading…
Reference in a new issue