QOL improvements to the FocusRange and SequenceEdito (#125)

This commit is contained in:
Aria 2022-05-03 12:38:08 +02:00 committed by GitHub
parent d85e3053af
commit 3ecc3dd012
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 571 additions and 380 deletions

View file

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

View file

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

View file

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

View file

@ -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<SequenceEditorPanelLayout>
}> = ({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 (
<Container style={{...conditionalStyleProps, height: `${height}px`}} />
)
}, [layoutP, existingRangeD])
}
export default FocusRangeArea

View file

@ -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<SequenceEditorPanelLayout>
}> = ({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 (
<Curtain
key={`curtain-${i}`}
enabled={true}
style={{
height: `${height}px`,
transform: `translateX(${
val(layoutP.scaledSpace.leftPadding) +
startPosInClippedSpace -
unitSpaceToClippedSpace(0)
}px) scaleX(${desiredWidth / divWidth})`,
}}
/>
)
})
return <>{els}</>
}, [layoutP, existingRangeD])
}
export default FocusRangeCurtains

View file

@ -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 (
<>
<HorizontallyScrollableArea layoutP={layoutP} height={height}>
<FocusRangeArea layoutP={layoutP} />
<FocusRangeCurtains layoutP={layoutP} />
<DopeSheetSelectionView layoutP={layoutP}>
<ListContainer style={{top: tree.top + 'px'}}>
<SheetRow leaf={tree} layoutP={layoutP} />

View file

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

View file

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

View file

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

View file

@ -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<HTMLElement | null>(
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<typeof useDrag>[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}
<RangeStrip
id="range-strip"
enabled={existingRange.enabled}
className={`${isDragging ? 'dragging' : ''} ${
existingRange.enabled ? 'enabled' : ''
}`}
ref={rangeStripRef as $IntentionalAny}
style={{
transform: `translateX(${translateX}px) scale(${scaleX}, 1)`,
...conditionalStyleProps,
}}
/>
</>
)
}, [layoutP, rangeStripRef, existingRangeD, contextMenu])
}, [layoutP, rangeStripRef, existingRangeD, contextMenu, isDragging])
}
export default FocusRangeStrip

View file

@ -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<SequenceEditorPanelLayout>
thumbType: keyof IRange
}> = ({layoutP, thumbType}) => {
const [hitZoneRef, hitZoneNode] = useRefAndState<HTMLElement | null>(null)
const handlerRef = useRef<HTMLElement | null>(null)
const [isDragging, setIsDragging] = useState<boolean>(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<typeof useDrag>[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 ? (
<>
<HitZone
ref={hitZoneRef as $IntentionalAny}
data-pos={position.toFixed(3)}
style={{
transform: `translate3d(${posInClippedSpace}px, 0, 0)`,
cursor: thumbType === 'start' ? 'w-resize' : 'e-resize',
pointerEvents,
}}
>
<Handler
ref={handlerRef as $IntentionalAny}
style={{
background,
left: `${
thumbType === 'start' ? startHandlerOffset : endHandlerOffset
}px`,
pointerEvents,
}}
>
<svg viewBox="0 0 9 18" xmlns="http://www.w3.org/2000/svg">
<line x1="4" y1="6" x2="4" y2="12" />
<line x1="6" y1="6" x2="6" y2="12" />
</svg>
<Tooltip>
{sequence.positionFormatter.formatBasic(sequence.length)}
</Tooltip>
</Handler>
</HitZone>
</>
) : (
<></>
return (
<TheDiv
ref={hitZoneRef as $IntentionalAny}
data-pos={position.toFixed(3)}
{...{
[attributeNameThatLocksFramestamp]: position.toFixed(3),
}}
className={`${isDragging && 'dragging'} ${enabled && 'enabled'}`}
enabled={enabled}
type={thumbType}
style={{
transform: `translate3d(${posInClippedSpace}px, 0, 0)`,
}}
>
<ColoredMargin type={thumbType} enabled={enabled} />
<OuterColoredMargin type={thumbType} />
<svg viewBox="0 0 9 18" xmlns="http://www.w3.org/2000/svg">
<line x1="4" y1="6" x2="4" y2="12" />
<line x1="6" y1="6" x2="6" y2="12" />
</svg>
</TheDiv>
)
}, [layoutP, hitZoneRef, existingRangeD, focusRangeEnabled])
}, [layoutP, hitZoneRef, existingRangeD, isDragging])
}
export default FocusRangeThumb

View file

