Implement Timeline V2 Phase 2: Track hierarchy with selection and scrolling

Phase 2 Implementation:
- Added TrackHierarchy class to build and manage hierarchical track structure
- Track display with expand/collapse triangles for layers and groups
- Hierarchical indentation for visual hierarchy
- Track selection syncs with stage selection (shapes, objects, layers)
- Vertical scrolling for track area when many tracks present
- Horizontal scrolling in ruler area for timeline navigation

Timeline Integration:
- Set TimelineV2 as default timeline on app load
- Timeline automatically updates when shapes added or grouped
- Trigger timeline redraw in renderLayers() for efficient batching

Selection System:
- Clicking tracks selects corresponding objects/shapes on stage
- Selected tracks highlighted in timeline
- Updates context.selection and context.shapeselection arrays
- Stores oldselection/oldshapeselection for undo support
- Calls updateUI() and updateMenu() to sync UI state

Visual Improvements:
- Use predefined colors from styles.js (no hardcoded colors)
- Alternating track background colors for readability
- Selection highlighting with predefined highlight color
- Type indicators for tracks: [L]ayer, [G]roup, [S]hape

Mouse Interactions:
- Click ruler area to move playhead
- Click track expand/collapse triangles to show/hide children
- Click track name to select object/shape
- Scroll wheel in ruler area for horizontal timeline scroll
- Scroll wheel in track area for vertical track list scroll
- Adjusts hit testing for vertical scroll offset
This commit is contained in:
Skyler Lehmkuhl 2025-10-15 01:47:18 -04:00
parent 6c79914ffb
commit 1936e91327
3 changed files with 240 additions and 28 deletions

View File

@ -467,6 +467,7 @@ let actions = {
undoStack.push({ name: "addShape", action: action });
actions.addShape.execute(action);
updateMenu();
updateLayers();
},
execute: (action) => {
let layer = pointerList[action.layer];
@ -1553,6 +1554,7 @@ let actions = {
undoStack.push({ name: "group", action: action });
actions.group.execute(action);
updateMenu();
updateLayers();
},
execute: (action) => {
let group = new GraphicsObject(action.groupUuid);
@ -4818,7 +4820,7 @@ window.addEventListener("DOMContentLoaded", () => {
rootPane,
10,
true,
createPane(panes.timeline),
createPane(panes.timelineV2),
);
let [stageAndTimeline, _infopanel] = splitPane(
panel,
@ -7394,6 +7396,11 @@ function timelineV2() {
canvas.addEventListener("wheel", (event) => {
event.preventDefault();
// Get mouse position
const rect = canvas.getBoundingClientRect();
const mouseX = event.clientX - rect.left;
const mouseY = event.clientY - rect.top;
// Check if this is a pinch-zoom gesture (ctrlKey is set on trackpad pinch)
if (event.ctrlKey) {
// Pinch zoom - zoom in/out based on deltaY
@ -7401,8 +7408,6 @@ function timelineV2() {
const oldPixelsPerSecond = timelineWidget.timelineState.pixelsPerSecond;
// Calculate the time under the mouse BEFORE zooming
const rect = canvas.getBoundingClientRect();
const mouseX = event.clientX - rect.left;
const mouseTimeBeforeZoom = timelineWidget.timelineState.pixelToTime(mouseX);
// Apply zoom
@ -7420,12 +7425,23 @@ function timelineV2() {
updateCanvasSize();
} else {
// Regular scroll - horizontal scroll for timeline
// Check if mouse is over the ruler area (horizontal scroll) or track area (vertical scroll)
if (mouseY <= timelineWidget.ruler.height) {
// Mouse over ruler - horizontal scroll for timeline
const deltaX = event.deltaX * config.scrollSpeed;
// Update viewport horizontal scroll
timelineWidget.timelineState.viewportStartTime += deltaX / timelineWidget.timelineState.pixelsPerSecond;
timelineWidget.timelineState.viewportStartTime = Math.max(0, timelineWidget.timelineState.viewportStartTime);
} else {
// Mouse over track area - vertical scroll for tracks
const deltaY = event.deltaY * config.scrollSpeed;
timelineWidget.trackScrollOffset -= deltaY;
// Clamp scroll offset
const trackAreaHeight = canvas.height - timelineWidget.ruler.height;
const totalTracksHeight = timelineWidget.trackHierarchy.getTotalHeight();
const maxScroll = Math.min(0, trackAreaHeight - totalTracksHeight);
timelineWidget.trackScrollOffset = Math.max(maxScroll, Math.min(0, timelineWidget.trackScrollOffset));
}
updateCanvasSize();
}
@ -7896,6 +7912,10 @@ function updateUI() {
uiDirty = true;
}
// Add updateUI and updateMenu to context so widgets can call them
context.updateUI = updateUI;
context.updateMenu = updateMenu;
function renderUI() {
for (let canvas of canvases) {
let ctx = canvas.getContext("2d");
@ -8016,6 +8036,11 @@ function updateLayers() {
}
function renderLayers() {
// Also trigger TimelineV2 redraw if it exists
if (context.timelineWidget?.requestRedraw) {
context.timelineWidget.requestRedraw();
}
for (let canvas of document.querySelectorAll(".timeline")) {
const width = canvas.width;
const height = canvas.height;

View File

@ -1,5 +1,7 @@
// 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
*/
@ -163,7 +165,7 @@ class TimeRuler {
ctx.save()
// Background
ctx.fillStyle = '#2a2a2a'
ctx.fillStyle = backgroundColor
ctx.fillRect(0, 0, width, this.height)
// Determine interval based on current zoom and format
@ -200,7 +202,7 @@ class TimeRuler {
const startFrame = Math.floor(this.state.timeToFrame(startTime) / interval) * interval
const endFrame = Math.ceil(this.state.timeToFrame(endTime) / interval) * interval
ctx.fillStyle = '#cccccc'
ctx.fillStyle = labelColor
ctx.font = '11px sans-serif'
ctx.textAlign = 'center'
ctx.textBaseline = 'top'
@ -212,7 +214,7 @@ class TimeRuler {
if (x < 0 || x > width) continue
// Major tick
ctx.strokeStyle = '#888888'
ctx.strokeStyle = foregroundColor
ctx.lineWidth = 1
ctx.beginPath()
ctx.moveTo(x, this.height - 10)
@ -232,7 +234,7 @@ class TimeRuler {
if (minorX < 0 || minorX > width) continue
ctx.strokeStyle = '#555555'
ctx.strokeStyle = shadow
ctx.beginPath()
ctx.moveTo(minorX, this.height - 5)
ctx.lineTo(minorX, this.height)
@ -249,7 +251,7 @@ class TimeRuler {
const startTick = Math.floor(startTime / interval) * interval
const endTick = Math.ceil(endTime / interval) * interval
ctx.fillStyle = '#cccccc'
ctx.fillStyle = labelColor
ctx.font = '11px sans-serif'
ctx.textAlign = 'center'
ctx.textBaseline = 'top'
@ -260,7 +262,7 @@ class TimeRuler {
if (x < 0 || x > width) continue
// Major tick
ctx.strokeStyle = '#888888'
ctx.strokeStyle = foregroundColor
ctx.lineWidth = 1
ctx.beginPath()
ctx.moveTo(x, this.height - 10)
@ -278,7 +280,7 @@ class TimeRuler {
if (minorX < 0 || minorX > width) continue
ctx.strokeStyle = '#555555'
ctx.strokeStyle = shadow
ctx.beginPath()
ctx.moveTo(minorX, this.height - 5)
ctx.lineTo(minorX, this.height)
@ -296,7 +298,7 @@ class TimeRuler {
// Only draw if playhead is visible
if (x < 0 || x > width) return
ctx.strokeStyle = '#ff0000'
ctx.strokeStyle = scrubberColor
ctx.lineWidth = 2
ctx.beginPath()
ctx.moveTo(x, 0)
@ -304,7 +306,7 @@ class TimeRuler {
ctx.stroke()
// Playhead handle (triangle at top)
ctx.fillStyle = '#ff0000'
ctx.fillStyle = scrubberColor
ctx.beginPath()
ctx.moveTo(x, 0)
ctx.lineTo(x - 6, 8)
@ -332,7 +334,6 @@ class TimeRuler {
this.state.currentTime = this.state.pixelToTime(x)
this.state.currentTime = Math.max(0, this.state.currentTime)
this.draggingPlayhead = true
console.log("TimeRuler: Set draggingPlayhead = true, currentTime =", this.state.currentTime);
return true
}
@ -340,11 +341,9 @@ class TimeRuler {
* Handle mouse move - drag playhead
*/
mousemove(x, y) {
console.log("TimeRuler.mousemove called, draggingPlayhead =", this.draggingPlayhead);
if (this.draggingPlayhead) {
const newTime = this.state.pixelToTime(x);
this.state.currentTime = Math.max(0, newTime);
console.log("TimeRuler: Updated currentTime to", this.state.currentTime);
return true
}
return false

View File

@ -1,6 +1,6 @@
import { backgroundColor, foregroundColor, frameWidth, highlight, layerHeight, shade, shadow } from "./styles.js";
import { backgroundColor, foregroundColor, frameWidth, highlight, layerHeight, shade, shadow, labelColor } from "./styles.js";
import { clamp, drawBorderedRect, drawCheckerboardBackground, hslToRgb, hsvToRgb, rgbToHex } from "./utils.js"
import { TimelineState, TimeRuler } from "./timeline.js"
import { TimelineState, TimeRuler, TrackHierarchy } from "./timeline.js"
function growBoundingBox(bboxa, bboxb) {
bboxa.x.min = Math.min(bboxa.x.min, bboxb.x.min);
@ -520,6 +520,7 @@ class TimelineWindow extends ScrollableWindow {
/**
* TimelineWindowV2 - New timeline widget using AnimationData curve-based system
* Phase 1: Time ruler with zoom-adaptive intervals and playhead
* Phase 2: Track hierarchy display
*/
class TimelineWindowV2 extends Widget {
constructor(x, y, context) {
@ -534,48 +535,235 @@ class TimelineWindowV2 extends Widget {
// Create time ruler widget
this.ruler = new TimeRuler(this.timelineState)
// Create track hierarchy manager
this.trackHierarchy = new TrackHierarchy()
// Track if we're dragging playhead
this.draggingPlayhead = false
// Vertical scroll offset for track hierarchy
this.trackScrollOffset = 0
}
draw(ctx) {
ctx.save()
// Draw background
ctx.fillStyle = '#1e1e1e'
ctx.fillStyle = backgroundColor
ctx.fillRect(0, 0, this.width, this.height)
// Draw time ruler at top
this.ruler.draw(ctx, this.width)
// TODO Phase 2: Draw track hierarchy below ruler
// Phase 2: Build and draw track hierarchy
if (this.context.activeObject) {
this.trackHierarchy.buildTracks(this.context.activeObject)
this.drawTracks(ctx)
}
// TODO Phase 3: Draw segments
// TODO Phase 4: Draw minimized curves
ctx.restore()
}
/**
* Draw track hierarchy (Phase 2)
*/
drawTracks(ctx) {
ctx.save()
ctx.translate(0, this.ruler.height) // Start below ruler
// Clip to available track area
const trackAreaHeight = this.height - this.ruler.height
ctx.beginPath()
ctx.rect(0, 0, this.width, trackAreaHeight)
ctx.clip()
// Apply vertical scroll offset
ctx.translate(0, this.trackScrollOffset)
const indentSize = 20 // Pixels per indent level
for (let i = 0; i < this.trackHierarchy.tracks.length; i++) {
const track = this.trackHierarchy.tracks[i]
const y = i * this.trackHierarchy.trackHeight
// Check if this track is selected
const isSelected = this.isTrackSelected(track)
// Draw track background (alternating colors or selected highlight)
if (isSelected) {
ctx.fillStyle = highlight // Highlighted color for selected track
} else {
ctx.fillStyle = i % 2 === 0 ? backgroundColor : shade
}
ctx.fillRect(0, y, this.width, this.trackHierarchy.trackHeight)
// Draw track border
ctx.strokeStyle = shadow
ctx.lineWidth = 1
ctx.beginPath()
ctx.moveTo(0, y + this.trackHierarchy.trackHeight)
ctx.lineTo(this.width, y + this.trackHierarchy.trackHeight)
ctx.stroke()
// Calculate indent
const indent = track.indent * indentSize
// Draw expand/collapse indicator for layers and objects with children
if (track.type === 'layer' || (track.type === 'object' && track.object.children && track.object.children.length > 0)) {
const triangleX = indent + 8
const triangleY = y + this.trackHierarchy.trackHeight / 2
ctx.fillStyle = foregroundColor
ctx.beginPath()
if (track.collapsed) {
// Collapsed: right-pointing triangle ▶
ctx.moveTo(triangleX, triangleY - 4)
ctx.lineTo(triangleX + 6, triangleY)
ctx.lineTo(triangleX, triangleY + 4)
} else {
// Expanded: down-pointing triangle ▼
ctx.moveTo(triangleX - 4, triangleY - 2)
ctx.lineTo(triangleX + 4, triangleY - 2)
ctx.lineTo(triangleX, triangleY + 4)
}
ctx.closePath()
ctx.fill()
}
// Draw track name
ctx.fillStyle = labelColor
ctx.font = '12px sans-serif'
ctx.textAlign = 'left'
ctx.textBaseline = 'middle'
ctx.fillText(track.name, indent + 20, y + this.trackHierarchy.trackHeight / 2)
// Draw type indicator
ctx.fillStyle = foregroundColor
ctx.font = '10px sans-serif'
const typeText = track.type === 'layer' ? '[L]' : track.type === 'object' ? '[G]' : '[S]'
ctx.fillText(typeText, indent + 20 + ctx.measureText(track.name).width + 8, y + this.trackHierarchy.trackHeight / 2)
}
ctx.restore()
}
mousedown(x, y) {
console.log("TimelineV2 mousedown:", x, y, "ruler height:", this.ruler.height);
// Check if clicking in ruler area
if (y <= this.ruler.height) {
// Let the ruler handle the mousedown (for playhead dragging)
const hitPlayhead = this.ruler.mousedown(x, y);
console.log("Ruler mousedown returned:", hitPlayhead);
if (hitPlayhead) {
this.draggingPlayhead = true
this._globalEvents.add("mousemove")
this._globalEvents.add("mouseup")
console.log("Started dragging playhead");
return true
}
}
// Check if clicking in track area
const trackY = y - this.ruler.height
if (trackY >= 0) {
// Adjust for vertical scroll offset
const adjustedY = trackY - this.trackScrollOffset
const track = this.trackHierarchy.getTrackAtY(adjustedY)
if (track) {
const indentSize = 20
const indent = track.indent * indentSize
const triangleX = indent + 8
// Check if clicking on expand/collapse triangle
if (x >= triangleX - 8 && x <= triangleX + 14) {
// Toggle collapsed state
if (track.type === 'layer') {
track.object.collapsed = !track.object.collapsed
} else if (track.type === 'object') {
track.object.trackCollapsed = !track.object.trackCollapsed
}
// Rebuild tracks after collapsing/expanding
this.trackHierarchy.buildTracks(this.context.activeObject)
if (this.requestRedraw) this.requestRedraw()
return true
}
// Clicking elsewhere on track selects it
this.selectTrack(track)
if (this.requestRedraw) this.requestRedraw()
return true
}
}
return false
}
/**
* Check if a track is currently selected
*/
isTrackSelected(track) {
if (track.type === 'layer') {
return this.context.activeLayer === track.object
} else if (track.type === 'shape') {
return this.context.shapeselection?.includes(track.object)
} else if (track.type === 'object') {
return this.context.selection?.includes(track.object)
}
return false
}
/**
* Select a track and update the stage selection
*/
selectTrack(track) {
// Store old selection before changing
this.context.oldselection = this.context.selection
this.context.oldshapeselection = this.context.shapeselection
if (track.type === 'layer') {
// Find the index of this layer in the activeObject
const layerIndex = this.context.activeObject.children.indexOf(track.object)
if (layerIndex !== -1) {
this.context.activeObject.currentLayer = layerIndex
}
// Clear selections when selecting layer
this.context.selection = []
this.context.shapeselection = []
} else if (track.type === 'shape') {
// Find the layer this shape belongs to and select it
for (let i = 0; i < this.context.activeObject.allLayers.length; i++) {
const layer = this.context.activeObject.allLayers[i]
if (layer.shapes && layer.shapes.includes(track.object)) {
// Find index in children array
const layerIndex = this.context.activeObject.children.indexOf(layer)
if (layerIndex !== -1) {
this.context.activeObject.currentLayer = layerIndex
}
// Set shape selection
this.context.shapeselection = [track.object]
this.context.selection = []
break
}
}
} else if (track.type === 'object') {
// Select the GraphicsObject
this.context.selection = [track.object]
this.context.shapeselection = []
}
// Update the stage UI to reflect selection changes
if (this.context.updateUI) {
this.context.updateUI()
}
// Update menu to enable/disable menu items based on selection
if (this.context.updateMenu) {
this.context.updateMenu()
}
}
mousemove(x, y) {
if (this.draggingPlayhead) {
console.log("TimelineV2 mousemove while dragging:", x, y);
// Let the ruler handle the mousemove
this.ruler.mousemove(x, y)