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"
|
"uuid": "^8.3.2"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"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) => {
|
callback: (e) => {
|
||||||
openPopover(e, node!)
|
openPopover(e, node!)
|
||||||
},
|
},
|
||||||
|
|
|
@ -1,30 +1,121 @@
|
||||||
import type {Pointer} from '@theatre/dataverse'
|
import type {Pointer} from '@theatre/dataverse'
|
||||||
import {val} 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 styled from 'styled-components'
|
||||||
|
import fuzzySort from 'fuzzysort'
|
||||||
import type {SequenceEditorPanelLayout} from '@theatre/studio/panels/SequenceEditorPanel/layout/layout'
|
import type {SequenceEditorPanelLayout} from '@theatre/studio/panels/SequenceEditorPanel/layout/layout'
|
||||||
import getStudio from '@theatre/studio/getStudio'
|
import getStudio from '@theatre/studio/getStudio'
|
||||||
import type {CommitOrDiscard} from '@theatre/studio/StudioStore/StudioStore'
|
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 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'
|
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`
|
const Container = styled.div`
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
width: 230px;
|
||||||
|
`
|
||||||
|
|
||||||
|
const InputContainer = styled.div`
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 4px 8px;
|
|
||||||
height: 28px;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
`
|
`
|
||||||
|
|
||||||
const Label = styled.div`
|
const OptionsContainer = styled.div`
|
||||||
${propNameText};
|
overflow: auto;
|
||||||
white-space: nowrap;
|
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<
|
const CurveEditorPopover: React.FC<
|
||||||
|
@ -37,6 +128,27 @@ const CurveEditorPopover: React.FC<
|
||||||
onRequestClose: () => void
|
onRequestClose: () => void
|
||||||
} & Parameters<typeof KeyframeEditor>[0]
|
} & Parameters<typeof KeyframeEditor>[0]
|
||||||
> = (props) => {
|
> = (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(() => {
|
const fns = useMemo(() => {
|
||||||
let tempTransaction: CommitOrDiscard | undefined
|
let tempTransaction: CommitOrDiscard | undefined
|
||||||
|
|
||||||
|
@ -46,7 +158,12 @@ const CurveEditorPopover: React.FC<
|
||||||
tempTransaction.discard()
|
tempTransaction.discard()
|
||||||
tempTransaction = undefined
|
tempTransaction = undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
const args = cssCubicBezierArgsToHandles(newCurve)!
|
const args = cssCubicBezierArgsToHandles(newCurve)!
|
||||||
|
if (!args) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
tempTransaction = getStudio()!.tempTransaction(({stateEditors}) => {
|
tempTransaction = getStudio()!.tempTransaction(({stateEditors}) => {
|
||||||
const {replaceKeyframes} =
|
const {replaceKeyframes} =
|
||||||
stateEditors.coreByProject.historic.sheetsById.sequence
|
stateEditors.coreByProject.historic.sheetsById.sequence
|
||||||
|
@ -80,7 +197,14 @@ const CurveEditorPopover: React.FC<
|
||||||
tempTransaction.discard()
|
tempTransaction.discard()
|
||||||
tempTransaction = undefined
|
tempTransaction = undefined
|
||||||
}
|
}
|
||||||
const args = cssCubicBezierArgsToHandles(newCurve)!
|
const args =
|
||||||
|
cssCubicBezierArgsToHandles(newCurve) ??
|
||||||
|
cssCubicBezierArgsToHandles(presetSearchResults[0].obj.value)
|
||||||
|
|
||||||
|
if (!args) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
getStudio()!.transaction(({stateEditors}) => {
|
getStudio()!.transaction(({stateEditors}) => {
|
||||||
const {replaceKeyframes} =
|
const {replaceKeyframes} =
|
||||||
stateEditors.coreByProject.historic.sheetsById.sequence
|
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)
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
inputRef.current!.focus()
|
inputRef.current!.focus()
|
||||||
inputRef.current!.setSelectionRange(0, 100)
|
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const {index, trackData} = props
|
const {index, trackData} = props
|
||||||
const cur = trackData.keyframes[index]
|
const cur = trackData.keyframes[index]
|
||||||
const next = trackData.keyframes[index + 1]
|
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 (
|
return (
|
||||||
<Container>
|
<Container>
|
||||||
<Label>cubic-bezier{'('}</Label>
|
<InputContainer>
|
||||||
<BasicStringInput
|
<SearchBox
|
||||||
value={cssCubicBezierString}
|
value={filter}
|
||||||
{...fns}
|
placeholder="Search presets..."
|
||||||
isValid={isValid}
|
onChange={(e) => {
|
||||||
inputRef={inputRef}
|
setFilter(e.target.value)
|
||||||
onBlur={props.onRequestClose}
|
}}
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<Label>{')'}</Label>
|
</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>
|
</Container>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default CurveEditorPopover
|
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(
|
function cssCubicBezierArgsToHandles(
|
||||||
str: string,
|
str: string,
|
||||||
):
|
):
|
||||||
|
|
|
@ -12227,6 +12227,13 @@ fsevents@^1.2.7:
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
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":
|
"gauge@npm:~2.7.3":
|
||||||
version: 2.7.4
|
version: 2.7.4
|
||||||
resolution: "gauge@npm:2.7.4"
|
resolution: "gauge@npm:2.7.4"
|
||||||
|
@ -22928,6 +22935,7 @@ fsevents@^1.2.7:
|
||||||
fast-deep-equal: ^3.1.3
|
fast-deep-equal: ^3.1.3
|
||||||
file-loader: ^6.2.0
|
file-loader: ^6.2.0
|
||||||
fs-extra: ^10.0.0
|
fs-extra: ^10.0.0
|
||||||
|
fuzzysort: ^1.1.4
|
||||||
html-loader: ^2.1.2
|
html-loader: ^2.1.2
|
||||||
identity-obj-proxy: ^3.0.0
|
identity-obj-proxy: ^3.0.0
|
||||||
immer: ^9.0.6
|
immer: ^9.0.6
|
||||||
|
|
Loading…
Reference in a new issue