diff --git a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/Dot.tsx b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/Dot.tsx index 88b6725..6811b5b 100644 --- a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/Dot.tsx +++ b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/Dot.tsx @@ -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 = (props) => { <> {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 | 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) diff --git a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/LengthIndicator.tsx b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/LengthIndicator.tsx index 9ccf1be..5a8c9d3 100644 --- a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/LengthIndicator.tsx +++ b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/LengthIndicator.tsx @@ -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 = ({layoutP}) => { - const [stripRef, stripNode] = useRefAndState(null) - const [isDraggingD] = useDragStrip(stripNode, {layoutP}) + const [nodeRef, node] = useRefAndState(null) + const [isDraggingD] = useDragBulge(node, {layoutP}) + const [popoverNode, openPopover, _, isPopoverOpen] = usePopover(() => { + return
poppio
+ }) return usePrism(() => { const sheet = val(layoutP.sheet) @@ -101,19 +116,25 @@ const LengthIndicator: React.FC = ({layoutP}) => { return ( <> + {popoverNode} - - sequence.length:{' '} + { + openPopover(e, node!) + }} + {...{[attributeNameThatLocksFramestamp]: 'hide'}} + > {sequence.positionFormatter.formatForPlayhead(sequenceLength)} - + = ({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), []) diff --git a/theatre/studio/src/panels/SequenceEditorPanel/FrameStampPositionProvider.tsx b/theatre/studio/src/panels/SequenceEditorPanel/FrameStampPositionProvider.tsx index b666584..6c9bcf8 100644 --- a/theatre/studio/src/panels/SequenceEditorPanel/FrameStampPositionProvider.tsx +++ b/theatre/studio/src/panels/SequenceEditorPanel/FrameStampPositionProvider.tsx @@ -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: + *
+ * Setting this attribute to "hide" hides the stamp. + */ +export const attributeNameThatLocksFramestamp = + 'data-theatre-lock-framestamp-to' const pointerPositionInUnitSpace = ( layoutP: Pointer, ): IDerivation => { @@ -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 diff --git a/theatre/studio/src/panels/SequenceEditorPanel/GraphEditor/BasicKeyframedTrack/KeyframeEditor/Dot.tsx b/theatre/studio/src/panels/SequenceEditorPanel/GraphEditor/BasicKeyframedTrack/KeyframeEditor/Dot.tsx index 8dff254..58b4526 100644 --- a/theatre/studio/src/panels/SequenceEditorPanel/GraphEditor/BasicKeyframedTrack/KeyframeEditor/Dot.tsx +++ b/theatre/studio/src/panels/SequenceEditorPanel/GraphEditor/BasicKeyframedTrack/KeyframeEditor/Dot.tsx @@ -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 = (props) => { cx: `calc(var(--unitSpaceToScaledSpaceMultiplier) * ${cur.position} * 1px)`, cy: `calc((var(--graphEditorVerticalSpace) - var(--graphEditorVerticalSpace) * ${cyInExtremumSpace}) * 1px)`, }} + {...{ + [attributeNameThatLocksFramestamp]: cur.position.toFixed(3), + }} /> diff --git a/theatre/studio/src/panels/SequenceEditorPanel/RightOverlay/FrameStamp.tsx b/theatre/studio/src/panels/SequenceEditorPanel/RightOverlay/FrameStamp.tsx index e98f096..e2dd25f 100644 --- a/theatre/studio/src/panels/SequenceEditorPanel/RightOverlay/FrameStamp.tsx +++ b/theatre/studio/src/panels/SequenceEditorPanel/RightOverlay/FrameStamp.tsx @@ -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, -): 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]) -} diff --git a/theatre/studio/src/panels/SequenceEditorPanel/RightOverlay/HorizontalScrollbar.tsx b/theatre/studio/src/panels/SequenceEditorPanel/RightOverlay/HorizontalScrollbar.tsx index 802c214..a72e8a2 100644 --- a/theatre/studio/src/panels/SequenceEditorPanel/RightOverlay/HorizontalScrollbar.tsx +++ b/theatre/studio/src/panels/SequenceEditorPanel/RightOverlay/HorizontalScrollbar.tsx @@ -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 ( - + ` --thumbColor: #00e0ff; @@ -177,6 +178,7 @@ const Playhead: React.FC<{layoutP: Pointer}> = ({ isVisible={isVisible} style={{transform: `translate3d(${posInClippedSpace}px, 0, 0)`}} className={isSeeking ? 'seeking' : ''} + {...{[attributeNameThatLocksFramestamp]: 'hide'}} > diff --git a/theatre/studio/src/panels/SequenceEditorPanel/RightOverlay/TopStrip.tsx b/theatre/studio/src/panels/SequenceEditorPanel/RightOverlay/TopStrip.tsx index cbbac40..0c3afe2 100644 --- a/theatre/studio/src/panels/SequenceEditorPanel/RightOverlay/TopStrip.tsx +++ b/theatre/studio/src/panels/SequenceEditorPanel/RightOverlay/TopStrip.tsx @@ -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}> = ({ }) => { const width = useVal(layoutP.rightDims.width) return ( - + ) diff --git a/theatre/studio/src/uiComponents/Popover/Popover.tsx b/theatre/studio/src/uiComponents/Popover/Popover.tsx new file mode 100644 index 0000000..b6e6e51 --- /dev/null +++ b/theatre/studio/src/uiComponents/Popover/Popover.tsx @@ -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(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( + {props.children()}, + getStudio()!.ui.containerShadow, + ) +} + +export default Popover diff --git a/theatre/studio/src/uiComponents/Popover/usePopover.tsx b/theatre/studio/src/uiComponents/Popover/usePopover.tsx new file mode 100644 index 0000000..e68cfa6 --- /dev/null +++ b/theatre/studio/src/uiComponents/Popover/usePopover.tsx @@ -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({ + isOpen: false, + }) + + const open = useCallback((e, target) => { + setState({ + isOpen: true, + clickPoint: {clientX: e.clientX, clientY: e.clientY}, + target, + }) + }, []) + + const close = useCallback(() => { + setState({isOpen: false}) + }, []) + + const node = state.isOpen ? ( + + ) : ( + <> + ) + + return [node, open, close, state.isOpen] +} diff --git a/theatre/studio/src/uiComponents/ShowMousePosition.tsx b/theatre/studio/src/uiComponents/ShowMousePosition.tsx index c498e24..032b6d1 100644 --- a/theatre/studio/src/uiComponents/ShowMousePosition.tsx +++ b/theatre/studio/src/uiComponents/ShowMousePosition.tsx @@ -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( <>
{ - const [pos, setPos] = prism.state('pos', {clientX: 0, clientY: 0}) + const [pos, setPos] = prism.state('pos', null) prism.effect( 'setupListeners', () => { const handleMouseMove = (e: MouseEvent) => { - setPos({clientX: e.clientX, clientY: e.clientY}) + setPos(e) } document.addEventListener('mousemove', handleMouseMove)