From 8a9b26eb4146b8c581cd83f55e14706ca1ab3cda Mon Sep 17 00:00:00 2001 From: Elliot Date: Thu, 24 Mar 2022 12:28:17 -0400 Subject: [PATCH] Add keyframe copy and paste draft (#105) * Add keyframe copy and paste draft Author: vezwork Date: Mon Mar 21 15:48:06 2022 -0400 * add first pass for copy and paste keyframes Author: vezwork 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 --- .../BasicKeyframedTrack.tsx | 150 ++++++++++++++---- .../KeyframeEditor/Connector.tsx | 107 +++++++++---- .../KeyframeEditor/Dot.tsx | 69 +++++--- .../KeyframeEditor/KeyframeEditor.tsx | 7 +- .../selectedKeyframeIdsIfInSingleTrack.ts | 21 +++ .../Right/HorizontallyScrollableArea.tsx | 12 +- theatre/studio/src/store/stateEditors.ts | 10 ++ theatre/studio/src/store/types/ahistoric.ts | 6 +- .../src/uiComponents/Popover/usePopover.tsx | 2 +- 9 files changed, 288 insertions(+), 96 deletions(-) create mode 100644 theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/selectedKeyframeIdsIfInSingleTrack.ts diff --git a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/BasicKeyframedTrack.tsx b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/BasicKeyframedTrack.tsx index 75281af..6b2133f 100644 --- a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/BasicKeyframedTrack.tsx +++ b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/BasicKeyframedTrack.tsx @@ -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 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 = React.memo( + (props) => { + const {layoutP, trackData, leaf} = props + const [containerRef, containerNode] = useRefAndState( + 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) => ( - - )) + const [contextMenu, _, isOpen] = useBasicKeyframedTrackContextMenu( + containerNode, + props, + ) - return <>{keyframeEditors} -}) + const keyframeEditors = trackData.keyframes.map((kf, index) => ( + + )) + + return ( + + {keyframeEditors} + {contextMenu} + + ) + }, +) 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 +} diff --git a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/Connector.tsx b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/Connector.tsx index 53eca32..bdbfa2e 100644 --- a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/Connector.tsx +++ b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/Connector.tsx @@ -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 = (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!) + }, + }, + ] + }, + }) +} diff --git a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/Dot.tsx b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/Dot.tsx index df1b027..141f5a6 100644 --- a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/Dot.tsx +++ b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/Dot.tsx @@ -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 = (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) + }) + }, + } +} diff --git a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/KeyframeEditor.tsx b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/KeyframeEditor.tsx index 737f9e4..3d62159 100644 --- a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/KeyframeEditor.tsx +++ b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/KeyframeEditor.tsx @@ -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<{ diff --git a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/selectedKeyframeIdsIfInSingleTrack.ts b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/selectedKeyframeIdsIfInSingleTrack.ts new file mode 100644 index 0000000..5dbd7cd --- /dev/null +++ b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/selectedKeyframeIdsIfInSingleTrack.ts @@ -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) +} diff --git a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/HorizontallyScrollableArea.tsx b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/HorizontallyScrollableArea.tsx index 1e92826..916a010 100644 --- a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/HorizontallyScrollableArea.tsx +++ b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/HorizontallyScrollableArea.tsx @@ -30,10 +30,6 @@ const Container = styled.div` } ` -const ShiftRight = styled.div` - position: absolute; -` - const HorizontallyScrollableArea: React.FC<{ layoutP: Pointer height: number @@ -66,13 +62,7 @@ const HorizontallyScrollableArea: React.FC<{ '--unitSpaceToScaledSpaceMultiplier': unitSpaceToScaledSpaceMultiplier, }} > - - {children} - + {children} ) }) diff --git a/theatre/studio/src/store/stateEditors.ts b/theatre/studio/src/store/stateEditors.ts index 66b3a5d..17ae2b4 100644 --- a/theatre/studio/src/store/stateEditors.ts +++ b/theatre/studio/src/store/stateEditors.ts @@ -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) { diff --git a/theatre/studio/src/store/types/ahistoric.ts b/theatre/studio/src/store/types/ahistoric.ts index bcc5fdc..1d8f725 100644 --- a/theatre/studio/src/store/types/ahistoric.ts +++ b/theatre/studio/src/store/types/ahistoric.ts @@ -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' diff --git a/theatre/studio/src/uiComponents/Popover/usePopover.tsx b/theatre/studio/src/uiComponents/Popover/usePopover.tsx index 8808959..48db18c 100644 --- a/theatre/studio/src/uiComponents/Popover/usePopover.tsx +++ b/theatre/studio/src/uiComponents/Popover/usePopover.tsx @@ -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}