UX Improvements

* Length of the sequence has a clear indicator now
* The frame stamp snaps to the time of keyframes when they're hovered
* The frame stamp hides when hovering over elements over which it wouldn't be helpful
This commit is contained in:
Aria Minaei 2021-08-01 17:42:31 +02:00
parent c6c37992ac
commit 0a3c699180
13 changed files with 266 additions and 59 deletions

View file

@ -12,6 +12,11 @@ import {lighten} from 'polished'
import React, {useMemo, useRef} from 'react'
import styled from 'styled-components'
import type KeyframeEditor from './KeyframeEditor'
import type {FrameStampPositionLock} from '@theatre/studio/panels/SequenceEditorPanel/FrameStampPositionProvider'
import {
attributeNameThatLocksFramestamp,
useFrameStampPosition,
} from '@theatre/studio/panels/SequenceEditorPanel/FrameStampPositionProvider'
export const dotSize = 6
const hitZoneSize = 12
@ -64,8 +69,12 @@ const Dot: React.FC<IProps> = (props) => {
<>
<HitZone
ref={ref}
title={props.keyframe.position.toString()}
title={props.keyframe.position.toFixed(8)}
data-pos={props.keyframe.position.toFixed(3)}
{...{
[attributeNameThatLocksFramestamp]:
props.keyframe.position.toFixed(3),
}}
/>
<Square isSelected={!!props.selection} />
{contextMenu}
@ -103,6 +112,8 @@ function useKeyframeContextMenu(node: HTMLDivElement | null, props: IProps) {
}
function useDragKeyframe(node: HTMLDivElement | null, props: IProps) {
const {getLock} = useFrameStampPosition()
const propsRef = useRef(props)
propsRef.current = props
@ -110,6 +121,8 @@ function useDragKeyframe(node: HTMLDivElement | null, props: IProps) {
let toUnitSpace: SequenceEditorPanelLayout['scaledSpace']['toUnitSpace']
let tempTransaction: CommitOrDiscard | undefined
let propsAtStartOfDrag: IProps
let frameStampPositionLock: FrameStampPositionLock
let selectionDragHandlers:
| ReturnType<DopeSheetSelection['getDragHandlers']>
| undefined
@ -131,8 +144,10 @@ function useDragKeyframe(node: HTMLDivElement | null, props: IProps) {
}
propsAtStartOfDrag = propsRef.current
toUnitSpace = val(propsAtStartOfDrag.layoutP.scaledSpace.toUnitSpace)
frameStampPositionLock = getLock()
frameStampPositionLock.set(propsAtStartOfDrag.keyframe.position)
},
onDrag(dx, dy, event) {
if (selectionDragHandlers) {
@ -140,6 +155,10 @@ function useDragKeyframe(node: HTMLDivElement | null, props: IProps) {
return
}
const delta = toUnitSpace(dx)
const original =
propsAtStartOfDrag.trackData.keyframes[propsAtStartOfDrag.index]
frameStampPositionLock.set(original.position + delta)
if (tempTransaction) {
tempTransaction.discard()
tempTransaction = undefined
@ -158,6 +177,8 @@ function useDragKeyframe(node: HTMLDivElement | null, props: IProps) {
})
},
onDragEnd(dragHappened) {
frameStampPositionLock.unlock()
if (selectionDragHandlers) {
selectionDragHandlers.onDragEnd?.(dragHappened)

View file

@ -12,46 +12,58 @@ import type {CommitOrDiscard} from '@theatre/studio/StudioStore/StudioStore'
import useDrag from '@theatre/studio/uiComponents/useDrag'
import getStudio from '@theatre/studio/getStudio'
import type Sheet from '@theatre/core/sheets/Sheet'
import usePopover from '@theatre/studio/uiComponents/Popover/usePopover'
import {attributeNameThatLocksFramestamp} from '@theatre/studio/panels/SequenceEditorPanel/FrameStampPositionProvider'
const coverWidth = 1000
const colors = {
stripNormal: `#0000006c`,
stripActive: `#000000`,
}
const Strip = styled.div`
position: absolute;
top: 0;
left: 0;
width: 4px;
z-index: ${() => zIndexes.lengthIndicatorStrip};
pointer-events: auto;
cursor: ew-resize;
pointer-events: none;
&:after {
display: block;
content: ' ';
position: absolute;
top: ${topStripHeight}px;
/* top: ${topStripHeight}px; */
top: 0;
bottom: 0;
left: -1px;
width: 1px;
background-color: #000000a6;
background-color: ${colors.stripNormal};
}
&:hover:after,
&.dragging:after {
background-color: #000000;
background-color: ${colors.stripActive};
}
`
const Info = styled.div`
const Bulge = styled.div`
position: absolute;
top: ${topStripHeight + 4}px;
font-size: 10px;
left: 4px;
color: #eee;
left: 0px;
white-space: nowrap;
display: none;
padding: 2px 8px 2px 8px;
border-radius: 0 2px 2px 0;
pointer-events: auto;
cursor: ew-resize;
color: #555;
background-color: ${colors.stripNormal};
${Strip}:hover &, ${Strip}.dragging & {
display: block;
color: white;
background-color: ${colors.stripActive};
}
`
@ -64,7 +76,7 @@ const Cover = styled.div`
z-index: ${() => zIndexes.lengthIndicatorCover};
transform-origin: left top;
${Strip}:hover ~ &, ${Strip}.dragging ~ & {
${Strip}.dragging ~ & {
background-color: rgb(23 23 23 / 60%);
}
`
@ -74,8 +86,11 @@ type IProps = {
}
const LengthIndicator: React.FC<IProps> = ({layoutP}) => {
const [stripRef, stripNode] = useRefAndState<HTMLDivElement | null>(null)
const [isDraggingD] = useDragStrip(stripNode, {layoutP})
const [nodeRef, node] = useRefAndState<HTMLDivElement | null>(null)
const [isDraggingD] = useDragBulge(node, {layoutP})
const [popoverNode, openPopover, _, isPopoverOpen] = usePopover(() => {
return <div>poppio</div>
})
return usePrism(() => {
const sheet = val(layoutP.sheet)
@ -101,19 +116,25 @@ const LengthIndicator: React.FC<IProps> = ({layoutP}) => {
return (
<>
{popoverNode}
<Strip
title="Change Sequence Length"
ref={stripRef}
style={{
height: height + 'px',
transform: `translateX(${translateX === 0 ? -1000 : translateX}px)`,
}}
className={val(isDraggingD) ? 'dragging' : ''}
>
<Info>
sequence.length:{' '}
<Bulge
ref={nodeRef}
title="Length of the sequence (sequence.length)"
onClick={(e) => {
openPopover(e, node!)
}}
{...{[attributeNameThatLocksFramestamp]: 'hide'}}
>
{sequence.positionFormatter.formatForPlayhead(sequenceLength)}
</Info>
</Bulge>
</Strip>
<Cover
title="Length"
@ -132,10 +153,10 @@ const LengthIndicator: React.FC<IProps> = ({layoutP}) => {
/>
</>
)
}, [layoutP, stripRef, isDraggingD])
}, [layoutP, nodeRef, isDraggingD, popoverNode])
}
function useDragStrip(node: HTMLDivElement | null, props: IProps) {
function useDragBulge(node: HTMLDivElement | null, props: IProps) {
const propsRef = useRef(props)
propsRef.current = props
const isDragging = useMemo(() => new Box(false), [])

View file

@ -95,6 +95,16 @@ const FrameStampPositionProvider: React.FC<{
export const useFrameStampPosition = () => useContext(context)
/**
* This attribute is used so that when the cursor hovers over a keyframe,
* the framestamp snaps to the position of that keyframe.
*
* Elements that need this behavior must set a data attribute like so:
* <div data-theatre-lock-framestamp-to="120.55" />
* Setting this attribute to "hide" hides the stamp.
*/
export const attributeNameThatLocksFramestamp =
'data-theatre-lock-framestamp-to'
const pointerPositionInUnitSpace = (
layoutP: Pointer<SequenceEditorPanelLayout>,
): IDerivation<number> => {
@ -103,7 +113,25 @@ const pointerPositionInUnitSpace = (
const clippedSpaceToUnitSpace = val(layoutP.clippedSpace.toUnitSpace)
const leftPadding = val(layoutP.scaledSpace.leftPadding)
const {clientX, clientY} = val(mousePositionD)
const mousePos = val(mousePositionD)
if (!mousePos) return -1
for (const el of mousePos.composedPath()) {
if (!(el instanceof HTMLElement || el instanceof SVGElement)) break
if (el.hasAttribute(attributeNameThatLocksFramestamp)) {
const val = el.getAttribute(attributeNameThatLocksFramestamp)
if (typeof val !== 'string') continue
if (val === 'hide') return -1
const double = parseFloat(val)
if (isFinite(double) && double >= 0) return double
}
}
// if (mousePos.composedPath())
const {clientX, clientY} = mousePos
const {screenX: x, screenY: y, width: rightWidth, height} = rightDims

View file

@ -11,6 +11,7 @@ import styled from 'styled-components'
import type KeyframeEditor from './KeyframeEditor'
import type {Keyframe} from '@theatre/core/projects/store/types/SheetState_Historic'
import type {FrameStampPositionLock} from '@theatre/studio/panels/SequenceEditorPanel/FrameStampPositionProvider'
import {attributeNameThatLocksFramestamp} from '@theatre/studio/panels/SequenceEditorPanel/FrameStampPositionProvider'
import {useFrameStampPosition} from '@theatre/studio/panels/SequenceEditorPanel/FrameStampPositionProvider'
export const dotSize = 6
@ -60,6 +61,9 @@ const Dot: React.FC<IProps> = (props) => {
cx: `calc(var(--unitSpaceToScaledSpaceMultiplier) * ${cur.position} * 1px)`,
cy: `calc((var(--graphEditorVerticalSpace) - var(--graphEditorVerticalSpace) * ${cyInExtremumSpace}) * 1px)`,
}}
{...{
[attributeNameThatLocksFramestamp]: cur.position.toFixed(3),
}}
/>
<Circle
style={{

View file

@ -6,6 +6,7 @@ import React, {useCallback} from 'react'
import styled from 'styled-components'
import type {SequenceEditorPanelLayout} from '@theatre/studio/panels/SequenceEditorPanel/layout/layout'
import {VscTriangleUp} from 'react-icons/all'
import {attributeNameThatLocksFramestamp} from './FrameStampPositionProvider'
const Container = styled.button`
outline: none;
@ -64,6 +65,7 @@ const GraphEditorToggle: React.FC<{
onClick={toggle}
title={'Toggle graph editor'}
className={isOpen ? 'open' : ''}
{...{[attributeNameThatLocksFramestamp]: 'hide'}}
>
<VscTriangleUp />
</Container>

View file

@ -1,5 +1,4 @@
import type {SequenceEditorPanelLayout} from '@theatre/studio/panels/SequenceEditorPanel/layout/layout'
import mousePositionD from '@theatre/studio/utils/mousePositionD'
import {usePrism, useVal} from '@theatre/dataverse-react'
import type {Pointer} from '@theatre/dataverse'
import {val} from '@theatre/dataverse'
@ -8,7 +7,6 @@ import styled from 'styled-components'
import {stampsGridTheme} from '@theatre/studio/panels/SequenceEditorPanel/FrameGrid/StampsGrid'
import {zIndexes} from '@theatre/studio/panels/SequenceEditorPanel/SequenceEditorPanel'
import {topStripTheme} from './TopStrip'
import {inRange} from 'lodash-es'
import {useFrameStampPosition} from '@theatre/studio/panels/SequenceEditorPanel/FrameStampPositionProvider'
const Label = styled.div`
@ -77,33 +75,3 @@ const FrameStamp: React.FC<{
})
export default FrameStamp
/**
*
* @returns -1 if outside, otherwise, a positive number
*/
const usePointerPositionInUnitSpace = (
layoutP: Pointer<SequenceEditorPanelLayout>,
): number => {
return usePrism(() => {
const rightDims = val(layoutP.rightDims)
const clippedSpaceToUnitSpace = val(layoutP.clippedSpace.toUnitSpace)
const leftPadding = val(layoutP.scaledSpace.leftPadding)
const {clientX, clientY} = val(mousePositionD)
const {screenX: x, screenY: y, width: rightWidth, height} = rightDims
if (
inRange(clientX, x, x + rightWidth) &&
inRange(clientY, y, y + height)
) {
const posInRightDims = clientX - x
const posInUnitSpace = clippedSpaceToUnitSpace(posInRightDims)
return posInUnitSpace
} else {
return -1
}
}, [layoutP])
}

View file

@ -9,6 +9,7 @@ import {position} from 'polished'
import React, {useCallback, useMemo, useState} from 'react'
import styled from 'styled-components'
import {zIndexes} from '@theatre/studio/panels/SequenceEditorPanel/SequenceEditorPanel'
import {attributeNameThatLocksFramestamp} from '@theatre/studio/panels/SequenceEditorPanel/FrameStampPositionProvider'
const Container = styled.div`
--threadHeight: 6px;
@ -237,7 +238,10 @@ const HorizontalScrollbar: React.FC<{
}, [layoutP, relevantValuesD])
return (
<Container style={{bottom: bottom + 8 + 'px'}}>
<Container
style={{bottom: bottom + 8 + 'px'}}
{...{[attributeNameThatLocksFramestamp]: 'hide'}}
>
<TimeThread>
<DraggableArea
onDragStart={handles.onDragStart}

View file

@ -11,6 +11,7 @@ import clamp from 'lodash-es/clamp'
import React, {useMemo} from 'react'
import styled from 'styled-components'
import {zIndexes} from '@theatre/studio/panels/SequenceEditorPanel/SequenceEditorPanel'
import {attributeNameThatLocksFramestamp} from '@theatre/studio/panels/SequenceEditorPanel/FrameStampPositionProvider'
const Container = styled.div<{isVisible: boolean}>`
--thumbColor: #00e0ff;
@ -177,6 +178,7 @@ const Playhead: React.FC<{layoutP: Pointer<SequenceEditorPanelLayout>}> = ({
isVisible={isVisible}
style={{transform: `translate3d(${posInClippedSpace}px, 0, 0)`}}
className={isSeeking ? 'seeking' : ''}
{...{[attributeNameThatLocksFramestamp]: 'hide'}}
>
<Thumb ref={thumbRef as $IntentionalAny}>
<RoomToClick room={8} />

View file

@ -5,6 +5,7 @@ import styled from 'styled-components'
import type {SequenceEditorPanelLayout} from '@theatre/studio/panels/SequenceEditorPanel/layout/layout'
import StampsGrid from '@theatre/studio/panels/SequenceEditorPanel/FrameGrid/StampsGrid'
import PanelDragZone from '@theatre/studio/panels/BasePanel/PanelDragZone'
import {attributeNameThatLocksFramestamp} from '@theatre/studio/panels/SequenceEditorPanel/FrameStampPositionProvider'
export const topStripHeight = 20
@ -30,7 +31,7 @@ const TopStrip: React.FC<{layoutP: Pointer<SequenceEditorPanelLayout>}> = ({
}) => {
const width = useVal(layoutP.rightDims.width)
return (
<Container>
<Container {...{[attributeNameThatLocksFramestamp]: 'hide'}}>
<StampsGrid layoutP={layoutP} width={width} height={topStripHeight} />
</Container>
)

View file

@ -0,0 +1,105 @@
import getStudio from '@theatre/studio/getStudio'
import useBoundingClientRect from '@theatre/studio/uiComponents/useBoundingClientRect'
import transparentize from 'polished/lib/color/transparentize'
import React, {useLayoutEffect, useState} from 'react'
import {createPortal} from 'react-dom'
import useWindowSize from 'react-use/esm/useWindowSize'
import styled from 'styled-components'
const minWidth = 190
/**
* How far from the menu should the pointer travel to auto close the menu
*/
const defaultPointerDistanceThreshold = 200
const Container = styled.ul`
position: absolute;
min-width: ${minWidth}px;
z-index: 10000;
background: ${transparentize(0.2, '#111')};
color: white;
list-style-type: none;
padding: 2px 0;
margin: 0;
border-radius: 1px;
cursor: default;
pointer-events: all;
border-radius: 3px;
`
const Popover: React.FC<{
clickPoint: {clientX: number; clientY: number}
target: HTMLElement
onRequestClose: () => void
children: () => React.ReactNode
pointerDistanceThreshold?: number
}> = (props) => {
const pointerDistanceThreshold =
props.pointerDistanceThreshold ?? defaultPointerDistanceThreshold
const [container, setContainer] = useState<HTMLElement | null>(null)
const rect = useBoundingClientRect(container)
const windowSize = useWindowSize()
useLayoutEffect(() => {
if (!rect || !container) return
const preferredAnchorPoint = {
left: rect.width / 2,
top: 0,
}
const pos = {
left: props.clickPoint.clientX - preferredAnchorPoint.left,
top: props.clickPoint.clientY - preferredAnchorPoint.top,
}
if (pos.left < 0) {
pos.left = 0
} else if (pos.left + rect.width > windowSize.width) {
pos.left = windowSize.width - rect.width
}
if (pos.top < 0) {
pos.top = 0
} else if (pos.top + rect.height > windowSize.height) {
pos.top = windowSize.height - rect.height
}
container.style.left = pos.left + 'px'
container.style.top = pos.top + 'px'
const onMouseMove = (e: MouseEvent) => {
if (
e.clientX < pos.left - pointerDistanceThreshold ||
e.clientX > pos.left + rect.width + pointerDistanceThreshold ||
e.clientY < pos.top - pointerDistanceThreshold ||
e.clientY > pos.top + rect.height + pointerDistanceThreshold
) {
props.onRequestClose()
}
}
const onMouseDown = (e: MouseEvent) => {
if (!e.composedPath().includes(container)) {
props.onRequestClose()
}
}
window.addEventListener('mousemove', onMouseMove)
window.addEventListener('mousedown', onMouseDown, {capture: true})
return () => {
window.removeEventListener('mousemove', onMouseMove)
window.removeEventListener('mousedown', onMouseDown, {capture: true})
}
}, [rect, container, props.clickPoint, windowSize, props.onRequestClose])
return createPortal(
<Container ref={setContainer}>{props.children()}</Container>,
getStudio()!.ui.containerShadow,
)
}
export default Popover

View file

@ -0,0 +1,48 @@
import React, {useCallback, useState} from 'react'
import Popover from './Popover'
type OpenFn = (e: React.MouseEvent, target: HTMLElement) => void
type CloseFn = () => void
type State =
| {isOpen: false}
| {
isOpen: true
clickPoint: {
clientX: number
clientY: number
}
target: HTMLElement
}
export default function usePopover(
render: () => React.ReactNode,
): [node: React.ReactNode, open: OpenFn, close: CloseFn, isOpen: boolean] {
const [state, setState] = useState<State>({
isOpen: false,
})
const open = useCallback<OpenFn>((e, target) => {
setState({
isOpen: true,
clickPoint: {clientX: e.clientX, clientY: e.clientY},
target,
})
}, [])
const close = useCallback<CloseFn>(() => {
setState({isOpen: false})
}, [])
const node = state.isOpen ? (
<Popover
children={render}
clickPoint={state.clickPoint}
target={state.target}
onRequestClose={close}
/>
) : (
<></>
)
return [node, open, close, state.isOpen]
}

View file

@ -5,7 +5,10 @@ import React from 'react'
import {createPortal} from 'react-dom'
const ShowMousePosition: React.FC<{}> = (props) => {
const pos = usePrism(() => val(mousePositionD), [])
const pos = usePrism(
() => val(mousePositionD) ?? {clientX: 0, clientY: 0},
[],
)
return createPortal(
<>
<div

View file

@ -1,12 +1,12 @@
import {prism} from '@theatre/dataverse'
const mousePositionD = prism(() => {
const [pos, setPos] = prism.state('pos', {clientX: 0, clientY: 0})
const [pos, setPos] = prism.state<MouseEvent | null>('pos', null)
prism.effect(
'setupListeners',
() => {
const handleMouseMove = (e: MouseEvent) => {
setPos({clientX: e.clientX, clientY: e.clientY})
setPos(e)
}
document.addEventListener('mousemove', handleMouseMove)