From 197d59e4c4430abf4a46289c626e954238a44ff3 Mon Sep 17 00:00:00 2001 From: Aria Minaei Date: Tue, 3 Aug 2021 23:56:04 +0200 Subject: [PATCH] Keyframe snapping * Implemented useCursorLock() to lock the cursor icon during a gesture * Keyframes now snap to other keyframes * Keyframes snap to the playhead * The playhead also snaps to keyframes --- theatre/studio/src/UI.ts | 4 +- theatre/studio/src/UIRoot/UIRoot.tsx | 4 +- theatre/studio/src/css.ts | 8 ++ .../src/panels/BasePanel/PaneWrapper.tsx | 20 +++++ .../panels/BasePanel/PanelResizeHandle.tsx | 3 +- .../src/panels/BasePanel/PanelWrapper.tsx | 3 +- .../ObjectEditorPanel/ObjectEditorPanel.tsx | 7 +- .../propEditors/CompoundPropEditor.tsx | 3 +- .../utils/NextPrevKeyframeCursors.tsx | 4 +- .../propEditors/utils/SingleRowPropEditor.tsx | 3 +- .../src/panels/OutlinePanel/BaseItem.tsx | 5 +- .../src/panels/OutlinePanel/OutlinePanel.tsx | 9 ++- .../KeyframeEditor/Dot.tsx | 47 +++++++++-- .../Right/HorizontallyScrollableArea.tsx | 51 +++++++++--- .../Right/LengthIndicator/LengthIndicator.tsx | 5 +- .../KeyframeEditor/CurveHandle.tsx | 3 +- .../KeyframeEditor/Dot.tsx | 31 +++++-- .../RightOverlay/HorizontalScrollbar.tsx | 3 +- .../RightOverlay/Playhead.tsx | 53 ++++++++++-- .../RightOverlay/TopStrip.tsx | 3 +- .../SequenceEditorPanel/positionSnapping.ts | 2 + .../src/uiComponents/PointerEventsHandler.tsx | 81 +++++++++++++++++++ .../src/uiComponents/Popover/Popover.tsx | 3 +- .../src/uiComponents/createCursorLock.ts | 33 ++++---- .../RightClickMenu/RightClickMenu.tsx | 3 +- .../toolbar/ToolbarIconButton.tsx | 3 +- theatre/studio/src/uiComponents/useDrag.ts | 27 ++++--- 27 files changed, 340 insertions(+), 81 deletions(-) create mode 100644 theatre/studio/src/panels/SequenceEditorPanel/positionSnapping.ts create mode 100644 theatre/studio/src/uiComponents/PointerEventsHandler.tsx diff --git a/theatre/studio/src/UI.ts b/theatre/studio/src/UI.ts index 37515a2..c66989a 100644 --- a/theatre/studio/src/UI.ts +++ b/theatre/studio/src/UI.ts @@ -9,7 +9,7 @@ export default class UI { _showing = false private _renderTimeout: NodeJS.Timer | undefined = undefined private _documentBodyUIIsRenderedIn: HTMLElement | undefined = undefined - readonly containerShadow: HTMLElement + readonly containerShadow: ShadowRoot & HTMLElement constructor(readonly studio: Studio) { // @todo we can't bootstrap theatre (as in, to design theatre using theatre), if we rely on IDed elements @@ -29,7 +29,7 @@ export default class UI { // To see why I had to cast this value to HTMLElement, take a look at its // references. There are a few functions that actually work with a ShadowRoot // but are typed to accept HTMLElement - }) as $IntentionalAny as HTMLElement + }) as $IntentionalAny as ShadowRoot & HTMLElement } show() { diff --git a/theatre/studio/src/UIRoot/UIRoot.tsx b/theatre/studio/src/UIRoot/UIRoot.tsx index ab254b6..dda5877 100644 --- a/theatre/studio/src/UIRoot/UIRoot.tsx +++ b/theatre/studio/src/UIRoot/UIRoot.tsx @@ -11,6 +11,7 @@ import useRefAndState from '@theatre/studio/utils/useRefAndState' import {PortalContext} from 'reakit' import type {$IntentionalAny} from '@theatre/shared/utils/types' import useKeyboardShortcuts from './useKeyboardShortcuts' +import PointerEventsHandler from '@theatre/studio/uiComponents/PointerEventsHandler' const GlobalStyle = createGlobalStyle` :host { @@ -31,14 +32,13 @@ const GlobalStyle = createGlobalStyle` } ` -const Container = styled.div` +const Container = styled(PointerEventsHandler)` z-index: 50; position: fixed; top: 0px; right: 0px; bottom: 0px; left: 0px; - pointer-events: none; ` const PortalLayer = styled.div` diff --git a/theatre/studio/src/css.ts b/theatre/studio/src/css.ts index 0e08183..c410aa0 100644 --- a/theatre/studio/src/css.ts +++ b/theatre/studio/src/css.ts @@ -1,4 +1,12 @@ import {lighten} from 'polished' +import {css} from 'styled-components' + +export const pointerEventsAutoInNormalMode = css` + pointer-events: none; + #pointer-root.normal & { + pointer-events: auto; + } +` export const theme = { panel: { diff --git a/theatre/studio/src/panels/BasePanel/PaneWrapper.tsx b/theatre/studio/src/panels/BasePanel/PaneWrapper.tsx index 11de9e0..e6ed7d6 100644 --- a/theatre/studio/src/panels/BasePanel/PaneWrapper.tsx +++ b/theatre/studio/src/panels/BasePanel/PaneWrapper.tsx @@ -77,8 +77,28 @@ const ClosePanelButton = styled.button` } ` +/** + * The &:after part blocks pointer events from reaching the content of the + * pane when a drag gesture is active in theatre's UI. It's a hack and its downside + * is that pane content cannot interact with the rest of theatre's UI while a drag + * gesture is active. + * @todo find a less hacky way? + */ const F2 = styled(F2Impl)` position: relative; + + &:after { + z-index: 10; + position: absolute; + inset: 0; + display: block; + content: ' '; + pointer-events: none; + + #pointer-root:not(.normal) & { + pointer-events: auto; + } + } ` const ErrorContainer = styled.div` diff --git a/theatre/studio/src/panels/BasePanel/PanelResizeHandle.tsx b/theatre/studio/src/panels/BasePanel/PanelResizeHandle.tsx index 232240f..6301437 100644 --- a/theatre/studio/src/panels/BasePanel/PanelResizeHandle.tsx +++ b/theatre/studio/src/panels/BasePanel/PanelResizeHandle.tsx @@ -7,11 +7,12 @@ import {lighten} from 'polished' import React, {useMemo, useRef, useState} from 'react' import styled from 'styled-components' import {panelDimsToPanelPosition, usePanel} from './BasePanel' +import {pointerEventsAutoInNormalMode} from '@theatre/studio/css' const Base = styled.div` position: absolute; z-index: 10; - pointer-events: auto; + ${pointerEventsAutoInNormalMode}; &:after { position: absolute; top: -2px; diff --git a/theatre/studio/src/panels/BasePanel/PanelWrapper.tsx b/theatre/studio/src/panels/BasePanel/PanelWrapper.tsx index 51a35b5..764f1b0 100644 --- a/theatre/studio/src/panels/BasePanel/PanelWrapper.tsx +++ b/theatre/studio/src/panels/BasePanel/PanelWrapper.tsx @@ -1,3 +1,4 @@ +import {pointerEventsAutoInNormalMode} from '@theatre/studio/css' import React from 'react' import styled from 'styled-components' import {usePanel} from './BasePanel' @@ -7,7 +8,7 @@ const Container = styled.div` position: absolute; user-select: none; box-sizing: border-box; - pointer-events: auto; + ${pointerEventsAutoInNormalMode}; /* box-shadow: 1px 2px 10px -5px black; */ z-index: 1000; diff --git a/theatre/studio/src/panels/ObjectEditorPanel/ObjectEditorPanel.tsx b/theatre/studio/src/panels/ObjectEditorPanel/ObjectEditorPanel.tsx index fd55625..4b630de 100644 --- a/theatre/studio/src/panels/ObjectEditorPanel/ObjectEditorPanel.tsx +++ b/theatre/studio/src/panels/ObjectEditorPanel/ObjectEditorPanel.tsx @@ -11,6 +11,7 @@ import { TitleBar_Piece, TitleBar_Punctuation, } from '@theatre/studio/panels/BasePanel/common' +import {pointerEventsAutoInNormalMode} from '@theatre/studio/css' const Container = styled.div` background-color: transparent; @@ -30,7 +31,7 @@ const Container = styled.div` bottom: 0; right: 0; width: 20px; - pointer-events: auto; + ${pointerEventsAutoInNormalMode}; } ` @@ -54,7 +55,7 @@ const Title = styled.div` font-weight: 500; font-size: 10px; user-select: none; - pointer-events: auto; + ${pointerEventsAutoInNormalMode}; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; @@ -84,7 +85,7 @@ const Header = styled.div` ` const Body = styled.div` - pointer-events: auto; + ${pointerEventsAutoInNormalMode}; position: absolute; top: ${headerHeight}; left: 0; diff --git a/theatre/studio/src/panels/ObjectEditorPanel/propEditors/CompoundPropEditor.tsx b/theatre/studio/src/panels/ObjectEditorPanel/propEditors/CompoundPropEditor.tsx index cf08a88..ef5b52d 100644 --- a/theatre/studio/src/panels/ObjectEditorPanel/propEditors/CompoundPropEditor.tsx +++ b/theatre/studio/src/panels/ObjectEditorPanel/propEditors/CompoundPropEditor.tsx @@ -15,11 +15,12 @@ import { rowBg, } from './utils/SingleRowPropEditor' import DefaultOrStaticValueIndicator from './utils/DefaultValueIndicator' +import {pointerEventsAutoInNormalMode} from '@theatre/studio/css' const Container = styled.div` --step: 8px; --left-pad: 0px; - pointer-events: auto; + ${pointerEventsAutoInNormalMode}; ` const Header = styled.div` diff --git a/theatre/studio/src/panels/ObjectEditorPanel/propEditors/utils/NextPrevKeyframeCursors.tsx b/theatre/studio/src/panels/ObjectEditorPanel/propEditors/utils/NextPrevKeyframeCursors.tsx index 9a16c06..00fac97 100644 --- a/theatre/studio/src/panels/ObjectEditorPanel/propEditors/utils/NextPrevKeyframeCursors.tsx +++ b/theatre/studio/src/panels/ObjectEditorPanel/propEditors/utils/NextPrevKeyframeCursors.tsx @@ -1,4 +1,5 @@ import type {Keyframe} from '@theatre/core/projects/store/types/SheetState_Historic' +import {pointerEventsAutoInNormalMode} from '@theatre/studio/css' import {transparentize} from 'polished' import React from 'react' import styled from 'styled-components' @@ -75,7 +76,8 @@ const PrevOrNextButton = styled(Button)<{available: boolean}>` props.available ? nextPrevCursorsTheme.onColor : nextPrevCursorsTheme.offColor}; - pointer-events: ${(props) => (props.available ? 'auto' : 'none')}; + + ${(props) => (props.available ? pointerEventsAutoInNormalMode : '')}; ` const Prev = styled(PrevOrNextButton)<{available: boolean}>` diff --git a/theatre/studio/src/panels/ObjectEditorPanel/propEditors/utils/SingleRowPropEditor.tsx b/theatre/studio/src/panels/ObjectEditorPanel/propEditors/utils/SingleRowPropEditor.tsx index 8ebc0f2..8aaddb0 100644 --- a/theatre/studio/src/panels/ObjectEditorPanel/propEditors/utils/SingleRowPropEditor.tsx +++ b/theatre/studio/src/panels/ObjectEditorPanel/propEditors/utils/SingleRowPropEditor.tsx @@ -9,6 +9,7 @@ import type {useEditingToolsForPrimitiveProp} from '@theatre/studio/panels/Objec import {shadeToColor} from '@theatre/studio/panels/ObjectEditorPanel/propEditors/utils/useEditingToolsForPrimitiveProp' import styled, {css} from 'styled-components' import {transparentize} from 'polished' +import {pointerEventsAutoInNormalMode} from '@theatre/studio/css' export const indentationFormula = `calc(var(--left-pad) + var(--depth) * var(--step))` @@ -39,7 +40,7 @@ const Row = styled.div` align-items: stretch; --right-width: 60%; position: relative; - pointer-events: auto; + ${pointerEventsAutoInNormalMode}; ${rowBg}; ` diff --git a/theatre/studio/src/panels/OutlinePanel/BaseItem.tsx b/theatre/studio/src/panels/OutlinePanel/BaseItem.tsx index e8dd492..dc4cacb 100644 --- a/theatre/studio/src/panels/OutlinePanel/BaseItem.tsx +++ b/theatre/studio/src/panels/OutlinePanel/BaseItem.tsx @@ -5,6 +5,7 @@ import styled, {css} from 'styled-components' import noop from '@theatre/shared/utils/noop' import {transparentize, darken, opacify, lighten} from 'polished' import {rowBgColor} from '@theatre/studio/panels/ObjectEditorPanel/propEditors/utils/SingleRowPropEditor' +import {pointerEventsAutoInNormalMode} from '@theatre/studio/css' export const Container = styled.li<{depth: number}>` --depth: ${(props) => props.depth}; @@ -70,7 +71,7 @@ const Head_Label = styled.span` ${outlineItemFont}; padding: 2px 8px; - pointer-events: auto; + ${pointerEventsAutoInNormalMode}; position: relative; display: block; height: 13px; @@ -96,7 +97,7 @@ const Head_Label = styled.span` display: block; content: ' '; z-index: 0; - pointer-events: auto; + ${pointerEventsAutoInNormalMode}; } ` diff --git a/theatre/studio/src/panels/OutlinePanel/OutlinePanel.tsx b/theatre/studio/src/panels/OutlinePanel/OutlinePanel.tsx index 6a33afc..c50e83f 100644 --- a/theatre/studio/src/panels/OutlinePanel/OutlinePanel.tsx +++ b/theatre/studio/src/panels/OutlinePanel/OutlinePanel.tsx @@ -2,6 +2,7 @@ import React from 'react' import styled from 'styled-components' import {panelZIndexes} from '@theatre/studio/panels/BasePanel/common' import ProjectsList from './ProjectsList/ProjectsList' +import {pointerEventsAutoInNormalMode} from '@theatre/studio/css' const Container = styled.div` background-color: transparent; @@ -21,7 +22,7 @@ const Container = styled.div` bottom: 0; left: 0; width: 20px; - pointer-events: auto; + ${pointerEventsAutoInNormalMode}; } &:hover:before { @@ -58,7 +59,7 @@ const Header = styled.div` top: 0; left: 0; width: 180px; - pointer-events: auto; + ${pointerEventsAutoInNormalMode}; &:after { position: absolute; @@ -78,14 +79,14 @@ const Title = styled.div` font-weight: 500; font-size: 10px; user-select: none; - pointer-events: auto; + ${pointerEventsAutoInNormalMode}; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; ` const Body = styled.div` - pointer-events: auto; + ${pointerEventsAutoInNormalMode}; position: absolute; top: ${headerHeight}; left: 0; 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 98fc2d3..c4eed86 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 @@ -14,6 +14,7 @@ import styled from 'styled-components' import type KeyframeEditor from './KeyframeEditor' import {useLockFrameStampPosition} from '@theatre/studio/panels/SequenceEditorPanel/FrameStampPositionProvider' import {attributeNameThatLocksFramestamp} from '@theatre/studio/panels/SequenceEditorPanel/FrameStampPositionProvider' +import {useCursorLock} from '@theatre/studio/uiComponents/PointerEventsHandler' export const dotSize = 6 const hitZoneSize = 12 @@ -46,10 +47,20 @@ const Square = styled.div<{isSelected: boolean}>` const HitZone = styled.div` position: absolute; ${dims(hitZoneSize)}; + z-index: 1; + cursor: ew-resize; - &:hover + ${Square} { + #pointer-root.draggingpositioninsequenceeditor & { + pointer-events: auto; + } + + &.beingDragged { + pointer-events: none !important; + } + + &:hover + ${Square}, &.beingDragged + ${Square} { ${dims(dotSize + 5)} } ` @@ -60,7 +71,7 @@ const Dot: React.FC = (props) => { const [ref, node] = useRefAndState(null) const [contextMenu] = useKeyframeContextMenu(node, props) - useDragKeyframe(node, props) + const [isDragging] = useDragKeyframe(node, props) return ( <> @@ -71,6 +82,7 @@ const Dot: React.FC = (props) => { [attributeNameThatLocksFramestamp]: props.keyframe.position.toFixed(3), }} + className={isDragging ? 'beingDragged' : ''} /> {contextMenu} @@ -107,7 +119,10 @@ function useKeyframeContextMenu(node: HTMLDivElement | null, props: IProps) { }) } -function useDragKeyframe(node: HTMLDivElement | null, props: IProps) { +function useDragKeyframe( + node: HTMLDivElement | null, + props: IProps, +): [isDragging: boolean] { const [isDragging, setIsDragging] = useState(false) useLockFrameStampPosition(isDragging, props.keyframe.position) @@ -125,7 +140,6 @@ function useDragKeyframe(node: HTMLDivElement | null, props: IProps) { | undefined return { - lockCursorTo: 'ew-resize', onDragStart(event) { setIsDragging(true) if (propsRef.current.selection) { @@ -149,11 +163,30 @@ function useDragKeyframe(node: HTMLDivElement | null, props: IProps) { selectionDragHandlers.onDrag(dx, dy, event) return } + const original = propsAtStartOfDrag.trackData.keyframes[propsAtStartOfDrag.index] const deltaPos = toUnitSpace(dx) const newPosBeforeSnapping = Math.max(original.position + deltaPos, 0) + let newPosition = newPosBeforeSnapping + + const snapTarget = event + .composedPath() + .find( + (el): el is Element => + el instanceof Element && + el !== node && + el.hasAttribute('data-pos'), + ) + + if (snapTarget) { + const snapPos = parseFloat(snapTarget.getAttribute('data-pos')!) + if (isFinite(snapPos)) { + newPosition = snapPos + } + } + if (tempTransaction) { tempTransaction.discard() tempTransaction = undefined @@ -163,7 +196,7 @@ function useDragKeyframe(node: HTMLDivElement | null, props: IProps) { { ...propsAtStartOfDrag.leaf.sheetObject.address, trackId: propsAtStartOfDrag.leaf.trackId, - keyframes: [{...original, position: newPosBeforeSnapping}], + keyframes: [{...original, position: newPosition}], }, ) }) @@ -191,4 +224,8 @@ function useDragKeyframe(node: HTMLDivElement | null, props: IProps) { }, []) useDrag(node, gestureHandlers) + + useCursorLock(isDragging, 'draggingPositionInSequenceEditor', 'ew-resize') + + return [isDragging] } diff --git a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/HorizontallyScrollableArea.tsx b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/HorizontallyScrollableArea.tsx index 41070c2..61b65e3 100644 --- a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/HorizontallyScrollableArea.tsx +++ b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/HorizontallyScrollableArea.tsx @@ -9,6 +9,8 @@ import {clamp, mapValues} from 'lodash-es' import React, {useLayoutEffect, useMemo} from 'react' import styled from 'styled-components' import {useReceiveVerticalWheelEvent} from '@theatre/studio/panels/SequenceEditorPanel/VerticalScrollContainer' +import {pointerEventsAutoInNormalMode} from '@theatre/studio/css' +import {useCursorLock} from '@theatre/studio/uiComponents/PointerEventsHandler' const Container = styled.div` position: absolute; @@ -16,7 +18,7 @@ const Container = styled.div` right: 0; overflow-x: scroll; overflow-y: hidden; - pointer-events: all; + ${pointerEventsAutoInNormalMode}; // hide the scrollbar on Gecko scrollbar-width: none; @@ -87,16 +89,42 @@ function useDragHandlers( const setIsSeeking = val(layoutP.seeker.setIsSeeking) return { - onDrag(dx: number) { + onDrag(dx: number, _, event) { const deltaPos = scaledSpaceToUnitSpace(dx) - const newPos = clamp(posBeforeSeek + deltaPos, 0, sequence.length) - sequence.position = newPos + const unsnappedPos = clamp(posBeforeSeek + deltaPos, 0, sequence.length) + + let newPosition = unsnappedPos + + const snapTarget = event.composedPath().find( + (el): el is Element => + el instanceof Element && + // el !== thumbNode && + el.hasAttribute('data-pos'), + ) + + if (snapTarget) { + const snapPos = parseFloat(snapTarget.getAttribute('data-pos')!) + + if (isFinite(snapPos)) { + newPosition = snapPos + } + } + + sequence.position = newPosition }, onDragStart(event) { if (event.target instanceof HTMLInputElement) return false if (event.shiftKey || event.altKey || event.ctrlKey || event.metaKey) { return false } + if ( + event + .composedPath() + .some((el) => el instanceof HTMLElement && el.draggable === true) + ) { + return false + } + const initialPositionInClippedSpace = event.clientX - containerEl!.getBoundingClientRect().left @@ -114,11 +142,12 @@ function useDragHandlers( onDragEnd() { setIsSeeking(false) }, - lockCursorTo: 'ew-resize', } }, [layoutP, containerEl]) - useDrag(containerEl, handlers) + const [isDragigng] = useDrag(containerEl, handlers) + + useCursorLock(isDragigng, 'draggingPositionInSequenceEditor', 'ew-resize') } function useHandlePanAndZoom( @@ -201,13 +230,17 @@ function useUpdateScrollFromClippedSpaceRange( return rangeStartInScaledSpace }) - const untap = d.changesWithoutValues().tap(() => { + const update = () => { const rangeStartInScaledSpace = d.getValue() - node.scrollLeft = rangeStartInScaledSpace - }) + } + const untap = d.changesWithoutValues().tap(update) + + update() + const timeout = setTimeout(update, 100) return () => { + clearTimeout(timeout) untap() } }, [layoutP, node]) 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 869d51f..1438c67 100644 --- a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/LengthIndicator/LengthIndicator.tsx +++ b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/LengthIndicator/LengthIndicator.tsx @@ -18,6 +18,7 @@ import { } from '@theatre/studio/panels/SequenceEditorPanel/FrameStampPositionProvider' import {GoChevronLeft, GoChevronRight} from 'react-icons/all' import LengthEditorPopover from './LengthEditorPopover' +import {pointerEventsAutoInNormalMode} from '@theatre/studio/css' const coverWidth = 1000 @@ -71,7 +72,7 @@ const Tooltip = styled.div` white-space: nowrap; padding: 1px 8px; border-radius: 0 2px 2px 0; - pointer-events: auto; + ${pointerEventsAutoInNormalMode}; cursor: ew-resize; color: #464646; background-color: #0000004d; @@ -89,7 +90,7 @@ const Tumb = styled.div` white-space: nowrap; padding: 1px 2px; border-radius: 2px; - pointer-events: auto; + ${pointerEventsAutoInNormalMode}; justify-content: center; align-items: center; cursor: ew-resize; 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 929700b..37d6900 100644 --- a/theatre/studio/src/panels/SequenceEditorPanel/GraphEditor/BasicKeyframedTrack/KeyframeEditor/CurveHandle.tsx +++ b/theatre/studio/src/panels/SequenceEditorPanel/GraphEditor/BasicKeyframedTrack/KeyframeEditor/CurveHandle.tsx @@ -11,6 +11,7 @@ import React, {useMemo, useRef} from 'react' import styled from 'styled-components' import {transformBox} from './Curve' import type KeyframeEditor from './KeyframeEditor' +import {pointerEventsAutoInNormalMode} from '@theatre/studio/css' export const dotSize = 6 @@ -28,7 +29,7 @@ const HitZone = styled.circle` r: 6px; fill: transparent; cursor: move; - pointer-events: all; + ${pointerEventsAutoInNormalMode}; &:hover { } &:hover + ${Circle} { diff --git a/theatre/studio/src/panels/SequenceEditorPanel/GraphEditor/BasicKeyframedTrack/KeyframeEditor/Dot.tsx b/theatre/studio/src/panels/SequenceEditorPanel/GraphEditor/BasicKeyframedTrack/KeyframeEditor/Dot.tsx index 188f902..c5d7d75 100644 --- a/theatre/studio/src/panels/SequenceEditorPanel/GraphEditor/BasicKeyframedTrack/KeyframeEditor/Dot.tsx +++ b/theatre/studio/src/panels/SequenceEditorPanel/GraphEditor/BasicKeyframedTrack/KeyframeEditor/Dot.tsx @@ -12,6 +12,8 @@ import type KeyframeEditor from './KeyframeEditor' import type {Keyframe} from '@theatre/core/projects/store/types/SheetState_Historic' import {useLockFrameStampPosition} from '@theatre/studio/panels/SequenceEditorPanel/FrameStampPositionProvider' import {attributeNameThatLocksFramestamp} from '@theatre/studio/panels/SequenceEditorPanel/FrameStampPositionProvider' +import {pointerEventsAutoInNormalMode} from '@theatre/studio/css' +import {useCursorLock} from '@theatre/studio/uiComponents/PointerEventsHandler' export const dotSize = 6 @@ -28,13 +30,23 @@ const HitZone = styled.circle` vector-effect: non-scaling-stroke; r: 6px; fill: transparent; - cursor: move; - pointer-events: all; - &:hover { - } + ${pointerEventsAutoInNormalMode}; + &:hover + ${Circle} { r: 6px; } + + #pointer-root.normal & { + cursor: move; + } + + #pointer-root.draggingpositioninsequenceeditor & { + pointer-events: auto; + } + + &.beingDragged { + pointer-events: none !important; + } ` type IProps = Parameters[0] @@ -47,7 +59,7 @@ const Dot: React.FC = (props) => { const next = trackData.keyframes[index + 1] const [contextMenu] = useKeyframeContextMenu(node, props) - useDragKeyframe(node, props) + const isDragging = useDragKeyframe(node, props) const cyInExtremumSpace = props.extremumSpace.fromValueSpace(cur.value) @@ -63,6 +75,8 @@ const Dot: React.FC = (props) => { {...{ [attributeNameThatLocksFramestamp]: cur.position.toFixed(3), }} + data-pos={cur.position.toFixed(3)} + className={isDragging ? 'beingDragged' : ''} /> = (props) => { export default Dot -function useDragKeyframe(node: SVGCircleElement | null, _props: IProps): void { +function useDragKeyframe( + node: SVGCircleElement | null, + _props: IProps, +): boolean { const [isDragging, setIsDragging] = useState(false) useLockFrameStampPosition(isDragging, _props.keyframe.position) const propsRef = useRef(_props) @@ -203,6 +220,8 @@ function useDragKeyframe(node: SVGCircleElement | null, _props: IProps): void { }, []) useDrag(node, gestureHandlers) + useCursorLock(isDragging, 'draggingPositionInSequenceEditor', 'ew-resize') + return isDragging } function useKeyframeContextMenu(node: SVGCircleElement | null, props: IProps) { diff --git a/theatre/studio/src/panels/SequenceEditorPanel/RightOverlay/HorizontalScrollbar.tsx b/theatre/studio/src/panels/SequenceEditorPanel/RightOverlay/HorizontalScrollbar.tsx index a72e8a2..c17fbd8 100644 --- a/theatre/studio/src/panels/SequenceEditorPanel/RightOverlay/HorizontalScrollbar.tsx +++ b/theatre/studio/src/panels/SequenceEditorPanel/RightOverlay/HorizontalScrollbar.tsx @@ -10,6 +10,7 @@ import React, {useCallback, useMemo, useState} from 'react' import styled from 'styled-components' import {zIndexes} from '@theatre/studio/panels/SequenceEditorPanel/SequenceEditorPanel' import {attributeNameThatLocksFramestamp} from '@theatre/studio/panels/SequenceEditorPanel/FrameStampPositionProvider' +import {pointerEventsAutoInNormalMode} from '@theatre/studio/css' const Container = styled.div` --threadHeight: 6px; @@ -20,7 +21,7 @@ const Container = styled.div` width: 100%; left: 12px; /* bottom: 8px; */ - pointer-events: auto; + ${pointerEventsAutoInNormalMode}; z-index: ${() => zIndexes.horizontalScrollbar}; ` diff --git a/theatre/studio/src/panels/SequenceEditorPanel/RightOverlay/Playhead.tsx b/theatre/studio/src/panels/SequenceEditorPanel/RightOverlay/Playhead.tsx index 8acfdf9..3ee8622 100644 --- a/theatre/studio/src/panels/SequenceEditorPanel/RightOverlay/Playhead.tsx +++ b/theatre/studio/src/panels/SequenceEditorPanel/RightOverlay/Playhead.tsx @@ -15,6 +15,7 @@ import { attributeNameThatLocksFramestamp, useLockFrameStampPosition, } from '@theatre/studio/panels/SequenceEditorPanel/FrameStampPositionProvider' +import {pointerEventsAutoInNormalMode} from '@theatre/studio/css' const Container = styled.div<{isVisible: boolean}>` --thumbColor: #00e0ff; @@ -36,6 +37,17 @@ const Rod = styled.div` height: calc(100% - 8px); border-left: 1px solid #27e0fd; z-index: 10; + + #pointer-root.draggingpositioninsequenceeditor &:not(.seeking) { + pointer-events: auto; + + &:after { + position: absolute; + inset: -2px; + display: block; + content: ' '; + } + } ` const Thumb = styled.div` @@ -47,7 +59,11 @@ const Thumb = styled.div` left: -2px; z-index: 11; cursor: ew-resize; - pointer-events: auto; + ${pointerEventsAutoInNormalMode}; + + #pointer-root.draggingpositioninsequenceeditor &:not(.seeking) { + pointer-events: auto; + } &:before { position: absolute; @@ -148,10 +164,29 @@ const Playhead: React.FC<{layoutP: Pointer}> = ({ scaledSpaceToUnitSpace = val(layoutP.scaledSpace.toUnitSpace) setIsSeeking(true) }, - onDrag(dx) { + onDrag(dx, _, event) { const deltaPos = scaledSpaceToUnitSpace(dx) - const newPos = clamp(posBeforeSeek + deltaPos, 0, sequence.length) - sequence.position = newPos + const unsnappedPos = clamp(posBeforeSeek + deltaPos, 0, sequence.length) + + let newPosition = unsnappedPos + + const snapTarget = event + .composedPath() + .find( + (el): el is Element => + el instanceof Element && + el !== thumbNode && + el.hasAttribute('data-pos'), + ) + + if (snapTarget) { + const snapPos = parseFloat(snapTarget.getAttribute('data-pos')!) + if (isFinite(snapPos)) { + newPosition = snapPos + } + } + + sequence.position = newPosition }, onDragEnd() { setIsSeeking(false) @@ -186,7 +221,10 @@ const Playhead: React.FC<{layoutP: Pointer}> = ({ className={isSeeking ? 'seeking' : ''} {...{[attributeNameThatLocksFramestamp]: 'hide'}} > - + @@ -196,7 +234,10 @@ const Playhead: React.FC<{layoutP: Pointer}> = ({ - + ) }, [layoutP]) diff --git a/theatre/studio/src/panels/SequenceEditorPanel/RightOverlay/TopStrip.tsx b/theatre/studio/src/panels/SequenceEditorPanel/RightOverlay/TopStrip.tsx index 0c3afe2..8f8dba1 100644 --- a/theatre/studio/src/panels/SequenceEditorPanel/RightOverlay/TopStrip.tsx +++ b/theatre/studio/src/panels/SequenceEditorPanel/RightOverlay/TopStrip.tsx @@ -6,6 +6,7 @@ import type {SequenceEditorPanelLayout} from '@theatre/studio/panels/SequenceEdi import StampsGrid from '@theatre/studio/panels/SequenceEditorPanel/FrameGrid/StampsGrid' import PanelDragZone from '@theatre/studio/panels/BasePanel/PanelDragZone' import {attributeNameThatLocksFramestamp} from '@theatre/studio/panels/SequenceEditorPanel/FrameStampPositionProvider' +import {pointerEventsAutoInNormalMode} from '@theatre/studio/css' export const topStripHeight = 20 @@ -23,7 +24,7 @@ const Container = styled(PanelDragZone)` box-sizing: border-box; background: ${topStripTheme.backgroundColor}; border-bottom: 1px solid ${topStripTheme.borderColor}; - pointer-events: auto; + ${pointerEventsAutoInNormalMode}; ` const TopStrip: React.FC<{layoutP: Pointer}> = ({ diff --git a/theatre/studio/src/panels/SequenceEditorPanel/positionSnapping.ts b/theatre/studio/src/panels/SequenceEditorPanel/positionSnapping.ts new file mode 100644 index 0000000..00988ef --- /dev/null +++ b/theatre/studio/src/panels/SequenceEditorPanel/positionSnapping.ts @@ -0,0 +1,2 @@ +// export const +export {} diff --git a/theatre/studio/src/uiComponents/PointerEventsHandler.tsx b/theatre/studio/src/uiComponents/PointerEventsHandler.tsx new file mode 100644 index 0000000..4c46b8d --- /dev/null +++ b/theatre/studio/src/uiComponents/PointerEventsHandler.tsx @@ -0,0 +1,81 @@ +import type {$IntentionalAny} from '@theatre/shared/utils/types' +import React, { + createContext, + useContext, + useLayoutEffect, + useMemo, + useState, +} from 'react' +import styled from 'styled-components' + +// using an ID to make CSS selectors faster +const elementId = 'pointer-root' + +const Container = styled.div` + pointer-events: auto; + &.normal { + pointer-events: none; + } +` + +const CursorOverride = styled.div<{cursor: null | string}>` + position: absolute; + inset: 0; + pointer-events: none; + + #pointer-root:not(.normal) > & { + cursor: ${(props) => props.cursor ?? 'default'}; + pointer-events: auto; + } +` + +type Context = { + getLock: (className: string, cursor: string) => () => void +} + +type Lock = {className: string; cursor: string} + +const context = createContext({} as $IntentionalAny) + +const PointerEventsHandler: React.FC<{ + className?: string +}> = (props) => { + const [locks, setLocks] = useState([]) + const contextValue = useMemo(() => { + const getLock = (className: string, cursor: string) => { + const lock = {className, cursor} + setLocks((s) => [...s, lock]) + const unlock = () => { + setLocks((s) => s.filter((l) => l !== lock)) + } + return unlock + } + return { + getLock, + } + }, []) + + return ( + + + + {props.children} + + + + ) +} + +export const useCursorLock = ( + enabled: boolean, + className: string, + cursor: string, +) => { + const ctx = useContext(context) + useLayoutEffect(() => { + if (!enabled) return + return ctx.getLock(className, cursor) + }, [enabled, className, cursor]) +} + +export default PointerEventsHandler diff --git a/theatre/studio/src/uiComponents/Popover/Popover.tsx b/theatre/studio/src/uiComponents/Popover/Popover.tsx index c504d54..ffe51ba 100644 --- a/theatre/studio/src/uiComponents/Popover/Popover.tsx +++ b/theatre/studio/src/uiComponents/Popover/Popover.tsx @@ -1,3 +1,4 @@ +import {pointerEventsAutoInNormalMode} from '@theatre/studio/css' import getStudio from '@theatre/studio/getStudio' import useBoundingClientRect from '@theatre/studio/uiComponents/useBoundingClientRect' import transparentize from 'polished/lib/color/transparentize' @@ -23,7 +24,7 @@ const Container = styled.ul` padding: 0; margin: 0; cursor: default; - pointer-events: all; + ${pointerEventsAutoInNormalMode}; border-radius: 3px; ` diff --git a/theatre/studio/src/uiComponents/createCursorLock.ts b/theatre/studio/src/uiComponents/createCursorLock.ts index ee6bf90..061afb4 100644 --- a/theatre/studio/src/uiComponents/createCursorLock.ts +++ b/theatre/studio/src/uiComponents/createCursorLock.ts @@ -1,18 +1,19 @@ -export function createCursorLock(cursor: string) { - const el = document.createElement('div') - el.style.cssText = ` - position: fixed; - top: 0; - right: 0; - bottom: 0; - left: 0; - z-index: 9999999;` +// import getStudio from '@theatre/studio/getStudio' - el.style.cursor = cursor - document.body.appendChild(el) - const relinquish = () => { - document.body.removeChild(el) - } +// export function createCursorLock(cursor: string) { +// const el = getStudio()!.ui.containerShadow.getElementById( +// 'pointer-events-root', +// )! as HTMLDivElement - return relinquish -} +// el.style.cursor = cursor +// el.classList.remove('pointer-events-mode-normal') +// el.classList.add('pointer-events-mode-locked-for-drag') +// const relinquish = () => { +// el.style.cursor = '' +// el.classList.add('pointer-events-mode-normal') +// el.classList.remove('pointer-events-mode-locked-for-drag') +// } + +// return relinquish +// } +export {} diff --git a/theatre/studio/src/uiComponents/simpleContextMenu/RightClickMenu/RightClickMenu.tsx b/theatre/studio/src/uiComponents/simpleContextMenu/RightClickMenu/RightClickMenu.tsx index 813c677..b8fe2aa 100644 --- a/theatre/studio/src/uiComponents/simpleContextMenu/RightClickMenu/RightClickMenu.tsx +++ b/theatre/studio/src/uiComponents/simpleContextMenu/RightClickMenu/RightClickMenu.tsx @@ -1,3 +1,4 @@ +import {pointerEventsAutoInNormalMode} from '@theatre/studio/css' import getStudio from '@theatre/studio/getStudio' import useBoundingClientRect from '@theatre/studio/uiComponents/useBoundingClientRect' import transparentize from 'polished/lib/color/transparentize' @@ -26,7 +27,7 @@ const Container = styled.ul` margin: 0; border-radius: 1px; cursor: default; - pointer-events: all; + ${pointerEventsAutoInNormalMode}; border-radius: 3px; ` diff --git a/theatre/studio/src/uiComponents/toolbar/ToolbarIconButton.tsx b/theatre/studio/src/uiComponents/toolbar/ToolbarIconButton.tsx index a757650..11b58fa 100644 --- a/theatre/studio/src/uiComponents/toolbar/ToolbarIconButton.tsx +++ b/theatre/studio/src/uiComponents/toolbar/ToolbarIconButton.tsx @@ -4,11 +4,12 @@ import styled from 'styled-components' import type {ButtonProps} from 'reakit' import {outlinePanelTheme} from '@theatre/studio/panels/OutlinePanel/BaseItem' import {darken, opacify} from 'polished' +import {pointerEventsAutoInNormalMode} from '@theatre/studio/css' const {baseBg, baseBorderColor, baseFontColor} = outlinePanelTheme export const TheButton = styled.button` - pointer-events: auto; + ${pointerEventsAutoInNormalMode}; position: relative; display: flex; align-items: center; diff --git a/theatre/studio/src/uiComponents/useDrag.ts b/theatre/studio/src/uiComponents/useDrag.ts index a138930..36f3d06 100644 --- a/theatre/studio/src/uiComponents/useDrag.ts +++ b/theatre/studio/src/uiComponents/useDrag.ts @@ -1,7 +1,7 @@ -import {voidFn} from '@theatre/shared/utils' import type {$FixMe} from '@theatre/shared/utils/types' import {useLayoutEffect, useRef} from 'react' -import {createCursorLock} from './createCursorLock' +import useRefAndState from '@theatre/studio/utils/useRefAndState' +import {useCursorLock} from './PointerEventsHandler' export type UseDragOpts = { disabled?: boolean @@ -15,12 +15,19 @@ export type UseDragOpts = { export default function useDrag( target: HTMLElement | SVGElement | undefined | null, opts: UseDragOpts, -) { +): [isDragging: boolean] { const optsRef = useRef(opts) optsRef.current = opts - const modeRef = - useRef<'dragStartCalled' | 'dragging' | 'notDragging'>('notDragging') + const [modeRef, mode] = useRefAndState< + 'dragStartCalled' | 'dragging' | 'notDragging' + >('notDragging') + + useCursorLock( + mode === 'dragging' && typeof opts.lockCursorTo === 'string', + 'dragging', + opts.lockCursorTo!, + ) const stateRef = useRef<{ dragHappened: boolean @@ -38,12 +45,7 @@ export default function useDrag( return [event.screenX - startPos.x, event.screenY - startPos.y] } - let relinquishCursorLock = voidFn - const dragHandler = (event: MouseEvent) => { - if (!stateRef.current.dragHappened && optsRef.current.lockCursorTo) { - relinquishCursorLock = createCursorLock(optsRef.current.lockCursorTo) - } if (!stateRef.current.dragHappened) stateRef.current.dragHappened = true modeRef.current = 'dragging' @@ -57,8 +59,6 @@ export default function useDrag( optsRef.current.onDragEnd && optsRef.current.onDragEnd(stateRef.current.dragHappened) - relinquishCursorLock() - relinquishCursorLock = voidFn } const addDragListeners = () => { @@ -119,7 +119,6 @@ export default function useDrag( removeDragListeners() target.removeEventListener('mousedown', onMouseDown as $FixMe) target.removeEventListener('click', preventUnwantedClick as $FixMe) - relinquishCursorLock() if (modeRef.current !== 'notDragging') { optsRef.current.onDragEnd && @@ -128,4 +127,6 @@ export default function useDrag( modeRef.current = 'notDragging' } }, [target]) + + return [mode === 'dragging'] }