Add focus range (#94)

This commit is contained in:
Fulop 2022-03-25 16:44:18 +01:00 committed by GitHub
parent 8a9b26eb41
commit ffdebebfff
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
22 changed files with 1328 additions and 101 deletions

View file

@ -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 {
sheetsById: StrictRecord<string, SheetState_Historic>

View file

@ -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(
conf?: Partial<{
iterationCount: number

View file

@ -5,7 +5,7 @@ import type {
import {defer} from '@theatre/shared/utils/defer'
import {InvalidArgumentError} from '@theatre/shared/utils/errors'
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 type {
IPlaybackController,
@ -33,6 +33,11 @@ export default class AudioPlaybackController implements IPlaybackController {
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() {
return this._state.getState().playing
}

View file

@ -4,7 +4,7 @@ import type {
} from '@theatre/core/sequences/Sequence'
import {defer} from '@theatre/shared/utils/defer'
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'
export interface IPlaybackState {
@ -25,6 +25,19 @@ export interface IPlaybackController {
direction: IPlaybackDirection,
): 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
}
@ -189,4 +202,59 @@ export default class DefaultPlaybackController implements IPlaybackController {
ticker.onThisOrNextTick(tick)
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
}
}

View 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)!
}
}

View file

@ -67,6 +67,9 @@ export type StrictRecord<Key extends string, V> = {[K in Key]?: V}
*/
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}
/** For `any`s that aren't meant to stay `any`*/

View file

@ -3,6 +3,11 @@ import getStudio from '@theatre/studio/getStudio'
import {cmdIsDown} from '@theatre/studio/utils/keyboardUtils'
import {getSelectedSequence} from '@theatre/studio/selectors'
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() {
const studio = getStudio()
@ -28,18 +33,92 @@ export default function useKeyboardShortcuts() {
return
}
} else if (
e.key === ' ' &&
e.code === 'Space' &&
!e.shiftKey &&
!e.metaKey &&
!e.altKey &&
!e.ctrlKey
) {
// Control the playback using the `Space` key
const seq = getSelectedSequence()
if (seq) {
if (seq.playing) {
seq.pause()
} 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 {
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
}
}),
)

View file

@ -41,7 +41,7 @@ export const F2 = styled.div`
padding: 0;
`
export const titleBarHeight = 20
export const titleBarHeight = 18
export const TitleBar = styled.div`
height: ${titleBarHeight}px;

View file

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

View file

@ -10,6 +10,7 @@ import {zIndexes} from '@theatre/studio/panels/SequenceEditorPanel/SequenceEdito
import DopeSheetSelectionView from './DopeSheetSelectionView'
import HorizontallyScrollableArea from './HorizontallyScrollableArea'
import SheetRow from './SheetRow'
import FocusRangeArea from './FocusRangeArea'
export const contentWidth = 1000000
@ -48,6 +49,7 @@ const Right: React.FC<{
return (
<>
<HorizontallyScrollableArea layoutP={layoutP} height={height}>
<FocusRangeArea layoutP={layoutP} />
<DopeSheetSelectionView layoutP={layoutP}>
<ListContainer style={{top: tree.top + 'px'}}>
<SheetRow leaf={tree} layoutP={layoutP} />

View file

@ -30,7 +30,7 @@ const TheStamps = styled.div`
height: 100%;
left: 0;
overflow: hidden;
/* z-index: 2; */
z-index: 2;
will-change: transform;
pointer-events: none;
`

View file

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

View file

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

View file

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

View file

@ -12,6 +12,13 @@ import {
useFrameStampPositionD,
} from '@theatre/studio/panels/SequenceEditorPanel/FrameStampPositionProvider'
const Container = styled.div`
position: absolute;
top: 0;
left: 0;
margin-top: 0px;
`
const Label = styled.div`
position: absolute;
top: 16px;
@ -63,21 +70,23 @@ const FrameStamp: React.FC<{
return (
<>
<Label
style={{
opacity: isVisible ? 1 : 0,
transform: `translate3d(calc(${posInClippedSpace}px - 50%), 0, 0)`,
}}
>
{formatter.formatForPlayhead(snappedPosInUnitSpace)}
</Label>
<Line
posType={posType}
style={{
opacity: isVisible ? 1 : 0,
transform: `translate3d(${posInClippedSpace}px, 0, 0)`,
}}
/>
<Container>
<Label
style={{
opacity: isVisible ? 1 : 0,
transform: `translate3d(calc(${posInClippedSpace}px - 50%), 0, 0)`,
}}
>
{formatter.formatForPlayhead(snappedPosInUnitSpace)}
</Label>
<Line
posType={posType}
style={{
opacity: isVisible ? 1 : 0,
transform: `translate3d(${posInClippedSpace}px, 0, 0)`,
}}
/>
</Container>{' '}
</>
)
})

View file

