From fceb1eb60a01a7860cbe23ac7745e374bbf9a499 Mon Sep 17 00:00:00 2001 From: Elliot Date: Wed, 4 May 2022 11:08:30 -0400 Subject: [PATCH] feature/single curve editor (#122) Co-authored-by: Cole Lawrence Co-authored-by: Andrew Prifer Co-authored-by: Aria Minaei --- .../studio/src/UIRoot/PointerCapturing.tsx | 102 +++ theatre/studio/src/UIRoot/UIRoot.tsx | 34 +- .../src/panels/BasePanel/PanelDragZone.tsx | 1 + .../panels/BasePanel/PanelResizeHandle.tsx | 1 + .../KeyframeEditor/Connector.tsx | 95 ++- .../CurveEditorPopover/CurveEditorPopover.tsx | 768 +++++++++--------- .../CurveEditorPopover/CurveSegmentEditor.tsx | 274 +++++++ .../CurveEditorPopover/EasingOption.tsx | 83 ++ .../CurveEditorPopover/SVGCurveSegment.tsx | 104 +++ .../CurveEditorPopover/colors.ts | 5 + .../CurveEditorPopover/shared.ts | 125 +++ .../CurveEditorPopover/useFreezableMemo.ts | 21 + .../CurveEditorPopover/useUIOptionGrid.tsx | 109 +++ .../KeyframeEditor/Dot.tsx | 23 +- .../Right/DopeSheetSelectionView.tsx | 2 + .../Right/HorizontallyScrollableArea.tsx | 3 +- .../Right/LengthIndicator/LengthIndicator.tsx | 1 + .../KeyframeEditor/CurveHandle.tsx | 1 + .../GraphEditorDotNonScalar.tsx | 1 + .../KeyframeEditor/GraphEditorDotScalar.tsx | 1 + .../FocusRangeZone/FocusRangeStrip.tsx | 1 + .../FocusRangeZone/FocusRangeThumb.tsx | 1 + .../FocusRangeZone/FocusRangeZone.tsx | 3 + .../RightOverlay/Playhead.tsx | 1 + .../src/uiComponents/Popover/BasicPopover.tsx | 16 +- .../uiComponents/Popover/TooltipWrapper.tsx | 76 +- .../src/uiComponents/Popover/usePopover.tsx | 3 + .../src/uiComponents/Popover/useTooltip.tsx | 10 +- theatre/studio/src/uiComponents/useDrag.ts | 32 +- 29 files changed, 1418 insertions(+), 479 deletions(-) create mode 100644 theatre/studio/src/UIRoot/PointerCapturing.tsx create mode 100644 theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/CurveEditorPopover/CurveSegmentEditor.tsx create mode 100644 theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/CurveEditorPopover/EasingOption.tsx create mode 100644 theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/CurveEditorPopover/SVGCurveSegment.tsx create mode 100644 theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/CurveEditorPopover/colors.ts create mode 100644 theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/CurveEditorPopover/shared.ts create mode 100644 theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/CurveEditorPopover/useFreezableMemo.ts create mode 100644 theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/CurveEditorPopover/useUIOptionGrid.tsx diff --git a/theatre/studio/src/UIRoot/PointerCapturing.tsx b/theatre/studio/src/UIRoot/PointerCapturing.tsx new file mode 100644 index 0000000..e82225a --- /dev/null +++ b/theatre/studio/src/UIRoot/PointerCapturing.tsx @@ -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) + + 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( + 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 ( + + + + ) +} + +export function usePointerCapturing(forDebugName: string): PointerCapturing { + const pointerCapturingFn = useContext(PointerCapturingContext) + return useMemo(() => { + return pointerCapturingFn(forDebugName) + }, [forDebugName, pointerCapturingFn]) +} diff --git a/theatre/studio/src/UIRoot/UIRoot.tsx b/theatre/studio/src/UIRoot/UIRoot.tsx index fa5cb86..2efd376 100644 --- a/theatre/studio/src/UIRoot/UIRoot.tsx +++ b/theatre/studio/src/UIRoot/UIRoot.tsx @@ -12,6 +12,7 @@ import type {$IntentionalAny} from '@theatre/shared/utils/types' import useKeyboardShortcuts from './useKeyboardShortcuts' import PointerEventsHandler from '@theatre/studio/uiComponents/PointerEventsHandler' import TooltipContext from '@theatre/studio/uiComponents/Popover/TooltipContext' +import {ProvidePointerCapturing} from './PointerCapturing' const GlobalStyle = createGlobalStyle` :host { @@ -67,6 +68,7 @@ export default function UIRoot() { ) useKeyboardShortcuts() + const visiblityState = useVal(studio.atomP.ahistoric.visibilityState) useEffect(() => { if (visiblityState === 'everythingIsHidden') { @@ -93,21 +95,23 @@ export default function UIRoot() { > <> - - - - - - {} - {} - - - - + + + + + + + {} + {} + + + + + ) diff --git a/theatre/studio/src/panels/BasePanel/PanelDragZone.tsx b/theatre/studio/src/panels/BasePanel/PanelDragZone.tsx index 4076f4f..a24aafa 100644 --- a/theatre/studio/src/panels/BasePanel/PanelDragZone.tsx +++ b/theatre/studio/src/panels/BasePanel/PanelDragZone.tsx @@ -25,6 +25,7 @@ const PanelDragZone: React.FC< let tempTransaction: CommitOrDiscard | undefined let unlock: VoidFn | undefined return { + debugName: 'PanelDragZone', lockCursorTo: 'move', onDragStart() { stuffBeforeDrag = panelStuffRef.current diff --git a/theatre/studio/src/panels/BasePanel/PanelResizeHandle.tsx b/theatre/studio/src/panels/BasePanel/PanelResizeHandle.tsx index b6a5d75..f31a521 100644 --- a/theatre/studio/src/panels/BasePanel/PanelResizeHandle.tsx +++ b/theatre/studio/src/panels/BasePanel/PanelResizeHandle.tsx @@ -151,6 +151,7 @@ const PanelResizeHandle: React.FC<{ let unlock: VoidFn | undefined return { + debugName: 'PanelResizeHandle', lockCursorTo: cursors[which], onDragStart() { stuffBeforeDrag = panelStuffRef.current diff --git a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/Connector.tsx b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/Connector.tsx index bdbfa2e..ae2dbc0 100644 --- a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/Connector.tsx +++ b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/Connector.tsx @@ -12,7 +12,7 @@ import type { SequenceEditorPanelLayout, DopeSheetSelection, } from '@theatre/studio/panels/SequenceEditorPanel/layout/layout' -import {dotSize} from './Dot' +import {DOT_SIZE_PX} from './Dot' import type KeyframeEditor from './KeyframeEditor' import type Sequence from '@theatre/core/sequences/Sequence' import usePopover from '@theatre/studio/uiComponents/Popover/usePopover' @@ -21,34 +21,44 @@ import CurveEditorPopover from './CurveEditorPopover/CurveEditorPopover' import selectedKeyframeIdsIfInSingleTrack from '@theatre/studio/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/selectedKeyframeIdsIfInSingleTrack' import type {OpenFn} from '@theatre/studio/src/uiComponents/Popover/usePopover' import type {Keyframe} from '@theatre/core/projects/store/types/SheetState_Historic' +import {usePointerCapturing} from '@theatre/studio/UIRoot/PointerCapturing' +import {COLOR_POPOVER_BACK} from './CurveEditorPopover/colors' -const connectorHeight = dotSize / 2 + 1 -const connectorWidthUnscaled = 1000 +const CONNECTOR_HEIGHT = DOT_SIZE_PX / 2 + 1 +const CONNECTOR_WIDTH_UNSCALED = 1000 -export const connectorTheme = { - normalColor: `#365b59`, - get hoverColor() { - return lighten(0.1, connectorTheme.normalColor) +const POPOVER_MARGIN = 5 + +type IConnectorThemeValues = { + isPopoverOpen: boolean + isSelected: boolean +} + +export const CONNECTOR_THEME = { + normalColor: `#365b59`, // (greenish-blueish)ish + popoverOpenColor: `#817720`, // orangey yellowish + barColor: (values: IConnectorThemeValues) => { + const base = values.isPopoverOpen + ? CONNECTOR_THEME.popoverOpenColor + : CONNECTOR_THEME.normalColor + return values.isSelected ? lighten(0.2, base) : base }, - get selectedColor() { - return lighten(0.2, connectorTheme.normalColor) - }, - get selectedHoverColor() { - return lighten(0.4, connectorTheme.normalColor) + hoverColor: (values: IConnectorThemeValues) => { + const base = values.isPopoverOpen + ? CONNECTOR_THEME.popoverOpenColor + : CONNECTOR_THEME.normalColor + return values.isSelected ? lighten(0.4, base) : lighten(0.1, base) }, } -const Container = styled.div<{isSelected: boolean}>` +const Container = styled.div` position: absolute; - background: ${(props) => - props.isSelected - ? connectorTheme.selectedColor - : connectorTheme.normalColor}; - height: ${connectorHeight}px; - width: ${connectorWidthUnscaled}px; + background: ${CONNECTOR_THEME.barColor}; + height: ${CONNECTOR_HEIGHT}px; + width: ${CONNECTOR_WIDTH_UNSCALED}px; left: 0; - top: -${connectorHeight / 2}px; + top: -${CONNECTOR_HEIGHT / 2}px; transform-origin: top left; z-index: 0; cursor: ew-resize; @@ -64,12 +74,15 @@ const Container = styled.div<{isSelected: boolean}>` } &:hover { - background: ${(props) => - props.isSelected - ? connectorTheme.selectedHoverColor - : connectorTheme.hoverColor}; + background: ${CONNECTOR_THEME.hoverColor}; } ` + +const EasingPopover = styled(BasicPopover)` + --popover-outer-stroke: transparent; + --popover-inner-stroke: ${COLOR_POPOVER_BACK}; +` + type IProps = Parameters[0] const Connector: React.FC = (props) => { @@ -77,17 +90,26 @@ const Connector: React.FC = (props) => { const cur = trackData.keyframes[index] const next = trackData.keyframes[index + 1] - const connectorLengthInUnitSpace = next.position - cur.position - const [nodeRef, node] = useRefAndState(null) + const {isPointerBeingCaptured} = usePointerCapturing( + 'KeyframeEditor Connector', + ) + + const rightDims = val(props.layoutP.rightDims) const [popoverNode, openPopover, closePopover, isPopoverOpen] = usePopover( - {}, + { + closeWhenPointerIsDistant: !isPointerBeingCaptured(), + constraints: { + minX: rightDims.screenX + POPOVER_MARGIN, + maxX: rightDims.screenX + rightDims.width - POPOVER_MARGIN, + }, + }, () => { return ( - + - + ) }, ) @@ -101,15 +123,25 @@ const Connector: React.FC = (props) => { ) useDragKeyframe(node, props) + const connectorLengthInUnitSpace = next.position - cur.position + + const themeValues: IConnectorThemeValues = { + isPopoverOpen, + isSelected: !!props.selection, + } + return ( { + if (node) openPopover(e, node) + }} > {popoverNode} {contextMenu} @@ -132,6 +164,7 @@ function useDragKeyframe(node: HTMLDivElement | null, props: IProps) { | undefined let sequence: Sequence return { + debugName: 'useDragKeyframe', lockCursorTo: 'ew-resize', onDragStart(event) { const props = propsRef.current diff --git a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/CurveEditorPopover/CurveEditorPopover.tsx b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/CurveEditorPopover/CurveEditorPopover.tsx index e6f6580..5b67a9d 100644 --- a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/CurveEditorPopover/CurveEditorPopover.tsx +++ b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/CurveEditorPopover/CurveEditorPopover.tsx @@ -1,457 +1,427 @@ import type {Pointer} from '@theatre/dataverse' import {val} from '@theatre/dataverse' -import React, {useLayoutEffect, useMemo, useRef, useState} from 'react' +import type {KeyboardEvent} from 'react' +import React, { + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from 'react' import styled from 'styled-components' import fuzzy from 'fuzzy' import type {SequenceEditorPanelLayout} from '@theatre/studio/panels/SequenceEditorPanel/layout/layout' import getStudio from '@theatre/studio/getStudio' import type {CommitOrDiscard} from '@theatre/studio/StudioStore/StudioStore' import type KeyframeEditor from '@theatre/studio/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/KeyframeEditor' -import type {$IntentionalAny} from '@theatre/shared/utils/types' +import CurveSegmentEditor from './CurveSegmentEditor' +import EasingOption from './EasingOption' +import type {CSSCubicBezierArgsString, CubicBezierHandles} from './shared' +import { + cssCubicBezierArgsFromHandles, + handlesFromCssCubicBezierArgs, + EASING_PRESETS, + areEasingsSimilar, +} from './shared' +import {COLOR_BASE, COLOR_POPOVER_BACK} from './colors' +import useRefAndState from '@theatre/studio/utils/useRefAndState' +import type {Keyframe} from '@theatre/core/projects/store/types/SheetState_Historic' +import {useUIOptionGrid, Outcome} from './useUIOptionGrid' -const presets = [ - {label: 'Linear', value: '0.5, 0.5, 0.5, 0.5'}, - {label: 'Back In Out', value: '0.680, -0.550, 0.265, 1.550'}, - {label: 'Back In', value: '0.600, -0.280, 0.735, 0.045'}, - {label: 'Back Out', value: '0.175, 0.885, 0.320, 1.275'}, - {label: 'Circ In Out', value: '0.785, 0.135, 0.150, 0.860'}, - {label: 'Circ In', value: '0.600, 0.040, 0.980, 0.335'}, - {label: 'Circ Out', value: '0.075, 0.820, 0.165, 1'}, - {label: 'Cubic In Out', value: '0.645, 0.045, 0.355, 1'}, - {label: 'Cubic In', value: '0.550, 0.055, 0.675, 0.190'}, - {label: 'Cubic Out', value: '0.215, 0.610, 0.355, 1'}, - {label: 'Ease Out In', value: '.42, 0, .58, 1'}, - {label: 'Expo In Out', value: '1, 0, 0, 1'}, - {label: 'Expo Out', value: '0.190, 1, 0.220, 1'}, - {label: 'Quad In Out', value: '0.455, 0.030, 0.515, 0.955'}, - {label: 'Quad In', value: '0.550, 0.085, 0.680, 0.530'}, - {label: 'Quad Out', value: '0.250, 0.460, 0.450, 0.940'}, - {label: 'Quart In Out', value: '0.770, 0, 0.175, 1'}, - {label: 'Quart In', value: '0.895, 0.030, 0.685, 0.220'}, - {label: 'Quart Out', value: '0.165, 0.840, 0.440, 1'}, - {label: 'Quint In Out', value: '0.860, 0, 0.070, 1'}, - {label: 'Quint In', value: '0.755, 0.050, 0.855, 0.060'}, - {label: 'Quint Out', value: '0.230, 1, 0.320, 1'}, - {label: 'Sine In Out', value: '0.445, 0.050, 0.550, 0.950'}, - {label: 'Sine In', value: '0.470, 0, 0.745, 0.715'}, - {label: 'Sine Out', value: '0.390, 0.575, 0.565, 1'}, -] +const PRESET_COLUMNS = 3 +const PRESET_SIZE = 53 -const Container = styled.div` - display: flex; - flex-direction: column; - align-items: stretch; - width: 230px; -` +const APPROX_TOOLTIP_HEIGHT = 25 -const InputContainer = styled.div` - display: flex; - gap: 8px; - align-items: center; +const Grid = styled.div` + background: ${COLOR_POPOVER_BACK}; + display: grid; + grid-template-areas: + 'search tween' + 'presets tween'; + grid-template-rows: 32px 1fr; + grid-template-columns: ${PRESET_COLUMNS * PRESET_SIZE}px 120px; + gap: 1px; + height: 120px; ` const OptionsContainer = styled.div` overflow: auto; - max-height: 130px; + grid-area: presets; - // Firefox doesn't let grids overflow their own element when the height is fixed so we need an extra inner div for the grid - & > div { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 8px; - padding: 8px; + display: grid; + grid-template-columns: repeat(${PRESET_COLUMNS}, 1fr); + grid-auto-rows: min-content; + gap: 1px; + + overflow-y: scroll; + scrollbar-width: none; /* Firefox */ + -ms-overflow-style: none; /* Internet Explorer 10+ */ + &::-webkit-scrollbar { + /* WebKit */ + width: 0; + height: 0; } ` -const EasingOption = styled.div` - display: flex; - flex-direction: row; - align-items: center; - gap: 4px; - overflow: hidden; - - background: rgba(255, 255, 255, 0.1); - color: rgba(255, 255, 255, 0.75); - border-radius: 4px; - - // The candidate preset is going to be applied when enter is pressed - - &:focus { - outline: none; - box-shadow: 0 0 0 2px rgb(78, 134, 136); - } - - &:hover { - background: rgba(255, 255, 255, 0.2); - } - - b { - text-decoration: underline; - // Default underline is too close to the text to be subtle - text-underline-offset: 2px; - text-decoration-color: rgba(255, 255, 255, 0.3); - } -` - -const EasingCurveContainer = styled.div` - display: flex; - padding: 6px; - background: rgba(255, 255, 255, 0.1); -` - const SearchBox = styled.input.attrs({type: 'text'})` - background-color: #10101042; + background-color: ${COLOR_BASE}; border: none; - border-bottom: 1px solid rgba(0, 0, 0, 0.16); - color: rgba(255, 255, 255, 0.9); - padding: 10px; - font: inherit; + border-radius: 2px; + color: rgba(255, 255, 255, 0.8); + padding: 6px; + font-size: 12px; outline: none; cursor: text; text-align: left; width: 100%; - height: calc(100% - 4px); + height: 100%; box-sizing: border-box; + grid-area: search; + + &:hover { + background-color: #212121; + } + &:focus { - cursor: text; + background-color: rgba(16, 16, 16, 0.26); + outline: 1px solid rgba(0, 0, 0, 0.35); } ` -const CurveEditorPopover: React.FC< - { - layoutP: Pointer +const CurveEditorContainer = styled.div` + grid-area: tween; + background: ${COLOR_BASE}; +` - /** - * Called when user hits enter/escape - */ - onRequestClose: () => void - } & Parameters[0] -> = (props) => { - const [filter, setFilter] = useState('') +const NoResultsFoundContainer = styled.div` + grid-column: 1 / 4; + padding: 6px; + color: #888888; +` - const presetSearchResults = useMemo( +enum TextInputMode { + user, + auto, +} + +type IProps = { + layoutP: Pointer + + /** + * Called when user hits enter/escape + */ + onRequestClose: () => void +} & Parameters[0] + +const CurveEditorPopover: React.FC = (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(null) + useEffect( () => - fuzzy.filter(filter, presets, { - extract: (el) => el.label, - pre: '', - post: '', - }), - - [filter], + // Clean-up function, called when this React component unmounts. + // When it unmounts, we want to commit edits that are outstanding + () => { + tempTransaction.current?.commit() + }, + [tempTransaction], ) - // Whether to interpret the search box input as a search query - const useQuery = /^[A-Za-z]/.test(filter) - const optionsEmpty = useQuery && presetSearchResults.length === 0 - - const displayedPresets = useMemo( - () => - useQuery ? presetSearchResults.map((result) => result.original) : presets, - [presetSearchResults, useQuery], - ) - - const fns = useMemo(() => { - let tempTransaction: CommitOrDiscard | undefined - - return { - temporarilySetValue(newCurve: string): void { - if (tempTransaction) { - tempTransaction.discard() - tempTransaction = undefined - } - - const args = cssCubicBezierArgsToHandles(newCurve)! - if (!args) { - return - } - - tempTransaction = getStudio()!.tempTransaction(({stateEditors}) => { - const {replaceKeyframes} = - stateEditors.coreByProject.historic.sheetsById.sequence - - replaceKeyframes({ - ...props.leaf.sheetObject.address, - snappingFunction: val(props.layoutP.sheet).getSequence() - .closestGridPosition, - trackId: props.leaf.trackId, - keyframes: [ - { - ...cur, - handles: [cur.handles[0], cur.handles[1], args[0], args[1]], - }, - { - ...next, - handles: [args[2], args[3], next.handles[2], next.handles[3]], - }, - ], - }) - }) - }, - discardTemporaryValue(): void { - if (tempTransaction) { - tempTransaction.discard() - tempTransaction = undefined - } - }, - permanentlySetValue(newCurve: string): void { - if (tempTransaction) { - tempTransaction.discard() - tempTransaction = undefined - } - const args = - cssCubicBezierArgsToHandles(newCurve) ?? - cssCubicBezierArgsToHandles(presetSearchResults[0].original.value) - - if (!args) { - return - } - - getStudio()!.transaction(({stateEditors}) => { - const {replaceKeyframes} = - stateEditors.coreByProject.historic.sheetsById.sequence - - replaceKeyframes({ - ...props.leaf.sheetObject.address, - snappingFunction: val(props.layoutP.sheet).getSequence() - .closestGridPosition, - trackId: props.leaf.trackId, - keyframes: [ - { - ...cur, - handles: [cur.handles[0], cur.handles[1], args[0], args[1]], - }, - { - ...next, - handles: [args[2], args[3], next.handles[2], next.handles[3]], - }, - ], - }) - }) - - props.onRequestClose() - }, - } - }, [props.layoutP, props.index, presetSearchResults]) - - const inputRef = useRef(null) - useLayoutEffect(() => { - inputRef.current!.focus() - }, []) - + ////// Keyframe and trackdata ////// const {index, trackData} = props const cur = trackData.keyframes[index] const next = trackData.keyframes[index + 1] + const easing: CubicBezierHandles = [ + trackData.keyframes[index].handles[2], + trackData.keyframes[index].handles[3], + trackData.keyframes[index + 1].handles[0], + trackData.keyframes[index + 1].handles[1], + ] - // Need some padding *inside* the SVG so that the handles and overshoots are not clipped - const svgPadding = 0.12 - const svgCircleRadius = 0.08 - const svgColor = '#b98b08' + ////// Text input data and reactivity ////// + const inputRef = useRef(null) + + // Select the easing string on popover open for quick copy&paste + useLayoutEffect(() => { + inputRef.current?.select() + inputRef.current?.focus() + }, [inputRef.current]) + + const [inputValue, setInputValue] = useState('') + + const onInputChange = (e: React.ChangeEvent) => { + setTextInputMode(TextInputMode.user) + setInputValue(e.target.value) + + const maybeHandles = handlesFromCssCubicBezierArgs(inputValue) + if (maybeHandles) setEdit(inputValue) + } + const onSearchKeyDown = (e: KeyboardEvent) => { + 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.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(null) + // `preview` is used when hovering over a curve to preview it. + const [preview, setPreview] = useState(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) => { + if (e.key === 'Escape') { + discardTempValue(tempTransaction) + props.onRequestClose() + e.stopPropagation() + } else if (e.key === 'Enter') { + props.onRequestClose() + e.stopPropagation() + } + } + const onEasingOptionMouseOver = (item: {label: string; value: string}) => + setPreview(item.value) + const onEasingOptionMouseOut = () => setPreview(null) + const onSelectEasingOption = (item: {label: string; value: string}) => { + setTextInputMode(TextInputMode.auto) + setEdit(item.value) + } // A map to store all html elements corresponding to easing options const optionsRef = useRef( - presets.reduce((acc, curr) => { + EASING_PRESETS.reduce((acc, curr) => { acc[curr.label] = {current: null} return acc }, {} as {[key: string]: {current: HTMLDivElement | null}}), ) - return ( - - - { - setFilter(e.target.value) - }} - ref={inputRef} - onKeyDown={(e) => { - if (e.key === 'ArrowDown') { - // Prevent scrolling on arrow key press - e.preventDefault() - optionsRef.current[displayedPresets[0].label].current?.focus() - } - if (e.key === 'ArrowUp') { - // Prevent scrolling on arrow key press - e.preventDefault() - optionsRef.current[ - displayedPresets[displayedPresets.length - 1].label - ].current?.focus() - } - if (e.key === 'Escape') { - props.onRequestClose() - } - if (e.key === 'Enter') { - fns.permanentlySetValue(filter) - props.onRequestClose() - } - }} - /> - - {!optionsEmpty && ( - e.preventDefault()}> - {/*Firefox doesn't let grids overflow their own element when the height is fixed so we need an extra inner div for the grid*/} -
- {displayedPresets.map((preset, index) => { - const easing = preset.value.split(', ').map((e) => Number(e)) + const [optionsContainerRef, optionsContainer] = + useRefAndState(null) + // Keep track of option container scroll position + const [optionsScrollPosition, setOptionsScrollPosition] = useState(0) + useEffect(() => { + const listener = () => { + setOptionsScrollPosition(optionsContainer?.scrollTop ?? 0) + } + optionsContainer?.addEventListener('scroll', listener) + return () => optionsContainer?.removeEventListener('scroll', listener) + }, [optionsContainer]) - return ( - { - if (e.key === 'Escape') { - props.onRequestClose() - } else if (e.key === 'Enter') { - fns.permanentlySetValue(preset.value) - props.onRequestClose() - } - if (e.key === 'ArrowRight') { - optionsRef.current[ - displayedPresets[(index + 1) % displayedPresets.length] - .label - ].current!.focus() - } - if (e.key === 'ArrowLeft') { - if (preset === displayedPresets[0]) { - optionsRef.current[ - displayedPresets[displayedPresets.length - 1].label - ].current?.focus() - } else { - optionsRef.current[ - displayedPresets[ - (index - 1) % displayedPresets.length - ].label - ].current?.focus() - } - } - if (e.key === 'ArrowUp') { - if (preset === displayedPresets[0]) { - inputRef.current!.focus() - } else if (preset === displayedPresets[1]) { - optionsRef.current[ - displayedPresets[0].label - ].current?.focus() - } else { - optionsRef.current[ - displayedPresets[index - 2].label - ].current?.focus() - } - } - if (e.key === 'ArrowDown') { - if ( - preset === displayedPresets[displayedPresets.length - 1] - ) { - inputRef.current!.focus() - } else if ( - preset === displayedPresets[displayedPresets.length - 2] - ) { - optionsRef.current[ - displayedPresets[displayedPresets.length - 1].label - ].current?.focus() - } else { - optionsRef.current[ - displayedPresets[index + 2].label - ].current?.focus() - } - } - }} - ref={optionsRef.current[preset.label]} - key={preset.label} - onClick={() => { - fns.permanentlySetValue(preset.value) - props.onRequestClose() - }} - // Temporarily apply on hover - onMouseOver={() => { - // When previewing with hover, we don't want to set the filter too - fns.temporarilySetValue(preset.value) - }} - onMouseOut={() => { - fns.discardTemporaryValue() - }} - > - - - - - - - - - {useQuery ? ( - - ) : ( - preset.label - )} - - - ) - })} -
-
- )} -
+ const grid = useUIOptionGrid({ + items: displayedPresets, + uiColumns: 3, + onSelectItem: onSelectEasingOption, + canVerticleExit(exitSide) { + if (exitSide === 'top') { + inputRef.current?.select() + inputRef.current?.focus() + return Outcome.Handled + } + return Outcome.Passthrough + }, + renderItem: ({item: preset, select}) => ( + onEasingOptionMouseOver(preset)} + onMouseOut={onEasingOptionMouseOut} + onClick={select} + tooltipPlacement={ + (optionsRef.current[preset.label].current?.offsetTop ?? 0) - + (optionsScrollPosition ?? 0) < + PRESET_SIZE + APPROX_TOOLTIP_HEIGHT + ? 'bottom' + : 'top' + } + isSelected={areEasingsSimilar( + easing, + handlesFromCssCubicBezierArgs(preset.value), + )} + /> + ), + }) + + // When the user navigates highlight between presets, focus the preset el and set the + // easing data to match the highlighted preset + useLayoutEffect(() => { + if ( + grid.currentSelection !== null && + document.activeElement !== inputRef.current // prevents taking focus away from input + ) { + const maybePresetEl = + optionsRef.current?.[grid.currentSelection.label]?.current + maybePresetEl?.focus() + setEdit(grid.currentSelection.value) + } + }, [grid.currentSelection]) + + return ( + + + grid.onParentEltKeyDown(evt)} + > + {grid.gridItems} + {grid.gridItems.length === 0 ? ( + No results found + ) : undefined} + + inputRef.current?.focus()}> + + + ) } export default CurveEditorPopover -function cssCubicBezierArgsToHandles( - str: string, -): - | undefined - | [ - leftHandle2: number, - leftHandle3: number, - rightHandle0: number, - rightHandle1: number, - ] { - if (str.length > 128) { - // string too long - return undefined - } - const args = str.split(',') - if (args.length !== 4) return undefined - const nums = args.map((arg) => { - return Number(arg.trim()) - }) +function setTempValue( + tempTransaction: React.MutableRefObject, + props: IProps, + cur: Keyframe, + next: Keyframe, + newCurve: string, +): void { + tempTransaction.current?.discard() + tempTransaction.current = null - if (!nums.every((v) => isFinite(v))) return undefined + const handles = handlesFromCssCubicBezierArgs(newCurve) + if (handles === null) return - if (nums[0] < 0 || nums[0] > 1 || nums[2] < 0 || nums[2] > 1) return undefined - return nums as $IntentionalAny + tempTransaction.current = transactionSetCubicBezier(props, cur, next, handles) +} + +function discardTempValue( + tempTransaction: React.MutableRefObject, +): 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) } diff --git a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/CurveEditorPopover/CurveSegmentEditor.tsx b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/CurveEditorPopover/CurveSegmentEditor.tsx new file mode 100644 index 0000000..5e1b96f --- /dev/null +++ b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/CurveEditorPopover/CurveSegmentEditor.tsx @@ -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[0] + +const CurveSegmentEditor: React.FC = (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(null) + + const viewboxToElWidthRatio = VIEWBOX_SIZE / (nodeSVG?.clientWidth || 1) + const viewboxToElHeightRatio = VIEWBOX_SIZE / (nodeSVG?.clientHeight || 1) + + const [refLeft, nodeLeft] = useRefAndState(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(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 ( + + + + + + + + + + + + {/* Unit space, opaque white dot pattern */} + + + + + {/* Fills the whole vertical extremum space, gray dot pattern */} + + + + + + {/* Line from right end of curve to right handle */} + + {/* Line from left end of curve to left handle */} + + + {/* Curve "shadow": the low-opacity filled area between the curve and the diagonal */} + + {/* The curve */} + + + {/* Right end of curve */} + + {/* Left end of curve */} + + + {/* Right handle and hit zone */} + + + {/* Left handle and hit zone */} + + + + ) +} +export default CurveSegmentEditor + +function useKeyframeDrag( + svgNode: SVGSVGElement | null, + node: SVGCircleElement | null, + props: IProps, + setHandles: (dx: number, dy: number) => CubicBezierHandles, +): void { + const handlers = useFreezableMemo[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) +} diff --git a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/CurveEditorPopover/EasingOption.tsx b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/CurveEditorPopover/EasingOption.tsx new file mode 100644 index 0000000..e63b956 --- /dev/null +++ b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/CurveEditorPopover/EasingOption.tsx @@ -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[0] + +const EasingOption: React.FC = React.forwardRef((props, ref) => { + const [tooltip, tooltipHostRef] = useTooltip( + {enabled: true, verticalPlacement: props.tooltipPlacement, verticalGap: 0}, + () => ( + + {props.easing.label} + + ), + ) + + return ( + + {tooltip} + + {/* 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. */} + + ) +}) + +export default EasingOption diff --git a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/CurveEditorPopover/SVGCurveSegment.tsx b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/CurveEditorPopover/SVGCurveSegment.tsx new file mode 100644 index 0000000..c5a9f11 --- /dev/null +++ b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/CurveEditorPopover/SVGCurveSegment.tsx @@ -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 = (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 ( + + {/* Control lines */} + + + + {/* Control point hitzonecircles */} + + + + {/* Control point circles */} + + + + {/* Bezier curve */} + + + + + ) +} +export default SVGCurveSegment diff --git a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/CurveEditorPopover/colors.ts b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/CurveEditorPopover/colors.ts new file mode 100644 index 0000000..ad1c9c9 --- /dev/null +++ b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/CurveEditorPopover/colors.ts @@ -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' diff --git a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/CurveEditorPopover/shared.ts b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/CurveEditorPopover/shared.ts new file mode 100644 index 0000000..685deb0 --- /dev/null +++ b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/CurveEditorPopover/shared.ts @@ -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 +} diff --git a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/CurveEditorPopover/useFreezableMemo.ts b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/CurveEditorPopover/useFreezableMemo.ts new file mode 100644 index 0000000..cd0e0ec --- /dev/null +++ b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/CurveEditorPopover/useFreezableMemo.ts @@ -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( + fn: (setFreeze: (isFrozen: boolean) => void) => T, + deps: any[], +): T { + const [isFrozen, setFreeze] = useState(false) + const freezableDeps = useRef(deps) + + if (!isFrozen) freezableDeps.current = deps + + return useMemo(() => fn(setFreeze), freezableDeps.current) +} diff --git a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/CurveEditorPopover/useUIOptionGrid.tsx b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/CurveEditorPopover/useUIOptionGrid.tsx new file mode 100644 index 0000000..3a945f9 --- /dev/null +++ b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/CurveEditorPopover/useUIOptionGrid.tsx @@ -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 = { + /** 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 = { + focusFirstItem(): void + onParentEltKeyDown(evt: KeyboardEvent): Outcome + gridItems: React.ReactNode[] + currentSelection: Item | null +} +export function useUIOptionGrid( + options: UIOptionGridOptions, +): UIOptionGrid { + // Helper functions for moving the highlight in the grid of presets + const [selectionIndex, setSelectionIndex] = useState(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) => { + 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, + } +} diff --git a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/Dot.tsx b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/Dot.tsx index a3bc7c9..6eee3fe 100644 --- a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/Dot.tsx +++ b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/Dot.tsx @@ -21,9 +21,10 @@ import { import SnapCursor from './SnapCursor.svg' import selectedKeyframeIdsIfInSingleTrack from '@theatre/studio/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/selectedKeyframeIdsIfInSingleTrack' -export const dotSize = 6 -const hitZoneSize = 12 -const snapCursorSize = 34 +export const DOT_SIZE_PX = 6 +const HIT_ZONE_SIZE_PX = 12 +const SNAP_CURSOR_SIZE_PX = 34 +const DOT_HOVER_SIZE_PX = DOT_SIZE_PX + 5 const dims = (size: number) => ` left: ${-size / 2}px; @@ -47,12 +48,12 @@ const Square = styled.div<{isSelected: boolean}>` z-index: 1; pointer-events: none; - ${(props) => dims(props.isSelected ? dotSize : dotSize)} + ${dims(DOT_SIZE_PX)} ` const HitZone = styled.div` position: absolute; - ${dims(hitZoneSize)}; + ${dims(HIT_ZONE_SIZE_PX)}; z-index: 1; @@ -64,10 +65,10 @@ const HitZone = styled.div` &:hover:after { position: absolute; - top: calc(50% - ${snapCursorSize / 2}px); - left: calc(50% - ${snapCursorSize / 2}px); - width: ${snapCursorSize}px; - height: ${snapCursorSize}px; + top: calc(50% - ${SNAP_CURSOR_SIZE_PX / 2}px); + left: calc(50% - ${SNAP_CURSOR_SIZE_PX / 2}px); + width: ${SNAP_CURSOR_SIZE_PX}px; + height: ${SNAP_CURSOR_SIZE_PX}px; display: block; content: ' '; background: url(${SnapCursor}) no-repeat 100% 100%; @@ -80,7 +81,7 @@ const HitZone = styled.div` } &:hover + ${Square}, &.beingDragged + ${Square} { - ${dims(dotSize + 5)} + ${dims(DOT_HOVER_SIZE_PX)} } ` @@ -143,13 +144,13 @@ function useDragKeyframe( let toUnitSpace: SequenceEditorPanelLayout['scaledSpace']['toUnitSpace'] let tempTransaction: CommitOrDiscard | undefined let propsAtStartOfDrag: IProps - let startingLayout: SequenceEditorPanelLayout let selectionDragHandlers: | ReturnType | undefined return { + debugName: 'Dot/useDragKeyframe', onDragStart(event) { setIsDragging(true) const props = propsRef.current diff --git a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/DopeSheetSelectionView.tsx b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/DopeSheetSelectionView.tsx index cf7d475..a1521fe 100644 --- a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/DopeSheetSelectionView.tsx +++ b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/DopeSheetSelectionView.tsx @@ -57,6 +57,7 @@ function useCaptureSelection( containerNode, useMemo((): Parameters[1] => { return { + debugName: 'DopeSheetSelectionView/useCaptureSelection', dontBlockMouseDown: true, lockCursorTo: 'cell', onDragStart(event) { @@ -202,6 +203,7 @@ namespace utils { let toUnitSpace: SequenceEditorPanelLayout['scaledSpace']['toUnitSpace'] return { + debugName: 'DopeSheetSelectionView/boundsToSelection', onDragStart() { toUnitSpace = val(layoutP.scaledSpace.toUnitSpace) }, diff --git a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/HorizontallyScrollableArea.tsx b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/HorizontallyScrollableArea.tsx index e434196..c853df2 100644 --- a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/HorizontallyScrollableArea.tsx +++ b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/HorizontallyScrollableArea.tsx @@ -80,7 +80,8 @@ function useDragHandlers( const setIsSeeking = val(layoutP.seeker.setIsSeeking) return { - onDrag(dx, _, event) { + debugName: 'HorizontallyScrollableArea', + onDrag(dx: number, _, event) { const deltaPos = scaledSpaceToUnitSpace(dx) const unsnappedPos = clamp(posBeforeSeek + deltaPos, 0, sequence.length) diff --git a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/LengthIndicator/LengthIndicator.tsx b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/LengthIndicator/LengthIndicator.tsx index 7e9f99b..d3b3c91 100644 --- a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/LengthIndicator/LengthIndicator.tsx +++ b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/LengthIndicator/LengthIndicator.tsx @@ -227,6 +227,7 @@ function useDragBulge( let initialLength: number return { + debugName: 'LengthIndicator/useDragBulge', lockCursorTo: 'ew-resize', onDragStart(event) { setIsDragging(true) diff --git a/theatre/studio/src/panels/SequenceEditorPanel/GraphEditor/BasicKeyframedTrack/KeyframeEditor/CurveHandle.tsx b/theatre/studio/src/panels/SequenceEditorPanel/GraphEditor/BasicKeyframedTrack/KeyframeEditor/CurveHandle.tsx index e26acef..9ac66a2 100644 --- a/theatre/studio/src/panels/SequenceEditorPanel/GraphEditor/BasicKeyframedTrack/KeyframeEditor/CurveHandle.tsx +++ b/theatre/studio/src/panels/SequenceEditorPanel/GraphEditor/BasicKeyframedTrack/KeyframeEditor/CurveHandle.tsx @@ -131,6 +131,7 @@ function useOurDrags(node: SVGCircleElement | null, props: IProps): void { let tempTransaction: CommitOrDiscard | undefined let unlockExtremums: VoidFn | undefined return { + debugName: 'CurveHandler/useOurDrags', lockCursorTo: 'move', onDragStart() { propsAtStartOfDrag = propsRef.current diff --git a/theatre/studio/src/panels/SequenceEditorPanel/GraphEditor/BasicKeyframedTrack/KeyframeEditor/GraphEditorDotNonScalar.tsx b/theatre/studio/src/panels/SequenceEditorPanel/GraphEditor/BasicKeyframedTrack/KeyframeEditor/GraphEditorDotNonScalar.tsx index 25ca26c..cfdb22e 100644 --- a/theatre/studio/src/panels/SequenceEditorPanel/GraphEditor/BasicKeyframedTrack/KeyframeEditor/GraphEditorDotNonScalar.tsx +++ b/theatre/studio/src/panels/SequenceEditorPanel/GraphEditor/BasicKeyframedTrack/KeyframeEditor/GraphEditorDotNonScalar.tsx @@ -115,6 +115,7 @@ function useDragKeyframe( let unlockExtremums: VoidFn | undefined return { + debugName: 'GraphEditorDotNonScalar/useDragKeyframe', lockCursorTo: 'ew-resize', onDragStart(event) { setIsDragging(true) diff --git a/theatre/studio/src/panels/SequenceEditorPanel/GraphEditor/BasicKeyframedTrack/KeyframeEditor/GraphEditorDotScalar.tsx b/theatre/studio/src/panels/SequenceEditorPanel/GraphEditor/BasicKeyframedTrack/KeyframeEditor/GraphEditorDotScalar.tsx index c2a90ab..eabd276 100644 --- a/theatre/studio/src/panels/SequenceEditorPanel/GraphEditor/BasicKeyframedTrack/KeyframeEditor/GraphEditorDotScalar.tsx +++ b/theatre/studio/src/panels/SequenceEditorPanel/GraphEditor/BasicKeyframedTrack/KeyframeEditor/GraphEditorDotScalar.tsx @@ -118,6 +118,7 @@ function useDragKeyframe( let keepSpeeds = false return { + debugName: 'GraphEditorDotScalar/useDragKeyframe', lockCursorTo: 'move', onDragStart(event) { setIsDragging(true) diff --git a/theatre/studio/src/panels/SequenceEditorPanel/RightOverlay/FocusRangeZone/FocusRangeStrip.tsx b/theatre/studio/src/panels/SequenceEditorPanel/RightOverlay/FocusRangeZone/FocusRangeStrip.tsx index c71c41c..e353b7a 100644 --- a/theatre/studio/src/panels/SequenceEditorPanel/RightOverlay/FocusRangeZone/FocusRangeStrip.tsx +++ b/theatre/studio/src/panels/SequenceEditorPanel/RightOverlay/FocusRangeZone/FocusRangeStrip.tsx @@ -190,6 +190,7 @@ const FocusRangeStrip: React.FC<{ let newStartPosition: number, newEndPosition: number return { + debugName: 'FocusRangeStrip', onDragStart(event) { existingRange = existingRangeD.getValue() diff --git a/theatre/studio/src/panels/SequenceEditorPanel/RightOverlay/FocusRangeZone/FocusRangeThumb.tsx b/theatre/studio/src/panels/SequenceEditorPanel/RightOverlay/FocusRangeZone/FocusRangeThumb.tsx index 2f0fe3c..32bbc33 100644 --- a/theatre/studio/src/panels/SequenceEditorPanel/RightOverlay/FocusRangeZone/FocusRangeThumb.tsx +++ b/theatre/studio/src/panels/SequenceEditorPanel/RightOverlay/FocusRangeZone/FocusRangeThumb.tsx @@ -181,6 +181,7 @@ const FocusRangeThumb: React.FC<{ let scaledSpaceToUnitSpace: (s: number) => number return { + debugName: 'FocusRangeThumb', onDragStart() { sheet = val(layoutP.sheet) const sequence = sheet.getSequence() diff --git a/theatre/studio/src/panels/SequenceEditorPanel/RightOverlay/FocusRangeZone/FocusRangeZone.tsx b/theatre/studio/src/panels/SequenceEditorPanel/RightOverlay/FocusRangeZone/FocusRangeZone.tsx index c6e7ac0..5287045 100644 --- a/theatre/studio/src/panels/SequenceEditorPanel/RightOverlay/FocusRangeZone/FocusRangeZone.tsx +++ b/theatre/studio/src/panels/SequenceEditorPanel/RightOverlay/FocusRangeZone/FocusRangeZone.tsx @@ -112,6 +112,7 @@ function usePanelDragZoneGestureHandlers( let minFocusRangeStripWidth: number return { + debugName: 'FocusRangeZone/focusRangeCreationGestureHandlers', onDragStart(event) { clippedSpaceToUnitSpace = val(layoutP.clippedSpace.toUnitSpace) scaledSpaceToUnitSpace = val(layoutP.scaledSpace.toUnitSpace) @@ -181,6 +182,7 @@ function usePanelDragZoneGestureHandlers( let tempTransaction: CommitOrDiscard | undefined let unlock: VoidFn | undefined return { + debugName: 'FocusRangeZone/panelMoveGestureHandlers', onDragStart() { stuffBeforeDrag = panelStuffRef.current if (unlock) { @@ -229,6 +231,7 @@ function usePanelDragZoneGestureHandlers( let currentGestureHandlers: undefined | Parameters[1] return { + debugName: 'FocusRangeZone', onDragStart(event) { if (event.shiftKey) { setMode('creating') diff --git a/theatre/studio/src/panels/SequenceEditorPanel/RightOverlay/Playhead.tsx b/theatre/studio/src/panels/SequenceEditorPanel/RightOverlay/Playhead.tsx index 9d64403..8022df8 100644 --- a/theatre/studio/src/panels/SequenceEditorPanel/RightOverlay/Playhead.tsx +++ b/theatre/studio/src/panels/SequenceEditorPanel/RightOverlay/Playhead.tsx @@ -206,6 +206,7 @@ const Playhead: React.FC<{layoutP: Pointer}> = ({ let scaledSpaceToUnitSpace: typeof layoutP.scaledSpace.toUnitSpace.$$__pointer_type return { + debugName: 'Playhead', onDragStart() { sequence = val(layoutP.sheet).getSequence() posBeforeSeek = sequence.position diff --git a/theatre/studio/src/uiComponents/Popover/BasicPopover.tsx b/theatre/studio/src/uiComponents/Popover/BasicPopover.tsx index c320cde..f3f8c25 100644 --- a/theatre/studio/src/uiComponents/Popover/BasicPopover.tsx +++ b/theatre/studio/src/uiComponents/Popover/BasicPopover.tsx @@ -31,11 +31,21 @@ const Container = styled.div` } ` -const BasicPopover: React.FC<{className?: string}> = React.forwardRef( - ({children, className}, ref) => { +const BasicPopover: React.FC<{ + className?: string + showPopoverEdgeTriangle?: boolean +}> = React.forwardRef( + ( + { + children, + className, + showPopoverEdgeTriangle: showPopoverEdgeTriangle = true, + }, + ref, + ) => { return ( - + {showPopoverEdgeTriangle ? : undefined} {children} ) diff --git a/theatre/studio/src/uiComponents/Popover/TooltipWrapper.tsx b/theatre/studio/src/uiComponents/Popover/TooltipWrapper.tsx index 4d3b357..234afbc 100644 --- a/theatre/studio/src/uiComponents/Popover/TooltipWrapper.tsx +++ b/theatre/studio/src/uiComponents/Popover/TooltipWrapper.tsx @@ -7,9 +7,17 @@ import useRefAndState from '@theatre/studio/utils/useRefAndState' import useOnClickOutside from '@theatre/studio/uiComponents/useOnClickOutside' import onPointerOutside from '@theatre/studio/uiComponents/onPointerOutside' import noop from '@theatre/shared/utils/noop' +import {clamp} from 'lodash-es' const minimumDistanceOfArrowToEdgeOfPopover = 8 +export type AbsolutePlacementBoxConstraints = { + minX?: number + maxX?: number + minY?: number + maxY?: number +} + const TooltipWrapper: React.FC<{ target: HTMLElement | SVGElement onClickOutside?: (e: MouseEvent) => void @@ -18,6 +26,9 @@ const TooltipWrapper: React.FC<{ threshold: number callback: (e: MouseEvent) => void } + verticalPlacement?: 'top' | 'bottom' | 'overlay' + verticalGap?: number // Has no effect if verticalPlacement === 'overlay' + constraints?: AbsolutePlacementBoxConstraints }> = (props) => { const originalElement = props.children() const [ref, container] = useRefAndState(null) @@ -36,23 +47,42 @@ const TooltipWrapper: React.FC<{ useLayoutEffect(() => { if (!containerRect || !container || !targetRect) return - const gap = 8 + const gap = props.verticalGap ?? 8 const arrowStyle: Record = {} - let verticalPlacement: 'bottom' | 'top' | 'overlay' = 'bottom' + let verticalPlacement: 'bottom' | 'top' | 'overlay' = + props.verticalPlacement ?? 'bottom' let top = 0 let left = 0 - if (targetRect.bottom + containerRect.height + gap < windowSize.height) { - verticalPlacement = 'bottom' - top = targetRect.bottom + gap - arrowStyle.top = '0px' - } else if (targetRect.top > containerRect.height + gap) { - verticalPlacement = 'top' - top = targetRect.top - (containerRect.height + gap) - arrowStyle.bottom = '0px' - arrowStyle.transform = 'rotateZ(180deg)' - } else { - verticalPlacement = 'overlay' + if (verticalPlacement === 'bottom') { + if (targetRect.bottom + containerRect.height + gap < windowSize.height) { + verticalPlacement = 'bottom' + top = targetRect.bottom + gap + arrowStyle.top = '0px' + } else if (targetRect.top > containerRect.height + gap) { + verticalPlacement = 'top' + top = targetRect.top - (containerRect.height + gap) + arrowStyle.bottom = '0px' + arrowStyle.transform = 'rotateZ(180deg)' + } else { + verticalPlacement = 'overlay' + } + } else if (verticalPlacement === 'top') { + if (targetRect.top > containerRect.height + gap) { + verticalPlacement = 'top' + top = targetRect.top - (containerRect.height + gap) + arrowStyle.bottom = '0px' + arrowStyle.transform = 'rotateZ(180deg)' + } else if ( + targetRect.bottom + containerRect.height + gap < + windowSize.height + ) { + verticalPlacement = 'bottom' + top = targetRect.bottom + gap + arrowStyle.top = '0px' + } else { + verticalPlacement = 'overlay' + } } let arrowLeft = 0 @@ -77,7 +107,16 @@ const TooltipWrapper: React.FC<{ arrowStyle.left = arrowLeft + 'px' } - const pos = {left, top} + const { + minX = -Infinity, + maxX = Infinity, + minY = -Infinity, + maxY = Infinity, + } = props.constraints ?? {} + const pos = { + left: clamp(left, minX, maxX - containerRect.width), + top: clamp(top, minY, maxY + containerRect.height), + } container.style.left = pos.left + 'px' container.style.top = pos.top + 'px' @@ -90,7 +129,14 @@ const TooltipWrapper: React.FC<{ props.onPointerOutside.callback, ) } - }, [containerRect, container, props.target, targetRect, windowSize]) + }, [ + containerRect, + container, + props.target, + targetRect, + windowSize, + props.onPointerOutside, + ]) useOnClickOutside(container, props.onClickOutside ?? noop) diff --git a/theatre/studio/src/uiComponents/Popover/usePopover.tsx b/theatre/studio/src/uiComponents/Popover/usePopover.tsx index 48db18c..59a67d9 100644 --- a/theatre/studio/src/uiComponents/Popover/usePopover.tsx +++ b/theatre/studio/src/uiComponents/Popover/usePopover.tsx @@ -1,6 +1,7 @@ import React, {useCallback, useContext, useMemo, useState} from 'react' import {createPortal} from 'react-dom' import {PortalContext} from 'reakit' +import type {AbsolutePlacementBoxConstraints} from './TooltipWrapper'; import TooltipWrapper from './TooltipWrapper' export type OpenFn = (e: React.MouseEvent, target: HTMLElement) => void @@ -21,6 +22,7 @@ export default function usePopover( closeWhenPointerIsDistant?: boolean pointerDistanceThreshold?: number closeOnClickOutside?: boolean + constraints?: AbsolutePlacementBoxConstraints }, render: () => React.ReactElement, ): [node: React.ReactNode, open: OpenFn, close: CloseFn, isOpen: boolean] { @@ -62,6 +64,7 @@ export default function usePopover( target={state.target} onClickOutside={onClickOutside} onPointerOutside={onPointerOutside} + constraints={opts.constraints} />, portalLayer!, ) diff --git a/theatre/studio/src/uiComponents/Popover/useTooltip.tsx b/theatre/studio/src/uiComponents/Popover/useTooltip.tsx index 6f0140c..69813d5 100644 --- a/theatre/studio/src/uiComponents/Popover/useTooltip.tsx +++ b/theatre/studio/src/uiComponents/Popover/useTooltip.tsx @@ -10,7 +10,13 @@ import {PortalContext} from 'reakit' import noop from '@theatre/shared/utils/noop' export default function useTooltip( - opts: {enabled?: boolean; enterDelay?: number; exitDelay?: number}, + opts: { + enabled?: boolean + enterDelay?: number + exitDelay?: number + verticalPlacement?: 'top' | 'bottom' | 'overlay' + verticalGap?: number + }, render: () => React.ReactElement, ): [ node: React.ReactNode, @@ -53,6 +59,8 @@ export default function useTooltip( children={render} target={targetNode} onClickOutside={noop} + verticalPlacement={opts.verticalPlacement} + verticalGap={opts.verticalGap} />, portalLayer!, ) diff --git a/theatre/studio/src/uiComponents/useDrag.ts b/theatre/studio/src/uiComponents/useDrag.ts index 6d0ccd4..cf7f62b 100644 --- a/theatre/studio/src/uiComponents/useDrag.ts +++ b/theatre/studio/src/uiComponents/useDrag.ts @@ -2,8 +2,15 @@ import type {$FixMe} from '@theatre/shared/utils/types' import {useLayoutEffect, useRef} from 'react' import useRefAndState from '@theatre/studio/utils/useRefAndState' import {useCssCursorLock} from './PointerEventsHandler' +import type {CapturedPointer} from '@theatre/studio/UIRoot/PointerCapturing' +import {usePointerCapturing} from '@theatre/studio/UIRoot/PointerCapturing' export type UseDragOpts = { + /** + * Provide a name for the thing wanting to use the drag helper. + * This can show up in various errors and potential debug logs to help narrow down. + */ + debugName: string /** * Setting it to true will disable the listeners. */ @@ -19,9 +26,14 @@ export type UseDragOpts = { /** * Called at the start of the gesture. Mind you, that this would be called, even * if the user is just clicking (and not dragging). However, if the gesture turns - * out to be a click, then onDragEnd(false) will be called. Otherwise, + * out to be a click, then `onDragEnd(false)` will be called. Otherwise, * a series of `onDrag(dx, dy, event)` events will be called, and the * gesture will end with `onDragEnd(true)`. + * + * + * @returns + * onDragStart can be undefined, in which case, we always handle useDrag, + * but when defined, we can allow the handler to return false to indicate ignore this dragging */ onDragStart?: (event: MouseEvent) => void | false /** @@ -58,6 +70,8 @@ export default function useDrag( opts.lockCursorTo!, ) + const {capturePointer} = usePointerCapturing(`useDrag for ${opts.debugName}`) + const stateRef = useRef<{ dragHappened: boolean startPos: { @@ -69,6 +83,7 @@ export default function useDrag( useLayoutEffect(() => { if (!target) return + let capturedPointer: undefined | CapturedPointer const getDistances = (event: MouseEvent): [number, number] => { const {startPos} = stateRef.current return [event.screenX - startPos.x, event.screenY - startPos.y] @@ -96,6 +111,7 @@ export default function useDrag( } const removeDragListeners = () => { + capturedPointer?.release() document.removeEventListener('mousemove', dragHandler) document.removeEventListener('mouseup', dragEndHandler) } @@ -115,13 +131,23 @@ export default function useDrag( } const dragStartHandler = (event: MouseEvent) => { + // defensively release + capturedPointer?.release() + const opts = optsRef.current if (opts.disabled === true) return if (event.button !== 0) return - const resultOfStart = opts.onDragStart && opts.onDragStart(event) - if (resultOfStart === false) return + // onDragStart can be undefined, in which case, we always handle useDrag, + // but when defined, we can allow the handler to return false to indicate ignore this dragging + if (opts.onDragStart != null) { + const shouldIgnore = opts.onDragStart(event) === false + if (shouldIgnore) return + } + + // need to capture pointer after we know the provided handler wants to handle drag start + capturedPointer = capturePointer('Drag start') if (!opts.dontBlockMouseDown) { event.stopPropagation()