594 lines
16 KiB
JavaScript
594 lines
16 KiB
JavaScript
// Timeline V2 - New timeline implementation for AnimationData curve-based system
|
|
|
|
import { backgroundColor, foregroundColor, shadow, labelColor, scrubberColor } from "./styles.js"
|
|
|
|
/**
|
|
* TimelineState - Global state for timeline display and interaction
|
|
*/
|
|
class TimelineState {
|
|
constructor(framerate = 24) {
|
|
// Time format settings
|
|
this.timeFormat = 'frames' // 'frames' | 'seconds' | 'measures'
|
|
this.framerate = framerate
|
|
|
|
// Zoom and viewport
|
|
this.pixelsPerSecond = 100 // Zoom level - how many pixels per second of animation
|
|
this.viewportStartTime = 0 // Horizontal scroll position (in seconds)
|
|
|
|
// Playhead
|
|
this.currentTime = 0 // Current time (in seconds)
|
|
|
|
// Ruler settings
|
|
this.rulerHeight = 30 // Height of time ruler in pixels
|
|
|
|
// Snapping (Phase 5)
|
|
this.snapToFrames = false // Whether to snap keyframes to frame boundaries
|
|
}
|
|
|
|
/**
|
|
* Convert time (seconds) to pixel position
|
|
*/
|
|
timeToPixel(time) {
|
|
return (time - this.viewportStartTime) * this.pixelsPerSecond
|
|
}
|
|
|
|
/**
|
|
* Convert pixel position to time (seconds)
|
|
*/
|
|
pixelToTime(pixel) {
|
|
return (pixel / this.pixelsPerSecond) + this.viewportStartTime
|
|
}
|
|
|
|
/**
|
|
* Convert time (seconds) to frame number
|
|
*/
|
|
timeToFrame(time) {
|
|
return Math.floor(time * this.framerate)
|
|
}
|
|
|
|
/**
|
|
* Convert frame number to time (seconds)
|
|
*/
|
|
frameToTime(frame) {
|
|
return frame / this.framerate
|
|
}
|
|
|
|
/**
|
|
* Calculate appropriate ruler interval based on zoom level
|
|
* Returns interval in seconds that gives ~50-100px spacing
|
|
*/
|
|
getRulerInterval() {
|
|
const targetPixelSpacing = 75 // Target pixels between major ticks
|
|
const timeSpacing = targetPixelSpacing / this.pixelsPerSecond // In seconds
|
|
|
|
// Standard interval options (in seconds)
|
|
const intervals = [
|
|
0.01, 0.02, 0.05, // 10ms, 20ms, 50ms
|
|
0.1, 0.2, 0.5, // 100ms, 200ms, 500ms
|
|
1, 2, 5, // 1s, 2s, 5s
|
|
10, 20, 30, 60, // 10s, 20s, 30s, 1min
|
|
120, 300, 600 // 2min, 5min, 10min
|
|
]
|
|
|
|
// Find closest interval
|
|
let bestInterval = intervals[0]
|
|
let bestDiff = Math.abs(timeSpacing - bestInterval)
|
|
|
|
for (let interval of intervals) {
|
|
const diff = Math.abs(timeSpacing - interval)
|
|
if (diff < bestDiff) {
|
|
bestDiff = diff
|
|
bestInterval = interval
|
|
}
|
|
}
|
|
|
|
return bestInterval
|
|
}
|
|
|
|
/**
|
|
* Calculate appropriate ruler interval for frame mode
|
|
* Returns interval in frames that gives ~50-100px spacing
|
|
*/
|
|
getRulerIntervalFrames() {
|
|
const targetPixelSpacing = 75
|
|
const pixelsPerFrame = this.pixelsPerSecond / this.framerate
|
|
const frameSpacing = targetPixelSpacing / pixelsPerFrame
|
|
|
|
// Standard frame intervals
|
|
const intervals = [1, 2, 5, 10, 20, 50, 100, 200, 500, 1000]
|
|
|
|
// Find closest interval
|
|
let bestInterval = intervals[0]
|
|
let bestDiff = Math.abs(frameSpacing - bestInterval)
|
|
|
|
for (let interval of intervals) {
|
|
const diff = Math.abs(frameSpacing - interval)
|
|
if (diff < bestDiff) {
|
|
bestDiff = diff
|
|
bestInterval = interval
|
|
}
|
|
}
|
|
|
|
return bestInterval
|
|
}
|
|
|
|
/**
|
|
* Format time for display based on current format setting
|
|
*/
|
|
formatTime(time) {
|
|
if (this.timeFormat === 'frames') {
|
|
return `${this.timeToFrame(time)}`
|
|
} else if (this.timeFormat === 'seconds') {
|
|
const minutes = Math.floor(time / 60)
|
|
const seconds = Math.floor(time % 60)
|
|
const ms = Math.floor((time % 1) * 10)
|
|
|
|
if (minutes > 0) {
|
|
return `${minutes}:${seconds.toString().padStart(2, '0')}`
|
|
} else {
|
|
return `${seconds}.${ms}s`
|
|
}
|
|
}
|
|
// measures format - TODO when DAW features added
|
|
return `${time.toFixed(2)}`
|
|
}
|
|
|
|
/**
|
|
* Zoom in (increase pixelsPerSecond)
|
|
*/
|
|
zoomIn(factor = 1.5) {
|
|
this.pixelsPerSecond *= factor
|
|
// Clamp to reasonable range
|
|
this.pixelsPerSecond = Math.min(this.pixelsPerSecond, 10000) // Max zoom
|
|
}
|
|
|
|
/**
|
|
* Zoom out (decrease pixelsPerSecond)
|
|
*/
|
|
zoomOut(factor = 1.5) {
|
|
this.pixelsPerSecond /= factor
|
|
// Clamp to reasonable range
|
|
this.pixelsPerSecond = Math.max(this.pixelsPerSecond, 10) // Min zoom
|
|
}
|
|
|
|
/**
|
|
* Snap time to nearest frame boundary (Phase 5)
|
|
*/
|
|
snapTime(time) {
|
|
if (!this.snapToFrames) {
|
|
return time
|
|
}
|
|
const frame = Math.round(time * this.framerate)
|
|
return frame / this.framerate
|
|
}
|
|
}
|
|
|
|
/**
|
|
* TimeRuler - Widget for displaying time ruler with adaptive intervals
|
|
*/
|
|
class TimeRuler {
|
|
constructor(timelineState) {
|
|
this.state = timelineState
|
|
this.height = timelineState.rulerHeight
|
|
}
|
|
|
|
/**
|
|
* Draw the time ruler
|
|
*/
|
|
draw(ctx, width) {
|
|
ctx.save()
|
|
|
|
// Background
|
|
ctx.fillStyle = backgroundColor
|
|
ctx.fillRect(0, 0, width, this.height)
|
|
|
|
// Determine interval based on current zoom and format
|
|
let interval, isFrameMode
|
|
if (this.state.timeFormat === 'frames') {
|
|
interval = this.state.getRulerIntervalFrames() // In frames
|
|
isFrameMode = true
|
|
} else {
|
|
interval = this.state.getRulerInterval() // In seconds
|
|
isFrameMode = false
|
|
}
|
|
|
|
// Calculate visible time range
|
|
const startTime = this.state.viewportStartTime
|
|
const endTime = this.state.pixelToTime(width)
|
|
|
|
// Draw tick marks and labels
|
|
if (isFrameMode) {
|
|
this.drawFrameTicks(ctx, width, interval, startTime, endTime)
|
|
} else {
|
|
this.drawSecondTicks(ctx, width, interval, startTime, endTime)
|
|
}
|
|
|
|
// Draw playhead (current time indicator)
|
|
this.drawPlayhead(ctx, width)
|
|
|
|
ctx.restore()
|
|
}
|
|
|
|
/**
|
|
* Draw tick marks for frame mode
|
|
*/
|
|
drawFrameTicks(ctx, width, interval, startTime, endTime) {
|
|
const startFrame = Math.floor(this.state.timeToFrame(startTime) / interval) * interval
|
|
const endFrame = Math.ceil(this.state.timeToFrame(endTime) / interval) * interval
|
|
|
|
ctx.fillStyle = labelColor
|
|
ctx.font = '11px sans-serif'
|
|
ctx.textAlign = 'center'
|
|
ctx.textBaseline = 'top'
|
|
|
|
for (let frame = startFrame; frame <= endFrame; frame += interval) {
|
|
const time = this.state.frameToTime(frame)
|
|
const x = this.state.timeToPixel(time)
|
|
|
|
if (x < 0 || x > width) continue
|
|
|
|
// Major tick
|
|
ctx.strokeStyle = foregroundColor
|
|
ctx.lineWidth = 1
|
|
ctx.beginPath()
|
|
ctx.moveTo(x, this.height - 10)
|
|
ctx.lineTo(x, this.height)
|
|
ctx.stroke()
|
|
|
|
// Label
|
|
ctx.fillText(frame.toString(), x, 2)
|
|
|
|
// Minor ticks (subdivisions)
|
|
const minorInterval = interval / 5
|
|
if (minorInterval >= 1) {
|
|
for (let i = 1; i < 5; i++) {
|
|
const minorFrame = frame + (minorInterval * i)
|
|
const minorTime = this.state.frameToTime(minorFrame)
|
|
const minorX = this.state.timeToPixel(minorTime)
|
|
|
|
if (minorX < 0 || minorX > width) continue
|
|
|
|
ctx.strokeStyle = shadow
|
|
ctx.beginPath()
|
|
ctx.moveTo(minorX, this.height - 5)
|
|
ctx.lineTo(minorX, this.height)
|
|
ctx.stroke()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Draw tick marks for second mode
|
|
*/
|
|
drawSecondTicks(ctx, width, interval, startTime, endTime) {
|
|
const startTick = Math.floor(startTime / interval) * interval
|
|
const endTick = Math.ceil(endTime / interval) * interval
|
|
|
|
ctx.fillStyle = labelColor
|
|
ctx.font = '11px sans-serif'
|
|
ctx.textAlign = 'center'
|
|
ctx.textBaseline = 'top'
|
|
|
|
for (let time = startTick; time <= endTick; time += interval) {
|
|
const x = this.state.timeToPixel(time)
|
|
|
|
if (x < 0 || x > width) continue
|
|
|
|
// Major tick
|
|
ctx.strokeStyle = foregroundColor
|
|
ctx.lineWidth = 1
|
|
ctx.beginPath()
|
|
ctx.moveTo(x, this.height - 10)
|
|
ctx.lineTo(x, this.height)
|
|
ctx.stroke()
|
|
|
|
// Label
|
|
ctx.fillText(this.state.formatTime(time), x, 2)
|
|
|
|
// Minor ticks (subdivisions)
|
|
const minorInterval = interval / 5
|
|
for (let i = 1; i < 5; i++) {
|
|
const minorTime = time + (minorInterval * i)
|
|
const minorX = this.state.timeToPixel(minorTime)
|
|
|
|
if (minorX < 0 || minorX > width) continue
|
|
|
|
ctx.strokeStyle = shadow
|
|
ctx.beginPath()
|
|
ctx.moveTo(minorX, this.height - 5)
|
|
ctx.lineTo(minorX, this.height)
|
|
ctx.stroke()
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Draw playhead (current time indicator)
|
|
*/
|
|
drawPlayhead(ctx, width) {
|
|
const x = this.state.timeToPixel(this.state.currentTime)
|
|
|
|
// Only draw if playhead is visible
|
|
if (x < 0 || x > width) return
|
|
|
|
ctx.strokeStyle = scrubberColor
|
|
ctx.lineWidth = 2
|
|
ctx.beginPath()
|
|
ctx.moveTo(x, 0)
|
|
ctx.lineTo(x, this.height)
|
|
ctx.stroke()
|
|
|
|
// Playhead handle (triangle at top)
|
|
ctx.fillStyle = scrubberColor
|
|
ctx.beginPath()
|
|
ctx.moveTo(x, 0)
|
|
ctx.lineTo(x - 6, 8)
|
|
ctx.lineTo(x + 6, 8)
|
|
ctx.closePath()
|
|
ctx.fill()
|
|
}
|
|
|
|
/**
|
|
* Hit test for playhead dragging (no longer used, kept for potential future use)
|
|
*/
|
|
hitTestPlayhead(x, y) {
|
|
const playheadX = this.state.timeToPixel(this.state.currentTime)
|
|
const distance = Math.abs(x - playheadX)
|
|
|
|
// 10px tolerance for hitting playhead
|
|
return distance < 10 && y >= 0 && y <= this.height
|
|
}
|
|
|
|
/**
|
|
* Handle mouse down - start dragging playhead
|
|
*/
|
|
mousedown(x, y) {
|
|
// Clicking anywhere in the ruler moves the playhead there
|
|
this.state.currentTime = this.state.pixelToTime(x)
|
|
this.state.currentTime = Math.max(0, this.state.currentTime)
|
|
this.draggingPlayhead = true
|
|
return true
|
|
}
|
|
|
|
/**
|
|
* Handle mouse move - drag playhead
|
|
*/
|
|
mousemove(x, y) {
|
|
if (this.draggingPlayhead) {
|
|
const newTime = this.state.pixelToTime(x);
|
|
this.state.currentTime = Math.max(0, newTime);
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
/**
|
|
* Handle mouse up - stop dragging
|
|
*/
|
|
mouseup(x, y) {
|
|
if (this.draggingPlayhead) {
|
|
this.draggingPlayhead = false
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
}
|
|
|
|
/**
|
|
* TrackHierarchy - Builds and manages hierarchical track structure from GraphicsObject
|
|
* Phase 2: Track hierarchy display
|
|
*/
|
|
class TrackHierarchy {
|
|
constructor() {
|
|
this.tracks = [] // Flat list of tracks for rendering
|
|
this.trackHeight = 30 // Default track height in pixels
|
|
}
|
|
|
|
/**
|
|
* Build track list from GraphicsObject layers
|
|
* Creates a flattened list of tracks for rendering, maintaining hierarchy info
|
|
*/
|
|
buildTracks(graphicsObject) {
|
|
this.tracks = []
|
|
|
|
if (!graphicsObject || !graphicsObject.children) {
|
|
return
|
|
}
|
|
|
|
// Iterate through layers (GraphicsObject.children are Layers)
|
|
for (let layer of graphicsObject.children) {
|
|
// Add layer track
|
|
const layerTrack = {
|
|
type: 'layer',
|
|
object: layer,
|
|
name: layer.name || 'Layer',
|
|
indent: 0,
|
|
collapsed: layer.collapsed || false,
|
|
visible: layer.visible !== false
|
|
}
|
|
this.tracks.push(layerTrack)
|
|
|
|
// If layer is not collapsed, add its children
|
|
if (!layerTrack.collapsed) {
|
|
// Add child GraphicsObjects (nested groups)
|
|
if (layer.children) {
|
|
for (let child of layer.children) {
|
|
this.addObjectTrack(child, 1)
|
|
}
|
|
}
|
|
|
|
// Add shapes (grouped by shapeId for shape tweening)
|
|
if (layer.shapes) {
|
|
// Group shapes by shapeId
|
|
const shapesByShapeId = new Map();
|
|
for (let shape of layer.shapes) {
|
|
if (!shapesByShapeId.has(shape.shapeId)) {
|
|
shapesByShapeId.set(shape.shapeId, []);
|
|
}
|
|
shapesByShapeId.get(shape.shapeId).push(shape);
|
|
}
|
|
|
|
// Add one track per unique shapeId
|
|
for (let [shapeId, shapes] of shapesByShapeId) {
|
|
// Use the first shape as the representative for the track
|
|
this.addShapeTrack(shapes[0], 1, shapeId, shapes)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Add audio tracks (after visual layers)
|
|
if (graphicsObject.audioTracks) {
|
|
for (let audioTrack of graphicsObject.audioTracks) {
|
|
const audioTrackItem = {
|
|
type: 'audio',
|
|
object: audioTrack,
|
|
name: audioTrack.name || 'Audio',
|
|
indent: 0,
|
|
collapsed: audioTrack.collapsed || false,
|
|
visible: audioTrack.audible !== false
|
|
}
|
|
this.tracks.push(audioTrackItem)
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Recursively add object track and its children
|
|
*/
|
|
addObjectTrack(obj, indent) {
|
|
const track = {
|
|
type: 'object',
|
|
object: obj,
|
|
name: obj.name || obj.idx,
|
|
indent: indent,
|
|
collapsed: obj.trackCollapsed || false
|
|
}
|
|
this.tracks.push(track)
|
|
|
|
// If object is not collapsed, add its children
|
|
if (!track.collapsed && obj.children) {
|
|
for (let layer of obj.children) {
|
|
// Nested object's layers
|
|
const nestedLayerTrack = {
|
|
type: 'layer',
|
|
object: layer,
|
|
name: layer.name || 'Layer',
|
|
indent: indent + 1,
|
|
collapsed: layer.collapsed || false,
|
|
visible: layer.visible !== false
|
|
}
|
|
this.tracks.push(nestedLayerTrack)
|
|
|
|
if (!nestedLayerTrack.collapsed) {
|
|
// Add nested layer's children
|
|
if (layer.children) {
|
|
for (let child of layer.children) {
|
|
this.addObjectTrack(child, indent + 2)
|
|
}
|
|
}
|
|
if (layer.shapes) {
|
|
// Group shapes by shapeId
|
|
const shapesByShapeId = new Map();
|
|
for (let shape of layer.shapes) {
|
|
if (!shapesByShapeId.has(shape.shapeId)) {
|
|
shapesByShapeId.set(shape.shapeId, []);
|
|
}
|
|
shapesByShapeId.get(shape.shapeId).push(shape);
|
|
}
|
|
|
|
// Add one track per unique shapeId
|
|
for (let [shapeId, shapes] of shapesByShapeId) {
|
|
this.addShapeTrack(shapes[0], indent + 2, shapeId, shapes)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Add shape track (grouped by shapeId for shape tweening)
|
|
*/
|
|
addShapeTrack(shape, indent, shapeId, shapes) {
|
|
const track = {
|
|
type: 'shape',
|
|
object: shape, // Representative shape for display
|
|
shapeId: shapeId, // The shared shapeId
|
|
shapes: shapes, // All shape versions with this shapeId
|
|
name: shape.constructor.name || 'Shape',
|
|
indent: indent
|
|
}
|
|
this.tracks.push(track)
|
|
}
|
|
|
|
/**
|
|
* Calculate height for a specific track based on its curves mode (Phase 4)
|
|
*/
|
|
getTrackHeight(track) {
|
|
const baseHeight = this.trackHeight
|
|
|
|
// Only objects, shapes, and audio tracks can have curves
|
|
if (track.type !== 'object' && track.type !== 'shape' && track.type !== 'audio') {
|
|
return baseHeight
|
|
}
|
|
|
|
const obj = track.object
|
|
|
|
// Calculate additional height needed for curves
|
|
if (obj.curvesMode === 'keyframe') {
|
|
// Phase 6: Minimized mode should be compact - no extra height
|
|
// Keyframes are overlaid on the segment bar
|
|
return baseHeight
|
|
} else if (obj.curvesMode === 'curve') {
|
|
// Use the object's curvesHeight property
|
|
return baseHeight + (obj.curvesHeight || 150) + 10 // +10 for padding
|
|
}
|
|
|
|
return baseHeight
|
|
}
|
|
|
|
/**
|
|
* Calculate total height needed for all tracks
|
|
*/
|
|
getTotalHeight() {
|
|
let totalHeight = 0
|
|
for (let track of this.tracks) {
|
|
totalHeight += this.getTrackHeight(track)
|
|
}
|
|
return totalHeight
|
|
}
|
|
|
|
/**
|
|
* Get track at a given Y position
|
|
*/
|
|
getTrackAtY(y) {
|
|
let currentY = 0
|
|
for (let i = 0; i < this.tracks.length; i++) {
|
|
const track = this.tracks[i]
|
|
const trackHeight = this.getTrackHeight(track)
|
|
|
|
if (y >= currentY && y < currentY + trackHeight) {
|
|
return track
|
|
}
|
|
|
|
currentY += trackHeight
|
|
}
|
|
return null
|
|
}
|
|
|
|
/**
|
|
* Get Y position for a specific track index (Phase 4)
|
|
*/
|
|
getTrackY(trackIndex) {
|
|
let y = 0
|
|
for (let i = 0; i < trackIndex && i < this.tracks.length; i++) {
|
|
y += this.getTrackHeight(this.tracks[i])
|
|
}
|
|
return y
|
|
}
|
|
}
|
|
|
|
export { TimelineState, TimeRuler, TrackHierarchy }
|