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
This commit is contained in:
Cole Lawrence 2022-06-15 07:36:57 -04:00 committed by GitHub
parent 87070bcdf3
commit e8c8168f0b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
26 changed files with 666 additions and 138 deletions

View file

@ -68,6 +68,9 @@ export function generateSequenceMarkerId(): SequenceMarkerId {
* versioning happens where something needs to * versioning happens where something needs to
*/ */
export const createStudioSheetItemKey = { export const createStudioSheetItemKey = {
forSheet(): StudioSheetItemKey {
return 'sheet' as StudioSheetItemKey
},
forSheetObject(obj: SheetObject): StudioSheetItemKey { forSheetObject(obj: SheetObject): StudioSheetItemKey {
return stableValueHash({ return stableValueHash({
o: obj.address.objectKey, o: obj.address.objectKey,
@ -82,4 +85,36 @@ export const createStudioSheetItemKey = {
p: pathToProp, p: pathToProp,
}) as StudioSheetItemKey }) 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
},
} }

View file

@ -5,6 +5,7 @@ import React, {
useContext, useContext,
useEffect, useEffect,
useLayoutEffect, useLayoutEffect,
useState,
} from 'react' } from 'react'
import styled from 'styled-components' import styled from 'styled-components'
import {isProject, isSheetObject} from '@theatre/shared/instanceTypes' 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 {Box, prism, val} from '@theatre/dataverse'
import EmptyState from './EmptyState' import EmptyState from './EmptyState'
import useLockSet from '@theatre/studio/uiComponents/useLockSet' import useLockSet from '@theatre/studio/uiComponents/useLockSet'
import {usePresenceListenersOnRootElement} from '@theatre/studio/uiComponents/usePresence'
const headerHeight = `32px` const headerHeight = `32px`
@ -106,6 +108,9 @@ const DetailPanel: React.FC<{}> = (props) => {
const showDetailsPanel = pin || hotspotActive || isContextMenuShown const showDetailsPanel = pin || hotspotActive || isContextMenuShown
const [containerElt, setContainerElt] = useState<null | HTMLDivElement>(null)
usePresenceListenersOnRootElement(containerElt)
return usePrism(() => { return usePrism(() => {
const selection = getOutlineSelection() const selection = getOutlineSelection()
@ -115,6 +120,7 @@ const DetailPanel: React.FC<{}> = (props) => {
<Container <Container
data-testid="DetailPanel-Object" data-testid="DetailPanel-Object"
pin={showDetailsPanel} pin={showDetailsPanel}
ref={setContainerElt}
onMouseEnter={() => { onMouseEnter={() => {
isDetailPanelHoveredB.set(true) isDetailPanelHoveredB.set(true)
}} }}

View file

@ -31,7 +31,10 @@ const PropWithChildrenRow: React.VFC<{
label={leaf.pathToProp[leaf.pathToProp.length - 1]} label={leaf.pathToProp[leaf.pathToProp.length - 1]}
isCollapsed={leaf.isCollapsed} isCollapsed={leaf.isCollapsed}
toggleCollapsed={() => toggleCollapsed={() =>
setCollapsedSheetObjectOrCompoundProp(!leaf.isCollapsed, leaf) setCollapsedSheetObjectOrCompoundProp(!leaf.isCollapsed, {
sheetAddress: leaf.sheetObject.address,
sheetItemKey: leaf.sheetItemKey,
})
} }
> >
{leaf.children.map((propLeaf) => decideRowByPropType(propLeaf))} {leaf.children.map((propLeaf) => decideRowByPropType(propLeaf))}

View file

@ -22,7 +22,10 @@ const LeftSheetObjectRow: React.VFC<{
}) })
}} }}
toggleCollapsed={() => toggleCollapsed={() =>
setCollapsedSheetObjectOrCompoundProp(!leaf.isCollapsed, leaf) setCollapsedSheetObjectOrCompoundProp(!leaf.isCollapsed, {
sheetAddress: leaf.sheetObject.address,
sheetItemKey: leaf.sheetItemKey,
})
} }
> >
{leaf.children.map((leaf) => decideRowByPropType(leaf))} {leaf.children.map((leaf) => decideRowByPropType(leaf))}

View file

@ -1,5 +1,8 @@
import React from 'react' import React from 'react'
import useRefAndState from '@theatre/studio/utils/useRefAndState' import useRefAndState from '@theatre/studio/utils/useRefAndState'
import usePresence, {
PresenceFlag,
} from '@theatre/studio/uiComponents/usePresence'
import {useLogger} from '@theatre/studio/uiComponents/useLogger' import {useLogger} from '@theatre/studio/uiComponents/useLogger'
import useContextMenu from '@theatre/studio/uiComponents/simpleContextMenu/useContextMenu' import useContextMenu from '@theatre/studio/uiComponents/simpleContextMenu/useContextMenu'
import type {IAggregateKeyframeEditorProps} from './AggregateKeyframeEditor' import type {IAggregateKeyframeEditorProps} from './AggregateKeyframeEditor'
@ -26,6 +29,22 @@ export function AggregateKeyframeDot(
const logger = useLogger('AggregateKeyframeDot') const logger = useLogger('AggregateKeyframeDot')
const {cur} = props.utils 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<HTMLDivElement | null>(null) const [ref, node] = useRefAndState<HTMLDivElement | null>(null)
const [contextMenu] = useAggregateKeyframeContextMenu(props, logger, node) const [contextMenu] = useAggregateKeyframeContextMenu(props, logger, node)
@ -34,11 +53,13 @@ export function AggregateKeyframeDot(
<> <>
<HitZone <HitZone
ref={ref} ref={ref}
{...presence.attrs}
// Need this for the dragging logic to be able to get the keyframe props // Need this for the dragging logic to be able to get the keyframe props
// based on the position. // based on the position.
{...DopeSnap.includePositionSnapAttrs(cur.position)} {...DopeSnap.includePositionSnapAttrs(cur.position)}
/> />
<AggregateKeyframeVisualDot <AggregateKeyframeVisualDot
flag={presence.flag}
isAllHere={cur.allHere} isAllHere={cur.allHere}
isSelected={cur.selected} isSelected={cur.selected}
/> />

View file

@ -1,5 +1,6 @@
import React from 'react' import React from 'react'
import {AggregateKeyframePositionIsSelected} from '@theatre/studio/panels/SequenceEditorPanel/DopeSheet/Right/AggregatedKeyframeTrack/AggregatedKeyframeTrack' import {AggregateKeyframePositionIsSelected} from '@theatre/studio/panels/SequenceEditorPanel/DopeSheet/Right/AggregatedKeyframeTrack/AggregatedKeyframeTrack'
import {PresenceFlag} from '@theatre/studio/uiComponents/usePresence'
import styled from 'styled-components' import styled from 'styled-components'
import {absoluteDims} from '@theatre/studio/utils/absoluteDims' import {absoluteDims} from '@theatre/studio/utils/absoluteDims'
import {pointerEventsAutoInNormalMode} from '@theatre/studio/css' import {pointerEventsAutoInNormalMode} from '@theatre/studio/css'
@ -34,11 +35,13 @@ export const HitZone = styled.div`
` `
export function AggregateKeyframeVisualDot(props: { export function AggregateKeyframeVisualDot(props: {
flag: PresenceFlag | undefined
isSelected: AggregateKeyframePositionIsSelected | undefined isSelected: AggregateKeyframePositionIsSelected | undefined
isAllHere: boolean isAllHere: boolean
}) { }) {
const theme: IDotThemeValues = { const theme: IDotThemeValues = {
isSelected: props.isSelected, isSelected: props.isSelected,
flag: props.flag,
} }
return ( return (
@ -53,6 +56,7 @@ export function AggregateKeyframeVisualDot(props: {
} }
type IDotThemeValues = { type IDotThemeValues = {
isSelected: AggregateKeyframePositionIsSelected | undefined isSelected: AggregateKeyframePositionIsSelected | undefined
flag: PresenceFlag | undefined
} }
const SELECTED_COLOR = '#F2C95C' const SELECTED_COLOR = '#F2C95C'
const DEFAULT_PRIMARY_COLOR = '#40AAA4' const DEFAULT_PRIMARY_COLOR = '#40AAA4'
@ -95,6 +99,8 @@ const AggregateDotAllHereSvg = (theme: IDotThemeValues) => (
height="6" height="6"
transform="rotate(-45 3.75732 6.01953)" transform="rotate(-45 3.75732 6.01953)"
fill={selectionColorAll(theme)} fill={selectionColorAll(theme)}
stroke={theme.flag === PresenceFlag.Primary ? 'white' : undefined}
strokeWidth={theme.flag === PresenceFlag.Primary ? '2px' : undefined}
/> />
</svg> </svg>
) )
@ -114,7 +120,10 @@ const AggregateDotSomeHereSvg = (theme: IDotThemeValues) => (
height="5" height="5"
transform="rotate(-45 4.46443 8)" transform="rotate(-45 4.46443 8)"
fill="#23262B" fill="#23262B"
stroke={selectionColorAll(theme)} stroke={
theme.flag === PresenceFlag.Primary ? 'white' : selectionColorAll(theme)
}
strokeWidth={theme.flag === PresenceFlag.Primary ? '2px' : undefined}
/> />
</svg> </svg>
) )

View file

@ -1,4 +1,5 @@
import {prism} from '@theatre/dataverse' import {prism} from '@theatre/dataverse'
import {createStudioSheetItemKey} from '@theatre/shared/utils/ids'
import {AggregateKeyframePositionIsSelected} from '@theatre/studio/panels/SequenceEditorPanel/DopeSheet/Right/AggregatedKeyframeTrack/AggregatedKeyframeTrack' 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 {isConnectionEditingInCurvePopover} from '@theatre/studio/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/CurveEditorPopover/CurveEditorPopover'
import {usePrism} from '@theatre/react' import {usePrism} from '@theatre/react'
@ -93,7 +94,27 @@ export function getAggregateKeyframeEditorUtilsPrismFn(
(con) => isConnectionEditingInCurvePopover(con), (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 { return {
itemKey,
cur, cur,
connected, connected,
isAggregateEditingInCurvePopover, isAggregateEditingInCurvePopover,

View file

@ -17,6 +17,7 @@ import type {KeyframeWithPathToPropFromCommonRoot} from '@theatre/studio/store/t
import KeyframeSnapTarget, { import KeyframeSnapTarget, {
snapPositionsStateD, snapPositionsStateD,
} from '@theatre/studio/panels/SequenceEditorPanel/DopeSheet/Right/KeyframeSnapTarget' } from '@theatre/studio/panels/SequenceEditorPanel/DopeSheet/Right/KeyframeSnapTarget'
import {createStudioSheetItemKey} from '@theatre/shared/utils/ids'
const Container = styled.div` const Container = styled.div`
position: relative; position: relative;
@ -82,9 +83,17 @@ const BasicKeyframedTrack: React.VFC<BasicKeyframedTracksProps> = React.memo(
/> />
)} )}
<SingleKeyframeEditor <SingleKeyframeEditor
itemKey={createStudioSheetItemKey.forTrackKeyframe(
leaf.sheetObject,
leaf.trackId,
kf.id,
)}
keyframe={kf} keyframe={kf}
index={index} index={index}
trackData={trackData} track={{
data: trackData,
id: leaf.trackId,
}}
layoutP={layoutP} layoutP={layoutP}
leaf={leaf} leaf={leaf}
selection={ selection={

View file

@ -35,9 +35,9 @@ type IBasicKeyframeConnectorProps = ISingleKeyframeEditorProps
const BasicKeyframeConnector: React.VFC<IBasicKeyframeConnectorProps> = ( const BasicKeyframeConnector: React.VFC<IBasicKeyframeConnectorProps> = (
props, props,
) => { ) => {
const {index, trackData} = props const {index, track} = props
const cur = trackData.keyframes[index] const cur = track.data.keyframes[index]
const next = trackData.keyframes[index + 1] const next = track.data.keyframes[index + 1]
const [nodeRef, node] = useRefAndState<HTMLDivElement | null>(null) const [nodeRef, node] = useRefAndState<HTMLDivElement | null>(null)
@ -99,7 +99,11 @@ export default BasicKeyframeConnector
const SingleCurveEditorPopover: React.FC< const SingleCurveEditorPopover: React.FC<
IBasicKeyframeConnectorProps & {closePopover: (reason: string) => void} IBasicKeyframeConnectorProps & {closePopover: (reason: string) => void}
> = React.forwardRef((props, ref) => { > = React.forwardRef((props, ref) => {
const {index, trackData, selection} = props const {
index,
track: {data: trackData},
selection,
} = props
const cur = trackData.keyframes[index] const cur = trackData.keyframes[index]
const next = trackData.keyframes[index + 1] const next = trackData.keyframes[index + 1]
@ -161,7 +165,7 @@ function useDragKeyframe(
...sheetObject.address, ...sheetObject.address,
domNode: node!, domNode: node!,
positionAtStartOfDrag: positionAtStartOfDrag:
props.trackData.keyframes[props.index].position, props.track.data.keyframes[props.index].position,
}) })
.onDragStart(event) .onDragStart(event)
} }
@ -187,7 +191,7 @@ function useDragKeyframe(
trackId: propsAtStartOfDrag.leaf.trackId, trackId: propsAtStartOfDrag.leaf.trackId,
keyframeIds: [ keyframeIds: [
propsAtStartOfDrag.keyframe.id, propsAtStartOfDrag.keyframe.id,
propsAtStartOfDrag.trackData.keyframes[ propsAtStartOfDrag.track.data.keyframes[
propsAtStartOfDrag.index + 1 propsAtStartOfDrag.index + 1
].id, ].id,
], ],

View file

@ -69,7 +69,6 @@ type ICurveSegmentEditorProps = {
const CurveSegmentEditor: React.VFC<ICurveSegmentEditorProps> = (props) => { const CurveSegmentEditor: React.VFC<ICurveSegmentEditorProps> = (props) => {
const { const {
curveConnection,
curveConnection: {left, right}, curveConnection: {left, right},
backgroundConnections, backgroundConnections,
} = props } = props

View file

@ -24,6 +24,9 @@ import {
snapToSome, snapToSome,
} from '@theatre/studio/panels/SequenceEditorPanel/DopeSheet/Right/KeyframeSnapTarget' } from '@theatre/studio/panels/SequenceEditorPanel/DopeSheet/Right/KeyframeSnapTarget'
import {useSingleKeyframeInlineEditorPopover} from './useSingleKeyframeInlineEditorPopover' import {useSingleKeyframeInlineEditorPopover} from './useSingleKeyframeInlineEditorPopover'
import usePresence, {
PresenceFlag,
} from '@theatre/studio/uiComponents/usePresence'
export const DOT_SIZE_PX = 6 export const DOT_SIZE_PX = 6
const DOT_HOVER_SIZE_PX = DOT_SIZE_PX + 2 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 ◆ */ /** The keyframe diamond ◆ */
const Diamond = styled.div<IDiamond>` const Diamond = styled.div<IDiamond>`
@ -60,6 +67,9 @@ const Diamond = styled.div<IDiamond>`
background: ${(props) => selectBacgroundForDiamond(props)}; background: ${(props) => selectBacgroundForDiamond(props)};
transform: rotateZ(45deg); transform: rotateZ(45deg);
${(props) =>
props.flag === PresenceFlag.Primary ? 'outline: 2px solid white;' : ''};
z-index: 1; z-index: 1;
pointer-events: none; pointer-events: none;
` `
@ -86,7 +96,8 @@ type ISingleKeyframeDotProps = ISingleKeyframeEditorProps
/** The ◆ you can grab onto in "keyframe editor" (aka "dope sheet" in other programs) */ /** The ◆ you can grab onto in "keyframe editor" (aka "dope sheet" in other programs) */
const SingleKeyframeDot: React.VFC<ISingleKeyframeDotProps> = (props) => { const SingleKeyframeDot: React.VFC<ISingleKeyframeDotProps> = (props) => {
const logger = useLogger('SingleKeyframeDot') const logger = useLogger('SingleKeyframeDot', props.keyframe.id)
const presence = usePresence(props.itemKey)
const [ref, node] = useRefAndState<HTMLDivElement | null>(null) const [ref, node] = useRefAndState<HTMLDivElement | null>(null)
const [contextMenu] = useSingleKeyframeContextMenu(node, logger, props) const [contextMenu] = useSingleKeyframeContextMenu(node, logger, props)
@ -110,10 +121,12 @@ const SingleKeyframeDot: React.VFC<ISingleKeyframeDotProps> = (props) => {
<HitZone <HitZone
ref={ref} ref={ref}
isInlineEditorPopoverOpen={isInlineEditorPopoverOpen} isInlineEditorPopoverOpen={isInlineEditorPopoverOpen}
{...presence.attrs}
/> />
<Diamond <Diamond
isSelected={!!props.selection} isSelected={!!props.selection}
isInlineEditorPopoverOpen={isInlineEditorPopoverOpen} isInlineEditorPopoverOpen={isInlineEditorPopoverOpen}
flag={presence.flag}
/> />
{inlineEditorPopover} {inlineEditorPopover}
{contextMenu} {contextMenu}
@ -242,7 +255,7 @@ function useDragForSingleKeyframeDot(
...sheetObject.address, ...sheetObject.address,
domNode: node!, domNode: node!,
positionAtStartOfDrag: positionAtStartOfDrag:
props.trackData.keyframes[props.index].position, props.track.data.keyframes[props.index].position,
}) })
.onDragStart(event) .onDragStart(event)
@ -272,7 +285,7 @@ function useDragForSingleKeyframeDot(
return { return {
onDrag(dx, dy, event) { onDrag(dx, dy, event) {
const original = const original =
propsAtStartOfDrag.trackData.keyframes[propsAtStartOfDrag.index] propsAtStartOfDrag.track.data.keyframes[propsAtStartOfDrag.index]
const newPosition = Math.max( const newPosition = Math.max(
// check if our event hoversover a [data-pos] element // check if our event hoversover a [data-pos] element
DopeSnap.checkIfMouseEventSnapToPos(event, { DopeSnap.checkIfMouseEventSnapToPos(event, {

View file

@ -1,7 +1,4 @@
import type { import type {Keyframe} from '@theatre/core/projects/store/types/SheetState_Historic'
Keyframe,
TrackData,
} from '@theatre/core/projects/store/types/SheetState_Historic'
import type { import type {
DopeSheetSelection, DopeSheetSelection,
SequenceEditorPanelLayout, SequenceEditorPanelLayout,
@ -13,6 +10,8 @@ import React from 'react'
import styled from 'styled-components' import styled from 'styled-components'
import SingleKeyframeConnector from './BasicKeyframeConnector' import SingleKeyframeConnector from './BasicKeyframeConnector'
import SingleKeyframeDot from './SingleKeyframeDot' 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` const SingleKeyframeEditorContainer = styled.div`
position: absolute; position: absolute;
@ -23,14 +22,19 @@ const noConnector = <></>
export type ISingleKeyframeEditorProps = { export type ISingleKeyframeEditorProps = {
index: number index: number
keyframe: Keyframe keyframe: Keyframe
trackData: TrackData track: TrackWithId
itemKey: StudioSheetItemKey
layoutP: Pointer<SequenceEditorPanelLayout> layoutP: Pointer<SequenceEditorPanelLayout>
leaf: SequenceEditorTree_PrimitiveProp leaf: SequenceEditorTree_PrimitiveProp
selection: undefined | DopeSheetSelection selection: undefined | DopeSheetSelection
} }
const SingleKeyframeEditor: React.VFC<ISingleKeyframeEditorProps> = (props) => { const SingleKeyframeEditor: React.VFC<ISingleKeyframeEditorProps> = (props) => {
const {index, trackData} = props const {
index,
keyframe,
track: {data: trackData},
} = props
const cur = trackData.keyframes[index] const cur = trackData.keyframes[index]
const next = trackData.keyframes[index + 1] const next = trackData.keyframes[index + 1]
@ -47,7 +51,7 @@ const SingleKeyframeEditor: React.VFC<ISingleKeyframeEditorProps> = (props) => {
}px))`, }px))`,
}} }}
> >
<SingleKeyframeDot {...props} /> <SingleKeyframeDot {...props} itemKey={props.itemKey} />
{connected ? <SingleKeyframeConnector {...props} /> : noConnector} {connected ? <SingleKeyframeConnector {...props} /> : noConnector}
</SingleKeyframeEditorContainer> </SingleKeyframeEditorContainer>
) )

View file

@ -4,7 +4,11 @@ import type {
SequenceEditorTree_PropWithChildren, SequenceEditorTree_PropWithChildren,
SequenceEditorTree_SheetObject, SequenceEditorTree_SheetObject,
} from '@theatre/studio/panels/SequenceEditorPanel/layout/tree' } 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 { import type {
Keyframe, Keyframe,
TrackData, TrackData,
@ -31,6 +35,7 @@ export type TrackWithId = {
export type KeyframeWithTrack = { export type KeyframeWithTrack = {
kf: Keyframe kf: Keyframe
track: TrackWithId track: TrackWithId
itemKey: StudioSheetItemKey
} }
/** /**
@ -120,7 +125,15 @@ export function collectAggregateKeyframesInPrism(
existing = [] existing = []
byPosition.set(kf.position, existing) byPosition.set(kf.position, existing)
} }
existing.push({kf, track}) existing.push({
kf,
track,
itemKey: createStudioSheetItemKey.forTrackKeyframe(
sheetObject,
track.id,
kf.id,
),
})
} }
} }

View file

@ -1,33 +1,22 @@
import type {StudioSheetItemKey} from '@theatre/shared/utils/ids' import type {StudioSheetItemKey} from '@theatre/shared/utils/ids'
import {createStudioSheetItemKey} from '@theatre/shared/utils/ids'
import getStudio from '@theatre/studio/getStudio' import getStudio from '@theatre/studio/getStudio'
import type {PathToProp} from '@theatre/shared/utils/addresses' import type {
import type SheetObject from '@theatre/core/sheetObjects/SheetObject' SheetAddress,
WithoutSheetInstance,
} from '@theatre/shared/utils/addresses'
export function setCollapsedSheetObjectOrCompoundProp( export function setCollapsedSheetObjectOrCompoundProp(
isCollapsed: boolean, isCollapsed: boolean,
toCollapse: toCollapse: {
| { sheetAddress: WithoutSheetInstance<SheetAddress>
sheetObject: SheetObject sheetItemKey: StudioSheetItemKey
} },
| {
sheetObject: SheetObject
pathToProp: PathToProp
},
) { ) {
const itemKey: StudioSheetItemKey =
'pathToProp' in toCollapse
? createStudioSheetItemKey.forSheetObjectProp(
toCollapse.sheetObject,
toCollapse.pathToProp,
)
: createStudioSheetItemKey.forSheetObject(toCollapse.sheetObject)
getStudio().transaction(({stateEditors}) => { getStudio().transaction(({stateEditors}) => {
stateEditors.studio.ahistoric.projects.stateByProjectId.stateBySheetId.sequence.sequenceEditorCollapsableItems.set( stateEditors.studio.ahistoric.projects.stateByProjectId.stateBySheetId.sequence.sequenceEditorCollapsableItems.set(
{ {
...toCollapse.sheetObject.address, ...toCollapse.sheetAddress,
studioSheetItemKey: itemKey, studioSheetItemKey: toCollapse.sheetItemKey,
isCollapsed, isCollapsed,
}, },
) )

View file

@ -5,6 +5,7 @@ import type {
import type SheetObject from '@theatre/core/sheetObjects/SheetObject' import type SheetObject from '@theatre/core/sheetObjects/SheetObject'
import type {PathToProp} from '@theatre/shared/utils/addresses' import type {PathToProp} from '@theatre/shared/utils/addresses'
import type {SequenceTrackId} from '@theatre/shared/utils/ids' 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 {$IntentionalAny, VoidFn} from '@theatre/shared/utils/types'
import type {Pointer} from '@theatre/dataverse' import type {Pointer} from '@theatre/dataverse'
import React, {useMemo, useRef, useState} from 'react' import React, {useMemo, useRef, useState} from 'react'
@ -96,6 +97,11 @@ const BasicKeyframedTrack: React.VFC<{
<KeyframeEditor <KeyframeEditor
pathToProp={pathToProp} pathToProp={pathToProp}
propConfig={propConfig} propConfig={propConfig}
itemKey={createStudioSheetItemKey.forTrackKeyframe(
sheetObject,
trackId,
kf.id,
)}
keyframe={kf} keyframe={kf}
index={index} index={index}
trackData={trackData} trackData={trackData}

View file

@ -17,6 +17,9 @@ import {
} from '@theatre/studio/uiComponents/PointerEventsHandler' } from '@theatre/studio/uiComponents/PointerEventsHandler'
import DopeSnap from '@theatre/studio/panels/SequenceEditorPanel/RightOverlay/DopeSnap' import DopeSnap from '@theatre/studio/panels/SequenceEditorPanel/RightOverlay/DopeSnap'
import {useSingleKeyframeInlineEditorPopover} from '@theatre/studio/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/useSingleKeyframeInlineEditorPopover' import {useSingleKeyframeInlineEditorPopover} from '@theatre/studio/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/useSingleKeyframeInlineEditorPopover'
import usePresence, {
PresenceFlag,
} from '@theatre/studio/uiComponents/usePresence'
export const dotSize = 6 export const dotSize = 6
@ -58,11 +61,13 @@ type IProps = Parameters<typeof KeyframeEditor>[0] & {which: 'left' | 'right'}
const GraphEditorDotNonScalar: React.VFC<IProps> = (props) => { const GraphEditorDotNonScalar: React.VFC<IProps> = (props) => {
const [ref, node] = useRefAndState<SVGCircleElement | null>(null) const [ref, node] = useRefAndState<SVGCircleElement | null>(null)
const {index, trackData} = props const {index, trackData, itemKey} = props
const cur = trackData.keyframes[index] const cur = trackData.keyframes[index]
const [contextMenu] = useKeyframeContextMenu(node, props) const [contextMenu] = useKeyframeContextMenu(node, props)
const presence = usePresence(itemKey)
const curValue = props.which === 'left' ? 0 : 1 const curValue = props.which === 'left' ? 0 : 1
const [inlineEditorPopover, openEditor, _, _isInlineEditorPopoverOpen] = const [inlineEditorPopover, openEditor, _, _isInlineEditorPopoverOpen] =
@ -93,6 +98,7 @@ const GraphEditorDotNonScalar: React.VFC<IProps> = (props) => {
cx: `calc(var(--unitSpaceToScaledSpaceMultiplier) * ${cur.position} * 1px)`, cx: `calc(var(--unitSpaceToScaledSpaceMultiplier) * ${cur.position} * 1px)`,
cy: `calc((var(--graphEditorVerticalSpace) - var(--graphEditorVerticalSpace) * ${cyInExtremumSpace}) * 1px)`, cy: `calc((var(--graphEditorVerticalSpace) - var(--graphEditorVerticalSpace) * ${cyInExtremumSpace}) * 1px)`,
}} }}
{...presence.attrs}
{...includeLockFrameStampAttrs(cur.position)} {...includeLockFrameStampAttrs(cur.position)}
{...DopeSnap.includePositionSnapAttrs(cur.position)} {...DopeSnap.includePositionSnapAttrs(cur.position)}
className={isDragging ? 'beingDragged' : ''} className={isDragging ? 'beingDragged' : ''}
@ -102,6 +108,7 @@ const GraphEditorDotNonScalar: React.VFC<IProps> = (props) => {
// @ts-ignore // @ts-ignore
cx: `calc(var(--unitSpaceToScaledSpaceMultiplier) * ${cur.position} * 1px)`, cx: `calc(var(--unitSpaceToScaledSpaceMultiplier) * ${cur.position} * 1px)`,
cy: `calc((var(--graphEditorVerticalSpace) - var(--graphEditorVerticalSpace) * ${cyInExtremumSpace}) * 1px)`, cy: `calc((var(--graphEditorVerticalSpace) - var(--graphEditorVerticalSpace) * ${cyInExtremumSpace}) * 1px)`,
fill: presence.flag === PresenceFlag.Primary ? 'white' : undefined,
}} }}
/> />
{inlineEditorPopover} {inlineEditorPopover}

View file

@ -17,6 +17,9 @@ import {
} from '@theatre/studio/uiComponents/PointerEventsHandler' } from '@theatre/studio/uiComponents/PointerEventsHandler'
import DopeSnap from '@theatre/studio/panels/SequenceEditorPanel/RightOverlay/DopeSnap' import DopeSnap from '@theatre/studio/panels/SequenceEditorPanel/RightOverlay/DopeSnap'
import {useSingleKeyframeInlineEditorPopover} from '@theatre/studio/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/useSingleKeyframeInlineEditorPopover' import {useSingleKeyframeInlineEditorPopover} from '@theatre/studio/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/useSingleKeyframeInlineEditorPopover'
import usePresence, {
PresenceFlag,
} from '@theatre/studio/uiComponents/usePresence'
export const dotSize = 6 export const dotSize = 6
@ -60,9 +63,9 @@ const GraphEditorDotScalar: React.VFC<IProps> = (props) => {
const {index, trackData} = props const {index, trackData} = props
const cur = trackData.keyframes[index] const cur = trackData.keyframes[index]
const next = trackData.keyframes[index + 1]
const [contextMenu] = useKeyframeContextMenu(node, props) const [contextMenu] = useKeyframeContextMenu(node, props)
const presence = usePresence(props.itemKey)
const curValue = cur.value as number const curValue = cur.value as number
@ -95,6 +98,7 @@ const GraphEditorDotScalar: React.VFC<IProps> = (props) => {
}} }}
{...includeLockFrameStampAttrs(cur.position)} {...includeLockFrameStampAttrs(cur.position)}
{...DopeSnap.includePositionSnapAttrs(cur.position)} {...DopeSnap.includePositionSnapAttrs(cur.position)}
{...presence.attrs}
className={isDragging ? 'beingDragged' : ''} className={isDragging ? 'beingDragged' : ''}
/> />
<Circle <Circle
@ -102,6 +106,7 @@ const GraphEditorDotScalar: React.VFC<IProps> = (props) => {
// @ts-ignore // @ts-ignore
cx: `calc(var(--unitSpaceToScaledSpaceMultiplier) * ${cur.position} * 1px)`, cx: `calc(var(--unitSpaceToScaledSpaceMultiplier) * ${cur.position} * 1px)`,
cy: `calc((var(--graphEditorVerticalSpace) - var(--graphEditorVerticalSpace) * ${cyInExtremumSpace}) * 1px)`, cy: `calc((var(--graphEditorVerticalSpace) - var(--graphEditorVerticalSpace) * ${cyInExtremumSpace}) * 1px)`,
fill: presence.flag === PresenceFlag.Primary ? 'white' : undefined,
}} }}
/> />
{inlineEditorPopover} {inlineEditorPopover}

View file

@ -4,7 +4,10 @@ import type {
} from '@theatre/core/projects/store/types/SheetState_Historic' } from '@theatre/core/projects/store/types/SheetState_Historic'
import type SheetObject from '@theatre/core/sheetObjects/SheetObject' import type SheetObject from '@theatre/core/sheetObjects/SheetObject'
import type {SequenceEditorPanelLayout} from '@theatre/studio/panels/SequenceEditorPanel/layout/layout' 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 type {Pointer} from '@theatre/dataverse'
import React from 'react' import React from 'react'
import styled from 'styled-components' import styled from 'styled-components'
@ -28,6 +31,7 @@ type IKeyframeEditorProps = {
index: number index: number
keyframe: Keyframe keyframe: Keyframe
trackData: TrackData trackData: TrackData
itemKey: StudioSheetItemKey
layoutP: Pointer<SequenceEditorPanelLayout> layoutP: Pointer<SequenceEditorPanelLayout>
trackId: SequenceTrackId trackId: SequenceTrackId
sheetObject: SheetObject sheetObject: SheetObject

View file

@ -3,7 +3,7 @@ import {usePrism} from '@theatre/react'
import {valToAtom} from '@theatre/shared/utils/valToAtom' import {valToAtom} from '@theatre/shared/utils/valToAtom'
import type {Pointer} from '@theatre/dataverse' import type {Pointer} from '@theatre/dataverse'
import {prism, val} from '@theatre/dataverse' import {prism, val} from '@theatre/dataverse'
import React from 'react' import React, {useState} from 'react'
import styled from 'styled-components' import styled from 'styled-components'
import DopeSheet from './DopeSheet/DopeSheet' import DopeSheet from './DopeSheet/DopeSheet'
@ -28,6 +28,7 @@ import {
TitleBar_Punctuation, TitleBar_Punctuation,
} from '@theatre/studio/panels/BasePanel/common' } from '@theatre/studio/panels/BasePanel/common'
import type {UIPanelId} from '@theatre/shared/utils/ids' import type {UIPanelId} from '@theatre/shared/utils/ids'
import {usePresenceListenersOnRootElement} from '@theatre/studio/uiComponents/usePresence'
const Container = styled(PanelWrapper)` const Container = styled(PanelWrapper)`
z-index: ${panelZIndexes.sequenceEditorPanel}; z-index: ${panelZIndexes.sequenceEditorPanel};
@ -99,7 +100,10 @@ const SequenceEditorPanel: React.VFC<{}> = (props) => {
const Content: React.VFC<{}> = () => { const Content: React.VFC<{}> = () => {
const {dims} = usePanel() const {dims} = usePanel()
const [containerNode, setContainerNode] = useState<null | HTMLDivElement>(
null,
)
usePresenceListenersOnRootElement(containerNode)
return usePrism(() => { return usePrism(() => {
const panelSize = prism.memo( const panelSize = prism.memo(
'panelSize', 'panelSize',
@ -161,7 +165,14 @@ const Content: React.VFC<{}> = () => {
const graphEditorOpen = val(layoutP.graphEditorDims.isOpen) const graphEditorOpen = val(layoutP.graphEditorDims.isOpen)
return ( return (
<Container ref={containerRef}> <Container
ref={(elt) => {
containerRef(elt as HTMLDivElement)
if (elt !== containerNode) {
setContainerNode(elt as HTMLDivElement)
}
}}
>
<LeftBackground style={{width: `${val(layoutP.leftDims.width)}px`}} /> <LeftBackground style={{width: `${val(layoutP.leftDims.width)}px`}} />
<FrameStampPositionProvider layoutP={layoutP}> <FrameStampPositionProvider layoutP={layoutP}>
<Header layoutP={layoutP} /> <Header layoutP={layoutP} />
@ -174,7 +185,7 @@ const Content: React.VFC<{}> = () => {
</FrameStampPositionProvider> </FrameStampPositionProvider>
</Container> </Container>
) )
}, [dims]) }, [dims, containerNode])
} }
const Header: React.FC<{layoutP: Pointer<SequenceEditorPanelLayout>}> = ({ const Header: React.FC<{layoutP: Pointer<SequenceEditorPanelLayout>}> = ({

View file

@ -8,7 +8,10 @@ import type SheetObject from '@theatre/core/sheetObjects/SheetObject'
import type {IPropPathToTrackIdTree} from '@theatre/core/sheetObjects/SheetObjectTemplate' import type {IPropPathToTrackIdTree} from '@theatre/core/sheetObjects/SheetObjectTemplate'
import type Sheet from '@theatre/core/sheets/Sheet' import type Sheet from '@theatre/core/sheets/Sheet'
import type {PathToProp} from '@theatre/shared/utils/addresses' 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 {createStudioSheetItemKey} from '@theatre/shared/utils/ids'
import type {$FixMe, $IntentionalAny} from '@theatre/shared/utils/types' import type {$FixMe, $IntentionalAny} from '@theatre/shared/utils/types'
import {prism, val, valueDerivation} from '@theatre/dataverse' import {prism, val, valueDerivation} from '@theatre/dataverse'
@ -30,6 +33,8 @@ export type SequenceEditorTree_Row<TypeName extends string> = {
/** Visual indentation */ /** Visual indentation */
depth: number 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, * This is a part of the tree, but it is not rendered at all,
* and it doesn't contribute to height. * and it doesn't contribute to height.
@ -107,6 +112,7 @@ export const calculateSequenceEditorTree = (
type: 'sheet', type: 'sheet',
sheet, sheet,
children: [], children: [],
sheetItemKey: createStudioSheetItemKey.forSheet(),
shouldRender: rootShouldRender, shouldRender: rootShouldRender,
top: topSoFar, top: topSoFar,
depth: -1, depth: -1,
@ -151,6 +157,7 @@ export const calculateSequenceEditorTree = (
const row: SequenceEditorTree_SheetObject = { const row: SequenceEditorTree_SheetObject = {
type: 'sheetObject', type: 'sheetObject',
isCollapsed, isCollapsed,
sheetItemKey: createStudioSheetItemKey.forSheetObject(sheetObject),
shouldRender, shouldRender,
top: topSoFar, top: topSoFar,
children: [], children: [],
@ -270,6 +277,10 @@ export const calculateSequenceEditorTree = (
type: 'propWithChildren', type: 'propWithChildren',
isCollapsed, isCollapsed,
pathToProp, pathToProp,
sheetItemKey: createStudioSheetItemKey.forSheetObjectProp(
sheetObject,
pathToProp,
),
sheetObject: sheetObject, sheetObject: sheetObject,
shouldRender, shouldRender,
top: topSoFar, top: topSoFar,
@ -316,6 +327,10 @@ export const calculateSequenceEditorTree = (
type: 'primitiveProp', type: 'primitiveProp',
propConf: propConf, propConf: propConf,
depth: level, depth: level,
sheetItemKey: createStudioSheetItemKey.forSheetObjectProp(
sheetObject,
pathToProp,
),
sheetObject: sheetObject, sheetObject: sheetObject,
pathToProp, pathToProp,
shouldRender, shouldRender,

View file

@ -1,14 +1,25 @@
import type {Keyframe} from '@theatre/core/projects/store/types/SheetState_Historic' 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 type {VoidFn} from '@theatre/shared/utils/types'
import {pointerEventsAutoInNormalMode} from '@theatre/studio/css' import {pointerEventsAutoInNormalMode} from '@theatre/studio/css'
import {transparentize} from 'polished' import {transparentize} from 'polished'
import React from 'react' import React from 'react'
import styled, {css} from 'styled-components' import styled, {css} from 'styled-components'
import {PresenceFlag} from '@theatre/studio/uiComponents/usePresence'
import usePresence from '@theatre/studio/uiComponents/usePresence'
export type NearbyKeyframesControls = { export type NearbyKeyframesControls = {
prev?: Pick<Keyframe, 'position'> & {jump: VoidFn} prev?: Pick<Keyframe, 'position'> & {
cur: {type: 'on'; toggle: VoidFn} | {type: 'off'; toggle: VoidFn} jump: VoidFn
next?: Pick<Keyframe, 'position'> & {jump: VoidFn} itemKey: StudioSheetItemKey
}
cur:
| {type: 'on'; toggle: VoidFn; itemKey: StudioSheetItemKey}
| {type: 'off'; toggle: VoidFn}
next?: Pick<Keyframe, 'position'> & {
jump: VoidFn
itemKey: StudioSheetItemKey
}
} }
const Container = styled.div` const Container = styled.div`
@ -70,21 +81,34 @@ export const nextPrevCursorsTheme = {
onColor: '#e0c917', onColor: '#e0c917',
} }
const CurButton = styled(Button)<{isOn: boolean}>` const CurButton = styled(Button)<{
isOn: boolean
presence: PresenceFlag | undefined
}>`
&:hover { &:hover {
color: #e0c917; color: #e0c917;
} }
color: ${(props) => color: ${(props) =>
props.isOn ? nextPrevCursorsTheme.onColor : nextPrevCursorsTheme.offColor}; props.presence === PresenceFlag.Primary
? 'white'
: props.isOn
? nextPrevCursorsTheme.onColor
: nextPrevCursorsTheme.offColor};
` `
const pointerEventsNone = css` const pointerEventsNone = css`
pointer-events: none !important; pointer-events: none !important;
` `
const PrevOrNextButton = styled(Button)<{available: boolean}>` const PrevOrNextButton = styled(Button)<{
available: boolean
flag: PresenceFlag | undefined
}>`
color: ${(props) => color: ${(props) =>
props.available props.flag === PresenceFlag.Primary
? 'white'
: props.available
? nextPrevCursorsTheme.onColor ? nextPrevCursorsTheme.onColor
: nextPrevCursorsTheme.offColor}; : nextPrevCursorsTheme.offColor};
@ -92,15 +116,20 @@ const PrevOrNextButton = styled(Button)<{available: boolean}>`
props.available ? pointerEventsAutoInNormalMode : pointerEventsNone}; props.available ? pointerEventsAutoInNormalMode : pointerEventsNone};
` `
const Prev = styled(PrevOrNextButton)<{available: boolean}>` const Prev = styled(PrevOrNextButton)<{
available: boolean
flag: PresenceFlag | undefined
}>`
transform: translateX(2px); transform: translateX(2px);
${Container}:hover & { ${Container}:hover & {
transform: translateX(-7px); transform: translateX(-7px);
} }
` `
const Next = styled(PrevOrNextButton)<{available: boolean}>` const Next = styled(PrevOrNextButton)<{
available: boolean
flag: PresenceFlag | undefined
}>`
transform: translateX(-2px); transform: translateX(-2px);
${Container}:hover & { ${Container}:hover & {
transform: translateX(7px); transform: translateX(7px);
} }
@ -165,16 +194,37 @@ namespace Icons {
) )
} }
const NextPrevKeyframeCursors: React.FC<NearbyKeyframesControls> = (props) => { const NextPrevKeyframeCursors: React.VFC<NearbyKeyframesControls> = (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 ( return (
<Container> <Container>
<Prev available={!!props.prev} onClick={props.prev?.jump}> <Prev
available={!!props.prev}
onClick={props.prev?.jump}
flag={prevPresence.flag}
{...prevPresence.attrs}
>
<Icons.Prev /> <Icons.Prev />
</Prev> </Prev>
<CurButton isOn={props.cur.type === 'on'} onClick={props.cur.toggle}> <CurButton
isOn={props.cur.type === 'on'}
onClick={props.cur.toggle}
presence={curPresence.flag}
{...curPresence.attrs}
>
<Icons.Cur /> <Icons.Cur />
</CurButton> </CurButton>
<Next available={!!props.next} onClick={props.next?.jump}> <Next
available={!!props.next}
onClick={props.next?.jump}
flag={nextPresence.flag}
{...nextPresence.attrs}
>
<Icons.Next /> <Icons.Next />
</Next> </Next>
</Container> </Container>

View file

@ -2,7 +2,12 @@ import type {
TrackData, TrackData,
Keyframe, Keyframe,
} from '@theatre/core/projects/store/types/SheetState_Historic' } 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< const cache = new WeakMap<
TrackData, TrackData,
@ -12,48 +17,67 @@ const cache = new WeakMap<
const noKeyframes: NearbyKeyframes = {} const noKeyframes: NearbyKeyframes = {}
export function getNearbyKeyframesOfTrack( export function getNearbyKeyframesOfTrack(
track: TrackData | undefined, obj: SheetObject,
track: TrackWithId | undefined,
sequencePosition: number, sequencePosition: number,
): NearbyKeyframes { ): 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) { if (cachedItem && cachedItem[0] === sequencePosition) {
return cachedItem[1] return cachedItem[1]
} }
const calculate = (): NearbyKeyframes => { function getKeyframeWithTrackId(idx: number): KeyframeWithTrack | undefined {
const i = track.keyframes.findIndex((kf) => kf.position >= sequencePosition) if (!track) return
const found = track.data.keyframes[idx]
if (i === -1) return (
return { found && {
prev: last(track.keyframes), kf: found,
track,
itemKey: createStudioSheetItemKey.forTrackKeyframe(
obj,
track.id,
found.id,
),
} }
)
}
const k = track.keyframes[i]! const calculate = (): NearbyKeyframes => {
if (k.position === sequencePosition) { const nextOrCurIdx = track.data.keyframes.findIndex(
(kf) => kf.position >= sequencePosition,
)
if (nextOrCurIdx === -1) {
return { return {
prev: i > 0 ? track.keyframes[i - 1] : undefined, prev: getKeyframeWithTrackId(track.data.keyframes.length - 1),
cur: k, }
next: }
i === track.keyframes.length - 1 ? undefined : track.keyframes[i + 1],
const nextOrCur = getKeyframeWithTrackId(nextOrCurIdx)!
if (nextOrCur.kf.position === sequencePosition) {
return {
prev: getKeyframeWithTrackId(nextOrCurIdx - 1),
cur: nextOrCur,
next: getKeyframeWithTrackId(nextOrCurIdx + 1),
} }
} else { } else {
return { return {
next: k, next: nextOrCur,
prev: i > 0 ? track.keyframes[i - 1] : undefined, prev: getKeyframeWithTrackId(nextOrCurIdx - 1),
} }
} }
} }
const result = calculate() const result = calculate()
cache.set(track, [sequencePosition, result]) cache.set(track.data, [sequencePosition, result])
return result return result
} }
export type NearbyKeyframes = { export type NearbyKeyframes = {
prev?: Keyframe prev?: KeyframeWithTrack
cur?: Keyframe cur?: KeyframeWithTrack
next?: Keyframe next?: KeyframeWithTrack
} }

View file

@ -19,11 +19,13 @@ import {
iteratePropType, iteratePropType,
} from '@theatre/shared/propTypes/utils' } from '@theatre/shared/propTypes/utils'
import type {SequenceTrackId} from '@theatre/shared/utils/ids' import type {SequenceTrackId} from '@theatre/shared/utils/ids'
import {createStudioSheetItemKey} from '@theatre/shared/utils/ids'
import type {IPropPathToTrackIdTree} from '@theatre/core/sheetObjects/SheetObjectTemplate' import type {IPropPathToTrackIdTree} from '@theatre/core/sheetObjects/SheetObjectTemplate'
import pointerDeep from '@theatre/shared/utils/pointerDeep' import pointerDeep from '@theatre/shared/utils/pointerDeep'
import type {NearbyKeyframesControls} from './NextPrevKeyframeCursors' import type {NearbyKeyframesControls} from './NextPrevKeyframeCursors'
import NextPrevKeyframeCursors from './NextPrevKeyframeCursors' import NextPrevKeyframeCursors from './NextPrevKeyframeCursors'
import {getNearbyKeyframesOfTrack} from './getNearbyKeyframesOfTrack' import {getNearbyKeyframesOfTrack} from './getNearbyKeyframesOfTrack'
import type {KeyframeWithTrack} from '@theatre/studio/panels/SequenceEditorPanel/DopeSheet/Right/collectAggregateKeyframes'
interface CommonStuff { interface CommonStuff {
beingScrubbed: boolean beingScrubbed: boolean
@ -212,7 +214,11 @@ export function useEditingToolsForCompoundProp<T extends SerializablePrimitive>(
.filter(({track}) => !!track) .filter(({track}) => !!track)
.map((s) => ({ .map((s) => ({
...s, ...s,
nearbies: getNearbyKeyframesOfTrack(s.track, sequencePosition), nearbies: getNearbyKeyframesOfTrack(
obj,
{id: s.trackId, data: s.track!},
sequencePosition,
),
})) }))
const hasCur = nearbyKeyframesInEachTrack.find( const hasCur = nearbyKeyframesInEachTrack.find(
@ -223,13 +229,16 @@ export function useEditingToolsForCompoundProp<T extends SerializablePrimitive>(
) )
const closestPrev = nearbyKeyframesInEachTrack.reduce< const closestPrev = nearbyKeyframesInEachTrack.reduce<
undefined | number undefined | KeyframeWithTrack
>((acc, s) => { >((acc, s) => {
if (s.nearbies.prev) { if (s.nearbies.prev) {
if (acc === undefined) { if (
return s.nearbies.prev.position acc === undefined ||
s.nearbies.prev.kf.position > acc.kf.position
) {
return s.nearbies.prev
} else { } else {
return Math.max(s.nearbies.prev.position, acc) return acc
} }
} else { } else {
return acc return acc
@ -237,53 +246,80 @@ export function useEditingToolsForCompoundProp<T extends SerializablePrimitive>(
}, undefined) }, undefined)
const closestNext = nearbyKeyframesInEachTrack.reduce< const closestNext = nearbyKeyframesInEachTrack.reduce<
undefined | number undefined | KeyframeWithTrack
>((acc, s) => { >((acc, s) => {
if (s.nearbies.next) { if (s.nearbies.next) {
if (acc === undefined) { if (
return s.nearbies.next.position acc === undefined ||
s.nearbies.next.kf.position < acc.kf.position
) {
return s.nearbies.next
} else { } else {
return Math.min(s.nearbies.next.position, acc) return acc
} }
} else { } else {
return acc return acc
} }
}, undefined) }, 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 { return {
cur: { cur: hasCur
type: hasCur ? 'on' : 'off', ? {
toggle: () => { type: 'on',
if (allCur) { itemKey:
getStudio().transaction((api) => { createStudioSheetItemKey.forCompoundPropAggregateKeyframe(
api.unset(pointerToProp) obj,
}) pathToProp,
} else if (hasCur) { sequencePosition,
getStudio().transaction((api) => { ),
api.set(pointerToProp, val(pointerToProp)) toggle,
})
} else {
getStudio().transaction((api) => {
api.set(pointerToProp, val(pointerToProp))
})
} }
}, : {
}, toggle,
type: 'off',
},
prev: prev:
closestPrev !== undefined closestPrev !== undefined
? { ? {
position: closestPrev, position: closestPrev.kf.position,
itemKey:
createStudioSheetItemKey.forCompoundPropAggregateKeyframe(
obj,
pathToProp,
closestPrev.kf.position,
),
jump: () => { jump: () => {
obj.sheet.getSequence().position = closestPrev obj.sheet.getSequence().position = closestPrev.kf.position
}, },
} }
: undefined, : undefined,
next: next:
closestNext !== undefined closestNext !== undefined
? { ? {
position: closestNext, position: closestNext.kf.position,
itemKey:
createStudioSheetItemKey.forCompoundPropAggregateKeyframe(
obj,
pathToProp,
closestNext.kf.position,
),
jump: () => { jump: () => {
obj.sheet.getSequence().position = closestNext obj.sheet.getSequence().position = closestNext.kf.position
}, },
} }
: undefined, : undefined,

View file

@ -172,7 +172,14 @@ export function useEditingToolsForSimplePropInDetailsPanel<
const sequencePosition = val( const sequencePosition = val(
obj.sheet.getSequence().positionDerivation, obj.sheet.getSequence().positionDerivation,
) )
return getNearbyKeyframesOfTrack(track, sequencePosition) return getNearbyKeyframesOfTrack(
obj,
track && {
data: track,
id: sequenceTrackId,
},
sequencePosition,
)
}, },
[sequenceTrackId], [sequenceTrackId],
) )
@ -184,45 +191,54 @@ export function useEditingToolsForSimplePropInDetailsPanel<
} else { } else {
if (nearbyKeyframes.cur) { if (nearbyKeyframes.cur) {
shade = 'Sequenced_OnKeyframe' shade = 'Sequenced_OnKeyframe'
} else if (nearbyKeyframes.prev?.connectedRight === true) { } else if (nearbyKeyframes.prev?.kf.connectedRight === true) {
shade = 'Sequenced_BeingInterpolated' shade = 'Sequenced_BeingInterpolated'
} else { } else {
shade = 'Sequened_NotBeingInterpolated' 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 = { const controls: NearbyKeyframesControls = {
cur: { cur: nearbyKeyframes.cur
type: nearbyKeyframes.cur ? 'on' : 'off', ? {
toggle: () => { type: 'on',
if (nearbyKeyframes.cur) { itemKey: nearbyKeyframes.cur.itemKey,
getStudio()!.transaction((api) => { toggle,
api.unset(pointerToProp)
})
} else {
getStudio()!.transaction((api) => {
api.set(pointerToProp, common.value)
})
} }
}, : {
}, type: 'off',
toggle,
},
prev: prev:
nearbyKeyframes.prev !== undefined nearbyKeyframes.prev !== undefined
? { ? {
position: nearbyKeyframes.prev.position, itemKey: nearbyKeyframes.prev.itemKey,
position: nearbyKeyframes.prev.kf.position,
jump: () => { jump: () => {
obj.sheet.getSequence().position = obj.sheet.getSequence().position =
nearbyKeyframes.prev!.position nearbyKeyframes.prev!.kf.position
}, },
} }
: undefined, : undefined,
next: next:
nearbyKeyframes.next !== undefined nearbyKeyframes.next !== undefined
? { ? {
position: nearbyKeyframes.next.position, itemKey: nearbyKeyframes.next.itemKey,
position: nearbyKeyframes.next.kf.position,
jump: () => { jump: () => {
obj.sheet.getSequence().position = obj.sheet.getSequence().position =
nearbyKeyframes.next!.position nearbyKeyframes.next!.kf.position
}, },
} }
: undefined, : undefined,

View file

@ -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<StudioSheetItemKey | undefined>(
undefined,
)
const currentUserHoverFlagItemsAtom = new Atom(
{} as StrictRecord<StudioSheetItemKey, boolean>,
)
// keep as part of presence creation
const relationsAtom = new Atom(
{} as StrictRecord<
StudioSheetItemKey,
StrictRecord<
StudioSheetItemKey,
StrictRecord<string, {flag: PresenceFlag}>
>
>,
)
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<FlagRelationConfig>,
): () => void
}
const presenceInternalCtx = React.createContext<InternalPresenceContext>(
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<FlagRelationConfig>, 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])
}

View file

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