WIP: Editing curves using the css cubic bezier function
This commit is contained in:
parent
4a65c6e91c
commit
786f645d0c
3 changed files with 205 additions and 48 deletions
|
@ -15,6 +15,9 @@ import type {
|
||||||
import {dotSize} from './Dot'
|
import {dotSize} from './Dot'
|
||||||
import type KeyframeEditor from './KeyframeEditor'
|
import type KeyframeEditor from './KeyframeEditor'
|
||||||
import type Sequence from '@theatre/core/sequences/Sequence'
|
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 connectorHeight = dotSize / 2 + 1
|
||||||
const connectorWidthUnscaled = 1000
|
const connectorWidthUnscaled = 1000
|
||||||
|
@ -47,6 +50,16 @@ const Container = styled.div<{isSelected: boolean}>`
|
||||||
z-index: 0;
|
z-index: 0;
|
||||||
cursor: ew-resize;
|
cursor: ew-resize;
|
||||||
|
|
||||||
|
&:after {
|
||||||
|
display: block;
|
||||||
|
position: absolute;
|
||||||
|
content: ' ';
|
||||||
|
top: -4px;
|
||||||
|
bottom: -4px;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: ${(props) =>
|
background: ${(props) =>
|
||||||
props.isSelected
|
props.isSelected
|
||||||
|
@ -65,6 +78,17 @@ const Connector: React.FC<IProps> = (props) => {
|
||||||
|
|
||||||
const [nodeRef, node] = useRefAndState<HTMLDivElement | null>(null)
|
const [nodeRef, node] = useRefAndState<HTMLDivElement | null>(null)
|
||||||
|
|
||||||
|
const [popoverNode, openPopover, closePopover, isPopoverOpen] = usePopover(
|
||||||
|
{},
|
||||||
|
() => {
|
||||||
|
return (
|
||||||
|
<BasicPopover>
|
||||||
|
<CurveEditorPopover {...props} onRequestClose={closePopover} />
|
||||||
|
</BasicPopover>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
const [contextMenu] = useContextMenu(node, {
|
const [contextMenu] = useContextMenu(node, {
|
||||||
items: () => {
|
items: () => {
|
||||||
return [
|
return [
|
||||||
|
@ -86,6 +110,12 @@ const Connector: React.FC<IProps> = (props) => {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: 'Edit Curve',
|
||||||
|
callback: (e) => {
|
||||||
|
openPopover(e, node!)
|
||||||
|
},
|
||||||
|
},
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
@ -96,60 +126,13 @@ const Connector: React.FC<IProps> = (props) => {
|
||||||
<Container
|
<Container
|
||||||
isSelected={!!props.selection}
|
isSelected={!!props.selection}
|
||||||
ref={nodeRef}
|
ref={nodeRef}
|
||||||
onClick={(event) => {
|
|
||||||
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={{
|
style={{
|
||||||
transform: `scale3d(calc(var(--unitSpaceToScaledSpaceMultiplier) * ${
|
transform: `scale3d(calc(var(--unitSpaceToScaledSpaceMultiplier) * ${
|
||||||
connectorLengthInUnitSpace / connectorWidthUnscaled
|
connectorLengthInUnitSpace / connectorWidthUnscaled
|
||||||
}), 1, 1)`,
|
}), 1, 1)`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{popoverNode}
|
||||||
{contextMenu}
|
{contextMenu}
|
||||||
</Container>
|
</Container>
|
||||||
)
|
)
|
||||||
|
|
|
@ -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<SequenceEditorPanelLayout>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called when user hits enter/escape
|
||||||
|
*/
|
||||||
|
onRequestClose: () => void
|
||||||
|
} & Parameters<typeof KeyframeEditor>[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<HTMLInputElement>(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 (
|
||||||
|
<Container>
|
||||||
|
<Label>cubic-bezier{'('}</Label>
|
||||||
|
<BasicStringInput
|
||||||
|
value={cssCubicBezierString}
|
||||||
|
{...fns}
|
||||||
|
isValid={isValid}
|
||||||
|
inputRef={inputRef}
|
||||||
|
onBlur={props.onRequestClose}
|
||||||
|
/>
|
||||||
|
<Label>{')'}</Label>
|
||||||
|
</Container>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
|
@ -29,6 +29,10 @@ const Input = styled.input.attrs({type: 'text'})`
|
||||||
background-color: #10101042;
|
background-color: #10101042;
|
||||||
border-color: #00000059;
|
border-color: #00000059;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.invalid {
|
||||||
|
border-color: red;
|
||||||
|
}
|
||||||
`
|
`
|
||||||
|
|
||||||
type IState_NoFocus = {
|
type IState_NoFocus = {
|
||||||
|
@ -165,6 +169,7 @@ const BasicStringInput: React.FC<{
|
||||||
<Input
|
<Input
|
||||||
key="input"
|
key="input"
|
||||||
type="text"
|
type="text"
|
||||||
|
className={!isValid(value) ? 'invalid' : ''}
|
||||||
onChange={callbacks.inputChange}
|
onChange={callbacks.inputChange}
|
||||||
value={value}
|
value={value}
|
||||||
onBlur={callbacks.onBlur}
|
onBlur={callbacks.onBlur}
|
||||||
|
|
Loading…
Reference in a new issue