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:
Elliot 2022-03-24 12:28:17 -04:00 committed by GitHub
parent 89133c5e6f
commit 8a9b26eb41
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 288 additions and 96 deletions

View file

@ -1,21 +1,35 @@
import type {TrackData} from '@theatre/core/projects/store/types/SheetState_Historic' import type {TrackData} from '@theatre/core/projects/store/types/SheetState_Historic'
import type {SequenceEditorPanelLayout} from '@theatre/studio/panels/SequenceEditorPanel/layout/layout' import type {SequenceEditorPanelLayout} from '@theatre/studio/panels/SequenceEditorPanel/layout/layout'
import type {SequenceEditorTree_PrimitiveProp} from '@theatre/studio/panels/SequenceEditorPanel/layout/tree' 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 {usePrism} from '@theatre/react'
import type {Pointer} from '@theatre/dataverse' import type {Pointer} from '@theatre/dataverse'
import {val} from '@theatre/dataverse' import {val} from '@theatre/dataverse'
import React from 'react' import React from 'react'
import styled from 'styled-components' import styled from 'styled-components'
import KeyframeEditor from './KeyframeEditor/KeyframeEditor' 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 leaf: SequenceEditorTree_PrimitiveProp
layoutP: Pointer<SequenceEditorPanelLayout> layoutP: Pointer<SequenceEditorPanelLayout>
trackData: TrackData trackData: TrackData
}> = React.memo(({layoutP, trackData, leaf}) => { }
const BasicKeyframedTrack: React.FC<BasicKeyframedTracksProps> = React.memo(
(props) => {
const {layoutP, trackData, leaf} = props
const [containerRef, containerNode] = useRefAndState<HTMLDivElement | null>(
null,
)
const {selectedKeyframeIds, selection} = usePrism(() => { const {selectedKeyframeIds, selection} = usePrism(() => {
const selectionAtom = val(layoutP.selectionAtom) const selectionAtom = val(layoutP.selectionAtom)
const selectedKeyframeIds = val( const selectedKeyframeIds = val(
@ -33,6 +47,11 @@ const BasicKeyframedTrack: React.FC<{
} }
}, [layoutP, leaf.trackId]) }, [layoutP, leaf.trackId])
const [contextMenu, _, isOpen] = useBasicKeyframedTrackContextMenu(
containerNode,
props,
)
const keyframeEditors = trackData.keyframes.map((kf, index) => ( const keyframeEditors = trackData.keyframes.map((kf, index) => (
<KeyframeEditor <KeyframeEditor
keyframe={kf} keyframe={kf}
@ -45,7 +64,76 @@ const BasicKeyframedTrack: React.FC<{
/> />
)) ))
return <>{keyframeEditors}</> return (
}) <Container
ref={containerRef}
style={{
background: isOpen ? '#444850 ' : 'unset',
}}
>
{keyframeEditors}
{contextMenu}
</Container>
)
},
)
export default BasicKeyframedTrack 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
}

View file

@ -18,6 +18,9 @@ import type Sequence from '@theatre/core/sequences/Sequence'
import usePopover from '@theatre/studio/uiComponents/Popover/usePopover' import usePopover from '@theatre/studio/uiComponents/Popover/usePopover'
import BasicPopover from '@theatre/studio/uiComponents/Popover/BasicPopover' import BasicPopover from '@theatre/studio/uiComponents/Popover/BasicPopover'
import CurveEditorPopover from './CurveEditorPopover/CurveEditorPopover' 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 connectorHeight = dotSize / 2 + 1
const connectorWidthUnscaled = 1000 const connectorWidthUnscaled = 1000
@ -89,37 +92,13 @@ const Connector: React.FC<IProps> = (props) => {
}, },
) )
const [contextMenu] = useContextMenu(node, { const [contextMenu] = useConnectorContextMenu(
items: () => { props,
return [ node,
{ cur,
label: props.selection ? 'Delete Selection' : 'Delete both Keyframes', next,
callback: () => { openPopover,
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!)
},
},
]
},
})
useDragKeyframe(node, props) useDragKeyframe(node, props)
return ( return (
@ -228,3 +207,69 @@ function useDragKeyframe(node: HTMLDivElement | null, props: IProps) {
useDrag(node, gestureHandlers) 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!)
},
},
]
},
})
}

View file

