mirror of
https://github.com/motion-canvas/motion-canvas.git
synced 2026-01-11 14:57:56 -05:00
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:
@@ -24,6 +24,7 @@ export function App() {
|
||||
return (
|
||||
<ResizeableLayout
|
||||
id={'main-timeline'}
|
||||
size={0.7}
|
||||
vertical
|
||||
start={
|
||||
<AppContext.Provider value={contextState}>
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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)),
|
||||
)}%`,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
|
||||
@@ -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`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
35
packages/ui/src/components/timeline/Timestamps.tsx
Normal file
35
packages/ui/src/components/timeline/Timestamps.tsx
Normal 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}</>;
|
||||
}
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from './player';
|
||||
export * from './project';
|
||||
export * from './timeline';
|
||||
|
||||
71
packages/ui/src/contexts/timeline.tsx
Normal file
71
packages/ui/src/contexts/timeline.tsx
Normal 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);
|
||||
}
|
||||
@@ -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});
|
||||
|
||||
Reference in New Issue
Block a user