diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index 3c2b1b50..c6f2b650 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -24,6 +24,7 @@ export function App() { return ( diff --git a/packages/ui/src/components/timeline/AudioTrack.tsx b/packages/ui/src/components/timeline/AudioTrack.tsx index 0769bb97..e2a40646 100644 --- a/packages/ui/src/components/timeline/AudioTrack.tsx +++ b/packages/ui/src/components/timeline/AudioTrack.tsx @@ -1,9 +1,8 @@ import styles from './Timeline.module.scss'; -import {useContext, useLayoutEffect, useMemo, useRef} from 'preact/hooks'; -import {TimelineContext} from './TimelineContext'; +import {useLayoutEffect, useMemo, useRef} from 'preact/hooks'; import {useSubscribableValue} from '../../hooks'; -import {useProject} from '../../contexts'; +import {useProject, useTimelineContext} from '../../contexts'; const HEIGHT = 48; @@ -11,8 +10,13 @@ export function AudioTrack() { const ref = useRef(); const project = useProject(); const context = useMemo(() => ref.current?.getContext('2d'), [ref.current]); - const {viewLength, startFrame, endFrame, duration, density} = - useContext(TimelineContext); + const { + viewLength, + firstVisibleFrame, + lastVisibleFrame, + density, + framesToPercents, + } = useTimelineContext(); const audioData = useSubscribableValue(project.audio.onDataChanged); @@ -21,23 +25,25 @@ export function AudioTrack() { context.clearRect(0, 0, viewLength, HEIGHT * 2); if (!audioData) return; - context.strokeStyle = 'white'; + context.strokeStyle = '#444'; context.lineWidth = 1; context.beginPath(); context.moveTo(0, HEIGHT); const start = - project.audio.toRelativeTime(project.framesToSeconds(startFrame)) * + (project.framesToSeconds(firstVisibleFrame) - + project.audio.onOffsetChanged.current) * audioData.sampleRate; const end = - project.audio.toRelativeTime(project.framesToSeconds(endFrame)) * + project.audio.toRelativeTime(project.framesToSeconds(lastVisibleFrame)) * audioData.sampleRate; - const flooredStart = ~~start; + const flooredStart = Math.floor(start); const padding = flooredStart - start; const length = end - start; const step = Math.ceil(density); - for (let offset = 0; offset < length; offset += step * 2) { + for (let index = start; index < end; index += step * 2) { + const offset = index - start; const sample = flooredStart + offset; if (sample >= audioData.peaks.length) break; @@ -52,15 +58,22 @@ export function AudioTrack() { } context.stroke(); - }, [context, audioData, density, viewLength, startFrame, endFrame]); + }, [ + context, + audioData, + density, + viewLength, + firstVisibleFrame, + lastVisibleFrame, + ]); const style = useMemo( () => ({ - marginLeft: `${(startFrame / duration) * 100}%`, - width: `${((endFrame - startFrame) / duration) * 100}%`, + marginLeft: `${framesToPercents(firstVisibleFrame)}%`, + width: `${framesToPercents(lastVisibleFrame - firstVisibleFrame)}%`, height: `${HEIGHT * 2}px`, }), - [startFrame, endFrame, duration], + [firstVisibleFrame, lastVisibleFrame, framesToPercents], ); return ( diff --git a/packages/ui/src/components/timeline/Label.tsx b/packages/ui/src/components/timeline/Label.tsx index f1736f82..adfb7cf3 100644 --- a/packages/ui/src/components/timeline/Label.tsx +++ b/packages/ui/src/components/timeline/Label.tsx @@ -2,9 +2,8 @@ import styles from './Timeline.module.scss'; import type {Scene, TimeEvent} from '@motion-canvas/core/lib/scenes'; import {useDrag} from '../../hooks'; -import {useCallback, useContext, useLayoutEffect, useState} from 'preact/hooks'; -import {TimelineContext} from './TimelineContext'; -import {usePlayer} from '../../contexts'; +import {useCallback, useLayoutEffect, useState} from 'preact/hooks'; +import {usePlayer, useProject, useTimelineContext} from '../../contexts'; interface LabelProps { event: TimeEvent; @@ -12,16 +11,16 @@ interface LabelProps { } export function Label({event, scene}: LabelProps) { - const {fullLength, duration} = useContext(TimelineContext); + const {framesToPercents, pixelsToFrames} = useTimelineContext(); const player = usePlayer(); - const durationSeconds = player.project.framesToSeconds(duration); + const project = useProject(); const [eventTime, setEventTime] = useState(event.offset); const [handleDrag] = useDrag( useCallback( dx => { - setEventTime(eventTime + (dx / fullLength) * durationSeconds); + setEventTime(eventTime + project.framesToSeconds(pixelsToFrames(dx))); }, - [eventTime, fullLength, durationSeconds], + [eventTime, project, pixelsToFrames], ), useCallback( e => { @@ -57,40 +56,32 @@ export function Label({event, scene}: LabelProps) { className={styles.labelClip} data-name={event.name} style={{ - left: `${ - ((scene.firstFrame + + left: `${framesToPercents( + scene.firstFrame + scene.project.secondsToFrames( event.initialTime + Math.max(0, eventTime), - )) / - duration) * - 100 - }%`, + ), + )}%`, }} />
diff --git a/packages/ui/src/components/timeline/LabelGroup.tsx b/packages/ui/src/components/timeline/LabelGroup.tsx index 29d88997..8fc81395 100644 --- a/packages/ui/src/components/timeline/LabelGroup.tsx +++ b/packages/ui/src/components/timeline/LabelGroup.tsx @@ -1,19 +1,19 @@ import type {Scene} from '@motion-canvas/core/lib/scenes'; import {Label} from './Label'; import {useSubscribableValue} from '../../hooks'; -import {useContext} from 'preact/hooks'; -import {TimelineContext} from './TimelineContext'; +import {useTimelineContext} from '../../contexts'; interface LabelGroupProps { scene: Scene; } export function LabelGroup({scene}: LabelGroupProps) { - const {startFrame, endFrame} = useContext(TimelineContext); + const {firstVisibleFrame, lastVisibleFrame} = useTimelineContext(); const events = useSubscribableValue(scene.timeEvents.onChanged); const cached = useSubscribableValue(scene.onCacheChanged); const isVisible = - cached.lastFrame >= startFrame && cached.firstFrame <= endFrame; + cached.lastFrame >= firstVisibleFrame && + cached.firstFrame <= lastVisibleFrame; return ( <> diff --git a/packages/ui/src/components/timeline/Playhead.tsx b/packages/ui/src/components/timeline/Playhead.tsx index 83ab99b3..c437c19c 100644 --- a/packages/ui/src/components/timeline/Playhead.tsx +++ b/packages/ui/src/components/timeline/Playhead.tsx @@ -1,17 +1,17 @@ import styles from './Timeline.module.scss'; import {usePlayerTime} from '../../hooks'; -import {useContext} from 'preact/hooks'; -import {TimelineContext} from './TimelineContext'; +import {useTimelineContext} from '../../contexts'; export function Playhead() { - const {fullLength} = useContext(TimelineContext); + const {framesToPixels} = useTimelineContext(); const time = usePlayerTime(); return (
); diff --git a/packages/ui/src/components/timeline/RangeTrack.tsx b/packages/ui/src/components/timeline/RangeSelector.tsx similarity index 65% rename from packages/ui/src/components/timeline/RangeTrack.tsx rename to packages/ui/src/components/timeline/RangeSelector.tsx index 95312f7a..ff28d5b3 100644 --- a/packages/ui/src/components/timeline/RangeTrack.tsx +++ b/packages/ui/src/components/timeline/RangeSelector.tsx @@ -1,13 +1,12 @@ import styles from './Timeline.module.scss'; import {useDrag, usePlayerState} from '../../hooks'; -import {useCallback, useContext, useEffect, useState} from 'preact/hooks'; +import {useCallback, useEffect, useState} from 'preact/hooks'; import {Icon, IconType} from '../controls'; -import {TimelineContext} from './TimelineContext'; -import {usePlayer} from '../../contexts'; +import {usePlayer, useTimelineContext} from '../../contexts'; -export function RangeTrack() { - const {fullLength} = useContext(TimelineContext); +export function RangeSelector() { + const {pixelsToFrames, framesToPercents} = useTimelineContext(); const player = usePlayer(); const state = usePlayerState(); @@ -21,9 +20,9 @@ export function RangeTrack() { const [handleDragStart] = useDrag( useCallback( dx => { - setStart(start + (dx / fullLength) * state.duration); + setStart(start + pixelsToFrames(dx)); }, - [start, fullLength, state.duration], + [start, pixelsToFrames], ), onDrop, ); @@ -31,11 +30,9 @@ export function RangeTrack() { const [handleDragEnd] = useDrag( useCallback( dx => { - setEnd( - Math.min(state.duration, end) + (dx / fullLength) * state.duration, - ); + setEnd(Math.min(state.duration, end) + pixelsToFrames(dx)); }, - [end, fullLength, state.duration], + [end, pixelsToFrames, state.duration], ), onDrop, ); @@ -43,12 +40,10 @@ export function RangeTrack() { const [handleDrag] = useDrag( useCallback( dx => { - setStart(start + (dx / fullLength) * state.duration); - setEnd( - Math.min(state.duration, end) + (dx / fullLength) * state.duration, - ); + setStart(start + pixelsToFrames(dx)); + setEnd(Math.min(state.duration, end) + pixelsToFrames(dx)); }, - [start, end, fullLength, state.duration], + [start, end, state.duration, pixelsToFrames], ), onDrop, ); @@ -69,9 +64,9 @@ export function RangeTrack() {
end ? 'row-reverse' : 'row', - left: `${(Math.max(0, normalizedStart) / state.duration) * 100}%`, + left: `${framesToPercents(Math.max(0, normalizedStart))}%`, right: `${ - 100 - Math.min(1, (normalizedEnd + 1) / state.duration) * 100 + 100 - framesToPercents(Math.min(state.duration, normalizedEnd + 1)) }%`, }} className={styles.range} @@ -92,7 +87,6 @@ export function RangeTrack() {
diff --git a/packages/ui/src/components/timeline/SceneTrack.tsx b/packages/ui/src/components/timeline/SceneTrack.tsx index 158a454b..2a49a66c 100644 --- a/packages/ui/src/components/timeline/SceneTrack.tsx +++ b/packages/ui/src/components/timeline/SceneTrack.tsx @@ -1,8 +1,9 @@ import styles from './Timeline.module.scss'; import type {Scene} from '@motion-canvas/core/lib/scenes'; -import {usePlayerState, useScenes, useSubscribableValue} from '../../hooks'; -import {usePlayer} from '../../contexts'; +import {useScenes, useSubscribableValue} from '../../hooks'; +import {usePlayer, useTimelineContext} from '../../contexts'; +import {useMemo} from 'preact/hooks'; export function SceneTrack() { const scenes = useScenes(); @@ -22,16 +23,22 @@ interface SceneClipProps { function SceneClip({scene}: SceneClipProps) { const player = usePlayer(); - const state = usePlayerState(); + const {framesToPercents, framesToPixels, offset} = useTimelineContext(); const cachedData = useSubscribableValue(scene.onCacheChanged); + const nameStyle = useMemo(() => { + const sceneOffset = framesToPixels(cachedData.firstFrame); + return offset > sceneOffset + ? {paddingLeft: `${offset - sceneOffset}px`} + : {}; + }, [offset, cachedData.firstFrame, framesToPixels]); + return (
{ if (event.button === 1) { @@ -46,18 +53,20 @@ function SceneClip({scene}: SceneClipProps) { }} >
-
{scene.name}
+ {cachedData.transitionDuration > 0 && ( +
+ )} +
+ {scene.name} +
- {cachedData.transitionDuration > 0 && ( -
- )}
); } diff --git a/packages/ui/src/components/timeline/Timeline.module.scss b/packages/ui/src/components/timeline/Timeline.module.scss index 91d4aa0c..982fbde2 100644 --- a/packages/ui/src/components/timeline/Timeline.module.scss +++ b/packages/ui/src/components/timeline/Timeline.module.scss @@ -14,60 +14,103 @@ flex-grow: 0; } -.timeline { - overflow-x: auto; +.timelineWrapper { + overflow-x: scroll; overflow-y: hidden; flex-grow: 1; position: relative; -} - -.track { - position: relative; - padding-top: 16px; - overflow: hidden; -} - -.timestampTrack { - position: relative; + background-color: #181818; width: 100%; - height: 24px; - border-bottom: 1px solid rgba(255, 255, 255, 0.24); - user-select: none; - pointer-events: none; +} + +.timeline { + position: relative; + overflow: hidden; + height: 100%; +} + +.timelineContent { + position: relative; + overflow: visible; + height: 100%; +} + +.trackContainer { + width: 100%; + height: 100%; + background-color: #242424; + + &::before { + width: 100%; + height: 32px; + display: block; + content: ''; + background-color: #181818; + } } .timestamp { - color: rgba(255, 255, 255, 0.54); - font-size: 12px; + user-select: none; + pointer-events: none; position: absolute; - padding: 0 4px; - top: 0; + top: 32px; bottom: 0; - border-left: 1px solid rgba(255, 255, 255, 0.24); + border-left: 1px solid #0003; + border-right: 1px solid #0003; + width: 0; + display: flex; + justify-content: center; + + &.odd { + opacity: 0.5; + + &::after { + display: none; + } + } + + &::after { + font-size: 12px; + line-height: 32px; + margin-top: -32px; + text-align: center; + display: block; + content: attr(data-frame); + color: rgba(255, 255, 255, 0.54); + } } .sceneTrack { - position: relative; height: 48px; padding: 4px 0; + display: flex; } .sceneClip { - $padding: 4px; - - position: absolute; height: 48px; + padding: 4px; .scene { - position: absolute; - top: $padding; - bottom: $padding; - left: $padding; - right: $padding; + position: relative; border-radius: 4px; - background-color: rgba(255, 255, 255, 0.16); + background-color: #444; + overflow: hidden; + + .transition { + position: absolute; + pointer-events: none; + top: 0; + bottom: 0; + left: -4px; + background: linear-gradient( + 90deg, + rgba(36, 36, 36, 1) 0%, + rgba(36, 36, 36, 0) 100% + ); + } .sceneName { + position: relative; line-height: 24px; pointer-events: none; overflow: hidden; @@ -75,20 +118,6 @@ margin: 8px 12px; } } - - .transition { - pointer-events: none; - position: absolute; - top: $padding; - bottom: $padding; - left: $padding; - border-radius: 4px 0 0 4px; - background: linear-gradient( - 90deg, - rgba(36, 36, 36, 1) 0%, - rgba(36, 36, 36, 0) 100% - ); - } } .labelTrack { @@ -163,21 +192,24 @@ .playhead { position: absolute; width: 2px; - top: 0; + top: 6px; bottom: 0; background-color: var(--theme); pointer-events: none; + display: flex; + align-items: flex-start; + justify-content: center; &::before { - position: absolute; - content: ''; display: block; - left: -8px; - top: 0; - width: 16px; - height: 8px; - border-radius: 0 0 4px 4px; + padding: 0 4px; + border-radius: 4px; background-color: var(--theme); + font-size: 12px; + line-height: 20px; + font-weight: bold; + color: rgba(0, 0, 0, 1); + content: attr(data-frame); } } @@ -190,18 +222,18 @@ opacity: 0; pointer-events: none; - .timeline:hover & { + .root:hover & { opacity: 0.24; } } .range { + border-radius: 4px; cursor: move; position: absolute; - top: 0; - height: 40px; + height: 32px; background-color: var(--theme-overlay); - border-bottom: 1px solid var(--theme); + border-bottom: 2px solid var(--theme); display: flex; align-items: center; justify-content: center; @@ -209,7 +241,7 @@ .handle { cursor: pointer; - padding: 8px 2px; + margin-top: 2px; &:after { background-color: rgba(255, 255, 255, 0); @@ -223,8 +255,6 @@ .range > &:active:after { background-color: rgba(255, 255, 255, 0.6); } - - margin-top: 4px; } .handleSpacer { @@ -233,6 +263,7 @@ } .audioTrack { - opacity: 0.16; + position: relative; + width: 100%; margin: 24px 0; } diff --git a/packages/ui/src/components/timeline/Timeline.tsx b/packages/ui/src/components/timeline/Timeline.tsx index 8b36c503..55de8855 100644 --- a/packages/ui/src/components/timeline/Timeline.tsx +++ b/packages/ui/src/components/timeline/Timeline.tsx @@ -14,16 +14,22 @@ import { useStateChange, } from '../../hooks'; import {Playhead} from './Playhead'; -import {TimestampTrack} from './TimestampTrack'; +import {Timestamps} from './Timestamps'; import {LabelTrack} from './LabelTrack'; import {SceneTrack} from './SceneTrack'; -import {RangeTrack} from './RangeTrack'; -import {TimelineContext, TimelineState} from './TimelineContext'; +import {RangeSelector} from './RangeSelector'; import {clamp} from '@motion-canvas/core/lib/tweening'; import {AudioTrack} from './AudioTrack'; -import {usePlayer} from '../../contexts'; +import { + usePlayer, + TimelineContextProvider, + TimelineState, +} from '../../contexts'; const ZOOM_SPEED = 0.1; +const ZOOM_MIN = 0.5; +const TIMESTAMP_SPACING = 32; +const MAX_FRAME_SIZE = 128; export function Timeline() { const player = usePlayer(); @@ -34,30 +40,58 @@ export function Timeline() { const [offset, setOffset] = useState(0); const [scale, setScale] = useState(1); + const sizes = useMemo( + () => ({ + viewLength: rect.width, + paddingLeft: rect.width / 2, + fullLength: rect.width * scale + rect.width, + playableLength: rect.width * scale, + }), + [rect.width, scale], + ); + + const ZOOM_MAX = (MAX_FRAME_SIZE / sizes.viewLength) * duration; + + const conversion = useMemo( + () => ({ + framesToPixels: (value: number) => + (value / duration) * sizes.playableLength, + framesToPercents: (value: number) => (value / duration) * 100, + pixelsToFrames: (value: number) => + (value / sizes.playableLength) * duration, + }), + [duration, sizes], + ); + const state = useMemo(() => { - const fullLength = rect.width * scale; - const density = Math.pow(2, Math.round(Math.log2(duration / fullLength))); - const segmentDensity = Math.max(1, Math.floor(128 * density)); - const startFrame = - Math.floor(((offset / fullLength) * duration) / segmentDensity) * - segmentDensity; - const endFrame = + const density = Math.pow( + 2, + Math.round(Math.log2(duration / sizes.playableLength)), + ); + const segmentDensity = Math.floor(TIMESTAMP_SPACING * density); + const clampedSegmentDensity = Math.max(1, segmentDensity); + const relativeOffset = offset - sizes.paddingLeft; + const firstVisibleFrame = + Math.floor( + conversion.pixelsToFrames(relativeOffset) / clampedSegmentDensity, + ) * clampedSegmentDensity; + const lastVisibleFrame = Math.ceil( - (((offset + rect.width) / fullLength) * duration) / segmentDensity, - ) * segmentDensity; + conversion.pixelsToFrames( + relativeOffset + sizes.viewLength + TIMESTAMP_SPACING, + ) / clampedSegmentDensity, + ) * clampedSegmentDensity; return { - scale, - offset, - fullLength, - viewLength: rect.width, - startFrame, - endFrame, + viewLength: sizes.viewLength, + offset: relativeOffset, + firstVisibleFrame, + lastVisibleFrame, density, segmentDensity, - duration, + ...conversion, }; - }, [rect.width, scale, duration, offset]); + }, [sizes, conversion, duration, offset]); useStateChange( ([prevDuration, prevWidth]) => { @@ -69,7 +103,7 @@ export function Timeline() { newScale *= prevWidth / rect.width; } if (!isNaN(newScale)) { - setScale(Math.max(1, newScale)); + setScale(clamp(ZOOM_MIN, ZOOM_MAX, newScale)); } }, [duration, rect.width], @@ -81,14 +115,14 @@ export function Timeline() { event => { if (event.key !== 'f') return; const frame = player.onFrameChanged.current; - const maxOffset = state.fullLength - rect.width; - const playheadPosition = (state.fullLength * frame) / duration; - const scrollLeft = playheadPosition - rect.width / 2; + const maxOffset = sizes.fullLength - sizes.viewLength; + const playheadPosition = state.framesToPixels(frame); + const scrollLeft = playheadPosition - sizes.viewLength / 2; const newOffset = clamp(0, maxOffset, scrollLeft); containerRef.current.scrollLeft = newOffset; setOffset(newOffset); }, - [state.fullLength, rect, scale], + [sizes], ), ); @@ -97,10 +131,10 @@ export function Timeline() { }, [scale]); return ( - +
setOffset((event.target as HTMLElement).scrollLeft) @@ -108,11 +142,22 @@ export function Timeline() { onWheel={event => { if (event.shiftKey) return; - const ratio = 1 - Math.sign(event.deltaY) * ZOOM_SPEED; - const newScale = scale * ratio < 1 ? 1 : scale * ratio; + let ratio = 1 - Math.sign(event.deltaY) * ZOOM_SPEED; + let newScale = scale * ratio; + if (newScale < ZOOM_MIN) { + newScale = ZOOM_MIN; + ratio = newScale / scale; + } + if (newScale > ZOOM_MAX) { + newScale = ZOOM_MAX; + ratio = newScale / scale; + } + if (newScale === scale) { + return; + } - const pointer = offset + event.x - rect.x; - const newTrackSize = rect.width * newScale; + const pointer = offset - sizes.paddingLeft + event.x - rect.x; + const newTrackSize = rect.width * newScale * +rect.width; const maxOffset = newTrackSize - rect.width; const newOffset = clamp( 0, @@ -131,31 +176,45 @@ export function Timeline() { event.x - rect.x + newOffset }px`; }} - onClick={event => { - player.requestSeek( - Math.floor( - ((offset + event.x - rect.x) / state.fullLength) * duration, - ), - ); - }} onMouseMove={event => { playheadRef.current.style.left = `${event.x - rect.x + offset}px`; }} >
{ + if (event.button === 0) { + player.requestSeek( + Math.floor( + state.pixelsToFrames( + offset - sizes.paddingLeft + event.x - rect.x, + ), + ), + ); + } + }} > - - - - - +
+ + +
+ + + +
+ +
-
- + ); } diff --git a/packages/ui/src/components/timeline/TimelineContext.ts b/packages/ui/src/components/timeline/TimelineContext.ts deleted file mode 100644 index e021fcea..00000000 --- a/packages/ui/src/components/timeline/TimelineContext.ts +++ /dev/null @@ -1,54 +0,0 @@ -import {createContext} from 'preact'; - -export interface TimelineState { - /** - * Length of the entire timeline in pixels - */ - fullLength: number; - /** - * Length of the visible area in pixels. - */ - viewLength: number; - /** - * The left offset of the view in pixels. - */ - offset: number; - /** - * How zoomed in the timeline is. - */ - scale: number; - /** - * First frame covered by the infinite scroll. - */ - startFrame: number; - /** - * Last frame covered by the infinite scroll. - */ - endFrame: number; - /** - * Frames per pixel rounded to the closest power of two. - */ - density: number; - /** - * Frames per timeline segment. - */ - segmentDensity: number; - /** - * Animation duration in frames. - */ - duration: number; -} - -const TimelineContext = createContext({ - fullLength: 0, - viewLength: 0, - offset: 0, - scale: 1, - density: 1, - segmentDensity: 1, - duration: 0, - endFrame: 0, - startFrame: 0, -}); - -export {TimelineContext}; diff --git a/packages/ui/src/components/timeline/TimestampTrack.tsx b/packages/ui/src/components/timeline/TimestampTrack.tsx deleted file mode 100644 index 852154c5..00000000 --- a/packages/ui/src/components/timeline/TimestampTrack.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import styles from './Timeline.module.scss'; - -import {useContext, useMemo} from 'preact/hooks'; -import {TimelineContext} from './TimelineContext'; - -export function TimestampTrack() { - const {fullLength, startFrame, endFrame, segmentDensity, duration} = - useContext(TimelineContext); - - const timestamps = useMemo(() => { - const timestamps = []; - for (let i = startFrame; i < endFrame; i += segmentDensity) { - timestamps.push({ - time: i, - style: {left: `${(i / duration) * fullLength}px`}, - }); - } - return timestamps; - }, [startFrame, endFrame, duration, fullLength, segmentDensity]); - - return ( -
- {timestamps.map(value => ( -
- {value.time} -
- ))} -
- ); -} diff --git a/packages/ui/src/components/timeline/Timestamps.tsx b/packages/ui/src/components/timeline/Timestamps.tsx new file mode 100644 index 00000000..fc711f88 --- /dev/null +++ b/packages/ui/src/components/timeline/Timestamps.tsx @@ -0,0 +1,35 @@ +import styles from './Timeline.module.scss'; + +import {useMemo} from 'preact/hooks'; +import {useTimelineContext} from '../../contexts'; +import {classes} from '../../utils'; + +export function Timestamps() { + const { + framesToPercents, + firstVisibleFrame, + lastVisibleFrame, + segmentDensity, + } = useTimelineContext(); + + const timestamps = useMemo(() => { + const timestamps = []; + const clamped = Math.max(1, segmentDensity); + for (let i = firstVisibleFrame; i < lastVisibleFrame; i += clamped) { + timestamps.push( +
0 && (i / segmentDensity) % 2 !== 0, + ])} + style={{left: `${framesToPercents(i)}%`}} + key={i} + data-frame={i} + />, + ); + } + return timestamps; + }, [firstVisibleFrame, lastVisibleFrame, framesToPercents, segmentDensity]); + + return <>{timestamps}; +} diff --git a/packages/ui/src/contexts/index.ts b/packages/ui/src/contexts/index.ts index bb2ac52f..9af63c8d 100644 --- a/packages/ui/src/contexts/index.ts +++ b/packages/ui/src/contexts/index.ts @@ -1,2 +1,3 @@ export * from './player'; export * from './project'; +export * from './timeline'; diff --git a/packages/ui/src/contexts/timeline.tsx b/packages/ui/src/contexts/timeline.tsx new file mode 100644 index 00000000..63b4ed3c --- /dev/null +++ b/packages/ui/src/contexts/timeline.tsx @@ -0,0 +1,71 @@ +import {ComponentChildren, createContext} from 'preact'; +import {useContext} from 'preact/hooks'; + +export interface TimelineState { + /** + * Length of the visible area in pixels. + */ + viewLength: number; + /** + * Scroll offset from the left in pixels. Measured from frame 0. + */ + offset: number; + /** + * First frame covered by the infinite scroll. + */ + firstVisibleFrame: number; + /** + * Last frame covered by the infinite scroll. + */ + lastVisibleFrame: number; + /** + * Frames per pixel rounded to the closest power of two. + */ + density: number; + /** + * Frames per timeline segment. + */ + segmentDensity: number; + /** + * Convert frames to percents. + */ + framesToPercents: (value: number) => number; + /** + * Convert frames pixels + */ + framesToPixels: (value: number) => number; + /** + * Convert pixels to frames. + */ + pixelsToFrames: (value: number) => number; +} + +const TimelineContext = createContext({ + viewLength: 0, + offset: 0, + density: 1, + segmentDensity: 1, + lastVisibleFrame: 0, + firstVisibleFrame: 0, + framesToPercents: value => value, + framesToPixels: value => value, + pixelsToFrames: value => value, +}); + +export function TimelineContextProvider({ + state, + children, +}: { + state: TimelineState; + children: ComponentChildren; +}) { + return ( + + {children} + + ); +} + +export function useTimelineContext() { + return useContext(TimelineContext); +} diff --git a/packages/ui/src/hooks/useDrag.ts b/packages/ui/src/hooks/useDrag.ts index 0395de2a..708b7203 100644 --- a/packages/ui/src/hooks/useDrag.ts +++ b/packages/ui/src/hooks/useDrag.ts @@ -61,6 +61,7 @@ export function useDrag( const handleDrag = useCallback( (event: MouseEvent) => { if (button !== null && event.button !== button) return; + // FIXME Calling this in Firefox prevents elements from receiving the `:active` pseudo class. event.preventDefault(); event.stopPropagation(); setStartPosition({x: event.x, y: event.y});