Add focus range (#94)
This commit is contained in:
parent
8a9b26eb41
commit
ffdebebfff
22 changed files with 1328 additions and 101 deletions
|
@ -26,7 +26,9 @@ export interface ProjectEphemeralState {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Historic state is both persisted and is undoable
|
* This is the state of each project that is consumable by `@theatre/core`.
|
||||||
|
* If the studio is present, this part of the state joins the studio's historic state,
|
||||||
|
* at {@link StudioHistoricState.coreByProject}
|
||||||
*/
|
*/
|
||||||
export interface ProjectState_Historic {
|
export interface ProjectState_Historic {
|
||||||
sheetsById: StrictRecord<string, SheetState_Historic>
|
sheetsById: StrictRecord<string, SheetState_Historic>
|
||||||
|
|
|
@ -175,6 +175,21 @@ export default class Sequence {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Controls the playback within a range. Repeats infinitely unless stopped.
|
||||||
|
*
|
||||||
|
* @remarks
|
||||||
|
* One use case for this is to play the playback within the focus range.
|
||||||
|
*
|
||||||
|
* @param rangeD The derivation that contains the range that will be used for the playback
|
||||||
|
*
|
||||||
|
* @returns a promise that gets rejected if the playback stopped for whatever reason
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
playDynamicRange(rangeD: IDerivation<IPlaybackRange>): Promise<unknown> {
|
||||||
|
return this._playbackControllerBox.get().playDynamicRange(rangeD)
|
||||||
|
}
|
||||||
|
|
||||||
async play(
|
async play(
|
||||||
conf?: Partial<{
|
conf?: Partial<{
|
||||||
iterationCount: number
|
iterationCount: number
|
||||||
|
|
|
@ -5,7 +5,7 @@ import type {
|
||||||
import {defer} from '@theatre/shared/utils/defer'
|
import {defer} from '@theatre/shared/utils/defer'
|
||||||
import {InvalidArgumentError} from '@theatre/shared/utils/errors'
|
import {InvalidArgumentError} from '@theatre/shared/utils/errors'
|
||||||
import noop from '@theatre/shared/utils/noop'
|
import noop from '@theatre/shared/utils/noop'
|
||||||
import type {Pointer, Ticker} from '@theatre/dataverse'
|
import type {IDerivation, Pointer, Ticker} from '@theatre/dataverse'
|
||||||
import {Atom} from '@theatre/dataverse'
|
import {Atom} from '@theatre/dataverse'
|
||||||
import type {
|
import type {
|
||||||
IPlaybackController,
|
IPlaybackController,
|
||||||
|
@ -33,6 +33,11 @@ export default class AudioPlaybackController implements IPlaybackController {
|
||||||
this._mainGain.connect(this._nodeDestination)
|
this._mainGain.connect(this._nodeDestination)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO: Implement this method in the future!
|
||||||
|
playDynamicRange(rangeD: IDerivation<IPlaybackRange>): Promise<unknown> {
|
||||||
|
throw new Error('Method not implemented.')
|
||||||
|
}
|
||||||
|
|
||||||
private get _playing() {
|
private get _playing() {
|
||||||
return this._state.getState().playing
|
return this._state.getState().playing
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,7 +4,7 @@ import type {
|
||||||
} from '@theatre/core/sequences/Sequence'
|
} from '@theatre/core/sequences/Sequence'
|
||||||
import {defer} from '@theatre/shared/utils/defer'
|
import {defer} from '@theatre/shared/utils/defer'
|
||||||
import noop from '@theatre/shared/utils/noop'
|
import noop from '@theatre/shared/utils/noop'
|
||||||
import type {Pointer, Ticker} from '@theatre/dataverse'
|
import type {IDerivation, Pointer, Ticker} from '@theatre/dataverse'
|
||||||
import {Atom} from '@theatre/dataverse'
|
import {Atom} from '@theatre/dataverse'
|
||||||
|
|
||||||
export interface IPlaybackState {
|
export interface IPlaybackState {
|
||||||
|
@ -25,6 +25,19 @@ export interface IPlaybackController {
|
||||||
direction: IPlaybackDirection,
|
direction: IPlaybackDirection,
|
||||||
): Promise<boolean>
|
): Promise<boolean>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Controls the playback within a range. Repeats infinitely unless stopped.
|
||||||
|
*
|
||||||
|
* @remarks
|
||||||
|
* One use case for this is to play the playback within the focus range.
|
||||||
|
*
|
||||||
|
* @param rangeD The derivation that contains the range that will be used for the playback
|
||||||
|
*
|
||||||
|
* @returns a promise that gets rejected if the playback stopped for whatever reason
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
playDynamicRange(rangeD: IDerivation<IPlaybackRange>): Promise<unknown>
|
||||||
|
|
||||||
pause(): void
|
pause(): void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -189,4 +202,59 @@ export default class DefaultPlaybackController implements IPlaybackController {
|
||||||
ticker.onThisOrNextTick(tick)
|
ticker.onThisOrNextTick(tick)
|
||||||
return deferred.promise
|
return deferred.promise
|
||||||
}
|
}
|
||||||
|
|
||||||
|
playDynamicRange(rangeD: IDerivation<IPlaybackRange>): Promise<unknown> {
|
||||||
|
if (this.playing) {
|
||||||
|
this.pause()
|
||||||
|
}
|
||||||
|
|
||||||
|
this.playing = true
|
||||||
|
|
||||||
|
const ticker = this._ticker
|
||||||
|
|
||||||
|
const deferred = defer<boolean>()
|
||||||
|
|
||||||
|
// We're keeping the rangeD hot, so we can read from it on every tick without
|
||||||
|
// causing unnecessary recalculations
|
||||||
|
const untapFromRangeD = rangeD.keepHot()
|
||||||
|
// We'll release our subscription once this promise resolves/rejects, for whatever reason
|
||||||
|
deferred.promise.then(untapFromRangeD, untapFromRangeD)
|
||||||
|
|
||||||
|
let lastTickerTime = ticker.time
|
||||||
|
|
||||||
|
const tick = (currentTickerTime: number) => {
|
||||||
|
const elapsedSinceLastTick = Math.max(
|
||||||
|
currentTickerTime - lastTickerTime,
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
lastTickerTime = currentTickerTime
|
||||||
|
const elapsedSinceLastTickInSeconds = elapsedSinceLastTick / 1000
|
||||||
|
|
||||||
|
const lastPosition = this.getCurrentPosition()
|
||||||
|
|
||||||
|
const range = rangeD.getValue()
|
||||||
|
|
||||||
|
if (lastPosition < range[0] || lastPosition > range[1]) {
|
||||||
|
this.gotoPosition(range[0])
|
||||||
|
} else {
|
||||||
|
let newPosition = lastPosition + elapsedSinceLastTickInSeconds
|
||||||
|
if (newPosition > range[1]) {
|
||||||
|
newPosition = range[0] + (newPosition - range[1])
|
||||||
|
}
|
||||||
|
this.gotoPosition(newPosition)
|
||||||
|
}
|
||||||
|
|
||||||
|
requestNextTick()
|
||||||
|
}
|
||||||
|
|
||||||
|
this._stopPlayCallback = () => {
|
||||||
|
ticker.offThisOrNextTick(tick)
|
||||||
|
ticker.offNextTick(tick)
|
||||||
|
|
||||||
|
deferred.resolve(false)
|
||||||
|
}
|
||||||
|
const requestNextTick = () => ticker.onNextTick(tick)
|
||||||
|
ticker.onThisOrNextTick(tick)
|
||||||
|
return deferred.promise
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
24
theatre/shared/src/utils/memoizeFn.ts
Normal file
24
theatre/shared/src/utils/memoizeFn.ts
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
/**
|
||||||
|
* Memoizes a unary function using a simple weakmap.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* const fn = memoizeFn((el) => getBoundingClientRect(el))
|
||||||
|
*
|
||||||
|
* const b1 = fn(el)
|
||||||
|
* const b2 = fn(el)
|
||||||
|
* assert.equal(b1, b2)
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export default function memoizeFn<K extends {}, V>(
|
||||||
|
producer: (k: K) => V,
|
||||||
|
): (k: K) => V {
|
||||||
|
const cache = new WeakMap<K, V>()
|
||||||
|
|
||||||
|
return (k: K): V => {
|
||||||
|
if (!cache.has(k)) {
|
||||||
|
cache.set(k, producer(k))
|
||||||
|
}
|
||||||
|
return cache.get(k)!
|
||||||
|
}
|
||||||
|
}
|
|
@ -67,6 +67,9 @@ export type StrictRecord<Key extends string, V> = {[K in Key]?: V}
|
||||||
*/
|
*/
|
||||||
export type Nominal<T, N extends string> = T
|
export type Nominal<T, N extends string> = T
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TODO: We should deprecate this and just use `[start: number, end: number]`
|
||||||
|
*/
|
||||||
export type IRange<T extends number = number> = {start: T; end: T}
|
export type IRange<T extends number = number> = {start: T; end: T}
|
||||||
|
|
||||||
/** For `any`s that aren't meant to stay `any`*/
|
/** For `any`s that aren't meant to stay `any`*/
|
||||||
|
|
|
@ -3,6 +3,11 @@ import getStudio from '@theatre/studio/getStudio'
|
||||||
import {cmdIsDown} from '@theatre/studio/utils/keyboardUtils'
|
import {cmdIsDown} from '@theatre/studio/utils/keyboardUtils'
|
||||||
import {getSelectedSequence} from '@theatre/studio/selectors'
|
import {getSelectedSequence} from '@theatre/studio/selectors'
|
||||||
import type {$IntentionalAny} from '@theatre/shared/utils/types'
|
import type {$IntentionalAny} from '@theatre/shared/utils/types'
|
||||||
|
import type {IDerivation} from '@theatre/dataverse'
|
||||||
|
import {Box, prism, val} from '@theatre/dataverse'
|
||||||
|
import type {IPlaybackRange} from '@theatre/core/sequences/Sequence'
|
||||||
|
import type Sequence from '@theatre/core/sequences/Sequence'
|
||||||
|
import memoizeFn from '@theatre/shared/utils/memoizeFn'
|
||||||
|
|
||||||
export default function useKeyboardShortcuts() {
|
export default function useKeyboardShortcuts() {
|
||||||
const studio = getStudio()
|
const studio = getStudio()
|
||||||
|
@ -28,18 +33,92 @@ export default function useKeyboardShortcuts() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
} else if (
|
} else if (
|
||||||
e.key === ' ' &&
|
e.code === 'Space' &&
|
||||||
!e.shiftKey &&
|
!e.shiftKey &&
|
||||||
!e.metaKey &&
|
!e.metaKey &&
|
||||||
!e.altKey &&
|
!e.altKey &&
|
||||||
!e.ctrlKey
|
!e.ctrlKey
|
||||||
) {
|
) {
|
||||||
|
// Control the playback using the `Space` key
|
||||||
const seq = getSelectedSequence()
|
const seq = getSelectedSequence()
|
||||||
if (seq) {
|
if (seq) {
|
||||||
if (seq.playing) {
|
if (seq.playing) {
|
||||||
seq.pause()
|
seq.pause()
|
||||||
} else {
|
} else {
|
||||||
seq.play({iterationCount: 1000})
|
/*
|
||||||
|
* The sequence will be played in its whole length unless all of the
|
||||||
|
* following conditions are met:
|
||||||
|
* 1. the focus range is set and enabled
|
||||||
|
* 2. the playback starts within the focus range.
|
||||||
|
*/
|
||||||
|
const {projectId, sheetId} = seq.address
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The value of this derivation is an array that contains the
|
||||||
|
* range of the playback (start and end), and a boolean that is
|
||||||
|
* `true` if the playback should be played within that range.
|
||||||
|
*/
|
||||||
|
const controlledPlaybackStateD = prism(
|
||||||
|
(): {range: IPlaybackRange; isFollowingARange: boolean} => {
|
||||||
|
const focusRange = val(
|
||||||
|
getStudio().atomP.ahistoric.projects.stateByProjectId[
|
||||||
|
projectId
|
||||||
|
].stateBySheetId[sheetId].sequence.focusRange,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Determines whether the playback should be played
|
||||||
|
// within the focus range.
|
||||||
|
const shouldFollowFocusRange = prism.memo<boolean>(
|
||||||
|
'shouldFollowFocusRange',
|
||||||
|
(): boolean => {
|
||||||
|
const posBeforePlay = seq.position
|
||||||
|
if (focusRange) {
|
||||||
|
const withinRange =
|
||||||
|
posBeforePlay >= focusRange.range.start &&
|
||||||
|
posBeforePlay <= focusRange.range.end
|
||||||
|
if (focusRange.enabled) {
|
||||||
|
if (withinRange) {
|
||||||
|
return true
|
||||||
|
} else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
if (
|
||||||
|
shouldFollowFocusRange &&
|
||||||
|
focusRange &&
|
||||||
|
focusRange.enabled
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
range: [focusRange.range.start, focusRange.range.end],
|
||||||
|
isFollowingARange: true,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const sequenceLength = val(seq.pointer.length)
|
||||||
|
return {range: [0, sequenceLength], isFollowingARange: false}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const playbackPromise = seq.playDynamicRange(
|
||||||
|
controlledPlaybackStateD.map(({range}) => range),
|
||||||
|
)
|
||||||
|
|
||||||
|
const playbackStateBox = getPlaybackStateBox(seq)
|
||||||
|
|
||||||
|
playbackPromise.finally(() => {
|
||||||
|
playbackStateBox.set(undefined)
|
||||||
|
})
|
||||||
|
|
||||||
|
playbackStateBox.set(controlledPlaybackStateD)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
return
|
return
|
||||||
|
@ -70,3 +149,45 @@ export default function useKeyboardShortcuts() {
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ControlledPlaybackStateBox = Box<
|
||||||
|
undefined | IDerivation<{range: IPlaybackRange; isFollowingARange: boolean}>
|
||||||
|
>
|
||||||
|
|
||||||
|
const getPlaybackStateBox = memoizeFn(
|
||||||
|
(sequence: Sequence): ControlledPlaybackStateBox => {
|
||||||
|
const box = new Box(undefined) as ControlledPlaybackStateBox
|
||||||
|
return box
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
/*
|
||||||
|
* A memoized function that returns a derivation with a boolean value.
|
||||||
|
* This value is set to `true` if:
|
||||||
|
* 1. the playback is playing and using the focus range instead of the whole sequence
|
||||||
|
* 2. the playback is stopped, but would use the focus range if it were started.
|
||||||
|
*/
|
||||||
|
export const getIsPlayheadAttachedToFocusRange = memoizeFn(
|
||||||
|
(sequence: Sequence) =>
|
||||||
|
prism<boolean>(() => {
|
||||||
|
const controlledPlaybackState =
|
||||||
|
getPlaybackStateBox(sequence).derivation.getValue()
|
||||||
|
if (controlledPlaybackState) {
|
||||||
|
return controlledPlaybackState.getValue().isFollowingARange
|
||||||
|
} else {
|
||||||
|
const {projectId, sheetId} = sequence.address
|
||||||
|
const focusRange = val(
|
||||||
|
getStudio().atomP.ahistoric.projects.stateByProjectId[projectId]
|
||||||
|
.stateBySheetId[sheetId].sequence.focusRange,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!focusRange || !focusRange.enabled) return false
|
||||||
|
|
||||||
|
const pos = val(sequence.pointer.position)
|
||||||
|
|
||||||
|
const withinRange =
|
||||||
|
pos >= focusRange.range.start && pos <= focusRange.range.end
|
||||||
|
return withinRange
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
|
@ -41,7 +41,7 @@ export const F2 = styled.div`
|
||||||
padding: 0;
|
padding: 0;
|
||||||
`
|
`
|
||||||
|
|
||||||
export const titleBarHeight = 20
|
export const titleBarHeight = 18
|
||||||
|
|
||||||
export const TitleBar = styled.div`
|
export const TitleBar = styled.div`
|
||||||
height: ${titleBarHeight}px;
|
height: ${titleBarHeight}px;
|
||||||
|
|
|
@ -0,0 +1,86 @@
|
||||||
|
import type {Pointer} from '@theatre/dataverse'
|
||||||
|
import {prism, val} from '@theatre/dataverse'
|
||||||
|
import {usePrism} from '@theatre/react'
|
||||||
|
import getStudio from '@theatre/studio/getStudio'
|
||||||
|
import type {SequenceEditorPanelLayout} from '@theatre/studio/panels/SequenceEditorPanel/layout/layout'
|
||||||
|
import {topStripHeight} from '@theatre/studio/panels/SequenceEditorPanel/RightOverlay/TopStrip'
|
||||||
|
import React, {useMemo} from 'react'
|
||||||
|
import styled from 'styled-components'
|
||||||
|
|
||||||
|
const focusRangeAreaTheme = {
|
||||||
|
enabled: {
|
||||||
|
backgroundColor: '#646568',
|
||||||
|
opacity: 0.05,
|
||||||
|
},
|
||||||
|
disabled: {
|
||||||
|
backgroundColor: '#646568',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const Container = styled.div`
|
||||||
|
position: absolute;
|
||||||
|
opacity: ${focusRangeAreaTheme.enabled.opacity};
|
||||||
|
background: transparent;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
`
|
||||||
|
const FocusRangeArea: React.FC<{
|
||||||
|
layoutP: Pointer<SequenceEditorPanelLayout>
|
||||||
|
}> = ({layoutP}) => {
|
||||||
|
const existingRangeD = useMemo(
|
||||||
|
() =>
|
||||||
|
prism(() => {
|
||||||
|
const {projectId, sheetId} = val(layoutP.sheet).address
|
||||||
|
const existingRange = val(
|
||||||
|
getStudio().atomP.ahistoric.projects.stateByProjectId[projectId]
|
||||||
|
.stateBySheetId[sheetId].sequence.focusRange,
|
||||||
|
)
|
||||||
|
return existingRange
|
||||||
|
}),
|
||||||
|
[layoutP],
|
||||||
|
)
|
||||||
|
|
||||||
|
return usePrism(() => {
|
||||||
|
const existingRange = existingRangeD.getValue()
|
||||||
|
|
||||||
|
const range = existingRange?.range || {start: 0, end: 0}
|
||||||
|
|
||||||
|
const height = val(layoutP.rightDims.height) + topStripHeight
|
||||||
|
|
||||||
|
let startPosInClippedSpace: number,
|
||||||
|
endPosInClippedSpace: number,
|
||||||
|
conditionalStyleProps:
|
||||||
|
| {
|
||||||
|
width: number
|
||||||
|
transform: string
|
||||||
|
background?: string
|
||||||
|
}
|
||||||
|
| undefined
|
||||||
|
|
||||||
|
if (existingRange !== undefined) {
|
||||||
|
startPosInClippedSpace = val(layoutP.clippedSpace.fromUnitSpace)(
|
||||||
|
range.start,
|
||||||
|
)
|
||||||
|
|
||||||
|
endPosInClippedSpace = val(layoutP.clippedSpace.fromUnitSpace)(range.end)
|
||||||
|
|
||||||
|
conditionalStyleProps = {
|
||||||
|
width: endPosInClippedSpace - startPosInClippedSpace,
|
||||||
|
transform: `translate3d(${
|
||||||
|
startPosInClippedSpace - val(layoutP.clippedSpace.fromUnitSpace)(0)
|
||||||
|
}px, 0, 0)`,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existingRange.enabled === true) {
|
||||||
|
conditionalStyleProps.background =
|
||||||
|
focusRangeAreaTheme.enabled.backgroundColor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container style={{...conditionalStyleProps, height: `${height}px`}} />
|
||||||
|
)
|
||||||
|
}, [layoutP, existingRangeD])
|
||||||
|
}
|
||||||
|
|
||||||
|
export default FocusRangeArea
|
|
@ -10,6 +10,7 @@ import {zIndexes} from '@theatre/studio/panels/SequenceEditorPanel/SequenceEdito
|
||||||
import DopeSheetSelectionView from './DopeSheetSelectionView'
|
import DopeSheetSelectionView from './DopeSheetSelectionView'
|
||||||
import HorizontallyScrollableArea from './HorizontallyScrollableArea'
|
import HorizontallyScrollableArea from './HorizontallyScrollableArea'
|
||||||
import SheetRow from './SheetRow'
|
import SheetRow from './SheetRow'
|
||||||
|
import FocusRangeArea from './FocusRangeArea'
|
||||||
|
|
||||||
export const contentWidth = 1000000
|
export const contentWidth = 1000000
|
||||||
|
|
||||||
|
@ -48,6 +49,7 @@ const Right: React.FC<{
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<HorizontallyScrollableArea layoutP={layoutP} height={height}>
|
<HorizontallyScrollableArea layoutP={layoutP} height={height}>
|
||||||
|
<FocusRangeArea layoutP={layoutP} />
|
||||||
<DopeSheetSelectionView layoutP={layoutP}>
|
<DopeSheetSelectionView layoutP={layoutP}>
|
||||||
<ListContainer style={{top: tree.top + 'px'}}>
|
<ListContainer style={{top: tree.top + 'px'}}>
|
||||||
<SheetRow leaf={tree} layoutP={layoutP} />
|
<SheetRow leaf={tree} layoutP={layoutP} />
|
||||||
|
|
|
@ -30,7 +30,7 @@ const TheStamps = styled.div`
|
||||||
height: 100%;
|
height: 100%;
|
||||||
left: 0;
|
left: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
/* z-index: 2; */
|
z-index: 2;
|
||||||
will-change: transform;
|
will-change: transform;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
`
|
`
|
||||||
|
|
|
@ -0,0 +1,297 @@
|
||||||
|
import type {Pointer} from '@theatre/dataverse'
|
||||||
|
import {prism, val} from '@theatre/dataverse'
|
||||||
|
import {usePrism} from '@theatre/react'
|
||||||
|
import type {$IntentionalAny, IRange} from '@theatre/shared/utils/types'
|
||||||
|
import {pointerEventsAutoInNormalMode} from '@theatre/studio/css'
|
||||||
|
import getStudio from '@theatre/studio/getStudio'
|
||||||
|
import type {SequenceEditorPanelLayout} from '@theatre/studio/panels/SequenceEditorPanel/layout/layout'
|
||||||
|
import {topStripHeight} from '@theatre/studio/panels/SequenceEditorPanel/RightOverlay/TopStrip'
|
||||||
|
import type {CommitOrDiscard} from '@theatre/studio/StudioStore/StudioStore'
|
||||||
|
import useContextMenu from '@theatre/studio/uiComponents/simpleContextMenu/useContextMenu'
|
||||||
|
import useDrag from '@theatre/studio/uiComponents/useDrag'
|
||||||
|
import useRefAndState from '@theatre/studio/utils/useRefAndState'
|
||||||
|
import React, {useMemo} from 'react'
|
||||||
|
import styled from 'styled-components'
|
||||||
|
|
||||||
|
export const focusRangeStripTheme = {
|
||||||
|
enabled: {
|
||||||
|
backgroundColor: '#2C2F34',
|
||||||
|
stroke: '#646568',
|
||||||
|
},
|
||||||
|
disabled: {
|
||||||
|
backgroundColor: '#282A2C',
|
||||||
|
},
|
||||||
|
playing: {
|
||||||
|
backgroundColor: 'red',
|
||||||
|
},
|
||||||
|
highlight: {
|
||||||
|
backgroundColor: '#34373D',
|
||||||
|
stroke: '#C8CAC0',
|
||||||
|
},
|
||||||
|
dragging: {
|
||||||
|
backgroundColor: '#3F444A',
|
||||||
|
},
|
||||||
|
thumbWidth: 9,
|
||||||
|
hitZoneWidth: 26,
|
||||||
|
rangeStripMinWidth: 30,
|
||||||
|
}
|
||||||
|
|
||||||
|
const stripWidth = 1000
|
||||||
|
|
||||||
|
const RangeStrip = styled.div`
|
||||||
|
position: absolute;
|
||||||
|
height: ${() => topStripHeight};
|
||||||
|
background-color: ${focusRangeStripTheme.enabled.backgroundColor};
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: ${stripWidth}px;
|
||||||
|
transform-origin: left top;
|
||||||
|
&:hover {
|
||||||
|
background-color: ${focusRangeStripTheme.highlight.backgroundColor};
|
||||||
|
}
|
||||||
|
&.dragging {
|
||||||
|
background-color: ${focusRangeStripTheme.dragging.backgroundColor};
|
||||||
|
cursor: grabbing !important;
|
||||||
|
}
|
||||||
|
${pointerEventsAutoInNormalMode};
|
||||||
|
`
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clamps the lower and upper bounds of a range to the lower and upper bounds of the reference range, while maintaining the original width of the range. If the range to be clamped has a greater width than the reference range, then the reference range is returned.
|
||||||
|
*
|
||||||
|
* @param range - The range bounds to be clamped
|
||||||
|
* @param referenceRange - The reference range
|
||||||
|
*
|
||||||
|
* @returns The clamped bounds.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* clampRange([-1, 4], [2, 3]) // returns [2, 3]
|
||||||
|
* clampRange([-1, 2.5], [2, 3]) // returns [2, 2.5]
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
function clampRange(
|
||||||
|
range: [number, number],
|
||||||
|
referenceRange: [number, number],
|
||||||
|
): [number, number] {
|
||||||
|
let overflow = 0
|
||||||
|
|
||||||
|
const [start, end] = range
|
||||||
|
const [lower, upper] = referenceRange
|
||||||
|
|
||||||
|
if (end - start > upper - lower) return [lower, upper]
|
||||||
|
|
||||||
|
if (start < lower) {
|
||||||
|
overflow = 0 - start
|
||||||
|
}
|
||||||
|
|
||||||
|
if (end > upper) {
|
||||||
|
overflow = upper - end
|
||||||
|
}
|
||||||
|
|
||||||
|
return [start + overflow, end + overflow]
|
||||||
|
}
|
||||||
|
|
||||||
|
const FocusRangeStrip: React.FC<{
|
||||||
|
layoutP: Pointer<SequenceEditorPanelLayout>
|
||||||
|
}> = ({layoutP}) => {
|
||||||
|
const existingRangeD = useMemo(
|
||||||
|
() =>
|
||||||
|
prism(() => {
|
||||||
|
const {projectId, sheetId} = val(layoutP.sheet).address
|
||||||
|
const existingRange = val(
|
||||||
|
getStudio().atomP.ahistoric.projects.stateByProjectId[projectId]
|
||||||
|
.stateBySheetId[sheetId].sequence.focusRange,
|
||||||
|
)
|
||||||
|
return existingRange
|
||||||
|
}),
|
||||||
|
[layoutP],
|
||||||
|
)
|
||||||
|
|
||||||
|
const sheet = val(layoutP.sheet)
|
||||||
|
|
||||||
|
const [rangeStripRef, rangeStripNode] = useRefAndState<HTMLElement | null>(
|
||||||
|
null,
|
||||||
|
)
|
||||||
|
|
||||||
|
const [contextMenu] = useContextMenu(rangeStripNode, {
|
||||||
|
items: () => {
|
||||||
|
const existingRange = existingRangeD.getValue()
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: 'Delete focus range',
|
||||||
|
callback: () => {
|
||||||
|
getStudio()
|
||||||
|
.tempTransaction(({stateEditors}) => {
|
||||||
|
stateEditors.studio.ahistoric.projects.stateByProjectId.stateBySheetId.sequence.focusRange.unset(
|
||||||
|
{
|
||||||
|
...sheet.address,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.commit()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: existingRange?.enabled
|
||||||
|
? 'Disable focus range'
|
||||||
|
: 'Enable focus range',
|
||||||
|
callback: () => {
|
||||||
|
if (existingRange !== undefined) {
|
||||||
|
getStudio()
|
||||||
|
.tempTransaction(({stateEditors}) => {
|
||||||
|
stateEditors.studio.ahistoric.projects.stateByProjectId.stateBySheetId.sequence.focusRange.set(
|
||||||
|
{
|
||||||
|
...sheet.address,
|
||||||
|
range: existingRange.range,
|
||||||
|
enabled: !existingRange.enabled,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.commit()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const scaledSpaceToUnitSpace = val(layoutP.scaledSpace.toUnitSpace)
|
||||||
|
|
||||||
|
const gestureHandlers = useMemo((): Parameters<typeof useDrag>[1] => {
|
||||||
|
let sequence = sheet.getSequence()
|
||||||
|
let startPosBeforeDrag: number,
|
||||||
|
endPosBeforeDrag: number,
|
||||||
|
tempTransaction: CommitOrDiscard | undefined
|
||||||
|
let dragHappened = false
|
||||||
|
let existingRange: {enabled: boolean; range: IRange<number>} | undefined
|
||||||
|
let target: HTMLDivElement | undefined
|
||||||
|
let newStartPosition: number, newEndPosition: number
|
||||||
|
|
||||||
|
return {
|
||||||
|
onDragStart(event) {
|
||||||
|
existingRange = existingRangeD.getValue()
|
||||||
|
|
||||||
|
if (existingRange?.enabled === true) {
|
||||||
|
startPosBeforeDrag = existingRange.range.start
|
||||||
|
endPosBeforeDrag = existingRange.range.end
|
||||||
|
dragHappened = false
|
||||||
|
sequence = val(layoutP.sheet).getSequence()
|
||||||
|
target = event.target as HTMLDivElement
|
||||||
|
target.classList.add('dragging')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onDrag(dx) {
|
||||||
|
existingRange = existingRangeD.getValue()
|
||||||
|
if (existingRange?.enabled) {
|
||||||
|
dragHappened = true
|
||||||
|
const deltaPos = scaledSpaceToUnitSpace(dx)
|
||||||
|
|
||||||
|
const start = startPosBeforeDrag + deltaPos
|
||||||
|
let end = endPosBeforeDrag + deltaPos
|
||||||
|
|
||||||
|
if (end < start) {
|
||||||
|
end = start
|
||||||
|
}
|
||||||
|
|
||||||
|
;[newStartPosition, newEndPosition] = clampRange(
|
||||||
|
[start, end],
|
||||||
|
[0, sequence.length],
|
||||||
|
).map((pos) => sequence.closestGridPosition(pos))
|
||||||
|
|
||||||
|
if (tempTransaction) {
|
||||||
|
tempTransaction.discard()
|
||||||
|
}
|
||||||
|
|
||||||
|
tempTransaction = getStudio().tempTransaction(({stateEditors}) => {
|
||||||
|
stateEditors.studio.ahistoric.projects.stateByProjectId.stateBySheetId.sequence.focusRange.set(
|
||||||
|
{
|
||||||
|
...sheet.address,
|
||||||
|
range: {
|
||||||
|
start: newStartPosition,
|
||||||
|
end: newEndPosition,
|
||||||
|
},
|
||||||
|
enabled: existingRange?.enabled || true,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onDragEnd() {
|
||||||
|
if (existingRange?.enabled) {
|
||||||
|
if (dragHappened && tempTransaction !== undefined) {
|
||||||
|
tempTransaction.commit()
|
||||||
|
} else if (tempTransaction) {
|
||||||
|
tempTransaction.discard()
|
||||||
|
}
|
||||||
|
tempTransaction = undefined
|
||||||
|
}
|
||||||
|
if (target !== undefined) {
|
||||||
|
target.classList.remove('dragging')
|
||||||
|
target = undefined
|
||||||
|
}
|
||||||
|
},
|
||||||
|
lockCursorTo: 'grabbing',
|
||||||
|
}
|
||||||
|
}, [sheet, scaledSpaceToUnitSpace])
|
||||||
|
|
||||||
|
useDrag(rangeStripNode, gestureHandlers)
|
||||||
|
|
||||||
|
return usePrism(() => {
|
||||||
|
const existingRange = existingRangeD.getValue()
|
||||||
|
|
||||||
|
const range = existingRange?.range || {start: 0, end: 0}
|
||||||
|
let startX = val(layoutP.clippedSpace.fromUnitSpace)(range.start)
|
||||||
|
let endX = val(layoutP.clippedSpace.fromUnitSpace)(range.end)
|
||||||
|
let scaleX: number, translateX: number
|
||||||
|
|
||||||
|
if (startX < 0) {
|
||||||
|
startX = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if (endX > val(layoutP.clippedSpace.width)) {
|
||||||
|
endX = val(layoutP.clippedSpace.width)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (startX > endX) {
|
||||||
|
translateX = 0
|
||||||
|
scaleX = 0
|
||||||
|
} else {
|
||||||
|
translateX = startX
|
||||||
|
scaleX = (endX - startX) / stripWidth
|
||||||
|
}
|
||||||
|
|
||||||
|
let conditionalStyleProps: {
|
||||||
|
background?: string
|
||||||
|
cursor?: string
|
||||||
|
} = {}
|
||||||
|
|
||||||
|
if (existingRange !== undefined) {
|
||||||
|
if (existingRange.enabled === false) {
|
||||||
|
conditionalStyleProps.background =
|
||||||
|
focusRangeStripTheme.disabled.backgroundColor
|
||||||
|
conditionalStyleProps.cursor = 'default'
|
||||||
|
} else {
|
||||||
|
conditionalStyleProps.cursor = 'grab'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return existingRange === undefined ? (
|
||||||
|
<></>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{contextMenu}
|
||||||
|
<RangeStrip
|
||||||
|
id="range-strip"
|
||||||
|
ref={rangeStripRef as $IntentionalAny}
|
||||||
|
style={{
|
||||||
|
transform: `translateX(${translateX}px) scale(${scaleX}, 1)`,
|
||||||
|
...conditionalStyleProps,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}, [layoutP, rangeStripRef, existingRangeD, contextMenu])
|
||||||
|
}
|
||||||
|
|
||||||
|
export default FocusRangeStrip
|
|
@ -0,0 +1,283 @@
|
||||||
|
import type {Pointer} from '@theatre/dataverse'
|
||||||
|
import {prism, val} from '@theatre/dataverse'
|
||||||
|
import {usePrism} from '@theatre/react'
|
||||||
|
import type {$IntentionalAny, IRange} from '@theatre/shared/utils/types'
|
||||||
|
import {pointerEventsAutoInNormalMode} from '@theatre/studio/css'
|
||||||
|
import getStudio from '@theatre/studio/getStudio'
|
||||||
|
import type {SequenceEditorPanelLayout} from '@theatre/studio/panels/SequenceEditorPanel/layout/layout'
|
||||||
|
import {topStripHeight} from '@theatre/studio/panels/SequenceEditorPanel/RightOverlay/TopStrip'
|
||||||
|
import type {CommitOrDiscard} from '@theatre/studio/StudioStore/StudioStore'
|
||||||
|
import {useCursorLock} from '@theatre/studio/uiComponents/PointerEventsHandler'
|
||||||
|
import useDrag from '@theatre/studio/uiComponents/useDrag'
|
||||||
|
import useRefAndState from '@theatre/studio/utils/useRefAndState'
|
||||||
|
import React, {useMemo, useRef, useState} from 'react'
|
||||||
|
import styled from 'styled-components'
|
||||||
|
import {focusRangeStripTheme} from './FocusRangeStrip'
|
||||||
|
|
||||||
|
const Handler = styled.div`
|
||||||
|
content: ' ';
|
||||||
|
width: ${focusRangeStripTheme.thumbWidth};
|
||||||
|
height: ${() => topStripHeight};
|
||||||
|
position: absolute;
|
||||||
|
${pointerEventsAutoInNormalMode};
|
||||||
|
stroke: ${focusRangeStripTheme.enabled.stroke};
|
||||||
|
user-select: none;
|
||||||
|
&:hover {
|
||||||
|
background: ${focusRangeStripTheme.highlight.backgroundColor} !important;
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
const dims = (size: number) => `
|
||||||
|
left: ${-size / 2}px;
|
||||||
|
width: ${size}px;
|
||||||
|
height: ${size}px;
|
||||||
|
`
|
||||||
|
|
||||||
|
const HitZone = styled.div`
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
transform-origin: left top;
|
||||||
|
position: absolute;
|
||||||
|
z-index: 3;
|
||||||
|
${dims(focusRangeStripTheme.hitZoneWidth)}
|
||||||
|
`
|
||||||
|
|
||||||
|
const Tooltip = styled.div`
|
||||||
|
font-size: 10px;
|
||||||
|
white-space: nowrap;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 2px;
|
||||||
|
${pointerEventsAutoInNormalMode};
|
||||||
|
background-color: #0000004d;
|
||||||
|
display: none;
|
||||||
|
position: absolute;
|
||||||
|
top: -${() => topStripHeight + 2};
|
||||||
|
transform: translateX(-50%);
|
||||||
|
${HitZone}:hover &, ${Handler}.dragging & {
|
||||||
|
display: block;
|
||||||
|
color: white;
|
||||||
|
background-color: '#000000';
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
const FocusRangeThumb: React.FC<{
|
||||||
|
layoutP: Pointer<SequenceEditorPanelLayout>
|
||||||
|
thumbType: keyof IRange
|
||||||
|
}> = ({layoutP, thumbType}) => {
|
||||||
|
const [hitZoneRef, hitZoneNode] = useRefAndState<HTMLElement | null>(null)
|
||||||
|
const handlerRef = useRef<HTMLElement | null>(null)
|
||||||
|
const [isDragging, setIsDragging] = useState<boolean>(false)
|
||||||
|
|
||||||
|
const existingRangeD = useMemo(
|
||||||
|
() =>
|
||||||
|
prism(() => {
|
||||||
|
const {projectId, sheetId} = val(layoutP.sheet).address
|
||||||
|
const existingRange = val(
|
||||||
|
getStudio().atomP.ahistoric.projects.stateByProjectId[projectId]
|
||||||
|
.stateBySheetId[sheetId].sequence.focusRange,
|
||||||
|
)
|
||||||
|
return existingRange
|
||||||
|
}),
|
||||||
|
[layoutP],
|
||||||
|
)
|
||||||
|
|
||||||
|
const sheet = val(layoutP.sheet)
|
||||||
|
const scaledSpaceToUnitSpace = val(layoutP.scaledSpace.toUnitSpace)
|
||||||
|
let sequence = sheet.getSequence()
|
||||||
|
|
||||||
|
const focusRangeEnabled = existingRangeD.getValue()?.enabled || false
|
||||||
|
|
||||||
|
const gestureHandlers = useMemo((): Parameters<typeof useDrag>[1] => {
|
||||||
|
const defaultRange = {start: 0, end: sequence.length}
|
||||||
|
let range = existingRangeD.getValue()?.range || defaultRange
|
||||||
|
let focusRangeEnabled: boolean
|
||||||
|
let posBeforeDrag = range[thumbType]
|
||||||
|
let tempTransaction: CommitOrDiscard | undefined
|
||||||
|
let dragHappened = false
|
||||||
|
let originalBackground: string
|
||||||
|
let originalStroke: string
|
||||||
|
let minFocusRangeStripWidth: number
|
||||||
|
|
||||||
|
return {
|
||||||
|
onDragStart() {
|
||||||
|
let existingRange = existingRangeD.getValue() || {
|
||||||
|
range: defaultRange,
|
||||||
|
enabled: false,
|
||||||
|
}
|
||||||
|
focusRangeEnabled = existingRange.enabled
|
||||||
|
dragHappened = false
|
||||||
|
sequence = val(layoutP.sheet).getSequence()
|
||||||
|
posBeforeDrag = existingRange.range[thumbType]
|
||||||
|
minFocusRangeStripWidth = scaledSpaceToUnitSpace(
|
||||||
|
focusRangeStripTheme.rangeStripMinWidth,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (handlerRef.current) {
|
||||||
|
originalBackground = handlerRef.current.style.background
|
||||||
|
originalStroke = handlerRef.current.style.stroke
|
||||||
|
handlerRef.current.style.background =
|
||||||
|
focusRangeStripTheme.highlight.backgroundColor
|
||||||
|
handlerRef.current.style.stroke =
|
||||||
|
focusRangeStripTheme.highlight.stroke
|
||||||
|
handlerRef.current.style
|
||||||
|
handlerRef.current.classList.add('dragging')
|
||||||
|
setIsDragging(true)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onDrag(dx, _, event) {
|
||||||
|
dragHappened = true
|
||||||
|
range = existingRangeD.getValue()?.range || defaultRange
|
||||||
|
|
||||||
|
const deltaPos = scaledSpaceToUnitSpace(dx)
|
||||||
|
let newPosition: number
|
||||||
|
const oldPosPlusDeltaPos = posBeforeDrag + deltaPos
|
||||||
|
|
||||||
|
// Make sure that the focus range has a minimal width
|
||||||
|
if (thumbType === 'start') {
|
||||||
|
// Prevent the start thumb from going below 0
|
||||||
|
newPosition = Math.max(
|
||||||
|
Math.min(
|
||||||
|
oldPosPlusDeltaPos,
|
||||||
|
range['end'] - minFocusRangeStripWidth,
|
||||||
|
),
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
// Prevent the start thumb from going over the length of the sequence
|
||||||
|
newPosition = Math.min(
|
||||||
|
Math.max(
|
||||||
|
oldPosPlusDeltaPos,
|
||||||
|
range['start'] + minFocusRangeStripWidth,
|
||||||
|
),
|
||||||
|
sequence.length,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enable snapping
|
||||||
|
const snapTarget = event
|
||||||
|
.composedPath()
|
||||||
|
.find(
|
||||||
|
(el): el is Element =>
|
||||||
|
el instanceof Element &&
|
||||||
|
el !== hitZoneNode &&
|
||||||
|
el.hasAttribute('data-pos'),
|
||||||
|
)
|
||||||
|
|
||||||
|
if (snapTarget) {
|
||||||
|
const snapPos = parseFloat(snapTarget.getAttribute('data-pos')!)
|
||||||
|
|
||||||
|
if (isFinite(snapPos)) {
|
||||||
|
newPosition = snapPos
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const newPositionInFrame = sequence.closestGridPosition(newPosition)
|
||||||
|
|
||||||
|
if (tempTransaction !== undefined) {
|
||||||
|
tempTransaction.discard()
|
||||||
|
}
|
||||||
|
|
||||||
|
tempTransaction = getStudio().tempTransaction(({stateEditors}) => {
|
||||||
|
stateEditors.studio.ahistoric.projects.stateByProjectId.stateBySheetId.sequence.focusRange.set(
|
||||||
|
{
|
||||||
|
...sheet.address,
|
||||||
|
range: {...range, [thumbType]: newPositionInFrame},
|
||||||
|
enabled: focusRangeEnabled,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
onDragEnd() {
|
||||||
|
if (handlerRef.current) {
|
||||||
|
handlerRef.current.classList.remove('dragging')
|
||||||
|
setIsDragging(false)
|
||||||
|
|
||||||
|
if (originalBackground) {
|
||||||
|
handlerRef.current.style.background = originalBackground
|
||||||
|
}
|
||||||
|
if (originalBackground) {
|
||||||
|
handlerRef.current.style.stroke = originalStroke
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (dragHappened && tempTransaction !== undefined) {
|
||||||
|
tempTransaction.commit()
|
||||||
|
} else if (tempTransaction) {
|
||||||
|
tempTransaction.discard()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
lockCursorTo: thumbType === 'start' ? 'w-resize' : 'e-resize',
|
||||||
|
}
|
||||||
|
}, [sheet, scaledSpaceToUnitSpace])
|
||||||
|
|
||||||
|
useDrag(hitZoneNode, gestureHandlers)
|
||||||
|
|
||||||
|
useCursorLock(isDragging, 'draggingPositionInSequenceEditor', 'ew-resize')
|
||||||
|
|
||||||
|
return usePrism(() => {
|
||||||
|
const existingRange = existingRangeD.getValue()
|
||||||
|
const defaultRange = {
|
||||||
|
range: {start: 0, end: sequence.length},
|
||||||
|
enabled: false,
|
||||||
|
}
|
||||||
|
const position =
|
||||||
|
existingRange?.range[thumbType] || defaultRange.range[thumbType]
|
||||||
|
|
||||||
|
let posInClippedSpace: number = val(layoutP.clippedSpace.fromUnitSpace)(
|
||||||
|
position,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (
|
||||||
|
posInClippedSpace < 0 ||
|
||||||
|
val(layoutP.clippedSpace.width) < posInClippedSpace
|
||||||
|
) {
|
||||||
|
posInClippedSpace = -1000
|
||||||
|
}
|
||||||
|
|
||||||
|
const pointerEvents = focusRangeEnabled ? 'auto' : 'none'
|
||||||
|
|
||||||
|
const background = focusRangeEnabled
|
||||||
|
? focusRangeStripTheme.disabled.backgroundColor
|
||||||
|
: focusRangeStripTheme.enabled.backgroundColor
|
||||||
|
|
||||||
|
const startHandlerOffset = focusRangeStripTheme.hitZoneWidth / 2
|
||||||
|
const endHandlerOffset =
|
||||||
|
startHandlerOffset - focusRangeStripTheme.thumbWidth
|
||||||
|
|
||||||
|
return existingRange !== undefined ? (
|
||||||
|
<>
|
||||||
|
<HitZone
|
||||||
|
ref={hitZoneRef as $IntentionalAny}
|
||||||
|
data-pos={position.toFixed(3)}
|
||||||
|
style={{
|
||||||
|
transform: `translate3d(${posInClippedSpace}px, 0, 0)`,
|
||||||
|
cursor: thumbType === 'start' ? 'w-resize' : 'e-resize',
|
||||||
|
pointerEvents,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Handler
|
||||||
|
ref={handlerRef as $IntentionalAny}
|
||||||
|
style={{
|
||||||
|
background,
|
||||||
|
left: `${
|
||||||
|
thumbType === 'start' ? startHandlerOffset : endHandlerOffset
|
||||||
|
}px`,
|
||||||
|
pointerEvents,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 9 18" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<line x1="4" y1="6" x2="4" y2="12" />
|
||||||
|
<line x1="6" y1="6" x2="6" y2="12" />
|
||||||
|
</svg>
|
||||||
|
<Tooltip>
|
||||||
|
{sequence.positionFormatter.formatBasic(sequence.length)}
|
||||||
|
</Tooltip>
|
||||||
|
</Handler>
|
||||||
|
</HitZone>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<></>
|
||||||
|
)
|
||||||
|
}, [layoutP, hitZoneRef, existingRangeD, focusRangeEnabled])
|
||||||
|
}
|
||||||
|
|
||||||
|
export default FocusRangeThumb
|
|
@ -0,0 +1,255 @@
|
||||||
|
import type Sequence from '@theatre/core/sequences/Sequence'
|
||||||
|
import type Sheet from '@theatre/core/sheets/Sheet'
|
||||||
|
import type {Pointer} from '@theatre/dataverse'
|
||||||
|
import {prism, val} from '@theatre/dataverse'
|
||||||
|
import {usePrism} from '@theatre/react'
|
||||||
|
import type {$IntentionalAny, VoidFn} from '@theatre/shared/utils/types'
|
||||||
|
import getStudio from '@theatre/studio/getStudio'
|
||||||
|
import {
|
||||||
|
panelDimsToPanelPosition,
|
||||||
|
usePanel,
|
||||||
|
} from '@theatre/studio/panels/BasePanel/BasePanel'
|
||||||
|
import type {SequenceEditorPanelLayout} from '@theatre/studio/panels/SequenceEditorPanel/layout/layout'
|
||||||
|
import {topStripHeight} from '@theatre/studio/panels/SequenceEditorPanel/RightOverlay/TopStrip'
|
||||||
|
import type {CommitOrDiscard} from '@theatre/studio/StudioStore/StudioStore'
|
||||||
|
import useDrag from '@theatre/studio/uiComponents/useDrag'
|
||||||
|
import useRefAndState from '@theatre/studio/utils/useRefAndState'
|
||||||
|
import {clamp} from 'lodash-es'
|
||||||
|
import React, {useMemo, useRef} from 'react'
|
||||||
|
import styled from 'styled-components'
|
||||||
|
import FocusRangeStrip, {focusRangeStripTheme} from './FocusRangeStrip'
|
||||||
|
import FocusRangeThumb from './FocusRangeThumb'
|
||||||
|
|
||||||
|
const Container = styled.div`
|
||||||
|
position: absolute;
|
||||||
|
height: ${() => topStripHeight}px;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
`
|
||||||
|
|
||||||
|
const FocusRangeZone: React.FC<{
|
||||||
|
layoutP: Pointer<SequenceEditorPanelLayout>
|
||||||
|
}> = ({layoutP}) => {
|
||||||
|
const [containerRef, containerNode] = useRefAndState<HTMLElement | null>(null)
|
||||||
|
|
||||||
|
const panelStuff = usePanel()
|
||||||
|
const panelStuffRef = useRef(panelStuff)
|
||||||
|
panelStuffRef.current = panelStuff
|
||||||
|
|
||||||
|
const existingRangeD = useMemo(
|
||||||
|
() =>
|
||||||
|
prism(() => {
|
||||||
|
const {projectId, sheetId} = val(layoutP.sheet).address
|
||||||
|
const existingRange = val(
|
||||||
|
getStudio().atomP.ahistoric.projects.stateByProjectId[projectId]
|
||||||
|
.stateBySheetId[sheetId].sequence.focusRange,
|
||||||
|
)
|
||||||
|
return existingRange
|
||||||
|
}),
|
||||||
|
[layoutP],
|
||||||
|
)
|
||||||
|
|
||||||
|
useDrag(
|
||||||
|
containerNode,
|
||||||
|
usePanelDragZoneGestureHandlers(layoutP, panelStuffRef),
|
||||||
|
)
|
||||||
|
|
||||||
|
const [onMouseEnter, onMouseLeave] = useMemo(() => {
|
||||||
|
let unlock: VoidFn | undefined
|
||||||
|
return [
|
||||||
|
function onMouseEnter(event: React.MouseEvent) {
|
||||||
|
if (event.shiftKey === false) {
|
||||||
|
if (unlock) {
|
||||||
|
const u = unlock
|
||||||
|
unlock = undefined
|
||||||
|
u()
|
||||||
|
}
|
||||||
|
unlock = panelStuffRef.current.addBoundsHighlightLock()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
function onMouseLeave(event: React.MouseEvent) {
|
||||||
|
if (event.shiftKey === false) {
|
||||||
|
if (unlock) {
|
||||||
|
const u = unlock
|
||||||
|
unlock = undefined
|
||||||
|
u()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return usePrism(() => {
|
||||||
|
return (
|
||||||
|
<Container
|
||||||
|
ref={containerRef as $IntentionalAny}
|
||||||
|
onMouseEnter={onMouseEnter}
|
||||||
|
onMouseLeave={onMouseLeave}
|
||||||
|
>
|
||||||
|
<FocusRangeStrip layoutP={layoutP} />
|
||||||
|
<FocusRangeThumb thumbType="start" layoutP={layoutP} />
|
||||||
|
<FocusRangeThumb thumbType="end" layoutP={layoutP} />
|
||||||
|
</Container>
|
||||||
|
)
|
||||||
|
}, [layoutP, existingRangeD])
|
||||||
|
}
|
||||||
|
|
||||||
|
export default FocusRangeZone
|
||||||
|
|
||||||
|
function usePanelDragZoneGestureHandlers(
|
||||||
|
layoutP: Pointer<SequenceEditorPanelLayout>,
|
||||||
|
panelStuffRef: React.MutableRefObject<ReturnType<typeof usePanel>>,
|
||||||
|
) {
|
||||||
|
return useMemo((): Parameters<typeof useDrag>[1] => {
|
||||||
|
const focusRangeCreationGestureHandlers = (): Parameters<
|
||||||
|
typeof useDrag
|
||||||
|
>[1] => {
|
||||||
|
let startPosInUnitSpace: number,
|
||||||
|
tempTransaction: CommitOrDiscard | undefined
|
||||||
|
|
||||||
|
let clippedSpaceToUnitSpace: (s: number) => number
|
||||||
|
let scaledSpaceToUnitSpace: (s: number) => number
|
||||||
|
let sequence: Sequence
|
||||||
|
let sheet: Sheet
|
||||||
|
let minFocusRangeStripWidth: number
|
||||||
|
|
||||||
|
return {
|
||||||
|
onDragStart(event) {
|
||||||
|
clippedSpaceToUnitSpace = val(layoutP.clippedSpace.toUnitSpace)
|
||||||
|
scaledSpaceToUnitSpace = val(layoutP.scaledSpace.toUnitSpace)
|
||||||
|
sheet = val(layoutP.sheet)
|
||||||
|
sequence = sheet.getSequence()
|
||||||
|
|
||||||
|
const targetElement: HTMLElement = event.target as HTMLElement
|
||||||
|
const rect = targetElement!.getBoundingClientRect()
|
||||||
|
startPosInUnitSpace = clippedSpaceToUnitSpace(
|
||||||
|
event.clientX - rect.left,
|
||||||
|
)
|
||||||
|
minFocusRangeStripWidth = scaledSpaceToUnitSpace(
|
||||||
|
focusRangeStripTheme.rangeStripMinWidth,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onDrag(dx) {
|
||||||
|
const deltaPos = scaledSpaceToUnitSpace(dx)
|
||||||
|
|
||||||
|
let start = startPosInUnitSpace
|
||||||
|
let end = startPosInUnitSpace + deltaPos
|
||||||
|
|
||||||
|
;[start, end] = [
|
||||||
|
clamp(start, 0, sequence.length),
|
||||||
|
clamp(end, 0, sequence.length),
|
||||||
|
].map((pos) => sequence.closestGridPosition(pos))
|
||||||
|
|
||||||
|
if (end < start) {
|
||||||
|
;[start, end] = [
|
||||||
|
Math.max(Math.min(end, start - minFocusRangeStripWidth), 0),
|
||||||
|
start,
|
||||||
|
]
|
||||||
|
} else if (dx > 0) {
|
||||||
|
end = Math.min(
|
||||||
|
Math.max(end, start + minFocusRangeStripWidth),
|
||||||
|
sequence.length,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tempTransaction) {
|
||||||
|
tempTransaction.discard()
|
||||||
|
}
|
||||||
|
|
||||||
|
tempTransaction = getStudio().tempTransaction(({stateEditors}) => {
|
||||||
|
stateEditors.studio.ahistoric.projects.stateByProjectId.stateBySheetId.sequence.focusRange.set(
|
||||||
|
{
|
||||||
|
...sheet.address,
|
||||||
|
range: {start, end},
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
onDragEnd(dragHappened) {
|
||||||
|
if (dragHappened && tempTransaction !== undefined) {
|
||||||
|
tempTransaction.commit()
|
||||||
|
} else if (tempTransaction) {
|
||||||
|
tempTransaction.discard()
|
||||||
|
}
|
||||||
|
tempTransaction = undefined
|
||||||
|
},
|
||||||
|
lockCursorTo: 'grabbing',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const panelMoveGestureHandlers = (): Parameters<typeof useDrag>[1] => {
|
||||||
|
let stuffBeforeDrag = panelStuffRef.current
|
||||||
|
let tempTransaction: CommitOrDiscard | undefined
|
||||||
|
let unlock: VoidFn | undefined
|
||||||
|
return {
|
||||||
|
onDragStart() {
|
||||||
|
stuffBeforeDrag = panelStuffRef.current
|
||||||
|
if (unlock) {
|
||||||
|
const u = unlock
|
||||||
|
unlock = undefined
|
||||||
|
u()
|
||||||
|
}
|
||||||
|
unlock = panelStuffRef.current.addBoundsHighlightLock()
|
||||||
|
},
|
||||||
|
onDrag(dx, dy) {
|
||||||
|
const newDims: typeof panelStuffRef.current['dims'] = {
|
||||||
|
...stuffBeforeDrag.dims,
|
||||||
|
top: stuffBeforeDrag.dims.top + dy,
|
||||||
|
left: stuffBeforeDrag.dims.left + dx,
|
||||||
|
}
|
||||||
|
const position = panelDimsToPanelPosition(newDims, {
|
||||||
|
width: window.innerWidth,
|
||||||
|
height: window.innerHeight,
|
||||||
|
})
|
||||||
|
|
||||||
|
tempTransaction?.discard()
|
||||||
|
tempTransaction = getStudio()!.tempTransaction(({stateEditors}) => {
|
||||||
|
stateEditors.studio.historic.panelPositions.setPanelPosition({
|
||||||
|
position,
|
||||||
|
panelId: stuffBeforeDrag.panelId,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
},
|
||||||
|
onDragEnd(dragHappened) {
|
||||||
|
if (unlock) {
|
||||||
|
const u = unlock
|
||||||
|
unlock = undefined
|
||||||
|
u()
|
||||||
|
}
|
||||||
|
if (dragHappened) {
|
||||||
|
tempTransaction?.commit()
|
||||||
|
} else {
|
||||||
|
tempTransaction?.discard()
|
||||||
|
}
|
||||||
|
tempTransaction = undefined
|
||||||
|
},
|
||||||
|
lockCursorTo: 'move',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let currentGestureHandlers: undefined | Parameters<typeof useDrag>[1]
|
||||||
|
|
||||||
|
return {
|
||||||
|
onDragStart(event) {
|
||||||
|
if (event.shiftKey) {
|
||||||
|
currentGestureHandlers = focusRangeCreationGestureHandlers()
|
||||||
|
} else {
|
||||||
|
currentGestureHandlers = panelMoveGestureHandlers()
|
||||||
|
}
|
||||||
|
currentGestureHandlers.onDragStart!(event)
|
||||||
|
},
|
||||||
|
onDrag(dx, dy, event) {
|
||||||
|
if (!currentGestureHandlers) {
|
||||||
|
console.error('oh no')
|
||||||
|
}
|
||||||
|
currentGestureHandlers!.onDrag(dx, dy, event)
|
||||||
|
},
|
||||||
|
onDragEnd(dragHappened) {
|
||||||
|
currentGestureHandlers!.onDragEnd!(dragHappened)
|
||||||
|
},
|
||||||
|
lockCursorTo: 'grabbing',
|
||||||
|
}
|
||||||
|
}, [layoutP, panelStuffRef])
|
||||||
|
}
|
|
@ -12,6 +12,13 @@ import {
|
||||||
useFrameStampPositionD,
|
useFrameStampPositionD,
|
||||||
} from '@theatre/studio/panels/SequenceEditorPanel/FrameStampPositionProvider'
|
} from '@theatre/studio/panels/SequenceEditorPanel/FrameStampPositionProvider'
|
||||||
|
|
||||||
|
const Container = styled.div`
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
margin-top: 0px;
|
||||||
|
`
|
||||||
|
|
||||||
const Label = styled.div`
|
const Label = styled.div`
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 16px;
|
top: 16px;
|
||||||
|
@ -63,21 +70,23 @@ const FrameStamp: React.FC<{
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Label
|
<Container>
|
||||||
style={{
|
<Label
|
||||||
opacity: isVisible ? 1 : 0,
|
style={{
|
||||||
transform: `translate3d(calc(${posInClippedSpace}px - 50%), 0, 0)`,
|
opacity: isVisible ? 1 : 0,
|
||||||
}}
|
transform: `translate3d(calc(${posInClippedSpace}px - 50%), 0, 0)`,
|
||||||
>
|
}}
|
||||||
{formatter.formatForPlayhead(snappedPosInUnitSpace)}
|
>
|
||||||
</Label>
|
{formatter.formatForPlayhead(snappedPosInUnitSpace)}
|
||||||
<Line
|
</Label>
|
||||||
posType={posType}
|
<Line
|
||||||
style={{
|
posType={posType}
|
||||||
opacity: isVisible ? 1 : 0,
|
style={{
|
||||||
transform: `translate3d(${posInClippedSpace}px, 0, 0)`,
|
opacity: isVisible ? 1 : 0,
|
||||||
}}
|
transform: `translate3d(${posInClippedSpace}px, 0, 0)`,
|
||||||
/>
|
}}
|
||||||
|
/>
|
||||||
|
</Container>{' '}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
|
@ -19,6 +19,7 @@ import {pointerEventsAutoInNormalMode} from '@theatre/studio/css'
|
||||||
import usePopover from '@theatre/studio/uiComponents/Popover/usePopover'
|
import usePopover from '@theatre/studio/uiComponents/Popover/usePopover'
|
||||||
import BasicPopover from '@theatre/studio/uiComponents/Popover/BasicPopover'
|
import BasicPopover from '@theatre/studio/uiComponents/Popover/BasicPopover'
|
||||||
import PlayheadPositionPopover from './PlayheadPositionPopover'
|
import PlayheadPositionPopover from './PlayheadPositionPopover'
|
||||||
|
import {getIsPlayheadAttachedToFocusRange} from '@theatre/studio/UIRoot/useKeyboardShortcuts'
|
||||||
|
|
||||||
const Container = styled.div<{isVisible: boolean}>`
|
const Container = styled.div<{isVisible: boolean}>`
|
||||||
--thumbColor: #00e0ff;
|
--thumbColor: #00e0ff;
|
||||||
|
@ -54,7 +55,6 @@ const Rod = styled.div`
|
||||||
`
|
`
|
||||||
|
|
||||||
const Thumb = styled.div`
|
const Thumb = styled.div`
|
||||||
background-color: var(--thumbColor);
|
|
||||||
position: absolute;
|
position: absolute;
|
||||||
width: 5px;
|
width: 5px;
|
||||||
height: 13px;
|
height: 13px;
|
||||||
|
@ -67,75 +67,11 @@ const Thumb = styled.div`
|
||||||
#pointer-root.draggingPositionInSequenceEditor &:not(.seeking) {
|
#pointer-root.draggingPositionInSequenceEditor &:not(.seeking) {
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:before {
|
|
||||||
position: absolute;
|
|
||||||
display: block;
|
|
||||||
content: ' ';
|
|
||||||
left: -2px;
|
|
||||||
width: 0;
|
|
||||||
height: 0;
|
|
||||||
border-bottom: 4px solid #1f2b2b;
|
|
||||||
border-left: 2px solid transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:after {
|
|
||||||
position: absolute;
|
|
||||||
display: block;
|
|
||||||
content: ' ';
|
|
||||||
right: -2px;
|
|
||||||
width: 0;
|
|
||||||
height: 0;
|
|
||||||
border-bottom: 4px solid #1f2b2b;
|
|
||||||
border-right: 2px solid transparent;
|
|
||||||
}
|
|
||||||
`
|
|
||||||
|
|
||||||
const Squinch = styled.div`
|
|
||||||
position: absolute;
|
|
||||||
left: 1px;
|
|
||||||
right: 1px;
|
|
||||||
top: 13px;
|
|
||||||
border-top: 3px solid var(--thumbColor);
|
|
||||||
border-right: 1px solid transparent;
|
|
||||||
border-left: 1px solid transparent;
|
|
||||||
pointer-events: none;
|
|
||||||
|
|
||||||
&:before {
|
|
||||||
position: absolute;
|
|
||||||
display: block;
|
|
||||||
content: ' ';
|
|
||||||
top: -4px;
|
|
||||||
left: -2px;
|
|
||||||
height: 8px;
|
|
||||||
width: 2px;
|
|
||||||
background: none;
|
|
||||||
border-radius: 0 100% 0 0;
|
|
||||||
border-top: 1px solid var(--thumbColor);
|
|
||||||
border-right: 1px solid var(--thumbColor);
|
|
||||||
}
|
|
||||||
|
|
||||||
&:after {
|
|
||||||
position: absolute;
|
|
||||||
display: block;
|
|
||||||
content: ' ';
|
|
||||||
top: -4px;
|
|
||||||
right: -2px;
|
|
||||||
height: 8px;
|
|
||||||
width: 2px;
|
|
||||||
background: none;
|
|
||||||
border-radius: 100% 0 0 0;
|
|
||||||
border-top: 1px solid var(--thumbColor);
|
|
||||||
border-left: 1px solid var(--thumbColor);
|
|
||||||
}
|
|
||||||
`
|
`
|
||||||
|
|
||||||
const Tooltip = styled.div`
|
const Tooltip = styled.div`
|
||||||
display: none;
|
display: none;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: -20px;
|
|
||||||
left: 4px;
|
|
||||||
padding: 0 2px;
|
|
||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
background: #1a1a1a;
|
background: #1a1a1a;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
|
@ -148,6 +84,34 @@ const Tooltip = styled.div`
|
||||||
}
|
}
|
||||||
`
|
`
|
||||||
|
|
||||||
|
const RegularThumbSvg: React.FC = () => (
|
||||||
|
<svg
|
||||||
|
width="7"
|
||||||
|
height="26"
|
||||||
|
viewBox="0 0 7 26"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
style={{fill: '#00e0ff', marginLeft: '-1px'}}
|
||||||
|
>
|
||||||
|
<path d="M 0,0 L 7,0 L 7,13 C 4,15 4,26 4,26 L 3,26 C 3,26 3,15 0,13 L 0,0 Z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
const LargeThumbSvg: React.FC = () => (
|
||||||
|
<svg
|
||||||
|
width="9"
|
||||||
|
height="37"
|
||||||
|
viewBox="0 0 9 37"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
style={{
|
||||||
|
fill: '#00e0ff',
|
||||||
|
marginLeft: '-2px',
|
||||||
|
marginTop: '-4px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<path d="M 0,0 L 9,0 L 9,18 C 5,20 5,37 5,37 L 4,37 C 4,37 4,20 0,18 L 0,0 Z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
const Playhead: React.FC<{layoutP: Pointer<SequenceEditorPanelLayout>}> = ({
|
const Playhead: React.FC<{layoutP: Pointer<SequenceEditorPanelLayout>}> = ({
|
||||||
layoutP,
|
layoutP,
|
||||||
}) => {
|
}) => {
|
||||||
|
@ -167,18 +131,18 @@ const Playhead: React.FC<{layoutP: Pointer<SequenceEditorPanelLayout>}> = ({
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const scaledSpaceToUnitSpace = val(layoutP.scaledSpace.toUnitSpace)
|
||||||
|
|
||||||
const gestureHandlers = useMemo((): Parameters<typeof useDrag>[1] => {
|
const gestureHandlers = useMemo((): Parameters<typeof useDrag>[1] => {
|
||||||
const setIsSeeking = val(layoutP.seeker.setIsSeeking)
|
const setIsSeeking = val(layoutP.seeker.setIsSeeking)
|
||||||
|
|
||||||
let posBeforeSeek = 0
|
let posBeforeSeek = 0
|
||||||
let sequence: Sequence
|
let sequence: Sequence
|
||||||
let scaledSpaceToUnitSpace: typeof layoutP.scaledSpace.toUnitSpace.$$__pointer_type
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
onDragStart() {
|
onDragStart() {
|
||||||
sequence = val(layoutP.sheet).getSequence()
|
sequence = val(layoutP.sheet).getSequence()
|
||||||
posBeforeSeek = sequence.position
|
posBeforeSeek = sequence.position
|
||||||
scaledSpaceToUnitSpace = val(layoutP.scaledSpace.toUnitSpace)
|
|
||||||
setIsSeeking(true)
|
setIsSeeking(true)
|
||||||
},
|
},
|
||||||
onDrag(dx, _, event) {
|
onDrag(dx, _, event) {
|
||||||
|
@ -210,7 +174,7 @@ const Playhead: React.FC<{layoutP: Pointer<SequenceEditorPanelLayout>}> = ({
|
||||||
},
|
},
|
||||||
lockCursorTo: 'ew-resize',
|
lockCursorTo: 'ew-resize',
|
||||||
}
|
}
|
||||||
}, [])
|
}, [scaledSpaceToUnitSpace])
|
||||||
|
|
||||||
useDrag(thumbNode, gestureHandlers)
|
useDrag(thumbNode, gestureHandlers)
|
||||||
|
|
||||||
|
@ -231,6 +195,10 @@ const Playhead: React.FC<{layoutP: Pointer<SequenceEditorPanelLayout>}> = ({
|
||||||
posInClippedSpace >= 0 &&
|
posInClippedSpace >= 0 &&
|
||||||
posInClippedSpace <= val(layoutP.clippedSpace.width)
|
posInClippedSpace <= val(layoutP.clippedSpace.width)
|
||||||
|
|
||||||
|
const isPlayheadAttachedToFocusRange = val(
|
||||||
|
getIsPlayheadAttachedToFocusRange(sequence),
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{popoverNode}
|
{popoverNode}
|
||||||
|
@ -243,13 +211,16 @@ const Playhead: React.FC<{layoutP: Pointer<SequenceEditorPanelLayout>}> = ({
|
||||||
<Thumb
|
<Thumb
|
||||||
ref={thumbRef as $IntentionalAny}
|
ref={thumbRef as $IntentionalAny}
|
||||||
data-pos={posInUnitSpace.toFixed(3)}
|
data-pos={posInUnitSpace.toFixed(3)}
|
||||||
onClick={(e) => {
|
|
||||||
openPopover(e, thumbNode!)
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<RoomToClick room={8} />
|
<RoomToClick room={8} />
|
||||||
<Squinch />
|
{isPlayheadAttachedToFocusRange ? (
|
||||||
<Tooltip>
|
<LargeThumbSvg />
|
||||||
|
) : (
|
||||||
|
<RegularThumbSvg />
|
||||||
|
)}
|
||||||
|
<Tooltip
|
||||||
|
style={{top: isPlayheadAttachedToFocusRange ? '-23px' : '-18px'}}
|
||||||
|
>
|
||||||
{sequence.positionFormatter.formatForPlayhead(
|
{sequence.positionFormatter.formatForPlayhead(
|
||||||
sequence.closestGridPosition(posInUnitSpace),
|
sequence.closestGridPosition(posInUnitSpace),
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -4,18 +4,18 @@ import React from 'react'
|
||||||
import styled from 'styled-components'
|
import styled from 'styled-components'
|
||||||
import type {SequenceEditorPanelLayout} from '@theatre/studio/panels/SequenceEditorPanel/layout/layout'
|
import type {SequenceEditorPanelLayout} from '@theatre/studio/panels/SequenceEditorPanel/layout/layout'
|
||||||
import StampsGrid from '@theatre/studio/panels/SequenceEditorPanel/FrameGrid/StampsGrid'
|
import StampsGrid from '@theatre/studio/panels/SequenceEditorPanel/FrameGrid/StampsGrid'
|
||||||
import PanelDragZone from '@theatre/studio/panels/BasePanel/PanelDragZone'
|
|
||||||
import {attributeNameThatLocksFramestamp} from '@theatre/studio/panels/SequenceEditorPanel/FrameStampPositionProvider'
|
import {attributeNameThatLocksFramestamp} from '@theatre/studio/panels/SequenceEditorPanel/FrameStampPositionProvider'
|
||||||
import {pointerEventsAutoInNormalMode} from '@theatre/studio/css'
|
import {pointerEventsAutoInNormalMode} from '@theatre/studio/css'
|
||||||
|
import FocusRangeZone from './FocusRangeZone/FocusRangeZone'
|
||||||
|
|
||||||
export const topStripHeight = 20
|
export const topStripHeight = 18
|
||||||
|
|
||||||
export const topStripTheme = {
|
export const topStripTheme = {
|
||||||
backgroundColor: `#1f2120eb`,
|
backgroundColor: `#1f2120eb`,
|
||||||
borderColor: `#1c1e21`,
|
borderColor: `#1c1e21`,
|
||||||
}
|
}
|
||||||
|
|
||||||
const Container = styled(PanelDragZone)`
|
const Container = styled.div`
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
|
@ -31,10 +31,14 @@ const TopStrip: React.FC<{layoutP: Pointer<SequenceEditorPanelLayout>}> = ({
|
||||||
layoutP,
|
layoutP,
|
||||||
}) => {
|
}) => {
|
||||||
const width = useVal(layoutP.rightDims.width)
|
const width = useVal(layoutP.rightDims.width)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container {...{[attributeNameThatLocksFramestamp]: 'hide'}}>
|
<>
|
||||||
<StampsGrid layoutP={layoutP} width={width} height={topStripHeight} />
|
<Container {...{[attributeNameThatLocksFramestamp]: 'hide'}}>
|
||||||
</Container>
|
<StampsGrid layoutP={layoutP} width={width} height={topStripHeight} />
|
||||||
|
<FocusRangeZone layoutP={layoutP} />
|
||||||
|
</Container>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -365,6 +365,25 @@ namespace stateEditors {
|
||||||
return sheetState.sequence!
|
return sheetState.sequence!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export namespace focusRange {
|
||||||
|
export function set(
|
||||||
|
p: WithoutSheetInstance<SheetAddress> & {
|
||||||
|
range: IRange
|
||||||
|
enabled: boolean
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
stateEditors.studio.ahistoric.projects.stateByProjectId.stateBySheetId.sequence._ensure(
|
||||||
|
p,
|
||||||
|
).focusRange = {range: p.range, enabled: p.enabled}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unset(p: WithoutSheetInstance<SheetAddress>) {
|
||||||
|
stateEditors.studio.ahistoric.projects.stateByProjectId.stateBySheetId.sequence._ensure(
|
||||||
|
p,
|
||||||
|
).focusRange = undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export namespace clippedSpaceRange {
|
export namespace clippedSpaceRange {
|
||||||
export function set(
|
export function set(
|
||||||
p: WithoutSheetInstance<SheetAddress> & {
|
p: WithoutSheetInstance<SheetAddress> & {
|
||||||
|
|
|
@ -23,7 +23,27 @@ export type StudioAhistoricState = {
|
||||||
string,
|
string,
|
||||||
{
|
{
|
||||||
sequence?: {
|
sequence?: {
|
||||||
|
/**
|
||||||
|
* Stores the zoom level and scroll position of the sequence editor panel
|
||||||
|
* for this particular sheet.
|
||||||
|
*/
|
||||||
clippedSpaceRange?: IRange
|
clippedSpaceRange?: IRange
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @remarks
|
||||||
|
* We just added this in 0.4.8. Because of that, we defined this as an optional
|
||||||
|
* prop, so that a user upgrading from 0.4.7 where `focusRange` did not exist,
|
||||||
|
* would not be shown an error.
|
||||||
|
*
|
||||||
|
* Basically, as the store's state evolves, it should always be backwards-compatible.
|
||||||
|
* In other words, the state of `<0.4.8` should always be valid state for `>=0.4.8`.
|
||||||
|
*
|
||||||
|
* If that is not feasible, then we should write a migration script.
|
||||||
|
*/
|
||||||
|
focusRange?: {
|
||||||
|
enabled: boolean
|
||||||
|
range: IRange
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|
|
@ -81,6 +81,7 @@ export type StudioHistoricState = {
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
}
|
}
|
||||||
|
|
||||||
panels?: Panels
|
panels?: Panels
|
||||||
panelPositions?: {[panelIdOrPaneId in string]?: PanelPosition}
|
panelPositions?: {[panelIdOrPaneId in string]?: PanelPosition}
|
||||||
panelInstanceDesceriptors: {
|
panelInstanceDesceriptors: {
|
||||||
|
|
|
@ -5,8 +5,21 @@ export * from './ahistoric'
|
||||||
export * from './ephemeral'
|
export * from './ephemeral'
|
||||||
export * from './historic'
|
export * from './historic'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Describes the type of the object inside our store (redux store).
|
||||||
|
*/
|
||||||
export type StudioState = {
|
export type StudioState = {
|
||||||
|
/**
|
||||||
|
* This is the part of the state that is undo/redo-able
|
||||||
|
*/
|
||||||
historic: StudioHistoricState
|
historic: StudioHistoricState
|
||||||
|
/**
|
||||||
|
* This is the part of the state that can't be undone, but it's
|
||||||
|
* still persisted to localStorage
|
||||||
|
*/
|
||||||
ahistoric: StudioAhistoricState
|
ahistoric: StudioAhistoricState
|
||||||
|
/**
|
||||||
|
* This is entirely ephemeral, and gets lost if user refreshes the page
|
||||||
|
*/
|
||||||
ephemeral: StudioEphemeralState
|
ephemeral: StudioEphemeralState
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,11 +4,40 @@ import useRefAndState from '@theatre/studio/utils/useRefAndState'
|
||||||
import {useCursorLock} from './PointerEventsHandler'
|
import {useCursorLock} from './PointerEventsHandler'
|
||||||
|
|
||||||
export type UseDragOpts = {
|
export type UseDragOpts = {
|
||||||
|
/**
|
||||||
|
* Setting it to true will disable the listeners.
|
||||||
|
*/
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
|
/**
|
||||||
|
* Setting it to true will allow the mouse down events to propagate up
|
||||||
|
*/
|
||||||
dontBlockMouseDown?: boolean
|
dontBlockMouseDown?: boolean
|
||||||
|
/**
|
||||||
|
* The css cursor property during the gesture will be locked to this value
|
||||||
|
*/
|
||||||
lockCursorTo?: string
|
lockCursorTo?: string
|
||||||
|
/**
|
||||||
|
* Called at the start of the gesture. Mind you, that this would be called, even
|
||||||
|
* if the user is just clicking (and not dragging). However, if the gesture turns
|
||||||
|
* out to be a click, then onDragEnd(false) will be called. Otherwise,
|
||||||
|
* a series of `onDrag(dx, dy, event)` events will be called, and the
|
||||||
|
* gesture will end with `onDragEnd(true)`.
|
||||||
|
*/
|
||||||
onDragStart?: (event: MouseEvent) => void | false
|
onDragStart?: (event: MouseEvent) => void | false
|
||||||
|
/**
|
||||||
|
* Called at the end of the drag gesture.
|
||||||
|
* `dragHappened` will be `true` if the user actually moved the pointer
|
||||||
|
* (if onDrag isn't called, then this will be false becuase the user hasn't moved the pointer)
|
||||||
|
*/
|
||||||
onDragEnd?: (dragHappened: boolean) => void
|
onDragEnd?: (dragHappened: boolean) => void
|
||||||
|
/**
|
||||||
|
* This will be called 0 times if the gesture ends up being a click,
|
||||||
|
* or 1 or more times if it ends up being a drag gesture.
|
||||||
|
*
|
||||||
|
* `dx`: the delta x
|
||||||
|
* `dy`: the delta y
|
||||||
|
* `event`: the mouse event
|
||||||
|
*/
|
||||||
onDrag: (dx: number, dy: number, event: MouseEvent) => void
|
onDrag: (dx: number, dy: number, event: MouseEvent) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue