diff --git a/theatre/core/src/projects/store/types/SheetState_Historic.ts b/theatre/core/src/projects/store/types/SheetState_Historic.ts index d6c4159..7ac914b 100644 --- a/theatre/core/src/projects/store/types/SheetState_Historic.ts +++ b/theatre/core/src/projects/store/types/SheetState_Historic.ts @@ -28,6 +28,7 @@ export type HistoricPositionalSequence = { type: 'PositionalSequence' length: number /** + * Given the most common case of tracking a sequence against time (where 1 second = position 1), * If set to, say, 30, then the keyframe editor will try to snap all keyframes * to a 30fps grid */ diff --git a/theatre/shared/src/utils/PointableSet.ts b/theatre/shared/src/utils/PointableSet.ts new file mode 100644 index 0000000..9e37310 --- /dev/null +++ b/theatre/shared/src/utils/PointableSet.ts @@ -0,0 +1,94 @@ +import type {StrictRecord} from './types' + +/** + * Consistent way to maintain an unordered structure which plays well with dataverse pointers and + * data change tracking: + * - paths to items are stable; + * - changing one item does not change `allIds`, so it can be mapped over in React without + * unnecessary re-renders + */ +export type PointableSet = { + /** + * Usually accessed through pointers before accessing the actual value, so we can separate + * changes to individual items from changes to the entire list of items. + */ + byId: StrictRecord + /** + * Separate list of ids allows us to recognize changes to set item memberships, without being triggered changed when + * the contents of its items have changed. + */ + allIds: StrictRecord +} + +export const pointableSetUtil = { + create( + values?: Iterable<[Id, V]>, + ): PointableSet { + const set: PointableSet = {byId: {}, allIds: {}} + if (values) { + for (const [id, value] of values) { + set.byId[id] = value + set.allIds[id] = true + } + } + return set + }, + shallowCopy( + existing: PointableSet | undefined, + ): PointableSet { + return { + byId: {...existing?.byId}, + allIds: {...existing?.allIds}, + } + }, + add( + existing: PointableSet | undefined, + id: Id, + value: V, + ): PointableSet { + return { + byId: {...existing?.byId, [id]: value}, + allIds: {...existing?.allIds, [id]: true}, + } + }, + merge( + sets: PointableSet[], + ): PointableSet { + const target = pointableSetUtil.create() + for (let i = 0; i < sets.length; i++) { + target.byId = {...target.byId, ...sets[i].byId} + target.allIds = {...target.allIds, ...sets[i].allIds} + } + return target + }, + remove( + existing: PointableSet, + id: Id, + ): PointableSet { + const set = pointableSetUtil.shallowCopy(existing) + delete set.allIds[id] + delete set.byId[id] + return set + }, + /** + * Note: this is not very performant (it's not crazy slow or anything) + * it's just that it's not able to re-use object classes in v8 due to + * excessive `delete obj[key]` instances. + * + * `Map`s would be faster, but they aren't used for synchronized JSON state stuff. + * See {@link StrictRecord} for related conversation. + */ + filter( + existing: PointableSet, + predicate: (value: V | undefined) => boolean | undefined | null, + ): PointableSet { + const set = pointableSetUtil.shallowCopy(existing) + for (const [id, value] of Object.entries(set.byId)) { + if (!predicate(value)) { + delete set.allIds[id] + delete set.byId[id] + } + } + return set + }, +} diff --git a/theatre/shared/src/utils/ids.ts b/theatre/shared/src/utils/ids.ts index 280612c..4c5528d 100644 --- a/theatre/shared/src/utils/ids.ts +++ b/theatre/shared/src/utils/ids.ts @@ -1,6 +1,5 @@ import {nanoid as generateNonSecure} from 'nanoid/non-secure' import type {Nominal} from './Nominal' -import type {$IntentionalAny} from './types' export type KeyframeId = Nominal<'KeyframeId'> @@ -9,7 +8,7 @@ export function generateKeyframeId(): KeyframeId { } export function asKeyframeId(s: string): KeyframeId { - return s as $IntentionalAny + return s as KeyframeId } export type ProjectId = Nominal<'ProjectId'> @@ -17,14 +16,19 @@ export type SheetId = Nominal<'SheetId'> export type SheetInstanceId = Nominal<'SheetInstanceId'> export type PaneInstanceId = Nominal<'PaneInstanceId'> export type SequenceTrackId = Nominal<'SequenceTrackId'> +export type SequenceMarkerId = Nominal<'SequenceMarkerId'> export type ObjectAddressKey = Nominal<'ObjectAddressKey'> /** UI panels can contain a {@link PaneInstanceId} or something else. */ export type UIPanelId = Nominal<'UIPanelId'> export function generateSequenceTrackId(): SequenceTrackId { - return generateNonSecure(10) as $IntentionalAny as SequenceTrackId + return generateNonSecure(10) as SequenceTrackId } export function asSequenceTrackId(s: string): SequenceTrackId { - return s as $IntentionalAny + return s as SequenceTrackId +} + +export function generateSequenceMarkerId(): SequenceMarkerId { + return generateNonSecure(10) as SequenceMarkerId } diff --git a/theatre/shared/src/utils/types.ts b/theatre/shared/src/utils/types.ts index fc2711b..863a5ce 100644 --- a/theatre/shared/src/utils/types.ts +++ b/theatre/shared/src/utils/types.ts @@ -68,7 +68,8 @@ export type DeepPartialOfSerializableValue = * This is equivalent to `Partial>` being used to describe a sort of Map * where the keys might not have values. * - * We do not use `Map`s, because they add comlpexity with converting to `JSON.stringify` + pointer types + * We do not use `Map`s or `Set`s, because they add complexity with converting to + * `JSON.stringify` + pointer types. */ export type StrictRecord = {[K in Key]?: V} diff --git a/theatre/studio/src/UIRoot/PointerCapturing.tsx b/theatre/studio/src/UIRoot/PointerCapturing.tsx index e82225a..ebafefd 100644 --- a/theatre/studio/src/UIRoot/PointerCapturing.tsx +++ b/theatre/studio/src/UIRoot/PointerCapturing.tsx @@ -1,9 +1,11 @@ -import React, {useContext, useMemo} from 'react' +import React, {useContext, useEffect, useMemo} from 'react' import type {$IntentionalAny} from '@theatre/shared/utils/types' /** See {@link PointerCapturing} */ export type CapturedPointer = { release(): void + /** Double check that you still have the current capture and weren't forcibly released */ + isCapturing(): boolean } /** @@ -23,41 +25,73 @@ export type PointerCapturing = { capturePointer(debugReason: string): CapturedPointer } -type PointerCapturingFn = (forDebugName: string) => PointerCapturing +type InternalPointerCapturing = { + capturing: PointerCapturing + forceRelease(): void +} + +type PointerCapturingFn = (forDebugName: string) => InternalPointerCapturing + +// const logger = console function _usePointerCapturingContext(): PointerCapturingFn { - let [currentCapture, setCurrentCapture] = React.useState(null) + } + let currentCaptureRef = React.useRef(null) return (forDebugName) => { - return { + /** keep track of the captures being made by this user of {@link usePointerCapturing} */ + let localCapture: CaptureInfo | null + const updateCapture = (to: CaptureInfo | null): CaptureInfo | null => { + localCapture = to + currentCaptureRef.current = to + return to + } + const capturing: PointerCapturing = { capturePointer(reason) { // logger.log('Capturing pointer', {forDebugName, reason}) - if (currentCapture != null) { + if (currentCaptureRef.current != null) { throw new Error( - `"${forDebugName}" attempted capturing pointer for "${reason}" while already captured by "${currentCapture.debugOwnerName}" for "${currentCapture.debugReason}"`, + `"${forDebugName}" attempted capturing pointer for "${reason}" while already captured by "${currentCaptureRef.current.debugOwnerName}" for "${currentCaptureRef.current.debugReason}"`, ) } - setCurrentCapture({debugOwnerName: forDebugName, debugReason: reason}) + const releaseCapture = updateCapture({ + debugOwnerName: forDebugName, + debugReason: reason, + }) - const releaseCapture = currentCapture return { + isCapturing() { + return releaseCapture === currentCaptureRef.current + }, release() { - if (releaseCapture === currentCapture) { + if (releaseCapture === currentCaptureRef.current) { // logger.log('Releasing pointer', { // forDebugName, // reason, // }) - setCurrentCapture(null) + updateCapture(null) + return true } + return false }, } }, isPointerBeingCaptured() { - return currentCapture != null + return currentCaptureRef.current != null + }, + } + + return { + capturing, + forceRelease() { + if (currentCaptureRef.current === localCapture) { + // logger.log('Force releasing pointer', currentCaptureRef.current) + updateCapture(null) + } }, } } @@ -96,7 +130,16 @@ export function ProvidePointerCapturing(props: { export function usePointerCapturing(forDebugName: string): PointerCapturing { const pointerCapturingFn = useContext(PointerCapturingContext) - return useMemo(() => { + const control = useMemo(() => { return pointerCapturingFn(forDebugName) }, [forDebugName, pointerCapturingFn]) + + useEffect(() => { + return () => { + // force release on unmount + control.forceRelease() + } + }, [forDebugName, pointerCapturingFn]) + + return control.capturing } diff --git a/theatre/studio/src/css.ts b/theatre/studio/src/css.ts index 07c004a..6c202b3 100644 --- a/theatre/studio/src/css.ts +++ b/theatre/studio/src/css.ts @@ -1,6 +1,13 @@ import {lighten} from 'polished' import {css} from 'styled-components' +/** + * This CSS string is used to correctly set pointer-events on an element + * when the pointer is dragging something. + * Naming explanation: "NormalMode" as opposed to dragging mode. + * + * @see PointerEventsHandler - the place that sets `.normal` on #pointer-root + */ export const pointerEventsAutoInNormalMode = css` #pointer-root & { pointer-events: none; diff --git a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/KeyframeDot.tsx b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/KeyframeDot.tsx index 4c0baf0..1eb275c 100644 --- a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/KeyframeDot.tsx +++ b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/KeyframeDot.tsx @@ -11,12 +11,12 @@ import type {UseDragOpts} from '@theatre/studio/uiComponents/useDrag' import useRefAndState from '@theatre/studio/utils/useRefAndState' import {val} from '@theatre/dataverse' import {lighten} from 'polished' -import React, {useMemo, useRef, useState} from 'react' +import React, {useMemo, useRef} from 'react' import styled from 'styled-components' import {useLockFrameStampPosition} from '@theatre/studio/panels/SequenceEditorPanel/FrameStampPositionProvider' import {attributeNameThatLocksFramestamp} from '@theatre/studio/panels/SequenceEditorPanel/FrameStampPositionProvider' import { - lockedCursorCssPropName, + lockedCursorCssVarName, useCssCursorLock, } from '@theatre/studio/uiComponents/PointerEventsHandler' import SnapCursor from './SnapCursor.svg' @@ -42,7 +42,8 @@ const dotTheme = { }, } -const Square = styled.div<{isSelected: boolean}>` +/** The keyframe diamond ◆ */ +const Diamond = styled.div<{isSelected: boolean}>` position: absolute; ${dims(DOT_SIZE_PX)} @@ -65,8 +66,11 @@ const HitZone = styled.div` #pointer-root.draggingPositionInSequenceEditor & { pointer-events: auto; - cursor: var(${lockedCursorCssPropName}); + cursor: var(${lockedCursorCssVarName}); + // ⸢⸤⸣⸥ thing + // This box extends the hitzone so the user does not + // accidentally leave the hitzone &:hover:after { position: absolute; top: calc(50% - ${SNAP_CURSOR_SIZE_PX / 2}px); @@ -84,7 +88,7 @@ const HitZone = styled.div` pointer-events: none !important; } - &:hover + ${Square}, &.beingDragged + ${Square} { + &:hover + ${Diamond}, &.beingDragged + ${Diamond} { ${dims(DOT_HOVER_SIZE_PX)} } ` @@ -109,7 +113,7 @@ const KeyframeDot: React.VFC = (props) => { }} className={isDragging ? 'beingDragged' : ''} /> - + {contextMenu} ) @@ -142,9 +146,6 @@ function useDragKeyframe( node: HTMLDivElement | null, props: IKeyframeDotProps, ): [isDragging: boolean] { - const [isDragging, setIsDragging] = useState(false) - useLockFrameStampPosition(isDragging, props.keyframe.position) - const propsRef = useRef(props) propsRef.current = props @@ -158,10 +159,9 @@ function useDragKeyframe( | undefined return { - debugName: 'Dot/useDragKeyframe', + debugName: 'KeyframeDot/useDragKeyframe', onDragStart(event) { - setIsDragging(true) const props = propsRef.current if (props.selection) { const {selection, leaf} = props @@ -229,29 +229,21 @@ function useDragKeyframe( }) }, onDragEnd(dragHappened) { - setIsDragging(false) - if (selectionDragHandlers) { selectionDragHandlers.onDragEnd?.(dragHappened) selectionDragHandlers = undefined } - if (dragHappened) { - if (tempTransaction) { - tempTransaction.commit() - } - } else { - if (tempTransaction) { - tempTransaction.discard() - } - } + if (dragHappened) tempTransaction?.commit() + else tempTransaction?.discard() tempTransaction = undefined }, } }, []) - useDrag(node, useDragOpts) + const [isDragging] = useDrag(node, useDragOpts) + useLockFrameStampPosition(isDragging, props.keyframe.position) useCssCursorLock(isDragging, 'draggingPositionInSequenceEditor', 'ew-resize') return [isDragging] diff --git a/theatre/studio/src/panels/SequenceEditorPanel/FrameStampPositionProvider.tsx b/theatre/studio/src/panels/SequenceEditorPanel/FrameStampPositionProvider.tsx index a0e791c..a9f8bbd 100644 --- a/theatre/studio/src/panels/SequenceEditorPanel/FrameStampPositionProvider.tsx +++ b/theatre/studio/src/panels/SequenceEditorPanel/FrameStampPositionProvider.tsx @@ -40,6 +40,12 @@ type LockItem = { let lastLockId = 0 +/** + * Provides snapping positions to "stamps". + * + * One example of a stamp includes the "Keyframe Dot" which show a `⌜⌞⌝⌟` kinda UI + * around the dot when dragged over. + */ const FrameStampPositionProvider: React.FC<{ layoutP: Pointer }> = ({children, layoutP}) => { @@ -144,6 +150,13 @@ export const useLockFrameStampPosition = (shouldLock: boolean, val: number) => { * Elements that need this behavior must set a data attribute like so: *
* Setting this attribute to "hide" hides the stamp. + * + * @see lockedCursorCssVarName - CSS variable used to set the cursor on an element that + * should lock the framestamp. Look for usages. + * @see pointerEventsAutoInNormalMode - CSS snippet used to correctly set + * `pointer-events` on an element that should lock the framestamp. + * + * See {@link FrameStampPositionProvider} */ export const attributeNameThatLocksFramestamp = 'data-theatre-lock-framestamp-to' diff --git a/theatre/studio/src/panels/SequenceEditorPanel/GraphEditor/BasicKeyframedTrack/KeyframeEditor/GraphEditorDotNonScalar.tsx b/theatre/studio/src/panels/SequenceEditorPanel/GraphEditor/BasicKeyframedTrack/KeyframeEditor/GraphEditorDotNonScalar.tsx index e97d37e..bf19f54 100644 --- a/theatre/studio/src/panels/SequenceEditorPanel/GraphEditor/BasicKeyframedTrack/KeyframeEditor/GraphEditorDotNonScalar.tsx +++ b/theatre/studio/src/panels/SequenceEditorPanel/GraphEditor/BasicKeyframedTrack/KeyframeEditor/GraphEditorDotNonScalar.tsx @@ -14,7 +14,7 @@ import {useLockFrameStampPosition} from '@theatre/studio/panels/SequenceEditorPa import {attributeNameThatLocksFramestamp} from '@theatre/studio/panels/SequenceEditorPanel/FrameStampPositionProvider' import {pointerEventsAutoInNormalMode} from '@theatre/studio/css' import { - lockedCursorCssPropName, + lockedCursorCssVarName, useCssCursorLock, } from '@theatre/studio/uiComponents/PointerEventsHandler' @@ -45,7 +45,7 @@ const HitZone = styled.circle` #pointer-root.draggingPositionInSequenceEditor & { pointer-events: auto; - cursor: var(${lockedCursorCssPropName}); + cursor: var(${lockedCursorCssVarName}); } &.beingDragged { diff --git a/theatre/studio/src/panels/SequenceEditorPanel/GraphEditor/BasicKeyframedTrack/KeyframeEditor/GraphEditorDotScalar.tsx b/theatre/studio/src/panels/SequenceEditorPanel/GraphEditor/BasicKeyframedTrack/KeyframeEditor/GraphEditorDotScalar.tsx index 841e16a..027df2e 100644 --- a/theatre/studio/src/panels/SequenceEditorPanel/GraphEditor/BasicKeyframedTrack/KeyframeEditor/GraphEditorDotScalar.tsx +++ b/theatre/studio/src/panels/SequenceEditorPanel/GraphEditor/BasicKeyframedTrack/KeyframeEditor/GraphEditorDotScalar.tsx @@ -14,7 +14,7 @@ import {useLockFrameStampPosition} from '@theatre/studio/panels/SequenceEditorPa import {attributeNameThatLocksFramestamp} from '@theatre/studio/panels/SequenceEditorPanel/FrameStampPositionProvider' import {pointerEventsAutoInNormalMode} from '@theatre/studio/css' import { - lockedCursorCssPropName, + lockedCursorCssVarName, useCssCursorLock, } from '@theatre/studio/uiComponents/PointerEventsHandler' @@ -45,7 +45,7 @@ const HitZone = styled.circle` #pointer-root.draggingPositionInSequenceEditor & { pointer-events: auto; - cursor: var(${lockedCursorCssPropName}); + cursor: var(${lockedCursorCssVarName}); } &.beingDragged { diff --git a/theatre/studio/src/panels/SequenceEditorPanel/RightOverlay/FocusRangeZone/FocusRangeThumb.tsx b/theatre/studio/src/panels/SequenceEditorPanel/RightOverlay/FocusRangeZone/FocusRangeThumb.tsx index 32bbc33..a711e76 100644 --- a/theatre/studio/src/panels/SequenceEditorPanel/RightOverlay/FocusRangeZone/FocusRangeThumb.tsx +++ b/theatre/studio/src/panels/SequenceEditorPanel/RightOverlay/FocusRangeZone/FocusRangeThumb.tsx @@ -10,7 +10,7 @@ import { } from '@theatre/studio/panels/SequenceEditorPanel/RightOverlay/TopStrip' import type {CommitOrDiscard} from '@theatre/studio/StudioStore/StudioStore' import { - lockedCursorCssPropName, + lockedCursorCssVarName, useCssCursorLock, } from '@theatre/studio/uiComponents/PointerEventsHandler' import useDrag from '@theatre/studio/uiComponents/useDrag' @@ -57,7 +57,7 @@ const TheDiv = styled.div<{enabled: boolean; type: 'start' | 'end'}>` #pointer-root.draggingPositionInSequenceEditor & { pointer-events: auto; - cursor: var(${lockedCursorCssPropName}); + cursor: var(${lockedCursorCssVarName}); } &.dragging { diff --git a/theatre/studio/src/panels/SequenceEditorPanel/RightOverlay/MarkerDot.tsx b/theatre/studio/src/panels/SequenceEditorPanel/RightOverlay/MarkerDot.tsx new file mode 100644 index 0000000..55da4f7 --- /dev/null +++ b/theatre/studio/src/panels/SequenceEditorPanel/RightOverlay/MarkerDot.tsx @@ -0,0 +1,305 @@ +import type {Pointer} from '@theatre/dataverse' +import {val} from '@theatre/dataverse' +import {useVal} from '@theatre/react' +import {pointerEventsAutoInNormalMode} from '@theatre/studio/css' +import getStudio from '@theatre/studio/getStudio' +import { + lockedCursorCssVarName, + useCssCursorLock, +} from '@theatre/studio/uiComponents/PointerEventsHandler' +import useContextMenu from '@theatre/studio/uiComponents/simpleContextMenu/useContextMenu' +import useRefAndState from '@theatre/studio/utils/useRefAndState' +import React, {useMemo, useRef} from 'react' +import styled from 'styled-components' +import { + attributeNameThatLocksFramestamp, + useLockFrameStampPosition, +} from '@theatre/studio/panels/SequenceEditorPanel/FrameStampPositionProvider' +import type {SequenceEditorPanelLayout} from '@theatre/studio/panels/SequenceEditorPanel/layout/layout' +import type {SequenceMarkerId} from '@theatre/shared/utils/ids' +import type {SheetAddress} from '@theatre/shared/utils/addresses' +import SnapCursor from './SnapCursor.svg' +import MarkerDotVisual from './MarkerDotVisual.svg' +import useDrag from '@theatre/studio/uiComponents/useDrag' +import type {UseDragOpts} from '@theatre/studio/uiComponents/useDrag' +import type {CommitOrDiscard} from '@theatre/studio/StudioStore/StudioStore' +import type {StudioHistoricStateSequenceEditorMarker} from '@theatre/studio/store/types' +import {zIndexes} from '@theatre/studio/panels/SequenceEditorPanel/SequenceEditorPanel' + +const MARKER_SIZE_W_PX = 10 +const MARKER_SIZE_H_PX = 8 +const HIT_ZONE_SIZE_PX = 12 +const SNAP_CURSOR_SIZE_PX = 34 +const MARKER_HOVER_SIZE_W_PX = MARKER_SIZE_W_PX * 2 +const MARKER_HOVER_SIZE_H_PX = MARKER_SIZE_H_PX * 2 +const dims = (w: number, h = w) => ` + left: ${w * -0.5}px; + top: ${h * -0.5}px; + width: ${w}px; + height: ${h}px; +` + +const MarkerVisualDot = styled.div` + position: absolute; + ${dims(MARKER_SIZE_W_PX, MARKER_SIZE_H_PX)} + + background-image: url(${MarkerDotVisual}); + background-size: contain; + pointer-events: none; +` + +const HitZone = styled.div` + position: absolute; + ${dims(HIT_ZONE_SIZE_PX)}; + z-index: 1; + + cursor: ew-resize; + + ${pointerEventsAutoInNormalMode}; + + // "All instances of this component inside #pointer-root when it has the .draggingPositionInSequenceEditor class" + // ref: https://styled-components.com/docs/basics#pseudoelements-pseudoselectors-and-nesting + #pointer-root.draggingPositionInSequenceEditor:not(.draggingMarker) &, + #pointer-root.draggingPositionInSequenceEditor &.beingDragged { + pointer-events: auto; + cursor: var(${lockedCursorCssVarName}); + } + + #pointer-root.draggingPositionInSequenceEditor:not(.draggingMarker) & { + pointer-events: auto; + cursor: var(${lockedCursorCssVarName}); + + // ⸢⸤⸣⸥ thing + // This box extends the hitzone so the user does not + // accidentally leave the hitzone + &:hover:after { + position: absolute; + top: calc(50% - ${SNAP_CURSOR_SIZE_PX / 2}px); + left: calc(50% - ${SNAP_CURSOR_SIZE_PX / 2}px); + width: ${SNAP_CURSOR_SIZE_PX}px; + height: ${SNAP_CURSOR_SIZE_PX}px; + display: block; + content: ' '; + background: url(${SnapCursor}) no-repeat 100% 100%; + // This icon might also fit: GiConvergenceTarget + } + } + + &.beingDragged { + pointer-events: none !important; + } + + &:hover + ${MarkerVisualDot}, &.beingDragged + ${MarkerVisualDot} { + ${dims(MARKER_HOVER_SIZE_W_PX, MARKER_HOVER_SIZE_H_PX)} + } +` +type IMarkerDotProps = { + layoutP: Pointer + markerId: SequenceMarkerId +} + +const MarkerDot: React.VFC = ({layoutP, markerId}) => { + const sheetAddress = useVal(layoutP.sheet.address) + const marker = useVal( + getStudio().atomP.historic.projects.stateByProjectId[sheetAddress.projectId] + .stateBySheetId[sheetAddress.sheetId].sequenceEditor.markerSet.byId[ + markerId + ], + ) + if (!marker) { + // 1/10 maybe this is normal if React tries to re-render this with + // out of date data. (e.g. Suspense / Transition stuff?) + return null + } + + return +} + +export default MarkerDot + +type IMarkerDotDefinedProps = { + layoutP: Pointer + marker: StudioHistoricStateSequenceEditorMarker +} + +const MarkerDotDefined: React.VFC = ({ + layoutP, + marker, +}) => { + const sheetAddress = useVal(layoutP.sheet.address) + const clippedSpaceFromUnitSpace = useVal(layoutP.clippedSpace.fromUnitSpace) + + const [markRef, markNode] = useRefAndState(null) + + const [contextMenu] = useMarkerContextMenu(markNode, { + sheetAddress, + markerId: marker.id, + }) + + const [isDragging] = useDragMarker(markNode, { + layoutP, + marker, + }) + + return ( +
+ {contextMenu} + + +
+ ) +} + +function useMarkerContextMenu( + node: HTMLElement | null, + options: { + sheetAddress: SheetAddress + markerId: SequenceMarkerId + }, +) { + return useContextMenu(node, { + menuItems() { + return [ + { + label: 'Remove marker', + callback: () => { + getStudio().transaction(({stateEditors}) => { + stateEditors.studio.historic.projects.stateByProjectId.stateBySheetId.sequenceEditor.removeMarker( + { + sheetAddress: options.sheetAddress, + markerId: options.markerId, + }, + ) + }) + }, + }, + ] + }, + }) +} + +function useDragMarker( + node: HTMLDivElement | null, + props: { + layoutP: Pointer + marker: StudioHistoricStateSequenceEditorMarker + }, +): [isDragging: boolean] { + const propsRef = useRef(props) + propsRef.current = props + + const useDragOpts = useMemo(() => { + let toUnitSpace: SequenceEditorPanelLayout['scaledSpace']['toUnitSpace'] + let tempTransaction: CommitOrDiscard | undefined + let markerAtStartOfDrag: StudioHistoricStateSequenceEditorMarker + + return { + debugName: `MarkerDot/useDragMarker (${props.marker.id})`, + + onDragStart(_event) { + markerAtStartOfDrag = propsRef.current.marker + toUnitSpace = val(props.layoutP.scaledSpace.toUnitSpace) + }, + onDrag(dx, _dy, event) { + const original = markerAtStartOfDrag + const newPosition = Math.max( + // check if our event hoversover a [data-pos] element + POSITION_SNAPPING.checkIfMouseEventSnapToPos(event, { + ignore: node, + }) ?? + // if we don't find snapping target, check the distance dragged + original position + original.position + toUnitSpace(dx), + // sanitize to minimum of zero + 0, + ) + + tempTransaction?.discard() + tempTransaction = getStudio()!.tempTransaction(({stateEditors}) => { + stateEditors.studio.historic.projects.stateByProjectId.stateBySheetId.sequenceEditor.replaceMarkers( + { + sheetAddress: val(props.layoutP.sheet.address), + markers: [{...original, position: newPosition}], + snappingFunction: val(props.layoutP.sheet).getSequence() + .closestGridPosition, + }, + ) + }) + }, + onDragEnd(dragHappened) { + if (dragHappened) tempTransaction?.commit() + else tempTransaction?.discard() + tempTransaction = undefined + }, + } + }, []) + + const [isDragging] = useDrag(node, useDragOpts) + + useLockFrameStampPosition(isDragging, props.marker.position) + useCssCursorLock( + isDragging, + 'draggingPositionInSequenceEditor draggingMarker', + 'ew-resize', + ) + + return [isDragging] +} + +// Pretty much same code as for keyframe and similar for playhead. +// Consider if we should unify the implementations. +// - See "useLockFrameStampPosition" +// - Also see "pointerPositionInUnitSpace" for a related impl (for different problem) +const POSITION_SNAPPING = { + /** + * Used to indicate that when hovering over this element, we should enable + * snapping to the given position. + */ + attributeNameForPosition: 'data-pos', + checkIfMouseEventSnapToPos( + event: MouseEvent, + options?: {ignore?: HTMLElement | null}, + ): number | null { + const snapTarget = event + .composedPath() + .find( + (el): el is Element => + el instanceof Element && + el !== options?.ignore && + el.hasAttribute(POSITION_SNAPPING.attributeNameForPosition), + ) + + if (snapTarget) { + const snapPos = parseFloat( + snapTarget.getAttribute(POSITION_SNAPPING.attributeNameForPosition)!, + ) + if (isFinite(snapPos)) { + return snapPos + } + } + + return null + }, +} diff --git a/theatre/studio/src/panels/SequenceEditorPanel/RightOverlay/MarkerDotVisual.svg b/theatre/studio/src/panels/SequenceEditorPanel/RightOverlay/MarkerDotVisual.svg new file mode 100644 index 0000000..08008bd --- /dev/null +++ b/theatre/studio/src/panels/SequenceEditorPanel/RightOverlay/MarkerDotVisual.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/theatre/studio/src/panels/SequenceEditorPanel/RightOverlay/Markers.tsx b/theatre/studio/src/panels/SequenceEditorPanel/RightOverlay/Markers.tsx new file mode 100644 index 0000000..3aa2bd4 --- /dev/null +++ b/theatre/studio/src/panels/SequenceEditorPanel/RightOverlay/Markers.tsx @@ -0,0 +1,27 @@ +import type {Pointer} from '@theatre/dataverse' +import {useVal} from '@theatre/react' +import getStudio from '@theatre/studio/getStudio' +import React from 'react' +import type {SequenceEditorPanelLayout} from '@theatre/studio/panels/SequenceEditorPanel/layout/layout' +import MarkerDot from './MarkerDot' + +const Markers: React.VFC<{layoutP: Pointer}> = ({ + layoutP, +}) => { + const sheetAddress = useVal(layoutP.sheet.address) + const markerSetP = + getStudio().atomP.historic.projects.stateByProjectId[sheetAddress.projectId] + .stateBySheetId[sheetAddress.sheetId].sequenceEditor.markerSet + const markerAllIds = useVal(markerSetP.allIds) + + return ( + <> + {markerAllIds && + Object.keys(markerAllIds).map((markerId) => ( + + ))} + + ) +} + +export default Markers diff --git a/theatre/studio/src/panels/SequenceEditorPanel/RightOverlay/Playhead.tsx b/theatre/studio/src/panels/SequenceEditorPanel/RightOverlay/Playhead.tsx index 8022df8..eeb3f1d 100644 --- a/theatre/studio/src/panels/SequenceEditorPanel/RightOverlay/Playhead.tsx +++ b/theatre/studio/src/panels/SequenceEditorPanel/RightOverlay/Playhead.tsx @@ -21,9 +21,12 @@ import BasicPopover from '@theatre/studio/uiComponents/Popover/BasicPopover' import PlayheadPositionPopover from './PlayheadPositionPopover' import {getIsPlayheadAttachedToFocusRange} from '@theatre/studio/UIRoot/useKeyboardShortcuts' import { - lockedCursorCssPropName, + lockedCursorCssVarName, useCssCursorLock, } from '@theatre/studio/uiComponents/PointerEventsHandler' +import useContextMenu from '@theatre/studio/uiComponents/simpleContextMenu/useContextMenu' +import getStudio from '@theatre/studio/getStudio' +import {generateSequenceMarkerId} from '@theatre/shared/utils/ids' const Container = styled.div<{isVisible: boolean}>` --thumbColor: #00e0ff; @@ -49,7 +52,7 @@ const Rod = styled.div` #pointer-root.draggingPositionInSequenceEditor &:not(.seeking) { /* pointer-events: auto; */ - /* cursor: var(${lockedCursorCssPropName}); */ + /* cursor: var(${lockedCursorCssVarName}); */ &:after { position: absolute; @@ -79,7 +82,7 @@ const Thumb = styled.div` #pointer-root.draggingPositionInSequenceEditor &:not(.seeking) { pointer-events: auto; - cursor: var(${lockedCursorCssPropName}); + cursor: var(${lockedCursorCssVarName}); } ${Container}.playheadattachedtofocusrange > & { @@ -250,6 +253,11 @@ const Playhead: React.FC<{layoutP: Pointer}> = ({ // hide the frame stamp when seeking useLockFrameStampPosition(useVal(layoutP.seeker.isSeeking) || isDragging, -1) + const [contextMenu] = usePlayheadContextMenu(thumbNode, { + // pass in a pointer to ensure we aren't spending retrieval on every render + layoutP, + }) + return usePrism(() => { const isSeeking = val(layoutP.seeker.isSeeking) @@ -270,6 +278,7 @@ const Playhead: React.FC<{layoutP: Pointer}> = ({ return ( <> + {contextMenu} {popoverNode} }> = ({ } export default Playhead + +function usePlayheadContextMenu( + node: HTMLElement | null, + options: {layoutP: Pointer}, +) { + return useContextMenu(node, { + menuItems() { + return [ + { + label: 'Place marker', + callback: () => { + getStudio().transaction(({stateEditors}) => { + // only retrieve val on callback to reduce unnecessary work on every use + const sheet = val(options.layoutP.sheet) + const sheetSequence = sheet.getSequence() + stateEditors.studio.historic.projects.stateByProjectId.stateBySheetId.sequenceEditor.replaceMarkers( + { + sheetAddress: sheet.address, + markers: [ + { + id: generateSequenceMarkerId(), + position: sheetSequence.position, + }, + ], + snappingFunction: sheetSequence.closestGridPosition, + }, + ) + }) + }, + }, + ] + }, + }) +} diff --git a/theatre/studio/src/panels/SequenceEditorPanel/RightOverlay/RightOverlay.tsx b/theatre/studio/src/panels/SequenceEditorPanel/RightOverlay/RightOverlay.tsx index 3802607..8ff28bc 100644 --- a/theatre/studio/src/panels/SequenceEditorPanel/RightOverlay/RightOverlay.tsx +++ b/theatre/studio/src/panels/SequenceEditorPanel/RightOverlay/RightOverlay.tsx @@ -11,6 +11,7 @@ import HorizontalScrollbar from './HorizontalScrollbar' import Playhead from './Playhead' import TopStrip from './TopStrip' import FocusRangeCurtains from '@theatre/studio/panels/SequenceEditorPanel/DopeSheet/Right/FocusRangeCurtains' +import Markers from './Markers' const Container = styled.div` position: absolute; @@ -34,6 +35,7 @@ const RightOverlay: React.FC<{ + diff --git a/theatre/studio/src/panels/SequenceEditorPanel/RightOverlay/SnapCursor.svg b/theatre/studio/src/panels/SequenceEditorPanel/RightOverlay/SnapCursor.svg new file mode 100644 index 0000000..6ea5abb --- /dev/null +++ b/theatre/studio/src/panels/SequenceEditorPanel/RightOverlay/SnapCursor.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/theatre/studio/src/panels/SequenceEditorPanel/SequenceEditorPanel.tsx b/theatre/studio/src/panels/SequenceEditorPanel/SequenceEditorPanel.tsx index 40b88e1..439d072 100644 --- a/theatre/studio/src/panels/SequenceEditorPanel/SequenceEditorPanel.tsx +++ b/theatre/studio/src/panels/SequenceEditorPanel/SequenceEditorPanel.tsx @@ -53,6 +53,7 @@ export const zIndexes = (() => { lengthIndicatorStrip: 0, playhead: 0, currentFrameStamp: 0, + marker: 0, horizontalScrollbar: 0, } diff --git a/theatre/studio/src/store/stateEditors.ts b/theatre/studio/src/store/stateEditors.ts index 1fc8988..3ea39c4 100644 --- a/theatre/studio/src/store/stateEditors.ts +++ b/theatre/studio/src/store/stateEditors.ts @@ -14,6 +14,7 @@ import type { import {encodePathToProp} from '@theatre/shared/utils/addresses' import type { KeyframeId, + SequenceMarkerId, SequenceTrackId, UIPanelId, } from '@theatre/shared/utils/ids' @@ -40,6 +41,7 @@ import type { OutlineSelectionState, PanelPosition, StudioAhistoricState, + StudioHistoricStateSequenceEditorMarker, } from './types' import {clamp, uniq} from 'lodash-es' import { @@ -52,6 +54,7 @@ import { import type SheetTemplate from '@theatre/core/sheets/SheetTemplate' import type SheetObjectTemplate from '@theatre/core/sheetObjects/SheetObjectTemplate' import type {PropTypeConfig} from '@theatre/core/propTypes' +import {pointableSetUtil} from '@theatre/shared/utils/PointableSet' export const setDrafts__onlyMeantToBeCalledByTransaction = ( drafts: undefined | Drafts, @@ -250,6 +253,85 @@ namespace stateEditors { ]) } } + + function _ensureMarkers(sheetAddress: SheetAddress) { + const sequenceEditor = + stateEditors.studio.historic.projects.stateByProjectId.stateBySheetId._ensure( + sheetAddress, + ).sequenceEditor + + if (!sequenceEditor.markerSet) { + sequenceEditor.markerSet = pointableSetUtil.create() + } + + return sequenceEditor.markerSet + } + + export function replaceMarkers(p: { + sheetAddress: SheetAddress + markers: Array + snappingFunction: (p: number) => number + }) { + const currentMarkerSet = _ensureMarkers(p.sheetAddress) + + const sanitizedMarkers = p.markers + .filter((marker) => { + if (!isFinite(marker.position)) return false + + return true // marker looks valid + }) + .map((marker) => ({ + ...marker, + position: p.snappingFunction(marker.position), + })) + + const newMarkersById = keyBy(sanitizedMarkers, 'id') + + /** Usually starts as the "unselected" markers */ + let markersThatArentBeingReplaced = pointableSetUtil.filter( + currentMarkerSet, + (marker) => marker && !newMarkersById[marker.id], + ) + + const markersThatArentBeingReplacedByPosition = keyBy( + Object.values(markersThatArentBeingReplaced.byId), + 'position', + ) + + // If the new transformed markers overlap with any existing markers, + // we remove the overlapped markers + sanitizedMarkers.forEach(({position}) => { + const existingMarkerAtThisPosition = + markersThatArentBeingReplacedByPosition[position] + if (existingMarkerAtThisPosition) { + markersThatArentBeingReplaced = pointableSetUtil.remove( + markersThatArentBeingReplaced, + existingMarkerAtThisPosition.id, + ) + } + }) + + Object.assign( + currentMarkerSet, + pointableSetUtil.merge([ + markersThatArentBeingReplaced, + pointableSetUtil.create( + sanitizedMarkers.map((marker) => [marker.id, marker]), + ), + ]), + ) + } + + export function removeMarker(options: { + sheetAddress: SheetAddress + markerId: SequenceMarkerId + }) { + const currentMarkerSet = _ensureMarkers(options.sheetAddress) + Object.assign( + currentMarkerSet, + pointableSetUtil.remove(currentMarkerSet, options.markerId), + ) + } } } } @@ -574,6 +656,9 @@ namespace stateEditors { ) if (indexOfLeftKeyframe === -1) { keyframes.unshift({ + // generating the keyframe within the `setKeyframeAtPosition` makes it impossible for us + // to make this business logic deterministic, which is important to guarantee for collaborative + // editing. id: generateKeyframeId(), position, connectedRight: true, diff --git a/theatre/studio/src/store/types/historic.ts b/theatre/studio/src/store/types/historic.ts index c0d3823..46e7f5b 100644 --- a/theatre/studio/src/store/types/historic.ts +++ b/theatre/studio/src/store/types/historic.ts @@ -8,6 +8,7 @@ import type { WithoutSheetInstance, } from '@theatre/shared/utils/addresses' import type {StrictRecord} from '@theatre/shared/utils/types' +import type {PointableSet} from '@theatre/shared/utils/PointableSet' import type Project from '@theatre/core/projects/Project' import type Sheet from '@theatre/core/sheets/Sheet' import type SheetObject from '@theatre/core/sheetObjects/SheetObject' @@ -15,6 +16,7 @@ import type { ObjectAddressKey, PaneInstanceId, ProjectId, + SequenceMarkerId, SheetId, SheetInstanceId, UIPanelId, @@ -69,6 +71,22 @@ export type PaneInstanceDescriptor = { paneClass: string } +/** + * Marker allows you to mark notable positions in your sequence. + * + * See root {@link StudioHistoricState} + */ +export type StudioHistoricStateSequenceEditorMarker = { + id: SequenceMarkerId + /** + * The position this marker takes in the sequence. + * + * Usually, this value is measured in seconds, but the unit could be varied based on the kind of + * unit you're using for mapping to the position (e.g. Position=1 = 10px of scrolling) + */ + position: number +} + /** * See parent {@link StudioHistoricStateProject}. * See root {@link StudioHistoricState} @@ -76,6 +94,10 @@ export type PaneInstanceDescriptor = { export type StudioHistoricStateProjectSheet = { selectedInstanceId: undefined | SheetInstanceId sequenceEditor: { + markerSet?: PointableSet< + SequenceMarkerId, + StudioHistoricStateSequenceEditorMarker + > selectedPropsByObject: StrictRecord< ObjectAddressKey, StrictRecord @@ -88,16 +110,6 @@ export type StudioHistoricStateProject = { stateBySheetId: StrictRecord } -/** - * {@link StudioHistoricState} includes both studio and project data, and - * contains data changed for an undo/redo history. - * - * ## Internally - * - * We use immer `Draft`s to encapsulate this whole state to then be operated - * on by each transaction. The derived values from the store will also include - * the application of the "temp transactions" stack. - */ export type StudioHistoricState = { projects: { stateByProjectId: StrictRecord diff --git a/theatre/studio/src/uiComponents/PointerEventsHandler.tsx b/theatre/studio/src/uiComponents/PointerEventsHandler.tsx index cab82cb..6a4f20c 100644 --- a/theatre/studio/src/uiComponents/PointerEventsHandler.tsx +++ b/theatre/studio/src/uiComponents/PointerEventsHandler.tsx @@ -12,12 +12,14 @@ import styled from 'styled-components' const elementId = 'pointer-root' /** - * When the cursor is locked, this css prop is added to #pointer-root + * When the cursor is locked, this css var is added to #pointer-root * whose value will be the locked cursor (e.g. ew-resize). * * Look up references of this constant for examples of how it is used. + * + * See {@link useCssCursorLock} - code that locks the cursor */ -export const lockedCursorCssPropName = '--lockedCursor' +export const lockedCursorCssVarName = '--lockedCursor' const Container = styled.div` pointer-events: auto; @@ -73,7 +75,7 @@ const PointerEventsHandler: React.FC<{ style={{ cursor: lockedCursor, // @ts-ignore - [lockedCursorCssPropName]: lockedCursor, + [lockedCursorCssVarName]: lockedCursor, }} > {props.children} @@ -94,6 +96,8 @@ const PointerEventsHandler: React.FC<{ * but then "unlocking" that style will again reveal the existing styles. * * It behaves a bit like a stack. + * + * See {@link lockedCursorCssVarName} */ export const useCssCursorLock = ( /** Whether to enable the provided cursor style */ diff --git a/theatre/studio/src/uiComponents/useDrag.ts b/theatre/studio/src/uiComponents/useDrag.ts index cf7f62b..745a79a 100644 --- a/theatre/studio/src/uiComponents/useDrag.ts +++ b/theatre/studio/src/uiComponents/useDrag.ts @@ -80,10 +80,10 @@ export default function useDrag( } }>({dragHappened: false, startPos: {x: 0, y: 0}}) + const capturedPointerRef = useRef() useLayoutEffect(() => { if (!target) return - let capturedPointer: undefined | CapturedPointer const getDistances = (event: MouseEvent): [number, number] => { const {startPos} = stateRef.current return [event.screenX - startPos.x, event.screenY - startPos.y] @@ -98,11 +98,12 @@ export default function useDrag( } const dragEndHandler = () => { - removeDragListeners() - modeRef.current = 'notDragging' + if (modeRef.current === 'dragging') { + removeDragListeners() + modeRef.current = 'notDragging' - optsRef.current.onDragEnd && - optsRef.current.onDragEnd(stateRef.current.dragHappened) + optsRef.current.onDragEnd?.(stateRef.current.dragHappened) + } } const addDragListeners = () => { @@ -111,7 +112,7 @@ export default function useDrag( } const removeDragListeners = () => { - capturedPointer?.release() + capturedPointerRef.current?.release() document.removeEventListener('mousemove', dragHandler) document.removeEventListener('mouseup', dragEndHandler) } @@ -132,7 +133,7 @@ export default function useDrag( const dragStartHandler = (event: MouseEvent) => { // defensively release - capturedPointer?.release() + capturedPointerRef.current?.release() const opts = optsRef.current if (opts.disabled === true) return @@ -147,7 +148,7 @@ export default function useDrag( } // need to capture pointer after we know the provided handler wants to handle drag start - capturedPointer = capturePointer('Drag start') + capturedPointerRef.current = capturePointer('Drag start') if (!opts.dontBlockMouseDown) { event.stopPropagation() @@ -176,8 +177,7 @@ export default function useDrag( target.removeEventListener('click', preventUnwantedClick as $FixMe) if (modeRef.current !== 'notDragging') { - optsRef.current.onDragEnd && - optsRef.current.onDragEnd(modeRef.current === 'dragging') + optsRef.current.onDragEnd?.(modeRef.current === 'dragging') } modeRef.current = 'notDragging' }