@ -19,6 +19,7 @@ import {pointerEventsAutoInNormalMode} from '@theatre/studio/css'
import usePopover from '@theatre/studio/uiComponents/Popover/usePopover'
import BasicPopover from '@theatre/studio/uiComponents/Popover/BasicPopover'
import PlayheadPositionPopover from './PlayheadPositionPopover'
import {getIsPlayheadAttachedToFocusRange} from '@theatre/studio/UIRoot/useKeyboardShortcuts'
const Container = styled.div<{isVisible: boolean}>`
--thumbColor: #00e0ff;
@ -54,7 +55,6 @@ const Rod = styled.div`
`
const Thumb = styled.div`
background-color: var(--thumbColor);
position: absolute;
width: 5px;
height: 13px;
@ -67,75 +67,11 @@ const Thumb = styled.div`
#pointer-root.draggingPositionInSequenceEditor &:not(.seeking) {
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`
display: none;
position: absolute;
top: -20px;
left: 4px;
padding: 0 2px;
transform: translateX(-50%);
background: #1a1a1a;
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>}> = ({
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 setIsSeeking = val(layoutP.seeker.setIsSeeking)
let posBeforeSeek = 0
let sequence: Sequence
let scaledSpaceToUnitSpace: typeof layoutP.scaledSpace.toUnitSpace.$$__pointer_type
return {
onDragStart() {
sequence = val(layoutP.sheet).getSequence()
posBeforeSeek = sequence.position
scaledSpaceToUnitSpace = val(layoutP.scaledSpace.toUnitSpace)
setIsSeeking(true)
},
onDrag(dx, _, event) {
@ -210,7 +174,7 @@ const Playhead: React.FC<{layoutP: Pointer<SequenceEditorPanelLayout>}> = ({
},
lockCursorTo: 'ew-resize',
}
}, [])
}, [scaledSpaceToUnitSpace])
useDrag(thumbNode, gestureHandlers)
@ -231,6 +195,10 @@ const Playhead: React.FC<{layoutP: Pointer<SequenceEditorPanelLayout>}> = ({
posInClippedSpace >= 0 &&
posInClippedSpace <= val(layoutP.clippedSpace.width)
const isPlayheadAttachedToFocusRange = val(
getIsPlayheadAttachedToFocusRange(sequence),
)
return (
<>
{popoverNode}
@ -243,13 +211,16 @@ const Playhead: React.FC<{layoutP: Pointer<SequenceEditorPanelLayout>}> = ({
<Thumb
ref={thumbRef as $IntentionalAny}
data-pos={posInUnitSpace.toFixed(3)}
onClick={(e) => {
openPopover(e, thumbNode!)
}}
>
<RoomToClick room={8} />
<Squinch />
<Tooltip>
{isPlayheadAttachedToFocusRange ? (
<LargeThumbSvg />
) : (
<RegularThumbSvg />
)}
<Tooltip
style={{top: isPlayheadAttachedToFocusRange ? '-23px' : '-18px'}}
>
{sequence.positionFormatter.formatForPlayhead(
sequence.closestGridPosition(posInUnitSpace),
)}

View file

@ -4,18 +4,18 @@ import React from 'react'
import styled from 'styled-components'
import type {SequenceEditorPanelLayout} from '@theatre/studio/panels/SequenceEditorPanel/layout/layout'
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 {pointerEventsAutoInNormalMode} from '@theatre/studio/css'
import FocusRangeZone from './FocusRangeZone/FocusRangeZone'
export const topStripHeight = 20
export const topStripHeight = 18
export const topStripTheme = {
backgroundColor: `#1f2120eb`,
borderColor: `#1c1e21`,
}
const Container = styled(PanelDragZone)`
const Container = styled.div`
position: absolute;
top: 0;
left: 0;
@ -31,10 +31,14 @@ const TopStrip: React.FC<{layoutP: Pointer<SequenceEditorPanelLayout>}> = ({
layoutP,
}) => {
const width = useVal(layoutP.rightDims.width)
return (
<Container {...{[attributeNameThatLocksFramestamp]: 'hide'}}>
<StampsGrid layoutP={layoutP} width={width} height={topStripHeight} />
</Container>
<>
<Container {...{[attributeNameThatLocksFramestamp]: 'hide'}}>
<StampsGrid layoutP={layoutP} width={width} height={topStripHeight} />
<FocusRangeZone layoutP={layoutP} />
</Container>
</>
)
}

View file

@ -365,6 +365,25 @@ namespace stateEditors {
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 function set(
p: WithoutSheetInstance<SheetAddress> & {

View file

@ -23,7 +23,27 @@ export type StudioAhistoricState = {
string,
{
sequence?: {
/**
* Stores the zoom level and scroll position of the sequence editor panel
* for this particular sheet.
*/
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
}
}
}
>

View file

@ -81,6 +81,7 @@ export type StudioHistoricState = {
}
>
}
panels?: Panels
panelPositions?: {[panelIdOrPaneId in string]?: PanelPosition}
panelInstanceDesceriptors: {

View file

@ -5,8 +5,21 @@ export * from './ahistoric'
export * from './ephemeral'
export * from './historic'
/**
* Describes the type of the object inside our store (redux store).
*/
export type StudioState = {
/**
* This is the part of the state that is undo/redo-able
*/
historic: StudioHistoricState
/**
* This is the part of the state that can't be undone, but it's
* still persisted to localStorage
*/
ahistoric: StudioAhistoricState
/**
* This is entirely ephemeral, and gets lost if user refreshes the page
*/
ephemeral: StudioEphemeralState
}

View file

@ -4,11 +4,40 @@ import useRefAndState from '@theatre/studio/utils/useRefAndState'
import {useCursorLock} from './PointerEventsHandler'
export type UseDragOpts = {
/**
* Setting it to true will disable the listeners.
*/
disabled?: boolean
/**
* Setting it to true will allow the mouse down events to propagate up
*/
dontBlockMouseDown?: boolean
/**
* The css cursor property during the gesture will be locked to this value
*/
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
/**
* 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
/**
* 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
}