feature/single curve editor (#122)
Co-authored-by: Cole Lawrence <cole@colelawrence.com> Co-authored-by: Andrew Prifer <AndrewPrifer@users.noreply.github.com> Co-authored-by: Aria Minaei <aria.minaei@gmail.com>
This commit is contained in:
parent
dceb3965d6
commit
fceb1eb60a
29 changed files with 1418 additions and 479 deletions
102
theatre/studio/src/UIRoot/PointerCapturing.tsx
Normal file
102
theatre/studio/src/UIRoot/PointerCapturing.tsx
Normal file
|
@ -0,0 +1,102 @@
|
|||
import React, {useContext, useMemo} from 'react'
|
||||
import type {$IntentionalAny} from '@theatre/shared/utils/types'
|
||||
|
||||
/** See {@link PointerCapturing} */
|
||||
export type CapturedPointer = {
|
||||
release(): void
|
||||
}
|
||||
|
||||
/**
|
||||
* Introduced `PointerCapturing` for addressing issues with over-shooting easing curves closing the popup preset modal.
|
||||
*
|
||||
* Goal is to be able to determine if the pointer is being captured somewhere in studio (e.g. dragging).
|
||||
*
|
||||
* Some other ideas we considered before going with the PointerCapturing provider and context
|
||||
* - provider: `onPointerCaptureChanged`
|
||||
* - `onDragging={isMouseActive = true}` / `onMouseActive={isMouseActive = true}`
|
||||
* - dragging tracked application wide (ephemeral state) in popover
|
||||
*
|
||||
* Caveats: I wonder if there's a shared abstraction we should use for "releasing" e.g. unsubscribe / untap in rxjs / tapable patterns.
|
||||
*/
|
||||
export type PointerCapturing = {
|
||||
isPointerBeingCaptured(): boolean
|
||||
capturePointer(debugReason: string): CapturedPointer
|
||||
}
|
||||
|
||||
type PointerCapturingFn = (forDebugName: string) => PointerCapturing
|
||||
|
||||
function _usePointerCapturingContext(): PointerCapturingFn {
|
||||
let [currentCapture, setCurrentCapture] = React.useState<null | {
|
||||
debugOwnerName: string
|
||||
debugReason: string
|
||||
}>(null)
|
||||
|
||||
return (forDebugName) => {
|
||||
return {
|
||||
capturePointer(reason) {
|
||||
// logger.log('Capturing pointer', {forDebugName, reason})
|
||||
if (currentCapture != null) {
|
||||
throw new Error(
|
||||
`"${forDebugName}" attempted capturing pointer for "${reason}" while already captured by "${currentCapture.debugOwnerName}" for "${currentCapture.debugReason}"`,
|
||||
)
|
||||
}
|
||||
|
||||
setCurrentCapture({debugOwnerName: forDebugName, debugReason: reason})
|
||||
|
||||
const releaseCapture = currentCapture
|
||||
return {
|
||||
release() {
|
||||
if (releaseCapture === currentCapture) {
|
||||
// logger.log('Releasing pointer', {
|
||||
// forDebugName,
|
||||
// reason,
|
||||
// })
|
||||
setCurrentCapture(null)
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
isPointerBeingCaptured() {
|
||||
return currentCapture != null
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const PointerCapturingContext = React.createContext<PointerCapturingFn>(
|
||||
null as $IntentionalAny,
|
||||
)
|
||||
// const ProviderChildren: React.FC<{children?: React.ReactNode}> = function
|
||||
|
||||
const ProviderChildrenMemo: React.FC<{}> = React.memo(({children}) => (
|
||||
<>{children}</>
|
||||
))
|
||||
|
||||
/**
|
||||
* See {@link PointerCapturing}.
|
||||
*
|
||||
* This should likely live towards the root of the application.
|
||||
*
|
||||
* Uncertain about whether nesting pointer capturing providers should be cognizant of each other.
|
||||
*/
|
||||
export function ProvidePointerCapturing(props: {
|
||||
children?: React.ReactNode
|
||||
}): React.ReactElement {
|
||||
const ctx = _usePointerCapturingContext()
|
||||
// Consider whether we want to manage multiple providers nested (e.g. embedding Theatre.js in Theatre.js or studio into whatever else)
|
||||
// This may not be necessary to consider due to the design of allowing a default value for contexts...
|
||||
// 1/10 importance to think about, now.
|
||||
// const parentCapturing = useContext(PointerCapturingContext)
|
||||
return (
|
||||
<PointerCapturingContext.Provider value={ctx}>
|
||||
<ProviderChildrenMemo children={props.children} />
|
||||
</PointerCapturingContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function usePointerCapturing(forDebugName: string): PointerCapturing {
|
||||
const pointerCapturingFn = useContext(PointerCapturingContext)
|
||||
return useMemo(() => {
|
||||
return pointerCapturingFn(forDebugName)
|
||||
}, [forDebugName, pointerCapturingFn])
|
||||
}
|
|
@ -12,6 +12,7 @@ import type {$IntentionalAny} from '@theatre/shared/utils/types'
|
|||
import useKeyboardShortcuts from './useKeyboardShortcuts'
|
||||
import PointerEventsHandler from '@theatre/studio/uiComponents/PointerEventsHandler'
|
||||
import TooltipContext from '@theatre/studio/uiComponents/Popover/TooltipContext'
|
||||
import {ProvidePointerCapturing} from './PointerCapturing'
|
||||
|
||||
const GlobalStyle = createGlobalStyle`
|
||||
:host {
|
||||
|
@ -67,6 +68,7 @@ export default function UIRoot() {
|
|||
)
|
||||
|
||||
useKeyboardShortcuts()
|
||||
|
||||
const visiblityState = useVal(studio.atomP.ahistoric.visibilityState)
|
||||
useEffect(() => {
|
||||
if (visiblityState === 'everythingIsHidden') {
|
||||
|
@ -93,21 +95,23 @@ export default function UIRoot() {
|
|||
>
|
||||
<>
|
||||
<GlobalStyle />
|
||||
<ProvideTheme>
|
||||
<PortalContext.Provider value={portalLayer}>
|
||||
<TooltipContext>
|
||||
<Container
|
||||
className={
|
||||
visiblityState === 'everythingIsHidden' ? 'invisible' : ''
|
||||
}
|
||||
>
|
||||
<PortalLayer ref={portalLayerRef} />
|
||||
{<GlobalToolbar />}
|
||||
{<PanelsRoot />}
|
||||
</Container>
|
||||
</TooltipContext>
|
||||
</PortalContext.Provider>
|
||||
</ProvideTheme>
|
||||
<ProvidePointerCapturing>
|
||||
<ProvideTheme>
|
||||
<PortalContext.Provider value={portalLayer}>
|
||||
<TooltipContext>
|
||||
<Container
|
||||
className={
|
||||
visiblityState === 'everythingIsHidden' ? 'invisible' : ''
|
||||
}
|
||||
>
|
||||
<PortalLayer ref={portalLayerRef} />
|
||||
{<GlobalToolbar />}
|
||||
{<PanelsRoot />}
|
||||
</Container>
|
||||
</TooltipContext>
|
||||
</PortalContext.Provider>
|
||||
</ProvideTheme>
|
||||
</ProvidePointerCapturing>
|
||||
</>
|
||||
</StyleSheetManager>
|
||||
)
|
||||
|
|
|
@ -25,6 +25,7 @@ const PanelDragZone: React.FC<
|
|||
let tempTransaction: CommitOrDiscard | undefined
|
||||
let unlock: VoidFn | undefined
|
||||
return {
|
||||
debugName: 'PanelDragZone',
|
||||
lockCursorTo: 'move',
|
||||
onDragStart() {
|
||||
stuffBeforeDrag = panelStuffRef.current
|
||||
|
|
|
@ -151,6 +151,7 @@ const PanelResizeHandle: React.FC<{
|
|||
let unlock: VoidFn | undefined
|
||||
|
||||
return {
|
||||
debugName: 'PanelResizeHandle',
|
||||
lockCursorTo: cursors[which],
|
||||
onDragStart() {
|
||||
stuffBeforeDrag = panelStuffRef.current
|
||||
|
|
|
@ -12,7 +12,7 @@ import type {
|
|||
SequenceEditorPanelLayout,
|
||||
DopeSheetSelection,
|
||||
} from '@theatre/studio/panels/SequenceEditorPanel/layout/layout'
|
||||
import {dotSize} from './Dot'
|
||||
import {DOT_SIZE_PX} from './Dot'
|
||||
import type KeyframeEditor from './KeyframeEditor'
|
||||
import type Sequence from '@theatre/core/sequences/Sequence'
|
||||
import usePopover from '@theatre/studio/uiComponents/Popover/usePopover'
|
||||
|
@ -21,34 +21,44 @@ import CurveEditorPopover from './CurveEditorPopover/CurveEditorPopover'
|
|||
import selectedKeyframeIdsIfInSingleTrack from '@theatre/studio/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/selectedKeyframeIdsIfInSingleTrack'
|
||||
import type {OpenFn} from '@theatre/studio/src/uiComponents/Popover/usePopover'
|
||||
import type {Keyframe} from '@theatre/core/projects/store/types/SheetState_Historic'
|
||||
import {usePointerCapturing} from '@theatre/studio/UIRoot/PointerCapturing'
|
||||
import {COLOR_POPOVER_BACK} from './CurveEditorPopover/colors'
|
||||
|
||||
const connectorHeight = dotSize / 2 + 1
|
||||
const connectorWidthUnscaled = 1000
|
||||
const CONNECTOR_HEIGHT = DOT_SIZE_PX / 2 + 1
|
||||
const CONNECTOR_WIDTH_UNSCALED = 1000
|
||||
|
||||
export const connectorTheme = {
|
||||
normalColor: `#365b59`,
|
||||
get hoverColor() {
|
||||
return lighten(0.1, connectorTheme.normalColor)
|
||||
const POPOVER_MARGIN = 5
|
||||
|
||||
type IConnectorThemeValues = {
|
||||
isPopoverOpen: boolean
|
||||
isSelected: boolean
|
||||
}
|
||||
|
||||
export const CONNECTOR_THEME = {
|
||||
normalColor: `#365b59`, // (greenish-blueish)ish
|
||||
popoverOpenColor: `#817720`, // orangey yellowish
|
||||
barColor: (values: IConnectorThemeValues) => {
|
||||
const base = values.isPopoverOpen
|
||||
? CONNECTOR_THEME.popoverOpenColor
|
||||
: CONNECTOR_THEME.normalColor
|
||||
return values.isSelected ? lighten(0.2, base) : base
|
||||
},
|
||||
get selectedColor() {
|
||||
return lighten(0.2, connectorTheme.normalColor)
|
||||
},
|
||||
get selectedHoverColor() {
|
||||
return lighten(0.4, connectorTheme.normalColor)
|
||||
hoverColor: (values: IConnectorThemeValues) => {
|
||||
const base = values.isPopoverOpen
|
||||
? CONNECTOR_THEME.popoverOpenColor
|
||||
: CONNECTOR_THEME.normalColor
|
||||
return values.isSelected ? lighten(0.4, base) : lighten(0.1, base)
|
||||
},
|
||||
}
|
||||
|
||||
const Container = styled.div<{isSelected: boolean}>`
|
||||
const Container = styled.div<IConnectorThemeValues>`
|
||||
position: absolute;
|
||||
background: ${(props) =>
|
||||
props.isSelected
|
||||
? connectorTheme.selectedColor
|
||||
: connectorTheme.normalColor};
|
||||
height: ${connectorHeight}px;
|
||||
width: ${connectorWidthUnscaled}px;
|
||||
background: ${CONNECTOR_THEME.barColor};
|
||||
height: ${CONNECTOR_HEIGHT}px;
|
||||
width: ${CONNECTOR_WIDTH_UNSCALED}px;
|
||||
|
||||
left: 0;
|
||||
top: -${connectorHeight / 2}px;
|
||||
top: -${CONNECTOR_HEIGHT / 2}px;
|
||||
transform-origin: top left;
|
||||
z-index: 0;
|
||||
cursor: ew-resize;
|
||||
|
@ -64,12 +74,15 @@ const Container = styled.div<{isSelected: boolean}>`
|
|||
}
|
||||
|
||||
&:hover {
|
||||
background: ${(props) =>
|
||||
props.isSelected
|
||||
? connectorTheme.selectedHoverColor
|
||||
: connectorTheme.hoverColor};
|
||||
background: ${CONNECTOR_THEME.hoverColor};
|
||||
}
|
||||
`
|
||||
|
||||
const EasingPopover = styled(BasicPopover)`
|
||||
--popover-outer-stroke: transparent;
|
||||
--popover-inner-stroke: ${COLOR_POPOVER_BACK};
|
||||
`
|
||||
|
||||
type IProps = Parameters<typeof KeyframeEditor>[0]
|
||||
|
||||
const Connector: React.FC<IProps> = (props) => {
|
||||
|
@ -77,17 +90,26 @@ const Connector: React.FC<IProps> = (props) => {
|
|||
const cur = trackData.keyframes[index]
|
||||
const next = trackData.keyframes[index + 1]
|
||||
|
||||
const connectorLengthInUnitSpace = next.position - cur.position
|
||||
|
||||
const [nodeRef, node] = useRefAndState<HTMLDivElement | null>(null)
|
||||
|
||||
const {isPointerBeingCaptured} = usePointerCapturing(
|
||||
'KeyframeEditor Connector',
|
||||
)
|
||||
|
||||
const rightDims = val(props.layoutP.rightDims)
|
||||
const [popoverNode, openPopover, closePopover, isPopoverOpen] = usePopover(
|
||||
{},
|
||||
{
|
||||
closeWhenPointerIsDistant: !isPointerBeingCaptured(),
|
||||
constraints: {
|
||||
minX: rightDims.screenX + POPOVER_MARGIN,
|
||||
maxX: rightDims.screenX + rightDims.width - POPOVER_MARGIN,
|
||||
},
|
||||
},
|
||||
() => {
|
||||
return (
|
||||
<BasicPopover>
|
||||
<EasingPopover showPopoverEdgeTriangle={false}>
|
||||
<CurveEditorPopover {...props} onRequestClose={closePopover} />
|
||||
</BasicPopover>
|
||||
</EasingPopover>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
@ -101,15 +123,25 @@ const Connector: React.FC<IProps> = (props) => {
|
|||
)
|
||||
useDragKeyframe(node, props)
|
||||
|
||||
const connectorLengthInUnitSpace = next.position - cur.position
|
||||
|
||||
const themeValues: IConnectorThemeValues = {
|
||||
isPopoverOpen,
|
||||
isSelected: !!props.selection,
|
||||
}
|
||||
|
||||
return (
|
||||
<Container
|
||||
isSelected={!!props.selection}
|
||||
{...themeValues}
|
||||
ref={nodeRef}
|
||||
style={{
|
||||
transform: `scale3d(calc(var(--unitSpaceToScaledSpaceMultiplier) * ${
|
||||
connectorLengthInUnitSpace / connectorWidthUnscaled
|
||||
connectorLengthInUnitSpace / CONNECTOR_WIDTH_UNSCALED
|
||||
}), 1, 1)`,
|
||||
}}
|
||||
onClick={(e) => {
|
||||
if (node) openPopover(e, node)
|
||||
}}
|
||||
>
|
||||
{popoverNode}
|
||||
{contextMenu}
|
||||
|
@ -132,6 +164,7 @@ function useDragKeyframe(node: HTMLDivElement | null, props: IProps) {
|
|||
| undefined
|
||||
let sequence: Sequence
|
||||
return {
|
||||
debugName: 'useDragKeyframe',
|
||||
lockCursorTo: 'ew-resize',
|
||||
onDragStart(event) {
|
||||
const props = propsRef.current
|
||||
|
|
|
@ -1,457 +1,427 @@
|
|||
import type {Pointer} from '@theatre/dataverse'
|
||||
import {val} from '@theatre/dataverse'
|
||||
import React, {useLayoutEffect, useMemo, useRef, useState} from 'react'
|
||||
import type {KeyboardEvent} from 'react'
|
||||
import React, {
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import styled from 'styled-components'
|
||||
import fuzzy from 'fuzzy'
|
||||
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 type KeyframeEditor from '@theatre/studio/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/KeyframeEditor'
|
||||
import type {$IntentionalAny} from '@theatre/shared/utils/types'
|
||||
import CurveSegmentEditor from './CurveSegmentEditor'
|
||||
import EasingOption from './EasingOption'
|
||||
import type {CSSCubicBezierArgsString, CubicBezierHandles} from './shared'
|
||||
import {
|
||||
cssCubicBezierArgsFromHandles,
|
||||
handlesFromCssCubicBezierArgs,
|
||||
EASING_PRESETS,
|
||||
areEasingsSimilar,
|
||||
} from './shared'
|
||||
import {COLOR_BASE, COLOR_POPOVER_BACK} from './colors'
|
||||
import useRefAndState from '@theatre/studio/utils/useRefAndState'
|
||||
import type {Keyframe} from '@theatre/core/projects/store/types/SheetState_Historic'
|
||||
import {useUIOptionGrid, Outcome} from './useUIOptionGrid'
|
||||
|
||||
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 PRESET_COLUMNS = 3
|
||||
const PRESET_SIZE = 53
|
||||
|
||||
const Container = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
width: 230px;
|
||||
`
|
||||
const APPROX_TOOLTIP_HEIGHT = 25
|
||||
|
||||
const InputContainer = styled.div`
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
const Grid = styled.div`
|
||||
background: ${COLOR_POPOVER_BACK};
|
||||
display: grid;
|
||||
grid-template-areas:
|
||||
'search tween'
|
||||
'presets tween';
|
||||
grid-template-rows: 32px 1fr;
|
||||
grid-template-columns: ${PRESET_COLUMNS * PRESET_SIZE}px 120px;
|
||||
gap: 1px;
|
||||
height: 120px;
|
||||
`
|
||||
|
||||
const OptionsContainer = styled.div`
|
||||
overflow: auto;
|
||||
max-height: 130px;
|
||||
grid-area: presets;
|
||||
|
||||
// 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;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(${PRESET_COLUMNS}, 1fr);
|
||||
grid-auto-rows: min-content;
|
||||
gap: 1px;
|
||||
|
||||
overflow-y: scroll;
|
||||
scrollbar-width: none; /* Firefox */
|
||||
-ms-overflow-style: none; /* Internet Explorer 10+ */
|
||||
&::-webkit-scrollbar {
|
||||
/* WebKit */
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
`
|
||||
|
||||
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;
|
||||
background-color: ${COLOR_BASE};
|
||||
border: none;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.16);
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
padding: 10px;
|
||||
font: inherit;
|
||||
border-radius: 2px;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
padding: 6px;
|
||||
font-size: 12px;
|
||||
outline: none;
|
||||
cursor: text;
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
height: calc(100% - 4px);
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
|
||||
grid-area: search;
|
||||
|
||||
&:hover {
|
||||
background-color: #212121;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
cursor: text;
|
||||
background-color: rgba(16, 16, 16, 0.26);
|
||||
outline: 1px solid rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
`
|
||||
|
||||
const CurveEditorPopover: React.FC<
|
||||
{
|
||||
layoutP: Pointer<SequenceEditorPanelLayout>
|
||||
const CurveEditorContainer = styled.div`
|
||||
grid-area: tween;
|
||||
background: ${COLOR_BASE};
|
||||
`
|
||||
|
||||
/**
|
||||
* Called when user hits enter/escape
|
||||
*/
|
||||
onRequestClose: () => void
|
||||
} & Parameters<typeof KeyframeEditor>[0]
|
||||
> = (props) => {
|
||||
const [filter, setFilter] = useState<string>('')
|
||||
const NoResultsFoundContainer = styled.div`
|
||||
grid-column: 1 / 4;
|
||||
padding: 6px;
|
||||
color: #888888;
|
||||
`
|
||||
|
||||
const presetSearchResults = useMemo(
|
||||
enum TextInputMode {
|
||||
user,
|
||||
auto,
|
||||
}
|
||||
|
||||
type IProps = {
|
||||
layoutP: Pointer<SequenceEditorPanelLayout>
|
||||
|
||||
/**
|
||||
* Called when user hits enter/escape
|
||||
*/
|
||||
onRequestClose: () => void
|
||||
} & Parameters<typeof KeyframeEditor>[0]
|
||||
|
||||
const CurveEditorPopover: React.FC<IProps> = (props) => {
|
||||
////// `tempTransaction` //////
|
||||
/*
|
||||
* `tempTransaction` is used for all edits in this popover. The transaction
|
||||
* is discared if the user presses escape, otherwise it is committed when the
|
||||
* popover closes.
|
||||
*/
|
||||
const tempTransaction = useRef<CommitOrDiscard | null>(null)
|
||||
useEffect(
|
||||
() =>
|
||||
fuzzy.filter(filter, presets, {
|
||||
extract: (el) => el.label,
|
||||
pre: '<b>',
|
||||
post: '</b>',
|
||||
}),
|
||||
|
||||
[filter],
|
||||
// Clean-up function, called when this React component unmounts.
|
||||
// When it unmounts, we want to commit edits that are outstanding
|
||||
() => {
|
||||
tempTransaction.current?.commit()
|
||||
},
|
||||
[tempTransaction],
|
||||
)
|
||||
|
||||
// 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.original) : presets,
|
||||
[presetSearchResults, useQuery],
|
||||
)
|
||||
|
||||
const fns = useMemo(() => {
|
||||
let tempTransaction: CommitOrDiscard | undefined
|
||||
|
||||
return {
|
||||
temporarilySetValue(newCurve: string): void {
|
||||
if (tempTransaction) {
|
||||
tempTransaction.discard()
|
||||
tempTransaction = undefined
|
||||
}
|
||||
|
||||
const args = cssCubicBezierArgsToHandles(newCurve)!
|
||||
if (!args) {
|
||||
return
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
},
|
||||
permanentlySetValue(newCurve: string): void {
|
||||
if (tempTransaction) {
|
||||
tempTransaction.discard()
|
||||
tempTransaction = undefined
|
||||
}
|
||||
const args =
|
||||
cssCubicBezierArgsToHandles(newCurve) ??
|
||||
cssCubicBezierArgsToHandles(presetSearchResults[0].original.value)
|
||||
|
||||
if (!args) {
|
||||
return
|
||||
}
|
||||
|
||||
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.onRequestClose()
|
||||
},
|
||||
}
|
||||
}, [props.layoutP, props.index, presetSearchResults])
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
useLayoutEffect(() => {
|
||||
inputRef.current!.focus()
|
||||
}, [])
|
||||
|
||||
////// Keyframe and trackdata //////
|
||||
const {index, trackData} = props
|
||||
const cur = trackData.keyframes[index]
|
||||
const next = trackData.keyframes[index + 1]
|
||||
const easing: CubicBezierHandles = [
|
||||
trackData.keyframes[index].handles[2],
|
||||
trackData.keyframes[index].handles[3],
|
||||
trackData.keyframes[index + 1].handles[0],
|
||||
trackData.keyframes[index + 1].handles[1],
|
||||
]
|
||||
|
||||
// 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'
|
||||
////// Text input data and reactivity //////
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// Select the easing string on popover open for quick copy&paste
|
||||
useLayoutEffect(() => {
|
||||
inputRef.current?.select()
|
||||
inputRef.current?.focus()
|
||||
}, [inputRef.current])
|
||||
|
||||
const [inputValue, setInputValue] = useState<string>('')
|
||||
|
||||
const onInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setTextInputMode(TextInputMode.user)
|
||||
setInputValue(e.target.value)
|
||||
|
||||
const maybeHandles = handlesFromCssCubicBezierArgs(inputValue)
|
||||
if (maybeHandles) setEdit(inputValue)
|
||||
}
|
||||
const onSearchKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
|
||||
setTextInputMode(TextInputMode.user)
|
||||
// Prevent scrolling on arrow key press
|
||||
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') e.preventDefault()
|
||||
|
||||
if (e.key === 'ArrowDown') {
|
||||
grid.focusFirstItem()
|
||||
optionsRef.current[displayedPresets[0].label]?.current?.focus()
|
||||
} else if (e.key === 'Escape') {
|
||||
discardTempValue(tempTransaction)
|
||||
props.onRequestClose()
|
||||
} else if (e.key === 'Enter') {
|
||||
props.onRequestClose()
|
||||
}
|
||||
}
|
||||
|
||||
// In auto mode, the text input field is continually updated to
|
||||
// a CSS cubic bezier args string to reflect the state of the curve;
|
||||
// in user mode, the text input field does not update when the curve
|
||||
// changes so that the user's search is preserved.
|
||||
const [textInputMode, setTextInputMode] = useState<TextInputMode>(
|
||||
TextInputMode.auto,
|
||||
)
|
||||
useEffect(() => {
|
||||
if (textInputMode === TextInputMode.auto)
|
||||
setInputValue(cssCubicBezierArgsFromHandles(easing))
|
||||
}, [trackData])
|
||||
|
||||
// `edit` keeps track of the current edited state of the curve.
|
||||
const [edit, setEdit] = useState<CSSCubicBezierArgsString | null>(null)
|
||||
// `preview` is used when hovering over a curve to preview it.
|
||||
const [preview, setPreview] = useState<CSSCubicBezierArgsString | null>(null)
|
||||
|
||||
// When `preview` or `edit` change, use the `tempTransaction` to change the
|
||||
// curve in Theate's data.
|
||||
useMemo(
|
||||
() =>
|
||||
setTempValue(tempTransaction, props, cur, next, preview ?? edit ?? ''),
|
||||
[preview, edit],
|
||||
)
|
||||
|
||||
////// Curve editing reactivity //////
|
||||
const onCurveChange = (newHandles: CubicBezierHandles) => {
|
||||
setTextInputMode(TextInputMode.auto)
|
||||
const value = cssCubicBezierArgsFromHandles(newHandles)
|
||||
setInputValue(value)
|
||||
setEdit(value)
|
||||
}
|
||||
const onCancelCurveChange = () => {}
|
||||
|
||||
////// Preset reactivity //////
|
||||
const displayedPresets = useMemo(() => {
|
||||
const presetSearchResults = fuzzy.filter(inputValue, EASING_PRESETS, {
|
||||
extract: (el) => el.label,
|
||||
})
|
||||
const isInputValueAQuery = /^[A-Za-z]/.test(inputValue)
|
||||
|
||||
return isInputValueAQuery
|
||||
? presetSearchResults.map((result) => result.original)
|
||||
: EASING_PRESETS
|
||||
}, [inputValue])
|
||||
// Use the first preset in the search when the displayed presets change
|
||||
useEffect(() => {
|
||||
if (displayedPresets[0]) setEdit(displayedPresets[0].value)
|
||||
}, [displayedPresets])
|
||||
|
||||
////// Option grid specification and reactivity //////
|
||||
const onEasingOptionKeydown = (e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Escape') {
|
||||
discardTempValue(tempTransaction)
|
||||
props.onRequestClose()
|
||||
e.stopPropagation()
|
||||
} else if (e.key === 'Enter') {
|
||||
props.onRequestClose()
|
||||
e.stopPropagation()
|
||||
}
|
||||
}
|
||||
const onEasingOptionMouseOver = (item: {label: string; value: string}) =>
|
||||
setPreview(item.value)
|
||||
const onEasingOptionMouseOut = () => setPreview(null)
|
||||
const onSelectEasingOption = (item: {label: string; value: string}) => {
|
||||
setTextInputMode(TextInputMode.auto)
|
||||
setEdit(item.value)
|
||||
}
|
||||
|
||||
// A map to store all html elements corresponding to easing options
|
||||
const optionsRef = useRef(
|
||||
presets.reduce((acc, curr) => {
|
||||
EASING_PRESETS.reduce((acc, curr) => {
|
||||
acc[curr.label] = {current: null}
|
||||
|
||||
return acc
|
||||
}, {} as {[key: string]: {current: HTMLDivElement | null}}),
|
||||
)
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<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.permanentlySetValue(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))
|
||||
const [optionsContainerRef, optionsContainer] =
|
||||
useRefAndState<HTMLDivElement | null>(null)
|
||||
// Keep track of option container scroll position
|
||||
const [optionsScrollPosition, setOptionsScrollPosition] = useState(0)
|
||||
useEffect(() => {
|
||||
const listener = () => {
|
||||
setOptionsScrollPosition(optionsContainer?.scrollTop ?? 0)
|
||||
}
|
||||
optionsContainer?.addEventListener('scroll', listener)
|
||||
return () => optionsContainer?.removeEventListener('scroll', listener)
|
||||
}, [optionsContainer])
|
||||
|
||||
return (
|
||||
<EasingOption
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape') {
|
||||
props.onRequestClose()
|
||||
} else if (e.key === 'Enter') {
|
||||
fns.permanentlySetValue(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.permanentlySetValue(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: presetSearchResults[index].string,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
preset.label
|
||||
)}
|
||||
</span>
|
||||
</EasingOption>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</OptionsContainer>
|
||||
)}
|
||||
</Container>
|
||||
const grid = useUIOptionGrid({
|
||||
items: displayedPresets,
|
||||
uiColumns: 3,
|
||||
onSelectItem: onSelectEasingOption,
|
||||
canVerticleExit(exitSide) {
|
||||
if (exitSide === 'top') {
|
||||
inputRef.current?.select()
|
||||
inputRef.current?.focus()
|
||||
return Outcome.Handled
|
||||
}
|
||||
return Outcome.Passthrough
|
||||
},
|
||||
renderItem: ({item: preset, select}) => (
|
||||
<EasingOption
|
||||
key={preset.label}
|
||||
easing={preset}
|
||||
tabIndex={0}
|
||||
onKeyDown={onEasingOptionKeydown}
|
||||
ref={optionsRef.current[preset.label]}
|
||||
onMouseOver={() => onEasingOptionMouseOver(preset)}
|
||||
onMouseOut={onEasingOptionMouseOut}
|
||||
onClick={select}
|
||||
tooltipPlacement={
|
||||
(optionsRef.current[preset.label].current?.offsetTop ?? 0) -
|
||||
(optionsScrollPosition ?? 0) <
|
||||
PRESET_SIZE + APPROX_TOOLTIP_HEIGHT
|
||||
? 'bottom'
|
||||
: 'top'
|
||||
}
|
||||
isSelected={areEasingsSimilar(
|
||||
easing,
|
||||
handlesFromCssCubicBezierArgs(preset.value),
|
||||
)}
|
||||
/>
|
||||
),
|
||||
})
|
||||
|
||||
// When the user navigates highlight between presets, focus the preset el and set the
|
||||
// easing data to match the highlighted preset
|
||||
useLayoutEffect(() => {
|
||||
if (
|
||||
grid.currentSelection !== null &&
|
||||
document.activeElement !== inputRef.current // prevents taking focus away from input
|
||||
) {
|
||||
const maybePresetEl =
|
||||
optionsRef.current?.[grid.currentSelection.label]?.current
|
||||
maybePresetEl?.focus()
|
||||
setEdit(grid.currentSelection.value)
|
||||
}
|
||||
}, [grid.currentSelection])
|
||||
|
||||
return (
|
||||
<Grid>
|
||||
<SearchBox
|
||||
value={inputValue}
|
||||
placeholder="Search presets..."
|
||||
onPaste={setTimeoutFunction(onInputChange)}
|
||||
onChange={onInputChange}
|
||||
ref={inputRef}
|
||||
onKeyDown={onSearchKeyDown}
|
||||
/>
|
||||
<OptionsContainer
|
||||
ref={optionsContainerRef}
|
||||
onKeyDown={(evt) => grid.onParentEltKeyDown(evt)}
|
||||
>
|
||||
{grid.gridItems}
|
||||
{grid.gridItems.length === 0 ? (
|
||||
<NoResultsFoundContainer>No results found</NoResultsFoundContainer>
|
||||
) : undefined}
|
||||
</OptionsContainer>
|
||||
<CurveEditorContainer onClick={() => inputRef.current?.focus()}>
|
||||
<CurveSegmentEditor
|
||||
{...props}
|
||||
onCurveChange={onCurveChange}
|
||||
onCancelCurveChange={onCancelCurveChange}
|
||||
/>
|
||||
</CurveEditorContainer>
|
||||
</Grid>
|
||||
)
|
||||
}
|
||||
|
||||
export default CurveEditorPopover
|
||||
|
||||
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())
|
||||
})
|
||||
function setTempValue(
|
||||
tempTransaction: React.MutableRefObject<CommitOrDiscard | null>,
|
||||
props: IProps,
|
||||
cur: Keyframe,
|
||||
next: Keyframe,
|
||||
newCurve: string,
|
||||
): void {
|
||||
tempTransaction.current?.discard()
|
||||
tempTransaction.current = null
|
||||
|
||||
if (!nums.every((v) => isFinite(v))) return undefined
|
||||
const handles = handlesFromCssCubicBezierArgs(newCurve)
|
||||
if (handles === null) return
|
||||
|
||||
if (nums[0] < 0 || nums[0] > 1 || nums[2] < 0 || nums[2] > 1) return undefined
|
||||
return nums as $IntentionalAny
|
||||
tempTransaction.current = transactionSetCubicBezier(props, cur, next, handles)
|
||||
}
|
||||
|
||||
function discardTempValue(
|
||||
tempTransaction: React.MutableRefObject<CommitOrDiscard | null>,
|
||||
): void {
|
||||
tempTransaction.current?.discard()
|
||||
tempTransaction.current = null
|
||||
}
|
||||
|
||||
function transactionSetCubicBezier(
|
||||
props: IProps,
|
||||
cur: Keyframe,
|
||||
next: Keyframe,
|
||||
newHandles: CubicBezierHandles,
|
||||
): CommitOrDiscard {
|
||||
return 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],
|
||||
newHandles[0],
|
||||
newHandles[1],
|
||||
],
|
||||
},
|
||||
{
|
||||
...next,
|
||||
handles: [
|
||||
newHandles[2],
|
||||
newHandles[3],
|
||||
next.handles[2],
|
||||
next.handles[3],
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* n mod m without negative results e.g. `mod(-1,5) = 4` contrasted with `-1 % 5 = -1`.
|
||||
*
|
||||
* ref: https://web.archive.org/web/20090717035140if_/javascript.about.com/od/problemsolving/a/modulobug.htm
|
||||
*/
|
||||
export function mod(n: number, m: number) {
|
||||
return ((n % m) + m) % m
|
||||
}
|
||||
|
||||
function setTimeoutFunction(f: Function, timeout?: number) {
|
||||
return () => setTimeout(f, timeout)
|
||||
}
|
||||
|
|
|
@ -0,0 +1,274 @@
|
|||
import React from 'react'
|
||||
import useDrag from '@theatre/studio/uiComponents/useDrag'
|
||||
import useRefAndState from '@theatre/studio/utils/useRefAndState'
|
||||
import clamp from 'lodash-es/clamp'
|
||||
import type CurveEditorPopover from './CurveEditorPopover'
|
||||
import styled from 'styled-components'
|
||||
import {pointerEventsAutoInNormalMode} from '@theatre/studio/css'
|
||||
import type {CubicBezierHandles} from './shared'
|
||||
import {useFreezableMemo} from './useFreezableMemo'
|
||||
import {COLOR_BASE} from './colors'
|
||||
|
||||
// Defines the dimensions of the SVG viewbox space
|
||||
const VIEWBOX_PADDING = 0.12
|
||||
const VIEWBOX_SIZE = 1 + VIEWBOX_PADDING * 2
|
||||
|
||||
const PATTERN_DOT_SIZE = 0.01
|
||||
const PATTERN_DOT_COUNT = 8
|
||||
const PATTERN_GRID_SIZE = (1 - PATTERN_DOT_SIZE) / (PATTERN_DOT_COUNT - 1)
|
||||
|
||||
// The curve supports a gradient but currently is solid cyan
|
||||
const CURVE_START_OVERSHOOT_COLOR = '#3EAAA4'
|
||||
const CURVE_START_COLOR = '#3EAAA4'
|
||||
const CURVE_MID_START_COLOR = '#3EAAA4'
|
||||
const CURVE_MID_COLOR = '#3EAAA4'
|
||||
const CURVE_MID_END_COLOR = '#3EAAA4'
|
||||
const CURVE_END_COLOR = '#3EAAA4'
|
||||
const CURVE_END_OVERSHOOT_COLOR = '#3EAAA4'
|
||||
|
||||
const CONTROL_COLOR = '#B3B3B3'
|
||||
const HANDLE_COLOR = '#3eaaa4'
|
||||
const HANDLE_HOVER_COLOR = '#67dfd8'
|
||||
|
||||
const Circle = styled.circle`
|
||||
stroke-width: 0.1px;
|
||||
vector-effect: non-scaling-stroke;
|
||||
r: 0.04px;
|
||||
pointer-events: none;
|
||||
transition: r 0.15s;
|
||||
fill: ${HANDLE_COLOR};
|
||||
`
|
||||
|
||||
const HitZone = styled.circle`
|
||||
stroke-width: 0.1px;
|
||||
vector-effect: non-scaling-stroke;
|
||||
r: 0.09px;
|
||||
cursor: move;
|
||||
${pointerEventsAutoInNormalMode};
|
||||
&:hover {
|
||||
opacity: 0.4;
|
||||
}
|
||||
&:hover + ${Circle} {
|
||||
fill: ${HANDLE_HOVER_COLOR};
|
||||
}
|
||||
`
|
||||
|
||||
type IProps = {
|
||||
onCurveChange: (newHandles: CubicBezierHandles) => void
|
||||
onCancelCurveChange: () => void
|
||||
} & Parameters<typeof CurveEditorPopover>[0]
|
||||
|
||||
const CurveSegmentEditor: React.FC<IProps> = (props) => {
|
||||
const {index, trackData} = props
|
||||
const cur = trackData.keyframes[index]
|
||||
const next = trackData.keyframes[index + 1]
|
||||
|
||||
// Calculations towards keeping the handles in the viewbox. The extremum space
|
||||
// of this editor vertically scales to keep the handles in the viewbox of the
|
||||
// SVG. This produces a nice "stretching space" effect while you are dragging
|
||||
// the handles.
|
||||
// Demo: https://user-images.githubusercontent.com/11082236/164542544-f1f66de2-f62e-44dd-b4cb-05b5f6e73a52.mp4
|
||||
const minY = Math.min(0, 1 - next.handles[1], 1 - cur.handles[3])
|
||||
const maxY = Math.max(1, 1 - next.handles[1], 1 - cur.handles[3])
|
||||
const h = Math.max(1, maxY - minY)
|
||||
|
||||
const toExtremumSpace = (y: number) => (y - minY) / h
|
||||
|
||||
const [refSVG, nodeSVG] = useRefAndState<SVGSVGElement | null>(null)
|
||||
|
||||
const viewboxToElWidthRatio = VIEWBOX_SIZE / (nodeSVG?.clientWidth || 1)
|
||||
const viewboxToElHeightRatio = VIEWBOX_SIZE / (nodeSVG?.clientHeight || 1)
|
||||
|
||||
const [refLeft, nodeLeft] = useRefAndState<SVGCircleElement | null>(null)
|
||||
useKeyframeDrag(nodeSVG, nodeLeft, props, (dx, dy) => {
|
||||
const handleX = clamp(cur.handles[2] + dx * viewboxToElWidthRatio, 0, 1)
|
||||
const handleY = cur.handles[3] - dy * viewboxToElHeightRatio
|
||||
|
||||
return [handleX, handleY, next.handles[0], next.handles[1]]
|
||||
})
|
||||
|
||||
const [refRight, nodeRight] = useRefAndState<SVGCircleElement | null>(null)
|
||||
useKeyframeDrag(nodeSVG, nodeRight, props, (dx, dy) => {
|
||||
const handleX = clamp(next.handles[0] + dx * viewboxToElWidthRatio, 0, 1)
|
||||
const handleY = next.handles[1] - dy * viewboxToElHeightRatio
|
||||
|
||||
return [cur.handles[2], cur.handles[3], handleX, handleY]
|
||||
})
|
||||
|
||||
const curvePathDAttrValue = `M0 ${toExtremumSpace(1)} C${
|
||||
cur.handles[2]
|
||||
} ${toExtremumSpace(1 - cur.handles[3])}
|
||||
${next.handles[0]} ${toExtremumSpace(1 - next.handles[1])} 1 ${toExtremumSpace(
|
||||
0,
|
||||
)}`
|
||||
|
||||
return (
|
||||
<svg
|
||||
height="100%"
|
||||
width="100%"
|
||||
ref={refSVG}
|
||||
viewBox={`${-VIEWBOX_PADDING} ${-VIEWBOX_PADDING} ${VIEWBOX_SIZE} ${VIEWBOX_SIZE}`}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
preserveAspectRatio="none"
|
||||
fill="none"
|
||||
>
|
||||
<linearGradient id="myGradient" gradientTransform="rotate(90)">
|
||||
<stop
|
||||
offset={toExtremumSpace(-1)}
|
||||
stopColor={CURVE_END_OVERSHOOT_COLOR}
|
||||
/>
|
||||
<stop offset={toExtremumSpace(0)} stopColor={CURVE_END_COLOR} />
|
||||
<stop offset={toExtremumSpace(0.3)} stopColor={CURVE_MID_END_COLOR} />
|
||||
<stop offset={toExtremumSpace(0.5)} stopColor={CURVE_MID_COLOR} />
|
||||
<stop offset={toExtremumSpace(0.7)} stopColor={CURVE_MID_START_COLOR} />
|
||||
<stop offset={toExtremumSpace(1)} stopColor={CURVE_START_COLOR} />
|
||||
<stop
|
||||
offset={toExtremumSpace(2)}
|
||||
stopColor={CURVE_START_OVERSHOOT_COLOR}
|
||||
/>
|
||||
</linearGradient>
|
||||
|
||||
{/* Unit space, opaque white dot pattern */}
|
||||
<pattern
|
||||
id="dot-background-pattern-1"
|
||||
width={PATTERN_GRID_SIZE}
|
||||
height={PATTERN_GRID_SIZE / h}
|
||||
y={-minY / h}
|
||||
>
|
||||
<rect
|
||||
width={PATTERN_DOT_SIZE}
|
||||
height={PATTERN_DOT_SIZE}
|
||||
fill={CONTROL_COLOR}
|
||||
opacity={0.3}
|
||||
></rect>
|
||||
</pattern>
|
||||
<rect
|
||||
x={0}
|
||||
y={0}
|
||||
width="1"
|
||||
height={1}
|
||||
fill="url(#dot-background-pattern-1)"
|
||||
/>
|
||||
{/* Fills the whole vertical extremum space, gray dot pattern */}
|
||||
<pattern
|
||||
id="dot-background-pattern-2"
|
||||
width={PATTERN_GRID_SIZE}
|
||||
height={PATTERN_GRID_SIZE}
|
||||
>
|
||||
<rect
|
||||
width={PATTERN_DOT_SIZE}
|
||||
height={PATTERN_DOT_SIZE}
|
||||
fill={CONTROL_COLOR}
|
||||
></rect>
|
||||
</pattern>
|
||||
<rect
|
||||
x={0}
|
||||
y={toExtremumSpace(0)}
|
||||
width="1"
|
||||
height={toExtremumSpace(1) - toExtremumSpace(0)}
|
||||
fill="url(#dot-background-pattern-2)"
|
||||
/>
|
||||
|
||||
{/* Line from right end of curve to right handle */}
|
||||
<line
|
||||
x1={0}
|
||||
y1={toExtremumSpace(1)}
|
||||
x2={cur.handles[2]}
|
||||
y2={toExtremumSpace(1 - cur.handles[3])}
|
||||
stroke={CONTROL_COLOR}
|
||||
strokeWidth="0.01"
|
||||
/>
|
||||
{/* Line from left end of curve to left handle */}
|
||||
<line
|
||||
x1={1}
|
||||
y1={toExtremumSpace(0)}
|
||||
x2={next.handles[0]}
|
||||
y2={toExtremumSpace(1 - next.handles[1])}
|
||||
stroke={CONTROL_COLOR}
|
||||
strokeWidth="0.01"
|
||||
/>
|
||||
|
||||
{/* Curve "shadow": the low-opacity filled area between the curve and the diagonal */}
|
||||
<path
|
||||
d={curvePathDAttrValue}
|
||||
stroke="none"
|
||||
fill="url('#myGradient')"
|
||||
opacity="0.1"
|
||||
/>
|
||||
{/* The curve */}
|
||||
<path
|
||||
d={curvePathDAttrValue}
|
||||
stroke="url('#myGradient')"
|
||||
strokeWidth="0.02"
|
||||
/>
|
||||
|
||||
{/* Right end of curve */}
|
||||
<circle
|
||||
cx={0}
|
||||
cy={toExtremumSpace(1)}
|
||||
r="0.025"
|
||||
stroke={CURVE_START_COLOR}
|
||||
strokeWidth="0.02"
|
||||
fill={COLOR_BASE}
|
||||
/>
|
||||
{/* Left end of curve */}
|
||||
<circle
|
||||
cx={1}
|
||||
cy={toExtremumSpace(0)}
|
||||
r="0.025"
|
||||
stroke={CURVE_END_COLOR}
|
||||
strokeWidth="0.02"
|
||||
fill={COLOR_BASE}
|
||||
/>
|
||||
|
||||
{/* Right handle and hit zone */}
|
||||
<HitZone
|
||||
ref={refLeft}
|
||||
cx={cur.handles[2]}
|
||||
cy={toExtremumSpace(1 - cur.handles[3])}
|
||||
fill={CURVE_START_COLOR}
|
||||
opacity={0.2}
|
||||
/>
|
||||
<Circle cx={cur.handles[2]} cy={toExtremumSpace(1 - cur.handles[3])} />
|
||||
{/* Left handle and hit zone */}
|
||||
<HitZone
|
||||
ref={refRight}
|
||||
cx={next.handles[0]}
|
||||
cy={toExtremumSpace(1 - next.handles[1])}
|
||||
fill={CURVE_END_COLOR}
|
||||
opacity={0.2}
|
||||
/>
|
||||
<Circle cx={next.handles[0]} cy={toExtremumSpace(1 - next.handles[1])} />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
export default CurveSegmentEditor
|
||||
|
||||
function useKeyframeDrag(
|
||||
svgNode: SVGSVGElement | null,
|
||||
node: SVGCircleElement | null,
|
||||
props: IProps,
|
||||
setHandles: (dx: number, dy: number) => CubicBezierHandles,
|
||||
): void {
|
||||
const handlers = useFreezableMemo<Parameters<typeof useDrag>[1]>(
|
||||
(setFrozen) => ({
|
||||
debugName: 'CurveSegmentEditor/useKeyframeDrag',
|
||||
lockCursorTo: 'move',
|
||||
onDragStart() {
|
||||
setFrozen(true)
|
||||
},
|
||||
onDrag(dx, dy) {
|
||||
if (!svgNode) return
|
||||
|
||||
props.onCurveChange(setHandles(dx, dy))
|
||||
},
|
||||
onDragEnd(dragHappened) {
|
||||
setFrozen(false)
|
||||
props.onCancelCurveChange()
|
||||
},
|
||||
}),
|
||||
[svgNode, props.trackData],
|
||||
)
|
||||
|
||||
useDrag(node, handlers)
|
||||
}
|
|
@ -0,0 +1,83 @@
|
|||
import useTooltip from '@theatre/studio/uiComponents/Popover/useTooltip'
|
||||
import React from 'react'
|
||||
import styled, {css} from 'styled-components'
|
||||
import {handlesFromCssCubicBezierArgs} from './shared'
|
||||
import SVGCurveSegment from './SVGCurveSegment'
|
||||
import mergeRefs from 'react-merge-refs'
|
||||
import {COLOR_BASE} from './colors'
|
||||
import BasicPopover from '@theatre/studio/uiComponents/Popover/BasicPopover'
|
||||
|
||||
const Wrapper = styled.div<{isSelected: boolean}>`
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
aspect-ratio: 1;
|
||||
|
||||
transition: background-color 0.15s;
|
||||
background-color: ${COLOR_BASE};
|
||||
border-radius: 2px;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
|
||||
${({isSelected}) =>
|
||||
isSelected &&
|
||||
css`
|
||||
background-color: #383d42;
|
||||
`}
|
||||
|
||||
&:hover {
|
||||
background-color: #31353a;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
background-color: #383d42;
|
||||
}
|
||||
`
|
||||
|
||||
const EasingTooltip = styled(BasicPopover)`
|
||||
padding: 0.5em;
|
||||
color: white;
|
||||
max-width: 240px;
|
||||
pointer-events: none !important;
|
||||
--popover-bg: black;
|
||||
--popover-outer-stroke: transparent;
|
||||
--popover-inner-stroke: transparent;
|
||||
box-shadow: none;
|
||||
`
|
||||
|
||||
type IProps = {
|
||||
easing: {
|
||||
label: string
|
||||
value: string
|
||||
}
|
||||
tooltipPlacement: 'top' | 'bottom'
|
||||
isSelected: boolean
|
||||
} & Parameters<typeof Wrapper>[0]
|
||||
|
||||
const EasingOption: React.FC<IProps> = React.forwardRef((props, ref) => {
|
||||
const [tooltip, tooltipHostRef] = useTooltip(
|
||||
{enabled: true, verticalPlacement: props.tooltipPlacement, verticalGap: 0},
|
||||
() => (
|
||||
<EasingTooltip showPopoverEdgeTriangle={false}>
|
||||
{props.easing.label}
|
||||
</EasingTooltip>
|
||||
),
|
||||
)
|
||||
|
||||
return (
|
||||
<Wrapper ref={mergeRefs([tooltipHostRef, ref])} {...props}>
|
||||
{tooltip}
|
||||
<SVGCurveSegment
|
||||
easing={handlesFromCssCubicBezierArgs(props.easing.value)}
|
||||
isSelected={props.isSelected}
|
||||
/>
|
||||
{/* In the past we used `dangerouslySetInnerHTML={{ _html: fuzzySort.highlight(presetSearchResults[index])}}`
|
||||
to display the name of the easing option, including an underline for the parts of it matching the search
|
||||
query. */}
|
||||
</Wrapper>
|
||||
)
|
||||
})
|
||||
|
||||
export default EasingOption
|
|
@ -0,0 +1,104 @@
|
|||
import React from 'react'
|
||||
import type {CubicBezierHandles} from './shared'
|
||||
|
||||
const VIEWBOX_PADDING = 0.75
|
||||
const SVG_CIRCLE_RADIUS = 0.1
|
||||
const VIEWBOX_SIZE = 1 + VIEWBOX_PADDING * 2
|
||||
|
||||
const SELECTED_CURVE_COLOR = '#F5F5F5'
|
||||
const CURVE_COLOR = '#888888'
|
||||
const CONTROL_COLOR = '#4f4f4f'
|
||||
const CONTROL_HITZONE_COLOR = 'rgba(255, 255, 255, 0.1)'
|
||||
|
||||
// SVG's y coordinates go from top to bottom, e.g. 1 is vertically lower than 0,
|
||||
// but easing points go from bottom to top.
|
||||
const toVerticalSVGSpace = (y: number) => 1 - y
|
||||
|
||||
type IProps = {
|
||||
easing: CubicBezierHandles | null
|
||||
isSelected: boolean
|
||||
}
|
||||
|
||||
const SVGCurveSegment: React.FC<IProps> = (props) => {
|
||||
const {easing, isSelected} = props
|
||||
|
||||
if (!easing) return <></>
|
||||
|
||||
const curveColor = isSelected ? SELECTED_CURVE_COLOR : CURVE_COLOR
|
||||
|
||||
const leftControlPoint = [easing[0], toVerticalSVGSpace(easing[1])]
|
||||
const rightControlPoint = [easing[2], toVerticalSVGSpace(easing[3])]
|
||||
|
||||
// With a padding of 0, this results in a "unit viewbox" i.e. `0 0 1 1`.
|
||||
// With padding e.g. VIEWBOX_PADDING=0.1, this results in a viewbox of `-0.1 -0,1 1.2 1.2`,
|
||||
// i.e. a viewbox with a top left coordinate of -0.1,-0.1 and a width and height of 1.2,
|
||||
// resulting in bottom right coordinate of 1.1,1.1
|
||||
const SVG_VIEWBOX_ATTR = `${-VIEWBOX_PADDING} ${-VIEWBOX_PADDING} ${VIEWBOX_SIZE} ${VIEWBOX_SIZE}`
|
||||
|
||||
return (
|
||||
<svg
|
||||
height="100%"
|
||||
width="100%"
|
||||
viewBox={SVG_VIEWBOX_ATTR}
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
{/* Control lines */}
|
||||
<line
|
||||
x1="0"
|
||||
y1="1"
|
||||
x2={leftControlPoint[0]}
|
||||
y2={leftControlPoint[1]}
|
||||
stroke={CONTROL_COLOR}
|
||||
strokeWidth="0.1"
|
||||
/>
|
||||
<line
|
||||
x1="1"
|
||||
y1="0"
|
||||
x2={rightControlPoint[0]}
|
||||
y2={rightControlPoint[1]}
|
||||
stroke={CONTROL_COLOR}
|
||||
strokeWidth="0.1"
|
||||
/>
|
||||
|
||||
{/* Control point hitzonecircles */}
|
||||
<circle
|
||||
cx={leftControlPoint[0]}
|
||||
cy={leftControlPoint[1]}
|
||||
r={0.1}
|
||||
fill={CONTROL_HITZONE_COLOR}
|
||||
/>
|
||||
<circle
|
||||
cx={rightControlPoint[0]}
|
||||
cy={rightControlPoint[1]}
|
||||
r={0.1}
|
||||
fill={CONTROL_HITZONE_COLOR}
|
||||
/>
|
||||
|
||||
{/* Control point circles */}
|
||||
<circle
|
||||
cx={leftControlPoint[0]}
|
||||
cy={leftControlPoint[1]}
|
||||
r={SVG_CIRCLE_RADIUS}
|
||||
fill={CONTROL_COLOR}
|
||||
/>
|
||||
<circle
|
||||
cx={rightControlPoint[0]}
|
||||
cy={rightControlPoint[1]}
|
||||
r={SVG_CIRCLE_RADIUS}
|
||||
fill={CONTROL_COLOR}
|
||||
/>
|
||||
|
||||
{/* Bezier curve */}
|
||||
<path
|
||||
d={`M0 1 C${leftControlPoint[0]} ${leftControlPoint[1]} ${rightControlPoint[0]}
|
||||
${rightControlPoint[1]} 1 0`}
|
||||
stroke={curveColor}
|
||||
strokeWidth="0.08"
|
||||
/>
|
||||
<circle cx={0} cy={1} r={SVG_CIRCLE_RADIUS} fill={curveColor} />
|
||||
<circle cx={1} cy={0} r={SVG_CIRCLE_RADIUS} fill={curveColor} />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
export default SVGCurveSegment
|
|
@ -0,0 +1,5 @@
|
|||
export const COLOR_POPOVER_BACK = 'rgba(26, 28, 30, 0.97);'
|
||||
|
||||
export const COLOR_BASE = '#272B2F'
|
||||
|
||||
export const COLOR_FOCUS_OUTLINE = '#0A4540'
|
|
@ -0,0 +1,125 @@
|
|||
/**
|
||||
* This 4-tuple defines the start control point x1,y1 and the end control point x2,y2
|
||||
* of a cubic bezier curve. It is assumed that the start of the curve is fixed at 0,0
|
||||
* and the end is fixed at 1,1. X values must be constrained to `0 <= x1 <= 1 and 0 <= x2 <= 1`.
|
||||
*
|
||||
* to get a feel for it: https://cubic-bezier.com/
|
||||
**/
|
||||
export type CubicBezierHandles = [
|
||||
x1: number,
|
||||
y1: number,
|
||||
x2: number,
|
||||
y2: number,
|
||||
]
|
||||
|
||||
/**
|
||||
* A full CSS cubic bezier string looks like `cubic-bezier(0, 0, 1, 1)`.
|
||||
* the "args" part of the name refers specifically to the comma separated substring
|
||||
* inside the parentheses of the CSS cubic bezier string i.e. `0, 0, 1, 1`.
|
||||
*/
|
||||
export type CSSCubicBezierArgsString = string
|
||||
|
||||
const CSS_BEZIER_ARGS_DECIMAL_POINTS = 3 // Doesn't have to be 3, but it matches our preset data
|
||||
export function cssCubicBezierArgsFromHandles(
|
||||
points: CubicBezierHandles,
|
||||
): CSSCubicBezierArgsString {
|
||||
return points.map((p) => p.toFixed(CSS_BEZIER_ARGS_DECIMAL_POINTS)).join(', ')
|
||||
}
|
||||
|
||||
const MAX_REASONABLE_BEZIER_STRING = 128
|
||||
export function handlesFromCssCubicBezierArgs(
|
||||
str: CSSCubicBezierArgsString | undefined | null,
|
||||
): null | CubicBezierHandles {
|
||||
if (!str || str?.length > MAX_REASONABLE_BEZIER_STRING) return null
|
||||
const args = str.split(',')
|
||||
if (args.length !== 4) return null
|
||||
const nums = args.map((arg) => {
|
||||
return Number(arg.trim())
|
||||
})
|
||||
|
||||
if (!nums.every((v) => isFinite(v))) return null
|
||||
|
||||
if (nums[0] < 0 || nums[0] > 1 || nums[2] < 0 || nums[2] > 1) return null
|
||||
return nums as CubicBezierHandles
|
||||
}
|
||||
|
||||
/**
|
||||
* A collection of cubic-bezier approximations of common easing functions
|
||||
* - ref: https://developer.mozilla.org/en-US/docs/Web/CSS/easing-function
|
||||
* - ref: [GitHub issue 28 comment "michaeltheory's suggested default easing presets"](https://github.com/theatre-js/theatre/issues/28#issuecomment-938752916)
|
||||
**/
|
||||
export const EASING_PRESETS = [
|
||||
{label: 'Quad Out', value: '0.250, 0.460, 0.450, 0.940'},
|
||||
{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: 'Cubic Out', value: '0.215, 0.610, 0.355, 1.000'},
|
||||
{label: 'Cubic In Out', value: '0.645, 0.045, 0.355, 1.000'},
|
||||
{label: 'Cubic In', value: '0.550, 0.055, 0.675, 0.190'},
|
||||
|
||||
{label: 'Quart Out', value: '0.165, 0.840, 0.440, 1.000'},
|
||||
{label: 'Quart In Out', value: '0.770, 0.000, 0.175, 1.000'},
|
||||
{label: 'Quart In', value: '0.895, 0.030, 0.685, 0.220'},
|
||||
|
||||
{label: 'Quint Out', value: '0.230, 1.000, 0.320, 1.000'},
|
||||
{label: 'Quint In Out', value: '0.860, 0.000, 0.070, 1.000'},
|
||||
{label: 'Quint In', value: '0.755, 0.050, 0.855, 0.060'},
|
||||
|
||||
{label: 'Sine Out', value: '0.390, 0.575, 0.565, 1.000'},
|
||||
{label: 'Sine In Out', value: '0.445, 0.050, 0.550, 0.950'},
|
||||
{label: 'Sine In', value: '0.470, 0.000, 0.745, 0.715'},
|
||||
|
||||
{label: 'Expo Out', value: '0.190, 1.000, 0.220, 1.000'},
|
||||
{label: 'Expo In Out', value: '1.000, 0.000, 0.000, 1.000'},
|
||||
{label: 'Expo In', value: '0.780, 0.000, 0.810, 0.00'},
|
||||
|
||||
{label: 'Circ Out', value: '0.075, 0.820, 0.165, 1.000'},
|
||||
{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: 'Back Out', value: '0.175, 0.885, 0.320, 1.275'},
|
||||
{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: 'linear', value: '0.5, 0.5, 0.5, 0.5'},
|
||||
{label: 'In Out', value: '0.42,0,0.58,1'},
|
||||
|
||||
/* These easings are not being included initially in order to
|
||||
simplify the choices */
|
||||
// {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: '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: 'Ease Out In', value: '.42, 0, .58, 1'},
|
||||
]
|
||||
|
||||
/**
|
||||
* Compares two easings and returns true iff they are similar up to a threshold
|
||||
*
|
||||
* @param easing1 - first easing to compare
|
||||
* @param easing2 - second easing to compare
|
||||
* @param options - optionally pass an object with a threshold that determines how similar the easings should be
|
||||
* @returns boolean if the easings are similar
|
||||
*/
|
||||
export function areEasingsSimilar(
|
||||
easing1: CubicBezierHandles | null | undefined,
|
||||
easing2: CubicBezierHandles | null | undefined,
|
||||
options: {
|
||||
threshold: number
|
||||
} = {threshold: 0.02},
|
||||
) {
|
||||
if (!easing1 || !easing2) return false
|
||||
let totalDiff = 0
|
||||
for (let i = 0; i < 4; i++) {
|
||||
totalDiff += Math.abs(easing1[i] - easing2[i])
|
||||
}
|
||||
return totalDiff < options.threshold
|
||||
}
|
|
@ -0,0 +1,21 @@
|
|||
import {useMemo, useRef, useState} from 'react'
|
||||
|
||||
/**
|
||||
* The same as useMemo except that it can be frozen so that
|
||||
* the memoized function is not recomputed even if the dependencies
|
||||
* change. It can also be unfrozen.
|
||||
*
|
||||
* An unfrozen useFreezableMemo is the same as useMemo.
|
||||
*
|
||||
*/
|
||||
export function useFreezableMemo<T>(
|
||||
fn: (setFreeze: (isFrozen: boolean) => void) => T,
|
||||
deps: any[],
|
||||
): T {
|
||||
const [isFrozen, setFreeze] = useState<boolean>(false)
|
||||
const freezableDeps = useRef(deps)
|
||||
|
||||
if (!isFrozen) freezableDeps.current = deps
|
||||
|
||||
return useMemo(() => fn(setFreeze), freezableDeps.current)
|
||||
}
|
|
@ -0,0 +1,109 @@
|
|||
import type {KeyboardEvent} from 'react'
|
||||
import type React from 'react'
|
||||
import {useState} from 'react'
|
||||
import {mod} from './CurveEditorPopover'
|
||||
|
||||
export enum Outcome {
|
||||
Handled = 1,
|
||||
Passthrough = 0,
|
||||
}
|
||||
type UIOptionGridOptions<Item> = {
|
||||
/** affect behavior of keyboard navigation */
|
||||
uiColumns: number
|
||||
/** each item in the grid */
|
||||
items: Item[]
|
||||
/** display of items */
|
||||
renderItem: (value: {
|
||||
select(): void
|
||||
/** data item */
|
||||
item: Item
|
||||
/** arrow key nav */
|
||||
isSelected: boolean
|
||||
}) => React.ReactNode
|
||||
onSelectItem(item: Item): void
|
||||
/** Set a callback for what to do if we try to leave the grid */
|
||||
canVerticleExit?: (exitSide: 'top' | 'bottom') => Outcome
|
||||
}
|
||||
type UIOptionGrid<Item> = {
|
||||
focusFirstItem(): void
|
||||
onParentEltKeyDown(evt: KeyboardEvent): Outcome
|
||||
gridItems: React.ReactNode[]
|
||||
currentSelection: Item | null
|
||||
}
|
||||
export function useUIOptionGrid<T>(
|
||||
options: UIOptionGridOptions<T>,
|
||||
): UIOptionGrid<T> {
|
||||
// Helper functions for moving the highlight in the grid of presets
|
||||
const [selectionIndex, setSelectionIndex] = useState<number | null>(null)
|
||||
const moveCursorVertical = (vdir: number) => {
|
||||
if (selectionIndex === null) {
|
||||
if (options.items.length > 0) {
|
||||
// start at the top first one
|
||||
setSelectionIndex(0)
|
||||
} else {
|
||||
// no items
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const nextSelectionIndex = selectionIndex + vdir * options.uiColumns
|
||||
const exitsTop = nextSelectionIndex < 0
|
||||
const exitsBottom = nextSelectionIndex > options.items.length - 1
|
||||
if (exitsTop || exitsBottom) {
|
||||
// up and out
|
||||
if (options.canVerticleExit) {
|
||||
if (options.canVerticleExit(exitsTop ? 'top' : 'bottom')) {
|
||||
// exited and handled
|
||||
setSelectionIndex(null)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// block the cursor from leaving (don't do anything)
|
||||
return
|
||||
}
|
||||
|
||||
// we know this highlight is in bounds now
|
||||
setSelectionIndex(nextSelectionIndex)
|
||||
}
|
||||
const moveCursorHorizontal = (hdir: number) => {
|
||||
if (selectionIndex === null)
|
||||
setSelectionIndex(mod(hdir, options.items.length))
|
||||
else if (selectionIndex + hdir < 0) {
|
||||
// Don't exit top on potentially a left arrow, bc that might feel like I should be able to exit right on right arrow.
|
||||
// Also, maybe cursor selection management in inputs is *lame*.
|
||||
setSelectionIndex(null)
|
||||
} else
|
||||
setSelectionIndex(
|
||||
Math.min(selectionIndex + hdir, options.items.length - 1),
|
||||
)
|
||||
}
|
||||
|
||||
const onParentKeydown = (e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'ArrowRight') moveCursorHorizontal(1)
|
||||
else if (e.key === 'ArrowLeft') moveCursorHorizontal(-1)
|
||||
else if (e.key === 'ArrowUp') moveCursorVertical(-1)
|
||||
else if (e.key === 'ArrowDown') moveCursorVertical(1)
|
||||
else return Outcome.Passthrough // so sorry, plz make this not terrible
|
||||
return Outcome.Handled
|
||||
}
|
||||
|
||||
return {
|
||||
focusFirstItem() {
|
||||
setSelectionIndex(0)
|
||||
},
|
||||
onParentEltKeyDown: onParentKeydown,
|
||||
gridItems: options.items.map((item, idx) =>
|
||||
options.renderItem({
|
||||
isSelected: idx === selectionIndex,
|
||||
item,
|
||||
select() {
|
||||
setSelectionIndex(idx)
|
||||
options.onSelectItem(item)
|
||||
},
|
||||
}),
|
||||
),
|
||||
currentSelection: options.items[selectionIndex ?? -1] ?? null,
|
||||
}
|
||||
}
|
|
@ -21,9 +21,10 @@ import {
|
|||
import SnapCursor from './SnapCursor.svg'
|
||||
import selectedKeyframeIdsIfInSingleTrack from '@theatre/studio/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/selectedKeyframeIdsIfInSingleTrack'
|
||||
|
||||
export const dotSize = 6
|
||||
const hitZoneSize = 12
|
||||
const snapCursorSize = 34
|
||||
export const DOT_SIZE_PX = 6
|
||||
const HIT_ZONE_SIZE_PX = 12
|
||||
const SNAP_CURSOR_SIZE_PX = 34
|
||||
const DOT_HOVER_SIZE_PX = DOT_SIZE_PX + 5
|
||||
|
||||
const dims = (size: number) => `
|
||||
left: ${-size / 2}px;
|
||||
|
@ -47,12 +48,12 @@ const Square = styled.div<{isSelected: boolean}>`
|
|||
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
${(props) => dims(props.isSelected ? dotSize : dotSize)}
|
||||
${dims(DOT_SIZE_PX)}
|
||||
`
|
||||
|
||||
const HitZone = styled.div`
|
||||
position: absolute;
|
||||
${dims(hitZoneSize)};
|
||||
${dims(HIT_ZONE_SIZE_PX)};
|
||||
|
||||
z-index: 1;
|
||||
|
||||
|
@ -64,10 +65,10 @@ const HitZone = styled.div`
|
|||
|
||||
&:hover:after {
|
||||
position: absolute;
|
||||
top: calc(50% - ${snapCursorSize / 2}px);
|
||||
left: calc(50% - ${snapCursorSize / 2}px);
|
||||
width: ${snapCursorSize}px;
|
||||
height: ${snapCursorSize}px;
|
||||
top: calc(50% - ${SNAP_CURSOR_SIZE_PX / 2}px);
|
||||
left: calc(50% - ${SNAP_CURSOR_SIZE_PX / 2}px);
|
||||
width: ${SNAP_CURSOR_SIZE_PX}px;
|
||||
height: ${SNAP_CURSOR_SIZE_PX}px;
|
||||
display: block;
|
||||
content: ' ';
|
||||
background: url(${SnapCursor}) no-repeat 100% 100%;
|
||||
|
@ -80,7 +81,7 @@ const HitZone = styled.div`
|
|||
}
|
||||
|
||||
&:hover + ${Square}, &.beingDragged + ${Square} {
|
||||
${dims(dotSize + 5)}
|
||||
${dims(DOT_HOVER_SIZE_PX)}
|
||||
}
|
||||
`
|
||||
|
||||
|
@ -143,13 +144,13 @@ function useDragKeyframe(
|
|||
let toUnitSpace: SequenceEditorPanelLayout['scaledSpace']['toUnitSpace']
|
||||
let tempTransaction: CommitOrDiscard | undefined
|
||||
let propsAtStartOfDrag: IProps
|
||||
let startingLayout: SequenceEditorPanelLayout
|
||||
|
||||
let selectionDragHandlers:
|
||||
| ReturnType<DopeSheetSelection['getDragHandlers']>
|
||||
| undefined
|
||||
|
||||
return {
|
||||
debugName: 'Dot/useDragKeyframe',
|
||||
onDragStart(event) {
|
||||
setIsDragging(true)
|
||||
const props = propsRef.current
|
||||
|
|
|
@ -57,6 +57,7 @@ function useCaptureSelection(
|
|||
containerNode,
|
||||
useMemo((): Parameters<typeof useDrag>[1] => {
|
||||
return {
|
||||
debugName: 'DopeSheetSelectionView/useCaptureSelection',
|
||||
dontBlockMouseDown: true,
|
||||
lockCursorTo: 'cell',
|
||||
onDragStart(event) {
|
||||
|
@ -202,6 +203,7 @@ namespace utils {
|
|||
|
||||
let toUnitSpace: SequenceEditorPanelLayout['scaledSpace']['toUnitSpace']
|
||||
return {
|
||||
debugName: 'DopeSheetSelectionView/boundsToSelection',
|
||||
onDragStart() {
|
||||
toUnitSpace = val(layoutP.scaledSpace.toUnitSpace)
|
||||
},
|
||||
|
|
|
@ -80,7 +80,8 @@ function useDragHandlers(
|
|||
const setIsSeeking = val(layoutP.seeker.setIsSeeking)
|
||||
|
||||
return {
|
||||
onDrag(dx, _, event) {
|
||||
debugName: 'HorizontallyScrollableArea',
|
||||
onDrag(dx: number, _, event) {
|
||||
const deltaPos = scaledSpaceToUnitSpace(dx)
|
||||
const unsnappedPos = clamp(posBeforeSeek + deltaPos, 0, sequence.length)
|
||||
|
||||
|
|
|
@ -227,6 +227,7 @@ function useDragBulge(
|
|||
let initialLength: number
|
||||
|
||||
return {
|
||||
debugName: 'LengthIndicator/useDragBulge',
|
||||
lockCursorTo: 'ew-resize',
|
||||
onDragStart(event) {
|
||||
setIsDragging(true)
|
||||
|
|
|
@ -131,6 +131,7 @@ function useOurDrags(node: SVGCircleElement | null, props: IProps): void {
|
|||
let tempTransaction: CommitOrDiscard | undefined
|
||||
let unlockExtremums: VoidFn | undefined
|
||||
return {
|
||||
debugName: 'CurveHandler/useOurDrags',
|
||||
lockCursorTo: 'move',
|
||||
onDragStart() {
|
||||
propsAtStartOfDrag = propsRef.current
|
||||
|
|
|
@ -115,6 +115,7 @@ function useDragKeyframe(
|
|||
let unlockExtremums: VoidFn | undefined
|
||||
|
||||
return {
|
||||
debugName: 'GraphEditorDotNonScalar/useDragKeyframe',
|
||||
lockCursorTo: 'ew-resize',
|
||||
onDragStart(event) {
|
||||
setIsDragging(true)
|
||||
|
|
|
@ -118,6 +118,7 @@ function useDragKeyframe(
|
|||
let keepSpeeds = false
|
||||
|
||||
return {
|
||||
debugName: 'GraphEditorDotScalar/useDragKeyframe',
|
||||
lockCursorTo: 'move',
|
||||
onDragStart(event) {
|
||||
setIsDragging(true)
|
||||
|
|
|
@ -190,6 +190,7 @@ const FocusRangeStrip: React.FC<{
|
|||
let newStartPosition: number, newEndPosition: number
|
||||
|
||||
return {
|
||||
debugName: 'FocusRangeStrip',
|
||||
onDragStart(event) {
|
||||
existingRange = existingRangeD.getValue()
|
||||
|
||||
|
|
|
@ -181,6 +181,7 @@ const FocusRangeThumb: React.FC<{
|
|||
let scaledSpaceToUnitSpace: (s: number) => number
|
||||
|
||||
return {
|
||||
debugName: 'FocusRangeThumb',
|
||||
onDragStart() {
|
||||
sheet = val(layoutP.sheet)
|
||||
const sequence = sheet.getSequence()
|
||||
|
|
|
@ -112,6 +112,7 @@ function usePanelDragZoneGestureHandlers(
|
|||
let minFocusRangeStripWidth: number
|
||||
|
||||
return {
|
||||
debugName: 'FocusRangeZone/focusRangeCreationGestureHandlers',
|
||||
onDragStart(event) {
|
||||
clippedSpaceToUnitSpace = val(layoutP.clippedSpace.toUnitSpace)
|
||||
scaledSpaceToUnitSpace = val(layoutP.scaledSpace.toUnitSpace)
|
||||
|
@ -181,6 +182,7 @@ function usePanelDragZoneGestureHandlers(
|
|||
let tempTransaction: CommitOrDiscard | undefined
|
||||
let unlock: VoidFn | undefined
|
||||
return {
|
||||
debugName: 'FocusRangeZone/panelMoveGestureHandlers',
|
||||
onDragStart() {
|
||||
stuffBeforeDrag = panelStuffRef.current
|
||||
if (unlock) {
|
||||
|
@ -229,6 +231,7 @@ function usePanelDragZoneGestureHandlers(
|
|||
let currentGestureHandlers: undefined | Parameters<typeof useDrag>[1]
|
||||
|
||||
return {
|
||||
debugName: 'FocusRangeZone',
|
||||
onDragStart(event) {
|
||||
if (event.shiftKey) {
|
||||
setMode('creating')
|
||||
|
|
|
@ -206,6 +206,7 @@ const Playhead: React.FC<{layoutP: Pointer<SequenceEditorPanelLayout>}> = ({
|
|||
let scaledSpaceToUnitSpace: typeof layoutP.scaledSpace.toUnitSpace.$$__pointer_type
|
||||
|
||||
return {
|
||||
debugName: 'Playhead',
|
||||
onDragStart() {
|
||||
sequence = val(layoutP.sheet).getSequence()
|
||||
posBeforeSeek = sequence.position
|
||||
|
|
|
@ -31,11 +31,21 @@ const Container = styled.div`
|
|||
}
|
||||
`
|
||||
|
||||
const BasicPopover: React.FC<{className?: string}> = React.forwardRef(
|
||||
({children, className}, ref) => {
|
||||
const BasicPopover: React.FC<{
|
||||
className?: string
|
||||
showPopoverEdgeTriangle?: boolean
|
||||
}> = React.forwardRef(
|
||||
(
|
||||
{
|
||||
children,
|
||||
className,
|
||||
showPopoverEdgeTriangle: showPopoverEdgeTriangle = true,
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
return (
|
||||
<Container className={className} ref={ref as $IntentionalAny}>
|
||||
<PopoverArrow />
|
||||
{showPopoverEdgeTriangle ? <PopoverArrow /> : undefined}
|
||||
{children}
|
||||
</Container>
|
||||
)
|
||||
|
|
|
@ -7,9 +7,17 @@ import useRefAndState from '@theatre/studio/utils/useRefAndState'
|
|||
import useOnClickOutside from '@theatre/studio/uiComponents/useOnClickOutside'
|
||||
import onPointerOutside from '@theatre/studio/uiComponents/onPointerOutside'
|
||||
import noop from '@theatre/shared/utils/noop'
|
||||
import {clamp} from 'lodash-es'
|
||||
|
||||
const minimumDistanceOfArrowToEdgeOfPopover = 8
|
||||
|
||||
export type AbsolutePlacementBoxConstraints = {
|
||||
minX?: number
|
||||
maxX?: number
|
||||
minY?: number
|
||||
maxY?: number
|
||||
}
|
||||
|
||||
const TooltipWrapper: React.FC<{
|
||||
target: HTMLElement | SVGElement
|
||||
onClickOutside?: (e: MouseEvent) => void
|
||||
|
@ -18,6 +26,9 @@ const TooltipWrapper: React.FC<{
|
|||
threshold: number
|
||||
callback: (e: MouseEvent) => void
|
||||
}
|
||||
verticalPlacement?: 'top' | 'bottom' | 'overlay'
|
||||
verticalGap?: number // Has no effect if verticalPlacement === 'overlay'
|
||||
constraints?: AbsolutePlacementBoxConstraints
|
||||
}> = (props) => {
|
||||
const originalElement = props.children()
|
||||
const [ref, container] = useRefAndState<HTMLElement | SVGElement | null>(null)
|
||||
|
@ -36,23 +47,42 @@ const TooltipWrapper: React.FC<{
|
|||
useLayoutEffect(() => {
|
||||
if (!containerRect || !container || !targetRect) return
|
||||
|
||||
const gap = 8
|
||||
const gap = props.verticalGap ?? 8
|
||||
const arrowStyle: Record<string, string> = {}
|
||||
|
||||
let verticalPlacement: 'bottom' | 'top' | 'overlay' = 'bottom'
|
||||
let verticalPlacement: 'bottom' | 'top' | 'overlay' =
|
||||
props.verticalPlacement ?? 'bottom'
|
||||
let top = 0
|
||||
let left = 0
|
||||
if (targetRect.bottom + containerRect.height + gap < windowSize.height) {
|
||||
verticalPlacement = 'bottom'
|
||||
top = targetRect.bottom + gap
|
||||
arrowStyle.top = '0px'
|
||||
} else if (targetRect.top > containerRect.height + gap) {
|
||||
verticalPlacement = 'top'
|
||||
top = targetRect.top - (containerRect.height + gap)
|
||||
arrowStyle.bottom = '0px'
|
||||
arrowStyle.transform = 'rotateZ(180deg)'
|
||||
} else {
|
||||
verticalPlacement = 'overlay'
|
||||
if (verticalPlacement === 'bottom') {
|
||||
if (targetRect.bottom + containerRect.height + gap < windowSize.height) {
|
||||
verticalPlacement = 'bottom'
|
||||
top = targetRect.bottom + gap
|
||||
arrowStyle.top = '0px'
|
||||
} else if (targetRect.top > containerRect.height + gap) {
|
||||
verticalPlacement = 'top'
|
||||
top = targetRect.top - (containerRect.height + gap)
|
||||
arrowStyle.bottom = '0px'
|
||||
arrowStyle.transform = 'rotateZ(180deg)'
|
||||
} else {
|
||||
verticalPlacement = 'overlay'
|
||||
}
|
||||
} else if (verticalPlacement === 'top') {
|
||||
if (targetRect.top > containerRect.height + gap) {
|
||||
verticalPlacement = 'top'
|
||||
top = targetRect.top - (containerRect.height + gap)
|
||||
arrowStyle.bottom = '0px'
|
||||
arrowStyle.transform = 'rotateZ(180deg)'
|
||||
} else if (
|
||||
targetRect.bottom + containerRect.height + gap <
|
||||
windowSize.height
|
||||
) {
|
||||
verticalPlacement = 'bottom'
|
||||
top = targetRect.bottom + gap
|
||||
arrowStyle.top = '0px'
|
||||
} else {
|
||||
verticalPlacement = 'overlay'
|
||||
}
|
||||
}
|
||||
|
||||
let arrowLeft = 0
|
||||
|
@ -77,7 +107,16 @@ const TooltipWrapper: React.FC<{
|
|||
arrowStyle.left = arrowLeft + 'px'
|
||||
}
|
||||
|
||||
const pos = {left, top}
|
||||
const {
|
||||
minX = -Infinity,
|
||||
maxX = Infinity,
|
||||
minY = -Infinity,
|
||||
maxY = Infinity,
|
||||
} = props.constraints ?? {}
|
||||
const pos = {
|
||||
left: clamp(left, minX, maxX - containerRect.width),
|
||||
top: clamp(top, minY, maxY + containerRect.height),
|
||||
}
|
||||
|
||||
container.style.left = pos.left + 'px'
|
||||
container.style.top = pos.top + 'px'
|
||||
|
@ -90,7 +129,14 @@ const TooltipWrapper: React.FC<{
|
|||
props.onPointerOutside.callback,
|
||||
)
|
||||
}
|
||||
}, [containerRect, container, props.target, targetRect, windowSize])
|
||||
}, [
|
||||
containerRect,
|
||||
container,
|
||||
props.target,
|
||||
targetRect,
|
||||
windowSize,
|
||||
props.onPointerOutside,
|
||||
])
|
||||
|
||||
useOnClickOutside(container, props.onClickOutside ?? noop)
|
||||
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import React, {useCallback, useContext, useMemo, useState} from 'react'
|
||||
import {createPortal} from 'react-dom'
|
||||
import {PortalContext} from 'reakit'
|
||||
import type {AbsolutePlacementBoxConstraints} from './TooltipWrapper';
|
||||
import TooltipWrapper from './TooltipWrapper'
|
||||
|
||||
export type OpenFn = (e: React.MouseEvent, target: HTMLElement) => void
|
||||
|
@ -21,6 +22,7 @@ export default function usePopover(
|
|||
closeWhenPointerIsDistant?: boolean
|
||||
pointerDistanceThreshold?: number
|
||||
closeOnClickOutside?: boolean
|
||||
constraints?: AbsolutePlacementBoxConstraints
|
||||
},
|
||||
render: () => React.ReactElement,
|
||||
): [node: React.ReactNode, open: OpenFn, close: CloseFn, isOpen: boolean] {
|
||||
|
@ -62,6 +64,7 @@ export default function usePopover(
|
|||
target={state.target}
|
||||
onClickOutside={onClickOutside}
|
||||
onPointerOutside={onPointerOutside}
|
||||
constraints={opts.constraints}
|
||||
/>,
|
||||
portalLayer!,
|
||||
)
|
||||
|
|
|
@ -10,7 +10,13 @@ import {PortalContext} from 'reakit'
|
|||
import noop from '@theatre/shared/utils/noop'
|
||||
|
||||
export default function useTooltip(
|
||||
opts: {enabled?: boolean; enterDelay?: number; exitDelay?: number},
|
||||
opts: {
|
||||
enabled?: boolean
|
||||
enterDelay?: number
|
||||
exitDelay?: number
|
||||
verticalPlacement?: 'top' | 'bottom' | 'overlay'
|
||||
verticalGap?: number
|
||||
},
|
||||
render: () => React.ReactElement,
|
||||
): [
|
||||
node: React.ReactNode,
|
||||
|
@ -53,6 +59,8 @@ export default function useTooltip(
|
|||
children={render}
|
||||
target={targetNode}
|
||||
onClickOutside={noop}
|
||||
verticalPlacement={opts.verticalPlacement}
|
||||
verticalGap={opts.verticalGap}
|
||||
/>,
|
||||
portalLayer!,
|
||||
)
|
||||
|
|
|
@ -2,8 +2,15 @@ import type {$FixMe} from '@theatre/shared/utils/types'
|
|||
import {useLayoutEffect, useRef} from 'react'
|
||||
import useRefAndState from '@theatre/studio/utils/useRefAndState'
|
||||
import {useCssCursorLock} from './PointerEventsHandler'
|
||||
import type {CapturedPointer} from '@theatre/studio/UIRoot/PointerCapturing'
|
||||
import {usePointerCapturing} from '@theatre/studio/UIRoot/PointerCapturing'
|
||||
|
||||
export type UseDragOpts = {
|
||||
/**
|
||||
* Provide a name for the thing wanting to use the drag helper.
|
||||
* This can show up in various errors and potential debug logs to help narrow down.
|
||||
*/
|
||||
debugName: string
|
||||
/**
|
||||
* Setting it to true will disable the listeners.
|
||||
*/
|
||||
|
@ -19,9 +26,14 @@ export type UseDragOpts = {
|
|||
/**
|
||||
* Called at the start of the gesture. Mind you, that this would be called, even
|
||||
* if the user is just clicking (and not dragging). However, if the gesture turns
|
||||
* out to be a click, then onDragEnd(false) will be called. Otherwise,
|
||||
* out to be a click, then `onDragEnd(false)` will be called. Otherwise,
|
||||
* a series of `onDrag(dx, dy, event)` events will be called, and the
|
||||
* gesture will end with `onDragEnd(true)`.
|
||||
*
|
||||
*
|
||||
* @returns
|
||||
* onDragStart can be undefined, in which case, we always handle useDrag,
|
||||
* but when defined, we can allow the handler to return false to indicate ignore this dragging
|
||||
*/
|
||||
onDragStart?: (event: MouseEvent) => void | false
|
||||
/**
|
||||
|
@ -58,6 +70,8 @@ export default function useDrag(
|
|||
opts.lockCursorTo!,
|
||||
)
|
||||
|
||||
const {capturePointer} = usePointerCapturing(`useDrag for ${opts.debugName}`)
|
||||
|
||||
const stateRef = useRef<{
|
||||
dragHappened: boolean
|
||||
startPos: {
|
||||
|
@ -69,6 +83,7 @@ export default function useDrag(
|
|||
useLayoutEffect(() => {
|
||||
if (!target) return
|
||||
|
||||
let capturedPointer: undefined | CapturedPointer
|
||||
const getDistances = (event: MouseEvent): [number, number] => {
|
||||
const {startPos} = stateRef.current
|
||||
return [event.screenX - startPos.x, event.screenY - startPos.y]
|
||||
|
@ -96,6 +111,7 @@ export default function useDrag(
|
|||
}
|
||||
|
||||
const removeDragListeners = () => {
|
||||
capturedPointer?.release()
|
||||
document.removeEventListener('mousemove', dragHandler)
|
||||
document.removeEventListener('mouseup', dragEndHandler)
|
||||
}
|
||||
|
@ -115,13 +131,23 @@ export default function useDrag(
|
|||
}
|
||||
|
||||
const dragStartHandler = (event: MouseEvent) => {
|
||||
// defensively release
|
||||
capturedPointer?.release()
|
||||
|
||||
const opts = optsRef.current
|
||||
if (opts.disabled === true) return
|
||||
|
||||
if (event.button !== 0) return
|
||||
const resultOfStart = opts.onDragStart && opts.onDragStart(event)
|
||||
|
||||
if (resultOfStart === false) return
|
||||
// onDragStart can be undefined, in which case, we always handle useDrag,
|
||||
// but when defined, we can allow the handler to return false to indicate ignore this dragging
|
||||
if (opts.onDragStart != null) {
|
||||
const shouldIgnore = opts.onDragStart(event) === false
|
||||
if (shouldIgnore) return
|
||||
}
|
||||
|
||||
// need to capture pointer after we know the provided handler wants to handle drag start
|
||||
capturedPointer = capturePointer('Drag start')
|
||||
|
||||
if (!opts.dontBlockMouseDown) {
|
||||
event.stopPropagation()
|
||||
|
|
Loading…
Reference in a new issue