feat(ui): timeline overhaul (#47)

This PR fixes and improves multiple aspects of the timeline:
- It's now possible to scroll past the end of the animation.
- The transition gradient no longer covers the name of the scene.
- Scene names now stick to the left of the screen when scrolling/zooming.
- It's no longer possible to zoom in indefinitely.
- The playhead now shows the current frame.
- Timestamps are placed tighter.
- The overall appearance of the timeline has changed.

Closes: #20
This commit is contained in:
Jacob
2022-08-18 16:28:25 +02:00
committed by GitHub
parent 93c934f9b5
commit 4232a60725
15 changed files with 401 additions and 279 deletions

View File

@@ -24,6 +24,7 @@ export function App() {
return (
<ResizeableLayout
id={'main-timeline'}
size={0.7}
vertical
start={
<AppContext.Provider value={contextState}>

View File

@@ -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<HTMLCanvasElement>();
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 (

View File

@@ -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
}%`,
),
)}%`,
}}
/>
<div
className={styles.labelClipTarget}
style={{
left: `${
((scene.firstFrame +
scene.project.secondsToFrames(event.targetTime)) /
duration) *
100
}%`,
left: `${framesToPercents(
scene.firstFrame + scene.project.secondsToFrames(event.targetTime),
)}%`,
}}
/>
<div
className={styles.labelClipStart}
style={{
left: `${
((scene.firstFrame +
scene.project.secondsToFrames(event.initialTime)) /
duration) *
100
}%`,
width: `${
(Math.max(0, scene.project.secondsToFrames(eventTime)) / duration) *
100
}%`,
left: `${framesToPercents(
scene.firstFrame + scene.project.secondsToFrames(event.initialTime),
)}%`,
width: `${Math.max(
0,
framesToPercents(scene.project.secondsToFrames(eventTime)),
)}%`,
}}
/>
</>

View File

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

View File

@@ -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 (
<div
className={styles.playhead}
data-frame={time.frame}
style={{
left: `${fullLength * time.completion}px`,
left: `${framesToPixels(time.frame)}px`,
}}
/>
);

View File

@@ -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() {
<div
style={{
flexDirection: start > 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() {
<div class={styles.handleSpacer} />
<Icon
onMouseDown={handleDragEnd}
onDblClick={console.log}
className={styles.handle}
type={IconType.dragIndicator}
/>

View File

@@ -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 (
<div
className={styles.sceneClip}
data-name={scene.name}
style={{
width: `${(cachedData.duration / state.duration) * 100}%`,
left: `${(cachedData.firstFrame / state.duration) * 100}%`,
width: `${framesToPercents(cachedData.duration)}%`,
}}
onMouseDown={event => {
if (event.button === 1) {
@@ -46,18 +53,20 @@ function SceneClip({scene}: SceneClipProps) {
}}
>
<div className={styles.scene}>
<div className={styles.sceneName}>{scene.name}</div>
{cachedData.transitionDuration > 0 && (
<div
style={{
width: `${
(cachedData.transitionDuration / cachedData.duration) * 100
}%`,
}}
className={styles.transition}
/>
)}
<div className={styles.sceneName} style={nameStyle}>
{scene.name}
</div>
</div>
{cachedData.transitionDuration > 0 && (
<div
style={{
width: `${
(cachedData.transitionDuration / cachedData.duration) * 100
}%`,
}}
className={styles.transition}
/>
)}
</div>
);
}

View File

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

View File

@@ -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<TimelineState>(() => {
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 (
<TimelineContext.Provider value={state}>
<TimelineContextProvider state={state}>
<div className={styles.root}>
<div
className={styles.timeline}
className={styles.timelineWrapper}
ref={containerRef}
onScroll={event =>
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`;
}}
>
<div
className={styles.track}
style={{width: `${state.fullLength}px`}}
className={styles.timeline}
style={{width: `${sizes.fullLength}px`}}
onMouseUp={event => {
if (event.button === 0) {
player.requestSeek(
Math.floor(
state.pixelsToFrames(
offset - sizes.paddingLeft + event.x - rect.x,
),
),
);
}
}}
>
<RangeTrack />
<TimestampTrack />
<SceneTrack />
<LabelTrack />
<AudioTrack />
<div
className={styles.timelineContent}
style={{
width: `${sizes.playableLength}px`,
left: `${sizes.paddingLeft}px`,
}}
>
<RangeSelector />
<Timestamps />
<div className={styles.trackContainer}>
<SceneTrack />
<LabelTrack />
<AudioTrack />
</div>
<Playhead />
</div>
</div>
<div ref={playheadRef} className={styles.playheadPreview} />
<Playhead />
</div>
</div>
</TimelineContext.Provider>
</TimelineContextProvider>
);
}

View File

@@ -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<TimelineState>({
fullLength: 0,
viewLength: 0,
offset: 0,
scale: 1,
density: 1,
segmentDensity: 1,
duration: 0,
endFrame: 0,
startFrame: 0,
});
export {TimelineContext};

View File

@@ -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 (
<div className={styles.timestampTrack}>
{timestamps.map(value => (
<div className={styles.timestamp} style={value.style} key={value.time}>
{value.time}
</div>
))}
</div>
);
}

View File

@@ -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(
<div
className={classes(styles.timestamp, [
styles.odd,
segmentDensity > 0 && (i / segmentDensity) % 2 !== 0,
])}
style={{left: `${framesToPercents(i)}%`}}
key={i}
data-frame={i}
/>,
);
}
return timestamps;
}, [firstVisibleFrame, lastVisibleFrame, framesToPercents, segmentDensity]);
return <>{timestamps}</>;
}

View File

@@ -1,2 +1,3 @@
export * from './player';
export * from './project';
export * from './timeline';

View File

@@ -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<TimelineState>({
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 (
<TimelineContext.Provider value={state}>
{children}
</TimelineContext.Provider>
);
}
export function useTimelineContext() {
return useContext(TimelineContext);
}

View File

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