Preset easings (#51)
* WIP - Preset easing - 1 * Implement easing presets (WIP) * Improve easing option colors * Make easing option border radius smaller so it fits the design language better * Fix easing option label color * Remove candidate indicator because it'll conflict with focus state for keyboard navigation * Improve match indicator * Simplify search box implementation (assuming it is for now only going to be a search box, this commit is easy to revert) * Fix options grid on Firefox * Implement arrow navigation * Tiny arrow nav fix * Now make it actually work lol * Improve menu item name * Fix up arrow behavior on search input * Clean up dead code Co-authored-by: Andrew Prifer <andrew.prifer@gmail.com>
This commit is contained in:
parent
607d6afe2b
commit
cb349b83a7
4 changed files with 330 additions and 34 deletions
|
@ -93,6 +93,7 @@
|
|||
"uuid": "^8.3.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.3"
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"fuzzysort": "^1.1.4"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -111,7 +111,7 @@ const Connector: React.FC<IProps> = (props) => {
|
|||
},
|
||||
},
|
||||
{
|
||||
label: 'Edit Curve',
|
||||
label: 'Open Easing Palette',
|
||||
callback: (e) => {
|
||||
openPopover(e, node!)
|
||||
},
|
||||
|
|
|
@ -1,30 +1,121 @@
|
|||
import type {Pointer} from '@theatre/dataverse'
|
||||
import {val} from '@theatre/dataverse'
|
||||
import React, {useLayoutEffect, useMemo, useRef} from 'react'
|
||||
import React, {useLayoutEffect, useMemo, useRef, useState} from 'react'
|
||||
import styled from 'styled-components'
|
||||
import fuzzySort from 'fuzzysort'
|
||||
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 presets = [
|
||||
{label: 'Linear', value: '0.5, 0.5, 0.5, 0.5'},
|
||||
{label: 'Back In Out', value: '0.680, -0.550, 0.265, 1.550'},
|
||||
{label: 'Back In', value: '0.600, -0.280, 0.735, 0.045'},
|
||||
{label: 'Back Out', value: '0.175, 0.885, 0.320, 1.275'},
|
||||
{label: 'Circ In Out', value: '0.785, 0.135, 0.150, 0.860'},
|
||||
{label: 'Circ In', value: '0.600, 0.040, 0.980, 0.335'},
|
||||
{label: 'Circ Out', value: '0.075, 0.820, 0.165, 1'},
|
||||
{label: 'Cubic In Out', value: '0.645, 0.045, 0.355, 1'},
|
||||
{label: 'Cubic In', value: '0.550, 0.055, 0.675, 0.190'},
|
||||
{label: 'Cubic Out', value: '0.215, 0.610, 0.355, 1'},
|
||||
{label: 'Ease Out In', value: '.42, 0, .58, 1'},
|
||||
{label: 'Expo In Out', value: '1, 0, 0, 1'},
|
||||
{label: 'Expo Out', value: '0.190, 1, 0.220, 1'},
|
||||
{label: 'Quad In Out', value: '0.455, 0.030, 0.515, 0.955'},
|
||||
{label: 'Quad In', value: '0.550, 0.085, 0.680, 0.530'},
|
||||
{label: 'Quad Out', value: '0.250, 0.460, 0.450, 0.940'},
|
||||
{label: 'Quart In Out', value: '0.770, 0, 0.175, 1'},
|
||||
{label: 'Quart In', value: '0.895, 0.030, 0.685, 0.220'},
|
||||
{label: 'Quart Out', value: '0.165, 0.840, 0.440, 1'},
|
||||
{label: 'Quint In Out', value: '0.860, 0, 0.070, 1'},
|
||||
{label: 'Quint In', value: '0.755, 0.050, 0.855, 0.060'},
|
||||
{label: 'Quint Out', value: '0.230, 1, 0.320, 1'},
|
||||
{label: 'Sine In Out', value: '0.445, 0.050, 0.550, 0.950'},
|
||||
{label: 'Sine In', value: '0.470, 0, 0.745, 0.715'},
|
||||
{label: 'Sine Out', value: '0.390, 0.575, 0.565, 1'},
|
||||
]
|
||||
|
||||
const Container = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
width: 230px;
|
||||
`
|
||||
|
||||
const InputContainer = styled.div`
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 4px 8px;
|
||||
height: 28px;
|
||||
align-items: center;
|
||||
`
|
||||
|
||||
const Label = styled.div`
|
||||
${propNameText};
|
||||
white-space: nowrap;
|
||||
const OptionsContainer = styled.div`
|
||||
overflow: auto;
|
||||
max-height: 130px;
|
||||
|
||||
// Firefox doesn't let grids overflow their own element when the height is fixed so we need an extra inner div for the grid
|
||||
& > div {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
}
|
||||
`
|
||||
|
||||
const EasingOption = styled.div`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
overflow: hidden;
|
||||
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
border-radius: 4px;
|
||||
|
||||
// The candidate preset is going to be applied when enter is pressed
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px rgb(78, 134, 136);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
b {
|
||||
text-decoration: underline;
|
||||
// Default underline is too close to the text to be subtle
|
||||
text-underline-offset: 2px;
|
||||
text-decoration-color: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
`
|
||||
|
||||
const EasingCurveContainer = styled.div`
|
||||
display: flex;
|
||||
padding: 6px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
`
|
||||
|
||||
const SearchBox = styled.input.attrs({type: 'text'})`
|
||||
background-color: #10101042;
|
||||
border: none;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.16);
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
padding: 10px;
|
||||
font: inherit;
|
||||
outline: none;
|
||||
cursor: text;
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
height: calc(100% - 4px);
|
||||
box-sizing: border-box;
|
||||
|
||||
&:focus {
|
||||
cursor: text;
|
||||
}
|
||||
`
|
||||
|
||||
const CurveEditorPopover: React.FC<
|
||||
|
@ -37,6 +128,27 @@ const CurveEditorPopover: React.FC<
|
|||
onRequestClose: () => void
|
||||
} & Parameters<typeof KeyframeEditor>[0]
|
||||
> = (props) => {
|
||||
const [filter, setFilter] = useState<string>('')
|
||||
|
||||
const presetSearchResults = useMemo(
|
||||
() =>
|
||||
fuzzySort.go(filter, presets, {
|
||||
key: 'label',
|
||||
allowTypo: false,
|
||||
}),
|
||||
[filter],
|
||||
)
|
||||
|
||||
// Whether to interpret the search box input as a search query
|
||||
const useQuery = /^[A-Za-z]/.test(filter)
|
||||
const optionsEmpty = useQuery && presetSearchResults.length === 0
|
||||
|
||||
const displayedPresets = useMemo(
|
||||
() =>
|
||||
useQuery ? presetSearchResults.map((result) => result.obj) : presets,
|
||||
[presetSearchResults, useQuery],
|
||||
)
|
||||
|
||||
const fns = useMemo(() => {
|
||||
let tempTransaction: CommitOrDiscard | undefined
|
||||
|
||||
|
@ -46,7 +158,12 @@ const CurveEditorPopover: React.FC<
|
|||
tempTransaction.discard()
|
||||
tempTransaction = undefined
|
||||
}
|
||||
|
||||
const args = cssCubicBezierArgsToHandles(newCurve)!
|
||||
if (!args) {
|
||||
return
|
||||
}
|
||||
|
||||
tempTransaction = getStudio()!.tempTransaction(({stateEditors}) => {
|
||||
const {replaceKeyframes} =
|
||||
stateEditors.coreByProject.historic.sheetsById.sequence
|
||||
|
@ -80,7 +197,14 @@ const CurveEditorPopover: React.FC<
|
|||
tempTransaction.discard()
|
||||
tempTransaction = undefined
|
||||
}
|
||||
const args = cssCubicBezierArgsToHandles(newCurve)!
|
||||
const args =
|
||||
cssCubicBezierArgsToHandles(newCurve) ??
|
||||
cssCubicBezierArgsToHandles(presetSearchResults[0].obj.value)
|
||||
|
||||
if (!args) {
|
||||
return
|
||||
}
|
||||
|
||||
getStudio()!.transaction(({stateEditors}) => {
|
||||
const {replaceKeyframes} =
|
||||
stateEditors.coreByProject.historic.sheetsById.sequence
|
||||
|
@ -102,47 +226,210 @@ const CurveEditorPopover: React.FC<
|
|||
],
|
||||
})
|
||||
})
|
||||
|
||||
props.onRequestClose()
|
||||
},
|
||||
}
|
||||
}, [props.layoutP, props.index])
|
||||
}, [props.layoutP, props.index, presetSearchResults])
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
useLayoutEffect(() => {
|
||||
inputRef.current!.focus()
|
||||
inputRef.current!.setSelectionRange(0, 100)
|
||||
}, [])
|
||||
|
||||
const {index, trackData} = props
|
||||
const cur = trackData.keyframes[index]
|
||||
const next = trackData.keyframes[index + 1]
|
||||
|
||||
const cssCubicBezierString = keyframesToCssCubicBezierArgs(cur, next)
|
||||
// Need some padding *inside* the SVG so that the handles and overshoots are not clipped
|
||||
const svgPadding = 0.12
|
||||
const svgCircleRadius = 0.08
|
||||
const svgColor = '#b98b08'
|
||||
|
||||
// A map to store all html elements corresponding to easing options
|
||||
const optionsRef = useRef(
|
||||
presets.reduce((acc, curr) => {
|
||||
acc[curr.label] = {current: null}
|
||||
|
||||
return acc
|
||||
}, {} as {[key: string]: {current: HTMLDivElement | null}}),
|
||||
)
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<Label>cubic-bezier{'('}</Label>
|
||||
<BasicStringInput
|
||||
value={cssCubicBezierString}
|
||||
{...fns}
|
||||
isValid={isValid}
|
||||
inputRef={inputRef}
|
||||
onBlur={props.onRequestClose}
|
||||
/>
|
||||
<Label>{')'}</Label>
|
||||
<InputContainer>
|
||||
<SearchBox
|
||||
value={filter}
|
||||
placeholder="Search presets..."
|
||||
onChange={(e) => {
|
||||
setFilter(e.target.value)
|
||||
}}
|
||||
ref={inputRef}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'ArrowDown') {
|
||||
// Prevent scrolling on arrow key press
|
||||
e.preventDefault()
|
||||
optionsRef.current[displayedPresets[0].label].current?.focus()
|
||||
}
|
||||
if (e.key === 'ArrowUp') {
|
||||
// Prevent scrolling on arrow key press
|
||||
e.preventDefault()
|
||||
optionsRef.current[
|
||||
displayedPresets[displayedPresets.length - 1].label
|
||||
].current?.focus()
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
props.onRequestClose()
|
||||
}
|
||||
if (e.key === 'Enter') {
|
||||
fns.permenantlySetValue(filter)
|
||||
props.onRequestClose()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</InputContainer>
|
||||
{!optionsEmpty && (
|
||||
<OptionsContainer onKeyDown={(e) => e.preventDefault()}>
|
||||
{/*Firefox doesn't let grids overflow their own element when the height is fixed so we need an extra inner div for the grid*/}
|
||||
<div>
|
||||
{displayedPresets.map((preset, index) => {
|
||||
const easing = preset.value.split(', ').map((e) => Number(e))
|
||||
|
||||
return (
|
||||
<EasingOption
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape') {
|
||||
props.onRequestClose()
|
||||
} else if (e.key === 'Enter') {
|
||||
fns.permenantlySetValue(preset.value)
|
||||
props.onRequestClose()
|
||||
}
|
||||
if (e.key === 'ArrowRight') {
|
||||
optionsRef.current[
|
||||
displayedPresets[(index + 1) % displayedPresets.length]
|
||||
.label
|
||||
].current!.focus()
|
||||
}
|
||||
if (e.key === 'ArrowLeft') {
|
||||
if (preset === displayedPresets[0]) {
|
||||
optionsRef.current[
|
||||
displayedPresets[displayedPresets.length - 1].label
|
||||
].current?.focus()
|
||||
} else {
|
||||
optionsRef.current[
|
||||
displayedPresets[
|
||||
(index - 1) % displayedPresets.length
|
||||
].label
|
||||
].current?.focus()
|
||||
}
|
||||
}
|
||||
if (e.key === 'ArrowUp') {
|
||||
if (preset === displayedPresets[0]) {
|
||||
inputRef.current!.focus()
|
||||
} else if (preset === displayedPresets[1]) {
|
||||
optionsRef.current[
|
||||
displayedPresets[0].label
|
||||
].current?.focus()
|
||||
} else {
|
||||
optionsRef.current[
|
||||
displayedPresets[index - 2].label
|
||||
].current?.focus()
|
||||
}
|
||||
}
|
||||
if (e.key === 'ArrowDown') {
|
||||
if (
|
||||
preset === displayedPresets[displayedPresets.length - 1]
|
||||
) {
|
||||
inputRef.current!.focus()
|
||||
} else if (
|
||||
preset === displayedPresets[displayedPresets.length - 2]
|
||||
) {
|
||||
optionsRef.current[
|
||||
displayedPresets[displayedPresets.length - 1].label
|
||||
].current?.focus()
|
||||
} else {
|
||||
optionsRef.current[
|
||||
displayedPresets[index + 2].label
|
||||
].current?.focus()
|
||||
}
|
||||
}
|
||||
}}
|
||||
ref={optionsRef.current[preset.label]}
|
||||
key={preset.label}
|
||||
onClick={() => {
|
||||
fns.permenantlySetValue(preset.value)
|
||||
props.onRequestClose()
|
||||
}}
|
||||
// Temporarily apply on hover
|
||||
onMouseOver={() => {
|
||||
// When previewing with hover, we don't want to set the filter too
|
||||
fns.temporarilySetValue(preset.value)
|
||||
}}
|
||||
onMouseOut={() => {
|
||||
fns.discardTemporaryValue()
|
||||
}}
|
||||
>
|
||||
<EasingCurveContainer>
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox={`0 0 ${1 + svgPadding * 2} ${
|
||||
1 + svgPadding * 2
|
||||
}`}
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d={`M${svgPadding} ${1 + svgPadding} C${
|
||||
easing[0] + svgPadding
|
||||
} ${1 - easing[1] + svgPadding} ${
|
||||
easing[2] + svgPadding
|
||||
} ${1 - easing[3] + svgPadding} ${
|
||||
1 + svgPadding
|
||||
} ${svgPadding}`}
|
||||
stroke={svgColor}
|
||||
strokeWidth="0.08"
|
||||
/>
|
||||
<circle
|
||||
cx={svgPadding}
|
||||
cy={1 + svgPadding}
|
||||
r={svgCircleRadius}
|
||||
fill={svgColor}
|
||||
/>
|
||||
<circle
|
||||
cx={1 + svgPadding}
|
||||
cy={svgPadding}
|
||||
r={svgCircleRadius}
|
||||
fill={svgColor}
|
||||
/>
|
||||
</svg>
|
||||
</EasingCurveContainer>
|
||||
<span>
|
||||
{useQuery ? (
|
||||
<span
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: fuzzySort.highlight(
|
||||
presetSearchResults[index] as any,
|
||||
)!,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
preset.label
|
||||
)}
|
||||
</span>
|
||||
</EasingOption>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</OptionsContainer>
|
||||
)}
|
||||
</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,
|
||||
):
|
||||
|
|
|
@ -12227,6 +12227,13 @@ fsevents@^1.2.7:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"fuzzysort@npm:^1.1.4":
|
||||
version: 1.1.4
|
||||
resolution: "fuzzysort@npm:1.1.4"
|
||||
checksum: 97c48b087814c517e6b6efcf0d26a11fe88df2cd31a2cfc0f27db2a2a9ff54f0312282ea884e60abf88a0e8d7677b97301a7d5282fe321cc6cc32579a3950e42
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"gauge@npm:~2.7.3":
|
||||
version: 2.7.4
|
||||
resolution: "gauge@npm:2.7.4"
|
||||
|
@ -22928,6 +22935,7 @@ fsevents@^1.2.7:
|
|||
fast-deep-equal: ^3.1.3
|
||||
file-loader: ^6.2.0
|
||||
fs-extra: ^10.0.0
|
||||
fuzzysort: ^1.1.4
|
||||
html-loader: ^2.1.2
|
||||
identity-obj-proxy: ^3.0.0
|
||||
immer: ^9.0.6
|
||||
|
|
Loading…
Reference in a new issue