feature/2022-05/display aggregate keyframes in sequence editor (#170)

Co-authored-by: Fülöp <fulopkovacs@users.noreply.github.com>
Co-authored-by: Fülöp Kovács <kovacs.fulop@gmail.com>
This commit is contained in:
Cole Lawrence 2022-05-26 08:10:54 -04:00 committed by GitHub
parent d83d2b558c
commit e8c440f357
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
41 changed files with 1410 additions and 462 deletions

View file

@ -1,3 +1,4 @@
import type {PathToProp_Encoded} from '@theatre/shared/utils/addresses'
import type {
KeyframeId,
ObjectAddressKey,
@ -26,6 +27,15 @@ export interface SheetState_Historic {
// Question: What is this? The timeline position of a sequence?
export type HistoricPositionalSequence = {
type: 'PositionalSequence'
/**
* This is the length of the sequence in unit position. If the sequence
* is interpreted in seconds, then a length=2 means the sequence is two
* seconds long.
*
* Note that if there are keyframes sitting after sequence.length, they don't
* get truncated, but calling sequence.play() will play until it reaches the
* length of the sequence.
*/
length: number
/**
* Given the most common case of tracking a sequence against time (where 1 second = position 1),
@ -37,12 +47,28 @@ export type HistoricPositionalSequence = {
tracksByObject: StrictRecord<
ObjectAddressKey,
{
trackIdByPropPath: StrictRecord<string, SequenceTrackId>
// I think this prop path has to be to a basic keyframe track (simple prop)
// at least until we have other kinds of "TrackData".
// Explicitly, this does not include prop paths for compound props (those
// are sequenced by sequenecing their simple descendant props)
trackIdByPropPath: StrictRecord<PathToProp_Encoded, SequenceTrackId>
/**
* A flat record of SequenceTrackId to TrackData. It's better
* that only its sub-props are observed (say via val(pointer(...))),
* rather than the object as a whole.
*/
trackData: StrictRecord<SequenceTrackId, TrackData>
}
>
}
/**
* Currently just {@link BasicKeyframedTrack}.
*
* Future: Other types of tracks can be added in, such as `MixedTrack` which would
* look like `[keyframes, expression, moreKeyframes, anotherExpression, …]`.
*/
export type TrackData = BasicKeyframedTrack
export type Keyframe = {
@ -56,8 +82,17 @@ export type Keyframe = {
connectedRight: boolean
}
export type BasicKeyframedTrack = {
type: 'BasicKeyframedTrack'
type TrackDataCommon<TypeName extends string> = {
type: TypeName
/**
* Initial name of the track for debugging purposes. In the future, let's
* strip this value from `studio.createContentOfSaveFile()` Could also be
* useful for users who manually edit the project state.
*/
__debugName?: string
}
export type BasicKeyframedTrack = TrackDataCommon<'BasicKeyframedTrack'> & {
/**
* {@link Keyframe} is not provided an explicit generic value `T`, because
* a single track can technically have multiple different types for each keyframe.

View file

@ -64,7 +64,7 @@ export default class SheetObject implements IdentityDerivationProvider {
template.address.objectKey,
)
this._logger._trace('creating object')
this._internalUtilCtx = {logger: this._logger.downgrade.internal()}
this._internalUtilCtx = {logger: this._logger.utilFor.internal()}
this.address = {
...template.address,
sheetInstanceId: sheet.address.sheetInstanceId,

View file

@ -29,8 +29,24 @@ import {
} from '@theatre/shared/propTypes/utils'
import getOrderingOfPropTypeConfig from './getOrderingOfPropTypeConfig'
/**
* Given an object like: `{transform: {type: 'absolute', position: {x: 0}}}`,
* if both `transform.type` and `transform.position.x` are sequenced, this
* type would look like:
*
* ```ts
* {
* transform: {
* type: 'SDFJSDFJ', // track id of transform.type
* position: {
* x: 'NCXNS' // track id of transform.position.x
* }
* }
* }
* ```
*/
export type IPropPathToTrackIdTree = {
[key in string]?: SequenceTrackId | IPropPathToTrackIdTree
[propName in string]?: SequenceTrackId | IPropPathToTrackIdTree
}
/**

View file

@ -79,8 +79,8 @@ function setupFn(options: ITheatreInternalLoggerOptions) {
named(name: string, key?: string | number) {
return t(logger.named(name, key))
},
downgrade: objMap(
logger.downgrade,
utilFor: objMap(
logger.utilFor,
([audience, downgradeFn]) =>
() =>
setupUtilLogger(downgradeFn(), audience, con),
@ -146,7 +146,7 @@ type TestLoggerIncludes = ((string | RegExp) | {not: string | RegExp})[]
function setupUtilLogger(
logger: IUtilLogger,
audience: keyof ILogger['downgrade'],
audience: keyof ILogger['utilFor'],
con: jest.Mocked<ITheatreConsoleLogger>,
) {
return {

View file

@ -159,11 +159,11 @@ describeLogger('Theatre internal logger', (setup) => {
t.expectIncluded('warnPublic', 'warn', [{not: 'color:'}, 'Test1'])
})
})
describe('downgrade', () => {
test('.downgrade.public() with defaults', () => {
describe('utilFor', () => {
test('.utilFor.public() with defaults', () => {
const h = setup()
const publ = h.t().downgrade.public()
const publ = h.t().utilFor.public()
publ.expectIncluded('error', 'error')
publ.expectIncluded('warn', 'warn')
@ -171,10 +171,10 @@ describeLogger('Theatre internal logger', (setup) => {
publ.expectExcluded('trace')
})
test('.downgrade.dev() with defaults', () => {
test('.utilFor.dev() with defaults', () => {
const h = setup()
const dev = h.t().downgrade.dev()
const dev = h.t().utilFor.dev()
dev.expectExcluded('error')
dev.expectExcluded('warn')
@ -182,10 +182,10 @@ describeLogger('Theatre internal logger', (setup) => {
dev.expectExcluded('trace')
})
test('.downgrade.internal() with defaults', () => {
test('.utilFor.internal() with defaults', () => {
const h = setup()
const internal = h.t().downgrade.internal()
const internal = h.t().utilFor.internal()
internal.expectExcluded('error')
internal.expectExcluded('warn')
@ -193,7 +193,7 @@ describeLogger('Theatre internal logger', (setup) => {
internal.expectExcluded('trace')
})
test('.downgrade.internal() can be named', () => {
test('.utilFor.internal() can be named', () => {
const h = setup()
h.internal.configureLogging({
@ -201,7 +201,7 @@ describeLogger('Theatre internal logger', (setup) => {
min: TheatreLoggerLevel.TRACE,
})
const internal = h.t().downgrade.internal()
const internal = h.t().utilFor.internal()
const appleInternal = internal.named('Apple')
internal.expectIncluded('error', 'error', [{not: 'Apple'}])
@ -215,13 +215,13 @@ describeLogger('Theatre internal logger', (setup) => {
appleInternal.expectIncluded('trace', 'debug', ['Apple'])
})
test('.downgrade.public() debug/trace warns internal', () => {
test('.utilFor.public() debug/trace warns internal', () => {
const h = setup()
{
h.internal.configureLogging({
internal: true,
})
const publ = h.t().downgrade.public()
const publ = h.t().utilFor.public()
publ.expectIncluded('error', 'error', [{not: 'filtered out'}])
publ.expectIncluded('warn', 'warn', [{not: 'filtered out'}])
@ -235,7 +235,7 @@ describeLogger('Theatre internal logger', (setup) => {
h.internal.configureLogging({
dev: true,
})
const publ = h.t().downgrade.public()
const publ = h.t().utilFor.public()
publ.expectIncluded('error', 'error', [{not: /filtered out/}])
publ.expectIncluded('warn', 'warn', [{not: /filtered out/}])

View file

@ -68,11 +68,13 @@ export type _LazyLogFns = Readonly<
}
>
/** Internal library logger */
/** Internal library logger
* TODO document these fns
*/
export interface ILogger extends _LogFns {
named(name: string, key?: string | number): ILogger
lazy: _LazyLogFns
readonly downgrade: {
readonly utilFor: {
internal(): IUtilLogger
dev(): IUtilLogger
public(): IUtilLogger
@ -537,7 +539,7 @@ function createExtLogger(
},
//
named,
downgrade: {
utilFor: {
internal() {
return {
debug: logger._debug,
@ -545,7 +547,7 @@ function createExtLogger(
warn: logger._warn,
trace: logger._trace,
named(name, key) {
return logger.named(name, key).downgrade.internal()
return logger.named(name, key).utilFor.internal()
},
}
},
@ -556,7 +558,7 @@ function createExtLogger(
warn: logger.warnDev,
trace: logger.traceDev,
named(name, key) {
return logger.named(name, key).downgrade.dev()
return logger.named(name, key).utilFor.dev()
},
}
},
@ -571,7 +573,7 @@ function createExtLogger(
logger._warn(`(public "trace" filtered out) ${message}`, obj)
},
named(name, key) {
return logger.named(name, key).downgrade.public()
return logger.named(name, key).utilFor.public()
},
}
},
@ -751,7 +753,7 @@ function _createConsoleLogger(
},
//
named,
downgrade: {
utilFor: {
internal() {
return {
debug: logger._debug,
@ -759,7 +761,7 @@ function _createConsoleLogger(
warn: logger._warn,
trace: logger._trace,
named(name, key) {
return logger.named(name, key).downgrade.internal()
return logger.named(name, key).utilFor.internal()
},
}
},
@ -770,7 +772,7 @@ function _createConsoleLogger(
warn: logger.warnDev,
trace: logger.traceDev,
named(name, key) {
return logger.named(name, key).downgrade.dev()
return logger.named(name, key).utilFor.dev()
},
}
},
@ -785,7 +787,7 @@ function _createConsoleLogger(
logger._warn(`(public "trace" filtered out) ${message}`, obj)
},
named(name, key) {
return logger.named(name, key).downgrade.public()
return logger.named(name, key).utilFor.public()
},
}
},

View file

@ -34,4 +34,4 @@ internal.configureLogging({
export default internal
.getLogger()
.named('Theatre.js (default logger)')
.downgrade.dev()
.utilFor.dev()

View file

@ -4,6 +4,8 @@ import type {
SerializableValue,
} from '@theatre/shared/utils/types'
import type {ObjectAddressKey, ProjectId, SheetId, SheetInstanceId} from './ids'
import memoizeFn from './memoizeFn'
import type {Nominal} from './Nominal'
/**
* Represents the address to a project
@ -57,10 +59,12 @@ export interface SheetObjectAddress extends SheetAddress {
export type PathToProp = Array<string | number>
export type PathToProp_Encoded = string
export type PathToProp_Encoded = Nominal<'PathToProp_Encoded'>
export const encodePathToProp = (p: PathToProp): PathToProp_Encoded =>
JSON.stringify(p)
export const encodePathToProp = memoizeFn(
(p: PathToProp): PathToProp_Encoded =>
JSON.stringify(p) as PathToProp_Encoded,
)
export const decodePathToProp = (s: PathToProp_Encoded): PathToProp =>
JSON.parse(s)

View file

@ -1,5 +1,6 @@
import React, {useContext, useEffect, useMemo} from 'react'
import type {$IntentionalAny} from '@theatre/shared/utils/types'
import {useLogger} from '@theatre/studio/uiComponents/useLogger'
/** See {@link PointerCapturing} */
export type CapturedPointer = {
@ -35,6 +36,7 @@ type PointerCapturingFn = (forDebugName: string) => InternalPointerCapturing
// const logger = console
function _usePointerCapturingContext(): PointerCapturingFn {
const logger = useLogger('PointerCapturing')
type CaptureInfo = {
debugOwnerName: string
debugReason: string
@ -51,7 +53,7 @@ function _usePointerCapturingContext(): PointerCapturingFn {
}
const capturing: PointerCapturing = {
capturePointer(reason) {
// logger.log('Capturing pointer', {forDebugName, reason})
logger._debug('Capturing pointer', {forDebugName, reason})
if (currentCaptureRef.current != null) {
throw new Error(
`"${forDebugName}" attempted capturing pointer for "${reason}" while already captured by "${currentCaptureRef.current.debugOwnerName}" for "${currentCaptureRef.current.debugReason}"`,
@ -69,10 +71,10 @@ function _usePointerCapturingContext(): PointerCapturingFn {
},
release() {
if (releaseCapture === currentCaptureRef.current) {
// logger.log('Releasing pointer', {
// forDebugName,
// reason,
// })
logger._debug('Releasing pointer', {
forDebugName,
reason,
})
updateCapture(null)
return true
}
@ -89,7 +91,7 @@ function _usePointerCapturingContext(): PointerCapturingFn {
capturing,
forceRelease() {
if (currentCaptureRef.current === localCapture) {
// logger.log('Force releasing pointer', currentCaptureRef.current)
logger._debug('Force releasing pointer', {localCapture})
updateCapture(null)
}
},
@ -100,7 +102,6 @@ function _usePointerCapturingContext(): PointerCapturingFn {
const PointerCapturingContext = React.createContext<PointerCapturingFn>(
null as $IntentionalAny,
)
// const ProviderChildren: React.FC<{children?: React.ReactNode}> = function
const ProviderChildrenMemo: React.FC<{}> = React.memo(({children}) => (
<>{children}</>

View file

@ -14,6 +14,11 @@ import TooltipContext from '@theatre/studio/uiComponents/Popover/TooltipContext'
import {ProvidePointerCapturing} from './PointerCapturing'
import {MountAll} from '@theatre/studio/utils/renderInPortalInContext'
import {PortalLayer, ProvideStyles} from '@theatre/studio/css'
import {
createTheatreInternalLogger,
TheatreLoggerLevel,
} from '@theatre/shared/logger'
import {ProvideLogger} from '@theatre/studio/uiComponents/useLogger'
const MakeRootHostContainStatic =
typeof window !== 'undefined'
@ -39,12 +44,24 @@ const Container = styled(PointerEventsHandler)`
}
`
const INTERNAL_LOGGING = /Playground.+Theatre\.js/.test(
(typeof document !== 'undefined' ? document?.title : null) ?? '',
)
export default function UIRoot() {
const studio = getStudio()
const [portalLayerRef, portalLayer] = useRefAndState<HTMLDivElement>(
undefined as $IntentionalAny,
)
const uiRootLogger = createTheatreInternalLogger()
uiRootLogger.configureLogging({
min: TheatreLoggerLevel.DEBUG,
dev: INTERNAL_LOGGING,
internal: INTERNAL_LOGGING,
})
const logger = uiRootLogger.getLogger().named('Theatre UIRoot')
useKeyboardShortcuts()
const visiblityState = useVal(studio.atomP.ahistoric.visibilityState)
@ -63,6 +80,7 @@ export default function UIRoot() {
const initialised = val(studio.atomP.ephemeral.initialised)
return !initialised ? null : (
<ProvideLogger logger={logger}>
<TooltipContext>
<ProvidePointerCapturing>
<MountExtensionComponents />
@ -82,14 +100,15 @@ export default function UIRoot() {
}
>
<PortalLayer ref={portalLayerRef} />
{<GlobalToolbar />}
{<PanelsRoot />}
<GlobalToolbar />
<PanelsRoot />
</Container>
</>
</ProvideStyles>
</PortalContext.Provider>
</ProvidePointerCapturing>
</TooltipContext>
</ProvideLogger>
)
}, [studio, portalLayerRef, portalLayer])

View file

@ -6,7 +6,7 @@ import {HiOutlineChevronRight} from 'react-icons/all'
import styled from 'styled-components'
import {propNameTextCSS} from '@theatre/studio/propEditors/utils/propNameTextCSS'
export const Container = styled.li<{depth: number}>`
export const LeftRowContainer = styled.li<{depth: number}>`
--depth: ${(props) => props.depth};
margin: 0;
padding: 0;
@ -17,7 +17,7 @@ export const BaseHeader = styled.div<{isEven: boolean}>`
border-bottom: 1px solid #7695b705;
`
const Header = styled(BaseHeader)<{
const LeftRowHeader = styled(BaseHeader)<{
isSelectable: boolean
isSelected: boolean
}>`
@ -32,7 +32,7 @@ const Header = styled(BaseHeader)<{
${(props) => props.isSelected && `background: blue`};
`
const Head_Label = styled.span`
const LeftRowHead_Label = styled.span`
${propNameTextCSS};
overflow-x: hidden;
text-overflow: ellipsis;
@ -42,7 +42,7 @@ const Head_Label = styled.span`
flex-wrap: nowrap;
`
const Head_Icon = styled.span<{isCollapsed: boolean}>`
const LeftRowHead_Icon = styled.span<{isCollapsed: boolean}>`
width: 12px;
padding: 8px;
font-size: 9px;
@ -59,14 +59,14 @@ const Head_Icon = styled.span<{isCollapsed: boolean}>`
}
`
const Children = styled.ul`
const LeftRowChildren = styled.ul`
margin: 0;
padding: 0;
list-style: none;
`
const AnyCompositeRow: React.FC<{
leaf: SequenceEditorTree_Row<unknown>
leaf: SequenceEditorTree_Row<string>
label: React.ReactNode
toggleSelect?: VoidFn
toggleCollapsed: VoidFn
@ -85,9 +85,9 @@ const AnyCompositeRow: React.FC<{
}) => {
const hasChildren = Array.isArray(children) && children.length > 0
return (
<Container depth={leaf.depth}>
<Header
return leaf.shouldRender ? (
<LeftRowContainer depth={leaf.depth}>
<LeftRowHeader
style={{
height: leaf.nodeHeight + 'px',
}}
@ -96,14 +96,14 @@ const AnyCompositeRow: React.FC<{
onClick={toggleSelect}
isEven={leaf.n % 2 === 0}
>
<Head_Icon isCollapsed={isCollapsed} onClick={toggleCollapsed}>
<LeftRowHead_Icon isCollapsed={isCollapsed} onClick={toggleCollapsed}>
<HiOutlineChevronRight />
</Head_Icon>
<Head_Label>{label}</Head_Label>
</Header>
{hasChildren && <Children>{children}</Children>}
</Container>
)
</LeftRowHead_Icon>
<LeftRowHead_Label>{label}</LeftRowHead_Label>
</LeftRowHeader>
{hasChildren && <LeftRowChildren>{children}</LeftRowChildren>}
</LeftRowContainer>
) : null
}
export default AnyCompositeRow

View file

@ -11,7 +11,7 @@ import styled from 'styled-components'
import {useEditingToolsForSimplePropInDetailsPanel} from '@theatre/studio/propEditors/useEditingToolsForSimpleProp'
import {nextPrevCursorsTheme} from '@theatre/studio/propEditors/NextPrevKeyframeCursors'
import {graphEditorColors} from '@theatre/studio/panels/SequenceEditorPanel/GraphEditor/GraphEditor'
import {BaseHeader, Container as BaseContainer} from './AnyCompositeRow'
import {BaseHeader, LeftRowContainer as BaseContainer} from './AnyCompositeRow'
import {propNameTextCSS} from '@theatre/studio/propEditors/utils/propNameTextCSS'
const theme = {
@ -20,9 +20,9 @@ const theme = {
},
}
const Container = styled(BaseContainer)<{}>``
const PrimitivePropRowContainer = styled(BaseContainer)<{}>``
const Head = styled(BaseHeader)<{
const PrimitivePropRowHead = styled(BaseHeader)<{
isSelected: boolean
isEven: boolean
}>`
@ -34,7 +34,7 @@ const Head = styled(BaseHeader)<{
box-sizing: border-box;
`
const IconContainer = styled.button<{
const PrimitivePropRowIconContainer = styled.button<{
isSelected: boolean
graphEditorColor: keyof typeof graphEditorColors
}>`
@ -73,7 +73,7 @@ const GraphIcon = () => (
</svg>
)
const Head_Label = styled.span`
const PrimitivePropRowHead_Label = styled.span`
margin-right: 4px;
${propNameTextCSS};
`
@ -132,17 +132,17 @@ const PrimitivePropRow: React.FC<{
const isSelectable = true
return (
<Container depth={leaf.depth}>
<Head
<PrimitivePropRowContainer depth={leaf.depth}>
<PrimitivePropRowHead
isEven={leaf.n % 2 === 0}
style={{
height: leaf.nodeHeight + 'px',
}}
isSelected={isSelected === true}
>
<Head_Label>{label}</Head_Label>
<PrimitivePropRowHead_Label>{label}</PrimitivePropRowHead_Label>
{controlIndicators}
<IconContainer
<PrimitivePropRowIconContainer
onClick={toggleSelect}
isSelected={isSelected === true}
graphEditorColor={possibleColor ?? '1'}
@ -150,9 +150,9 @@ const PrimitivePropRow: React.FC<{
disabled={!isSelectable}
>
<GraphIcon />
</IconContainer>
</Head>
</Container>
</PrimitivePropRowIconContainer>
</PrimitivePropRowHead>
</PrimitivePropRowContainer>
)
}

View file

@ -7,20 +7,21 @@ import React from 'react'
import AnyCompositeRow from './AnyCompositeRow'
import PrimitivePropRow from './PrimitivePropRow'
import {setCollapsedSheetObjectOrCompoundProp} from '@theatre/studio/panels/SequenceEditorPanel/DopeSheet/setCollapsedSheetObjectOrCompoundProp'
export const decideRowByPropType = (
leaf: SequenceEditorTree_PropWithChildren | SequenceEditorTree_PrimitiveProp,
): React.ReactElement =>
): React.ReactElement => {
const key = 'prop' + leaf.pathToProp[leaf.pathToProp.length - 1]
return leaf.shouldRender ? (
leaf.type === 'propWithChildren' ? (
<PropWithChildrenRow
leaf={leaf}
key={'prop' + leaf.pathToProp[leaf.pathToProp.length - 1]}
/>
<PropWithChildrenRow leaf={leaf} key={key} />
) : (
<PrimitivePropRow
leaf={leaf}
key={'prop' + leaf.pathToProp[leaf.pathToProp.length - 1]}
/>
<PrimitivePropRow leaf={leaf} key={key} />
)
) : (
<React.Fragment key={key} />
)
}
const PropWithChildrenRow: React.VFC<{
leaf: SequenceEditorTree_PropWithChildren

View file

@ -0,0 +1,239 @@
import type {
Keyframe,
TrackData,
} from '@theatre/core/projects/store/types/SheetState_Historic'
import type {
DopeSheetSelection,
SequenceEditorPanelLayout,
} from '@theatre/studio/panels/SequenceEditorPanel/layout/layout'
import type {
SequenceEditorTree_PropWithChildren,
SequenceEditorTree_SheetObject,
} from '@theatre/studio/panels/SequenceEditorPanel/layout/tree'
import type {Pointer} from '@theatre/dataverse'
import {val} from '@theatre/dataverse'
import React from 'react'
import styled from 'styled-components'
import type {SequenceTrackId} from '@theatre/shared/utils/ids'
import {ConnectorLine} from '@theatre/studio/panels/SequenceEditorPanel/DopeSheet/Right/keyframeRowUI/ConnectorLine'
import {AggregateKeyframePositionIsSelected} from './AggregatedKeyframeTrack'
import type {KeyframeWithTrack} from '@theatre/studio/panels/SequenceEditorPanel/DopeSheet/Right/collectAggregateKeyframes'
import {DopeSnapHitZoneUI} from '@theatre/studio/panels/SequenceEditorPanel/RightOverlay/DopeSnapHitZoneUI'
import {absoluteDims} from '@theatre/studio/utils/absoluteDims'
const AggregateKeyframeEditorContainer = styled.div`
position: absolute;
`
const noConnector = <></>
export type IAggregateKeyframesAtPosition = {
position: number
/** all tracks have a keyframe for this position (otherwise, false means 'partial') */
allHere: boolean
selected: AggregateKeyframePositionIsSelected | undefined
keyframes: {
kf: Keyframe
track: {
id: SequenceTrackId
data: TrackData
}
}[]
}
export type IAggregateKeyframeEditorProps = {
index: number
aggregateKeyframes: IAggregateKeyframesAtPosition[]
layoutP: Pointer<SequenceEditorPanelLayout>
viewModel:
| SequenceEditorTree_PropWithChildren
| SequenceEditorTree_SheetObject
selection: undefined | DopeSheetSelection
}
const AggregateKeyframeEditor: React.VFC<IAggregateKeyframeEditorProps> = (
props,
) => {
const {index, aggregateKeyframes} = props
const cur = aggregateKeyframes[index]
const next = aggregateKeyframes[index + 1]
const connected =
next && cur.keyframes.length === next.keyframes.length
? // all keyframes are same in the next position
cur.keyframes.every(
({track}, ind) => next.keyframes[ind].track === track,
) && {
length: next.position - cur.position,
selected:
cur.selected === AggregateKeyframePositionIsSelected.AllSelected &&
next.selected === AggregateKeyframePositionIsSelected.AllSelected,
}
: null
return (
<AggregateKeyframeEditorContainer
style={{
top: `${props.viewModel.nodeHeight / 2}px`,
left: `calc(${val(
props.layoutP.scaledSpace.leftPadding,
)}px + calc(var(--unitSpaceToScaledSpaceMultiplier) * ${
cur.position
}px))`,
}}
>
<AggregateKeyframeDot
keyframes={cur.keyframes}
position={cur.position}
theme={{
isSelected: cur.selected,
}}
isAllHere={cur.allHere}
/>
{connected ? (
<ConnectorLine
/* TEMP: Disabled until interactivity */
mvpIsInteractiveDisabled={true}
connectorLengthInUnitSpace={connected.length}
isPopoverOpen={false}
// if all keyframe aggregates are selected
isSelected={connected.selected}
/>
) : (
noConnector
)}
</AggregateKeyframeEditorContainer>
)
}
const DOT_SIZE_PX = 16
const DOT_HOVER_SIZE_PX = DOT_SIZE_PX + 5
/** The keyframe diamond ◆ */
const DotContainer = styled.div`
position: absolute;
${absoluteDims(DOT_SIZE_PX)}
z-index: 1;
`
const HitZone = styled.div`
z-index: 2;
/* TEMP: Disabled until interactivity */
/* cursor: ew-resize; */
${DopeSnapHitZoneUI.CSS}
#pointer-root.draggingPositionInSequenceEditor & {
${DopeSnapHitZoneUI.CSS_WHEN_SOMETHING_DRAGGING}
}
/* TEMP: Disabled until interactivity */
/* &:hover + ${DotContainer}, */
#pointer-root.draggingPositionInSequenceEditor &:hover + ${DotContainer},
// notice "," css "or"
&.${DopeSnapHitZoneUI.BEING_DRAGGED_CLASS} + ${DotContainer} {
${absoluteDims(DOT_HOVER_SIZE_PX)}
}
`
const AggregateKeyframeDot = React.forwardRef(AggregateKeyframeDot_ref)
function AggregateKeyframeDot_ref(
props: React.PropsWithChildren<{
theme: IDotThemeValues
isAllHere: boolean
position: number
keyframes: KeyframeWithTrack[]
}>,
ref: React.ForwardedRef<HTMLDivElement>,
) {
return (
<>
<HitZone
ref={ref}
{...DopeSnapHitZoneUI.reactProps({
isDragging: false,
position: props.position,
})}
/>
<DotContainer>
{props.isAllHere ? (
<AggregateDotAllHereSvg {...props.theme} />
) : (
<AggregateDotSomeHereSvg {...props.theme} />
)}
</DotContainer>
</>
)
}
type IDotThemeValues = {
isSelected: AggregateKeyframePositionIsSelected | undefined
}
const SELECTED_COLOR = '#b8e4e2'
const DEFAULT_PRIMARY_COLOR = '#40AAA4'
const DEFAULT_SECONDARY_COLOR = '#45747C'
const selectionColorAll = (theme: IDotThemeValues) =>
theme.isSelected === AggregateKeyframePositionIsSelected.AllSelected
? SELECTED_COLOR
: theme.isSelected ===
AggregateKeyframePositionIsSelected.AtLeastOneUnselected
? DEFAULT_PRIMARY_COLOR
: DEFAULT_SECONDARY_COLOR
const selectionColorSome = (theme: IDotThemeValues) =>
theme.isSelected === AggregateKeyframePositionIsSelected.AllSelected
? SELECTED_COLOR
: theme.isSelected ===
AggregateKeyframePositionIsSelected.AtLeastOneUnselected
? DEFAULT_PRIMARY_COLOR
: DEFAULT_SECONDARY_COLOR
const AggregateDotAllHereSvg = (theme: IDotThemeValues) => (
<svg
width="100%"
height="100%"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<rect
x="4.46443"
y="10.0078"
width="5"
height="5"
transform="rotate(-45 4.46443 10.0078)"
fill="#212327" // background knockout fill
stroke={selectionColorSome(theme)}
/>
<rect
x="3.75732"
y="6.01953"
width="6"
height="6"
transform="rotate(-45 3.75732 6.01953)"
fill={selectionColorAll(theme)}
/>
</svg>
)
// when the aggregate keyframes are sparse across tracks at this position
const AggregateDotSomeHereSvg = (theme: IDotThemeValues) => (
<svg
width="100%"
height="100%"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<rect
x="4.46443"
y="8"
width="5"
height="5"
transform="rotate(-45 4.46443 8)"
fill="#23262B"
stroke={selectionColorAll(theme)}
/>
</svg>
)
export default AggregateKeyframeEditor

View file

@ -0,0 +1,188 @@
import type {
DopeSheetSelection,
SequenceEditorPanelLayout,
} from '@theatre/studio/panels/SequenceEditorPanel/layout/layout'
import type {
SequenceEditorTree_PropWithChildren,
SequenceEditorTree_SheetObject,
} from '@theatre/studio/panels/SequenceEditorPanel/layout/tree'
import type {Keyframe} from '@theatre/core/projects/store/types/SheetState_Historic'
import {usePrism} from '@theatre/react'
import type {Pointer} from '@theatre/dataverse'
import {val} from '@theatre/dataverse'
import React from 'react'
import styled from 'styled-components'
import useContextMenu from '@theatre/studio/uiComponents/simpleContextMenu/useContextMenu'
import useRefAndState from '@theatre/studio/utils/useRefAndState'
import type {IAggregateKeyframesAtPosition} from './AggregateKeyframeEditor'
import AggregateKeyframeEditor from './AggregateKeyframeEditor'
import type {AggregatedKeyframes} from '@theatre/studio/panels/SequenceEditorPanel/DopeSheet/Right/collectAggregateKeyframes'
import {useLogger} from '@theatre/studio/uiComponents/useLogger'
const AggregatedKeyframeTrackContainer = styled.div`
position: relative;
height: 100%;
width: 100%;
`
type IAggregatedKeyframeTracksProps = {
viewModel:
| SequenceEditorTree_PropWithChildren
| SequenceEditorTree_SheetObject
aggregatedKeyframes: AggregatedKeyframes
layoutP: Pointer<SequenceEditorPanelLayout>
}
type _AggSelection = {
selectedPositions: Map<number, AggregateKeyframePositionIsSelected>
selection: DopeSheetSelection | undefined
}
const EMPTY_SELECTION: _AggSelection = Object.freeze({
selectedPositions: new Map(),
selection: undefined,
})
function AggregatedKeyframeTrack_memo(props: IAggregatedKeyframeTracksProps) {
const {layoutP, aggregatedKeyframes, viewModel} = props
const logger = useLogger('AggregatedKeyframeTrack')
const [containerRef, containerNode] = useRefAndState<HTMLDivElement | null>(
null,
)
const {selectedPositions, selection} = useCollectedSelectedPositions(
layoutP,
viewModel,
aggregatedKeyframes,
)
const [contextMenu, _, isOpen] = useAggregatedKeyframeTrackContextMenu(
containerNode,
props,
() => logger._debug('see aggregatedKeyframes', props.aggregatedKeyframes),
)
const posKfs: IAggregateKeyframesAtPosition[] = [
...aggregatedKeyframes.byPosition.entries(),
]
.sort((a, b) => a[0] - b[0])
.map(([position, keyframes]) => ({
position,
keyframes,
selected: selectedPositions.get(position),
allHere: keyframes.length === aggregatedKeyframes.tracks.length,
}))
const keyframeEditors = posKfs.map(({position, keyframes}, index) => (
<AggregateKeyframeEditor
index={index}
layoutP={layoutP}
viewModel={viewModel}
aggregateKeyframes={posKfs}
key={'agg-' + position}
selection={
selectedPositions.has(position) === true ? selection : undefined
}
/>
))
return (
<AggregatedKeyframeTrackContainer
ref={containerRef}
style={{
background: isOpen ? '#444850 ' : 'unset',
}}
>
{keyframeEditors}
{contextMenu}
</AggregatedKeyframeTrackContainer>
)
}
const AggregatedKeyframeTrack = React.memo(AggregatedKeyframeTrack_memo)
export default AggregatedKeyframeTrack
export enum AggregateKeyframePositionIsSelected {
AllSelected,
AtLeastOneUnselected,
NoneSelected,
}
const {AllSelected, AtLeastOneUnselected, NoneSelected} =
AggregateKeyframePositionIsSelected
/** Helper to put together the selected positions */
function useCollectedSelectedPositions(
layoutP: Pointer<SequenceEditorPanelLayout>,
viewModel:
| SequenceEditorTree_PropWithChildren
| SequenceEditorTree_SheetObject,
aggregatedKeyframes: AggregatedKeyframes,
): _AggSelection {
return usePrism(() => {
const selectionAtom = val(layoutP.selectionAtom)
const sheetObjectSelection = val(
selectionAtom.pointer.current.byObjectKey[
viewModel.sheetObject.address.objectKey
],
)
if (!sheetObjectSelection) return EMPTY_SELECTION
const selectedAtPositions = new Map<
number,
AggregateKeyframePositionIsSelected
>()
for (const [position, kfsWithTrack] of aggregatedKeyframes.byPosition) {
let positionIsSelected: undefined | AggregateKeyframePositionIsSelected =
undefined
for (const kfWithTrack of kfsWithTrack) {
const kfIsSelected =
sheetObjectSelection.byTrackId[kfWithTrack.track.id]?.byKeyframeId?.[
kfWithTrack.kf.id
] === true
// -1/10: This sux
// undefined = have not encountered
if (positionIsSelected === undefined) {
// first item
if (kfIsSelected) {
positionIsSelected = AllSelected
} else {
positionIsSelected = NoneSelected
}
} else if (kfIsSelected) {
if (positionIsSelected === NoneSelected) {
positionIsSelected = AtLeastOneUnselected
}
} else {
if (positionIsSelected === AllSelected) {
positionIsSelected = AtLeastOneUnselected
}
}
}
if (positionIsSelected != null) {
selectedAtPositions.set(position, positionIsSelected)
}
}
return {
selectedPositions: selectedAtPositions,
selection: val(selectionAtom.pointer.current),
}
}, [layoutP, aggregatedKeyframes])
}
function useAggregatedKeyframeTrackContextMenu(
node: HTMLDivElement | null,
props: IAggregatedKeyframeTracksProps,
debugOnOpen: () => void,
) {
return useContextMenu(node, {
onOpen: debugOnOpen,
displayName: 'Aggregate Keyframe Track',
menuItems: () => {
return []
},
})
}

View file

@ -7,7 +7,7 @@ import type {Pointer} from '@theatre/dataverse'
import {val} from '@theatre/dataverse'
import React from 'react'
import styled from 'styled-components'
import KeyframeEditor from './KeyframeEditor/KeyframeEditor'
import SingleKeyframeEditor from './KeyframeEditor/SingleKeyframeEditor'
import type {IContextMenuItem} from '@theatre/studio/uiComponents/simpleContextMenu/useContextMenu'
import useContextMenu from '@theatre/studio/uiComponents/simpleContextMenu/useContextMenu'
import useRefAndState from '@theatre/studio/utils/useRefAndState'
@ -25,7 +25,7 @@ type BasicKeyframedTracksProps = {
trackData: TrackData
}
const BasicKeyframedTrack: React.FC<BasicKeyframedTracksProps> = React.memo(
const BasicKeyframedTrack: React.VFC<BasicKeyframedTracksProps> = React.memo(
(props) => {
const {layoutP, trackData, leaf} = props
const [containerRef, containerNode] = useRefAndState<HTMLDivElement | null>(
@ -57,7 +57,7 @@ const BasicKeyframedTrack: React.FC<BasicKeyframedTracksProps> = React.memo(
)
const keyframeEditors = trackData.keyframes.map((kf, index) => (
<KeyframeEditor
<SingleKeyframeEditor
keyframe={kf}
index={index}
trackData={trackData}
@ -89,15 +89,12 @@ function useBasicKeyframedTrackContextMenu(
props: BasicKeyframedTracksProps,
) {
return useContextMenu(node, {
displayName: 'Keyframe Track',
menuItems: () => {
const selectionKeyframes =
val(getStudio()!.atomP.ahistoric.clipboard.keyframes) || []
if (selectionKeyframes.length > 0) {
return [pasteKeyframesContextMenuItem(props, selectionKeyframes)]
} else {
return []
}
},
})
}
@ -108,6 +105,7 @@ function pasteKeyframesContextMenuItem(
): IContextMenuItem {
return {
label: 'Paste Keyframes',
enabled: keyframes.length > 0,
callback: () => {
const sheet = val(props.layoutP.sheet)
const sequence = sheet.getSequence()

View file

@ -4,12 +4,8 @@ import useContextMenu from '@theatre/studio/uiComponents/simpleContextMenu/useCo
import useDrag from '@theatre/studio/uiComponents/useDrag'
import useRefAndState from '@theatre/studio/utils/useRefAndState'
import {val} from '@theatre/dataverse'
import {lighten} from 'polished'
import React from 'react'
import {useMemo, useRef} from 'react'
import styled from 'styled-components'
import {DOT_SIZE_PX} from './KeyframeDot'
import type KeyframeEditor from './KeyframeEditor'
import usePopover from '@theatre/studio/uiComponents/Popover/usePopover'
import BasicPopover from '@theatre/studio/uiComponents/Popover/BasicPopover'
import CurveEditorPopover, {
@ -19,72 +15,30 @@ import selectedKeyframeIdsIfInSingleTrack from '@theatre/studio/panels/SequenceE
import type {OpenFn} from '@theatre/studio/src/uiComponents/Popover/usePopover'
import type {Keyframe} from '@theatre/core/projects/store/types/SheetState_Historic'
import {usePointerCapturing} from '@theatre/studio/UIRoot/PointerCapturing'
import type {ISingleKeyframeEditorProps} from './SingleKeyframeEditor'
import type {IConnectorThemeValues} from '@theatre/studio/panels/SequenceEditorPanel/DopeSheet/Right/keyframeRowUI/ConnectorLine'
import {ConnectorLine} from '@theatre/studio/panels/SequenceEditorPanel/DopeSheet/Right/keyframeRowUI/ConnectorLine'
import {COLOR_POPOVER_BACK} from './CurveEditorPopover/colors'
import {useVal} from '@theatre/react'
import {isKeyframeConnectionInSelection} from '@theatre/studio/panels/SequenceEditorPanel/DopeSheet/selections'
const CONNECTOR_HEIGHT = DOT_SIZE_PX / 2 + 1
const CONNECTOR_WIDTH_UNSCALED = 1000
import styled from 'styled-components'
import {DOT_SIZE_PX} from './SingleKeyframeDot'
const POPOVER_MARGIN = 5
type IConnectorThemeValues = {
isPopoverOpen: boolean
isSelected: boolean
}
export const CONNECTOR_THEME = {
normalColor: `#365b59`, // (greenish-blueish)ish
popoverOpenColor: `#817720`, // orangey yellowish
barColor: (values: IConnectorThemeValues) => {
const base = values.isPopoverOpen
? CONNECTOR_THEME.popoverOpenColor
: CONNECTOR_THEME.normalColor
return values.isSelected ? lighten(0.2, base) : base
},
hoverColor: (values: IConnectorThemeValues) => {
const base = values.isPopoverOpen
? CONNECTOR_THEME.popoverOpenColor
: CONNECTOR_THEME.normalColor
return values.isSelected ? lighten(0.4, base) : lighten(0.1, base)
},
}
const Container = styled.div<IConnectorThemeValues>`
position: absolute;
background: ${CONNECTOR_THEME.barColor};
height: ${CONNECTOR_HEIGHT}px;
width: ${CONNECTOR_WIDTH_UNSCALED}px;
left: 0;
top: -${CONNECTOR_HEIGHT / 2}px;
transform-origin: top left;
z-index: 0;
cursor: ew-resize;
&:after {
display: block;
position: absolute;
content: ' ';
top: -4px;
bottom: -4px;
left: 0;
right: 0;
}
&:hover {
background: ${CONNECTOR_THEME.hoverColor};
}
`
const EasingPopover = styled(BasicPopover)`
--popover-outer-stroke: transparent;
--popover-inner-stroke: ${COLOR_POPOVER_BACK};
`
type IProps = Parameters<typeof KeyframeEditor>[0]
type IBasicKeyframeConnectorProps = ISingleKeyframeEditorProps
const Connector: React.FC<IProps> = (props) => {
const BasicKeyframeConnector: React.VFC<IBasicKeyframeConnectorProps> = (
props,
) => {
const {index, trackData} = props
const cur = trackData.keyframes[index]
const next = trackData.keyframes[index + 1]
@ -140,28 +94,26 @@ const Connector: React.FC<IProps> = (props) => {
}
return (
<Container
{...themeValues}
<ConnectorLine
ref={nodeRef}
style={{
// Previously we used scale3d, which had weird fuzzy rendering look in both FF & Chrome
transform: `scaleX(calc(var(--unitSpaceToScaledSpaceMultiplier) * ${
connectorLengthInUnitSpace / CONNECTOR_WIDTH_UNSCALED
}))`,
}}
onClick={(e) => {
connectorLengthInUnitSpace={connectorLengthInUnitSpace}
isPopoverOpen={isPopoverOpen}
isSelected={!!props.selection}
openPopover={(e) => {
if (node) openPopover(e, node)
}}
>
{popoverNode}
{contextMenu}
</Container>
</ConnectorLine>
)
}
export default BasicKeyframeConnector
export default Connector
function useDragKeyframe(node: HTMLDivElement | null, props: IProps) {
function useDragKeyframe(
node: HTMLDivElement | null,
props: IBasicKeyframeConnectorProps,
) {
const propsRef = useRef(props)
propsRef.current = props
@ -240,9 +192,8 @@ function useDragKeyframe(node: HTMLDivElement | null, props: IProps) {
useDrag(node, gestureHandlers)
}
function useConnectorContextMenu(
props: IProps,
props: IBasicKeyframeConnectorProps,
node: HTMLDivElement | null,
cur: Keyframe,
next: Keyframe,

View file

@ -13,7 +13,7 @@ import fuzzy from 'fuzzy'
import type {SequenceEditorPanelLayout} from '@theatre/studio/panels/SequenceEditorPanel/layout/layout'
import getStudio from '@theatre/studio/getStudio'
import type {CommitOrDiscard} from '@theatre/studio/StudioStore/StudioStore'
import type KeyframeEditor from '@theatre/studio/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/KeyframeEditor'
import type {ISingleKeyframeEditorProps} from '@theatre/studio/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/SingleKeyframeEditor'
import CurveSegmentEditor from './CurveSegmentEditor'
import EasingOption from './EasingOption'
import type {CSSCubicBezierArgsString, CubicBezierHandles} from './shared'
@ -133,7 +133,7 @@ type IProps = {
* Called when user hits enter/escape
*/
onRequestClose: (reason: string) => void
} & Parameters<typeof KeyframeEditor>[0]
} & ISingleKeyframeEditorProps
const CurveEditorPopover: React.FC<IProps> = (props) => {
////// `tempTransaction` //////

View file

@ -10,16 +10,18 @@ import type {PropConfigForType} from '@theatre/studio/propEditors/utils/PropConf
import type {ISimplePropEditorReactProps} from '@theatre/studio/propEditors/simpleEditors/ISimplePropEditorReactProps'
import {simplePropEditorByPropType} from '@theatre/studio/propEditors/simpleEditors/simplePropEditorByPropType'
import KeyframeSimplePropEditor from './DeterminePropEditorForKeyframe/KeyframeSimplePropEditor'
import SingleKeyframeSimplePropEditor from './DeterminePropEditorForSingleKeyframe/SingleKeyframeSimplePropEditor'
type IDeterminePropEditorForKeyframeProps<K extends PropTypeConfig['type']> = {
type IDeterminePropEditorForSingleKeyframeProps<
K extends PropTypeConfig['type'],
> = {
editingTools: IEditingTools<PropConfigForType<K>['valueType']>
propConfig: PropConfigForType<K>
keyframeValue: PropConfigForType<K>['valueType']
displayLabel?: string
}
const KeyframePropEditorContainer = styled.div`
const SingleKeyframePropEditorContainer = styled.div`
padding: 2px;
display: flex;
align-items: stretch;
@ -28,7 +30,7 @@ const KeyframePropEditorContainer = styled.div`
min-width: 100px;
}
`
const KeyframePropLabel = styled.span`
const SingleKeyframePropLabel = styled.span`
font-style: normal;
font-weight: 400;
font-size: 11px;
@ -50,8 +52,8 @@ const KeyframePropLabel = styled.span`
*
* @param p - propConfig object for any type of prop.
*/
export function DeterminePropEditorForKeyframe(
p: IDeterminePropEditorForKeyframeProps<PropTypeConfig['type']>,
export function DeterminePropEditorForSingleKeyframe(
p: IDeterminePropEditorForSingleKeyframeProps<PropTypeConfig['type']>,
) {
const propConfig = p.propConfig
@ -66,9 +68,9 @@ export function DeterminePropEditorForKeyframe(
const PropEditor = simplePropEditorByPropType[propConfig.type]
return (
<KeyframePropEditorContainer>
<KeyframePropLabel>{p.displayLabel}</KeyframePropLabel>
<KeyframeSimplePropEditor
<SingleKeyframePropEditorContainer>
<SingleKeyframePropLabel>{p.displayLabel}</SingleKeyframePropLabel>
<SingleKeyframeSimplePropEditor
SimpleEditorComponent={
PropEditor as React.VFC<
ISimplePropEditorReactProps<PropTypeConfig_AllSimples>
@ -78,7 +80,7 @@ export function DeterminePropEditorForKeyframe(
editingTools={p.editingTools}
keyframeValue={p.keyframeValue}
/>
</KeyframePropEditorContainer>
</SingleKeyframePropEditorContainer>
)
}
}

View file

@ -4,7 +4,7 @@ import styled from 'styled-components'
import type {PropTypeConfig_AllSimples} from '@theatre/core/propTypes'
import type {IEditingTools} from '@theatre/studio/propEditors/utils/IEditingTools'
export type IKeyframeSimplePropEditorProps<
export type ISingleKeyframeSimplePropEditorProps<
TPropTypeConfig extends PropTypeConfig_AllSimples,
> = {
propConfig: TPropTypeConfig
@ -13,7 +13,7 @@ export type IKeyframeSimplePropEditorProps<
SimpleEditorComponent: React.VFC<ISimplePropEditorReactProps<TPropTypeConfig>>
}
const KeyframeSimplePropEditorContainer = styled.div`
const SingleKeyframeSimplePropEditorContainer = styled.div`
padding: 0 6px;
display: flex;
align-items: center;
@ -23,23 +23,23 @@ const KeyframeSimplePropEditorContainer = styled.div`
* Initially used for inline keyframe property editor, this editor is attached to the
* functionality of editing a property for a sequence keyframe.
*/
function KeyframeSimplePropEditor<
function SingleKeyframeSimplePropEditor<
TPropTypeConfig extends PropTypeConfig_AllSimples,
>({
propConfig,
editingTools,
keyframeValue: value,
SimpleEditorComponent: EditorComponent,
}: IKeyframeSimplePropEditorProps<TPropTypeConfig>) {
}: ISingleKeyframeSimplePropEditorProps<TPropTypeConfig>) {
return (
<KeyframeSimplePropEditorContainer>
<SingleKeyframeSimplePropEditorContainer>
<EditorComponent
editingTools={editingTools}
propConfig={propConfig}
value={value}
/>
</KeyframeSimplePropEditorContainer>
</SingleKeyframeSimplePropEditorContainer>
)
}
export default KeyframeSimplePropEditor
export default SingleKeyframeSimplePropEditor

View file

@ -11,36 +11,24 @@ import useDrag from '@theatre/studio/uiComponents/useDrag'
import type {UseDragOpts} from '@theatre/studio/uiComponents/useDrag'
import useRefAndState from '@theatre/studio/utils/useRefAndState'
import {val} from '@theatre/dataverse'
import {
includeLockFrameStampAttrs,
useLockFrameStampPosition,
} from '@theatre/studio/panels/SequenceEditorPanel/FrameStampPositionProvider'
import {
lockedCursorCssVarName,
useCssCursorLock,
} from '@theatre/studio/uiComponents/PointerEventsHandler'
import SnapCursor from './SnapCursor.svg'
import {useLockFrameStampPosition} from '@theatre/studio/panels/SequenceEditorPanel/FrameStampPositionProvider'
import {useCssCursorLock} from '@theatre/studio/uiComponents/PointerEventsHandler'
import selectedKeyframeIdsIfInSingleTrack from '@theatre/studio/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/selectedKeyframeIdsIfInSingleTrack'
import type {IKeyframeEditorProps} from './KeyframeEditor'
import DopeSnap from '@theatre/studio/panels/SequenceEditorPanel/RightOverlay/DopeSnap'
import usePopover from '@theatre/studio/uiComponents/Popover/usePopover'
import BasicPopover from '@theatre/studio/uiComponents/Popover/BasicPopover'
import {useTempTransactionEditingTools} from './useTempTransactionEditingTools'
import {DeterminePropEditorForKeyframe} from './DeterminePropEditorForKeyframe'
import {DeterminePropEditorForSingleKeyframe} from './DeterminePropEditorForSingleKeyframe'
import type {ISingleKeyframeEditorProps} from './SingleKeyframeEditor'
import {absoluteDims} from '@theatre/studio/utils/absoluteDims'
import {DopeSnapHitZoneUI} from '@theatre/studio/panels/SequenceEditorPanel/RightOverlay/DopeSnapHitZoneUI'
import {useLogger} from '@theatre/studio/uiComponents/useLogger'
import type {ILogger} from '@theatre/shared/logger'
export const DOT_SIZE_PX = 6
const HIT_ZONE_SIZE_PX = 12
const SNAP_CURSOR_SIZE_PX = 34
const DOT_HOVER_SIZE_PX = DOT_SIZE_PX + 5
const dims = (size: number) => `
left: ${-size / 2}px;
top: ${-size / 2}px;
width: ${size}px;
height: ${size}px;
`
const dotTheme = {
normalColor: '#40AAA4',
get selectedColor() {
@ -51,7 +39,7 @@ const dotTheme = {
/** The keyframe diamond ◆ */
const Diamond = styled.div<{isSelected: boolean}>`
position: absolute;
${dims(DOT_SIZE_PX)}
${absoluteDims(DOT_SIZE_PX)}
background: ${(props) =>
props.isSelected ? dotTheme.selectedColor : dotTheme.normalColor};
@ -59,56 +47,38 @@ const Diamond = styled.div<{isSelected: boolean}>`
z-index: 1;
pointer-events: none;
${dims(DOT_SIZE_PX)}
`
const HitZone = styled.div`
position: absolute;
${dims(HIT_ZONE_SIZE_PX)};
z-index: 1;
cursor: ew-resize;
${DopeSnapHitZoneUI.CSS}
#pointer-root.draggingPositionInSequenceEditor & {
pointer-events: auto;
cursor: var(${lockedCursorCssVarName});
// ⸢⸤⸣⸥ thing
// This box extends the hitzone so the user does not
// accidentally leave the hitzone
&:hover:after {
position: absolute;
top: calc(50% - ${SNAP_CURSOR_SIZE_PX / 2}px);
left: calc(50% - ${SNAP_CURSOR_SIZE_PX / 2}px);
width: ${SNAP_CURSOR_SIZE_PX}px;
height: ${SNAP_CURSOR_SIZE_PX}px;
display: block;
content: ' ';
background: url(${SnapCursor}) no-repeat 100% 100%;
// This icon might also fit: GiConvergenceTarget
}
${DopeSnapHitZoneUI.CSS_WHEN_SOMETHING_DRAGGING}
}
&.beingDragged {
pointer-events: none !important;
}
&:hover + ${Diamond}, &.beingDragged + ${Diamond} {
${dims(DOT_HOVER_SIZE_PX)}
&:hover
+ ${Diamond},
// notice , "or" in CSS
&.${DopeSnapHitZoneUI.BEING_DRAGGED_CLASS}
+ ${Diamond} {
${absoluteDims(DOT_HOVER_SIZE_PX)}
}
`
type IKeyframeDotProps = IKeyframeEditorProps
type ISingleKeyframeDotProps = ISingleKeyframeEditorProps
/** The ◆ you can grab onto in "keyframe editor" (aka "dope sheet" in other programs) */
const KeyframeDot: React.VFC<IKeyframeDotProps> = (props) => {
const SingleKeyframeDot: React.VFC<ISingleKeyframeDotProps> = (props) => {
const logger = useLogger('SingleKeyframeDot')
const [ref, node] = useRefAndState<HTMLDivElement | null>(null)
const [contextMenu] = useKeyframeContextMenu(node, props)
const [contextMenu] = useSingleKeyframeContextMenu(node, logger, props)
const [inlineEditorPopover, openEditor] =
useKeyframeInlineEditorPopover(props)
const [isDragging] = useDragForKeyframeDot(node, props, {
useSingleKeyframeInlineEditorPopover(props)
const [isDragging] = useDragForSingleKeyframeDot(node, props, {
onClickFromDrag(dragStartEvent) {
openEditor(dragStartEvent, ref.current!)
},
@ -118,9 +88,10 @@ const KeyframeDot: React.VFC<IKeyframeDotProps> = (props) => {
<>
<HitZone
ref={ref}
{...includeLockFrameStampAttrs(props.keyframe.position)}
{...DopeSnap.includePositionSnapAttrs(props.keyframe.position)}
className={isDragging ? 'beingDragged' : ''}
{...DopeSnapHitZoneUI.reactProps({
isDragging,
position: props.keyframe.position,
})}
/>
<Diamond isSelected={!!props.selection} />
{inlineEditorPopover}
@ -129,11 +100,12 @@ const KeyframeDot: React.VFC<IKeyframeDotProps> = (props) => {
)
}
export default KeyframeDot
export default SingleKeyframeDot
function useKeyframeContextMenu(
function useSingleKeyframeContextMenu(
target: HTMLDivElement | null,
props: IKeyframeDotProps,
logger: ILogger,
props: ISingleKeyframeDotProps,
) {
const maybeSelectedKeyframeIds = selectedKeyframeIdsIfInSingleTrack(
props.selection,
@ -146,20 +118,24 @@ function useKeyframeContextMenu(
const deleteItem = deleteSelectionOrKeyframeContextMenuItem(props)
return useContextMenu(target, {
displayName: 'Keyframe',
menuItems: () => {
return [keyframeSelectionItem, deleteItem]
},
onOpen() {
logger._debug('Show keyframe', props)
},
})
}
/** The editor that pops up when directly clicking a Keyframe. */
function useKeyframeInlineEditorPopover(props: IKeyframeDotProps) {
function useSingleKeyframeInlineEditorPopover(props: ISingleKeyframeDotProps) {
const editingTools = useEditingToolsForKeyframeEditorPopover(props)
const label = props.leaf.propConf.label ?? last(props.leaf.pathToProp)
return usePopover({debugName: 'useKeyframeInlineEditorPopover'}, () => (
<BasicPopover showPopoverEdgeTriangle>
<DeterminePropEditorForKeyframe
<DeterminePropEditorForSingleKeyframe
propConfig={props.leaf.propConf}
editingTools={editingTools}
keyframeValue={props.keyframe.value}
@ -169,7 +145,9 @@ function useKeyframeInlineEditorPopover(props: IKeyframeDotProps) {
))
}
function useEditingToolsForKeyframeEditorPopover(props: IKeyframeDotProps) {
function useEditingToolsForKeyframeEditorPopover(
props: ISingleKeyframeDotProps,
) {
const obj = props.leaf.sheetObject
return useTempTransactionEditingTools(({stateEditors}, value) => {
const newKeyframe = {...props.keyframe, value}
@ -182,9 +160,9 @@ function useEditingToolsForKeyframeEditorPopover(props: IKeyframeDotProps) {
})
}
function useDragForKeyframeDot(
function useDragForSingleKeyframeDot(
node: HTMLDivElement | null,
props: IKeyframeDotProps,
props: ISingleKeyframeDotProps,
options: {
/**
* hmm: this is a hack so we can actually receive the
@ -277,7 +255,7 @@ function useDragForKeyframeDot(
}
function deleteSelectionOrKeyframeContextMenuItem(
props: IKeyframeDotProps,
props: ISingleKeyframeDotProps,
): IContextMenuItem {
return {
label: props.selection ? 'Delete Selection' : 'Delete Keyframe',
@ -300,7 +278,7 @@ function deleteSelectionOrKeyframeContextMenuItem(
}
function copyKeyFrameContextMenuItem(
props: IKeyframeDotProps,
props: ISingleKeyframeDotProps,
keyframeIds: string[],
): IContextMenuItem {
return {

View file

@ -11,16 +11,16 @@ import type {Pointer} from '@theatre/dataverse'
import {val} from '@theatre/dataverse'
import React from 'react'
import styled from 'styled-components'
import Connector from './Connector'
import KeyframeDot from './KeyframeDot'
import SingleKeyframeConnector from './BasicKeyframeConnector'
import SingleKeyframeDot from './SingleKeyframeDot'
const Container = styled.div`
const SingleKeyframeEditorContainer = styled.div`
position: absolute;
`
const noConnector = <></>
export type IKeyframeEditorProps = {
export type ISingleKeyframeEditorProps = {
index: number
keyframe: Keyframe
trackData: TrackData
@ -29,7 +29,7 @@ export type IKeyframeEditorProps = {
selection: undefined | DopeSheetSelection
}
const KeyframeEditor: React.FC<IKeyframeEditorProps> = (props) => {
const SingleKeyframeEditor: React.VFC<ISingleKeyframeEditorProps> = (props) => {
const {index, trackData} = props
const cur = trackData.keyframes[index]
const next = trackData.keyframes[index + 1]
@ -37,7 +37,7 @@ const KeyframeEditor: React.FC<IKeyframeEditorProps> = (props) => {
const connected = cur.connectedRight && !!next
return (
<Container
<SingleKeyframeEditorContainer
style={{
top: `${props.leaf.nodeHeight / 2}px`,
left: `calc(${val(
@ -47,10 +47,10 @@ const KeyframeEditor: React.FC<IKeyframeEditorProps> = (props) => {
}px))`,
}}
>
<KeyframeDot {...props} />
{connected ? <Connector {...props} /> : noConnector}
</Container>
<SingleKeyframeDot {...props} />
{connected ? <SingleKeyframeConnector {...props} /> : noConnector}
</SingleKeyframeEditorContainer>
)
}
export default KeyframeEditor
export default SingleKeyframeEditor

View file

@ -1,6 +0,0 @@
<svg width="34" height="34" viewBox="0 0 34 34" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1 7V1H7" stroke="#74FFDE" stroke-width="0.25" />
<path d="M7 33H1L1 27" stroke="#74FFDE" stroke-width="0.25" />
<path d="M33 27V33H27" stroke="#74FFDE" stroke-width="0.25" />
<path d="M27 1L33 1V7" stroke="#74FFDE" stroke-width="0.25" />
</svg>

Before

Width:  |  Height:  |  Size: 358 B

View file

@ -15,8 +15,15 @@ import type {
DopeSheetSelection,
SequenceEditorPanelLayout,
} from '@theatre/studio/panels/SequenceEditorPanel/layout/layout'
import type {SequenceEditorTree_AllRowTypes} from '@theatre/studio/panels/SequenceEditorPanel/layout/tree'
import type {
SequenceEditorTree_AllRowTypes,
SequenceEditorTree_PropWithChildren,
SequenceEditorTree_SheetObject,
} from '@theatre/studio/panels/SequenceEditorPanel/layout/tree'
import DopeSnap from '@theatre/studio/panels/SequenceEditorPanel/RightOverlay/DopeSnap'
import {collectAggregateKeyframesInPrism} from './collectAggregateKeyframes'
import type {ILogger, IUtilLogger} from '@theatre/shared/logger'
import {useLogger} from '@theatre/studio/uiComponents/useLogger'
const Container = styled.div<{isShiftDown: boolean}>`
cursor: ${(props) => (props.isShiftDown ? 'cell' : 'default')};
@ -54,6 +61,7 @@ function useCaptureSelection(
) {
const [ref, state] = useRefAndState<SelectionBounds | null>(null)
const logger = useLogger('useCaptureSelection')
useDrag(
containerNode,
useMemo((): Parameters<typeof useDrag>[1] => {
@ -96,7 +104,11 @@ function useCaptureSelection(
ys: [ref.current!.ys[0], event.clientY - rect.top],
}
const selection = utils.boundsToSelection(layoutP, ref.current)
const selection = utils.boundsToSelection(
logger,
layoutP,
ref.current,
)
val(layoutP.selectionAtom).setState({current: selection})
},
onDragEnd(_dragHappened) {
@ -112,15 +124,70 @@ function useCaptureSelection(
}
namespace utils {
const collectForAggregatedChildren = (
logger: IUtilLogger,
layoutP: Pointer<SequenceEditorPanelLayout>,
leaf: SequenceEditorTree_SheetObject | SequenceEditorTree_PropWithChildren,
bounds: SelectionBounds,
selectionByObjectKey: DopeSheetSelection['byObjectKey'],
) => {
const sheetObject = leaf.sheetObject
const aggregatedKeyframes = collectAggregateKeyframesInPrism(logger, leaf)
const bottom = leaf.top + leaf.nodeHeight
if (bottom > bounds.ys[0]) {
for (const [position, keyframes] of aggregatedKeyframes.byPosition) {
if (position <= bounds.positions[0]) continue
if (position >= bounds.positions[1]) break
// yes selected
for (const keyframeWithTrack of keyframes) {
mutableSetDeep(
selectionByObjectKey,
(selectionByObjectKeyP) =>
// convenience for accessing a deep path which might not actually exist
// through the use of pointer proxy (so we don't have to deal with undeifned )
selectionByObjectKeyP[sheetObject.address.objectKey].byTrackId[
keyframeWithTrack.track.id
].byKeyframeId[keyframeWithTrack.kf.id],
true,
)
}
}
}
collectChildren(logger, layoutP, leaf, bounds, selectionByObjectKey)
}
const collectorByLeafType: {
[K in SequenceEditorTree_AllRowTypes['type']]?: (
logger: IUtilLogger,
layoutP: Pointer<SequenceEditorPanelLayout>,
leaf: Extract<SequenceEditorTree_AllRowTypes, {type: K}>,
bounds: Exclude<SelectionBounds, null>,
selection: DopeSheetSelection,
bounds: SelectionBounds,
selectionByObjectKey: DopeSheetSelection['byObjectKey'],
) => void
} = {
primitiveProp(layoutP, leaf, bounds, selection) {
propWithChildren(logger, layoutP, leaf, bounds, selectionByObjectKey) {
collectForAggregatedChildren(
logger,
layoutP,
leaf,
bounds,
selectionByObjectKey,
)
},
sheetObject(logger, layoutP, leaf, bounds, selectionByObjectKey) {
collectForAggregatedChildren(
logger,
layoutP,
leaf,
bounds,
selectionByObjectKey,
)
},
primitiveProp(logger, layoutP, leaf, bounds, selectionByObjectKey) {
const {sheetObject, trackId} = leaf
const trackData = val(
getStudio().atomP.historic.coreByProject[sheetObject.address.projectId]
@ -134,10 +201,13 @@ namespace utils {
if (kf.position >= bounds.positions[1]) break
mutableSetDeep(
selection,
(p) =>
p.byObjectKey[sheetObject.address.objectKey].byTrackId[trackId]
.byKeyframeId[kf.id],
selectionByObjectKey,
(selectionByObjectKeyP) =>
// convenience for accessing a deep path which might not actually exist
// through the use of pointer proxy (so we don't have to deal with undeifned )
selectionByObjectKeyP[sheetObject.address.objectKey].byTrackId[
trackId
].byKeyframeId[kf.id],
true,
)
}
@ -145,24 +215,29 @@ namespace utils {
}
const collectChildren = (
logger: IUtilLogger,
layoutP: Pointer<SequenceEditorPanelLayout>,
leaf: SequenceEditorTree_AllRowTypes,
bounds: Exclude<SelectionBounds, null>,
selection: DopeSheetSelection,
bounds: SelectionBounds,
selectionByObjectKey: DopeSheetSelection['byObjectKey'],
) => {
if ('children' in leaf) {
for (const sub of leaf.children) {
collectFromAnyLeaf(layoutP, sub, bounds, selection)
collectFromAnyLeaf(logger, layoutP, sub, bounds, selectionByObjectKey)
}
}
}
function collectFromAnyLeaf(
logger: IUtilLogger,
layoutP: Pointer<SequenceEditorPanelLayout>,
leaf: SequenceEditorTree_AllRowTypes,
bounds: Exclude<SelectionBounds, null>,
selection: DopeSheetSelection,
bounds: SelectionBounds,
selectionByObjectKey: DopeSheetSelection['byObjectKey'],
) {
// don't collect from non rendered
if (!leaf.shouldRender) return
if (
bounds.ys[0] > leaf.top + leaf.heightIncludingChildren ||
leaf.top > bounds.ys[1]
@ -171,20 +246,39 @@ namespace utils {
}
const collector = collectorByLeafType[leaf.type]
if (collector) {
collector(layoutP, leaf as $IntentionalAny, bounds, selection)
collector(
logger,
layoutP,
leaf as $IntentionalAny,
bounds,
selectionByObjectKey,
)
} else {
collectChildren(layoutP, leaf, bounds, selection)
collectChildren(logger, layoutP, leaf, bounds, selectionByObjectKey)
}
}
export function boundsToSelection(
logger: ILogger,
layoutP: Pointer<SequenceEditorPanelLayout>,
bounds: Exclude<SelectionBounds, null>,
bounds: SelectionBounds,
): DopeSheetSelection {
const selectionByObjectKey: DopeSheetSelection['byObjectKey'] = {}
bounds = sortBounds(bounds)
const tree = val(layoutP.tree)
collectFromAnyLeaf(
logger.utilFor.internal(),
layoutP,
tree,
bounds,
selectionByObjectKey,
)
const sheet = val(layoutP.tree.sheet)
const selection: DopeSheetSelection = {
return {
type: 'DopeSheetSelection',
byObjectKey: {},
byObjectKey: selectionByObjectKey,
getDragHandlers(origin) {
return {
debugName: 'DopeSheetSelectionView/boundsToSelection',
@ -204,23 +298,19 @@ namespace utils {
ignore: origin.domNode,
})
let delta: number
if (snapPos != null) {
delta = snapPos - origin.positionAtStartOfDrag
} else {
delta = toUnitSpace(dx)
}
const delta =
snapPos != null
? snapPos - origin.positionAtStartOfDrag
: toUnitSpace(dx)
tempTransaction = getStudio()!.tempTransaction(
tempTransaction = getStudio().tempTransaction(
({stateEditors}) => {
const transformKeyframes =
stateEditors.coreByProject.historic.sheetsById.sequence
.transformKeyframes
for (const objectKey of Object.keys(
selection.byObjectKey,
)) {
const {byTrackId} = selection.byObjectKey[objectKey]!
for (const objectKey of Object.keys(selectionByObjectKey)) {
const {byTrackId} = selectionByObjectKey[objectKey]!
for (const trackId of Object.keys(byTrackId)) {
const {byKeyframeId} = byTrackId[trackId]!
transformKeyframes({
@ -249,13 +339,13 @@ namespace utils {
}
},
delete() {
getStudio()!.transaction(({stateEditors}) => {
getStudio().transaction(({stateEditors}) => {
const deleteKeyframes =
stateEditors.coreByProject.historic.sheetsById.sequence
.deleteKeyframes
for (const objectKey of Object.keys(selection.byObjectKey)) {
const {byTrackId} = selection.byObjectKey[objectKey]!
for (const objectKey of Object.keys(selectionByObjectKey)) {
const {byTrackId} = selectionByObjectKey[objectKey]!
for (const trackId of Object.keys(byTrackId)) {
const {byKeyframeId} = byTrackId[trackId]!
deleteKeyframes({
@ -269,13 +359,6 @@ namespace utils {
})
},
}
bounds = sortBounds(bounds)
const tree = val(layoutP.tree)
collectFromAnyLeaf(layoutP, tree, bounds, selection)
return selection
}
}
@ -295,8 +378,8 @@ const sortBounds = (b: SelectionBounds): SelectionBounds => {
}
}
const SelectionRectangle: React.FC<{
state: Exclude<SelectionBounds, null>
const SelectionRectangle: React.VFC<{
state: SelectionBounds
layoutP: Pointer<SequenceEditorPanelLayout>
}> = ({state, layoutP}) => {
const atom = useValToAtom(state)

View file

@ -5,13 +5,15 @@ import {usePrism} from '@theatre/react'
import type {Pointer} from '@theatre/dataverse'
import {val} from '@theatre/dataverse'
import React from 'react'
import KeyframedTrack from './BasicKeyframedTrack/BasicKeyframedTrack'
import RightRow from './Row'
import BasicKeyframedTrack from './BasicKeyframedTrack/BasicKeyframedTrack'
import {useLogger} from '@theatre/studio/uiComponents/useLogger'
const PrimitivePropRow: React.FC<{
const PrimitivePropRow: React.VFC<{
leaf: SequenceEditorTree_PrimitiveProp
layoutP: Pointer<SequenceEditorPanelLayout>
}> = ({leaf, layoutP}) => {
const logger = useLogger('PrimitivePropRow', leaf.pathToProp.join())
return usePrism(() => {
const {sheetObject} = leaf
const {trackId} = leaf
@ -24,7 +26,7 @@ const PrimitivePropRow: React.FC<{
)
if (trackData?.type !== 'BasicKeyframedTrack') {
console.error(
logger.errorDev(
`trackData type ${trackData?.type} is not yet supported on the sequence editor`,
)
return (
@ -32,7 +34,11 @@ const PrimitivePropRow: React.FC<{
)
} else {
const node = (
<KeyframedTrack layoutP={layoutP} trackData={trackData} leaf={leaf} />
<BasicKeyframedTrack
layoutP={layoutP}
trackData={trackData}
leaf={leaf}
/>
)
return <RightRow leaf={leaf} isCollapsed={false} node={node}></RightRow>

View file

@ -8,15 +8,18 @@ import type {Pointer} from '@theatre/dataverse'
import React from 'react'
import PrimitivePropRow from './PrimitivePropRow'
import RightRow from './Row'
import AggregatedKeyframeTrack from './AggregatedKeyframeTrack/AggregatedKeyframeTrack'
import {collectAggregateKeyframesInPrism} from './collectAggregateKeyframes'
import {ProvideLogger, useLogger} from '@theatre/studio/uiComponents/useLogger'
export const decideRowByPropType = (
leaf: SequenceEditorTree_PropWithChildren | SequenceEditorTree_PrimitiveProp,
layoutP: Pointer<SequenceEditorPanelLayout>,
): React.ReactElement =>
leaf.type === 'propWithChildren' ? (
<PropWithChildrenRow
<RightPropWithChildrenRow
layoutP={layoutP}
leaf={leaf}
viewModel={leaf}
key={'prop' + leaf.pathToProp[leaf.pathToProp.length - 1]}
/>
) : (
@ -27,19 +30,42 @@ export const decideRowByPropType = (
/>
)
const PropWithChildrenRow: React.VFC<{
leaf: SequenceEditorTree_PropWithChildren
const RightPropWithChildrenRow: React.VFC<{
viewModel: SequenceEditorTree_PropWithChildren
layoutP: Pointer<SequenceEditorPanelLayout>
}> = ({leaf, layoutP}) => {
}> = ({viewModel, layoutP}) => {
const logger = useLogger(
'RightPropWithChildrenRow',
viewModel.pathToProp.join(),
)
return usePrism(() => {
const node = <div />
const aggregatedKeyframes = collectAggregateKeyframesInPrism(
logger.utilFor.internal(),
viewModel,
)
const node = (
<AggregatedKeyframeTrack
layoutP={layoutP}
aggregatedKeyframes={aggregatedKeyframes}
viewModel={viewModel}
/>
)
return (
<RightRow leaf={leaf} node={node} isCollapsed={leaf.isCollapsed}>
{leaf.children.map((propLeaf) =>
<ProvideLogger logger={logger}>
<RightRow
leaf={viewModel}
node={node}
isCollapsed={viewModel.isCollapsed}
>
{viewModel.children.map((propLeaf) =>
decideRowByPropType(propLeaf, layoutP),
)}
</RightRow>
</ProvideLogger>
)
}, [leaf, layoutP])
}, [viewModel, layoutP])
}
export default RightPropWithChildrenRow

View file

@ -2,7 +2,7 @@ import type {SequenceEditorTree_Row} from '@theatre/studio/panels/SequenceEditor
import React from 'react'
import styled from 'styled-components'
const Container = styled.li<{}>`
const RightRowContainer = styled.li<{}>`
margin: 0;
padding: 0;
list-style: none;
@ -10,7 +10,7 @@ const Container = styled.li<{}>`
position: relative;
`
const NodeWrapper = styled.div<{isEven: boolean}>`
const RightRowNodeWrapper = styled.div<{isEven: boolean}>`
box-sizing: border-box;
width: 100%;
position: relative;
@ -29,7 +29,7 @@ const NodeWrapper = styled.div<{isEven: boolean}>`
}
`
const Children = styled.ul`
const RightRowChildren = styled.ul`
margin: 0;
padding: 0;
list-style: none;
@ -46,24 +46,25 @@ const Children = styled.ul`
* Note that we don't need to change {@link calculateSequenceEditorTree} to be list-based. It can
* retain its hierarchy. It's just the DOM tree that should be list-based.
*/
const RightRow: React.FC<{
leaf: SequenceEditorTree_Row<unknown>
leaf: SequenceEditorTree_Row<string>
node: React.ReactElement
isCollapsed: boolean
}> = ({leaf, children, node, isCollapsed}) => {
const hasChildren = Array.isArray(children) && children.length > 0
return (
<Container>
<NodeWrapper
return leaf.shouldRender ? (
<RightRowContainer>
<RightRowNodeWrapper
style={{height: leaf.nodeHeight + 'px'}}
isEven={leaf.n % 2 === 0}
>
{node}
</NodeWrapper>
{hasChildren && <Children>{children}</Children>}
</Container>
)
</RightRowNodeWrapper>
{hasChildren && <RightRowChildren>{children}</RightRowChildren>}
</RightRowContainer>
) : null
}
export default RightRow

View file

@ -5,13 +5,32 @@ import type {Pointer} from '@theatre/dataverse'
import React from 'react'
import {decideRowByPropType} from './PropWithChildrenRow'
import RightRow from './Row'
import {useLogger} from '@theatre/studio/uiComponents/useLogger'
import {collectAggregateKeyframesInPrism} from './collectAggregateKeyframes'
import AggregatedKeyframeTrack from './AggregatedKeyframeTrack/AggregatedKeyframeTrack'
const RightSheetObjectRow: React.VFC<{
leaf: SequenceEditorTree_SheetObject
layoutP: Pointer<SequenceEditorPanelLayout>
}> = ({leaf, layoutP}) => {
const logger = useLogger(
`RightSheetObjectRow`,
leaf.sheetObject.address.objectKey,
)
return usePrism(() => {
const node = <div />
const aggregatedKeyframes = collectAggregateKeyframesInPrism(
logger.utilFor.internal(),
leaf,
)
const node = (
<AggregatedKeyframeTrack
layoutP={layoutP}
aggregatedKeyframes={aggregatedKeyframes}
viewModel={leaf}
/>
)
return (
<RightRow leaf={leaf} node={node} isCollapsed={leaf.isCollapsed}>
{leaf.children.map((leaf) => decideRowByPropType(leaf, layoutP))}

View file

@ -0,0 +1,130 @@
import getStudio from '@theatre/studio/getStudio'
import {val} from '@theatre/dataverse'
import type {
SequenceEditorTree_PropWithChildren,
SequenceEditorTree_SheetObject,
} from '@theatre/studio/panels/SequenceEditorPanel/layout/tree'
import type {SequenceTrackId} from '@theatre/shared/utils/ids'
import type {
Keyframe,
TrackData,
} from '@theatre/core/projects/store/types/SheetState_Historic'
import type {IUtilLogger} from '@theatre/shared/logger'
import {encodePathToProp} from '@theatre/shared/utils/addresses'
/**
* An index over a series of keyframes that have been collected from different tracks.
*
* Usually constructed via {@link collectAggregateKeyframesInPrism}.
*/
export type AggregatedKeyframes = {
byPosition: Map<number, KeyframeWithTrack[]>
tracks: TrackWithId[]
}
export type TrackWithId = {
id: SequenceTrackId
data: TrackData
}
export type KeyframeWithTrack = {
kf: Keyframe
track: TrackWithId
}
/**
* Collect {@link AggregatedKeyframes} information from the given tree row with children.
*
* Must be called within a `prism` context.
*
* Implementation progress 2/10:
* - This currently does a lot of duplicate work for each compound rows' compound rows.
* - This appears to have O(N) complexity with N being the number of "things" in the
* tree, thus we don't see an immediate need to cache it further.
* - If concerned, consider making a playground with a lot of objects to test this kind of thing.
*
* Note that we do not need to filter to only tracks that should be displayed, because we
* do not do anything counting or iterating over all tracks.
*
* Furthermore, we _could_ have been traversing the tree of the sheet and producing
* an aggreagte from that, but _that_ aggregate would not take into account
* things like filters in the `SequenceEditorPanel`, where the filter would exclude
* certain objects and props from the tree.
*
*/
export function collectAggregateKeyframesInPrism(
logger: IUtilLogger,
leaf: SequenceEditorTree_PropWithChildren | SequenceEditorTree_SheetObject,
): AggregatedKeyframes {
const sheetObject = leaf.sheetObject
const projectId = sheetObject.address.projectId
const sheetObjectTracksP =
getStudio().atomP.historic.coreByProject[projectId].sheetsById[
sheetObject.address.sheetId
].sequence.tracksByObject[sheetObject.address.objectKey]
const aggregatedKeyframes: AggregatedKeyframes[] = []
const childSimpleTracks: TrackWithId[] = []
for (const childLeaf of leaf.children) {
if (childLeaf.type === 'primitiveProp') {
const trackId = val(
sheetObjectTracksP.trackIdByPropPath[
encodePathToProp(childLeaf.pathToProp)
],
)
if (!trackId) {
logger.trace('missing track id?', {childLeaf})
continue
}
const trackData = val(sheetObjectTracksP.trackData[trackId])
if (!trackData) {
logger.trace('missing track data?', {trackId, childLeaf})
continue
}
childSimpleTracks.push({id: trackId, data: trackData})
} else if (childLeaf.type === 'propWithChildren') {
aggregatedKeyframes.push(
collectAggregateKeyframesInPrism(
logger.named('propWithChildren', childLeaf.pathToProp.join()),
childLeaf,
),
)
} else {
const _exhaustive: never = childLeaf
logger.error('unexpected kind of prop', {childLeaf})
}
}
logger.trace('see collected of children', {
aggregatedKeyframes,
childSimpleTracks,
})
const tracks = aggregatedKeyframes
.flatMap((a) => a.tracks)
.concat(childSimpleTracks)
const byPosition = new Map<number, KeyframeWithTrack[]>()
for (const track of tracks) {
const kfs = track.data.keyframes
for (let i = 0; i < kfs.length; i++) {
const kf = kfs[i]
let existing = byPosition.get(kf.position)
if (!existing) {
existing = []
byPosition.set(kf.position, existing)
}
existing.push({kf, track})
}
}
return {
byPosition,
tracks,
}
}

View file

@ -0,0 +1,94 @@
import {lighten} from 'polished'
import React from 'react'
import styled from 'styled-components'
import {DOT_SIZE_PX} from '@theatre/studio/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/SingleKeyframeDot'
const CONNECTOR_HEIGHT = DOT_SIZE_PX / 2 + 1
const CONNECTOR_WIDTH_UNSCALED = 1000
export type IConnectorThemeValues = {
isPopoverOpen: boolean
isSelected: boolean
}
export const CONNECTOR_THEME = {
normalColor: `#365b59`, // (greenish-blueish)ish
popoverOpenColor: `#817720`, // orangey yellowish
barColor: (values: IConnectorThemeValues) => {
const base = values.isPopoverOpen
? CONNECTOR_THEME.popoverOpenColor
: CONNECTOR_THEME.normalColor
return values.isSelected ? lighten(0.2, base) : base
},
hoverColor: (values: IConnectorThemeValues) => {
const base = values.isPopoverOpen
? CONNECTOR_THEME.popoverOpenColor
: CONNECTOR_THEME.normalColor
return values.isSelected ? lighten(0.4, base) : lighten(0.1, base)
},
}
const Container = styled.div<IConnectorThemeValues>`
position: absolute;
background: ${CONNECTOR_THEME.barColor};
height: ${CONNECTOR_HEIGHT}px;
width: ${CONNECTOR_WIDTH_UNSCALED}px;
left: 0;
top: -${CONNECTOR_HEIGHT / 2}px;
transform-origin: top left;
z-index: 0;
cursor: ew-resize;
&:after {
display: block;
position: absolute;
content: ' ';
top: -4px;
bottom: -4px;
left: 0;
right: 0;
}
&:hover {
background: ${CONNECTOR_THEME.hoverColor};
}
`
type IConnectorLineProps = React.PropsWithChildren<{
isPopoverOpen: boolean
/** TEMP: Remove once interactivity is added for aggregate? */
mvpIsInteractiveDisabled?: boolean
openPopover?: (event: React.MouseEvent) => void
isSelected: boolean
connectorLengthInUnitSpace: number
}>
export const ConnectorLine = React.forwardRef<
HTMLDivElement,
IConnectorLineProps
>((props, ref) => {
const themeValues: IConnectorThemeValues = {
isPopoverOpen: props.isPopoverOpen,
isSelected: props.isSelected,
}
return (
<Container
{...themeValues}
ref={ref}
style={{
// Previously we used scale3d, which had weird fuzzy rendering look in both FF & Chrome
transform: `scaleX(calc(var(--unitSpaceToScaledSpaceMultiplier) * ${
props.connectorLengthInUnitSpace / CONNECTOR_WIDTH_UNSCALED
}))`,
pointerEvents: props.mvpIsInteractiveDisabled ? 'none' : undefined,
}}
onClick={(e) => {
props.openPopover?.(e)
}}
>
{props.children}
</Container>
)
})

View file

@ -25,11 +25,16 @@ export function isKeyframeConnectionInSelection(
return false
}
/**
* Returns an array of all the selected keyframes
* that are connected to one another. Useful for changing
* the tweening in between keyframes.
*/
export function selectedKeyframeConnections(
projectId: ProjectId,
sheetId: SheetId,
selection: DopeSheetSelection | undefined,
): IDerivation<Array<[Keyframe, Keyframe]>> {
): IDerivation<Array<[left: Keyframe, right: Keyframe]>> {
return prism(() => {
if (selection === undefined) return []

View file

@ -0,0 +1,59 @@
import {pointerEventsAutoInNormalMode} from '@theatre/studio/css'
import {lockedCursorCssVarName} from '@theatre/studio/uiComponents/PointerEventsHandler'
import {css} from 'styled-components'
import SnapCursor from './SnapCursor.svg'
import {absoluteDims} from '@theatre/studio/utils/absoluteDims'
import DopeSnap from './DopeSnap'
import {includeLockFrameStampAttrs} from '@theatre/studio/panels/SequenceEditorPanel/FrameStampPositionProvider'
const HIT_ZONE_SIZE_PX = 12
const SNAP_CURSOR_SIZE_PX = 34
const BEING_DRAGGED_CLASS = 'beingDragged'
/**
* Helper CSS for consistent display of the `⸢⸤⸣⸥` thing
*/
export const DopeSnapHitZoneUI = {
BEING_DRAGGED_CLASS,
CSS: css`
position: absolute;
${absoluteDims(HIT_ZONE_SIZE_PX)};
${pointerEventsAutoInNormalMode};
&.${BEING_DRAGGED_CLASS} {
pointer-events: none !important;
}
`,
CSS_WHEN_SOMETHING_DRAGGING: css`
pointer-events: auto;
cursor: var(${lockedCursorCssVarName});
// ⸢⸤⸣⸥ thing
// This box extends the hitzone so the user does not
// accidentally leave the hitzone
&:hover:after {
position: absolute;
top: calc(50% - ${SNAP_CURSOR_SIZE_PX / 2}px);
left: calc(50% - ${SNAP_CURSOR_SIZE_PX / 2}px);
width: ${SNAP_CURSOR_SIZE_PX}px;
height: ${SNAP_CURSOR_SIZE_PX}px;
display: block;
content: ' ';
background: url(${SnapCursor}) no-repeat 100% 100%;
// This icon might also fit: GiConvergenceTarget
}
`,
/** Intrinsic element props for `<HitZone/>`s */
reactProps(config: {position: number; isDragging: boolean}) {
return {
// `data-pos` and `includeLockFrameStampAttrs` are used by FrameStampPositionProvider
// in order to handle snapping the playhead. Adding these props effectively
// causes the playhead to "snap" to the marker on mouse over.
// `pointerEventsAutoInNormalMode` and `lockedCursorCssVarName` in the CSS above are also
// used to make this behave correctly.
...includeLockFrameStampAttrs(config.position),
...DopeSnap.includePositionSnapAttrs(config.position),
className: config.isDragging ? DopeSnapHitZoneUI.BEING_DRAGGED_CLASS : '',
}
},
}

View file

@ -1,7 +1,6 @@
import type {Pointer} from '@theatre/dataverse'
import {val} from '@theatre/dataverse'
import {useVal} from '@theatre/react'
import {pointerEventsAutoInNormalMode} from '@theatre/studio/css'
import getStudio from '@theatre/studio/getStudio'
import {
lockedCursorCssVarName,
@ -11,33 +10,23 @@ import useContextMenu from '@theatre/studio/uiComponents/simpleContextMenu/useCo
import useRefAndState from '@theatre/studio/utils/useRefAndState'
import React, {useMemo, useRef} from 'react'
import styled from 'styled-components'
import {
includeLockFrameStampAttrs,
useLockFrameStampPosition,
} from '@theatre/studio/panels/SequenceEditorPanel/FrameStampPositionProvider'
import {useLockFrameStampPosition} from '@theatre/studio/panels/SequenceEditorPanel/FrameStampPositionProvider'
import type {SequenceEditorPanelLayout} from '@theatre/studio/panels/SequenceEditorPanel/layout/layout'
import type {SequenceMarkerId} from '@theatre/shared/utils/ids'
import type {SheetAddress} from '@theatre/shared/utils/addresses'
import SnapCursor from './SnapCursor.svg'
import useDrag from '@theatre/studio/uiComponents/useDrag'
import type {UseDragOpts} from '@theatre/studio/uiComponents/useDrag'
import type {CommitOrDiscard} from '@theatre/studio/StudioStore/StudioStore'
import type {StudioHistoricStateSequenceEditorMarker} from '@theatre/studio/store/types'
import {zIndexes} from '@theatre/studio/panels/SequenceEditorPanel/SequenceEditorPanel'
import DopeSnap from './DopeSnap'
import {absoluteDims} from '@theatre/studio/utils/absoluteDims'
import {DopeSnapHitZoneUI} from './DopeSnapHitZoneUI'
const MARKER_SIZE_W_PX = 12
const MARKER_SIZE_H_PX = 12
const HIT_ZONE_SIZE_PX = 12
const SNAP_CURSOR_SIZE_PX = 34
const MARKER_HOVER_SIZE_W_PX = MARKER_SIZE_W_PX * 2
const MARKER_HOVER_SIZE_H_PX = MARKER_SIZE_H_PX * 2
const dims = (w: number, h = w) => `
left: ${w * -0.5}px;
top: ${h * -0.5}px;
width: ${w}px;
height: ${h}px;
`
const MarkerDotContainer = styled.div`
position: absolute;
@ -48,7 +37,7 @@ const MarkerDotContainer = styled.div`
const MarkerVisualDotSVGContainer = styled.div`
position: absolute;
${dims(MARKER_SIZE_W_PX, MARKER_SIZE_H_PX)}
${absoluteDims(MARKER_SIZE_W_PX, MARKER_SIZE_H_PX)}
pointer-events: none;
`
@ -73,53 +62,35 @@ const MarkerVisualDot = React.memo(() => (
))
const HitZone = styled.div`
position: absolute;
${dims(HIT_ZONE_SIZE_PX)};
z-index: 1;
cursor: ew-resize;
${pointerEventsAutoInNormalMode};
${DopeSnapHitZoneUI.CSS}
// :not dragging marker to ensure that markers don't snap to other markers
// this works because only one marker track (so this technique is not used by keyframes...)
#pointer-root.draggingPositionInSequenceEditor:not(.draggingMarker) & {
${DopeSnapHitZoneUI.CSS_WHEN_SOMETHING_DRAGGING}
}
// "All instances of this component <Mark/> inside #pointer-root when it has the .draggingPositionInSequenceEditor class"
// ref: https://styled-components.com/docs/basics#pseudoelements-pseudoselectors-and-nesting
#pointer-root.draggingPositionInSequenceEditor:not(.draggingMarker) &,
#pointer-root.draggingPositionInSequenceEditor &.beingDragged {
#pointer-root.draggingPositionInSequenceEditor
&.${DopeSnapHitZoneUI.BEING_DRAGGED_CLASS} {
pointer-events: auto;
cursor: var(${lockedCursorCssVarName});
}
#pointer-root.draggingPositionInSequenceEditor:not(.draggingMarker) & {
pointer-events: auto;
cursor: var(${lockedCursorCssVarName});
// ⸢⸤⸣⸥ thing
// This box extends the hitzone so the user does not
// accidentally leave the hitzone
&:hover:after {
position: absolute;
top: calc(50% - ${SNAP_CURSOR_SIZE_PX / 2}px);
left: calc(50% - ${SNAP_CURSOR_SIZE_PX / 2}px);
width: ${SNAP_CURSOR_SIZE_PX}px;
height: ${SNAP_CURSOR_SIZE_PX}px;
display: block;
content: ' ';
background: url(${SnapCursor}) no-repeat 100% 100%;
// This icon might also fit: GiConvergenceTarget
}
}
&.beingDragged {
pointer-events: none !important;
}
&:hover
+ ${MarkerVisualDotSVGContainer},
&.beingDragged
// notice , "or" in CSS
&.${DopeSnapHitZoneUI.BEING_DRAGGED_CLASS}
+ ${MarkerVisualDotSVGContainer} {
${dims(MARKER_HOVER_SIZE_W_PX, MARKER_HOVER_SIZE_H_PX)}
${absoluteDims(MARKER_HOVER_SIZE_W_PX, MARKER_HOVER_SIZE_H_PX)}
}
`
type IMarkerDotProps = {
layoutP: Pointer<SequenceEditorPanelLayout>
markerId: SequenceMarkerId
@ -194,14 +165,10 @@ const MarkerDotVisible: React.VFC<IMarkerDotVisibleProps> = ({
{contextMenu}
<HitZone
ref={markRef}
// `data-pos` and `includeLockFrameStampAttrs` are used by FrameStampPositionProvider
// in order to handle snapping the playhead. Adding these props effectively
// causes the playhead to "snap" to the marker on mouse over.
// `pointerEventsAutoInNormalMode` and `lockedCursorCssVarName` in the CSS above are also
// used to make this behave correctly.
{...includeLockFrameStampAttrs(marker.position)}
{...DopeSnap.includePositionSnapAttrs(marker.position)}
className={isDragging ? 'beingDragged' : ''}
{...DopeSnapHitZoneUI.reactProps({
isDragging,
position: marker.position,
})}
/>
<MarkerVisualDot />
</>

View file

@ -76,11 +76,13 @@ const Thumb = styled.div`
${pointerEventsAutoInNormalMode};
&.seeking {
${Container}.seeking > & {
pointer-events: none !important;
}
#pointer-root.draggingPositionInSequenceEditor &:not(.seeking) {
#pointer-root.draggingPositionInSequenceEditor
${Container}:not(.seeking)
> & {
pointer-events: auto;
cursor: var(${lockedCursorCssVarName});
}
@ -203,13 +205,13 @@ const Playhead: React.FC<{layoutP: Pointer<SequenceEditorPanelLayout>}> = ({
const gestureHandlers = useMemo((): Parameters<typeof useDrag>[1] => {
return {
debugName: 'Playhead',
debugName: 'RightOverlay/Playhead',
onDragStart() {
const setIsSeeking = val(layoutP.seeker.setIsSeeking)
const sequence = val(layoutP.sheet).getSequence()
const posBeforeSeek = sequence.position
const scaledSpaceToUnitSpace = val(layoutP.scaledSpace.toUnitSpace)
const setIsSeeking = val(layoutP.seeker.setIsSeeking)
setIsSeeking(true)
return {
@ -232,7 +234,7 @@ const Playhead: React.FC<{layoutP: Pointer<SequenceEditorPanelLayout>}> = ({
}
},
}
}, [])
}, [layoutP, thumbNode])
const [isDragging] = useDrag(thumbNode, gestureHandlers)

View file

@ -16,12 +16,35 @@ import logger from '@theatre/shared/logger'
import {titleBarHeight} from '@theatre/studio/panels/BasePanel/common'
import type {Studio} from '@theatre/studio/Studio'
export type SequenceEditorTree_Row<Type> = {
type: Type
/**
* Base "view model" for each row with common
* required information such as row heights & depth.
*/
export type SequenceEditorTree_Row<TypeName extends string> = {
/** type of this row, e.g. `"sheet"` or `"sheetObject"` */
type: TypeName
/** Height of just the row in pixels */
nodeHeight: number
/** Height of the row + height with children in pixels */
heightIncludingChildren: number
/** Visual indentation */
depth: number
/**
* This is a part of the tree, but it is not rendered at all,
* and it doesn't contribute to height.
*
* In the future, if we have a filtering mechanism like "show only position props",
* this would not be the place to make false, that node should just not be included
* in the tree at all, so it doesn't affect aggregate keyframes.
*/
shouldRender: boolean
/**
* Distance in pixels from the top of this row to the row container's top
* This can be used to help figure out what's being box selected (marquee).
*/
top: number
/** Row number (e.g. for correctly styling even / odd alternating styles) */
n: number
}
@ -78,26 +101,31 @@ export const calculateSequenceEditorTree = (
prism.ensurePrism()
let topSoFar = titleBarHeight
let nSoFar = 0
const rootShouldRender = true
const tree: SequenceEditorTree = {
type: 'sheet',
sheet,
children: [],
shouldRender: rootShouldRender,
top: topSoFar,
depth: -1,
n: nSoFar,
nodeHeight: 0, // always 0
heightIncludingChildren: -1, // will define this later
}
nSoFar += 1
const collapsableP =
if (rootShouldRender) {
nSoFar += 1
}
const collapsableItemSetP =
studio.atomP.ahistoric.projects.stateByProjectId[sheet.address.projectId]
.stateBySheetId[sheet.address.sheetId].sequence.collapsableItems
for (const sheetObject of Object.values(val(sheet.objectsP))) {
if (sheetObject) {
addObject(sheetObject, tree.children, tree.depth + 1)
addObject(sheetObject, tree.children, tree.depth + 1, rootShouldRender)
}
}
tree.heightIncludingChildren = topSoFar - tree.top
@ -106,6 +134,7 @@ export const calculateSequenceEditorTree = (
sheetObject: SheetObject,
arrayOfChildren: Array<SequenceEditorTree_SheetObject>,
level: number,
shouldRender: boolean,
) {
const trackSetups = val(
sheetObject.template.getMapOfValidSequenceTracks_forStudio(),
@ -114,27 +143,34 @@ export const calculateSequenceEditorTree = (
if (Object.keys(trackSetups).length === 0) return
const isCollapsedP =
collapsableP.byId[createStudioSheetItemKey.forSheetObject(sheetObject)]
.isCollapsed
collapsableItemSetP.byId[
createStudioSheetItemKey.forSheetObject(sheetObject)
].isCollapsed
const isCollapsed = valueDerivation(isCollapsedP).getValue() ?? false
const row: SequenceEditorTree_SheetObject = {
type: 'sheetObject',
isCollapsed: isCollapsed,
isCollapsed,
shouldRender,
top: topSoFar,
children: [],
depth: level,
n: nSoFar,
sheetObject: sheetObject,
nodeHeight: HEIGHT_OF_ANY_TITLE,
nodeHeight: shouldRender ? HEIGHT_OF_ANY_TITLE : 0,
// Question: Why -1? Is this relevant for "shouldRender"?
// Perhaps this is to indicate this does not have a valid value.
heightIncludingChildren: -1,
}
arrayOfChildren.push(row)
if (shouldRender) {
nSoFar += 1
// As we add rows to the tree, top to bottom, we accumulate the pixel
// distance to the top of the tree from the bottom of the current row:
topSoFar += row.nodeHeight
if (!isCollapsed) {
}
addProps(
sheetObject,
trackSetups,
@ -142,8 +178,9 @@ export const calculateSequenceEditorTree = (
sheetObject.template.config,
row.children,
level + 1,
shouldRender && !isCollapsed,
)
}
row.heightIncludingChildren = topSoFar - row.top
}
@ -156,6 +193,7 @@ export const calculateSequenceEditorTree = (
SequenceEditorTree_PrimitiveProp | SequenceEditorTree_PropWithChildren
>,
level: number,
shouldRender: boolean,
) {
for (const [propKey, setupOrSetups] of Object.entries(trackSetups)) {
const propConfig = parentPropConfig.props[propKey]
@ -166,6 +204,7 @@ export const calculateSequenceEditorTree = (
propConfig,
arrayOfChildren,
level,
shouldRender,
)
}
}
@ -179,6 +218,7 @@ export const calculateSequenceEditorTree = (
SequenceEditorTree_PrimitiveProp | SequenceEditorTree_PropWithChildren
>,
level: number,
shouldRender: boolean,
) {
if (conf.type === 'compound') {
const trackMapping =
@ -190,6 +230,7 @@ export const calculateSequenceEditorTree = (
conf,
arrayOfChildren,
level,
shouldRender,
)
} else if (conf.type === 'enum') {
logger.warn('Prop type enum is not yet supported in the sequence editor')
@ -203,6 +244,7 @@ export const calculateSequenceEditorTree = (
conf,
arrayOfChildren,
level,
shouldRender,
)
}
}
@ -216,30 +258,34 @@ export const calculateSequenceEditorTree = (
SequenceEditorTree_PrimitiveProp | SequenceEditorTree_PropWithChildren
>,
level: number,
shouldRender: boolean,
) {
const isCollapsedP =
collapsableP.byId[
collapsableItemSetP.byId[
createStudioSheetItemKey.forSheetObjectProp(sheetObject, pathToProp)
].isCollapsed
const isCollapsed = valueDerivation(isCollapsedP).getValue() ?? false
const row: SequenceEditorTree_PropWithChildren = {
type: 'propWithChildren',
isCollapsed: isCollapsed,
isCollapsed,
pathToProp,
sheetObject: sheetObject,
shouldRender,
top: topSoFar,
children: [],
nodeHeight: HEIGHT_OF_ANY_TITLE,
nodeHeight: shouldRender ? HEIGHT_OF_ANY_TITLE : 0,
heightIncludingChildren: -1,
depth: level,
trackMapping,
n: nSoFar,
}
arrayOfChildren.push(row)
if (shouldRender) {
topSoFar += row.nodeHeight
if (!isCollapsed) {
nSoFar += 1
}
addProps(
sheetObject,
@ -248,8 +294,10 @@ export const calculateSequenceEditorTree = (
conf,
row.children,
level + 1,
// collapsed shouldn't render child props
shouldRender && !isCollapsed,
)
}
// }
row.heightIncludingChildren = topSoFar - row.top
}
@ -262,6 +310,7 @@ export const calculateSequenceEditorTree = (
SequenceEditorTree_PrimitiveProp | SequenceEditorTree_PropWithChildren
>,
level: number,
shouldRender: boolean,
) {
const row: SequenceEditorTree_PrimitiveProp = {
type: 'primitiveProp',
@ -269,9 +318,10 @@ export const calculateSequenceEditorTree = (
depth: level,
sheetObject: sheetObject,
pathToProp,
shouldRender,
top: topSoFar,
nodeHeight: HEIGHT_OF_ANY_TITLE,
heightIncludingChildren: HEIGHT_OF_ANY_TITLE,
nodeHeight: shouldRender ? HEIGHT_OF_ANY_TITLE : 0,
heightIncludingChildren: shouldRender ? HEIGHT_OF_ANY_TITLE : 0,
trackId,
n: nSoFar,
}

View file

@ -1,4 +1,5 @@
import type {
BasicKeyframedTrack,
HistoricPositionalSequence,
Keyframe,
SheetState_Historic,
@ -609,10 +610,13 @@ namespace stateEditors {
const trackId = generateSequenceTrackId()
tracks.trackData[trackId] = {
const track: BasicKeyframedTrack = {
type: 'BasicKeyframedTrack',
__debugName: `${p.objectKey}:${pathEncoded}`,
keyframes: [],
}
tracks.trackData[trackId] = track
tracks.trackIdByPropPath[pathEncoded] = trackId
}
@ -622,7 +626,7 @@ namespace stateEditors {
},
) {
const tracks = _ensureTracksOfObject(p)
const encodedPropPath = JSON.stringify(p.pathToProp)
const encodedPropPath = encodePathToProp(p.pathToProp)
const trackId = tracks.trackIdByPropPath[encodedPropPath]
if (typeof trackId !== 'string') return

View file

@ -2,6 +2,7 @@ import {pointerEventsAutoInNormalMode} from '@theatre/studio/css'
import useBoundingClientRect from '@theatre/studio/uiComponents/useBoundingClientRect'
import transparentize from 'polished/lib/color/transparentize'
import type {ElementType} from 'react'
import {useMemo} from 'react'
import {useContext} from 'react'
import React, {useLayoutEffect, useState} from 'react'
import {createPortal} from 'react-dom'
@ -18,6 +19,8 @@ const minWidth = 190
*/
const pointerDistanceThreshold = 20
const SHOW_OPTIONAL_MENU_TITLE = true
const MenuContainer = styled.ul`
position: absolute;
min-width: ${minWidth}px;
@ -33,6 +36,13 @@ const MenuContainer = styled.ul`
${pointerEventsAutoInNormalMode};
border-radius: 3px;
`
const MenuTitle = styled.div`
padding: 4px 10px;
border-bottom: 1px solid #6262626d;
color: #adadadb3;
font-size: 11px;
font-weight: 500;
`
export type IContextMenuItemCustomNodeRenderFn = (controls: {
closeMenu(): void
@ -49,8 +59,13 @@ export type IContextMenuItemsValue =
| IContextMenuItem[]
| (() => IContextMenuItem[])
/**
* TODO let's make sure that triggering a context menu would close
* the other open context menu (if one _is_ open).
*/
const ContextMenu: React.FC<{
items: IContextMenuItemsValue
displayName?: string
clickPoint: {clientX: number; clientY: number}
onRequestClose: () => void
}> = (props) => {
@ -109,10 +124,28 @@ const ContextMenu: React.FC<{
if (ev.key === 'Escape') props.onRequestClose()
})
const items = Array.isArray(props.items) ? props.items : props.items()
const items = useMemo(() => {
const itemsArr = Array.isArray(props.items) ? props.items : props.items()
if (itemsArr.length > 0) return itemsArr
else
return [
{
/**
* TODO Need design for this
*/
label: props.displayName
? `No actions for ${props.displayName}`
: `No actions found`,
enabled: false,
},
]
}, [props.items])
return createPortal(
<MenuContainer ref={setContainer}>
{SHOW_OPTIONAL_MENU_TITLE && props.displayName ? (
<MenuTitle>{props.displayName}</MenuTitle>
) : null}
{items.map((item, i) => (
<Item
key={`item-${i}`}

View file

@ -14,8 +14,8 @@ const ItemContainer = styled.li<{enabled: boolean}>`
font-size: 11px;
font-weight: 400;
position: relative;
pointer-events: ${(props) => (props.enabled ? 'auto' : 'none')};
color: ${(props) => (props.enabled ? 'white' : '#AAA')};
color: ${(props) => (props.enabled ? 'white' : '#8f8f8f')};
cursor: ${(props) => (props.enabled ? 'normal' : 'not-allowed')};
&:after {
position: absolute;
@ -28,7 +28,8 @@ const ItemContainer = styled.li<{enabled: boolean}>`
}
&:hover:after {
background-color: rgba(63, 174, 191, 0.75);
background-color: ${(props) =>
props.enabled ? 'rgba(63, 174, 191, 0.75)' : 'initial'};
}
`
@ -43,6 +44,7 @@ const Item: React.FC<{
<ItemContainer
onClick={props.enabled ? props.onClick : noop}
enabled={props.enabled}
title={props.enabled ? undefined : 'Disabled'}
>
<ItemLabel>{props.label}</ItemLabel>
</ItemContainer>

View file

@ -1,5 +1,5 @@
import type {VoidFn} from '@theatre/shared/utils/types'
import React from 'react'
import React, {useEffect} from 'react'
import ContextMenu from './ContextMenu/ContextMenu'
import type {
IContextMenuItemsValue,
@ -21,15 +21,24 @@ export default function useContextMenu(
target: HTMLElement | SVGElement | null,
opts: IRequestContextMenuOptions & {
menuItems: IContextMenuItemsValue
displayName?: string
onOpen?: () => void
},
): [node: React.ReactNode, close: VoidFn, isOpen: boolean] {
const [status, close] = useRequestContextMenu(target, opts)
useEffect(() => {
if (status.isOpen) {
opts.onOpen?.()
}
}, [status.isOpen, opts.onOpen])
const node = !status.isOpen ? (
emptyNode
) : (
<ContextMenu
items={opts.menuItems}
displayName={opts.displayName}
clickPoint={status.event}
onRequestClose={close}
/>

View file

@ -0,0 +1,24 @@
import type {ILogger} from '@theatre/shared/logger'
import React, {useContext, useMemo} from 'react'
const loggerContext = React.createContext<ILogger>(null!)
export function ProvideLogger(
props: React.PropsWithChildren<{logger: ILogger}>,
) {
return (
<loggerContext.Provider value={props.logger}>
{props.children}
</loggerContext.Provider>
)
}
export function useLogger(name?: string, key?: number | string) {
const parentLogger = useContext(loggerContext)
return useMemo(() => {
if (name) {
return parentLogger.named(name, key)
} else {
return parentLogger
}
}, [parentLogger, name, key])
}

View file

@ -0,0 +1,6 @@
export const absoluteDims = (w: number, h = w) => `
left: ${w * -0.5}px;
top: ${h * -0.5}px;
width: ${w}px;
height: ${h}px;
`