diff --git a/theatre/shared/src/utils/addresses.ts b/theatre/shared/src/utils/addresses.ts index 5348304..17827c2 100644 --- a/theatre/shared/src/utils/addresses.ts +++ b/theatre/shared/src/utils/addresses.ts @@ -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) + } +} diff --git a/theatre/studio/src/panels/SequenceEditorPanel/AGGREGATE_COPY_PASTE.md b/theatre/studio/src/panels/SequenceEditorPanel/AGGREGATE_COPY_PASTE.md new file mode 100644 index 0000000..abf018a --- /dev/null +++ b/theatre/studio/src/panels/SequenceEditorPanel/AGGREGATE_COPY_PASTE.md @@ -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 +``` \ No newline at end of file diff --git a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/AggregatedKeyframeTrack/AggregateKeyframeEditor/AggregateKeyframeConnector.tsx b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/AggregatedKeyframeTrack/AggregateKeyframeEditor/AggregateKeyframeConnector.tsx index 2c83477..99cebe7 100644 --- a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/AggregatedKeyframeTrack/AggregateKeyframeEditor/AggregateKeyframeConnector.tsx +++ b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/AggregatedKeyframeTrack/AggregateKeyframeEditor/AggregateKeyframeConnector.tsx @@ -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(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 {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, + }, + ) + } + }) + } + }, + }, + ] + }, + }) +} diff --git a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/AggregatedKeyframeTrack/AggregateKeyframeEditor/AggregateKeyframeDot.tsx b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/AggregatedKeyframeTrack/AggregateKeyframeEditor/AggregateKeyframeDot.tsx index 7f9a9a3..43703df 100644 --- a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/AggregatedKeyframeTrack/AggregateKeyframeEditor/AggregateKeyframeDot.tsx +++ b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/AggregatedKeyframeTrack/AggregateKeyframeEditor/AggregateKeyframeDot.tsx @@ -15,6 +15,12 @@ import DopeSnap from '@theatre/studio/panels/SequenceEditorPanel/RightOverlay/Do import type {IAggregateKeyframeEditorProps} from './AggregateKeyframeEditor' import type {IAggregateKeyframeEditorUtils} from './useAggregateKeyframeEditorUtils' import {AggregateKeyframeVisualDot, HitZone} from './AggregateKeyframeVisualDot' +import { + copyableKeyframesFromSelection, + keyframesWithPaths, +} from '@theatre/studio/panels/SequenceEditorPanel/DopeSheet/selections' +import type {KeyframeWithPathToPropFromCommonRoot} from '@theatre/studio/store/types/ahistoric' +import {commonRootOfPathsToProps} from '@theatre/shared/utils/addresses' type IAggregateKeyframeDotProps = { editorProps: IAggregateKeyframeEditorProps @@ -35,9 +41,7 @@ export function AggregateKeyframeDot( }, }) - const [contextMenu] = useAggregateKeyframeContextMenu(node, () => - logger._debug('Show Aggregate Keyframe', props), - ) + const [contextMenu] = useAggregateKeyframeContextMenu(props, node) return ( <> @@ -58,17 +62,81 @@ export function AggregateKeyframeDot( } function useAggregateKeyframeContextMenu( + props: IAggregateKeyframeDotProps, target: HTMLDivElement | null, - debugOnOpen: () => void, ) { - // TODO: missing features: delete, copy + paste return useContextMenu(target, { displayName: 'Aggregate Keyframe', menuItems: () => { - return [] - }, - onOpen() { - debugOnOpen() + // see AGGREGATE_COPY_PASTE.md for explanation of this + // code that makes some keyframes with paths for copying + // to clipboard + const kfs = props.utils.cur.keyframes.reduce( + (acc, kfWithTrack) => + acc.concat( + keyframesWithPaths({ + ...props.editorProps.viewModel.sheetObject.address, + trackId: kfWithTrack.track.id, + keyframeIds: [kfWithTrack.kf.id], + }) ?? [], + ), + [] as KeyframeWithPathToPropFromCommonRoot[], + ) + + const commonPath = commonRootOfPathsToProps( + kfs.map((kf) => kf.pathToProp), + ) + + const keyframesWithCommonRootPath = kfs.map(({keyframe, pathToProp}) => ({ + keyframe, + pathToProp: pathToProp.slice(commonPath.length), + })) + + return [ + { + label: props.editorProps.selection ? 'Copy (selection)' : 'Copy', + callback: () => { + if (props.editorProps.selection) { + const copyableKeyframes = copyableKeyframesFromSelection( + props.editorProps.viewModel.sheetObject.address.projectId, + props.editorProps.viewModel.sheetObject.address.sheetId, + props.editorProps.selection, + ) + getStudio().transaction((api) => { + api.stateEditors.studio.ahistoric.setClipboardKeyframes( + copyableKeyframes, + ) + }) + } else { + getStudio().transaction((api) => { + api.stateEditors.studio.ahistoric.setClipboardKeyframes( + keyframesWithCommonRootPath, + ) + }) + } + }, + }, + { + label: props.editorProps.selection ? 'Delete (selection)' : 'Delete', + callback: () => { + if (props.editorProps.selection) { + props.editorProps.selection.delete() + } else { + getStudio().transaction(({stateEditors}) => { + for (const kfWithTrack of props.utils.cur.keyframes) { + stateEditors.coreByProject.historic.sheetsById.sequence.deleteKeyframes( + { + ...props.editorProps.viewModel.sheetObject.address, + keyframeIds: [kfWithTrack.kf.id], + trackId: kfWithTrack.track.id, + }, + ) + } + }) + } + }, + }, + ] }, }) } @@ -104,13 +172,15 @@ function useDragForAggregateKeyframeDot( ) { const {selection, viewModel} = props const {sheetObject} = viewModel - return selection + const hanlders = selection .getDragHandlers({ ...sheetObject.address, domNode: node!, positionAtStartOfDrag: keyframes[0].kf.position, }) .onDragStart(event) + + return hanlders && {...hanlders, onClick: options.onClickFromDrag} } const propsAtStartOfDrag = props @@ -156,9 +226,11 @@ function useDragForAggregateKeyframeDot( tempTransaction?.commit() } else { tempTransaction?.discard() - options.onClickFromDrag(event) } }, + onClick(ev) { + options.onClickFromDrag(ev) + }, } }, } diff --git a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/AggregatedKeyframeTrack/AggregatedKeyframeTrack.tsx b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/AggregatedKeyframeTrack/AggregatedKeyframeTrack.tsx index 890101f..2026e6c 100644 --- a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/AggregatedKeyframeTrack/AggregatedKeyframeTrack.tsx +++ b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/AggregatedKeyframeTrack/AggregatedKeyframeTrack.tsx @@ -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 +} 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 1e1b1c9..fbf4411 100644 --- a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/BasicKeyframedTrack.tsx +++ b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/BasicKeyframedTrack.tsx @@ -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, diff --git a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/BasicKeyframeConnector.tsx b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/BasicKeyframeConnector.tsx index f367a48..0cf8e78 100644 --- a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/BasicKeyframeConnector.tsx +++ b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/BasicKeyframeConnector.tsx @@ -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 = ( () => , ) - const [contextMenu] = useConnectorContextMenu( - props, - node, - cur, - next, - openPopover, - ) + const [contextMenu] = useConnectorContextMenu(props, node, cur, next) useDragKeyframe(node, props) const connectorLengthInUnitSpace = next.position - cur.position @@ -84,17 +77,21 @@ const BasicKeyframeConnector: React.VFC = ( } return ( - { - if (node) openPopover(e, node) - }} - > - {popoverNode} + <> + { + if (node) openPopover(e, node) + }} + > + {popoverNode} + + {/* 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: () => { + const copyableKeyframes = copyableKeyframesFromSelection( + props.leaf.sheetObject.address.projectId, + props.leaf.sheetObject.address.sheetId, + props.selection, + ) + return [ { - label: maybeKeyframeIds ? 'Copy Selection' : 'Copy both Keyframes', + label: copyableKeyframes.length > 0 ? 'Copy (selection)' : 'Copy', callback: () => { - if (maybeKeyframeIds) { - const keyframes = maybeKeyframeIds.map( - (keyframeId) => - props.trackData.keyframes.find( - (keyframe) => keyframe.id === keyframeId, - )!, - ) - - getStudio!().transaction((api) => { + if (copyableKeyframes.length > 0) { + getStudio().transaction((api) => { api.stateEditors.studio.ahistoric.setClipboardKeyframes( - keyframes, + copyableKeyframes, ) }) } else { - getStudio!().transaction((api) => { + getStudio().transaction((api) => { api.stateEditors.studio.ahistoric.setClipboardKeyframes([ - cur, - next, + {keyframe: cur, pathToProp: props.leaf.pathToProp}, + {keyframe: next, pathToProp: props.leaf.pathToProp}, ]) }) } }, }, { - label: props.selection ? 'Delete Selection' : 'Delete both Keyframes', + label: props.selection ? 'Delete (selection)' : 'Delete', callback: () => { - if (props.selection) { - props.selection.delete() - } else { - getStudio()!.transaction(({stateEditors}) => { + getStudio().transaction(({stateEditors}) => { + if (props.selection) { + props.selection.delete() + } else { stateEditors.coreByProject.historic.sheetsById.sequence.deleteKeyframes( { ...props.leaf.sheetObject.address, @@ -271,14 +269,8 @@ function useConnectorContextMenu( trackId: props.leaf.trackId, }, ) - }) - } - }, - }, - { - label: 'Open Easing Palette', - callback: (e) => { - openPopover(e, node!) + } + }) }, }, ] diff --git a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/SingleKeyframeDot.tsx b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/SingleKeyframeDot.tsx index ccd15f7..ecf4a13 100644 --- a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/SingleKeyframeDot.tsx +++ b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/SingleKeyframeDot.tsx @@ -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) - }) - }, - } -} diff --git a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/selectedKeyframeIdsIfInSingleTrack.ts b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/selectedKeyframeIdsIfInSingleTrack.ts deleted file mode 100644 index 5dbd7cd..0000000 --- a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/selectedKeyframeIdsIfInSingleTrack.ts +++ /dev/null @@ -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) -} diff --git a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/selections.ts b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/selections.ts index 9da5660..ce1a4da 100644 --- a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/selections.ts +++ b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/selections.ts @@ -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( + obj: StrictRecord, +): StrictRecord { + const result: StrictRecord = {} + for (const [key, value] of Object.entries(obj)) { + result[value as V] = key + } + return result +} + export function keyframeConnections( keyframes: Array, ): Array<{left: Keyframe; right: Keyframe}> { diff --git a/theatre/studio/src/panels/SequenceEditorPanel/layout/layout.ts b/theatre/studio/src/panels/SequenceEditorPanel/layout/layout.ts index 8d3cd5d..b7b0586 100644 --- a/theatre/studio/src/panels/SequenceEditorPanel/layout/layout.ts +++ b/theatre/studio/src/panels/SequenceEditorPanel/layout/layout.ts @@ -61,7 +61,7 @@ export type DopeSheetSelection = { positionAtStartOfDrag: number domNode: Element }, - ): Parameters[1] + ): Omit[1], 'onClick'> delete(): void } diff --git a/theatre/studio/src/store/stateEditors.ts b/theatre/studio/src/store/stateEditors.ts index bde2f4d..fbdd789 100644 --- a/theatre/studio/src/store/stateEditors.ts +++ b/theatre/studio/src/store/stateEditors.ts @@ -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, } } } diff --git a/theatre/studio/src/store/types/ahistoric.ts b/theatre/studio/src/store/types/ahistoric.ts index 4f8b6f7..7aabb91 100644 --- a/theatre/studio/src/store/types/ahistoric.ts +++ b/theatre/studio/src/store/types/ahistoric.ts @@ -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: { diff --git a/theatre/studio/src/uiComponents/form/BasicNumberInput.tsx b/theatre/studio/src/uiComponents/form/BasicNumberInput.tsx index 1ae4e10..6e1bb62 100644 --- a/theatre/studio/src/uiComponents/form/BasicNumberInput.tsx +++ b/theatre/studio/src/uiComponents/form/BasicNumberInput.tsx @@ -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) + }, } } diff --git a/theatre/studio/src/uiComponents/simpleContextMenu/ContextMenu/ContextMenu.tsx b/theatre/studio/src/uiComponents/simpleContextMenu/ContextMenu/ContextMenu.tsx index f72ac5d..3918fa3 100644 --- a/theatre/studio/src/uiComponents/simpleContextMenu/ContextMenu/ContextMenu.tsx +++ b/theatre/studio/src/uiComponents/simpleContextMenu/ContextMenu/ContextMenu.tsx @@ -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 = {