diff --git a/theatre/package.json b/theatre/package.json index d066e91..1748006 100644 --- a/theatre/package.json +++ b/theatre/package.json @@ -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" } } 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 2cd8afd..53eca32 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 @@ -111,7 +111,7 @@ const Connector: React.FC = (props) => { }, }, { - label: 'Edit Curve', + label: 'Open Easing Palette', callback: (e) => { openPopover(e, node!) }, 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 index 870ac7f..820ac54 100644 --- 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 @@ -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[0] > = (props) => { + const [filter, setFilter] = useState('') + + 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(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 ( - - - + + { + 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() + } + }} + /> + + {!optionsEmpty && ( + 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*/} +
+ {displayedPresets.map((preset, index) => { + const easing = preset.value.split(', ').map((e) => Number(e)) + + return ( + { + 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() + }} + > + + + + + + + + + {useQuery ? ( + + ) : ( + preset.label + )} + + + ) + })} +
+
+ )}
) } 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, ): diff --git a/yarn.lock b/yarn.lock index a97a633..14ae987 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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