From 1936e91327b0662d18636edd8107dccc3ec89fdb Mon Sep 17 00:00:00 2001 From: Skyler Lehmkuhl Date: Wed, 15 Oct 2025 01:47:18 -0400 Subject: [PATCH] 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 --- src/main.js | 41 ++++++++-- src/timeline.js | 23 +++--- src/widgets.js | 204 ++++++++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 240 insertions(+), 28 deletions(-) diff --git a/src/main.js b/src/main.js index 334edc8..b83aa45 100644 --- a/src/main.js +++ b/src/main.js @@ -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 - const deltaX = event.deltaX * config.scrollSpeed; + // 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; + 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; - // Update viewport horizontal scroll - timelineWidget.timelineState.viewportStartTime += deltaX / timelineWidget.timelineState.pixelsPerSecond; - timelineWidget.timelineState.viewportStartTime = Math.max(0, timelineWidget.timelineState.viewportStartTime); + // 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; diff --git a/src/timeline.js b/src/timeline.js index 76fd1fd..6094da4 100644 --- a/src/timeline.js +++ b/src/timeline.js @@ -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 diff --git a/src/widgets.js b/src/widgets.js index d6b9c59..908060e 100644 --- a/src/widgets.js +++ b/src/widgets.js @@ -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)