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
This commit is contained in:
Aria Minaei 2021-08-03 23:56:04 +02:00
parent e6bfd999a2
commit 197d59e4c4
27 changed files with 340 additions and 81 deletions

View file

@ -9,7 +9,7 @@ export default class UI {
_showing = false _showing = false
private _renderTimeout: NodeJS.Timer | undefined = undefined private _renderTimeout: NodeJS.Timer | undefined = undefined
private _documentBodyUIIsRenderedIn: HTMLElement | undefined = undefined private _documentBodyUIIsRenderedIn: HTMLElement | undefined = undefined
readonly containerShadow: HTMLElement readonly containerShadow: ShadowRoot & HTMLElement
constructor(readonly studio: Studio) { constructor(readonly studio: Studio) {
// @todo we can't bootstrap theatre (as in, to design theatre using theatre), if we rely on IDed elements // @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 // 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 // references. There are a few functions that actually work with a ShadowRoot
// but are typed to accept HTMLElement // but are typed to accept HTMLElement
}) as $IntentionalAny as HTMLElement }) as $IntentionalAny as ShadowRoot & HTMLElement
} }
show() { show() {

View file

@ -11,6 +11,7 @@ import useRefAndState from '@theatre/studio/utils/useRefAndState'
import {PortalContext} from 'reakit' import {PortalContext} from 'reakit'
import type {$IntentionalAny} from '@theatre/shared/utils/types' import type {$IntentionalAny} from '@theatre/shared/utils/types'
import useKeyboardShortcuts from './useKeyboardShortcuts' import useKeyboardShortcuts from './useKeyboardShortcuts'
import PointerEventsHandler from '@theatre/studio/uiComponents/PointerEventsHandler'
const GlobalStyle = createGlobalStyle` const GlobalStyle = createGlobalStyle`
:host { :host {
@ -31,14 +32,13 @@ const GlobalStyle = createGlobalStyle`
} }
` `
const Container = styled.div` const Container = styled(PointerEventsHandler)`
z-index: 50; z-index: 50;
position: fixed; position: fixed;
top: 0px; top: 0px;
right: 0px; right: 0px;
bottom: 0px; bottom: 0px;
left: 0px; left: 0px;
pointer-events: none;
` `
const PortalLayer = styled.div` const PortalLayer = styled.div`

View file

@ -1,4 +1,12 @@
import {lighten} from 'polished' 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 = { export const theme = {
panel: { panel: {

View file

@ -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)` const F2 = styled(F2Impl)`
position: relative; 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` const ErrorContainer = styled.div`

View file

@ -7,11 +7,12 @@ import {lighten} from 'polished'
import React, {useMemo, useRef, useState} from 'react' import React, {useMemo, useRef, useState} from 'react'
import styled from 'styled-components' import styled from 'styled-components'
import {panelDimsToPanelPosition, usePanel} from './BasePanel' import {panelDimsToPanelPosition, usePanel} from './BasePanel'
import {pointerEventsAutoInNormalMode} from '@theatre/studio/css'
const Base = styled.div` const Base = styled.div`
position: absolute; position: absolute;
z-index: 10; z-index: 10;
pointer-events: auto; ${pointerEventsAutoInNormalMode};
&:after { &:after {
position: absolute; position: absolute;
top: -2px; top: -2px;

View file

@ -1,3 +1,4 @@
import {pointerEventsAutoInNormalMode} from '@theatre/studio/css'
import React from 'react' import React from 'react'
import styled from 'styled-components' import styled from 'styled-components'
import {usePanel} from './BasePanel' import {usePanel} from './BasePanel'
@ -7,7 +8,7 @@ const Container = styled.div`
position: absolute; position: absolute;
user-select: none; user-select: none;
box-sizing: border-box; box-sizing: border-box;
pointer-events: auto; ${pointerEventsAutoInNormalMode};
/* box-shadow: 1px 2px 10px -5px black; */ /* box-shadow: 1px 2px 10px -5px black; */
z-index: 1000; z-index: 1000;

View file

@ -11,6 +11,7 @@ import {
TitleBar_Piece, TitleBar_Piece,
TitleBar_Punctuation, TitleBar_Punctuation,
} from '@theatre/studio/panels/BasePanel/common' } from '@theatre/studio/panels/BasePanel/common'
import {pointerEventsAutoInNormalMode} from '@theatre/studio/css'
const Container = styled.div` const Container = styled.div`
background-color: transparent; background-color: transparent;
@ -30,7 +31,7 @@ const Container = styled.div`
bottom: 0; bottom: 0;
right: 0; right: 0;
width: 20px; width: 20px;
pointer-events: auto; ${pointerEventsAutoInNormalMode};
} }
` `
@ -54,7 +55,7 @@ const Title = styled.div`
font-weight: 500; font-weight: 500;
font-size: 10px; font-size: 10px;
user-select: none; user-select: none;
pointer-events: auto; ${pointerEventsAutoInNormalMode};
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
@ -84,7 +85,7 @@ const Header = styled.div`
` `
const Body = styled.div` const Body = styled.div`
pointer-events: auto; ${pointerEventsAutoInNormalMode};
position: absolute; position: absolute;
top: ${headerHeight}; top: ${headerHeight};
left: 0; left: 0;

View file

@ -15,11 +15,12 @@ import {
rowBg, rowBg,
} from './utils/SingleRowPropEditor' } from './utils/SingleRowPropEditor'
import DefaultOrStaticValueIndicator from './utils/DefaultValueIndicator' import DefaultOrStaticValueIndicator from './utils/DefaultValueIndicator'
import {pointerEventsAutoInNormalMode} from '@theatre/studio/css'
const Container = styled.div` const Container = styled.div`
--step: 8px; --step: 8px;
--left-pad: 0px; --left-pad: 0px;
pointer-events: auto; ${pointerEventsAutoInNormalMode};
` `
const Header = styled.div` const Header = styled.div`

View file

@ -1,4 +1,5 @@
import type {Keyframe} from '@theatre/core/projects/store/types/SheetState_Historic' import type {Keyframe} from '@theatre/core/projects/store/types/SheetState_Historic'
import {pointerEventsAutoInNormalMode} from '@theatre/studio/css'
import {transparentize} from 'polished' import {transparentize} from 'polished'
import React from 'react' import React from 'react'
import styled from 'styled-components' import styled from 'styled-components'
@ -75,7 +76,8 @@ const PrevOrNextButton = styled(Button)<{available: boolean}>`
props.available props.available
? nextPrevCursorsTheme.onColor ? nextPrevCursorsTheme.onColor
: nextPrevCursorsTheme.offColor}; : nextPrevCursorsTheme.offColor};
pointer-events: ${(props) => (props.available ? 'auto' : 'none')};
${(props) => (props.available ? pointerEventsAutoInNormalMode : '')};
` `
const Prev = styled(PrevOrNextButton)<{available: boolean}>` const Prev = styled(PrevOrNextButton)<{available: boolean}>`

View file

@ -9,6 +9,7 @@ import type {useEditingToolsForPrimitiveProp} from '@theatre/studio/panels/Objec
import {shadeToColor} from '@theatre/studio/panels/ObjectEditorPanel/propEditors/utils/useEditingToolsForPrimitiveProp' import {shadeToColor} from '@theatre/studio/panels/ObjectEditorPanel/propEditors/utils/useEditingToolsForPrimitiveProp'
import styled, {css} from 'styled-components' import styled, {css} from 'styled-components'
import {transparentize} from 'polished' import {transparentize} from 'polished'
import {pointerEventsAutoInNormalMode} from '@theatre/studio/css'
export const indentationFormula = `calc(var(--left-pad) + var(--depth) * var(--step))` export const indentationFormula = `calc(var(--left-pad) + var(--depth) * var(--step))`
@ -39,7 +40,7 @@ const Row = styled.div`
align-items: stretch; align-items: stretch;
--right-width: 60%; --right-width: 60%;
position: relative; position: relative;
pointer-events: auto; ${pointerEventsAutoInNormalMode};
${rowBg}; ${rowBg};
` `

View file

@ -5,6 +5,7 @@ import styled, {css} from 'styled-components'
import noop from '@theatre/shared/utils/noop' import noop from '@theatre/shared/utils/noop'
import {transparentize, darken, opacify, lighten} from 'polished' import {transparentize, darken, opacify, lighten} from 'polished'
import {rowBgColor} from '@theatre/studio/panels/ObjectEditorPanel/propEditors/utils/SingleRowPropEditor' import {rowBgColor} from '@theatre/studio/panels/ObjectEditorPanel/propEditors/utils/SingleRowPropEditor'
import {pointerEventsAutoInNormalMode} from '@theatre/studio/css'
export const Container = styled.li<{depth: number}>` export const Container = styled.li<{depth: number}>`
--depth: ${(props) => props.depth}; --depth: ${(props) => props.depth};
@ -70,7 +71,7 @@ const Head_Label = styled.span`
${outlineItemFont}; ${outlineItemFont};
padding: 2px 8px; padding: 2px 8px;
pointer-events: auto; ${pointerEventsAutoInNormalMode};
position: relative; position: relative;
display: block; display: block;
height: 13px; height: 13px;
@ -96,7 +97,7 @@ const Head_Label = styled.span`
display: block; display: block;
content: ' '; content: ' ';
z-index: 0; z-index: 0;
pointer-events: auto; ${pointerEventsAutoInNormalMode};
} }
` `

View file

@ -2,6 +2,7 @@ import React from 'react'
import styled from 'styled-components' import styled from 'styled-components'
import {panelZIndexes} from '@theatre/studio/panels/BasePanel/common' import {panelZIndexes} from '@theatre/studio/panels/BasePanel/common'
import ProjectsList from './ProjectsList/ProjectsList' import ProjectsList from './ProjectsList/ProjectsList'
import {pointerEventsAutoInNormalMode} from '@theatre/studio/css'
const Container = styled.div` const Container = styled.div`
background-color: transparent; background-color: transparent;
@ -21,7 +22,7 @@ const Container = styled.div`
bottom: 0; bottom: 0;
left: 0; left: 0;
width: 20px; width: 20px;
pointer-events: auto; ${pointerEventsAutoInNormalMode};
} }
&:hover:before { &:hover:before {
@ -58,7 +59,7 @@ const Header = styled.div`
top: 0; top: 0;
left: 0; left: 0;
width: 180px; width: 180px;
pointer-events: auto; ${pointerEventsAutoInNormalMode};
&:after { &:after {
position: absolute; position: absolute;
@ -78,14 +79,14 @@ const Title = styled.div`
font-weight: 500; font-weight: 500;
font-size: 10px; font-size: 10px;
user-select: none; user-select: none;
pointer-events: auto; ${pointerEventsAutoInNormalMode};
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
` `
const Body = styled.div` const Body = styled.div`
pointer-events: auto; ${pointerEventsAutoInNormalMode};
position: absolute; position: absolute;
top: ${headerHeight}; top: ${headerHeight};
left: 0; left: 0;

View file

@ -14,6 +14,7 @@ import styled from 'styled-components'
import type KeyframeEditor from './KeyframeEditor' import type KeyframeEditor from './KeyframeEditor'
import {useLockFrameStampPosition} from '@theatre/studio/panels/SequenceEditorPanel/FrameStampPositionProvider' import {useLockFrameStampPosition} from '@theatre/studio/panels/SequenceEditorPanel/FrameStampPositionProvider'
import {attributeNameThatLocksFramestamp} 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 export const dotSize = 6
const hitZoneSize = 12 const hitZoneSize = 12
@ -46,10 +47,20 @@ const Square = styled.div<{isSelected: boolean}>`
const HitZone = styled.div` const HitZone = styled.div`
position: absolute; position: absolute;
${dims(hitZoneSize)}; ${dims(hitZoneSize)};
z-index: 1; z-index: 1;
cursor: ew-resize; cursor: ew-resize;
&:hover + ${Square} { #pointer-root.draggingpositioninsequenceeditor & {
pointer-events: auto;
}
&.beingDragged {
pointer-events: none !important;
}
&:hover + ${Square}, &.beingDragged + ${Square} {
${dims(dotSize + 5)} ${dims(dotSize + 5)}
} }
` `
@ -60,7 +71,7 @@ const Dot: React.FC<IProps> = (props) => {
const [ref, node] = useRefAndState<HTMLDivElement | null>(null) const [ref, node] = useRefAndState<HTMLDivElement | null>(null)
const [contextMenu] = useKeyframeContextMenu(node, props) const [contextMenu] = useKeyframeContextMenu(node, props)
useDragKeyframe(node, props) const [isDragging] = useDragKeyframe(node, props)
return ( return (
<> <>
@ -71,6 +82,7 @@ const Dot: React.FC<IProps> = (props) => {
[attributeNameThatLocksFramestamp]: [attributeNameThatLocksFramestamp]:
props.keyframe.position.toFixed(3), props.keyframe.position.toFixed(3),
}} }}
className={isDragging ? 'beingDragged' : ''}
/> />
<Square isSelected={!!props.selection} /> <Square isSelected={!!props.selection} />
{contextMenu} {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) const [isDragging, setIsDragging] = useState(false)
useLockFrameStampPosition(isDragging, props.keyframe.position) useLockFrameStampPosition(isDragging, props.keyframe.position)
@ -125,7 +140,6 @@ function useDragKeyframe(node: HTMLDivElement | null, props: IProps) {
| undefined | undefined
return { return {
lockCursorTo: 'ew-resize',
onDragStart(event) { onDragStart(event) {
setIsDragging(true) setIsDragging(true)
if (propsRef.current.selection) { if (propsRef.current.selection) {
@ -149,11 +163,30 @@ function useDragKeyframe(node: HTMLDivElement | null, props: IProps) {
selectionDragHandlers.onDrag(dx, dy, event) selectionDragHandlers.onDrag(dx, dy, event)
return return
} }
const original = const original =
propsAtStartOfDrag.trackData.keyframes[propsAtStartOfDrag.index] propsAtStartOfDrag.trackData.keyframes[propsAtStartOfDrag.index]
const deltaPos = toUnitSpace(dx) const deltaPos = toUnitSpace(dx)
const newPosBeforeSnapping = Math.max(original.position + deltaPos, 0) 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) { if (tempTransaction) {
tempTransaction.discard() tempTransaction.discard()
tempTransaction = undefined tempTransaction = undefined
@ -163,7 +196,7 @@ function useDragKeyframe(node: HTMLDivElement | null, props: IProps) {
{ {
...propsAtStartOfDrag.leaf.sheetObject.address, ...propsAtStartOfDrag.leaf.sheetObject.address,
trackId: propsAtStartOfDrag.leaf.trackId, 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) useDrag(node, gestureHandlers)
useCursorLock(isDragging, 'draggingPositionInSequenceEditor', 'ew-resize')
return [isDragging]
} }

View file

@ -9,6 +9,8 @@ import {clamp, mapValues} from 'lodash-es'
import React, {useLayoutEffect, useMemo} from 'react' import React, {useLayoutEffect, useMemo} from 'react'
import styled from 'styled-components' import styled from 'styled-components'
import {useReceiveVerticalWheelEvent} from '@theatre/studio/panels/SequenceEditorPanel/VerticalScrollContainer' 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` const Container = styled.div`
position: absolute; position: absolute;
@ -16,7 +18,7 @@ const Container = styled.div`
right: 0; right: 0;
overflow-x: scroll; overflow-x: scroll;
overflow-y: hidden; overflow-y: hidden;
pointer-events: all; ${pointerEventsAutoInNormalMode};
// hide the scrollbar on Gecko // hide the scrollbar on Gecko
scrollbar-width: none; scrollbar-width: none;
@ -87,16 +89,42 @@ function useDragHandlers(
const setIsSeeking = val(layoutP.seeker.setIsSeeking) const setIsSeeking = val(layoutP.seeker.setIsSeeking)
return { return {
onDrag(dx: number) { onDrag(dx: number, _, event) {
const deltaPos = scaledSpaceToUnitSpace(dx) const deltaPos = scaledSpaceToUnitSpace(dx)
const newPos = clamp(posBeforeSeek + deltaPos, 0, sequence.length) const unsnappedPos = clamp(posBeforeSeek + deltaPos, 0, sequence.length)
sequence.position = newPos
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) { onDragStart(event) {
if (event.target instanceof HTMLInputElement) return false if (event.target instanceof HTMLInputElement) return false
if (event.shiftKey || event.altKey || event.ctrlKey || event.metaKey) { if (event.shiftKey || event.altKey || event.ctrlKey || event.metaKey) {
return false return false
} }
if (
event
.composedPath()
.some((el) => el instanceof HTMLElement && el.draggable === true)
) {
return false
}
const initialPositionInClippedSpace = const initialPositionInClippedSpace =
event.clientX - containerEl!.getBoundingClientRect().left event.clientX - containerEl!.getBoundingClientRect().left
@ -114,11 +142,12 @@ function useDragHandlers(
onDragEnd() { onDragEnd() {
setIsSeeking(false) setIsSeeking(false)
}, },
lockCursorTo: 'ew-resize',
} }
}, [layoutP, containerEl]) }, [layoutP, containerEl])
useDrag(containerEl, handlers) const [isDragigng] = useDrag(containerEl, handlers)
useCursorLock(isDragigng, 'draggingPositionInSequenceEditor', 'ew-resize')
} }
function useHandlePanAndZoom( function useHandlePanAndZoom(
@ -201,13 +230,17 @@ function useUpdateScrollFromClippedSpaceRange(
return rangeStartInScaledSpace return rangeStartInScaledSpace
}) })
const untap = d.changesWithoutValues().tap(() => { const update = () => {
const rangeStartInScaledSpace = d.getValue() const rangeStartInScaledSpace = d.getValue()
node.scrollLeft = rangeStartInScaledSpace node.scrollLeft = rangeStartInScaledSpace
}) }
const untap = d.changesWithoutValues().tap(update)
update()
const timeout = setTimeout(update, 100)
return () => { return () => {
clearTimeout(timeout)
untap() untap()
} }
}, [layoutP, node]) }, [layoutP, node])

