From e8c8168f0b63aa440509476e7b39500395d12318 Mon Sep 17 00:00:00 2001 From: Cole Lawrence Date: Wed, 15 Jun 2022 07:36:57 -0400 Subject: [PATCH] UX: Add "PresenceFlag" item indicators (usePresence) (#184) * feat/dev: Add usePresence and enable for keyframes & keyframe cursors * Enable hovered styles for AggregateKeyframeDot * Enable hovered styles for graph editor keyframes --- theatre/shared/src/utils/ids.ts | 35 +++ .../src/panels/DetailPanel/DetailPanel.tsx | 6 + .../DopeSheet/Left/PropWithChildrenRow.tsx | 5 +- .../DopeSheet/Left/SheetObjectRow.tsx | 5 +- .../AggregateKeyframeDot.tsx | 21 ++ .../AggregateKeyframeVisualDot.tsx | 11 +- .../useAggregateKeyframeEditorUtils.tsx | 21 ++ .../BasicKeyframedTrack.tsx | 11 +- .../KeyframeEditor/BasicKeyframeConnector.tsx | 16 +- .../CurveEditorPopover/CurveSegmentEditor.tsx | 1 - .../KeyframeEditor/SingleKeyframeDot.tsx | 21 +- .../KeyframeEditor/SingleKeyframeEditor.tsx | 18 +- .../Right/collectAggregateKeyframes.tsx | 17 +- .../setCollapsedSheetObjectOrCompoundProp.tsx | 31 +-- .../BasicKeyframedTrack.tsx | 6 + .../GraphEditorDotNonScalar.tsx | 9 +- .../KeyframeEditor/GraphEditorDotScalar.tsx | 7 +- .../KeyframeEditor/KeyframeEditor.tsx | 6 +- .../SequenceEditorPanel.tsx | 19 +- .../panels/SequenceEditorPanel/layout/tree.ts | 17 +- .../propEditors/NextPrevKeyframeCursors.tsx | 78 +++++-- .../propEditors/getNearbyKeyframesOfTrack.tsx | 68 ++++-- .../useEditingToolsForCompoundProp.tsx | 96 +++++--- .../useEditingToolsForSimpleProp.tsx | 54 +++-- .../studio/src/uiComponents/usePresence.tsx | 211 ++++++++++++++++++ .../src/utils/selectClosestHTMLAncestor.ts | 14 ++ 26 files changed, 666 insertions(+), 138 deletions(-) create mode 100644 theatre/studio/src/uiComponents/usePresence.tsx create mode 100644 theatre/studio/src/utils/selectClosestHTMLAncestor.ts diff --git a/theatre/shared/src/utils/ids.ts b/theatre/shared/src/utils/ids.ts index cdd09e4..194a99d 100644 --- a/theatre/shared/src/utils/ids.ts +++ b/theatre/shared/src/utils/ids.ts @@ -68,6 +68,9 @@ export function generateSequenceMarkerId(): SequenceMarkerId { * versioning happens where something needs to */ export const createStudioSheetItemKey = { + forSheet(): StudioSheetItemKey { + return 'sheet' as StudioSheetItemKey + }, forSheetObject(obj: SheetObject): StudioSheetItemKey { return stableValueHash({ o: obj.address.objectKey, @@ -82,4 +85,36 @@ export const createStudioSheetItemKey = { p: pathToProp, }) as StudioSheetItemKey }, + forTrackKeyframe( + obj: SheetObject, + trackId: SequenceTrackId, + keyframeId: KeyframeId, + ): StudioSheetItemKey { + return stableValueHash({ + o: obj.address.objectKey, + t: trackId, + k: keyframeId, + }) as StudioSheetItemKey + }, + forSheetObjectAggregateKeyframe( + obj: SheetObject, + position: number, + ): StudioSheetItemKey { + return createStudioSheetItemKey.forCompoundPropAggregateKeyframe( + obj, + [], + position, + ) + }, + forCompoundPropAggregateKeyframe( + obj: SheetObject, + pathToProp: PathToProp, + position: number, + ): StudioSheetItemKey { + return stableValueHash({ + o: obj.address.objectKey, + p: pathToProp, + pos: position, + }) as StudioSheetItemKey + }, } diff --git a/theatre/studio/src/panels/DetailPanel/DetailPanel.tsx b/theatre/studio/src/panels/DetailPanel/DetailPanel.tsx index 2e4c60d..ab78fc1 100644 --- a/theatre/studio/src/panels/DetailPanel/DetailPanel.tsx +++ b/theatre/studio/src/panels/DetailPanel/DetailPanel.tsx @@ -5,6 +5,7 @@ import React, { useContext, useEffect, useLayoutEffect, + useState, } from 'react' import styled from 'styled-components' import {isProject, isSheetObject} from '@theatre/shared/instanceTypes' @@ -21,6 +22,7 @@ import useHotspot from '@theatre/studio/uiComponents/useHotspot' import {Box, prism, val} from '@theatre/dataverse' import EmptyState from './EmptyState' import useLockSet from '@theatre/studio/uiComponents/useLockSet' +import {usePresenceListenersOnRootElement} from '@theatre/studio/uiComponents/usePresence' const headerHeight = `32px` @@ -106,6 +108,9 @@ const DetailPanel: React.FC<{}> = (props) => { const showDetailsPanel = pin || hotspotActive || isContextMenuShown + const [containerElt, setContainerElt] = useState(null) + usePresenceListenersOnRootElement(containerElt) + return usePrism(() => { const selection = getOutlineSelection() @@ -115,6 +120,7 @@ const DetailPanel: React.FC<{}> = (props) => { { isDetailPanelHoveredB.set(true) }} diff --git a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Left/PropWithChildrenRow.tsx b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Left/PropWithChildrenRow.tsx index 390e699..9c85ff6 100644 --- a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Left/PropWithChildrenRow.tsx +++ b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Left/PropWithChildrenRow.tsx @@ -31,7 +31,10 @@ const PropWithChildrenRow: React.VFC<{ label={leaf.pathToProp[leaf.pathToProp.length - 1]} isCollapsed={leaf.isCollapsed} toggleCollapsed={() => - setCollapsedSheetObjectOrCompoundProp(!leaf.isCollapsed, leaf) + setCollapsedSheetObjectOrCompoundProp(!leaf.isCollapsed, { + sheetAddress: leaf.sheetObject.address, + sheetItemKey: leaf.sheetItemKey, + }) } > {leaf.children.map((propLeaf) => decideRowByPropType(propLeaf))} diff --git a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Left/SheetObjectRow.tsx b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Left/SheetObjectRow.tsx index f1518bd..a5075b1 100644 --- a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Left/SheetObjectRow.tsx +++ b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Left/SheetObjectRow.tsx @@ -22,7 +22,10 @@ const LeftSheetObjectRow: React.VFC<{ }) }} toggleCollapsed={() => - setCollapsedSheetObjectOrCompoundProp(!leaf.isCollapsed, leaf) + setCollapsedSheetObjectOrCompoundProp(!leaf.isCollapsed, { + sheetAddress: leaf.sheetObject.address, + sheetItemKey: leaf.sheetItemKey, + }) } > {leaf.children.map((leaf) => decideRowByPropType(leaf))} diff --git a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/AggregatedKeyframeTrack/AggregateKeyframeEditor/AggregateKeyframeDot.tsx b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/AggregatedKeyframeTrack/AggregateKeyframeEditor/AggregateKeyframeDot.tsx index 8c5cf78..b075654 100644 --- a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/AggregatedKeyframeTrack/AggregateKeyframeEditor/AggregateKeyframeDot.tsx +++ b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/AggregatedKeyframeTrack/AggregateKeyframeEditor/AggregateKeyframeDot.tsx @@ -1,5 +1,8 @@ import React from 'react' import useRefAndState from '@theatre/studio/utils/useRefAndState' +import usePresence, { + PresenceFlag, +} from '@theatre/studio/uiComponents/usePresence' import {useLogger} from '@theatre/studio/uiComponents/useLogger' import useContextMenu from '@theatre/studio/uiComponents/simpleContextMenu/useContextMenu' import type {IAggregateKeyframeEditorProps} from './AggregateKeyframeEditor' @@ -26,6 +29,22 @@ export function AggregateKeyframeDot( const logger = useLogger('AggregateKeyframeDot') const {cur} = props.utils + const presence = usePresence(props.utils.itemKey) + presence.useRelations( + () => + cur.keyframes.map((kf) => ({ + affects: kf.itemKey, + flag: PresenceFlag.Primary, + })), + [ + // Hmm: Is this a valid fix for the changing size of the useEffect's dependency array? + // also: does it work properly with selections? + cur.keyframes + .map((keyframeWithTrack) => keyframeWithTrack.track.id) + .join('-'), + ], + ) + const [ref, node] = useRefAndState(null) const [contextMenu] = useAggregateKeyframeContextMenu(props, logger, node) @@ -34,11 +53,13 @@ export function AggregateKeyframeDot( <> diff --git a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/AggregatedKeyframeTrack/AggregateKeyframeEditor/AggregateKeyframeVisualDot.tsx b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/AggregatedKeyframeTrack/AggregateKeyframeEditor/AggregateKeyframeVisualDot.tsx index d49f543..1af3b40 100644 --- a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/AggregatedKeyframeTrack/AggregateKeyframeEditor/AggregateKeyframeVisualDot.tsx +++ b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/AggregatedKeyframeTrack/AggregateKeyframeEditor/AggregateKeyframeVisualDot.tsx @@ -1,5 +1,6 @@ import React from 'react' import {AggregateKeyframePositionIsSelected} from '@theatre/studio/panels/SequenceEditorPanel/DopeSheet/Right/AggregatedKeyframeTrack/AggregatedKeyframeTrack' +import {PresenceFlag} from '@theatre/studio/uiComponents/usePresence' import styled from 'styled-components' import {absoluteDims} from '@theatre/studio/utils/absoluteDims' import {pointerEventsAutoInNormalMode} from '@theatre/studio/css' @@ -34,11 +35,13 @@ export const HitZone = styled.div` ` export function AggregateKeyframeVisualDot(props: { + flag: PresenceFlag | undefined isSelected: AggregateKeyframePositionIsSelected | undefined isAllHere: boolean }) { const theme: IDotThemeValues = { isSelected: props.isSelected, + flag: props.flag, } return ( @@ -53,6 +56,7 @@ export function AggregateKeyframeVisualDot(props: { } type IDotThemeValues = { isSelected: AggregateKeyframePositionIsSelected | undefined + flag: PresenceFlag | undefined } const SELECTED_COLOR = '#F2C95C' const DEFAULT_PRIMARY_COLOR = '#40AAA4' @@ -95,6 +99,8 @@ const AggregateDotAllHereSvg = (theme: IDotThemeValues) => ( height="6" transform="rotate(-45 3.75732 6.01953)" fill={selectionColorAll(theme)} + stroke={theme.flag === PresenceFlag.Primary ? 'white' : undefined} + strokeWidth={theme.flag === PresenceFlag.Primary ? '2px' : undefined} /> ) @@ -114,7 +120,10 @@ const AggregateDotSomeHereSvg = (theme: IDotThemeValues) => ( height="5" transform="rotate(-45 4.46443 8)" fill="#23262B" - stroke={selectionColorAll(theme)} + stroke={ + theme.flag === PresenceFlag.Primary ? 'white' : selectionColorAll(theme) + } + strokeWidth={theme.flag === PresenceFlag.Primary ? '2px' : undefined} /> ) diff --git a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/AggregatedKeyframeTrack/AggregateKeyframeEditor/useAggregateKeyframeEditorUtils.tsx b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/AggregatedKeyframeTrack/AggregateKeyframeEditor/useAggregateKeyframeEditorUtils.tsx index dca46ea..56a57b8 100644 --- a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/AggregatedKeyframeTrack/AggregateKeyframeEditor/useAggregateKeyframeEditorUtils.tsx +++ b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/AggregatedKeyframeTrack/AggregateKeyframeEditor/useAggregateKeyframeEditorUtils.tsx @@ -1,4 +1,5 @@ import {prism} from '@theatre/dataverse' +import {createStudioSheetItemKey} from '@theatre/shared/utils/ids' import {AggregateKeyframePositionIsSelected} from '@theatre/studio/panels/SequenceEditorPanel/DopeSheet/Right/AggregatedKeyframeTrack/AggregatedKeyframeTrack' import {isConnectionEditingInCurvePopover} from '@theatre/studio/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/CurveEditorPopover/CurveEditorPopover' import {usePrism} from '@theatre/react' @@ -93,7 +94,27 @@ export function getAggregateKeyframeEditorUtilsPrismFn( (con) => isConnectionEditingInCurvePopover(con), ) + const itemKey = prism.memo( + 'itemKey', + () => { + if (props.viewModel.type === 'sheetObject') { + return createStudioSheetItemKey.forSheetObjectAggregateKeyframe( + props.viewModel.sheetObject, + cur.position, + ) + } else { + return createStudioSheetItemKey.forCompoundPropAggregateKeyframe( + props.viewModel.sheetObject, + props.viewModel.pathToProp, + cur.position, + ) + } + }, + [props.viewModel.sheetObject, cur.position], + ) + return { + itemKey, cur, connected, isAggregateEditingInCurvePopover, diff --git a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/BasicKeyframedTrack.tsx b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/BasicKeyframedTrack.tsx index 7bf6fc7..16b37ea 100644 --- a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/BasicKeyframedTrack.tsx +++ b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/BasicKeyframedTrack.tsx @@ -17,6 +17,7 @@ import type {KeyframeWithPathToPropFromCommonRoot} from '@theatre/studio/store/t import KeyframeSnapTarget, { snapPositionsStateD, } from '@theatre/studio/panels/SequenceEditorPanel/DopeSheet/Right/KeyframeSnapTarget' +import {createStudioSheetItemKey} from '@theatre/shared/utils/ids' const Container = styled.div` position: relative; @@ -82,9 +83,17 @@ const BasicKeyframedTrack: React.VFC = React.memo( /> )} = ( props, ) => { - const {index, trackData} = props - const cur = trackData.keyframes[index] - const next = trackData.keyframes[index + 1] + const {index, track} = props + const cur = track.data.keyframes[index] + const next = track.data.keyframes[index + 1] const [nodeRef, node] = useRefAndState(null) @@ -99,7 +99,11 @@ export default BasicKeyframeConnector const SingleCurveEditorPopover: React.FC< IBasicKeyframeConnectorProps & {closePopover: (reason: string) => void} > = React.forwardRef((props, ref) => { - const {index, trackData, selection} = props + const { + index, + track: {data: trackData}, + selection, + } = props const cur = trackData.keyframes[index] const next = trackData.keyframes[index + 1] @@ -161,7 +165,7 @@ function useDragKeyframe( ...sheetObject.address, domNode: node!, positionAtStartOfDrag: - props.trackData.keyframes[props.index].position, + props.track.data.keyframes[props.index].position, }) .onDragStart(event) } @@ -187,7 +191,7 @@ function useDragKeyframe( trackId: propsAtStartOfDrag.leaf.trackId, keyframeIds: [ propsAtStartOfDrag.keyframe.id, - propsAtStartOfDrag.trackData.keyframes[ + propsAtStartOfDrag.track.data.keyframes[ propsAtStartOfDrag.index + 1 ].id, ], diff --git a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/CurveEditorPopover/CurveSegmentEditor.tsx b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/CurveEditorPopover/CurveSegmentEditor.tsx index f250002..804f1b3 100644 --- a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/CurveEditorPopover/CurveSegmentEditor.tsx +++ b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/CurveEditorPopover/CurveSegmentEditor.tsx @@ -69,7 +69,6 @@ type ICurveSegmentEditorProps = { const CurveSegmentEditor: React.VFC = (props) => { const { - curveConnection, curveConnection: {left, right}, backgroundConnections, } = props diff --git a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/SingleKeyframeDot.tsx b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/SingleKeyframeDot.tsx index 2f84364..697b2dc 100644 --- a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/SingleKeyframeDot.tsx +++ b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/SingleKeyframeDot.tsx @@ -24,6 +24,9 @@ import { snapToSome, } from '@theatre/studio/panels/SequenceEditorPanel/DopeSheet/Right/KeyframeSnapTarget' import {useSingleKeyframeInlineEditorPopover} from './useSingleKeyframeInlineEditorPopover' +import usePresence, { + PresenceFlag, +} from '@theatre/studio/uiComponents/usePresence' export const DOT_SIZE_PX = 6 const DOT_HOVER_SIZE_PX = DOT_SIZE_PX + 2 @@ -50,7 +53,11 @@ const selectBacgroundForDiamond = ({ } } -type IDiamond = {isSelected: boolean; isInlineEditorPopoverOpen: boolean} +type IDiamond = { + isSelected: boolean + isInlineEditorPopoverOpen: boolean + flag: PresenceFlag | undefined +} /** The keyframe diamond ◆ */ const Diamond = styled.div` @@ -60,6 +67,9 @@ const Diamond = styled.div` background: ${(props) => selectBacgroundForDiamond(props)}; transform: rotateZ(45deg); + ${(props) => + props.flag === PresenceFlag.Primary ? 'outline: 2px solid white;' : ''}; + z-index: 1; pointer-events: none; ` @@ -86,7 +96,8 @@ type ISingleKeyframeDotProps = ISingleKeyframeEditorProps /** The ◆ you can grab onto in "keyframe editor" (aka "dope sheet" in other programs) */ const SingleKeyframeDot: React.VFC = (props) => { - const logger = useLogger('SingleKeyframeDot') + const logger = useLogger('SingleKeyframeDot', props.keyframe.id) + const presence = usePresence(props.itemKey) const [ref, node] = useRefAndState(null) const [contextMenu] = useSingleKeyframeContextMenu(node, logger, props) @@ -110,10 +121,12 @@ const SingleKeyframeDot: React.VFC = (props) => { {inlineEditorPopover} {contextMenu} @@ -242,7 +255,7 @@ function useDragForSingleKeyframeDot( ...sheetObject.address, domNode: node!, positionAtStartOfDrag: - props.trackData.keyframes[props.index].position, + props.track.data.keyframes[props.index].position, }) .onDragStart(event) @@ -272,7 +285,7 @@ function useDragForSingleKeyframeDot( return { onDrag(dx, dy, event) { const original = - propsAtStartOfDrag.trackData.keyframes[propsAtStartOfDrag.index] + propsAtStartOfDrag.track.data.keyframes[propsAtStartOfDrag.index] const newPosition = Math.max( // check if our event hoversover a [data-pos] element DopeSnap.checkIfMouseEventSnapToPos(event, { diff --git a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/SingleKeyframeEditor.tsx b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/SingleKeyframeEditor.tsx index 94678ba..48ecb04 100644 --- a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/SingleKeyframeEditor.tsx +++ b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/SingleKeyframeEditor.tsx @@ -1,7 +1,4 @@ -import type { - Keyframe, - TrackData, -} from '@theatre/core/projects/store/types/SheetState_Historic' +import type {Keyframe} from '@theatre/core/projects/store/types/SheetState_Historic' import type { DopeSheetSelection, SequenceEditorPanelLayout, @@ -13,6 +10,8 @@ import React from 'react' import styled from 'styled-components' import SingleKeyframeConnector from './BasicKeyframeConnector' import SingleKeyframeDot from './SingleKeyframeDot' +import type {TrackWithId} from '@theatre/studio/panels/SequenceEditorPanel/DopeSheet/Right/collectAggregateKeyframes' +import type {StudioSheetItemKey} from '@theatre/shared/utils/ids' const SingleKeyframeEditorContainer = styled.div` position: absolute; @@ -23,14 +22,19 @@ const noConnector = <> export type ISingleKeyframeEditorProps = { index: number keyframe: Keyframe - trackData: TrackData + track: TrackWithId + itemKey: StudioSheetItemKey layoutP: Pointer leaf: SequenceEditorTree_PrimitiveProp selection: undefined | DopeSheetSelection } const SingleKeyframeEditor: React.VFC = (props) => { - const {index, trackData} = props + const { + index, + keyframe, + track: {data: trackData}, + } = props const cur = trackData.keyframes[index] const next = trackData.keyframes[index + 1] @@ -47,7 +51,7 @@ const SingleKeyframeEditor: React.VFC = (props) => { }px))`, }} > - + {connected ? : noConnector} ) diff --git a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/collectAggregateKeyframes.tsx b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/collectAggregateKeyframes.tsx index a9e1af5..333b242 100644 --- a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/collectAggregateKeyframes.tsx +++ b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/collectAggregateKeyframes.tsx @@ -4,7 +4,11 @@ import type { SequenceEditorTree_PropWithChildren, SequenceEditorTree_SheetObject, } from '@theatre/studio/panels/SequenceEditorPanel/layout/tree' -import type {SequenceTrackId} from '@theatre/shared/utils/ids' +import type { + SequenceTrackId, + StudioSheetItemKey, +} from '@theatre/shared/utils/ids' +import {createStudioSheetItemKey} from '@theatre/shared/utils/ids' import type { Keyframe, TrackData, @@ -31,6 +35,7 @@ export type TrackWithId = { export type KeyframeWithTrack = { kf: Keyframe track: TrackWithId + itemKey: StudioSheetItemKey } /** @@ -120,7 +125,15 @@ export function collectAggregateKeyframesInPrism( existing = [] byPosition.set(kf.position, existing) } - existing.push({kf, track}) + existing.push({ + kf, + track, + itemKey: createStudioSheetItemKey.forTrackKeyframe( + sheetObject, + track.id, + kf.id, + ), + }) } } diff --git a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/setCollapsedSheetObjectOrCompoundProp.tsx b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/setCollapsedSheetObjectOrCompoundProp.tsx index c0aeea6..bf01ec4 100644 --- a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/setCollapsedSheetObjectOrCompoundProp.tsx +++ b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/setCollapsedSheetObjectOrCompoundProp.tsx @@ -1,33 +1,22 @@ import type {StudioSheetItemKey} from '@theatre/shared/utils/ids' -import {createStudioSheetItemKey} from '@theatre/shared/utils/ids' import getStudio from '@theatre/studio/getStudio' -import type {PathToProp} from '@theatre/shared/utils/addresses' -import type SheetObject from '@theatre/core/sheetObjects/SheetObject' +import type { + SheetAddress, + WithoutSheetInstance, +} from '@theatre/shared/utils/addresses' export function setCollapsedSheetObjectOrCompoundProp( isCollapsed: boolean, - toCollapse: - | { - sheetObject: SheetObject - } - | { - sheetObject: SheetObject - pathToProp: PathToProp - }, + toCollapse: { + sheetAddress: WithoutSheetInstance + sheetItemKey: StudioSheetItemKey + }, ) { - const itemKey: StudioSheetItemKey = - 'pathToProp' in toCollapse - ? createStudioSheetItemKey.forSheetObjectProp( - toCollapse.sheetObject, - toCollapse.pathToProp, - ) - : createStudioSheetItemKey.forSheetObject(toCollapse.sheetObject) - getStudio().transaction(({stateEditors}) => { stateEditors.studio.ahistoric.projects.stateByProjectId.stateBySheetId.sequence.sequenceEditorCollapsableItems.set( { - ...toCollapse.sheetObject.address, - studioSheetItemKey: itemKey, + ...toCollapse.sheetAddress, + studioSheetItemKey: toCollapse.sheetItemKey, isCollapsed, }, ) diff --git a/theatre/studio/src/panels/SequenceEditorPanel/GraphEditor/BasicKeyframedTrack/BasicKeyframedTrack.tsx b/theatre/studio/src/panels/SequenceEditorPanel/GraphEditor/BasicKeyframedTrack/BasicKeyframedTrack.tsx index aded180..d1789de 100644 --- a/theatre/studio/src/panels/SequenceEditorPanel/GraphEditor/BasicKeyframedTrack/BasicKeyframedTrack.tsx +++ b/theatre/studio/src/panels/SequenceEditorPanel/GraphEditor/BasicKeyframedTrack/BasicKeyframedTrack.tsx @@ -5,6 +5,7 @@ import type { import type SheetObject from '@theatre/core/sheetObjects/SheetObject' import type {PathToProp} from '@theatre/shared/utils/addresses' import type {SequenceTrackId} from '@theatre/shared/utils/ids' +import {createStudioSheetItemKey} from '@theatre/shared/utils/ids' import type {$IntentionalAny, VoidFn} from '@theatre/shared/utils/types' import type {Pointer} from '@theatre/dataverse' import React, {useMemo, useRef, useState} from 'react' @@ -96,6 +97,11 @@ const BasicKeyframedTrack: React.VFC<{ [0] & {which: 'left' | 'right'} const GraphEditorDotNonScalar: React.VFC = (props) => { const [ref, node] = useRefAndState(null) - const {index, trackData} = props + const {index, trackData, itemKey} = props const cur = trackData.keyframes[index] const [contextMenu] = useKeyframeContextMenu(node, props) + const presence = usePresence(itemKey) + const curValue = props.which === 'left' ? 0 : 1 const [inlineEditorPopover, openEditor, _, _isInlineEditorPopoverOpen] = @@ -93,6 +98,7 @@ const GraphEditorDotNonScalar: React.VFC = (props) => { cx: `calc(var(--unitSpaceToScaledSpaceMultiplier) * ${cur.position} * 1px)`, cy: `calc((var(--graphEditorVerticalSpace) - var(--graphEditorVerticalSpace) * ${cyInExtremumSpace}) * 1px)`, }} + {...presence.attrs} {...includeLockFrameStampAttrs(cur.position)} {...DopeSnap.includePositionSnapAttrs(cur.position)} className={isDragging ? 'beingDragged' : ''} @@ -102,6 +108,7 @@ const GraphEditorDotNonScalar: React.VFC = (props) => { // @ts-ignore cx: `calc(var(--unitSpaceToScaledSpaceMultiplier) * ${cur.position} * 1px)`, cy: `calc((var(--graphEditorVerticalSpace) - var(--graphEditorVerticalSpace) * ${cyInExtremumSpace}) * 1px)`, + fill: presence.flag === PresenceFlag.Primary ? 'white' : undefined, }} /> {inlineEditorPopover} diff --git a/theatre/studio/src/panels/SequenceEditorPanel/GraphEditor/BasicKeyframedTrack/KeyframeEditor/GraphEditorDotScalar.tsx b/theatre/studio/src/panels/SequenceEditorPanel/GraphEditor/BasicKeyframedTrack/KeyframeEditor/GraphEditorDotScalar.tsx index c23fb52..27005e9 100644 --- a/theatre/studio/src/panels/SequenceEditorPanel/GraphEditor/BasicKeyframedTrack/KeyframeEditor/GraphEditorDotScalar.tsx +++ b/theatre/studio/src/panels/SequenceEditorPanel/GraphEditor/BasicKeyframedTrack/KeyframeEditor/GraphEditorDotScalar.tsx @@ -17,6 +17,9 @@ import { } from '@theatre/studio/uiComponents/PointerEventsHandler' import DopeSnap from '@theatre/studio/panels/SequenceEditorPanel/RightOverlay/DopeSnap' import {useSingleKeyframeInlineEditorPopover} from '@theatre/studio/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/useSingleKeyframeInlineEditorPopover' +import usePresence, { + PresenceFlag, +} from '@theatre/studio/uiComponents/usePresence' export const dotSize = 6 @@ -60,9 +63,9 @@ const GraphEditorDotScalar: React.VFC = (props) => { const {index, trackData} = props const cur = trackData.keyframes[index] - const next = trackData.keyframes[index + 1] const [contextMenu] = useKeyframeContextMenu(node, props) + const presence = usePresence(props.itemKey) const curValue = cur.value as number @@ -95,6 +98,7 @@ const GraphEditorDotScalar: React.VFC = (props) => { }} {...includeLockFrameStampAttrs(cur.position)} {...DopeSnap.includePositionSnapAttrs(cur.position)} + {...presence.attrs} className={isDragging ? 'beingDragged' : ''} /> = (props) => { // @ts-ignore cx: `calc(var(--unitSpaceToScaledSpaceMultiplier) * ${cur.position} * 1px)`, cy: `calc((var(--graphEditorVerticalSpace) - var(--graphEditorVerticalSpace) * ${cyInExtremumSpace}) * 1px)`, + fill: presence.flag === PresenceFlag.Primary ? 'white' : undefined, }} /> {inlineEditorPopover} diff --git a/theatre/studio/src/panels/SequenceEditorPanel/GraphEditor/BasicKeyframedTrack/KeyframeEditor/KeyframeEditor.tsx b/theatre/studio/src/panels/SequenceEditorPanel/GraphEditor/BasicKeyframedTrack/KeyframeEditor/KeyframeEditor.tsx index 8d19183..121bf0c 100644 --- a/theatre/studio/src/panels/SequenceEditorPanel/GraphEditor/BasicKeyframedTrack/KeyframeEditor/KeyframeEditor.tsx +++ b/theatre/studio/src/panels/SequenceEditorPanel/GraphEditor/BasicKeyframedTrack/KeyframeEditor/KeyframeEditor.tsx @@ -4,7 +4,10 @@ import type { } from '@theatre/core/projects/store/types/SheetState_Historic' import type SheetObject from '@theatre/core/sheetObjects/SheetObject' import type {SequenceEditorPanelLayout} from '@theatre/studio/panels/SequenceEditorPanel/layout/layout' -import type {SequenceTrackId} from '@theatre/shared/utils/ids' +import type { + SequenceTrackId, + StudioSheetItemKey, +} from '@theatre/shared/utils/ids' import type {Pointer} from '@theatre/dataverse' import React from 'react' import styled from 'styled-components' @@ -28,6 +31,7 @@ type IKeyframeEditorProps = { index: number keyframe: Keyframe trackData: TrackData + itemKey: StudioSheetItemKey layoutP: Pointer trackId: SequenceTrackId sheetObject: SheetObject diff --git a/theatre/studio/src/panels/SequenceEditorPanel/SequenceEditorPanel.tsx b/theatre/studio/src/panels/SequenceEditorPanel/SequenceEditorPanel.tsx index 439d072..5eb2e1f 100644 --- a/theatre/studio/src/panels/SequenceEditorPanel/SequenceEditorPanel.tsx +++ b/theatre/studio/src/panels/SequenceEditorPanel/SequenceEditorPanel.tsx @@ -3,7 +3,7 @@ import {usePrism} from '@theatre/react' import {valToAtom} from '@theatre/shared/utils/valToAtom' import type {Pointer} from '@theatre/dataverse' import {prism, val} from '@theatre/dataverse' -import React from 'react' +import React, {useState} from 'react' import styled from 'styled-components' import DopeSheet from './DopeSheet/DopeSheet' @@ -28,6 +28,7 @@ import { TitleBar_Punctuation, } from '@theatre/studio/panels/BasePanel/common' import type {UIPanelId} from '@theatre/shared/utils/ids' +import {usePresenceListenersOnRootElement} from '@theatre/studio/uiComponents/usePresence' const Container = styled(PanelWrapper)` z-index: ${panelZIndexes.sequenceEditorPanel}; @@ -99,7 +100,10 @@ const SequenceEditorPanel: React.VFC<{}> = (props) => { const Content: React.VFC<{}> = () => { const {dims} = usePanel() - + const [containerNode, setContainerNode] = useState( + null, + ) + usePresenceListenersOnRootElement(containerNode) return usePrism(() => { const panelSize = prism.memo( 'panelSize', @@ -161,7 +165,14 @@ const Content: React.VFC<{}> = () => { const graphEditorOpen = val(layoutP.graphEditorDims.isOpen) return ( - + { + containerRef(elt as HTMLDivElement) + if (elt !== containerNode) { + setContainerNode(elt as HTMLDivElement) + } + }} + >
@@ -174,7 +185,7 @@ const Content: React.VFC<{}> = () => { ) - }, [dims]) + }, [dims, containerNode]) } const Header: React.FC<{layoutP: Pointer}> = ({ diff --git a/theatre/studio/src/panels/SequenceEditorPanel/layout/tree.ts b/theatre/studio/src/panels/SequenceEditorPanel/layout/tree.ts index 60fde69..877d9d0 100644 --- a/theatre/studio/src/panels/SequenceEditorPanel/layout/tree.ts +++ b/theatre/studio/src/panels/SequenceEditorPanel/layout/tree.ts @@ -8,7 +8,10 @@ import type SheetObject from '@theatre/core/sheetObjects/SheetObject' import type {IPropPathToTrackIdTree} from '@theatre/core/sheetObjects/SheetObjectTemplate' import type Sheet from '@theatre/core/sheets/Sheet' import type {PathToProp} from '@theatre/shared/utils/addresses' -import type {SequenceTrackId} from '@theatre/shared/utils/ids' +import type { + SequenceTrackId, + StudioSheetItemKey, +} from '@theatre/shared/utils/ids' import {createStudioSheetItemKey} from '@theatre/shared/utils/ids' import type {$FixMe, $IntentionalAny} from '@theatre/shared/utils/types' import {prism, val, valueDerivation} from '@theatre/dataverse' @@ -30,6 +33,8 @@ export type SequenceEditorTree_Row = { /** Visual indentation */ depth: number + /** A convenient studio sheet localized identifier for managing presence and ephemeral visual effects. */ + sheetItemKey: StudioSheetItemKey /** * This is a part of the tree, but it is not rendered at all, * and it doesn't contribute to height. @@ -107,6 +112,7 @@ export const calculateSequenceEditorTree = ( type: 'sheet', sheet, children: [], + sheetItemKey: createStudioSheetItemKey.forSheet(), shouldRender: rootShouldRender, top: topSoFar, depth: -1, @@ -151,6 +157,7 @@ export const calculateSequenceEditorTree = ( const row: SequenceEditorTree_SheetObject = { type: 'sheetObject', isCollapsed, + sheetItemKey: createStudioSheetItemKey.forSheetObject(sheetObject), shouldRender, top: topSoFar, children: [], @@ -270,6 +277,10 @@ export const calculateSequenceEditorTree = ( type: 'propWithChildren', isCollapsed, pathToProp, + sheetItemKey: createStudioSheetItemKey.forSheetObjectProp( + sheetObject, + pathToProp, + ), sheetObject: sheetObject, shouldRender, top: topSoFar, @@ -316,6 +327,10 @@ export const calculateSequenceEditorTree = ( type: 'primitiveProp', propConf: propConf, depth: level, + sheetItemKey: createStudioSheetItemKey.forSheetObjectProp( + sheetObject, + pathToProp, + ), sheetObject: sheetObject, pathToProp, shouldRender, diff --git a/theatre/studio/src/propEditors/NextPrevKeyframeCursors.tsx b/theatre/studio/src/propEditors/NextPrevKeyframeCursors.tsx index 44137eb..316ccb2 100644 --- a/theatre/studio/src/propEditors/NextPrevKeyframeCursors.tsx +++ b/theatre/studio/src/propEditors/NextPrevKeyframeCursors.tsx @@ -1,14 +1,25 @@ import type {Keyframe} from '@theatre/core/projects/store/types/SheetState_Historic' +import type {StudioSheetItemKey} from '@theatre/shared/utils/ids' import type {VoidFn} from '@theatre/shared/utils/types' import {pointerEventsAutoInNormalMode} from '@theatre/studio/css' import {transparentize} from 'polished' import React from 'react' import styled, {css} from 'styled-components' +import {PresenceFlag} from '@theatre/studio/uiComponents/usePresence' +import usePresence from '@theatre/studio/uiComponents/usePresence' export type NearbyKeyframesControls = { - prev?: Pick & {jump: VoidFn} - cur: {type: 'on'; toggle: VoidFn} | {type: 'off'; toggle: VoidFn} - next?: Pick & {jump: VoidFn} + prev?: Pick & { + jump: VoidFn + itemKey: StudioSheetItemKey + } + cur: + | {type: 'on'; toggle: VoidFn; itemKey: StudioSheetItemKey} + | {type: 'off'; toggle: VoidFn} + next?: Pick & { + jump: VoidFn + itemKey: StudioSheetItemKey + } } const Container = styled.div` @@ -70,21 +81,34 @@ export const nextPrevCursorsTheme = { onColor: '#e0c917', } -const CurButton = styled(Button)<{isOn: boolean}>` +const CurButton = styled(Button)<{ + isOn: boolean + presence: PresenceFlag | undefined +}>` &:hover { color: #e0c917; } + color: ${(props) => - props.isOn ? nextPrevCursorsTheme.onColor : nextPrevCursorsTheme.offColor}; + props.presence === PresenceFlag.Primary + ? 'white' + : props.isOn + ? nextPrevCursorsTheme.onColor + : nextPrevCursorsTheme.offColor}; ` const pointerEventsNone = css` pointer-events: none !important; ` -const PrevOrNextButton = styled(Button)<{available: boolean}>` +const PrevOrNextButton = styled(Button)<{ + available: boolean + flag: PresenceFlag | undefined +}>` color: ${(props) => - props.available + props.flag === PresenceFlag.Primary + ? 'white' + : props.available ? nextPrevCursorsTheme.onColor : nextPrevCursorsTheme.offColor}; @@ -92,15 +116,20 @@ const PrevOrNextButton = styled(Button)<{available: boolean}>` props.available ? pointerEventsAutoInNormalMode : pointerEventsNone}; ` -const Prev = styled(PrevOrNextButton)<{available: boolean}>` +const Prev = styled(PrevOrNextButton)<{ + available: boolean + flag: PresenceFlag | undefined +}>` transform: translateX(2px); ${Container}:hover & { transform: translateX(-7px); } ` -const Next = styled(PrevOrNextButton)<{available: boolean}>` +const Next = styled(PrevOrNextButton)<{ + available: boolean + flag: PresenceFlag | undefined +}>` transform: translateX(-2px); - ${Container}:hover & { transform: translateX(7px); } @@ -165,16 +194,37 @@ namespace Icons { ) } -const NextPrevKeyframeCursors: React.FC = (props) => { +const NextPrevKeyframeCursors: React.VFC = (props) => { + const prevPresence = usePresence(props.prev?.itemKey) + const curPresence = usePresence( + props.cur?.type === 'on' ? props.cur.itemKey : undefined, + ) + const nextPresence = usePresence(props.next?.itemKey) + return ( - + - + - + diff --git a/theatre/studio/src/propEditors/getNearbyKeyframesOfTrack.tsx b/theatre/studio/src/propEditors/getNearbyKeyframesOfTrack.tsx index ac186dc..4cfa9b7 100644 --- a/theatre/studio/src/propEditors/getNearbyKeyframesOfTrack.tsx +++ b/theatre/studio/src/propEditors/getNearbyKeyframesOfTrack.tsx @@ -2,7 +2,12 @@ import type { TrackData, Keyframe, } from '@theatre/core/projects/store/types/SheetState_Historic' -import last from 'lodash-es/last' +import type SheetObject from '@theatre/core/sheetObjects/SheetObject' +import {createStudioSheetItemKey} from '@theatre/shared/utils/ids' +import type { + KeyframeWithTrack, + TrackWithId, +} from '@theatre/studio/panels/SequenceEditorPanel/DopeSheet/Right/collectAggregateKeyframes' const cache = new WeakMap< TrackData, @@ -12,48 +17,67 @@ const cache = new WeakMap< const noKeyframes: NearbyKeyframes = {} export function getNearbyKeyframesOfTrack( - track: TrackData | undefined, + obj: SheetObject, + track: TrackWithId | undefined, sequencePosition: number, ): NearbyKeyframes { - if (!track || track.keyframes.length === 0) return noKeyframes + if (!track || track.data.keyframes.length === 0) return noKeyframes - const cachedItem = cache.get(track) + const cachedItem = cache.get(track.data) if (cachedItem && cachedItem[0] === sequencePosition) { return cachedItem[1] } - const calculate = (): NearbyKeyframes => { - const i = track.keyframes.findIndex((kf) => kf.position >= sequencePosition) - - if (i === -1) - return { - prev: last(track.keyframes), + function getKeyframeWithTrackId(idx: number): KeyframeWithTrack | undefined { + if (!track) return + const found = track.data.keyframes[idx] + return ( + found && { + kf: found, + track, + itemKey: createStudioSheetItemKey.forTrackKeyframe( + obj, + track.id, + found.id, + ), } + ) + } - const k = track.keyframes[i]! - if (k.position === sequencePosition) { + const calculate = (): NearbyKeyframes => { + const nextOrCurIdx = track.data.keyframes.findIndex( + (kf) => kf.position >= sequencePosition, + ) + + if (nextOrCurIdx === -1) { return { - prev: i > 0 ? track.keyframes[i - 1] : undefined, - cur: k, - next: - i === track.keyframes.length - 1 ? undefined : track.keyframes[i + 1], + prev: getKeyframeWithTrackId(track.data.keyframes.length - 1), + } + } + + const nextOrCur = getKeyframeWithTrackId(nextOrCurIdx)! + if (nextOrCur.kf.position === sequencePosition) { + return { + prev: getKeyframeWithTrackId(nextOrCurIdx - 1), + cur: nextOrCur, + next: getKeyframeWithTrackId(nextOrCurIdx + 1), } } else { return { - next: k, - prev: i > 0 ? track.keyframes[i - 1] : undefined, + next: nextOrCur, + prev: getKeyframeWithTrackId(nextOrCurIdx - 1), } } } const result = calculate() - cache.set(track, [sequencePosition, result]) + cache.set(track.data, [sequencePosition, result]) return result } export type NearbyKeyframes = { - prev?: Keyframe - cur?: Keyframe - next?: Keyframe + prev?: KeyframeWithTrack + cur?: KeyframeWithTrack + next?: KeyframeWithTrack } diff --git a/theatre/studio/src/propEditors/useEditingToolsForCompoundProp.tsx b/theatre/studio/src/propEditors/useEditingToolsForCompoundProp.tsx index 57f25cb..9586a99 100644 --- a/theatre/studio/src/propEditors/useEditingToolsForCompoundProp.tsx +++ b/theatre/studio/src/propEditors/useEditingToolsForCompoundProp.tsx @@ -19,11 +19,13 @@ import { iteratePropType, } from '@theatre/shared/propTypes/utils' import type {SequenceTrackId} from '@theatre/shared/utils/ids' +import {createStudioSheetItemKey} from '@theatre/shared/utils/ids' import type {IPropPathToTrackIdTree} from '@theatre/core/sheetObjects/SheetObjectTemplate' import pointerDeep from '@theatre/shared/utils/pointerDeep' import type {NearbyKeyframesControls} from './NextPrevKeyframeCursors' import NextPrevKeyframeCursors from './NextPrevKeyframeCursors' import {getNearbyKeyframesOfTrack} from './getNearbyKeyframesOfTrack' +import type {KeyframeWithTrack} from '@theatre/studio/panels/SequenceEditorPanel/DopeSheet/Right/collectAggregateKeyframes' interface CommonStuff { beingScrubbed: boolean @@ -212,7 +214,11 @@ export function useEditingToolsForCompoundProp( .filter(({track}) => !!track) .map((s) => ({ ...s, - nearbies: getNearbyKeyframesOfTrack(s.track, sequencePosition), + nearbies: getNearbyKeyframesOfTrack( + obj, + {id: s.trackId, data: s.track!}, + sequencePosition, + ), })) const hasCur = nearbyKeyframesInEachTrack.find( @@ -223,13 +229,16 @@ export function useEditingToolsForCompoundProp( ) const closestPrev = nearbyKeyframesInEachTrack.reduce< - undefined | number + undefined | KeyframeWithTrack >((acc, s) => { if (s.nearbies.prev) { - if (acc === undefined) { - return s.nearbies.prev.position + if ( + acc === undefined || + s.nearbies.prev.kf.position > acc.kf.position + ) { + return s.nearbies.prev } else { - return Math.max(s.nearbies.prev.position, acc) + return acc } } else { return acc @@ -237,53 +246,80 @@ export function useEditingToolsForCompoundProp( }, undefined) const closestNext = nearbyKeyframesInEachTrack.reduce< - undefined | number + undefined | KeyframeWithTrack >((acc, s) => { if (s.nearbies.next) { - if (acc === undefined) { - return s.nearbies.next.position + if ( + acc === undefined || + s.nearbies.next.kf.position < acc.kf.position + ) { + return s.nearbies.next } else { - return Math.min(s.nearbies.next.position, acc) + return acc } } else { return acc } }, undefined) + const toggle = () => { + if (allCur) { + getStudio().transaction((api) => { + api.unset(pointerToProp) + }) + } else if (hasCur) { + getStudio().transaction((api) => { + api.set(pointerToProp, val(pointerToProp)) + }) + } else { + getStudio().transaction((api) => { + api.set(pointerToProp, val(pointerToProp)) + }) + } + } return { - cur: { - type: hasCur ? 'on' : 'off', - toggle: () => { - if (allCur) { - getStudio().transaction((api) => { - api.unset(pointerToProp) - }) - } else if (hasCur) { - getStudio().transaction((api) => { - api.set(pointerToProp, val(pointerToProp)) - }) - } else { - getStudio().transaction((api) => { - api.set(pointerToProp, val(pointerToProp)) - }) + cur: hasCur + ? { + type: 'on', + itemKey: + createStudioSheetItemKey.forCompoundPropAggregateKeyframe( + obj, + pathToProp, + sequencePosition, + ), + toggle, } - }, - }, + : { + toggle, + type: 'off', + }, prev: closestPrev !== undefined ? { - position: closestPrev, + position: closestPrev.kf.position, + itemKey: + createStudioSheetItemKey.forCompoundPropAggregateKeyframe( + obj, + pathToProp, + closestPrev.kf.position, + ), jump: () => { - obj.sheet.getSequence().position = closestPrev + obj.sheet.getSequence().position = closestPrev.kf.position }, } : undefined, next: closestNext !== undefined ? { - position: closestNext, + position: closestNext.kf.position, + itemKey: + createStudioSheetItemKey.forCompoundPropAggregateKeyframe( + obj, + pathToProp, + closestNext.kf.position, + ), jump: () => { - obj.sheet.getSequence().position = closestNext + obj.sheet.getSequence().position = closestNext.kf.position }, } : undefined, diff --git a/theatre/studio/src/propEditors/useEditingToolsForSimpleProp.tsx b/theatre/studio/src/propEditors/useEditingToolsForSimpleProp.tsx index b490011..0ab71cf 100644 --- a/theatre/studio/src/propEditors/useEditingToolsForSimpleProp.tsx +++ b/theatre/studio/src/propEditors/useEditingToolsForSimpleProp.tsx @@ -172,7 +172,14 @@ export function useEditingToolsForSimplePropInDetailsPanel< const sequencePosition = val( obj.sheet.getSequence().positionDerivation, ) - return getNearbyKeyframesOfTrack(track, sequencePosition) + return getNearbyKeyframesOfTrack( + obj, + track && { + data: track, + id: sequenceTrackId, + }, + sequencePosition, + ) }, [sequenceTrackId], ) @@ -184,45 +191,54 @@ export function useEditingToolsForSimplePropInDetailsPanel< } else { if (nearbyKeyframes.cur) { shade = 'Sequenced_OnKeyframe' - } else if (nearbyKeyframes.prev?.connectedRight === true) { + } else if (nearbyKeyframes.prev?.kf.connectedRight === true) { shade = 'Sequenced_BeingInterpolated' } else { shade = 'Sequened_NotBeingInterpolated' } } + const toggle = () => { + if (nearbyKeyframes.cur) { + getStudio()!.transaction((api) => { + api.unset(pointerToProp) + }) + } else { + getStudio()!.transaction((api) => { + api.set(pointerToProp, common.value) + }) + } + } const controls: NearbyKeyframesControls = { - cur: { - type: nearbyKeyframes.cur ? 'on' : 'off', - toggle: () => { - if (nearbyKeyframes.cur) { - getStudio()!.transaction((api) => { - api.unset(pointerToProp) - }) - } else { - getStudio()!.transaction((api) => { - api.set(pointerToProp, common.value) - }) + cur: nearbyKeyframes.cur + ? { + type: 'on', + itemKey: nearbyKeyframes.cur.itemKey, + toggle, } - }, - }, + : { + type: 'off', + toggle, + }, prev: nearbyKeyframes.prev !== undefined ? { - position: nearbyKeyframes.prev.position, + itemKey: nearbyKeyframes.prev.itemKey, + position: nearbyKeyframes.prev.kf.position, jump: () => { obj.sheet.getSequence().position = - nearbyKeyframes.prev!.position + nearbyKeyframes.prev!.kf.position }, } : undefined, next: nearbyKeyframes.next !== undefined ? { - position: nearbyKeyframes.next.position, + itemKey: nearbyKeyframes.next.itemKey, + position: nearbyKeyframes.next.kf.position, jump: () => { obj.sheet.getSequence().position = - nearbyKeyframes.next!.position + nearbyKeyframes.next!.kf.position }, } : undefined, diff --git a/theatre/studio/src/uiComponents/usePresence.tsx b/theatre/studio/src/uiComponents/usePresence.tsx new file mode 100644 index 0000000..ceab033 --- /dev/null +++ b/theatre/studio/src/uiComponents/usePresence.tsx @@ -0,0 +1,211 @@ +import type {StudioSheetItemKey} from '@theatre/shared/utils/ids' +import type {StrictRecord} from '@theatre/shared/utils/types' +import React, {useMemo} from 'react' +import {useEffect} from 'react' +import {useLogger} from './useLogger' +import {Box, prism, valueDerivation} from '@theatre/dataverse' +import {Atom} from '@theatre/dataverse' +import {useDerivation} from '@theatre/react' +import {selectClosestHTMLAncestor} from '@theatre/studio/utils/selectClosestHTMLAncestor' + +/** To mean the presence value */ +export enum PresenceFlag { + /** Self is hovered or what represents "self" is being hovered */ + Primary = 2, + /** Related item is hovered */ + Secondary = 1, + // /** Tutorial */ + // TutorialEmphasis = 0, +} + +const undefinedD = prism(() => undefined) +undefinedD.keepHot() // constant anyway... + +function createPresenceContext(): InternalPresenceContext { + const currentUserHoverItemB = new Box( + undefined, + ) + const currentUserHoverFlagItemsAtom = new Atom( + {} as StrictRecord, + ) + + // keep as part of presence creation + const relationsAtom = new Atom( + {} as StrictRecord< + StudioSheetItemKey, + StrictRecord< + StudioSheetItemKey, + StrictRecord + > + >, + ) + + let lastRelationId = 0 + + return { + addRelatedFlags(itemKey, relationships) { + const relationId = String(++lastRelationId) + // "clean up" paths returned from relationships declared + const undoAtPaths = relationships.map((rel) => { + const presence: {flag: PresenceFlag} = { + flag: rel.flag, + } + const path = [rel.affects, itemKey, relationId] + relationsAtom.setIn(path, presence) + return path + }) + return () => { + for (const pathToUndo of undoAtPaths) { + relationsAtom.setIn(pathToUndo, undefined) + } + } + }, + usePresenceFlag(itemKey) { + const focusD = useMemo(() => { + if (!itemKey) return undefinedD + // this is the thing being hovered + const currentD = currentUserHoverItemB.derivation + const primaryFocusDer = valueDerivation( + currentUserHoverFlagItemsAtom.pointer[itemKey], + ) + const relationsDer = valueDerivation(relationsAtom.pointer[itemKey]) + return prism(() => { + const primary = primaryFocusDer.getValue() + if (primary) { + return PresenceFlag.Primary + } else { + const related = relationsDer.getValue() + const current = currentD.getValue() + const rels = related && current && related[current] + if (rels) { + // can this be cached into a derived atom? + let best: PresenceFlag | undefined + for (const rel of Object.values(rels)) { + if (!rel) continue + if (best && best >= rel.flag) continue + best = rel.flag + } + return best + } + return undefined + } + }) + }, [itemKey]) + return useDerivation(focusD) + }, + setUserHover(itemKeyOpt) { + const prev = currentUserHoverItemB.get() + if (prev === itemKeyOpt) { + return + } + if (prev) { + currentUserHoverFlagItemsAtom.setIn([prev], false) + } + currentUserHoverItemB.set(itemKeyOpt) + if (itemKeyOpt) { + currentUserHoverFlagItemsAtom.setIn([itemKeyOpt], true) + } + }, + } +} + +type FlagRelationConfig = { + affects: StudioSheetItemKey + /** adds this flag to affects */ + flag: PresenceFlag +} + +type InternalPresenceContext = { + usePresenceFlag( + itemKey: StudioSheetItemKey | undefined, + ): PresenceFlag | undefined + setUserHover(itemKey: StudioSheetItemKey | undefined): void + addRelatedFlags( + itemKey: StudioSheetItemKey, + config: Array, + ): () => void +} + +const presenceInternalCtx = React.createContext( + createPresenceContext(), +) +export function ProvidePresenceRoot({children}: React.PropsWithChildren<{}>) { + const presence = useMemo(() => createPresenceContext(), []) + return React.createElement( + presenceInternalCtx.Provider, + {children, value: presence}, + children, + ) +} + +const PRESENCE_ITEM_DATA_ATTR = 'data-pi-key' + +export default function usePresence(key: StudioSheetItemKey | undefined): { + attrs: {[attr: `data-${string}`]: string} + flag: PresenceFlag | undefined + useRelations(getRelations: () => Array, deps: any[]): void +} { + const presenceInternal = React.useContext(presenceInternalCtx) + const flag = presenceInternal.usePresenceFlag(key) + + return { + attrs: { + [PRESENCE_ITEM_DATA_ATTR]: key as string, + }, + flag, + useRelations(getRelations, deps) { + useEffect(() => { + return key && presenceInternal.addRelatedFlags(key, getRelations()) + }, [key, ...deps]) + }, + } +} + +export function usePresenceListenersOnRootElement( + target: HTMLElement | null | undefined, +) { + const presence = React.useContext(presenceInternalCtx) + const logger = useLogger('PresenceListeners') + useEffect(() => { + // keep track of current primary hover to make sure we make changes to presence distinct + let currentItemKeyUserHover: any + if (!target) return + const onMouseOver = (event: MouseEvent) => { + if (event.target instanceof Node) { + const found = selectClosestHTMLAncestor( + event.target, + `[${PRESENCE_ITEM_DATA_ATTR}]`, + ) + if (found) { + const itemKey = found.getAttribute(PRESENCE_ITEM_DATA_ATTR) + if (currentItemKeyUserHover !== itemKey) { + currentItemKeyUserHover = itemKey + presence.setUserHover( + (itemKey || undefined) as StudioSheetItemKey | undefined, + ) + logger._debug('Updated current hover', {itemKey}) + } + return + } + + // remove hover + if (currentItemKeyUserHover != null) { + currentItemKeyUserHover = null + presence.setUserHover(undefined) + logger._debug('Cleared current hover') + } + } + } + + target.addEventListener('mouseover', onMouseOver) + + return () => { + target.removeEventListener('mouseover', onMouseOver) + // remove hover + if (currentItemKeyUserHover != null) { + currentItemKeyUserHover = null + logger._debug('Cleared current hover as part of cleanup') + } + } + }, [target, presence]) +} diff --git a/theatre/studio/src/utils/selectClosestHTMLAncestor.ts b/theatre/studio/src/utils/selectClosestHTMLAncestor.ts new file mode 100644 index 0000000..2fbdf21 --- /dev/null +++ b/theatre/studio/src/utils/selectClosestHTMLAncestor.ts @@ -0,0 +1,14 @@ +/** + * Traverse upwards from the current element to find the first element that matches the selector. + */ +export function selectClosestHTMLAncestor( + start: Element | Node | null, + selector: string, +): Element | null { + if (start == null) return null + if (start instanceof Element && start.matches(selector)) { + return start + } else { + return selectClosestHTMLAncestor(start.parentElement, selector) + } +}