@ -16,6 +16,7 @@ import {useLockFrameStampPosition} from '@theatre/studio/panels/SequenceEditorPa
import {attributeNameThatLocksFramestamp} from '@theatre/studio/panels/SequenceEditorPanel/FrameStampPositionProvider' import {attributeNameThatLocksFramestamp} from '@theatre/studio/panels/SequenceEditorPanel/FrameStampPositionProvider'
import {useCursorLock} from '@theatre/studio/uiComponents/PointerEventsHandler' import {useCursorLock} from '@theatre/studio/uiComponents/PointerEventsHandler'
import SnapCursor from './SnapCursor.svg' import SnapCursor from './SnapCursor.svg'
import selectedKeyframeIdsIfInSingleTrack from '@theatre/studio/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/selectedKeyframeIdsIfInSingleTrack'
export const dotSize = 6 export const dotSize = 6
const hitZoneSize = 12 const hitZoneSize = 12
@ -106,28 +107,17 @@ const Dot: React.FC<IProps> = (props) => {
export default Dot export default Dot
function useKeyframeContextMenu(node: HTMLDivElement | null, props: IProps) { 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, { return useContextMenu(node, {
items: () => { items: () => {
return [ return [...keyframeSelectionItems, deleteItem]
{
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,
},
)
})
}
},
},
]
}, },
}) })
} }
@ -249,3 +239,42 @@ function useDragKeyframe(
return [isDragging] 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)
})
},
}
}

View file

@ -8,6 +8,7 @@ import type {
} from '@theatre/studio/panels/SequenceEditorPanel/layout/layout' } from '@theatre/studio/panels/SequenceEditorPanel/layout/layout'
import type {SequenceEditorTree_PrimitiveProp} from '@theatre/studio/panels/SequenceEditorPanel/layout/tree' import type {SequenceEditorTree_PrimitiveProp} from '@theatre/studio/panels/SequenceEditorPanel/layout/tree'
import type {Pointer} from '@theatre/dataverse' import type {Pointer} from '@theatre/dataverse'
import {val} from '@theatre/dataverse'
import React from 'react' import React from 'react'
import styled from 'styled-components' import styled from 'styled-components'
import Connector from './Connector' import Connector from './Connector'
@ -37,7 +38,11 @@ const KeyframeEditor: React.FC<{
<Container <Container
style={{ style={{
top: `${props.leaf.nodeHeight / 2}px`, 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} /> <Dot {...props} />

View file

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

View file

@ -30,10 +30,6 @@ const Container = styled.div`
} }
` `
const ShiftRight = styled.div`
position: absolute;
`
const HorizontallyScrollableArea: React.FC<{ const HorizontallyScrollableArea: React.FC<{
layoutP: Pointer<SequenceEditorPanelLayout> layoutP: Pointer<SequenceEditorPanelLayout>
height: number height: number
@ -65,14 +61,8 @@ const HorizontallyScrollableArea: React.FC<{
// @ts-expect-error // @ts-expect-error
'--unitSpaceToScaledSpaceMultiplier': unitSpaceToScaledSpaceMultiplier, '--unitSpaceToScaledSpaceMultiplier': unitSpaceToScaledSpaceMultiplier,
}} }}
>
<ShiftRight
style={{
left: val(layoutP.scaledSpace.leftPadding) + 'px',
}}
> >
{children} {children}
</ShiftRight>
</Container> </Container>
) )
}) })

View file

@ -317,6 +317,16 @@ namespace stateEditors {
) { ) {
drafts().ahistoric.visibilityState = visibilityState 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 projects {
export namespace stateByProjectId { export namespace stateByProjectId {
export function _ensure(p: ProjectAddress) { export function _ensure(p: ProjectAddress) {

View file

@ -1,9 +1,13 @@
import type {ProjectState} from '@theatre/core/projects/store/storeTypes' 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' import type {IRange, StrictRecord} from '@theatre/shared/utils/types'
export type StudioAhistoricState = { export type StudioAhistoricState = {
visibilityState: 'everythingIsHidden' | 'everythingIsVisible' visibilityState: 'everythingIsHidden' | 'everythingIsVisible'
clipboard?: {
keyframes?: Keyframe[]
// future clipboard data goes here
}
theTrigger: { theTrigger: {
position: { position: {
closestCorner: 'topLeft' | 'topRight' | 'bottomLeft' | 'bottomRight' closestCorner: 'topLeft' | 'topRight' | 'bottomLeft' | 'bottomRight'

View file

@ -3,7 +3,7 @@ import {createPortal} from 'react-dom'
import {PortalContext} from 'reakit' import {PortalContext} from 'reakit'
import TooltipWrapper from './TooltipWrapper' 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 CloseFn = () => void
type State = type State =
| {isOpen: false} | {isOpen: false}