View file

@ -18,6 +18,7 @@ import {
} from '@theatre/studio/panels/SequenceEditorPanel/FrameStampPositionProvider' } from '@theatre/studio/panels/SequenceEditorPanel/FrameStampPositionProvider'
import {GoChevronLeft, GoChevronRight} from 'react-icons/all' import {GoChevronLeft, GoChevronRight} from 'react-icons/all'
import LengthEditorPopover from './LengthEditorPopover' import LengthEditorPopover from './LengthEditorPopover'
import {pointerEventsAutoInNormalMode} from '@theatre/studio/css'
const coverWidth = 1000 const coverWidth = 1000
@ -71,7 +72,7 @@ const Tooltip = styled.div`
white-space: nowrap; white-space: nowrap;
padding: 1px 8px; padding: 1px 8px;
border-radius: 0 2px 2px 0; border-radius: 0 2px 2px 0;
pointer-events: auto; ${pointerEventsAutoInNormalMode};
cursor: ew-resize; cursor: ew-resize;
color: #464646; color: #464646;
background-color: #0000004d; background-color: #0000004d;
@ -89,7 +90,7 @@ const Tumb = styled.div`
white-space: nowrap; white-space: nowrap;
padding: 1px 2px; padding: 1px 2px;
border-radius: 2px; border-radius: 2px;
pointer-events: auto; ${pointerEventsAutoInNormalMode};
justify-content: center; justify-content: center;
align-items: center; align-items: center;
cursor: ew-resize; cursor: ew-resize;