@ -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 (
<Container
ref={containerRef as $IntentionalAny}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
isShiftDown={isShiftDown}
>
<FocusRangeStrip layoutP={layoutP} />
<FocusRangeThumb thumbType="start" layoutP={layoutP} />
<FocusRangeThumb thumbType="end" layoutP={layoutP} />
</Container>
)
}, [layoutP, existingRangeD])
}, [layoutP, existingRangeD, isShiftDown])
}
export default FocusRangeZone
@ -101,6 +90,14 @@ function usePanelDragZoneGestureHandlers(
layoutP: Pointer<SequenceEditorPanelLayout>,
panelStuffRef: React.MutableRefObject<ReturnType<typeof usePanel>>,
) {
const [mode, setMode] = useState<'none' | 'creating' | 'moving-panel'>('none')
useCssCursorLock(
mode !== 'none',
'dragging',
mode === 'creating' ? 'ew-resize' : 'move',
)
return useMemo((): Parameters<typeof useDrag>[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])
}

View file

@ -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 = () => (
<svg
width="7"
height="26"
viewBox="0 0 7 26"
xmlns="http://www.w3.org/2000/svg"
style={{fill: '#00e0ff', marginLeft: '-1px'}}
>
<path d="M 0,0 L 7,0 L 7,13 C 4,15 4,26 4,26 L 3,26 C 3,26 3,15 0,13 L 0,0 Z" />
</svg>
)
const LargeThumbSvg: React.FC = () => (
<svg
width="9"
height="37"
viewBox="0 0 9 37"
xmlns="http://www.w3.org/2000/svg"
style={{
fill: '#00e0ff',
marginLeft: '-2px',
marginTop: '-4px',
}}
>
<path d="M 0,0 L 9,0 L 9,18 C 5,20 5,37 5,37 L 4,37 C 4,37 4,20 0,18 L 0,0 Z" />
</svg>
)
const Playhead: React.FC<{layoutP: Pointer<SequenceEditorPanelLayout>}> = ({
layoutP,
}) => {
@ -131,20 +198,18 @@ const Playhead: React.FC<{layoutP: Pointer<SequenceEditorPanelLayout>}> = ({
},
)
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<typeof useDrag>[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<SequenceEditorPanelLayout>}> = ({
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<SequenceEditorPanelLayout>}> = ({
posInClippedSpace >= 0 &&
posInClippedSpace <= val(layoutP.clippedSpace.width)
const isPlayheadAttachedToFocusRange = val(
getIsPlayheadAttachedToFocusRange(sequence),
)
return (
<>
{popoverNode}
<Container
isVisible={isVisible}
style={{transform: `translate3d(${posInClippedSpace}px, 0, 0)`}}
className={isSeeking ? 'seeking' : ''}
className={`${isSeeking && 'seeking'} ${
isPlayheadAttachedToFocusRange && 'playheadattachedtofocusrange'
}`}
{...{[attributeNameThatLocksFramestamp]: 'hide'}}
>
<Thumb
ref={thumbRef as $IntentionalAny}
data-pos={posInUnitSpace.toFixed(3)}
onClick={(e) => {
openPopover(e, thumbNode!)
}}
>
<RoomToClick room={8} />
{isPlayheadAttachedToFocusRange ? (
<LargeThumbSvg />
) : (
<RegularThumbSvg />
)}
<Tooltip
style={{top: isPlayheadAttachedToFocusRange ? '-23px' : '-18px'}}
>
<Squinch />
<Tooltip>
{sequence.positionFormatter.formatForPlayhead(
sequence.closestGridPosition(posInUnitSpace),
)}

View file

@ -74,9 +74,9 @@ const PlayheadPositionPopover: React.FC<{
return (
<Container>
<Label>Playhead position</Label>
<Label>Sequence position</Label>
<BasicNumberInput
value={sequence.position}
value={Number(sequence.position.toFixed(3))}
{...fns}
isValid={greaterThanZero}
inputRef={inputRef}

View file

@ -11,6 +11,14 @@ import styled from 'styled-components'
// using an ID to make CSS selectors faster
const elementId = 'pointer-root'
/**
* When the cursor is locked, this css prop is added to #pointer-root
* whose value will be the locked cursor (e.g. ew-resize).
*
* Look up references of this constant for examples of how it is used.
*/
export const lockedCursorCssPropName = '--lockedCursor'
const Container = styled.div`
pointer-events: auto;
&.normal {
@ -54,13 +62,20 @@ const PointerEventsHandler: React.FC<{
}
}, [])
const lockedCursor = locks[0]?.cursor ?? ''
return (
<context.Provider value={contextValue}>
<Container
id={elementId}
className={(locks[0]?.className ?? 'normal') + ' ' + props.className}
>
<CursorOverride style={{cursor: locks[0]?.cursor ?? ''}}>
<CursorOverride
style={{
cursor: lockedCursor,
// @ts-ignore
[lockedCursorCssPropName]: lockedCursor,
}}
>
{props.children}
</CursorOverride>
</Container>

View file

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