From 786f645d0cf8032dc3d2f3eacaba85bde54fc6c5 Mon Sep 17 00:00:00 2001 From: Aria Minaei Date: Sat, 13 Nov 2021 20:06:17 +0100 Subject: [PATCH] WIP: Editing curves using the css cubic bezier function --- .../KeyframeEditor/Connector.tsx | 79 ++++---- .../CurveEditorPopover/CurveEditorPopover.tsx | 169 ++++++++++++++++++ .../uiComponents/form/BasicStringInput.tsx | 5 + 3 files changed, 205 insertions(+), 48 deletions(-) create mode 100644 theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/CurveEditorPopover/CurveEditorPopover.tsx 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 d561d0e..2cd8afd 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 @@ -15,6 +15,9 @@ import type { import {dotSize} from './Dot' import type KeyframeEditor from './KeyframeEditor' 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' const connectorHeight = dotSize / 2 + 1 const connectorWidthUnscaled = 1000 @@ -47,6 +50,16 @@ const Container = styled.div<{isSelected: boolean}>` z-index: 0; cursor: ew-resize; + &:after { + display: block; + position: absolute; + content: ' '; + top: -4px; + bottom: -4px; + left: 0; + right: 0; + } + &:hover { background: ${(props) => props.isSelected @@ -65,6 +78,17 @@ const Connector: React.FC = (props) => { const [nodeRef, node] = useRefAndState(null) + const [popoverNode, openPopover, closePopover, isPopoverOpen] = usePopover( + {}, + () => { + return ( + + + + ) + }, + ) + const [contextMenu] = useContextMenu(node, { items: () => { return [ @@ -86,6 +110,12 @@ const Connector: React.FC = (props) => { } }, }, + { + label: 'Edit Curve', + callback: (e) => { + openPopover(e, node!) + }, + }, ] }, }) @@ -96,60 +126,13 @@ const Connector: React.FC = (props) => { { - if (event.button !== 0) return - - // @todo Put this in the context menu - - const orig = JSON.stringify([ - cur.handles[2], - cur.handles[3], - next.handles[0], - next.handles[1], - ]) - const modifiedS = orig // window.prompt('As cubic-bezier()', orig) - if (modifiedS && modifiedS !== orig) { - return - // const modified = JSON.parse(modifiedS) - // getStudio()!.transaction(({stateEditors}) => { - // const {replaceKeyframes} = - // stateEditors.coreByProject.historic.sheetsById.sequence - - // replaceKeyframes({ - // ...props.leaf.sheetObject.address, - // snappingFunction: val(props.layoutP.sheet).getSequence() - // .closestGridPosition, - // trackId: props.leaf.trackId, - // keyframes: [ - // { - // ...cur, - // handles: [ - // cur.handles[0], - // cur.handles[1], - // modified[0], - // modified[1], - // ], - // }, - // { - // ...next, - // handles: [ - // modified[2], - // modified[3], - // next.handles[2], - // next.handles[3], - // ], - // }, - // ], - // }) - // }) - } - }} style={{ transform: `scale3d(calc(var(--unitSpaceToScaledSpaceMultiplier) * ${ connectorLengthInUnitSpace / connectorWidthUnscaled }), 1, 1)`, }} > + {popoverNode} {contextMenu} ) diff --git a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/CurveEditorPopover/CurveEditorPopover.tsx b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/CurveEditorPopover/CurveEditorPopover.tsx new file mode 100644 index 0000000..a32b84e --- /dev/null +++ b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/CurveEditorPopover/CurveEditorPopover.tsx @@ -0,0 +1,169 @@ +import type {Pointer} from '@theatre/dataverse' +import {val} from '@theatre/dataverse' +import React, {useLayoutEffect, useMemo, useRef} from 'react' +import styled from 'styled-components' +import type {SequenceEditorPanelLayout} from '@theatre/studio/panels/SequenceEditorPanel/layout/layout' +import getStudio from '@theatre/studio/getStudio' +import type {CommitOrDiscard} from '@theatre/studio/StudioStore/StudioStore' +import {propNameText} from '@theatre/studio/panels/DetailPanel/propEditors/utils/SingleRowPropEditor' +import BasicStringInput from '@theatre/studio/uiComponents/form/BasicStringInput' +import type KeyframeEditor from '@theatre/studio/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/KeyframeEditor' +import {round} from 'lodash-es' +import type {Keyframe} from '@theatre/core/projects/store/types/SheetState_Historic' +import type {$IntentionalAny} from '@theatre/shared/utils/types' + +const greaterThanZero = (v: number) => isFinite(v) && v > 0 + +const Container = styled.div` + display: flex; + gap: 8px; + padding: 4px 8px; + height: 28px; + align-items: center; +` + +const Label = styled.div` + ${propNameText}; + white-space: nowrap; +` + +const CurveEditorPopover: React.FC< + { + layoutP: Pointer + + /** + * Called when user hits enter/escape + */ + onRequestClose: () => void + } & Parameters[0] +> = (props) => { + const fns = useMemo(() => { + let tempTransaction: CommitOrDiscard | undefined + + return { + temporarilySetValue(newCurve: string): void { + if (tempTransaction) { + tempTransaction.discard() + tempTransaction = undefined + } + const args = cssCubicBezierArgsToHandles(newCurve)! + tempTransaction = getStudio()!.tempTransaction(({stateEditors}) => { + const {replaceKeyframes} = + stateEditors.coreByProject.historic.sheetsById.sequence + + replaceKeyframes({ + ...props.leaf.sheetObject.address, + snappingFunction: val(props.layoutP.sheet).getSequence() + .closestGridPosition, + trackId: props.leaf.trackId, + keyframes: [ + { + ...cur, + handles: [cur.handles[0], cur.handles[1], args[0], args[1]], + }, + { + ...next, + handles: [args[2], args[3], next.handles[2], next.handles[3]], + }, + ], + }) + }) + }, + discardTemporaryValue(): void { + if (tempTransaction) { + tempTransaction.discard() + tempTransaction = undefined + } + }, + permenantlySetValue(newCurve: string): void { + if (tempTransaction) { + tempTransaction.discard() + tempTransaction = undefined + } + const args = cssCubicBezierArgsToHandles(newCurve)! + getStudio()!.transaction(({stateEditors}) => { + const {replaceKeyframes} = + stateEditors.coreByProject.historic.sheetsById.sequence + + replaceKeyframes({ + ...props.leaf.sheetObject.address, + snappingFunction: val(props.layoutP.sheet).getSequence() + .closestGridPosition, + trackId: props.leaf.trackId, + keyframes: [ + { + ...cur, + handles: [cur.handles[0], cur.handles[1], args[0], args[1]], + }, + { + ...next, + handles: [args[2], args[3], next.handles[2], next.handles[3]], + }, + ], + }) + }) + }, + } + }, [props.layoutP, props.index]) + + const inputRef = useRef(null) + useLayoutEffect(() => { + inputRef.current!.focus() + }, []) + + const {index, trackData} = props + const cur = trackData.keyframes[index] + const next = trackData.keyframes[index + 1] + + const cssCubicBezierString = keyframesToCssCubicBezierArgs(cur, next) + + return ( + + + + + + ) +} + +export default CurveEditorPopover + +function keyframesToCssCubicBezierArgs(left: Keyframe, right: Keyframe) { + return [left.handles[2], left.handles[3], right.handles[0], right.handles[1]] + .map((n) => round(n, 3).toString()) + .join(', ') +} + +const isValid = (str: string): boolean => !!cssCubicBezierArgsToHandles(str) + +function cssCubicBezierArgsToHandles( + str: string, +): + | undefined + | [ + leftHandle2: number, + leftHandle3: number, + rightHandle0: number, + rightHandle1: number, + ] { + if (str.length > 128) { + // string too long + return undefined + } + const args = str.split(',') + if (args.length !== 4) return undefined + const nums = args.map((arg) => { + return Number(arg.trim()) + }) + + if (!nums.every((v) => isFinite(v))) return undefined + + if (nums[0] < 0 || nums[0] > 1 || nums[2] < 0 || nums[2] > 1) return undefined + return nums as $IntentionalAny +} diff --git a/theatre/studio/src/uiComponents/form/BasicStringInput.tsx b/theatre/studio/src/uiComponents/form/BasicStringInput.tsx index e00c7cf..00792d9 100644 --- a/theatre/studio/src/uiComponents/form/BasicStringInput.tsx +++ b/theatre/studio/src/uiComponents/form/BasicStringInput.tsx @@ -29,6 +29,10 @@ const Input = styled.input.attrs({type: 'text'})` background-color: #10101042; border-color: #00000059; } + + &.invalid { + border-color: red; + } ` type IState_NoFocus = { @@ -165,6 +169,7 @@ const BasicStringInput: React.FC<{