diff --git a/theatre/core/src/projects/store/storeTypes.ts b/theatre/core/src/projects/store/storeTypes.ts index 57bc6c0..fea7699 100644 --- a/theatre/core/src/projects/store/storeTypes.ts +++ b/theatre/core/src/projects/store/storeTypes.ts @@ -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 diff --git a/theatre/core/src/sequences/Sequence.ts b/theatre/core/src/sequences/Sequence.ts index cba59e6..e658ca4 100644 --- a/theatre/core/src/sequences/Sequence.ts +++ b/theatre/core/src/sequences/Sequence.ts @@ -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): Promise { + return this._playbackControllerBox.get().playDynamicRange(rangeD) + } + async play( conf?: Partial<{ iterationCount: number diff --git a/theatre/core/src/sequences/playbackControllers/AudioPlaybackController.ts b/theatre/core/src/sequences/playbackControllers/AudioPlaybackController.ts index 538e381..cebd36d 100644 --- a/theatre/core/src/sequences/playbackControllers/AudioPlaybackController.ts +++ b/theatre/core/src/sequences/playbackControllers/AudioPlaybackController.ts @@ -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): Promise { + throw new Error('Method not implemented.') + } + private get _playing() { return this._state.getState().playing } diff --git a/theatre/core/src/sequences/playbackControllers/DefaultPlaybackController.ts b/theatre/core/src/sequences/playbackControllers/DefaultPlaybackController.ts index 268f80e..9ca6c79 100644 --- a/theatre/core/src/sequences/playbackControllers/DefaultPlaybackController.ts +++ b/theatre/core/src/sequences/playbackControllers/DefaultPlaybackController.ts @@ -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 + /** + * 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): Promise + pause(): void } @@ -189,4 +202,59 @@ export default class DefaultPlaybackController implements IPlaybackController { ticker.onThisOrNextTick(tick) return deferred.promise } + + playDynamicRange(rangeD: IDerivation): Promise { + if (this.playing) { + this.pause() + } + + this.playing = true + + const ticker = this._ticker + + const deferred = defer() + + // 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 + } } diff --git a/theatre/shared/src/utils/memoizeFn.ts b/theatre/shared/src/utils/memoizeFn.ts new file mode 100644 index 0000000..70fe471 --- /dev/null +++ b/theatre/shared/src/utils/memoizeFn.ts @@ -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( + producer: (k: K) => V, +): (k: K) => V { + const cache = new WeakMap() + + return (k: K): V => { + if (!cache.has(k)) { + cache.set(k, producer(k)) + } + return cache.get(k)! + } +} diff --git a/theatre/shared/src/utils/types.ts b/theatre/shared/src/utils/types.ts index 82c2c9c..0432660 100644 --- a/theatre/shared/src/utils/types.ts +++ b/theatre/shared/src/utils/types.ts @@ -67,6 +67,9 @@ export type StrictRecord = {[K in Key]?: V} */ export type Nominal = T +/** + * TODO: We should deprecate this and just use `[start: number, end: number]` + */ export type IRange = {start: T; end: T} /** For `any`s that aren't meant to stay `any`*/ diff --git a/theatre/studio/src/UIRoot/useKeyboardShortcuts.ts b/theatre/studio/src/UIRoot/useKeyboardShortcuts.ts index 6b06c14..de49974 100644 --- a/theatre/studio/src/UIRoot/useKeyboardShortcuts.ts +++ b/theatre/studio/src/UIRoot/useKeyboardShortcuts.ts @@ -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( + '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(() => { + 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 + } + }), +) diff --git a/theatre/studio/src/panels/BasePanel/common.tsx b/theatre/studio/src/panels/BasePanel/common.tsx index ad05bf8..ccb1e74 100644 --- a/theatre/studio/src/panels/BasePanel/common.tsx +++ b/theatre/studio/src/panels/BasePanel/common.tsx @@ -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; diff --git a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/FocusRangeArea.tsx b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/FocusRangeArea.tsx new file mode 100644 index 0000000..fc8e943 --- /dev/null +++ b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/FocusRangeArea.tsx @@ -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 +}> = ({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 ( + + ) + }, [layoutP, existingRangeD]) +} + +export default FocusRangeArea diff --git a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/Right.tsx b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/Right.tsx index f40d4c8..1582440 100644 --- a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/Right.tsx +++ b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/Right.tsx @@ -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 ( <> + diff --git a/theatre/studio/src/panels/SequenceEditorPanel/FrameGrid/StampsGrid.tsx b/theatre/studio/src/panels/SequenceEditorPanel/FrameGrid/StampsGrid.tsx index 121770e..1458a6c 100644 --- a/theatre/studio/src/panels/SequenceEditorPanel/FrameGrid/StampsGrid.tsx +++ b/theatre/studio/src/panels/SequenceEditorPanel/FrameGrid/StampsGrid.tsx @@ -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; ` diff --git a/theatre/studio/src/panels/SequenceEditorPanel/RightOverlay/FocusRangeZone/FocusRangeStrip.tsx b/theatre/studio/src/panels/SequenceEditorPanel/RightOverlay/FocusRangeZone/FocusRangeStrip.tsx new file mode 100644 index 0000000..e76e57c --- /dev/null +++ b/theatre/studio/src/panels/SequenceEditorPanel/RightOverlay/FocusRangeZone/FocusRangeStrip.tsx @@ -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 +}> = ({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( + 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[1] => { + let sequence = sheet.getSequence() + let startPosBeforeDrag: number, + endPosBeforeDrag: number, + tempTransaction: CommitOrDiscard | undefined + let dragHappened = false + let existingRange: {enabled: boolean; range: IRange} | 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} + + + ) + }, [layoutP, rangeStripRef, existingRangeD, contextMenu]) +} + +export default FocusRangeStrip diff --git a/theatre/studio/src/panels/SequenceEditorPanel/RightOverlay/FocusRangeZone/FocusRangeThumb.tsx b/theatre/studio/src/panels/SequenceEditorPanel/RightOverlay/FocusRangeZone/FocusRangeThumb.tsx new file mode 100644 index 0000000..0855357 --- /dev/null +++ b/theatre/studio/src/panels/SequenceEditorPanel/RightOverlay/FocusRangeZone/FocusRangeThumb.tsx @@ -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 + thumbType: keyof IRange +}> = ({layoutP, thumbType}) => { + const [hitZoneRef, hitZoneNode] = useRefAndState(null) + const handlerRef = useRef(null) + const [isDragging, setIsDragging] = useState(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[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 ? ( + <> + + + + + + + + {sequence.positionFormatter.formatBasic(sequence.length)} + + + + + ) : ( + <> + ) + }, [layoutP, hitZoneRef, existingRangeD, focusRangeEnabled]) +} + +export default FocusRangeThumb diff --git a/theatre/studio/src/panels/SequenceEditorPanel/RightOverlay/FocusRangeZone/FocusRangeZone.tsx b/theatre/studio/src/panels/SequenceEditorPanel/RightOverlay/FocusRangeZone/FocusRangeZone.tsx new file mode 100644 index 0000000..3cd2cdf --- /dev/null +++ b/theatre/studio/src/panels/SequenceEditorPanel/RightOverlay/FocusRangeZone/FocusRangeZone.tsx @@ -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 +}> = ({layoutP}) => { + const [containerRef, containerNode] = useRefAndState(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 ( + + + + + + ) + }, [layoutP, existingRangeD]) +} + +export default FocusRangeZone + +function usePanelDragZoneGestureHandlers( + layoutP: Pointer, + panelStuffRef: React.MutableRefObject>, +) { + return useMemo((): Parameters[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[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[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]) +} diff --git a/theatre/studio/src/panels/SequenceEditorPanel/RightOverlay/FrameStamp.tsx b/theatre/studio/src/panels/SequenceEditorPanel/RightOverlay/FrameStamp.tsx index 0a31129..e266b7d 100644 --- a/theatre/studio/src/panels/SequenceEditorPanel/RightOverlay/FrameStamp.tsx +++ b/theatre/studio/src/panels/SequenceEditorPanel/RightOverlay/FrameStamp.tsx @@ -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 ( <> - - + + + + {' '} ) }) diff --git a/theatre/studio/src/panels/SequenceEditorPanel/RightOverlay/Playhead.tsx b/theatre/studio/src/panels/SequenceEditorPanel/RightOverlay/Playhead.tsx index 33ac9b7..fc70d07 100644 --- a/theatre/studio/src/panels/SequenceEditorPanel/RightOverlay/Playhead.tsx +++ b/theatre/studio/src/panels/SequenceEditorPanel/RightOverlay/Playhead.tsx @@ -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 = () => ( + + + +) + +const LargeThumbSvg: React.FC = () => ( + + + +) + const Playhead: React.FC<{layoutP: Pointer}> = ({ layoutP, }) => { @@ -167,18 +131,18 @@ const Playhead: React.FC<{layoutP: Pointer}> = ({ }, ) + const scaledSpaceToUnitSpace = val(layoutP.scaledSpace.toUnitSpace) + const gestureHandlers = useMemo((): Parameters[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}> = ({ }, lockCursorTo: 'ew-resize', } - }, []) + }, [scaledSpaceToUnitSpace]) useDrag(thumbNode, gestureHandlers) @@ -231,6 +195,10 @@ const Playhead: React.FC<{layoutP: Pointer}> = ({ posInClippedSpace >= 0 && posInClippedSpace <= val(layoutP.clippedSpace.width) + const isPlayheadAttachedToFocusRange = val( + getIsPlayheadAttachedToFocusRange(sequence), + ) + return ( <> {popoverNode} @@ -243,13 +211,16 @@ const Playhead: React.FC<{layoutP: Pointer}> = ({ { - openPopover(e, thumbNode!) - }} > - - + {isPlayheadAttachedToFocusRange ? ( + + ) : ( + + )} + {sequence.positionFormatter.formatForPlayhead( sequence.closestGridPosition(posInUnitSpace), )} diff --git a/theatre/studio/src/panels/SequenceEditorPanel/RightOverlay/TopStrip.tsx b/theatre/studio/src/panels/SequenceEditorPanel/RightOverlay/TopStrip.tsx index 2dccb50..490d0cd 100644 --- a/theatre/studio/src/panels/SequenceEditorPanel/RightOverlay/TopStrip.tsx +++ b/theatre/studio/src/panels/SequenceEditorPanel/RightOverlay/TopStrip.tsx @@ -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}> = ({ layoutP, }) => { const width = useVal(layoutP.rightDims.width) + return ( - - - + <> + + + + + ) } diff --git a/theatre/studio/src/store/stateEditors.ts b/theatre/studio/src/store/stateEditors.ts index 17ae2b4..1f521a6 100644 --- a/theatre/studio/src/store/stateEditors.ts +++ b/theatre/studio/src/store/stateEditors.ts @@ -365,6 +365,25 @@ namespace stateEditors { return sheetState.sequence! } + export namespace focusRange { + export function set( + p: WithoutSheetInstance & { + 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) { + stateEditors.studio.ahistoric.projects.stateByProjectId.stateBySheetId.sequence._ensure( + p, + ).focusRange = undefined + } + } + export namespace clippedSpaceRange { export function set( p: WithoutSheetInstance & { diff --git a/theatre/studio/src/store/types/ahistoric.ts b/theatre/studio/src/store/types/ahistoric.ts index 1d8f725..3a101dd 100644 --- a/theatre/studio/src/store/types/ahistoric.ts +++ b/theatre/studio/src/store/types/ahistoric.ts @@ -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 + } } } > diff --git a/theatre/studio/src/store/types/historic.ts b/theatre/studio/src/store/types/historic.ts index f402497..7f88afd 100644 --- a/theatre/studio/src/store/types/historic.ts +++ b/theatre/studio/src/store/types/historic.ts @@ -81,6 +81,7 @@ export type StudioHistoricState = { } > } + panels?: Panels panelPositions?: {[panelIdOrPaneId in string]?: PanelPosition} panelInstanceDesceriptors: { diff --git a/theatre/studio/src/store/types/index.ts b/theatre/studio/src/store/types/index.ts index 6637ff5..15031f3 100644 --- a/theatre/studio/src/store/types/index.ts +++ b/theatre/studio/src/store/types/index.ts @@ -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 } diff --git a/theatre/studio/src/uiComponents/useDrag.ts b/theatre/studio/src/uiComponents/useDrag.ts index 36f3d06..dc66e90 100644 --- a/theatre/studio/src/uiComponents/useDrag.ts +++ b/theatre/studio/src/uiComponents/useDrag.ts @@ -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 }