diff --git a/theatre/core/src/projects/store/types/SheetState_Historic.ts b/theatre/core/src/projects/store/types/SheetState_Historic.ts index 7ac914b..6b527d8 100644 --- a/theatre/core/src/projects/store/types/SheetState_Historic.ts +++ b/theatre/core/src/projects/store/types/SheetState_Historic.ts @@ -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 + // 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 + + /** + * 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 } > } +/** + * 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 = { + 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. diff --git a/theatre/core/src/sheetObjects/SheetObject.ts b/theatre/core/src/sheetObjects/SheetObject.ts index 8ab798a..2a3a26c 100644 --- a/theatre/core/src/sheetObjects/SheetObject.ts +++ b/theatre/core/src/sheetObjects/SheetObject.ts @@ -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, diff --git a/theatre/core/src/sheetObjects/SheetObjectTemplate.ts b/theatre/core/src/sheetObjects/SheetObjectTemplate.ts index 64f7307..95d18e9 100644 --- a/theatre/core/src/sheetObjects/SheetObjectTemplate.ts +++ b/theatre/core/src/sheetObjects/SheetObjectTemplate.ts @@ -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 } /** diff --git a/theatre/shared/src/_logger/logger.test-helpers.ts b/theatre/shared/src/_logger/logger.test-helpers.ts index b14e8cd..4b42063 100644 --- a/theatre/shared/src/_logger/logger.test-helpers.ts +++ b/theatre/shared/src/_logger/logger.test-helpers.ts @@ -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, ) { return { diff --git a/theatre/shared/src/_logger/logger.test.ts b/theatre/shared/src/_logger/logger.test.ts index ef98d86..af3cd87 100644 --- a/theatre/shared/src/_logger/logger.test.ts +++ b/theatre/shared/src/_logger/logger.test.ts @@ -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/}]) diff --git a/theatre/shared/src/_logger/logger.ts b/theatre/shared/src/_logger/logger.ts index ac70b19..656dbcb 100644 --- a/theatre/shared/src/_logger/logger.ts +++ b/theatre/shared/src/_logger/logger.ts @@ -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() }, } }, diff --git a/theatre/shared/src/logger.ts b/theatre/shared/src/logger.ts index 74eb7c2..fb8f0c1 100644 --- a/theatre/shared/src/logger.ts +++ b/theatre/shared/src/logger.ts @@ -34,4 +34,4 @@ internal.configureLogging({ export default internal .getLogger() .named('Theatre.js (default logger)') - .downgrade.dev() + .utilFor.dev() diff --git a/theatre/shared/src/utils/addresses.ts b/theatre/shared/src/utils/addresses.ts index 584bcca..5348304 100644 --- a/theatre/shared/src/utils/addresses.ts +++ b/theatre/shared/src/utils/addresses.ts @@ -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 -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) diff --git a/theatre/studio/src/UIRoot/PointerCapturing.tsx b/theatre/studio/src/UIRoot/PointerCapturing.tsx index ebafefd..89b1cf2 100644 --- a/theatre/studio/src/UIRoot/PointerCapturing.tsx +++ b/theatre/studio/src/UIRoot/PointerCapturing.tsx @@ -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( null as $IntentionalAny, ) -// const ProviderChildren: React.FC<{children?: React.ReactNode}> = function const ProviderChildrenMemo: React.FC<{}> = React.memo(({children}) => ( <>{children} diff --git a/theatre/studio/src/UIRoot/UIRoot.tsx b/theatre/studio/src/UIRoot/UIRoot.tsx index 758825f..80d06a2 100644 --- a/theatre/studio/src/UIRoot/UIRoot.tsx +++ b/theatre/studio/src/UIRoot/UIRoot.tsx @@ -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( 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,33 +80,35 @@ export default function UIRoot() { const initialised = val(studio.atomP.ephemeral.initialised) return !initialised ? null : ( - - - - - - <> - - - - {} - {} - - - - - - + + + + + + + <> + + + + + + + + + + + + ) }, [studio, portalLayerRef, portalLayer]) diff --git a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Left/AnyCompositeRow.tsx b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Left/AnyCompositeRow.tsx index dbfa4c2..e912335 100644 --- a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Left/AnyCompositeRow.tsx +++ b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Left/AnyCompositeRow.tsx @@ -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 + leaf: SequenceEditorTree_Row label: React.ReactNode toggleSelect?: VoidFn toggleCollapsed: VoidFn @@ -85,9 +85,9 @@ const AnyCompositeRow: React.FC<{ }) => { const hasChildren = Array.isArray(children) && children.length > 0 - return ( - -
+ - + - - {label} -
- {hasChildren && {children}} -
- ) + + {label} + + {hasChildren && {children}} + + ) : null } export default AnyCompositeRow diff --git a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Left/PrimitivePropRow.tsx b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Left/PrimitivePropRow.tsx index c3c71ea..563e452 100644 --- a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Left/PrimitivePropRow.tsx +++ b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Left/PrimitivePropRow.tsx @@ -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 = () => ( ) -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 ( - - + - {label} + {label} {controlIndicators} - - - - + + + ) } diff --git a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Left/PropWithChildrenRow.tsx b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Left/PropWithChildrenRow.tsx index 8f511d4..988f00a 100644 --- a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Left/PropWithChildrenRow.tsx +++ b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Left/PropWithChildrenRow.tsx @@ -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 => - leaf.type === 'propWithChildren' ? ( - +): React.ReactElement => { + const key = 'prop' + leaf.pathToProp[leaf.pathToProp.length - 1] + return leaf.shouldRender ? ( + leaf.type === 'propWithChildren' ? ( + + ) : ( + + ) ) : ( - + ) +} const PropWithChildrenRow: React.VFC<{ leaf: SequenceEditorTree_PropWithChildren diff --git a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/AggregatedKeyframeTrack/AggregateKeyframeEditor.tsx b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/AggregatedKeyframeTrack/AggregateKeyframeEditor.tsx new file mode 100644 index 0000000..57aa6c3 --- /dev/null +++ b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/AggregatedKeyframeTrack/AggregateKeyframeEditor.tsx @@ -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 + viewModel: + | SequenceEditorTree_PropWithChildren + | SequenceEditorTree_SheetObject + selection: undefined | DopeSheetSelection +} + +const AggregateKeyframeEditor: React.VFC = ( + 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 ( + + + {connected ? ( + + ) : ( + noConnector + )} + + ) +} + +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, +) { + return ( + <> + + + {props.isAllHere ? ( + + ) : ( + + )} + + + ) +} + +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) => ( + + + + +) + +// when the aggregate keyframes are sparse across tracks at this position +const AggregateDotSomeHereSvg = (theme: IDotThemeValues) => ( + + + +) + +export default AggregateKeyframeEditor diff --git a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/AggregatedKeyframeTrack/AggregatedKeyframeTrack.tsx b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/AggregatedKeyframeTrack/AggregatedKeyframeTrack.tsx new file mode 100644 index 0000000..b91df57 --- /dev/null +++ b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/AggregatedKeyframeTrack/AggregatedKeyframeTrack.tsx @@ -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 +} + +type _AggSelection = { + selectedPositions: Map + 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( + 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) => ( + + )) + + return ( + + {keyframeEditors} + {contextMenu} + + ) +} + +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, + 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 [] + }, + }) +} diff --git a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/BasicKeyframedTrack.tsx b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/BasicKeyframedTrack.tsx index 29f32f4..1e1b1c9 100644 --- a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/BasicKeyframedTrack.tsx +++ b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/BasicKeyframedTrack.tsx @@ -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 = React.memo( +const BasicKeyframedTrack: React.VFC = React.memo( (props) => { const {layoutP, trackData, leaf} = props const [containerRef, containerNode] = useRefAndState( @@ -57,7 +57,7 @@ const BasicKeyframedTrack: React.FC = React.memo( ) const keyframeEditors = trackData.keyframes.map((kf, index) => ( - { const selectionKeyframes = val(getStudio()!.atomP.ahistoric.clipboard.keyframes) || [] - if (selectionKeyframes.length > 0) { - return [pasteKeyframesContextMenuItem(props, selectionKeyframes)] - } else { - return [] - } + return [pasteKeyframesContextMenuItem(props, selectionKeyframes)] }, }) } @@ -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() diff --git a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/Connector.tsx b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/BasicKeyframeConnector.tsx similarity index 80% rename from theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/Connector.tsx rename to theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/BasicKeyframeConnector.tsx index b86c572..e0d3c1e 100644 --- a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/Connector.tsx +++ b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/BasicKeyframeConnector.tsx @@ -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` - 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[0] +type IBasicKeyframeConnectorProps = ISingleKeyframeEditorProps -const Connector: React.FC = (props) => { +const BasicKeyframeConnector: React.VFC = ( + props, +) => { const {index, trackData} = props const cur = trackData.keyframes[index] const next = trackData.keyframes[index + 1] @@ -140,28 +94,26 @@ const Connector: React.FC = (props) => { } return ( - { + connectorLengthInUnitSpace={connectorLengthInUnitSpace} + isPopoverOpen={isPopoverOpen} + isSelected={!!props.selection} + openPopover={(e) => { if (node) openPopover(e, node) }} > {popoverNode} {contextMenu} - + ) } +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, diff --git a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/CurveEditorPopover/CurveEditorPopover.tsx b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/CurveEditorPopover/CurveEditorPopover.tsx index cf102f6..f6d226a 100644 --- a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/CurveEditorPopover/CurveEditorPopover.tsx +++ b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/CurveEditorPopover/CurveEditorPopover.tsx @@ -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[0] +} & ISingleKeyframeEditorProps const CurveEditorPopover: React.FC = (props) => { ////// `tempTransaction` ////// diff --git a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/DeterminePropEditorForKeyframe.tsx b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/DeterminePropEditorForSingleKeyframe.tsx similarity index 77% rename from theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/DeterminePropEditorForKeyframe.tsx rename to theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/DeterminePropEditorForSingleKeyframe.tsx index a1d11cf..3843c22 100644 --- a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/DeterminePropEditorForKeyframe.tsx +++ b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/DeterminePropEditorForSingleKeyframe.tsx @@ -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 = { +type IDeterminePropEditorForSingleKeyframeProps< + K extends PropTypeConfig['type'], +> = { editingTools: IEditingTools['valueType']> propConfig: PropConfigForType keyframeValue: PropConfigForType['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, +export function DeterminePropEditorForSingleKeyframe( + p: IDeterminePropEditorForSingleKeyframeProps, ) { const propConfig = p.propConfig @@ -66,9 +68,9 @@ export function DeterminePropEditorForKeyframe( const PropEditor = simplePropEditorByPropType[propConfig.type] return ( - - {p.displayLabel} - + {p.displayLabel} + @@ -78,7 +80,7 @@ export function DeterminePropEditorForKeyframe( editingTools={p.editingTools} keyframeValue={p.keyframeValue} /> - + ) } } diff --git a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/DeterminePropEditorForKeyframe/KeyframeSimplePropEditor.tsx b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/DeterminePropEditorForSingleKeyframe/SingleKeyframeSimplePropEditor.tsx similarity index 76% rename from theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/DeterminePropEditorForKeyframe/KeyframeSimplePropEditor.tsx rename to theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/DeterminePropEditorForSingleKeyframe/SingleKeyframeSimplePropEditor.tsx index a2b3f4b..9354d58 100644 --- a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/DeterminePropEditorForKeyframe/KeyframeSimplePropEditor.tsx +++ b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/DeterminePropEditorForSingleKeyframe/SingleKeyframeSimplePropEditor.tsx @@ -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> } -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) { +}: ISingleKeyframeSimplePropEditorProps) { return ( - + - + ) } -export default KeyframeSimplePropEditor +export default SingleKeyframeSimplePropEditor diff --git a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/KeyframeDot.tsx b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/SingleKeyframeDot.tsx similarity index 77% rename from theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/KeyframeDot.tsx rename to theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/SingleKeyframeDot.tsx index 35a6922..6bd77d1 100644 --- a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/KeyframeDot.tsx +++ b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/SingleKeyframeDot.tsx @@ -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 = (props) => { +const SingleKeyframeDot: React.VFC = (props) => { + const logger = useLogger('SingleKeyframeDot') const [ref, node] = useRefAndState(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 = (props) => { <> {inlineEditorPopover} @@ -129,11 +100,12 @@ const KeyframeDot: React.VFC = (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'}, () => ( - { 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 { diff --git a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/KeyframeEditor.tsx b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/SingleKeyframeEditor.tsx similarity index 70% rename from theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/KeyframeEditor.tsx rename to theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/SingleKeyframeEditor.tsx index ec283e0..94678ba 100644 --- a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/KeyframeEditor.tsx +++ b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/SingleKeyframeEditor.tsx @@ -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 = (props) => { +const SingleKeyframeEditor: React.VFC = (props) => { const {index, trackData} = props const cur = trackData.keyframes[index] const next = trackData.keyframes[index + 1] @@ -37,7 +37,7 @@ const KeyframeEditor: React.FC = (props) => { const connected = cur.connectedRight && !!next return ( - = (props) => { }px))`, }} > - - {connected ? : noConnector} - + + {connected ? : noConnector} + ) } -export default KeyframeEditor +export default SingleKeyframeEditor diff --git a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/SnapCursor.svg b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/SnapCursor.svg deleted file mode 100644 index 6ea5abb..0000000 --- a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/SnapCursor.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/DopeSheetSelectionView.tsx b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/DopeSheetSelectionView.tsx index 6afd74f..185059c 100644 --- a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/DopeSheetSelectionView.tsx +++ b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/DopeSheetSelectionView.tsx @@ -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(null) + const logger = useLogger('useCaptureSelection') useDrag( containerNode, useMemo((): Parameters[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, + 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, leaf: Extract, - bounds: Exclude, - 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, leaf: SequenceEditorTree_AllRowTypes, - bounds: Exclude, - 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, leaf: SequenceEditorTree_AllRowTypes, - bounds: Exclude, - 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, - bounds: Exclude, + 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 +const SelectionRectangle: React.VFC<{ + state: SelectionBounds layoutP: Pointer }> = ({state, layoutP}) => { const atom = useValToAtom(state) diff --git a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/PrimitivePropRow.tsx b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/PrimitivePropRow.tsx index 811ea92..927541e 100644 --- a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/PrimitivePropRow.tsx +++ b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/PrimitivePropRow.tsx @@ -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 }> = ({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 = ( - + ) return diff --git a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/PropWithChildrenRow.tsx b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/PropWithChildrenRow.tsx index e21bb8b..335949b 100644 --- a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/PropWithChildrenRow.tsx +++ b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/PropWithChildrenRow.tsx @@ -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, ): React.ReactElement => leaf.type === 'propWithChildren' ? ( - ) : ( @@ -27,19 +30,42 @@ export const decideRowByPropType = ( /> ) -const PropWithChildrenRow: React.VFC<{ - leaf: SequenceEditorTree_PropWithChildren +const RightPropWithChildrenRow: React.VFC<{ + viewModel: SequenceEditorTree_PropWithChildren layoutP: Pointer -}> = ({leaf, layoutP}) => { +}> = ({viewModel, layoutP}) => { + const logger = useLogger( + 'RightPropWithChildrenRow', + viewModel.pathToProp.join(), + ) return usePrism(() => { - const node =
+ const aggregatedKeyframes = collectAggregateKeyframesInPrism( + logger.utilFor.internal(), + viewModel, + ) + + const node = ( + + ) return ( - - {leaf.children.map((propLeaf) => - decideRowByPropType(propLeaf, layoutP), - )} - + + + {viewModel.children.map((propLeaf) => + decideRowByPropType(propLeaf, layoutP), + )} + + ) - }, [leaf, layoutP]) + }, [viewModel, layoutP]) } + +export default RightPropWithChildrenRow diff --git a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/Row.tsx b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/Row.tsx index f7d00c0..3c911b6 100644 --- a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/Row.tsx +++ b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/Row.tsx @@ -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 + leaf: SequenceEditorTree_Row node: React.ReactElement isCollapsed: boolean }> = ({leaf, children, node, isCollapsed}) => { const hasChildren = Array.isArray(children) && children.length > 0 - return ( - - + {node} - - {hasChildren && {children}} - - ) + + {hasChildren && {children}} + + ) : null } export default RightRow diff --git a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/SheetObjectRow.tsx b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/SheetObjectRow.tsx index 770c7d3..1be0414 100644 --- a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/SheetObjectRow.tsx +++ b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/SheetObjectRow.tsx @@ -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 }> = ({leaf, layoutP}) => { + const logger = useLogger( + `RightSheetObjectRow`, + leaf.sheetObject.address.objectKey, + ) return usePrism(() => { - const node =
+ const aggregatedKeyframes = collectAggregateKeyframesInPrism( + logger.utilFor.internal(), + leaf, + ) + + const node = ( + + ) + return ( {leaf.children.map((leaf) => decideRowByPropType(leaf, layoutP))} diff --git a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/collectAggregateKeyframes.tsx b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/collectAggregateKeyframes.tsx new file mode 100644 index 0000000..5ca3e89 --- /dev/null +++ b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/collectAggregateKeyframes.tsx @@ -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 + 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() + + 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, + } +} diff --git a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/keyframeRowUI/ConnectorLine.tsx b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/keyframeRowUI/ConnectorLine.tsx new file mode 100644 index 0000000..8d60a46 --- /dev/null +++ b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/keyframeRowUI/ConnectorLine.tsx @@ -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` + 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 ( + { + props.openPopover?.(e) + }} + > + {props.children} + + ) +}) diff --git a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/selections.ts b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/selections.ts index 17dadc1..7cbe5b2 100644 --- a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/selections.ts +++ b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/selections.ts @@ -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> { +): IDerivation> { return prism(() => { if (selection === undefined) return [] diff --git a/theatre/studio/src/panels/SequenceEditorPanel/RightOverlay/DopeSnapHitZoneUI.tsx b/theatre/studio/src/panels/SequenceEditorPanel/RightOverlay/DopeSnapHitZoneUI.tsx new file mode 100644 index 0000000..81270fd --- /dev/null +++ b/theatre/studio/src/panels/SequenceEditorPanel/RightOverlay/DopeSnapHitZoneUI.tsx @@ -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 ``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 : '', + } + }, +} diff --git a/theatre/studio/src/panels/SequenceEditorPanel/RightOverlay/MarkerDot.tsx b/theatre/studio/src/panels/SequenceEditorPanel/RightOverlay/MarkerDot.tsx index 3421607..bd39883 100644 --- a/theatre/studio/src/panels/SequenceEditorPanel/RightOverlay/MarkerDot.tsx +++ b/theatre/studio/src/panels/SequenceEditorPanel/RightOverlay/MarkerDot.tsx @@ -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 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 markerId: SequenceMarkerId @@ -194,14 +165,10 @@ const MarkerDotVisible: React.VFC = ({ {contextMenu} diff --git a/theatre/studio/src/panels/SequenceEditorPanel/RightOverlay/Playhead.tsx b/theatre/studio/src/panels/SequenceEditorPanel/RightOverlay/Playhead.tsx index f637899..f1b271e 100644 --- a/theatre/studio/src/panels/SequenceEditorPanel/RightOverlay/Playhead.tsx +++ b/theatre/studio/src/panels/SequenceEditorPanel/RightOverlay/Playhead.tsx @@ -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}> = ({ const gestureHandlers = useMemo((): Parameters[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}> = ({ } }, } - }, []) + }, [layoutP, thumbNode]) const [isDragging] = useDrag(thumbNode, gestureHandlers) diff --git a/theatre/studio/src/panels/SequenceEditorPanel/layout/tree.ts b/theatre/studio/src/panels/SequenceEditorPanel/layout/tree.ts index 978ae06..60fde69 100644 --- a/theatre/studio/src/panels/SequenceEditorPanel/layout/tree.ts +++ b/theatre/studio/src/panels/SequenceEditorPanel/layout/tree.ts @@ -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 +/** + * Base "view model" for each row with common + * required information such as row heights & depth. + */ +export type SequenceEditorTree_Row = { + /** 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, level: number, + shouldRender: boolean, ) { const trackSetups = val( sheetObject.template.getMapOfValidSequenceTracks_forStudio(), @@ -114,36 +143,44 @@ 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) - 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, - [], - sheetObject.template.config, - row.children, - level + 1, - ) + + 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 } + + addProps( + sheetObject, + trackSetups, + [], + 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,40 +258,46 @@ 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) - topSoFar += row.nodeHeight - if (!isCollapsed) { - nSoFar += 1 - addProps( - sheetObject, - trackMapping, - pathToProp, - conf, - row.children, - level + 1, - ) + if (shouldRender) { + topSoFar += row.nodeHeight + nSoFar += 1 } + + addProps( + sheetObject, + trackMapping, + pathToProp, + 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, } diff --git a/theatre/studio/src/store/stateEditors.ts b/theatre/studio/src/store/stateEditors.ts index c6c743b..d29ca6c 100644 --- a/theatre/studio/src/store/stateEditors.ts +++ b/theatre/studio/src/store/stateEditors.ts @@ -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 diff --git a/theatre/studio/src/uiComponents/simpleContextMenu/ContextMenu/ContextMenu.tsx b/theatre/studio/src/uiComponents/simpleContextMenu/ContextMenu/ContextMenu.tsx index f2faaf8..f72ac5d 100644 --- a/theatre/studio/src/uiComponents/simpleContextMenu/ContextMenu/ContextMenu.tsx +++ b/theatre/studio/src/uiComponents/simpleContextMenu/ContextMenu/ContextMenu.tsx @@ -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( + {SHOW_OPTIONAL_MENU_TITLE && props.displayName ? ( + {props.displayName} + ) : null} {items.map((item, i) => ( ` 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<{ {props.label} diff --git a/theatre/studio/src/uiComponents/simpleContextMenu/useContextMenu.tsx b/theatre/studio/src/uiComponents/simpleContextMenu/useContextMenu.tsx index 904401f..e343370 100644 --- a/theatre/studio/src/uiComponents/simpleContextMenu/useContextMenu.tsx +++ b/theatre/studio/src/uiComponents/simpleContextMenu/useContextMenu.tsx @@ -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 ) : ( diff --git a/theatre/studio/src/uiComponents/useLogger.tsx b/theatre/studio/src/uiComponents/useLogger.tsx new file mode 100644 index 0000000..5b0ca06 --- /dev/null +++ b/theatre/studio/src/uiComponents/useLogger.tsx @@ -0,0 +1,24 @@ +import type {ILogger} from '@theatre/shared/logger' +import React, {useContext, useMemo} from 'react' + +const loggerContext = React.createContext(null!) +export function ProvideLogger( + props: React.PropsWithChildren<{logger: ILogger}>, +) { + return ( + + {props.children} + + ) +} + +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]) +} diff --git a/theatre/studio/src/utils/absoluteDims.tsx b/theatre/studio/src/utils/absoluteDims.tsx new file mode 100644 index 0000000..065b05d --- /dev/null +++ b/theatre/studio/src/utils/absoluteDims.tsx @@ -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; +`