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 useKeyboardShortcuts from './useKeyboardShortcuts'
|
||||||
import PointerEventsHandler from '@theatre/studio/uiComponents/PointerEventsHandler'
|
import PointerEventsHandler from '@theatre/studio/uiComponents/PointerEventsHandler'
|
||||||
import TooltipContext from '@theatre/studio/uiComponents/Popover/TooltipContext'
|
import TooltipContext from '@theatre/studio/uiComponents/Popover/TooltipContext'
|
||||||
|
import {ProvidePointerCapturing} from './PointerCapturing'
|
||||||
|
|
||||||
const GlobalStyle = createGlobalStyle`
|
const GlobalStyle = createGlobalStyle`
|
||||||
:host {
|
:host {
|
||||||
|
@ -67,6 +68,7 @@ export default function UIRoot() {
|
||||||
)
|
)
|
||||||
|
|
||||||
useKeyboardShortcuts()
|
useKeyboardShortcuts()
|
||||||
|
|
||||||
const visiblityState = useVal(studio.atomP.ahistoric.visibilityState)
|
const visiblityState = useVal(studio.atomP.ahistoric.visibilityState)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (visiblityState === 'everythingIsHidden') {
|
if (visiblityState === 'everythingIsHidden') {
|
||||||
|
@ -93,21 +95,23 @@ export default function UIRoot() {
|
||||||
>
|
>
|
||||||
<>
|
<>
|
||||||
<GlobalStyle />
|
<GlobalStyle />
|
||||||
<ProvideTheme>
|
<ProvidePointerCapturing>
|
||||||
<PortalContext.Provider value={portalLayer}>
|
<ProvideTheme>
|
||||||
<TooltipContext>
|
<PortalContext.Provider value={portalLayer}>
|
||||||
<Container
|
<TooltipContext>
|
||||||
className={
|
<Container
|
||||||
visiblityState === 'everythingIsHidden' ? 'invisible' : ''
|
className={
|
||||||
}
|
visiblityState === 'everythingIsHidden' ? 'invisible' : ''
|
||||||
>
|
}
|
||||||
<PortalLayer ref={portalLayerRef} />
|
>
|
||||||
{<GlobalToolbar />}
|
<PortalLayer ref={portalLayerRef} />
|
||||||
{<PanelsRoot />}
|
{<GlobalToolbar />}
|
||||||
</Container>
|
{<PanelsRoot />}
|
||||||
</TooltipContext>
|
</Container>
|
||||||
</PortalContext.Provider>
|
</TooltipContext>
|
||||||
</ProvideTheme>
|
</PortalContext.Provider>
|
||||||
|
</ProvideTheme>
|
||||||
|
</ProvidePointerCapturing>
|
||||||
</>
|
</>
|
||||||
</StyleSheetManager>
|
</StyleSheetManager>
|
||||||
)
|
)
|
||||||
|
|
|
@ -25,6 +25,7 @@ const PanelDragZone: React.FC<
|
||||||
let tempTransaction: CommitOrDiscard | undefined
|
let tempTransaction: CommitOrDiscard | undefined
|
||||||
let unlock: VoidFn | undefined
|
let unlock: VoidFn | undefined
|
||||||
return {
|
return {
|
||||||
|
debugName: 'PanelDragZone',
|
||||||
lockCursorTo: 'move',
|
lockCursorTo: 'move',
|
||||||
onDragStart() {
|
onDragStart() {
|
||||||
stuffBeforeDrag = panelStuffRef.current
|
stuffBeforeDrag = panelStuffRef.current
|
||||||
|
|
|
@ -151,6 +151,7 @@ const PanelResizeHandle: React.FC<{
|
||||||
let unlock: VoidFn | undefined
|
let unlock: VoidFn | undefined
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
debugName: 'PanelResizeHandle',
|
||||||
lockCursorTo: cursors[which],
|
lockCursorTo: cursors[which],
|
||||||
onDragStart() {
|
onDragStart() {
|
||||||
stuffBeforeDrag = panelStuffRef.current
|
stuffBeforeDrag = panelStuffRef.current
|
||||||
|
|
|
@ -12,7 +12,7 @@ import type {
|
||||||
SequenceEditorPanelLayout,
|
SequenceEditorPanelLayout,
|
||||||
DopeSheetSelection,
|
DopeSheetSelection,
|
||||||
} from '@theatre/studio/panels/SequenceEditorPanel/layout/layout'
|
} from '@theatre/studio/panels/SequenceEditorPanel/layout/layout'
|
||||||
import {dotSize} from './Dot'
|
import {DOT_SIZE_PX} from './Dot'
|
||||||
import type KeyframeEditor from './KeyframeEditor'
|
import type KeyframeEditor from './KeyframeEditor'
|
||||||
import type Sequence from '@theatre/core/sequences/Sequence'
|
import type Sequence from '@theatre/core/sequences/Sequence'
|
||||||
import usePopover from '@theatre/studio/uiComponents/Popover/usePopover'
|
import 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 selectedKeyframeIdsIfInSingleTrack from '@theatre/studio/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/selectedKeyframeIdsIfInSingleTrack'
|
||||||
import type {OpenFn} from '@theatre/studio/src/uiComponents/Popover/usePopover'
|
import type {OpenFn} from '@theatre/studio/src/uiComponents/Popover/usePopover'
|
||||||
import type {Keyframe} from '@theatre/core/projects/store/types/SheetState_Historic'
|
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 CONNECTOR_HEIGHT = DOT_SIZE_PX / 2 + 1
|
||||||
const connectorWidthUnscaled = 1000
|
const CONNECTOR_WIDTH_UNSCALED = 1000
|
||||||
|
|
||||||
export const connectorTheme = {
|
const POPOVER_MARGIN = 5
|
||||||
normalColor: `#365b59`,
|
|
||||||
get hoverColor() {
|
type IConnectorThemeValues = {
|
||||||
return lighten(0.1, connectorTheme.normalColor)
|
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() {
|
hoverColor: (values: IConnectorThemeValues) => {
|
||||||
return lighten(0.2, connectorTheme.normalColor)
|
const base = values.isPopoverOpen
|
||||||
},
|
? CONNECTOR_THEME.popoverOpenColor
|
||||||
get selectedHoverColor() {
|
: CONNECTOR_THEME.normalColor
|
||||||
return lighten(0.4, connectorTheme.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;
|
position: absolute;
|
||||||
background: ${(props) =>
|
background: ${CONNECTOR_THEME.barColor};
|
||||||
props.isSelected
|
height: ${CONNECTOR_HEIGHT}px;
|
||||||
? connectorTheme.selectedColor
|
width: ${CONNECTOR_WIDTH_UNSCALED}px;
|
||||||
: connectorTheme.normalColor};
|
|
||||||
height: ${connectorHeight}px;
|
|
||||||
width: ${connectorWidthUnscaled}px;
|
|
||||||
|
|
||||||
left: 0;
|
left: 0;
|
||||||
top: -${connectorHeight / 2}px;
|
top: -${CONNECTOR_HEIGHT / 2}px;
|
||||||
transform-origin: top left;
|
transform-origin: top left;
|
||||||
z-index: 0;
|
z-index: 0;
|
||||||
cursor: ew-resize;
|
cursor: ew-resize;
|
||||||
|
@ -64,12 +74,15 @@ const Container = styled.div<{isSelected: boolean}>`
|
||||||
}
|
}
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: ${(props) =>
|
background: ${CONNECTOR_THEME.hoverColor};
|
||||||
props.isSelected
|
|
||||||
? connectorTheme.selectedHoverColor
|
|
||||||
: connectorTheme.hoverColor};
|
|
||||||
}
|
}
|
||||||
`
|
`
|
||||||
|
|
||||||
|
const EasingPopover = styled(BasicPopover)`
|
||||||
|
--popover-outer-stroke: transparent;
|
||||||
|
--popover-inner-stroke: ${COLOR_POPOVER_BACK};
|
||||||
|
`
|
||||||
|
|
||||||
type IProps = Parameters<typeof KeyframeEditor>[0]
|
type IProps = Parameters<typeof KeyframeEditor>[0]
|
||||||
|
|
||||||
const Connector: React.FC<IProps> = (props) => {
|
const Connector: React.FC<IProps> = (props) => {
|
||||||
|
@ -77,17 +90,26 @@ const Connector: React.FC<IProps> = (props) => {
|
||||||
const cur = trackData.keyframes[index]
|
const cur = trackData.keyframes[index]
|
||||||
const next = trackData.keyframes[index + 1]
|
const next = trackData.keyframes[index + 1]
|
||||||
|
|
||||||
const connectorLengthInUnitSpace = next.position - cur.position
|
|
||||||
|
|
||||||
const [nodeRef, node] = useRefAndState<HTMLDivElement | null>(null)
|
const [nodeRef, node] = useRefAndState<HTMLDivElement | null>(null)
|
||||||
|
|
||||||
|
const {isPointerBeingCaptured} = usePointerCapturing(
|
||||||
|
'KeyframeEditor Connector',
|
||||||
|
)
|
||||||
|
|
||||||
|
const rightDims = val(props.layoutP.rightDims)
|
||||||
const [popoverNode, openPopover, closePopover, isPopoverOpen] = usePopover(
|
const [popoverNode, openPopover, closePopover, isPopoverOpen] = usePopover(
|
||||||
{},
|
{
|
||||||
|
closeWhenPointerIsDistant: !isPointerBeingCaptured(),
|
||||||
|
constraints: {
|
||||||
|
minX: rightDims.screenX + POPOVER_MARGIN,
|
||||||
|
maxX: rightDims.screenX + rightDims.width - POPOVER_MARGIN,
|
||||||
|
},
|
||||||
|
},
|
||||||
() => {
|
() => {
|
||||||
return (
|
return (
|
||||||
<BasicPopover>
|
<EasingPopover showPopoverEdgeTriangle={false}>
|
||||||
<CurveEditorPopover {...props} onRequestClose={closePopover} />
|
<CurveEditorPopover {...props} onRequestClose={closePopover} />
|
||||||
</BasicPopover>
|
</EasingPopover>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
@ -101,15 +123,25 @@ const Connector: React.FC<IProps> = (props) => {
|
||||||
)
|
)
|
||||||
useDragKeyframe(node, props)
|
useDragKeyframe(node, props)
|
||||||
|
|
||||||
|
const connectorLengthInUnitSpace = next.position - cur.position
|
||||||
|
|
||||||
|
const themeValues: IConnectorThemeValues = {
|
||||||
|
isPopoverOpen,
|
||||||
|
isSelected: !!props.selection,
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container
|
<Container
|
||||||
isSelected={!!props.selection}
|
{...themeValues}
|
||||||
ref={nodeRef}
|
ref={nodeRef}
|
||||||
style={{
|
style={{
|
||||||
transform: `scale3d(calc(var(--unitSpaceToScaledSpaceMultiplier) * ${
|
transform: `scale3d(calc(var(--unitSpaceToScaledSpaceMultiplier) * ${
|
||||||
connectorLengthInUnitSpace / connectorWidthUnscaled
|
connectorLengthInUnitSpace / CONNECTOR_WIDTH_UNSCALED
|
||||||
}), 1, 1)`,
|
}), 1, 1)`,
|
||||||
}}
|
}}
|
||||||
|
onClick={(e) => {
|
||||||
|
if (node) openPopover(e, node)
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{popoverNode}
|
{popoverNode}
|
||||||
{contextMenu}
|
{contextMenu}
|
||||||
|
@ -132,6 +164,7 @@ function useDragKeyframe(node: HTMLDivElement | null, props: IProps) {
|
||||||
| undefined
|
| undefined
|
||||||
let sequence: Sequence
|
let sequence: Sequence
|
||||||
return {
|
return {
|
||||||
|
debugName: 'useDragKeyframe',
|
||||||
lockCursorTo: 'ew-resize',
|
lockCursorTo: 'ew-resize',
|
||||||
onDragStart(event) {
|
onDragStart(event) {
|
||||||
const props = propsRef.current
|
const props = propsRef.current
|
||||||
|
|
|
@ -1,457 +1,427 @@
|
||||||
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, useState} from 'react'
|
import type {KeyboardEvent} from 'react'
|
||||||
|
import React, {
|
||||||
|
useEffect,
|
||||||
|
useLayoutEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from 'react'
|
||||||
import styled from 'styled-components'
|
import styled from 'styled-components'
|
||||||
import fuzzy from 'fuzzy'
|
import fuzzy from 'fuzzy'
|
||||||
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 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 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 = [
|
const PRESET_COLUMNS = 3
|
||||||
{label: 'Linear', value: '0.5, 0.5, 0.5, 0.5'},
|
const PRESET_SIZE = 53
|
||||||
{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 APPROX_TOOLTIP_HEIGHT = 25
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: stretch;
|
|
||||||
width: 230px;
|
|
||||||
`
|
|
||||||
|
|
||||||
const InputContainer = styled.div`
|
const Grid = styled.div`
|
||||||
display: flex;
|
background: ${COLOR_POPOVER_BACK};
|
||||||
gap: 8px;
|
display: grid;
|
||||||
align-items: center;
|
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`
|
const OptionsContainer = styled.div`
|
||||||
overflow: auto;
|
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
|
display: grid;
|
||||||
& > div {
|
grid-template-columns: repeat(${PRESET_COLUMNS}, 1fr);
|
||||||
display: grid;
|
grid-auto-rows: min-content;
|
||||||
grid-template-columns: 1fr 1fr;
|
gap: 1px;
|
||||||
gap: 8px;
|
|
||||||
padding: 8px;
|
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'})`
|
const SearchBox = styled.input.attrs({type: 'text'})`
|
||||||
background-color: #10101042;
|
background-color: ${COLOR_BASE};
|
||||||
border: none;
|
border: none;
|
||||||
border-bottom: 1px solid rgba(0, 0, 0, 0.16);
|
border-radius: 2px;
|
||||||
color: rgba(255, 255, 255, 0.9);
|
color: rgba(255, 255, 255, 0.8);
|
||||||
padding: 10px;
|
padding: 6px;
|
||||||
font: inherit;
|
font-size: 12px;
|
||||||
outline: none;
|
outline: none;
|
||||||
cursor: text;
|
cursor: text;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: calc(100% - 4px);
|
height: 100%;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
|
||||||
|
grid-area: search;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: #212121;
|
||||||
|
}
|
||||||
|
|
||||||
&:focus {
|
&:focus {
|
||||||
cursor: text;
|
background-color: rgba(16, 16, 16, 0.26);
|
||||||
|
outline: 1px solid rgba(0, 0, 0, 0.35);
|
||||||
}
|
}
|
||||||
`
|
`
|
||||||
|
|
||||||
const CurveEditorPopover: React.FC<
|
const CurveEditorContainer = styled.div`
|
||||||
{
|
grid-area: tween;
|
||||||
layoutP: Pointer<SequenceEditorPanelLayout>
|
background: ${COLOR_BASE};
|
||||||
|
`
|
||||||
|
|
||||||
/**
|
const NoResultsFoundContainer = styled.div`
|
||||||
* Called when user hits enter/escape
|
grid-column: 1 / 4;
|
||||||
*/
|
padding: 6px;
|
||||||
onRequestClose: () => void
|
color: #888888;
|
||||||
} & Parameters<typeof KeyframeEditor>[0]
|
`
|
||||||
> = (props) => {
|
|
||||||
const [filter, setFilter] = useState<string>('')
|
|
||||||
|
|
||||||
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, {
|
// Clean-up function, called when this React component unmounts.
|
||||||
extract: (el) => el.label,
|
// When it unmounts, we want to commit edits that are outstanding
|
||||||
pre: '<b>',
|
() => {
|
||||||
post: '</b>',
|
tempTransaction.current?.commit()
|
||||||
}),
|
},
|
||||||
|
[tempTransaction],
|
||||||
[filter],
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Whether to interpret the search box input as a search query
|
////// Keyframe and trackdata //////
|
||||||
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()
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
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 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
|
////// Text input data and reactivity //////
|
||||||
const svgPadding = 0.12
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
const svgCircleRadius = 0.08
|
|
||||||
const svgColor = '#b98b08'
|
// 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
|
// A map to store all html elements corresponding to easing options
|
||||||
const optionsRef = useRef(
|
const optionsRef = useRef(
|
||||||
presets.reduce((acc, curr) => {
|
EASING_PRESETS.reduce((acc, curr) => {
|
||||||
acc[curr.label] = {current: null}
|
acc[curr.label] = {current: null}
|
||||||
|
|
||||||
return acc
|
return acc
|
||||||
}, {} as {[key: string]: {current: HTMLDivElement | null}}),
|
}, {} as {[key: string]: {current: HTMLDivElement | null}}),
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
const [optionsContainerRef, optionsContainer] =
|
||||||
<Container>
|
useRefAndState<HTMLDivElement | null>(null)
|
||||||
<InputContainer>
|
// Keep track of option container scroll position
|
||||||
<SearchBox
|
const [optionsScrollPosition, setOptionsScrollPosition] = useState(0)
|
||||||
value={filter}
|
useEffect(() => {
|
||||||
placeholder="Search presets..."
|
const listener = () => {
|
||||||
onChange={(e) => {
|
setOptionsScrollPosition(optionsContainer?.scrollTop ?? 0)
|
||||||
setFilter(e.target.value)
|
}
|
||||||
}}
|
optionsContainer?.addEventListener('scroll', listener)
|
||||||
ref={inputRef}
|
return () => optionsContainer?.removeEventListener('scroll', listener)
|
||||||
onKeyDown={(e) => {
|
}, [optionsContainer])
|
||||||
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))
|
|
||||||
|
|
||||||
return (
|
const grid = useUIOptionGrid({
|
||||||
<EasingOption
|
items: displayedPresets,
|
||||||
tabIndex={0}
|
uiColumns: 3,
|
||||||
onKeyDown={(e) => {
|
onSelectItem: onSelectEasingOption,
|
||||||
if (e.key === 'Escape') {
|
canVerticleExit(exitSide) {
|
||||||
props.onRequestClose()
|
if (exitSide === 'top') {
|
||||||
} else if (e.key === 'Enter') {
|
inputRef.current?.select()
|
||||||
fns.permanentlySetValue(preset.value)
|
inputRef.current?.focus()
|
||||||
props.onRequestClose()
|
return Outcome.Handled
|
||||||
}
|
}
|
||||||
if (e.key === 'ArrowRight') {
|
return Outcome.Passthrough
|
||||||
optionsRef.current[
|
},
|
||||||
displayedPresets[(index + 1) % displayedPresets.length]
|
renderItem: ({item: preset, select}) => (
|
||||||
.label
|
<EasingOption
|
||||||
].current!.focus()
|
key={preset.label}
|
||||||
}
|
easing={preset}
|
||||||
if (e.key === 'ArrowLeft') {
|
tabIndex={0}
|
||||||
if (preset === displayedPresets[0]) {
|
onKeyDown={onEasingOptionKeydown}
|
||||||
optionsRef.current[
|
ref={optionsRef.current[preset.label]}
|
||||||
displayedPresets[displayedPresets.length - 1].label
|
onMouseOver={() => onEasingOptionMouseOver(preset)}
|
||||||
].current?.focus()
|
onMouseOut={onEasingOptionMouseOut}
|
||||||
} else {
|
onClick={select}
|
||||||
optionsRef.current[
|
tooltipPlacement={
|
||||||
displayedPresets[
|
(optionsRef.current[preset.label].current?.offsetTop ?? 0) -
|
||||||
(index - 1) % displayedPresets.length
|
(optionsScrollPosition ?? 0) <
|
||||||
].label
|
PRESET_SIZE + APPROX_TOOLTIP_HEIGHT
|
||||||
].current?.focus()
|
? 'bottom'
|
||||||
}
|
: 'top'
|
||||||
}
|
}
|
||||||
if (e.key === 'ArrowUp') {
|
isSelected={areEasingsSimilar(
|
||||||
if (preset === displayedPresets[0]) {
|
easing,
|
||||||
inputRef.current!.focus()
|
handlesFromCssCubicBezierArgs(preset.value),
|
||||||
} else if (preset === displayedPresets[1]) {
|
)}
|
||||||
optionsRef.current[
|
/>
|
||||||
displayedPresets[0].label
|
),
|
||||||
].current?.focus()
|
})
|
||||||
} else {
|
|
||||||
optionsRef.current[
|
// When the user navigates highlight between presets, focus the preset el and set the
|
||||||
displayedPresets[index - 2].label
|
// easing data to match the highlighted preset
|
||||||
].current?.focus()
|
useLayoutEffect(() => {
|
||||||
}
|
if (
|
||||||
}
|
grid.currentSelection !== null &&
|
||||||
if (e.key === 'ArrowDown') {
|
document.activeElement !== inputRef.current // prevents taking focus away from input
|
||||||
if (
|
) {
|
||||||
preset === displayedPresets[displayedPresets.length - 1]
|
const maybePresetEl =
|
||||||
) {
|
optionsRef.current?.[grid.currentSelection.label]?.current
|
||||||
inputRef.current!.focus()
|
maybePresetEl?.focus()
|
||||||
} else if (
|
setEdit(grid.currentSelection.value)
|
||||||
preset === displayedPresets[displayedPresets.length - 2]
|
}
|
||||||
) {
|
}, [grid.currentSelection])
|
||||||
optionsRef.current[
|
|
||||||
displayedPresets[displayedPresets.length - 1].label
|
return (
|
||||||
].current?.focus()
|
<Grid>
|
||||||
} else {
|
<SearchBox
|
||||||
optionsRef.current[
|
value={inputValue}
|
||||||
displayedPresets[index + 2].label
|
placeholder="Search presets..."
|
||||||
].current?.focus()
|
onPaste={setTimeoutFunction(onInputChange)}
|
||||||
}
|
onChange={onInputChange}
|
||||||
}
|
ref={inputRef}
|
||||||
}}
|
onKeyDown={onSearchKeyDown}
|
||||||
ref={optionsRef.current[preset.label]}
|
/>
|
||||||
key={preset.label}
|
<OptionsContainer
|
||||||
onClick={() => {
|
ref={optionsContainerRef}
|
||||||
fns.permanentlySetValue(preset.value)
|
onKeyDown={(evt) => grid.onParentEltKeyDown(evt)}
|
||||||
props.onRequestClose()
|
>
|
||||||
}}
|
{grid.gridItems}
|
||||||
// Temporarily apply on hover
|
{grid.gridItems.length === 0 ? (
|
||||||
onMouseOver={() => {
|
<NoResultsFoundContainer>No results found</NoResultsFoundContainer>
|
||||||
// When previewing with hover, we don't want to set the filter too
|
) : undefined}
|
||||||
fns.temporarilySetValue(preset.value)
|
</OptionsContainer>
|
||||||
}}
|
<CurveEditorContainer onClick={() => inputRef.current?.focus()}>
|
||||||
onMouseOut={() => {
|
<CurveSegmentEditor
|
||||||
fns.discardTemporaryValue()
|
{...props}
|
||||||
}}
|
onCurveChange={onCurveChange}
|
||||||
>
|
onCancelCurveChange={onCancelCurveChange}
|
||||||
<EasingCurveContainer>
|
/>
|
||||||
<svg
|
</CurveEditorContainer>
|
||||||
width="18"
|
</Grid>
|
||||||
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>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default CurveEditorPopover
|
export default CurveEditorPopover
|
||||||
|
|
||||||
function cssCubicBezierArgsToHandles(
|
function setTempValue(
|
||||||
str: string,
|
tempTransaction: React.MutableRefObject<CommitOrDiscard | null>,
|
||||||
):
|
props: IProps,
|
||||||
| undefined
|
cur: Keyframe,
|
||||||
| [
|
next: Keyframe,
|
||||||
leftHandle2: number,
|
newCurve: string,
|
||||||
leftHandle3: number,
|
): void {
|
||||||
rightHandle0: number,
|
tempTransaction.current?.discard()
|
||||||
rightHandle1: number,
|
tempTransaction.current = null
|
||||||
] {
|
|
||||||
if (str.length > 128) {
|
|
||||||
// string too long
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
const args = str.split(',')
|
|
||||||
if (args.length !== 4) return undefined
|
|
||||||
const nums = args.map((arg) => {
|
|
||||||
return Number(arg.trim())
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!nums.every((v) => isFinite(v))) return undefined
|
const handles = handlesFromCssCubicBezierArgs(newCurve)
|
||||||
|
if (handles === null) return
|
||||||
|
|
||||||
if (nums[0] < 0 || nums[0] > 1 || nums[2] < 0 || nums[2] > 1) return undefined
|
tempTransaction.current = transactionSetCubicBezier(props, cur, next, handles)
|
||||||
return nums as $IntentionalAny
|
}
|
||||||
|
|
||||||
|
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 SnapCursor from './SnapCursor.svg'
|
||||||
import selectedKeyframeIdsIfInSingleTrack from '@theatre/studio/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/selectedKeyframeIdsIfInSingleTrack'
|
import selectedKeyframeIdsIfInSingleTrack from '@theatre/studio/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/selectedKeyframeIdsIfInSingleTrack'
|
||||||
|
|
||||||
export const dotSize = 6
|
export const DOT_SIZE_PX = 6
|
||||||
const hitZoneSize = 12
|
const HIT_ZONE_SIZE_PX = 12
|
||||||
const snapCursorSize = 34
|
const SNAP_CURSOR_SIZE_PX = 34
|
||||||
|
const DOT_HOVER_SIZE_PX = DOT_SIZE_PX + 5
|
||||||
|
|
||||||
const dims = (size: number) => `
|
const dims = (size: number) => `
|
||||||
left: ${-size / 2}px;
|
left: ${-size / 2}px;
|
||||||
|
@ -47,12 +48,12 @@ const Square = styled.div<{isSelected: boolean}>`
|
||||||
|
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
${(props) => dims(props.isSelected ? dotSize : dotSize)}
|
${dims(DOT_SIZE_PX)}
|
||||||
`
|
`
|
||||||
|
|
||||||
const HitZone = styled.div`
|
const HitZone = styled.div`
|
||||||
position: absolute;
|
position: absolute;
|
||||||
${dims(hitZoneSize)};
|
${dims(HIT_ZONE_SIZE_PX)};
|
||||||
|
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
|
|
||||||
|
@ -64,10 +65,10 @@ const HitZone = styled.div`
|
||||||
|
|
||||||
&:hover:after {
|
&:hover:after {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: calc(50% - ${snapCursorSize / 2}px);
|
top: calc(50% - ${SNAP_CURSOR_SIZE_PX / 2}px);
|
||||||
left: calc(50% - ${snapCursorSize / 2}px);
|
left: calc(50% - ${SNAP_CURSOR_SIZE_PX / 2}px);
|
||||||
width: ${snapCursorSize}px;
|
width: ${SNAP_CURSOR_SIZE_PX}px;
|
||||||
height: ${snapCursorSize}px;
|
height: ${SNAP_CURSOR_SIZE_PX}px;
|
||||||
display: block;
|
display: block;
|
||||||
content: ' ';
|
content: ' ';
|
||||||
background: url(${SnapCursor}) no-repeat 100% 100%;
|
background: url(${SnapCursor}) no-repeat 100% 100%;
|
||||||
|
@ -80,7 +81,7 @@ const HitZone = styled.div`
|
||||||
}
|
}
|
||||||
|
|
||||||
&:hover + ${Square}, &.beingDragged + ${Square} {
|
&:hover + ${Square}, &.beingDragged + ${Square} {
|
||||||
${dims(dotSize + 5)}
|
${dims(DOT_HOVER_SIZE_PX)}
|
||||||
}
|
}
|
||||||
`
|
`
|
||||||
|
|
||||||
|
@ -143,13 +144,13 @@ function useDragKeyframe(
|
||||||
let toUnitSpace: SequenceEditorPanelLayout['scaledSpace']['toUnitSpace']
|
let toUnitSpace: SequenceEditorPanelLayout['scaledSpace']['toUnitSpace']
|
||||||
let tempTransaction: CommitOrDiscard | undefined
|
let tempTransaction: CommitOrDiscard | undefined
|
||||||
let propsAtStartOfDrag: IProps
|
let propsAtStartOfDrag: IProps
|
||||||
let startingLayout: SequenceEditorPanelLayout
|
|
||||||
|
|
||||||
let selectionDragHandlers:
|
let selectionDragHandlers:
|
||||||
| ReturnType<DopeSheetSelection['getDragHandlers']>
|
| ReturnType<DopeSheetSelection['getDragHandlers']>
|
||||||
| undefined
|
| undefined
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
debugName: 'Dot/useDragKeyframe',
|
||||||
onDragStart(event) {
|
onDragStart(event) {
|
||||||
setIsDragging(true)
|
setIsDragging(true)
|
||||||
const props = propsRef.current
|
const props = propsRef.current
|
||||||
|
|
|
@ -57,6 +57,7 @@ function useCaptureSelection(
|
||||||
containerNode,
|
containerNode,
|
||||||
useMemo((): Parameters<typeof useDrag>[1] => {
|
useMemo((): Parameters<typeof useDrag>[1] => {
|
||||||
return {
|
return {
|
||||||
|
debugName: 'DopeSheetSelectionView/useCaptureSelection',
|
||||||
dontBlockMouseDown: true,
|
dontBlockMouseDown: true,
|
||||||
lockCursorTo: 'cell',
|
lockCursorTo: 'cell',
|
||||||
onDragStart(event) {
|
onDragStart(event) {
|
||||||
|
@ -202,6 +203,7 @@ namespace utils {
|
||||||
|
|
||||||
let toUnitSpace: SequenceEditorPanelLayout['scaledSpace']['toUnitSpace']
|
let toUnitSpace: SequenceEditorPanelLayout['scaledSpace']['toUnitSpace']
|
||||||
return {
|
return {
|
||||||
|
debugName: 'DopeSheetSelectionView/boundsToSelection',
|
||||||
onDragStart() {
|
onDragStart() {
|
||||||
toUnitSpace = val(layoutP.scaledSpace.toUnitSpace)
|
toUnitSpace = val(layoutP.scaledSpace.toUnitSpace)
|
||||||
},
|
},
|
||||||
|
|
|
@ -80,7 +80,8 @@ function useDragHandlers(
|
||||||
const setIsSeeking = val(layoutP.seeker.setIsSeeking)
|
const setIsSeeking = val(layoutP.seeker.setIsSeeking)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
onDrag(dx, _, event) {
|
debugName: 'HorizontallyScrollableArea',
|
||||||
|
onDrag(dx: number, _, event) {
|
||||||
const deltaPos = scaledSpaceToUnitSpace(dx)
|
const deltaPos = scaledSpaceToUnitSpace(dx)
|
||||||
const unsnappedPos = clamp(posBeforeSeek + deltaPos, 0, sequence.length)
|
const unsnappedPos = clamp(posBeforeSeek + deltaPos, 0, sequence.length)
|
||||||
|
|
||||||
|
|
|
@ -227,6 +227,7 @@ function useDragBulge(
|
||||||
let initialLength: number
|
let initialLength: number
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
debugName: 'LengthIndicator/useDragBulge',
|
||||||
lockCursorTo: 'ew-resize',
|
lockCursorTo: 'ew-resize',
|
||||||
onDragStart(event) {
|
onDragStart(event) {
|
||||||
setIsDragging(true)
|
setIsDragging(true)
|
||||||
|
|
|
@ -131,6 +131,7 @@ function useOurDrags(node: SVGCircleElement | null, props: IProps): void {
|
||||||
let tempTransaction: CommitOrDiscard | undefined
|
let tempTransaction: CommitOrDiscard | undefined
|
||||||
let unlockExtremums: VoidFn | undefined
|
let unlockExtremums: VoidFn | undefined
|
||||||
return {
|
return {
|
||||||
|
debugName: 'CurveHandler/useOurDrags',
|
||||||
lockCursorTo: 'move',
|
lockCursorTo: 'move',
|
||||||
onDragStart() {
|
onDragStart() {
|
||||||
propsAtStartOfDrag = propsRef.current
|
propsAtStartOfDrag = propsRef.current
|
||||||
|
|
|
@ -115,6 +115,7 @@ function useDragKeyframe(
|
||||||
let unlockExtremums: VoidFn | undefined
|
let unlockExtremums: VoidFn | undefined
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
debugName: 'GraphEditorDotNonScalar/useDragKeyframe',
|
||||||
lockCursorTo: 'ew-resize',
|
lockCursorTo: 'ew-resize',
|
||||||
onDragStart(event) {
|
onDragStart(event) {
|
||||||
setIsDragging(true)
|
setIsDragging(true)
|
||||||
|
|
|
@ -118,6 +118,7 @@ function useDragKeyframe(
|
||||||
let keepSpeeds = false
|
let keepSpeeds = false
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
debugName: 'GraphEditorDotScalar/useDragKeyframe',
|
||||||
lockCursorTo: 'move',
|
lockCursorTo: 'move',
|
||||||
onDragStart(event) {
|
onDragStart(event) {
|
||||||
setIsDragging(true)
|
setIsDragging(true)
|
||||||
|
|
|
@ -190,6 +190,7 @@ const FocusRangeStrip: React.FC<{
|
||||||
let newStartPosition: number, newEndPosition: number
|
let newStartPosition: number, newEndPosition: number
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
debugName: 'FocusRangeStrip',
|
||||||
onDragStart(event) {
|
onDragStart(event) {
|
||||||
existingRange = existingRangeD.getValue()
|
existingRange = existingRangeD.getValue()
|
||||||
|
|
||||||
|
|
|
@ -181,6 +181,7 @@ const FocusRangeThumb: React.FC<{
|
||||||
let scaledSpaceToUnitSpace: (s: number) => number
|
let scaledSpaceToUnitSpace: (s: number) => number
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
debugName: 'FocusRangeThumb',
|
||||||
onDragStart() {
|
onDragStart() {
|
||||||
sheet = val(layoutP.sheet)
|
sheet = val(layoutP.sheet)
|
||||||
const sequence = sheet.getSequence()
|
const sequence = sheet.getSequence()
|
||||||
|
|
|
@ -112,6 +112,7 @@ function usePanelDragZoneGestureHandlers(
|
||||||
let minFocusRangeStripWidth: number
|
let minFocusRangeStripWidth: number
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
debugName: 'FocusRangeZone/focusRangeCreationGestureHandlers',
|
||||||
onDragStart(event) {
|
onDragStart(event) {
|
||||||
clippedSpaceToUnitSpace = val(layoutP.clippedSpace.toUnitSpace)
|
clippedSpaceToUnitSpace = val(layoutP.clippedSpace.toUnitSpace)
|
||||||
scaledSpaceToUnitSpace = val(layoutP.scaledSpace.toUnitSpace)
|
scaledSpaceToUnitSpace = val(layoutP.scaledSpace.toUnitSpace)
|
||||||
|
@ -181,6 +182,7 @@ function usePanelDragZoneGestureHandlers(
|
||||||
let tempTransaction: CommitOrDiscard | undefined
|
let tempTransaction: CommitOrDiscard | undefined
|
||||||
let unlock: VoidFn | undefined
|
let unlock: VoidFn | undefined
|
||||||
return {
|
return {
|
||||||
|
debugName: 'FocusRangeZone/panelMoveGestureHandlers',
|
||||||
onDragStart() {
|
onDragStart() {
|
||||||
stuffBeforeDrag = panelStuffRef.current
|
stuffBeforeDrag = panelStuffRef.current
|
||||||
if (unlock) {
|
if (unlock) {
|
||||||
|
@ -229,6 +231,7 @@ function usePanelDragZoneGestureHandlers(
|
||||||
let currentGestureHandlers: undefined | Parameters<typeof useDrag>[1]
|
let currentGestureHandlers: undefined | Parameters<typeof useDrag>[1]
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
debugName: 'FocusRangeZone',
|
||||||
onDragStart(event) {
|
onDragStart(event) {
|
||||||
if (event.shiftKey) {
|
if (event.shiftKey) {
|
||||||
setMode('creating')
|
setMode('creating')
|
||||||
|
|
|
@ -206,6 +206,7 @@ const Playhead: React.FC<{layoutP: Pointer<SequenceEditorPanelLayout>}> = ({
|
||||||
let scaledSpaceToUnitSpace: typeof layoutP.scaledSpace.toUnitSpace.$$__pointer_type
|
let scaledSpaceToUnitSpace: typeof layoutP.scaledSpace.toUnitSpace.$$__pointer_type
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
debugName: 'Playhead',
|
||||||
onDragStart() {
|
onDragStart() {
|
||||||
sequence = val(layoutP.sheet).getSequence()
|
sequence = val(layoutP.sheet).getSequence()
|
||||||
posBeforeSeek = sequence.position
|
posBeforeSeek = sequence.position
|
||||||
|
|
|
@ -31,11 +31,21 @@ const Container = styled.div`
|
||||||
}
|
}
|
||||||
`
|
`
|
||||||
|
|
||||||
const BasicPopover: React.FC<{className?: string}> = React.forwardRef(
|
const BasicPopover: React.FC<{
|
||||||
({children, className}, ref) => {
|
className?: string
|
||||||
|
showPopoverEdgeTriangle?: boolean
|
||||||
|
}> = React.forwardRef(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
showPopoverEdgeTriangle: showPopoverEdgeTriangle = true,
|
||||||
|
},
|
||||||
|
ref,
|
||||||
|
) => {
|
||||||
return (
|
return (
|
||||||
<Container className={className} ref={ref as $IntentionalAny}>
|
<Container className={className} ref={ref as $IntentionalAny}>
|
||||||
<PopoverArrow />
|
{showPopoverEdgeTriangle ? <PopoverArrow /> : undefined}
|
||||||
{children}
|
{children}
|
||||||
</Container>
|
</Container>
|
||||||
)
|
)
|
||||||
|
|
|
@ -7,9 +7,17 @@ import useRefAndState from '@theatre/studio/utils/useRefAndState'
|
||||||
import useOnClickOutside from '@theatre/studio/uiComponents/useOnClickOutside'
|
import useOnClickOutside from '@theatre/studio/uiComponents/useOnClickOutside'
|
||||||
import onPointerOutside from '@theatre/studio/uiComponents/onPointerOutside'
|
import onPointerOutside from '@theatre/studio/uiComponents/onPointerOutside'
|
||||||
import noop from '@theatre/shared/utils/noop'
|
import noop from '@theatre/shared/utils/noop'
|
||||||
|
import {clamp} from 'lodash-es'
|
||||||
|
|
||||||
const minimumDistanceOfArrowToEdgeOfPopover = 8
|
const minimumDistanceOfArrowToEdgeOfPopover = 8
|
||||||
|
|
||||||
|
export type AbsolutePlacementBoxConstraints = {
|
||||||
|
minX?: number
|
||||||
|
maxX?: number
|
||||||
|
minY?: number
|
||||||
|
maxY?: number
|
||||||
|
}
|
||||||
|
|
||||||
const TooltipWrapper: React.FC<{
|
const TooltipWrapper: React.FC<{
|
||||||
target: HTMLElement | SVGElement
|
target: HTMLElement | SVGElement
|
||||||
onClickOutside?: (e: MouseEvent) => void
|
onClickOutside?: (e: MouseEvent) => void
|
||||||
|
@ -18,6 +26,9 @@ const TooltipWrapper: React.FC<{
|
||||||
threshold: number
|
threshold: number
|
||||||
callback: (e: MouseEvent) => void
|
callback: (e: MouseEvent) => void
|
||||||
}
|
}
|
||||||
|
verticalPlacement?: 'top' | 'bottom' | 'overlay'
|
||||||
|
verticalGap?: number // Has no effect if verticalPlacement === 'overlay'
|
||||||
|
constraints?: AbsolutePlacementBoxConstraints
|
||||||
}> = (props) => {
|
}> = (props) => {
|
||||||
const originalElement = props.children()
|
const originalElement = props.children()
|
||||||
const [ref, container] = useRefAndState<HTMLElement | SVGElement | null>(null)
|
const [ref, container] = useRefAndState<HTMLElement | SVGElement | null>(null)
|
||||||
|
@ -36,23 +47,42 @@ const TooltipWrapper: React.FC<{
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (!containerRect || !container || !targetRect) return
|
if (!containerRect || !container || !targetRect) return
|
||||||
|
|
||||||
const gap = 8
|
const gap = props.verticalGap ?? 8
|
||||||
const arrowStyle: Record<string, string> = {}
|
const arrowStyle: Record<string, string> = {}
|
||||||
|
|
||||||
let verticalPlacement: 'bottom' | 'top' | 'overlay' = 'bottom'
|
let verticalPlacement: 'bottom' | 'top' | 'overlay' =
|
||||||
|
props.verticalPlacement ?? 'bottom'
|
||||||
let top = 0
|
let top = 0
|
||||||
let left = 0
|
let left = 0
|
||||||
if (targetRect.bottom + containerRect.height + gap < windowSize.height) {
|
if (verticalPlacement === 'bottom') {
|
||||||
verticalPlacement = 'bottom'
|
if (targetRect.bottom + containerRect.height + gap < windowSize.height) {
|
||||||
top = targetRect.bottom + gap
|
verticalPlacement = 'bottom'
|
||||||
arrowStyle.top = '0px'
|
top = targetRect.bottom + gap
|
||||||
} else if (targetRect.top > containerRect.height + gap) {
|
arrowStyle.top = '0px'
|
||||||
verticalPlacement = 'top'
|
} else if (targetRect.top > containerRect.height + gap) {
|
||||||
top = targetRect.top - (containerRect.height + gap)
|
verticalPlacement = 'top'
|
||||||
arrowStyle.bottom = '0px'
|
top = targetRect.top - (containerRect.height + gap)
|
||||||
arrowStyle.transform = 'rotateZ(180deg)'
|
arrowStyle.bottom = '0px'
|
||||||
} else {
|
arrowStyle.transform = 'rotateZ(180deg)'
|
||||||
verticalPlacement = 'overlay'
|
} 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
|
let arrowLeft = 0
|
||||||
|
@ -77,7 +107,16 @@ const TooltipWrapper: React.FC<{
|
||||||
arrowStyle.left = arrowLeft + 'px'
|
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.left = pos.left + 'px'
|
||||||
container.style.top = pos.top + 'px'
|
container.style.top = pos.top + 'px'
|
||||||
|
@ -90,7 +129,14 @@ const TooltipWrapper: React.FC<{
|
||||||
props.onPointerOutside.callback,
|
props.onPointerOutside.callback,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}, [containerRect, container, props.target, targetRect, windowSize])
|
}, [
|
||||||
|
containerRect,
|
||||||
|
container,
|
||||||
|
props.target,
|
||||||
|
targetRect,
|
||||||
|
windowSize,
|
||||||
|
props.onPointerOutside,
|
||||||
|
])
|
||||||
|
|
||||||
useOnClickOutside(container, props.onClickOutside ?? noop)
|
useOnClickOutside(container, props.onClickOutside ?? noop)
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import React, {useCallback, useContext, useMemo, useState} from 'react'
|
import React, {useCallback, useContext, useMemo, useState} from 'react'
|
||||||
import {createPortal} from 'react-dom'
|
import {createPortal} from 'react-dom'
|
||||||
import {PortalContext} from 'reakit'
|
import {PortalContext} from 'reakit'
|
||||||
|
import type {AbsolutePlacementBoxConstraints} from './TooltipWrapper';
|
||||||
import TooltipWrapper from './TooltipWrapper'
|
import TooltipWrapper from './TooltipWrapper'
|
||||||
|
|
||||||
export type OpenFn = (e: React.MouseEvent, target: HTMLElement) => void
|
export type OpenFn = (e: React.MouseEvent, target: HTMLElement) => void
|
||||||
|
@ -21,6 +22,7 @@ export default function usePopover(
|
||||||
closeWhenPointerIsDistant?: boolean
|
closeWhenPointerIsDistant?: boolean
|
||||||
pointerDistanceThreshold?: number
|
pointerDistanceThreshold?: number
|
||||||
closeOnClickOutside?: boolean
|
closeOnClickOutside?: boolean
|
||||||
|
constraints?: AbsolutePlacementBoxConstraints
|
||||||
},
|
},
|
||||||
render: () => React.ReactElement,
|
render: () => React.ReactElement,
|
||||||
): [node: React.ReactNode, open: OpenFn, close: CloseFn, isOpen: boolean] {
|
): [node: React.ReactNode, open: OpenFn, close: CloseFn, isOpen: boolean] {
|
||||||
|
@ -62,6 +64,7 @@ export default function usePopover(
|
||||||
target={state.target}
|
target={state.target}
|
||||||
onClickOutside={onClickOutside}
|
onClickOutside={onClickOutside}
|
||||||
onPointerOutside={onPointerOutside}
|
onPointerOutside={onPointerOutside}
|
||||||
|
constraints={opts.constraints}
|
||||||
/>,
|
/>,
|
||||||
portalLayer!,
|
portalLayer!,
|
||||||
)
|
)
|
||||||
|
|
|
@ -10,7 +10,13 @@ import {PortalContext} from 'reakit'
|
||||||
import noop from '@theatre/shared/utils/noop'
|
import noop from '@theatre/shared/utils/noop'
|
||||||
|
|
||||||
export default function useTooltip(
|
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,
|
render: () => React.ReactElement,
|
||||||
): [
|
): [
|
||||||
node: React.ReactNode,
|
node: React.ReactNode,
|
||||||
|
@ -53,6 +59,8 @@ export default function useTooltip(
|
||||||
children={render}
|
children={render}
|
||||||
target={targetNode}
|
target={targetNode}
|
||||||
onClickOutside={noop}
|
onClickOutside={noop}
|
||||||
|
verticalPlacement={opts.verticalPlacement}
|
||||||
|
verticalGap={opts.verticalGap}
|
||||||
/>,
|
/>,
|
||||||
portalLayer!,
|
portalLayer!,
|
||||||
)
|
)
|
||||||
|
|
|
@ -2,8 +2,15 @@ import type {$FixMe} from '@theatre/shared/utils/types'
|
||||||
import {useLayoutEffect, useRef} from 'react'
|
import {useLayoutEffect, useRef} from 'react'
|
||||||
import useRefAndState from '@theatre/studio/utils/useRefAndState'
|
import useRefAndState from '@theatre/studio/utils/useRefAndState'
|
||||||
import {useCssCursorLock} from './PointerEventsHandler'
|
import {useCssCursorLock} from './PointerEventsHandler'
|
||||||
|
import type {CapturedPointer} from '@theatre/studio/UIRoot/PointerCapturing'
|
||||||
|
import {usePointerCapturing} from '@theatre/studio/UIRoot/PointerCapturing'
|
||||||
|
|
||||||
export type UseDragOpts = {
|
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.
|
* 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
|
* 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
|
* 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
|
* a series of `onDrag(dx, dy, event)` events will be called, and the
|
||||||
* gesture will end with `onDragEnd(true)`.
|
* 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
|
onDragStart?: (event: MouseEvent) => void | false
|
||||||
/**
|
/**
|
||||||
|
@ -58,6 +70,8 @@ export default function useDrag(
|
||||||
opts.lockCursorTo!,
|
opts.lockCursorTo!,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const {capturePointer} = usePointerCapturing(`useDrag for ${opts.debugName}`)
|
||||||
|
|
||||||
const stateRef = useRef<{
|
const stateRef = useRef<{
|
||||||
dragHappened: boolean
|
dragHappened: boolean
|
||||||
startPos: {
|
startPos: {
|
||||||
|
@ -69,6 +83,7 @@ export default function useDrag(
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (!target) return
|
if (!target) return
|
||||||
|
|
||||||
|
let capturedPointer: undefined | CapturedPointer
|
||||||
const getDistances = (event: MouseEvent): [number, number] => {
|
const getDistances = (event: MouseEvent): [number, number] => {
|
||||||
const {startPos} = stateRef.current
|
const {startPos} = stateRef.current
|
||||||
return [event.screenX - startPos.x, event.screenY - startPos.y]
|
return [event.screenX - startPos.x, event.screenY - startPos.y]
|
||||||
|
@ -96,6 +111,7 @@ export default function useDrag(
|
||||||
}
|
}
|
||||||
|
|
||||||
const removeDragListeners = () => {
|
const removeDragListeners = () => {
|
||||||
|
capturedPointer?.release()
|
||||||
document.removeEventListener('mousemove', dragHandler)
|
document.removeEventListener('mousemove', dragHandler)
|
||||||
document.removeEventListener('mouseup', dragEndHandler)
|
document.removeEventListener('mouseup', dragEndHandler)
|
||||||
}
|
}
|
||||||
|
@ -115,13 +131,23 @@ export default function useDrag(
|
||||||
}
|
}
|
||||||
|
|
||||||
const dragStartHandler = (event: MouseEvent) => {
|
const dragStartHandler = (event: MouseEvent) => {
|
||||||
|
// defensively release
|
||||||
|
capturedPointer?.release()
|
||||||
|
|
||||||
const opts = optsRef.current
|
const opts = optsRef.current
|
||||||
if (opts.disabled === true) return
|
if (opts.disabled === true) return
|
||||||
|
|
||||||
if (event.button !== 0) 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) {
|
if (!opts.dontBlockMouseDown) {
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
|
|
Loading…
Reference in a new issue