Initial marker features

- Add comments about cursor lock, framestamp lock
- Add PointableSet, Mark Ids, dataverse usage fix
- Add marker dragging, fix PointerCapturing

Co-authored-by: Cole Lawrence <cole@colelawrence.com>
Co-authored-by: Aria <aria.minaei@gmail.com>
This commit is contained in:
vezwork 2022-05-03 07:29:05 -04:00 committed by Cole Lawrence
parent a3b1938d43
commit 030b6d2804
22 changed files with 722 additions and 73 deletions

View file

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

View file

@ -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<Id extends string, V> = {
/**
* 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<Id, V>
/**
* 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<Id, true>
}
export const pointableSetUtil = {
create<Id extends string, V>(
values?: Iterable<[Id, V]>,
): PointableSet<Id, V> {
const set: PointableSet<Id, V> = {byId: {}, allIds: {}}
if (values) {
for (const [id, value] of values) {
set.byId[id] = value
set.allIds[id] = true
}
}
return set
},
shallowCopy<Id extends string, V>(
existing: PointableSet<Id, V> | undefined,
): PointableSet<Id, V> {
return {
byId: {...existing?.byId},
allIds: {...existing?.allIds},
}
},
add<Id extends string, V>(
existing: PointableSet<Id, V> | undefined,
id: Id,
value: V,
): PointableSet<Id, V> {
return {
byId: {...existing?.byId, [id]: value},
allIds: {...existing?.allIds, [id]: true},
}
},
merge<Id extends string, V>(
sets: PointableSet<Id, V>[],
): PointableSet<Id, V> {
const target = pointableSetUtil.create<Id, V>()
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<Id extends string, V>(
existing: PointableSet<Id, V>,
id: Id,
): PointableSet<Id, V> {
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<Id extends string, V>(
existing: PointableSet<Id, V>,
predicate: (value: V | undefined) => boolean | undefined | null,
): PointableSet<Id, V> {
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
},
}

View file

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

View file

@ -68,7 +68,8 @@ export type DeepPartialOfSerializableValue<T extends SerializableValue> =
* This is equivalent to `Partial<Record<Key, V>>` 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<Key extends string, V> = {[K in Key]?: V}

View file

@ -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 | {
type CaptureInfo = {
debugOwnerName: string
debugReason: string
}>(null)
}
let currentCaptureRef = React.useRef<null | CaptureInfo>(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
}

View file

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

View file

@ -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<IKeyframeDotProps> = (props) => {
}}
className={isDragging ? 'beingDragged' : ''}
/>
<Square isSelected={!!props.selection} />
<Diamond isSelected={!!props.selection} />
{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]

View file

@ -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<SequenceEditorPanelLayout>
}> = ({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:
* <div data-theatre-lock-framestamp-to="120.55" />
* 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'

View file

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

View file

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

View file

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

View file

@ -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 <Mark/> 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<SequenceEditorPanelLayout>
markerId: SequenceMarkerId
}
const MarkerDot: React.VFC<IMarkerDotProps> = ({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 <MarkerDotDefined marker={marker} layoutP={layoutP} />
}
export default MarkerDot
type IMarkerDotDefinedProps = {
layoutP: Pointer<SequenceEditorPanelLayout>
marker: StudioHistoricStateSequenceEditorMarker
}
const MarkerDotDefined: React.VFC<IMarkerDotDefinedProps> = ({
layoutP,
marker,
}) => {
const sheetAddress = useVal(layoutP.sheet.address)
const clippedSpaceFromUnitSpace = useVal(layoutP.clippedSpace.fromUnitSpace)
const [markRef, markNode] = useRefAndState<HTMLDivElement | null>(null)
const [contextMenu] = useMarkerContextMenu(markNode, {
sheetAddress,
markerId: marker.id,
})
const [isDragging] = useDragMarker(markNode, {
layoutP,
marker,
})
return (
<div
style={{
position: 'absolute',
zIndex: zIndexes.marker,
transform: `translateX(${clippedSpaceFromUnitSpace(
marker.position,
)}px)`,
// below the sequence ruler "top bar"
top: '18px',
}}
>
{contextMenu}
<HitZone
ref={markRef}
// `data-pos` and `attributeNameThatLocksFramestamp` are used by FrameStampPositionProvider
// in order to handle snapping the playhead. Adding these props effectively
// causes the playhead to "snap" to the marker on mouse over.
// `pointerEventsAutoInNormalMode` and `lockedCursorCssVarName` in the CSS above are also
// used to make this behave correctly.
{...{
[attributeNameThatLocksFramestamp]: marker.position.toFixed(3),
[POSITION_SNAPPING.attributeNameForPosition]:
marker.position.toFixed(3),
}}
className={isDragging ? 'beingDragged' : ''}
/>
<MarkerVisualDot />
</div>
)
}
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<SequenceEditorPanelLayout>
marker: StudioHistoricStateSequenceEditorMarker
},
): [isDragging: boolean] {
const propsRef = useRef(props)
propsRef.current = props
const useDragOpts = useMemo<UseDragOpts>(() => {
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
},
}

View file

@ -0,0 +1,9 @@
<svg width="10" height="8" viewBox="0 0 10 8" fill="none" xmlns="http://www.w3.org/2000/svg">
<mask id="mask0_220_60402" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="3" width="10" height="6">
<rect width="8.56043" height="5.43915" transform="matrix(-1 -8.74228e-08 -8.74228e-08 1 9.28027 3.28027)" fill="#C4C4C4"/>
</mask>
<g mask="url(#mask0_220_60402)">
<rect width="6" height="6" transform="matrix(-0.707107 -0.707107 -0.707107 0.707107 9.28027 2.65527)" fill="#E0C917"/>
</g>
<rect width="8.02659" height="2.00315" transform="matrix(-1 -8.74228e-08 -8.74228e-08 1 9.01318 1.99707)" fill="#E0C917"/>
</svg>

After

Width:  |  Height:  |  Size: 625 B

View file

@ -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<SequenceEditorPanelLayout>}> = ({
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) => (
<MarkerDot key={markerId} layoutP={layoutP} markerId={markerId} />
))}
</>
)
}
export default Markers

View file

@ -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<SequenceEditorPanelLayout>}> = ({
// 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<SequenceEditorPanelLayout>}> = ({
return (
<>
{contextMenu}
{popoverNode}
<Container
isVisible={isVisible}
@ -306,3 +315,37 @@ const Playhead: React.FC<{layoutP: Pointer<SequenceEditorPanelLayout>}> = ({
}
export default Playhead
function usePlayheadContextMenu(
node: HTMLElement | null,
options: {layoutP: Pointer<SequenceEditorPanelLayout>},
) {
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,
},
)
})
},
},
]
},
})
}

View file

@ -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<{
<HorizontalScrollbar layoutP={layoutP} />
<FrameStamp layoutP={layoutP} />
<TopStrip layoutP={layoutP} />
<Markers layoutP={layoutP} />
<LengthIndicator layoutP={layoutP} />
<FocusRangeCurtains layoutP={layoutP} />
</Container>

View file

@ -0,0 +1,6 @@
<svg width="34" height="34" viewBox="0 0 34 34" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1 7V1H7" stroke="#74FFDE" stroke-width="0.25" />
<path d="M7 33H1L1 27" stroke="#74FFDE" stroke-width="0.25" />
<path d="M33 27V33H27" stroke="#74FFDE" stroke-width="0.25" />
<path d="M27 1L33 1V7" stroke="#74FFDE" stroke-width="0.25" />
</svg>

After

Width:  |  Height:  |  Size: 358 B

View file

@ -53,6 +53,7 @@ export const zIndexes = (() => {
lengthIndicatorStrip: 0,
playhead: 0,
currentFrameStamp: 0,
marker: 0,
horizontalScrollbar: 0,
}

View file

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

View file

@ -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<PathToProp_Encoded, keyof typeof graphEditorColors>
@ -88,16 +110,6 @@ export type StudioHistoricStateProject = {
stateBySheetId: StrictRecord<SheetId, StudioHistoricStateProjectSheet>
}
/**
* {@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<ProjectId, StudioHistoricStateProject>

View file

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

View file

@ -80,10 +80,10 @@ export default function useDrag(
}
}>({dragHappened: false, startPos: {x: 0, y: 0}})
const capturedPointerRef = useRef<undefined | CapturedPointer>()
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'
}