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:
parent
6c79914ffb
commit
1936e91327
41
src/main.js
41
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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
204
src/widgets.js
204
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)
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue