Add keyframe copy and paste draft (#105)
* Add keyframe copy and paste draft Author: vezwork <elliot@theatrejs.com> Date: Mon Mar 21 15:48:06 2022 -0400 * add first pass for copy and paste keyframes Author: vezwork <elliot@theatrejs.com> Date: Tue Mar 22 10:35:17 2022 -0400 * add clipboard with keyframes to ahistoric data * Refactor keyframe context menu * fix type error * refactor context menus * cleanup small bits of code * reorder function defs * Add connector copy keyframes and fix highlight left margin * modify keyframe positioning Co-authored-by: Elliot <elliot@Elliots-MacBook-Pro.local>
This commit is contained in:
parent
89133c5e6f
commit
8a9b26eb41
9 changed files with 288 additions and 96 deletions
|
@ -1,51 +1,139 @@
|
|||
import type {TrackData} from '@theatre/core/projects/store/types/SheetState_Historic'
|
||||
import type {SequenceEditorPanelLayout} from '@theatre/studio/panels/SequenceEditorPanel/layout/layout'
|
||||
import type {SequenceEditorTree_PrimitiveProp} from '@theatre/studio/panels/SequenceEditorPanel/layout/tree'
|
||||
import type {Keyframe} from '@theatre/core/projects/store/types/SheetState_Historic'
|
||||
import {usePrism} from '@theatre/react'
|
||||
import type {Pointer} from '@theatre/dataverse'
|
||||
import {val} from '@theatre/dataverse'
|
||||
import React from 'react'
|
||||
import styled from 'styled-components'
|
||||
import KeyframeEditor from './KeyframeEditor/KeyframeEditor'
|
||||
import useContextMenu from '@theatre/studio/uiComponents/simpleContextMenu/useContextMenu'
|
||||
import useRefAndState from '@theatre/studio/utils/useRefAndState'
|
||||
import getStudio from '@theatre/studio/getStudio'
|
||||
|
||||
const Container = styled.div``
|
||||
const Container = styled.div`
|
||||
position: relative;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
`
|
||||
|
||||
const BasicKeyframedTrack: React.FC<{
|
||||
type BasicKeyframedTracksProps = {
|
||||
leaf: SequenceEditorTree_PrimitiveProp
|
||||
|
||||
layoutP: Pointer<SequenceEditorPanelLayout>
|
||||
trackData: TrackData
|
||||
}> = React.memo(({layoutP, trackData, leaf}) => {
|
||||
const {selectedKeyframeIds, selection} = usePrism(() => {
|
||||
const selectionAtom = val(layoutP.selectionAtom)
|
||||
const selectedKeyframeIds = val(
|
||||
selectionAtom.pointer.current.byObjectKey[
|
||||
leaf.sheetObject.address.objectKey
|
||||
].byTrackId[leaf.trackId].byKeyframeId,
|
||||
}
|
||||
|
||||
const BasicKeyframedTrack: React.FC<BasicKeyframedTracksProps> = React.memo(
|
||||
(props) => {
|
||||
const {layoutP, trackData, leaf} = props
|
||||
const [containerRef, containerNode] = useRefAndState<HTMLDivElement | null>(
|
||||
null,
|
||||
)
|
||||
if (selectedKeyframeIds) {
|
||||
return {
|
||||
selectedKeyframeIds,
|
||||
selection: val(selectionAtom.pointer.current),
|
||||
const {selectedKeyframeIds, selection} = usePrism(() => {
|
||||
const selectionAtom = val(layoutP.selectionAtom)
|
||||
const selectedKeyframeIds = val(
|
||||
selectionAtom.pointer.current.byObjectKey[
|
||||
leaf.sheetObject.address.objectKey
|
||||
].byTrackId[leaf.trackId].byKeyframeId,
|
||||
)
|
||||
if (selectedKeyframeIds) {
|
||||
return {
|
||||
selectedKeyframeIds,
|
||||
selection: val(selectionAtom.pointer.current),
|
||||
}
|
||||
} else {
|
||||
return {selectedKeyframeIds: {}, selection: undefined}
|
||||
}
|
||||
} else {
|
||||
return {selectedKeyframeIds: {}, selection: undefined}
|
||||
}
|
||||
}, [layoutP, leaf.trackId])
|
||||
}, [layoutP, leaf.trackId])
|
||||
|
||||
const keyframeEditors = trackData.keyframes.map((kf, index) => (
|
||||
<KeyframeEditor
|
||||
keyframe={kf}
|
||||
index={index}
|
||||
trackData={trackData}
|
||||
layoutP={layoutP}
|
||||
leaf={leaf}
|
||||
key={'keyframe-' + kf.id}
|
||||
selection={selectedKeyframeIds[kf.id] === true ? selection : undefined}
|
||||
/>
|
||||
))
|
||||
const [contextMenu, _, isOpen] = useBasicKeyframedTrackContextMenu(
|
||||
containerNode,
|
||||
props,
|
||||
)
|
||||
|
||||
return <>{keyframeEditors}</>
|
||||
})
|
||||
const keyframeEditors = trackData.keyframes.map((kf, index) => (
|
||||
<KeyframeEditor
|
||||
keyframe={kf}
|
||||
index={index}
|
||||
trackData={trackData}
|
||||
layoutP={layoutP}
|
||||
leaf={leaf}
|
||||
key={'keyframe-' + kf.id}
|
||||
selection={selectedKeyframeIds[kf.id] === true ? selection : undefined}
|
||||
/>
|
||||
))
|
||||
|
||||
return (
|
||||
<Container
|
||||
ref={containerRef}
|
||||
style={{
|
||||
background: isOpen ? '#444850 ' : 'unset',
|
||||
}}
|
||||
>
|
||||
{keyframeEditors}
|
||||
{contextMenu}
|
||||
</Container>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
export default BasicKeyframedTrack
|
||||
|
||||
function useBasicKeyframedTrackContextMenu(
|
||||
node: HTMLDivElement | null,
|
||||
props: BasicKeyframedTracksProps,
|
||||
) {
|
||||
return useContextMenu(node, {
|
||||
items: () => {
|
||||
const selectionKeyframes =
|
||||
val(getStudio()!.atomP.ahistoric.clipboard.keyframes) || []
|
||||
|
||||
if (selectionKeyframes.length > 0) {
|
||||
return [pasteKeyframesContextMenuItem(props, selectionKeyframes)]
|
||||
} else {
|
||||
return []
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function pasteKeyframesContextMenuItem(
|
||||
props: BasicKeyframedTracksProps,
|
||||
keyframes: Keyframe[],
|
||||
) {
|
||||
return {
|
||||
label: 'Paste Keyframes',
|
||||
callback: () => {
|
||||
const sheet = val(props.layoutP.sheet)
|
||||
const sequence = sheet.getSequence()
|
||||
|
||||
getStudio()!.transaction(({stateEditors}) => {
|
||||
sequence.position = sequence.closestGridPosition(sequence.position)
|
||||
const keyframeOffset = earliestKeyframe(keyframes)?.position!
|
||||
|
||||
for (const keyframe of keyframes) {
|
||||
stateEditors.coreByProject.historic.sheetsById.sequence.setKeyframeAtPosition(
|
||||
{
|
||||
...props.leaf.sheetObject.address,
|
||||
trackId: props.leaf.trackId,
|
||||
position: sequence.position + keyframe.position - keyframeOffset,
|
||||
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
|
||||
}
|
||||
|
|
|
@ -18,6 +18,9 @@ import type Sequence from '@theatre/core/sequences/Sequence'
|
|||
import usePopover from '@theatre/studio/uiComponents/Popover/usePopover'
|
||||
import BasicPopover from '@theatre/studio/uiComponents/Popover/BasicPopover'
|
||||
import 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'
|
||||
|
||||
const connectorHeight = dotSize / 2 + 1
|
||||
const connectorWidthUnscaled = 1000
|
||||
|
@ -89,37 +92,13 @@ const Connector: React.FC<IProps> = (props) => {
|
|||
},
|
||||
)
|
||||
|
||||
const [contextMenu] = useContextMenu(node, {
|
||||
items: () => {
|
||||
return [
|
||||
{
|
||||
label: props.selection ? 'Delete Selection' : 'Delete both Keyframes',
|
||||
callback: () => {
|
||||
if (props.selection) {
|
||||
props.selection.delete()
|
||||
} else {
|
||||
getStudio()!.transaction(({stateEditors}) => {
|
||||
stateEditors.coreByProject.historic.sheetsById.sequence.deleteKeyframes(
|
||||
{
|
||||
...props.leaf.sheetObject.address,
|
||||
keyframeIds: [cur.id, next.id],
|
||||
trackId: props.leaf.trackId,
|
||||
},
|
||||
)
|
||||
})
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Open Easing Palette',
|
||||
callback: (e) => {
|
||||
openPopover(e, node!)
|
||||
},
|
||||
},
|
||||
]
|
||||
},
|
||||
})
|
||||
|
||||
const [contextMenu] = useConnectorContextMenu(
|
||||
props,
|
||||
node,
|
||||
cur,
|
||||
next,
|
||||
openPopover,
|
||||
)
|
||||
useDragKeyframe(node, props)
|
||||
|
||||
return (
|
||||
|
@ -228,3 +207,69 @@ function useDragKeyframe(node: HTMLDivElement | null, props: IProps) {
|
|||
|
||||
useDrag(node, gestureHandlers)
|
||||
}
|
||||
|
||||
function useConnectorContextMenu(
|
||||
props: IProps,
|
||||
node: HTMLDivElement | null,
|
||||
cur: Keyframe,
|
||||
next: Keyframe,
|
||||
openPopover: OpenFn,
|
||||
) {
|
||||
const maybeKeyframeIds = selectedKeyframeIdsIfInSingleTrack(props.selection)
|
||||
return useContextMenu(node, {
|
||||
items: () => {
|
||||
return [
|
||||
{
|
||||
label: maybeKeyframeIds ? 'Copy Selection' : 'Copy both Keyframes',
|
||||
callback: () => {
|
||||
if (maybeKeyframeIds) {
|
||||
const keyframes = maybeKeyframeIds.map(
|
||||
(keyframeId) =>
|
||||
props.trackData.keyframes.find(
|
||||
(keyframe) => keyframe.id === keyframeId,
|
||||
)!,
|
||||
)
|
||||
|
||||
getStudio!().transaction((api) => {
|
||||
api.stateEditors.studio.ahistoric.setClipboardKeyframes(
|
||||
keyframes,
|
||||
)
|
||||
})
|
||||
} else {
|
||||
getStudio!().transaction((api) => {
|
||||
api.stateEditors.studio.ahistoric.setClipboardKeyframes([
|
||||
cur,
|
||||
next,
|
||||
])
|
||||
})
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
label: props.selection ? 'Delete Selection' : 'Delete both Keyframes',
|
||||
callback: () => {
|
||||
if (props.selection) {
|
||||
props.selection.delete()
|
||||
} else {
|
||||
getStudio()!.transaction(({stateEditors}) => {
|
||||
stateEditors.coreByProject.historic.sheetsById.sequence.deleteKeyframes(
|
||||
{
|
||||
...props.leaf.sheetObject.address,
|
||||
keyframeIds: [cur.id, next.id],
|
||||
trackId: props.leaf.trackId,
|
||||
},
|
||||
)
|
||||
})
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Open Easing Palette',
|
||||
callback: (e) => {
|
||||
openPopover(e, node!)
|
||||
},
|
||||
},
|
||||
]
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
|
@ -16,6 +16,7 @@ import {useLockFrameStampPosition} from '@theatre/studio/panels/SequenceEditorPa
|
|||
import {attributeNameThatLocksFramestamp} from '@theatre/studio/panels/SequenceEditorPanel/FrameStampPositionProvider'
|
||||
import {useCursorLock} from '@theatre/studio/uiComponents/PointerEventsHandler'
|
||||
import SnapCursor from './SnapCursor.svg'
|
||||
import selectedKeyframeIdsIfInSingleTrack from '@theatre/studio/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/selectedKeyframeIdsIfInSingleTrack'
|
||||
|
||||
export const dotSize = 6
|
||||
const hitZoneSize = 12
|
||||
|
@ -106,28 +107,17 @@ const Dot: React.FC<IProps> = (props) => {
|
|||
export default Dot
|
||||
|
||||
function useKeyframeContextMenu(node: HTMLDivElement | null, props: IProps) {
|
||||
const maybeKeyframeIds = selectedKeyframeIdsIfInSingleTrack(props.selection)
|
||||
|
||||
const keyframeSelectionItems = maybeKeyframeIds
|
||||
? [copyKeyFrameContextMenuItem(props, maybeKeyframeIds)]
|
||||
: []
|
||||
|
||||
const deleteItem = deleteSelectionOrKeyframeContextMenuItem(props)
|
||||
|
||||
return useContextMenu(node, {
|
||||
items: () => {
|
||||
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,
|
||||
},
|
||||
)
|
||||
})
|
||||
}
|
||||
},
|
||||
},
|
||||
]
|
||||
return [...keyframeSelectionItems, deleteItem]
|
||||
},
|
||||
})
|
||||
}
|
||||
|
@ -249,3 +239,42 @@ function useDragKeyframe(
|
|||
|
||||
return [isDragging]
|
||||
}
|
||||
|
||||
function deleteSelectionOrKeyframeContextMenuItem(props: IProps) {
|
||||
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: IProps, keyframeIds: string[]) {
|
||||
return {
|
||||
label: 'Copy Keyframes',
|
||||
callback: () => {
|
||||
const keyframes = keyframeIds.map(
|
||||
(keyframeId) =>
|
||||
props.trackData.keyframes.find(
|
||||
(keyframe) => keyframe.id === keyframeId,
|
||||
)!,
|
||||
)
|
||||
|
||||
getStudio!().transaction((api) => {
|
||||
api.stateEditors.studio.ahistoric.setClipboardKeyframes(keyframes)
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
@ -8,6 +8,7 @@ import type {
|
|||
} from '@theatre/studio/panels/SequenceEditorPanel/layout/layout'
|
||||
import type {SequenceEditorTree_PrimitiveProp} from '@theatre/studio/panels/SequenceEditorPanel/layout/tree'
|
||||
import type {Pointer} from '@theatre/dataverse'
|
||||
import {val} from '@theatre/dataverse'
|
||||
import React from 'react'
|
||||
import styled from 'styled-components'
|
||||
import Connector from './Connector'
|
||||
|
@ -37,7 +38,11 @@ const KeyframeEditor: React.FC<{
|
|||
<Container
|
||||
style={{
|
||||
top: `${props.leaf.nodeHeight / 2}px`,
|
||||
left: `calc(var(--unitSpaceToScaledSpaceMultiplier) * ${cur.position}px)`,
|
||||
left: `calc(${val(
|
||||
props.layoutP.scaledSpace.leftPadding,
|
||||
)}px + calc(var(--unitSpaceToScaledSpaceMultiplier) * ${
|
||||
cur.position
|
||||
}px))`,
|
||||
}}
|
||||
>
|
||||
<Dot {...props} />
|
||||
|
|
|
@ -0,0 +1,21 @@
|
|||
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)
|
||||
}
|
|
@ -30,10 +30,6 @@ const Container = styled.div`
|
|||
}
|
||||
`
|
||||
|
||||
const ShiftRight = styled.div`
|
||||
position: absolute;
|
||||
`
|
||||
|
||||
const HorizontallyScrollableArea: React.FC<{
|
||||
layoutP: Pointer<SequenceEditorPanelLayout>
|
||||
height: number
|
||||
|
@ -66,13 +62,7 @@ const HorizontallyScrollableArea: React.FC<{
|
|||
'--unitSpaceToScaledSpaceMultiplier': unitSpaceToScaledSpaceMultiplier,
|
||||
}}
|
||||
>
|
||||
<ShiftRight
|
||||
style={{
|
||||
left: val(layoutP.scaledSpace.leftPadding) + 'px',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</ShiftRight>
|
||||
{children}
|
||||
</Container>
|
||||
)
|
||||
})
|
||||
|
|
|
@ -317,6 +317,16 @@ namespace stateEditors {
|
|||
) {
|
||||
drafts().ahistoric.visibilityState = visibilityState
|
||||
}
|
||||
export function setClipboardKeyframes(keyframes: Keyframe[]) {
|
||||
const draft = drafts()
|
||||
if (draft.ahistoric.clipboard) {
|
||||
draft.ahistoric.clipboard.keyframes = keyframes
|
||||
} else {
|
||||
draft.ahistoric.clipboard = {
|
||||
keyframes,
|
||||
}
|
||||
}
|
||||
}
|
||||
export namespace projects {
|
||||
export namespace stateByProjectId {
|
||||
export function _ensure(p: ProjectAddress) {
|
||||
|
|
|
@ -1,9 +1,13 @@
|
|||
import type {ProjectState} from '@theatre/core/projects/store/storeTypes'
|
||||
import type {Keyframe} from '@theatre/core/projects/store/types/SheetState_Historic'
|
||||
import type {IRange, StrictRecord} from '@theatre/shared/utils/types'
|
||||
|
||||
export type StudioAhistoricState = {
|
||||
visibilityState: 'everythingIsHidden' | 'everythingIsVisible'
|
||||
|
||||
clipboard?: {
|
||||
keyframes?: Keyframe[]
|
||||
// future clipboard data goes here
|
||||
}
|
||||
theTrigger: {
|
||||
position: {
|
||||
closestCorner: 'topLeft' | 'topRight' | 'bottomLeft' | 'bottomRight'
|
||||
|
|
|
@ -3,7 +3,7 @@ import {createPortal} from 'react-dom'
|
|||
import {PortalContext} from 'reakit'
|
||||
import TooltipWrapper from './TooltipWrapper'
|
||||
|
||||
type OpenFn = (e: React.MouseEvent, target: HTMLElement) => void
|
||||
export type OpenFn = (e: React.MouseEvent, target: HTMLElement) => void
|
||||
type CloseFn = () => void
|
||||
type State =
|
||||
| {isOpen: false}
|
||||
|
|
Loading…
Reference in a new issue