View file

@ -11,6 +11,7 @@ import React, {useMemo, useRef} from 'react'
import styled from 'styled-components' import styled from 'styled-components'
import {transformBox} from './Curve' import {transformBox} from './Curve'
import type KeyframeEditor from './KeyframeEditor' import type KeyframeEditor from './KeyframeEditor'
import {pointerEventsAutoInNormalMode} from '@theatre/studio/css'
export const dotSize = 6 export const dotSize = 6
@ -28,7 +29,7 @@ const HitZone = styled.circle`
r: 6px; r: 6px;
fill: transparent; fill: transparent;
cursor: move; cursor: move;
pointer-events: all; ${pointerEventsAutoInNormalMode};
&:hover { &:hover {
} }
&:hover + ${Circle} { &:hover + ${Circle} {

View file

@ -12,6 +12,8 @@ import type KeyframeEditor from './KeyframeEditor'
import type {Keyframe} from '@theatre/core/projects/store/types/SheetState_Historic' import type {Keyframe} from '@theatre/core/projects/store/types/SheetState_Historic'
import {useLockFrameStampPosition} from '@theatre/studio/panels/SequenceEditorPanel/FrameStampPositionProvider' import {useLockFrameStampPosition} from '@theatre/studio/panels/SequenceEditorPanel/FrameStampPositionProvider'
import {attributeNameThatLocksFramestamp} 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 export const dotSize = 6
@ -28,13 +30,23 @@ const HitZone = styled.circle`
vector-effect: non-scaling-stroke; vector-effect: non-scaling-stroke;
r: 6px; r: 6px;
fill: transparent; fill: transparent;
cursor: move; ${pointerEventsAutoInNormalMode};
pointer-events: all;
&:hover {
}
&:hover + ${Circle} { &:hover + ${Circle} {
r: 6px; r: 6px;
} }
#pointer-root.normal & {
cursor: move;
}
#pointer-root.draggingpositioninsequenceeditor & {
pointer-events: auto;
}
&.beingDragged {
pointer-events: none !important;
}
` `
type IProps = Parameters<typeof KeyframeEditor>[0] type IProps = Parameters<typeof KeyframeEditor>[0]
@ -47,7 +59,7 @@ const Dot: React.FC<IProps> = (props) => {
const next = trackData.keyframes[index + 1] const next = trackData.keyframes[index + 1]
const [contextMenu] = useKeyframeContextMenu(node, props) const [contextMenu] = useKeyframeContextMenu(node, props)
useDragKeyframe(node, props) const isDragging = useDragKeyframe(node, props)
const cyInExtremumSpace = props.extremumSpace.fromValueSpace(cur.value) const cyInExtremumSpace = props.extremumSpace.fromValueSpace(cur.value)
@ -63,6 +75,8 @@ const Dot: React.FC<IProps> = (props) => {
{...{ {...{
[attributeNameThatLocksFramestamp]: cur.position.toFixed(3), [attributeNameThatLocksFramestamp]: cur.position.toFixed(3),
}} }}
data-pos={cur.position.toFixed(3)}
className={isDragging ? 'beingDragged' : ''}
/> />
<Circle <Circle
style={{ style={{
@ -78,7 +92,10 @@ const Dot: React.FC<IProps> = (props) => {
export default Dot export default Dot
function useDragKeyframe(node: SVGCircleElement | null, _props: IProps): void { function useDragKeyframe(
node: SVGCircleElement | null,
_props: IProps,
): boolean {
const [isDragging, setIsDragging] = useState(false) const [isDragging, setIsDragging] = useState(false)
useLockFrameStampPosition(isDragging, _props.keyframe.position) useLockFrameStampPosition(isDragging, _props.keyframe.position)
const propsRef = useRef(_props) const propsRef = useRef(_props)
@ -203,6 +220,8 @@ function useDragKeyframe(node: SVGCircleElement | null, _props: IProps): void {
}, []) }, [])
useDrag(node, gestureHandlers) useDrag(node, gestureHandlers)
useCursorLock(isDragging, 'draggingPositionInSequenceEditor', 'ew-resize')
return isDragging
} }
function useKeyframeContextMenu(node: SVGCircleElement | null, props: IProps) { function useKeyframeContextMenu(node: SVGCircleElement | null, props: IProps) {

View file

@ -10,6 +10,7 @@ import React, {useCallback, useMemo, useState} from 'react'
import styled from 'styled-components' import styled from 'styled-components'
import {zIndexes} from '@theatre/studio/panels/SequenceEditorPanel/SequenceEditorPanel' import {zIndexes} from '@theatre/studio/panels/SequenceEditorPanel/SequenceEditorPanel'
import {attributeNameThatLocksFramestamp} from '@theatre/studio/panels/SequenceEditorPanel/FrameStampPositionProvider' import {attributeNameThatLocksFramestamp} from '@theatre/studio/panels/SequenceEditorPanel/FrameStampPositionProvider'
import {pointerEventsAutoInNormalMode} from '@theatre/studio/css'
const Container = styled.div` const Container = styled.div`
--threadHeight: 6px; --threadHeight: 6px;
@ -20,7 +21,7 @@ const Container = styled.div`
width: 100%; width: 100%;
left: 12px; left: 12px;
/* bottom: 8px; */ /* bottom: 8px; */
pointer-events: auto; ${pointerEventsAutoInNormalMode};
z-index: ${() => zIndexes.horizontalScrollbar}; z-index: ${() => zIndexes.horizontalScrollbar};
` `

View file

@ -15,6 +15,7 @@ import {
attributeNameThatLocksFramestamp, attributeNameThatLocksFramestamp,
useLockFrameStampPosition, useLockFrameStampPosition,
} from '@theatre/studio/panels/SequenceEditorPanel/FrameStampPositionProvider' } from '@theatre/studio/panels/SequenceEditorPanel/FrameStampPositionProvider'
import {pointerEventsAutoInNormalMode} from '@theatre/studio/css'
const Container = styled.div<{isVisible: boolean}>` const Container = styled.div<{isVisible: boolean}>`
--thumbColor: #00e0ff; --thumbColor: #00e0ff;
@ -36,6 +37,17 @@ const Rod = styled.div`
height: calc(100% - 8px); height: calc(100% - 8px);
border-left: 1px solid #27e0fd; border-left: 1px solid #27e0fd;
z-index: 10; z-index: 10;
#pointer-root.draggingpositioninsequenceeditor &:not(.seeking) {
pointer-events: auto;
&:after {
position: absolute;
inset: -2px;
display: block;
content: ' ';
}
}
` `
const Thumb = styled.div` const Thumb = styled.div`
@ -47,7 +59,11 @@ const Thumb = styled.div`
left: -2px; left: -2px;
z-index: 11; z-index: 11;
cursor: ew-resize; cursor: ew-resize;
pointer-events: auto; ${pointerEventsAutoInNormalMode};
#pointer-root.draggingpositioninsequenceeditor &:not(.seeking) {
pointer-events: auto;
}
&:before { &:before {
position: absolute; position: absolute;
@ -148,10 +164,29 @@ const Playhead: React.FC<{layoutP: Pointer<SequenceEditorPanelLayout>}> = ({
scaledSpaceToUnitSpace = val(layoutP.scaledSpace.toUnitSpace) scaledSpaceToUnitSpace = val(layoutP.scaledSpace.toUnitSpace)
setIsSeeking(true) setIsSeeking(true)
}, },
onDrag(dx) { onDrag(dx, _, event) {
const deltaPos = scaledSpaceToUnitSpace(dx) const deltaPos = scaledSpaceToUnitSpace(dx)
const newPos = clamp(posBeforeSeek + deltaPos, 0, sequence.length) const unsnappedPos = clamp(posBeforeSeek + deltaPos, 0, sequence.length)
sequence.position = newPos
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() { onDragEnd() {
setIsSeeking(false) setIsSeeking(false)
@ -186,7 +221,10 @@ const Playhead: React.FC<{layoutP: Pointer<SequenceEditorPanelLayout>}> = ({
className={isSeeking ? 'seeking' : ''} className={isSeeking ? 'seeking' : ''}
{...{[attributeNameThatLocksFramestamp]: 'hide'}} {...{[attributeNameThatLocksFramestamp]: 'hide'}}
> >
<Thumb ref={thumbRef as $IntentionalAny}> <Thumb
ref={thumbRef as $IntentionalAny}
data-pos={posInUnitSpace.toFixed(3)}
>
<RoomToClick room={8} /> <RoomToClick room={8} />
<Squinch /> <Squinch />
<Tooltip> <Tooltip>
@ -196,7 +234,10 @@ const Playhead: React.FC<{layoutP: Pointer<SequenceEditorPanelLayout>}> = ({
</Tooltip> </Tooltip>
</Thumb> </Thumb>
<Rod /> <Rod
data-pos={posInUnitSpace.toFixed(3)}
className={isSeeking ? 'seeking' : ''}
/>
</Container> </Container>
) )
}, [layoutP]) }, [layoutP])

View file

@ -6,6 +6,7 @@ import type {SequenceEditorPanelLayout} from '@theatre/studio/panels/SequenceEdi
import StampsGrid from '@theatre/studio/panels/SequenceEditorPanel/FrameGrid/StampsGrid' import StampsGrid from '@theatre/studio/panels/SequenceEditorPanel/FrameGrid/StampsGrid'
import PanelDragZone from '@theatre/studio/panels/BasePanel/PanelDragZone' import PanelDragZone from '@theatre/studio/panels/BasePanel/PanelDragZone'
import {attributeNameThatLocksFramestamp} from '@theatre/studio/panels/SequenceEditorPanel/FrameStampPositionProvider' import {attributeNameThatLocksFramestamp} from '@theatre/studio/panels/SequenceEditorPanel/FrameStampPositionProvider'
import {pointerEventsAutoInNormalMode} from '@theatre/studio/css'
export const topStripHeight = 20 export const topStripHeight = 20
@ -23,7 +24,7 @@ const Container = styled(PanelDragZone)`
box-sizing: border-box; box-sizing: border-box;
background: ${topStripTheme.backgroundColor}; background: ${topStripTheme.backgroundColor};
border-bottom: 1px solid ${topStripTheme.borderColor}; border-bottom: 1px solid ${topStripTheme.borderColor};
pointer-events: auto; ${pointerEventsAutoInNormalMode};
` `
const TopStrip: React.FC<{layoutP: Pointer<SequenceEditorPanelLayout>}> = ({ const TopStrip: React.FC<{layoutP: Pointer<SequenceEditorPanelLayout>}> = ({

View file

@ -0,0 +1,2 @@
// export const
export {}

View file

@ -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<Context>({} as $IntentionalAny)
const PointerEventsHandler: React.FC<{
className?: string
}> = (props) => {
const [locks, setLocks] = useState<Lock[]>([])
const contextValue = useMemo<Context>(() => {
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 (
<context.Provider value={contextValue}>
<Container id={elementId} className={locks[0]?.className ?? 'normal'}>
<CursorOverride cursor={locks[0]?.cursor}>
{props.children}
</CursorOverride>
</Container>
</context.Provider>
)
}
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

View file

@ -1,3 +1,4 @@
import {pointerEventsAutoInNormalMode} from '@theatre/studio/css'
import getStudio from '@theatre/studio/getStudio' import getStudio from '@theatre/studio/getStudio'
import useBoundingClientRect from '@theatre/studio/uiComponents/useBoundingClientRect' import useBoundingClientRect from '@theatre/studio/uiComponents/useBoundingClientRect'
import transparentize from 'polished/lib/color/transparentize' import transparentize from 'polished/lib/color/transparentize'
@ -23,7 +24,7 @@ const Container = styled.ul`
padding: 0; padding: 0;
margin: 0; margin: 0;
cursor: default; cursor: default;
pointer-events: all; ${pointerEventsAutoInNormalMode};
border-radius: 3px; border-radius: 3px;
` `

View file

@ -1,18 +1,19 @@
export function createCursorLock(cursor: string) { // import getStudio from '@theatre/studio/getStudio'
const el = document.createElement('div')
el.style.cssText = `
position: fixed;
top: 0;
right: 0;
bottom: 0;
left: 0;
z-index: 9999999;`
el.style.cursor = cursor // export function createCursorLock(cursor: string) {
document.body.appendChild(el) // const el = getStudio()!.ui.containerShadow.getElementById(
const relinquish = () => { // 'pointer-events-root',
document.body.removeChild(el) // )! 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 {}

View file

@ -1,3 +1,4 @@
import {pointerEventsAutoInNormalMode} from '@theatre/studio/css'
import getStudio from '@theatre/studio/getStudio' import getStudio from '@theatre/studio/getStudio'
import useBoundingClientRect from '@theatre/studio/uiComponents/useBoundingClientRect' import useBoundingClientRect from '@theatre/studio/uiComponents/useBoundingClientRect'
import transparentize from 'polished/lib/color/transparentize' import transparentize from 'polished/lib/color/transparentize'
@ -26,7 +27,7 @@ const Container = styled.ul`
margin: 0; margin: 0;
border-radius: 1px; border-radius: 1px;
cursor: default; cursor: default;
pointer-events: all; ${pointerEventsAutoInNormalMode};
border-radius: 3px; border-radius: 3px;
` `

View file

@ -4,11 +4,12 @@ import styled from 'styled-components'
import type {ButtonProps} from 'reakit' import type {ButtonProps} from 'reakit'
import {outlinePanelTheme} from '@theatre/studio/panels/OutlinePanel/BaseItem' import {outlinePanelTheme} from '@theatre/studio/panels/OutlinePanel/BaseItem'
import {darken, opacify} from 'polished' import {darken, opacify} from 'polished'
import {pointerEventsAutoInNormalMode} from '@theatre/studio/css'
const {baseBg, baseBorderColor, baseFontColor} = outlinePanelTheme const {baseBg, baseBorderColor, baseFontColor} = outlinePanelTheme
export const TheButton = styled.button` export const TheButton = styled.button`
pointer-events: auto; ${pointerEventsAutoInNormalMode};
position: relative; position: relative;
display: flex; display: flex;
align-items: center; align-items: center;

View file

@ -1,7 +1,7 @@
import {voidFn} from '@theatre/shared/utils'
import type {$FixMe} from '@theatre/shared/utils/types' import type {$FixMe} from '@theatre/shared/utils/types'
import {useLayoutEffect, useRef} from 'react' import {useLayoutEffect, useRef} from 'react'
import {createCursorLock} from './createCursorLock' import useRefAndState from '@theatre/studio/utils/useRefAndState'
import {useCursorLock} from './PointerEventsHandler'
export type UseDragOpts = { export type UseDragOpts = {
disabled?: boolean disabled?: boolean
@ -15,12 +15,19 @@ export type UseDragOpts = {
export default function useDrag( export default function useDrag(
target: HTMLElement | SVGElement | undefined | null, target: HTMLElement | SVGElement | undefined | null,
opts: UseDragOpts, opts: UseDragOpts,
) { ): [isDragging: boolean] {
const optsRef = useRef<typeof opts>(opts) const optsRef = useRef<typeof opts>(opts)
optsRef.current = opts optsRef.current = opts
const modeRef = const [modeRef, mode] = useRefAndState<
useRef<'dragStartCalled' | 'dragging' | 'notDragging'>('notDragging') 'dragStartCalled' | 'dragging' | 'notDragging'
>('notDragging')
useCursorLock(
mode === 'dragging' && typeof opts.lockCursorTo === 'string',
'dragging',
opts.lockCursorTo!,
)
const stateRef = useRef<{ const stateRef = useRef<{
dragHappened: boolean dragHappened: boolean
@ -38,12 +45,7 @@ export default function useDrag(
return [event.screenX - startPos.x, event.screenY - startPos.y] return [event.screenX - startPos.x, event.screenY - startPos.y]
} }
let relinquishCursorLock = voidFn
const dragHandler = (event: MouseEvent) => { const dragHandler = (event: MouseEvent) => {
if (!stateRef.current.dragHappened && optsRef.current.lockCursorTo) {
relinquishCursorLock = createCursorLock(optsRef.current.lockCursorTo)
}
if (!stateRef.current.dragHappened) stateRef.current.dragHappened = true if (!stateRef.current.dragHappened) stateRef.current.dragHappened = true
modeRef.current = 'dragging' modeRef.current = 'dragging'
@ -57,8 +59,6 @@ export default function useDrag(
optsRef.current.onDragEnd && optsRef.current.onDragEnd &&
optsRef.current.onDragEnd(stateRef.current.dragHappened) optsRef.current.onDragEnd(stateRef.current.dragHappened)
relinquishCursorLock()
relinquishCursorLock = voidFn
} }
const addDragListeners = () => { const addDragListeners = () => {
@ -119,7 +119,6 @@ export default function useDrag(
removeDragListeners() removeDragListeners()
target.removeEventListener('mousedown', onMouseDown as $FixMe) target.removeEventListener('mousedown', onMouseDown as $FixMe)
target.removeEventListener('click', preventUnwantedClick as $FixMe) target.removeEventListener('click', preventUnwantedClick as $FixMe)
relinquishCursorLock()
if (modeRef.current !== 'notDragging') { if (modeRef.current !== 'notDragging') {
optsRef.current.onDragEnd && optsRef.current.onDragEnd &&
@ -128,4 +127,6 @@ export default function useDrag(
modeRef.current = 'notDragging' modeRef.current = 'notDragging'
} }
}, [target]) }, [target])
return [mode === 'dragging']
} }