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:
Elliot 2022-05-04 11:08:30 -04:00 committed by GitHub
parent dceb3965d6
commit fceb1eb60a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
29 changed files with 1418 additions and 479 deletions

View 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])
}

View file

@ -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>
) )

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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)
} }

View file

@ -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)
}

View file

@ -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

View file

@ -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

View file

@ -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'

View file

@ -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
}

View file

@ -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)
}

View file

@ -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,
}
}

View file

@ -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

View file

@ -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)
}, },

View file

@ -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)

View file

@ -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)

View file

@ -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

View file

@ -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)

View file

@ -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)

View file

@ -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()

View file

@ -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()

View file

@ -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')

View file

@ -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

View file

@ -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>
) )

View file

@ -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)

View file

@ -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!,
) )

View file

@ -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!,
) )

View file

@ -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()