From 3ecc3dd012a87d26858d6cd43066356362713711 Mon Sep 17 00:00:00 2001 From: Aria Date: Tue, 3 May 2022 12:38:08 +0200 Subject: [PATCH] QOL improvements to the FocusRange and SequenceEdito (#125) --- .../panels/BasePanel/PanelResizeHandle.tsx | 51 +-- .../src/panels/BasePanel/PanelResizers.tsx | 3 - .../KeyframeEditor/Dot.tsx | 7 +- .../DopeSheet/Right/FocusRangeArea.tsx | 86 ----- .../DopeSheet/Right/FocusRangeCurtains.tsx | 80 +++++ .../DopeSheet/Right/Right.tsx | 6 +- .../FrameStampPositionProvider.tsx | 6 +- .../GraphEditorDotNonScalar.tsx | 6 +- .../KeyframeEditor/GraphEditorDotScalar.tsx | 6 +- .../FocusRangeZone/FocusRangeStrip.tsx | 87 +++-- .../FocusRangeZone/FocusRangeThumb.tsx | 324 ++++++++++-------- .../FocusRangeZone/FocusRangeZone.tsx | 64 ++-- .../RightOverlay/Playhead.tsx | 165 ++++++--- .../RightOverlay/PlayheadPositionPopover.tsx | 4 +- .../src/uiComponents/PointerEventsHandler.tsx | 17 +- .../useHoverWithoutDescendants.ts | 39 +++ 16 files changed, 571 insertions(+), 380 deletions(-) delete mode 100644 theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/FocusRangeArea.tsx create mode 100644 theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/FocusRangeCurtains.tsx create mode 100644 theatre/studio/src/uiComponents/useHoverWithoutDescendants.ts diff --git a/theatre/studio/src/panels/BasePanel/PanelResizeHandle.tsx b/theatre/studio/src/panels/BasePanel/PanelResizeHandle.tsx index 6301437..b6a5d75 100644 --- a/theatre/studio/src/panels/BasePanel/PanelResizeHandle.tsx +++ b/theatre/studio/src/panels/BasePanel/PanelResizeHandle.tsx @@ -11,14 +11,10 @@ import {pointerEventsAutoInNormalMode} from '@theatre/studio/css' const Base = styled.div` position: absolute; - z-index: 10; ${pointerEventsAutoInNormalMode}; &:after { position: absolute; - top: -2px; - right: -2px; - bottom: -2px; - left: -2px; + inset: -5px; display: block; content: ' '; } @@ -40,55 +36,70 @@ const Base = styled.div` } ` -const Horizontal = styled(Base)` - left: 0; - right: 0; +const Side = styled(Base)` + /** + The horizintal/vertical resize handles have z-index:-1 and are offset 1px outside of the panel + to make sure they don't occlude any element that pops out of the panel (like the Playhead in SequenceEditorPanel). + + This means that panels will always need an extra 1px margin for their resize handles to be visible, but that's not a problem + that we have to deal with right now (if it is at all a problem). + + */ + z-index: -1; +` + +const Horizontal = styled(Side)` + left: 0px; + right: 0px; height: 1px; ` const Top = styled(Horizontal)` - top: 0; + top: -1px; ` const Bottom = styled(Horizontal)` - bottom: 0; + bottom: -1px; ` -const Vertical = styled(Base)` - top: 0; - bottom: 0; +const Vertical = styled(Side)` + z-index: -1; + top: -1px; + bottom: -1px; width: 1px; ` const Left = styled(Vertical)` - left: 0; + left: -1px; ` const Right = styled(Vertical)` - right: 0; + right: -1px; ` -const Square = styled(Base)` +const Angle = styled(Base)` + // The angles have z-index: 10 to make sure they _do_ occlude other elements in the panel. + z-index: 10; width: 8px; height: 8px; ` -const TopLeft = styled(Square)` +const TopLeft = styled(Angle)` top: 0; left: 0; ` -const TopRight = styled(Square)` +const TopRight = styled(Angle)` top: 0; right: 0; ` -const BottomLeft = styled(Square)` +const BottomLeft = styled(Angle)` bottom: 0; left: 0; ` -const BottomRight = styled(Square)` +const BottomRight = styled(Angle)` bottom: 0; right: 0; ` diff --git a/theatre/studio/src/panels/BasePanel/PanelResizers.tsx b/theatre/studio/src/panels/BasePanel/PanelResizers.tsx index a63142a..7a2d520 100644 --- a/theatre/studio/src/panels/BasePanel/PanelResizers.tsx +++ b/theatre/studio/src/panels/BasePanel/PanelResizers.tsx @@ -1,9 +1,6 @@ import React from 'react' -import styled from 'styled-components' import PanelResizeHandle from './PanelResizeHandle' -const Container = styled.div`` - const PanelResizers: React.FC<{}> = (props) => { return ( <> 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 3353d1f..a3bc7c9 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,7 +14,10 @@ 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 {useCssCursorLock} from '@theatre/studio/uiComponents/PointerEventsHandler' +import { + lockedCursorCssPropName, + useCssCursorLock, +} from '@theatre/studio/uiComponents/PointerEventsHandler' import SnapCursor from './SnapCursor.svg' import selectedKeyframeIdsIfInSingleTrack from '@theatre/studio/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/selectedKeyframeIdsIfInSingleTrack' @@ -57,6 +60,8 @@ const HitZone = styled.div` #pointer-root.draggingPositionInSequenceEditor & { pointer-events: auto; + cursor: var(${lockedCursorCssPropName}); + &:hover:after { position: absolute; top: calc(50% - ${snapCursorSize / 2}px); diff --git a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/FocusRangeArea.tsx b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/FocusRangeArea.tsx deleted file mode 100644 index fc8e943..0000000 --- a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/FocusRangeArea.tsx +++ /dev/null @@ -1,86 +0,0 @@ -import type {Pointer} from '@theatre/dataverse' -import {prism, val} from '@theatre/dataverse' -import {usePrism} from '@theatre/react' -import getStudio from '@theatre/studio/getStudio' -import type {SequenceEditorPanelLayout} from '@theatre/studio/panels/SequenceEditorPanel/layout/layout' -import {topStripHeight} from '@theatre/studio/panels/SequenceEditorPanel/RightOverlay/TopStrip' -import React, {useMemo} from 'react' -import styled from 'styled-components' - -const focusRangeAreaTheme = { - enabled: { - backgroundColor: '#646568', - opacity: 0.05, - }, - disabled: { - backgroundColor: '#646568', - }, -} - -const Container = styled.div` - position: absolute; - opacity: ${focusRangeAreaTheme.enabled.opacity}; - background: transparent; - left: 0; - top: 0; -` -const FocusRangeArea: React.FC<{ - layoutP: Pointer -}> = ({layoutP}) => { - const existingRangeD = useMemo( - () => - prism(() => { - const {projectId, sheetId} = val(layoutP.sheet).address - const existingRange = val( - getStudio().atomP.ahistoric.projects.stateByProjectId[projectId] - .stateBySheetId[sheetId].sequence.focusRange, - ) - return existingRange - }), - [layoutP], - ) - - return usePrism(() => { - const existingRange = existingRangeD.getValue() - - const range = existingRange?.range || {start: 0, end: 0} - - const height = val(layoutP.rightDims.height) + topStripHeight - - let startPosInClippedSpace: number, - endPosInClippedSpace: number, - conditionalStyleProps: - | { - width: number - transform: string - background?: string - } - | undefined - - if (existingRange !== undefined) { - startPosInClippedSpace = val(layoutP.clippedSpace.fromUnitSpace)( - range.start, - ) - - endPosInClippedSpace = val(layoutP.clippedSpace.fromUnitSpace)(range.end) - - conditionalStyleProps = { - width: endPosInClippedSpace - startPosInClippedSpace, - transform: `translate3d(${ - startPosInClippedSpace - val(layoutP.clippedSpace.fromUnitSpace)(0) - }px, 0, 0)`, - } - - if (existingRange.enabled === true) { - conditionalStyleProps.background = - focusRangeAreaTheme.enabled.backgroundColor - } - } - - return ( - - ) - }, [layoutP, existingRangeD]) -} - -export default FocusRangeArea diff --git a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/FocusRangeCurtains.tsx b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/FocusRangeCurtains.tsx new file mode 100644 index 0000000..7e34579 --- /dev/null +++ b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/FocusRangeCurtains.tsx @@ -0,0 +1,80 @@ +import type {Pointer} from '@theatre/dataverse' +import {prism, val} from '@theatre/dataverse' +import {usePrism} from '@theatre/react' +import getStudio from '@theatre/studio/getStudio' +import type {SequenceEditorPanelLayout} from '@theatre/studio/panels/SequenceEditorPanel/layout/layout' +import {topStripHeight} from '@theatre/studio/panels/SequenceEditorPanel/RightOverlay/TopStrip' +import React, {useMemo} from 'react' +import styled from 'styled-components' + +const divWidth = 1000 + +const Curtain = styled.div<{enabled: boolean}>` + position: absolute; + left: 0; + opacity: 0.15; + top: ${topStripHeight}px; + width: ${divWidth}px; + transform-origin: top left; + z-index: 20; + pointer-events: none; + background-color: ${(props) => (props.enabled ? '#000000' : 'transparent')}; +` + +const FocusRangeCurtains: React.FC<{ + layoutP: Pointer +}> = ({layoutP}) => { + const existingRangeD = useMemo( + () => + prism(() => { + const {projectId, sheetId} = val(layoutP.sheet).address + const existingRange = val( + getStudio().atomP.ahistoric.projects.stateByProjectId[projectId] + .stateBySheetId[sheetId].sequence.focusRange, + ) + return existingRange + }), + [layoutP], + ) + + return usePrism(() => { + const existingRange = existingRangeD.getValue() + + if (!existingRange || !existingRange.enabled) return null + + const {range} = existingRange + + const height = val(layoutP.rightDims.height) + + const unitSpaceToClippedSpace = val(layoutP.clippedSpace.fromUnitSpace) + + const els = [ + [-1000, range.start], + [range.end, val(layoutP.clippedSpace.range.end)], + ].map(([start, end], i) => { + const startPosInClippedSpace = unitSpaceToClippedSpace(start) + + const endPosInClippedSpace = unitSpaceToClippedSpace(end) + const desiredWidth = endPosInClippedSpace - startPosInClippedSpace + + return ( + + ) + }) + + return <>{els} + }, [layoutP, existingRangeD]) +} + +export default FocusRangeCurtains diff --git a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/Right.tsx b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/Right.tsx index 1582440..dcd5063 100644 --- a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/Right.tsx +++ b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/Right.tsx @@ -10,7 +10,7 @@ import {zIndexes} from '@theatre/studio/panels/SequenceEditorPanel/SequenceEdito import DopeSheetSelectionView from './DopeSheetSelectionView' import HorizontallyScrollableArea from './HorizontallyScrollableArea' import SheetRow from './SheetRow' -import FocusRangeArea from './FocusRangeArea' +import FocusRangeCurtains from './FocusRangeCurtains' export const contentWidth = 1000000 @@ -27,7 +27,7 @@ const Background = styled.div<{width: number}>` position: absolute; top: 0; right: 0; - width: ${(props) => props.width}; + width: ${(props) => props.width}px; bottom: 0; z-index: ${() => zIndexes.rightBackground}; overflow: hidden; @@ -49,7 +49,7 @@ const Right: React.FC<{ return ( <> - + diff --git a/theatre/studio/src/panels/SequenceEditorPanel/FrameStampPositionProvider.tsx b/theatre/studio/src/panels/SequenceEditorPanel/FrameStampPositionProvider.tsx index b0a8cbd..a0e791c 100644 --- a/theatre/studio/src/panels/SequenceEditorPanel/FrameStampPositionProvider.tsx +++ b/theatre/studio/src/panels/SequenceEditorPanel/FrameStampPositionProvider.tsx @@ -179,7 +179,11 @@ const pointerPositionInUnitSpace = ( if ( inRange(clientX, x, x + rightWidth) && - inRange(clientY, y, y + height) + inRange( + clientY, + y + 16 /* leaving a bit of space for the top stip here */, + y + height, + ) ) { const posInRightDims = clientX - x const posInUnitSpace = clippedSpaceToUnitSpace(posInRightDims) 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 79ea741..25ca26c 100644 --- a/theatre/studio/src/panels/SequenceEditorPanel/GraphEditor/BasicKeyframedTrack/KeyframeEditor/GraphEditorDotNonScalar.tsx +++ b/theatre/studio/src/panels/SequenceEditorPanel/GraphEditor/BasicKeyframedTrack/KeyframeEditor/GraphEditorDotNonScalar.tsx @@ -13,7 +13,10 @@ import type {Keyframe} from '@theatre/core/projects/store/types/SheetState_Histo import {useLockFrameStampPosition} from '@theatre/studio/panels/SequenceEditorPanel/FrameStampPositionProvider' import {attributeNameThatLocksFramestamp} from '@theatre/studio/panels/SequenceEditorPanel/FrameStampPositionProvider' import {pointerEventsAutoInNormalMode} from '@theatre/studio/css' -import {useCssCursorLock} from '@theatre/studio/uiComponents/PointerEventsHandler' +import { + lockedCursorCssPropName, + useCssCursorLock, +} from '@theatre/studio/uiComponents/PointerEventsHandler' export const dotSize = 6 @@ -42,6 +45,7 @@ const HitZone = styled.circle` #pointer-root.draggingPositionInSequenceEditor & { pointer-events: auto; + cursor: var(${lockedCursorCssPropName}); } &.beingDragged { 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 5cfc209..c2a90ab 100644 --- a/theatre/studio/src/panels/SequenceEditorPanel/GraphEditor/BasicKeyframedTrack/KeyframeEditor/GraphEditorDotScalar.tsx +++ b/theatre/studio/src/panels/SequenceEditorPanel/GraphEditor/BasicKeyframedTrack/KeyframeEditor/GraphEditorDotScalar.tsx @@ -13,7 +13,10 @@ import type {Keyframe} from '@theatre/core/projects/store/types/SheetState_Histo import {useLockFrameStampPosition} from '@theatre/studio/panels/SequenceEditorPanel/FrameStampPositionProvider' import {attributeNameThatLocksFramestamp} from '@theatre/studio/panels/SequenceEditorPanel/FrameStampPositionProvider' import {pointerEventsAutoInNormalMode} from '@theatre/studio/css' -import {useCssCursorLock} from '@theatre/studio/uiComponents/PointerEventsHandler' +import { + lockedCursorCssPropName, + useCssCursorLock, +} from '@theatre/studio/uiComponents/PointerEventsHandler' export const dotSize = 6 @@ -42,6 +45,7 @@ const HitZone = styled.circle` #pointer-root.draggingPositionInSequenceEditor & { pointer-events: auto; + cursor: var(${lockedCursorCssPropName}); } &.beingDragged { diff --git a/theatre/studio/src/panels/SequenceEditorPanel/RightOverlay/FocusRangeZone/FocusRangeStrip.tsx b/theatre/studio/src/panels/SequenceEditorPanel/RightOverlay/FocusRangeZone/FocusRangeStrip.tsx index e76e57c..c71c41c 100644 --- a/theatre/studio/src/panels/SequenceEditorPanel/RightOverlay/FocusRangeZone/FocusRangeStrip.tsx +++ b/theatre/studio/src/panels/SequenceEditorPanel/RightOverlay/FocusRangeZone/FocusRangeStrip.tsx @@ -1,6 +1,6 @@ import type {Pointer} from '@theatre/dataverse' import {prism, val} from '@theatre/dataverse' -import {usePrism} from '@theatre/react' +import {usePrism, useVal} from '@theatre/react' import type {$IntentionalAny, IRange} from '@theatre/shared/utils/types' import {pointerEventsAutoInNormalMode} from '@theatre/studio/css' import getStudio from '@theatre/studio/getStudio' @@ -19,17 +19,16 @@ export const focusRangeStripTheme = { stroke: '#646568', }, disabled: { - backgroundColor: '#282A2C', + backgroundColor: '#282a2cc5', + stroke: '#595a5d', }, - playing: { - backgroundColor: 'red', - }, - highlight: { + hover: { backgroundColor: '#34373D', stroke: '#C8CAC0', }, dragging: { backgroundColor: '#3F444A', + stroke: '#C8CAC0', }, thumbWidth: 9, hitZoneWidth: 26, @@ -38,22 +37,42 @@ export const focusRangeStripTheme = { const stripWidth = 1000 -const RangeStrip = styled.div` +export const RangeStrip = styled.div<{enabled: boolean}>` position: absolute; - height: ${() => topStripHeight}; - background-color: ${focusRangeStripTheme.enabled.backgroundColor}; + height: ${() => topStripHeight - 1}px; + background-color: ${(props) => + props.enabled + ? focusRangeStripTheme.enabled.backgroundColor + : focusRangeStripTheme.disabled.backgroundColor}; + cursor: grab; top: 0; left: 0; width: ${stripWidth}px; transform-origin: left top; &:hover { - background-color: ${focusRangeStripTheme.highlight.backgroundColor}; + background-color: ${focusRangeStripTheme.hover.backgroundColor}; } &.dragging { background-color: ${focusRangeStripTheme.dragging.backgroundColor}; cursor: grabbing !important; } ${pointerEventsAutoInNormalMode}; + + /* covers the one pixel space between the focus range strip and the top strip + of the sequence editor panel, which would have caused that one pixel to act + like a panel drag zone */ + &:after { + display: block; + content: ' '; + position: absolute; + bottom: -1px; + height: 1px; + left: 0; + right: 0; + background: transparent; + pointer-events: normal; + z-index: -1; + } ` /** @@ -108,14 +127,14 @@ const FocusRangeStrip: React.FC<{ [layoutP], ) - const sheet = val(layoutP.sheet) - const [rangeStripRef, rangeStripNode] = useRefAndState( null, ) const [contextMenu] = useContextMenu(rangeStripNode, { items: () => { + const sheet = val(layoutP.sheet) + const existingRange = existingRangeD.getValue() return [ { @@ -156,7 +175,9 @@ const FocusRangeStrip: React.FC<{ }, }) - const scaledSpaceToUnitSpace = val(layoutP.scaledSpace.toUnitSpace) + const scaledSpaceToUnitSpace = useVal(layoutP.scaledSpace.toUnitSpace) + const [isDraggingRef, isDragging] = useRefAndState(false) + const sheet = useVal(layoutP.sheet) const gestureHandlers = useMemo((): Parameters[1] => { let sequence = sheet.getSequence() @@ -172,18 +193,20 @@ const FocusRangeStrip: React.FC<{ onDragStart(event) { existingRange = existingRangeD.getValue() - if (existingRange?.enabled === true) { + if (existingRange) { startPosBeforeDrag = existingRange.range.start endPosBeforeDrag = existingRange.range.end dragHappened = false sequence = val(layoutP.sheet).getSequence() target = event.target as HTMLDivElement - target.classList.add('dragging') + isDraggingRef.current = true + } else { + return false } }, onDrag(dx) { existingRange = existingRangeD.getValue() - if (existingRange?.enabled) { + if (existingRange) { dragHappened = true const deltaPos = scaledSpaceToUnitSpace(dx) @@ -211,14 +234,15 @@ const FocusRangeStrip: React.FC<{ start: newStartPosition, end: newEndPosition, }, - enabled: existingRange?.enabled || true, + enabled: existingRange?.enabled ?? true, }, ) }) } }, onDragEnd() { - if (existingRange?.enabled) { + isDraggingRef.current = false + if (existingRange) { if (dragHappened && tempTransaction !== undefined) { tempTransaction.commit() } else if (tempTransaction) { @@ -227,7 +251,6 @@ const FocusRangeStrip: React.FC<{ tempTransaction = undefined } if (target !== undefined) { - target.classList.remove('dragging') target = undefined } }, @@ -261,37 +284,25 @@ const FocusRangeStrip: React.FC<{ scaleX = (endX - startX) / stripWidth } - let conditionalStyleProps: { - background?: string - cursor?: string - } = {} + if (!existingRange) return <> - if (existingRange !== undefined) { - if (existingRange.enabled === false) { - conditionalStyleProps.background = - focusRangeStripTheme.disabled.backgroundColor - conditionalStyleProps.cursor = 'default' - } else { - conditionalStyleProps.cursor = 'grab' - } - } - - return existingRange === undefined ? ( - <> - ) : ( + return ( <> {contextMenu} ) - }, [layoutP, rangeStripRef, existingRangeD, contextMenu]) + }, [layoutP, rangeStripRef, existingRangeD, contextMenu, isDragging]) } export default FocusRangeStrip diff --git a/theatre/studio/src/panels/SequenceEditorPanel/RightOverlay/FocusRangeZone/FocusRangeThumb.tsx b/theatre/studio/src/panels/SequenceEditorPanel/RightOverlay/FocusRangeZone/FocusRangeThumb.tsx index 2baeb9a..2fec23e 100644 --- a/theatre/studio/src/panels/SequenceEditorPanel/RightOverlay/FocusRangeZone/FocusRangeThumb.tsx +++ b/theatre/studio/src/panels/SequenceEditorPanel/RightOverlay/FocusRangeZone/FocusRangeThumb.tsx @@ -1,72 +1,161 @@ import type {Pointer} from '@theatre/dataverse' import {prism, val} from '@theatre/dataverse' -import {usePrism} from '@theatre/react' +import {usePrism, useVal} from '@theatre/react' import type {$IntentionalAny, IRange} from '@theatre/shared/utils/types' -import {pointerEventsAutoInNormalMode} from '@theatre/studio/css' import getStudio from '@theatre/studio/getStudio' import type {SequenceEditorPanelLayout} from '@theatre/studio/panels/SequenceEditorPanel/layout/layout' -import {topStripHeight} from '@theatre/studio/panels/SequenceEditorPanel/RightOverlay/TopStrip' +import { + topStripHeight, + topStripTheme, +} from '@theatre/studio/panels/SequenceEditorPanel/RightOverlay/TopStrip' import type {CommitOrDiscard} from '@theatre/studio/StudioStore/StudioStore' -import {useCssCursorLock} from '@theatre/studio/uiComponents/PointerEventsHandler' +import { + lockedCursorCssPropName, + useCssCursorLock, +} from '@theatre/studio/uiComponents/PointerEventsHandler' import useDrag from '@theatre/studio/uiComponents/useDrag' import useRefAndState from '@theatre/studio/utils/useRefAndState' -import React, {useMemo, useRef, useState} from 'react' +import React, {useMemo} from 'react' import styled from 'styled-components' -import {focusRangeStripTheme} from './FocusRangeStrip' +import { + attributeNameThatLocksFramestamp, + useLockFrameStampPosition, +} from '@theatre/studio/panels/SequenceEditorPanel/FrameStampPositionProvider' +import {focusRangeStripTheme, RangeStrip} from './FocusRangeStrip' +import type Sheet from '@theatre/core/sheets/Sheet' -const Handler = styled.div` - content: ' '; - width: ${focusRangeStripTheme.thumbWidth}; - height: ${() => topStripHeight}; +const TheDiv = styled.div<{enabled: boolean; type: 'start' | 'end'}>` position: absolute; - ${pointerEventsAutoInNormalMode}; + top: 0; + // the right handle has to be pulled back by its width since its right side indicates its position, not its left side + left: ${(props) => + props.type === 'start' ? 0 : -focusRangeStripTheme.thumbWidth}px; + transform-origin: left top; + width: ${focusRangeStripTheme.thumbWidth}px; + height: ${() => topStripHeight - 1}px; + z-index: 3; + + background-color: ${({enabled}) => + enabled + ? focusRangeStripTheme.enabled.backgroundColor + : focusRangeStripTheme.disabled.backgroundColor}; + stroke: ${focusRangeStripTheme.enabled.stroke}; user-select: none; - &:hover { - background: ${focusRangeStripTheme.highlight.backgroundColor} !important; + + cursor: ${(props) => (props.type === 'start' ? 'w-resize' : 'e-resize')}; + + // no pointer events unless pointer-root is in normal mode _and_ the + // focus range is enabled + #pointer-root & { + pointer-events: none; } -` -const dims = (size: number) => ` - left: ${-size / 2}px; - width: ${size}px; - height: ${size}px; -` + #pointer-root.normal & { + pointer-events: auto; + } -const HitZone = styled.div` - top: 0; - left: 0; - transform-origin: left top; - position: absolute; - z-index: 3; - ${dims(focusRangeStripTheme.hitZoneWidth)} -` + #pointer-root.draggingPositionInSequenceEditor & { + pointer-events: auto; + cursor: var(${lockedCursorCssPropName}); + } -const Tooltip = styled.div` - font-size: 10px; - white-space: nowrap; - padding: 2px 8px; - border-radius: 2px; - ${pointerEventsAutoInNormalMode}; - background-color: #0000004d; - display: none; - position: absolute; - top: -${() => topStripHeight + 2}; - transform: translateX(-50%); - ${HitZone}:hover &, ${Handler}.dragging & { + &.dragging { + pointer-events: none !important; + } + + // highlight the handle when it's being dragged or the whole strip is being dragged + &.dragging, + ${() => RangeStrip}.dragging ~ & { + background: ${focusRangeStripTheme.dragging.backgroundColor}; + stroke: ${focusRangeStripTheme.dragging.stroke}; + } + + #pointer-root.draggingPositionInSequenceEditor &:hover { + background: ${focusRangeStripTheme.dragging.backgroundColor}; + stroke: #40aaa4; + } + + // highlight the handle if it's hovered, or the whole strip is hovverd + ${() => RangeStrip}:hover ~ &, &:hover { + background: ${focusRangeStripTheme.hover.backgroundColor}; + stroke: ${focusRangeStripTheme.hover.stroke}; + } + + // a larger hit zone + &:before { display: block; - color: white; - background-color: '#000000'; + content: ' '; + position: absolute; + inset: -8px; } ` +/** + * This acts as a bit of a horizontal shadow that covers the frame numbers that show up + * right next to the thumb, making the appearance of the focus range more tidy. + */ +const ColoredMargin = styled.div<{type: 'start' | 'end'; enabled: boolean}>` + position: absolute; + top: 0; + bottom: 0; + pointer-events: none; + + ${() => RangeStrip}.dragging ~ ${TheDiv} > & { + --bg: ${focusRangeStripTheme.dragging.backgroundColor}; + } + + --bg: ${({enabled}) => + enabled + ? focusRangeStripTheme.enabled.backgroundColor + : focusRangeStripTheme.disabled.backgroundColor}; + + // highlight the handle if it's hovered, or the whole strip is hovverd + ${() => RangeStrip}:hover ~ ${TheDiv} > & { + --bg: ${focusRangeStripTheme.hover.backgroundColor}; + } + + background: linear-gradient( + ${(props) => (props.type === 'start' ? 90 : -90)}deg, + var(--bg) 0%, + #ffffff00 100% + ); + + width: 12px; + left: ${(props) => + props.type === 'start' + ? focusRangeStripTheme.thumbWidth + : // pushing the right-side thumb's margin 1px to the right to make sure there is no space + // between it and the thumb + -focusRangeStripTheme.thumbWidth + 1}px; +` + +const OuterColoredMargin = styled.div<{ + type: 'start' | 'end' +}>` + position: absolute; + top: 0; + bottom: 0; + pointer-events: none; + + --bg: ${() => topStripTheme.backgroundColor}; + + background: linear-gradient( + ${(props) => (props.type === 'start' ? -90 : 90)}deg, + var(--bg) 0%, + #ffffff00 100% + ); + + width: 12px; + left: ${(props) => + props.type === 'start' ? -12 : focusRangeStripTheme.thumbWidth}px; +` + const FocusRangeThumb: React.FC<{ layoutP: Pointer thumbType: keyof IRange }> = ({layoutP, thumbType}) => { const [hitZoneRef, hitZoneNode] = useRefAndState(null) - const handlerRef = useRef(null) - const [isDragging, setIsDragging] = useState(false) const existingRangeD = useMemo( () => @@ -81,51 +170,34 @@ const FocusRangeThumb: React.FC<{ [layoutP], ) - const sheet = val(layoutP.sheet) - const scaledSpaceToUnitSpace = val(layoutP.scaledSpace.toUnitSpace) - let sequence = sheet.getSequence() - - const focusRangeEnabled = existingRangeD.getValue()?.enabled || false - const gestureHandlers = useMemo((): Parameters[1] => { - const defaultRange = {start: 0, end: sequence.length} - let range = existingRangeD.getValue()?.range || defaultRange + let defaultRange: IRange + let range: IRange let focusRangeEnabled: boolean - let posBeforeDrag = range[thumbType] + let posBeforeDrag: number let tempTransaction: CommitOrDiscard | undefined - let dragHappened = false - let originalBackground: string - let originalStroke: string let minFocusRangeStripWidth: number + let sheet: Sheet + let scaledSpaceToUnitSpace: (s: number) => number return { onDragStart() { + sheet = val(layoutP.sheet) + const sequence = sheet.getSequence() + defaultRange = {start: 0, end: sequence.length} let existingRange = existingRangeD.getValue() || { range: defaultRange, enabled: false, } focusRangeEnabled = existingRange.enabled - dragHappened = false - sequence = val(layoutP.sheet).getSequence() + posBeforeDrag = existingRange.range[thumbType] + scaledSpaceToUnitSpace = val(layoutP.scaledSpace.toUnitSpace) minFocusRangeStripWidth = scaledSpaceToUnitSpace( focusRangeStripTheme.rangeStripMinWidth, ) - - if (handlerRef.current) { - originalBackground = handlerRef.current.style.background - originalStroke = handlerRef.current.style.stroke - handlerRef.current.style.background = - focusRangeStripTheme.highlight.backgroundColor - handlerRef.current.style.stroke = - focusRangeStripTheme.highlight.stroke - handlerRef.current.style - handlerRef.current.classList.add('dragging') - setIsDragging(true) - } }, onDrag(dx, _, event) { - dragHappened = true range = existingRangeD.getValue()?.range || defaultRange const deltaPos = scaledSpaceToUnitSpace(dx) @@ -149,7 +221,7 @@ const FocusRangeThumb: React.FC<{ oldPosPlusDeltaPos, range['start'] + minFocusRangeStripWidth, ), - sequence.length, + sheet.getSequence().length, ) } @@ -171,7 +243,9 @@ const FocusRangeThumb: React.FC<{ } } - const newPositionInFrame = sequence.closestGridPosition(newPosition) + const newPositionInFrame = sheet + .getSequence() + .closestGridPosition(newPosition) if (tempTransaction !== undefined) { tempTransaction.discard() @@ -187,40 +261,34 @@ const FocusRangeThumb: React.FC<{ ) }) }, - onDragEnd() { - if (handlerRef.current) { - handlerRef.current.classList.remove('dragging') - setIsDragging(false) - - if (originalBackground) { - handlerRef.current.style.background = originalBackground - } - if (originalBackground) { - handlerRef.current.style.stroke = originalStroke - } - } + onDragEnd(dragHappened) { if (dragHappened && tempTransaction !== undefined) { tempTransaction.commit() } else if (tempTransaction) { tempTransaction.discard() } }, - lockCursorTo: thumbType === 'start' ? 'w-resize' : 'e-resize', } - }, [sheet, scaledSpaceToUnitSpace]) + }, [layoutP]) - useDrag(hitZoneNode, gestureHandlers) + const [isDragging] = useDrag(hitZoneNode, gestureHandlers) - useCssCursorLock(isDragging, 'draggingPositionInSequenceEditor', 'ew-resize') + useCssCursorLock( + isDragging, + 'draggingPositionInSequenceEditor', + thumbType === 'start' ? 'w-resize' : 'e-resize', + ) + + const existingRange = useVal(existingRangeD) + + useLockFrameStampPosition(isDragging, existingRange?.range[thumbType] ?? 0) return usePrism(() => { const existingRange = existingRangeD.getValue() - const defaultRange = { - range: {start: 0, end: sequence.length}, - enabled: false, - } - const position = - existingRange?.range[thumbType] || defaultRange.range[thumbType] + if (!existingRange) return null + const {enabled} = existingRange + + const position = existingRange.range[thumbType] let posInClippedSpace: number = val(layoutP.clippedSpace.fromUnitSpace)( position, @@ -230,54 +298,32 @@ const FocusRangeThumb: React.FC<{ posInClippedSpace < 0 || val(layoutP.clippedSpace.width) < posInClippedSpace ) { - posInClippedSpace = -1000 + posInClippedSpace = -10000 } - const pointerEvents = focusRangeEnabled ? 'auto' : 'none' - - const background = focusRangeEnabled - ? focusRangeStripTheme.disabled.backgroundColor - : focusRangeStripTheme.enabled.backgroundColor - - const startHandlerOffset = focusRangeStripTheme.hitZoneWidth / 2 - const endHandlerOffset = - startHandlerOffset - focusRangeStripTheme.thumbWidth - - return existingRange !== undefined ? ( - <> - - - - - - - - {sequence.positionFormatter.formatBasic(sequence.length)} - - - - - ) : ( - <> + return ( + + + + + + + + ) - }, [layoutP, hitZoneRef, existingRangeD, focusRangeEnabled]) + }, [layoutP, hitZoneRef, existingRangeD, isDragging]) } export default FocusRangeThumb diff --git a/theatre/studio/src/panels/SequenceEditorPanel/RightOverlay/FocusRangeZone/FocusRangeZone.tsx b/theatre/studio/src/panels/SequenceEditorPanel/RightOverlay/FocusRangeZone/FocusRangeZone.tsx index 3cd2cdf..c6e7ac0 100644 --- a/theatre/studio/src/panels/SequenceEditorPanel/RightOverlay/FocusRangeZone/FocusRangeZone.tsx +++ b/theatre/studio/src/panels/SequenceEditorPanel/RightOverlay/FocusRangeZone/FocusRangeZone.tsx @@ -12,20 +12,25 @@ import { import type {SequenceEditorPanelLayout} from '@theatre/studio/panels/SequenceEditorPanel/layout/layout' import {topStripHeight} from '@theatre/studio/panels/SequenceEditorPanel/RightOverlay/TopStrip' import type {CommitOrDiscard} from '@theatre/studio/StudioStore/StudioStore' +import {useCssCursorLock} from '@theatre/studio/uiComponents/PointerEventsHandler' import useDrag from '@theatre/studio/uiComponents/useDrag' +import useHoverWithoutDescendants from '@theatre/studio/uiComponents/useHoverWithoutDescendants' +import useKeyDown from '@theatre/studio/uiComponents/useKeyDown' import useRefAndState from '@theatre/studio/utils/useRefAndState' import {clamp} from 'lodash-es' -import React, {useMemo, useRef} from 'react' +import React, {useEffect, useMemo, useRef, useState} from 'react' import styled from 'styled-components' import FocusRangeStrip, {focusRangeStripTheme} from './FocusRangeStrip' import FocusRangeThumb from './FocusRangeThumb' -const Container = styled.div` +const Container = styled.div<{isShiftDown: boolean}>` position: absolute; height: ${() => topStripHeight}px; left: 0; right: 0; box-sizing: border-box; + /* Use the "grab" cursor if the shift key is up, which is the one used on the top strip of the sequence editor */ + cursor: ${(props) => (props.isShiftDown ? 'ew-resize' : 'move')}; ` const FocusRangeZone: React.FC<{ @@ -55,44 +60,28 @@ const FocusRangeZone: React.FC<{ usePanelDragZoneGestureHandlers(layoutP, panelStuffRef), ) - const [onMouseEnter, onMouseLeave] = useMemo(() => { - let unlock: VoidFn | undefined - return [ - function onMouseEnter(event: React.MouseEvent) { - if (event.shiftKey === false) { - if (unlock) { - const u = unlock - unlock = undefined - u() - } - unlock = panelStuffRef.current.addBoundsHighlightLock() - } - }, - function onMouseLeave(event: React.MouseEvent) { - if (event.shiftKey === false) { - if (unlock) { - const u = unlock - unlock = undefined - u() - } - } - }, - ] - }, []) + const isShiftDown = useKeyDown('Shift') + const isPointerHovering = useHoverWithoutDescendants(containerNode) + + useEffect(() => { + if (!isShiftDown && isPointerHovering) { + const unlock = panelStuffRef.current.addBoundsHighlightLock() + return unlock + } + }, [!isShiftDown && isPointerHovering]) return usePrism(() => { return ( ) - }, [layoutP, existingRangeD]) + }, [layoutP, existingRangeD, isShiftDown]) } export default FocusRangeZone @@ -101,6 +90,14 @@ function usePanelDragZoneGestureHandlers( layoutP: Pointer, panelStuffRef: React.MutableRefObject>, ) { + const [mode, setMode] = useState<'none' | 'creating' | 'moving-panel'>('none') + + useCssCursorLock( + mode !== 'none', + 'dragging', + mode === 'creating' ? 'ew-resize' : 'move', + ) + return useMemo((): Parameters[1] => { const focusRangeCreationGestureHandlers = (): Parameters< typeof useDrag @@ -175,7 +172,7 @@ function usePanelDragZoneGestureHandlers( } tempTransaction = undefined }, - lockCursorTo: 'grabbing', + lockCursorTo: 'ew-resize', } } @@ -234,22 +231,21 @@ function usePanelDragZoneGestureHandlers( return { onDragStart(event) { if (event.shiftKey) { + setMode('creating') currentGestureHandlers = focusRangeCreationGestureHandlers() } else { + setMode('moving-panel') currentGestureHandlers = panelMoveGestureHandlers() } currentGestureHandlers.onDragStart!(event) }, onDrag(dx, dy, event) { - if (!currentGestureHandlers) { - console.error('oh no') - } currentGestureHandlers!.onDrag(dx, dy, event) }, onDragEnd(dragHappened) { + setMode('none') currentGestureHandlers!.onDragEnd!(dragHappened) }, - lockCursorTo: 'grabbing', } }, [layoutP, panelStuffRef]) } diff --git a/theatre/studio/src/panels/SequenceEditorPanel/RightOverlay/Playhead.tsx b/theatre/studio/src/panels/SequenceEditorPanel/RightOverlay/Playhead.tsx index 848ae20..9d64403 100644 --- a/theatre/studio/src/panels/SequenceEditorPanel/RightOverlay/Playhead.tsx +++ b/theatre/studio/src/panels/SequenceEditorPanel/RightOverlay/Playhead.tsx @@ -20,6 +20,10 @@ import usePopover from '@theatre/studio/uiComponents/Popover/usePopover' import BasicPopover from '@theatre/studio/uiComponents/Popover/BasicPopover' import PlayheadPositionPopover from './PlayheadPositionPopover' import {getIsPlayheadAttachedToFocusRange} from '@theatre/studio/UIRoot/useKeyboardShortcuts' +import { + lockedCursorCssPropName, + useCssCursorLock, +} from '@theatre/studio/uiComponents/PointerEventsHandler' const Container = styled.div<{isVisible: boolean}>` --thumbColor: #00e0ff; @@ -41,9 +45,11 @@ const Rod = styled.div` height: calc(100% - 8px); border-left: 1px solid #27e0fd; z-index: 10; + pointer-events: none; #pointer-root.draggingPositionInSequenceEditor &:not(.seeking) { - pointer-events: auto; + /* pointer-events: auto; */ + /* cursor: var(${lockedCursorCssPropName}); */ &:after { position: absolute; @@ -55,6 +61,7 @@ const Rod = styled.div` ` const Thumb = styled.div` + background-color: var(--thumbColor); position: absolute; width: 5px; height: 13px; @@ -62,16 +69,104 @@ const Thumb = styled.div` left: -2px; z-index: 11; cursor: ew-resize; + --sunblock-color: #1f2b2b; + ${pointerEventsAutoInNormalMode}; + &.seeking { + pointer-events: none !important; + } + #pointer-root.draggingPositionInSequenceEditor &:not(.seeking) { pointer-events: auto; + cursor: var(${lockedCursorCssPropName}); + } + + ${Container}.playheadattachedtofocusrange > & { + top: -8px; + --sunblock-color: #005662; + &:before, + &:after { + border-bottom-width: 8px; + } + } + + &:before { + position: absolute; + display: block; + content: ' '; + left: -2px; + width: 0; + height: 0; + border-bottom: 4px solid var(--sunblock-color); + border-left: 2px solid transparent; + } + + &:after { + position: absolute; + display: block; + content: ' '; + right: -2px; + width: 0; + height: 0; + border-bottom: 4px solid var(--sunblock-color); + border-right: 2px solid transparent; + } +` + +const Squinch = styled.div` + position: absolute; + left: 1px; + right: 1px; + top: 13px; + border-top: 3px solid var(--thumbColor); + border-right: 1px solid transparent; + border-left: 1px solid transparent; + pointer-events: none; + + /* ${Container}.playheadattachedtofocusrange & { + top: 10px; + &:before, + &:after { + height: 15px; + } + } */ + + &:before { + position: absolute; + display: block; + content: ' '; + top: -4px; + left: -2px; + height: 8px; + width: 2px; + background: none; + border-radius: 0 100% 0 0; + border-top: 1px solid var(--thumbColor); + border-right: 1px solid var(--thumbColor); + } + + &:after { + position: absolute; + display: block; + content: ' '; + top: -4px; + right: -2px; + height: 8px; + width: 2px; + background: none; + border-radius: 100% 0 0 0; + border-top: 1px solid var(--thumbColor); + border-left: 1px solid var(--thumbColor); } ` const Tooltip = styled.div` display: none; position: absolute; + top: -20px; + left: 4px; + padding: 0 2px; transform: translateX(-50%); background: #1a1a1a; border-radius: 4px; @@ -84,34 +179,6 @@ const Tooltip = styled.div` } ` -const RegularThumbSvg: React.FC = () => ( - - - -) - -const LargeThumbSvg: React.FC = () => ( - - - -) - const Playhead: React.FC<{layoutP: Pointer}> = ({ layoutP, }) => { @@ -131,20 +198,18 @@ const Playhead: React.FC<{layoutP: Pointer}> = ({ }, ) - const scaledSpaceToUnitSpace = val(layoutP.scaledSpace.toUnitSpace) - - // This may not currently snap correctly like it does when grabbing the "Rod". - // See https://www.notion.so/theatrejs/dragging-from-playhead-does-not-snap-dadac4fa755149cebbcb70a655c3a0d5 const gestureHandlers = useMemo((): Parameters[1] => { const setIsSeeking = val(layoutP.seeker.setIsSeeking) let posBeforeSeek = 0 let sequence: Sequence + let scaledSpaceToUnitSpace: typeof layoutP.scaledSpace.toUnitSpace.$$__pointer_type return { onDragStart() { sequence = val(layoutP.sheet).getSequence() posBeforeSeek = sequence.position + scaledSpaceToUnitSpace = val(layoutP.scaledSpace.toUnitSpace) setIsSeeking(true) }, onDrag(dx, _, event) { @@ -174,20 +239,25 @@ const Playhead: React.FC<{layoutP: Pointer}> = ({ onDragEnd() { setIsSeeking(false) }, - lockCursorTo: 'ew-resize', } - }, [scaledSpaceToUnitSpace]) + }, []) - useDrag(thumbNode, gestureHandlers) + const [isDragging] = useDrag(thumbNode, gestureHandlers) + + useCssCursorLock(isDragging, 'draggingPositionInSequenceEditor', 'ew-resize') // hide the frame stamp when seeking - useLockFrameStampPosition(useVal(layoutP.seeker.isSeeking), -1) + useLockFrameStampPosition(useVal(layoutP.seeker.isSeeking) || isDragging, -1) return usePrism(() => { const isSeeking = val(layoutP.seeker.isSeeking) const sequence = val(layoutP.sheet).getSequence() + const isPlayheadAttachedToFocusRange = val( + getIsPlayheadAttachedToFocusRange(sequence), + ) + const posInUnitSpace = sequence.positionDerivation.getValue() const posInClippedSpace = val(layoutP.clippedSpace.fromUnitSpace)( @@ -197,32 +267,27 @@ const Playhead: React.FC<{layoutP: Pointer}> = ({ posInClippedSpace >= 0 && posInClippedSpace <= val(layoutP.clippedSpace.width) - const isPlayheadAttachedToFocusRange = val( - getIsPlayheadAttachedToFocusRange(sequence), - ) - return ( <> {popoverNode} { + openPopover(e, thumbNode!) + }} > - {isPlayheadAttachedToFocusRange ? ( - - ) : ( - - )} - + + {sequence.positionFormatter.formatForPlayhead( sequence.closestGridPosition(posInUnitSpace), )} diff --git a/theatre/studio/src/panels/SequenceEditorPanel/RightOverlay/PlayheadPositionPopover.tsx b/theatre/studio/src/panels/SequenceEditorPanel/RightOverlay/PlayheadPositionPopover.tsx index ba6dd9d..9c759a3 100644 --- a/theatre/studio/src/panels/SequenceEditorPanel/RightOverlay/PlayheadPositionPopover.tsx +++ b/theatre/studio/src/panels/SequenceEditorPanel/RightOverlay/PlayheadPositionPopover.tsx @@ -74,9 +74,9 @@ const PlayheadPositionPopover: React.FC<{ return ( - + - + {props.children} diff --git a/theatre/studio/src/uiComponents/useHoverWithoutDescendants.ts b/theatre/studio/src/uiComponents/useHoverWithoutDescendants.ts new file mode 100644 index 0000000..c09afa6 --- /dev/null +++ b/theatre/studio/src/uiComponents/useHoverWithoutDescendants.ts @@ -0,0 +1,39 @@ +import {useEffect, useState} from 'react' + +/** + * A react hook that returns true if the pointer is hovering over the target element and not its descendants + */ +export default function useHoverWithoutDescendants( + target: HTMLElement | null | undefined, +): boolean { + const [isHovered, setIsHovered] = useState(false) + + useEffect(() => { + setIsHovered(false) + if (!target) return + + const onMouseEnterOrMove = (e: MouseEvent) => { + if (e.target === target) { + setIsHovered(true) + } else { + setIsHovered(false) + } + } + const onMouseLeave = () => { + setIsHovered(false) + } + + target.addEventListener('mouseenter', onMouseEnterOrMove) + target.addEventListener('mousemove', onMouseEnterOrMove) + target.addEventListener('mouseleave', onMouseLeave) + + return () => { + setIsHovered(false) + target.removeEventListener('mouseenter', onMouseEnterOrMove) + target.removeEventListener('mousemove', onMouseEnterOrMove) + target.removeEventListener('mouseleave', onMouseLeave) + } + }, [target]) + + return isHovered +}