From 87d2036f07bf43cd5c26063ac2b4043e77cb16ef Mon Sep 17 00:00:00 2001 From: Skyler Lehmkuhl Date: Wed, 15 Oct 2025 19:08:49 -0400 Subject: [PATCH] Complete Phase 5: Timeline curve interaction and nested animation support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 5 adds interactive curve editing, proper interpolation visualization, and automatic segment keyframe management for nested animations. Timeline curve interaction features: - Add keyframe creation by clicking in expanded curve view - Implement keyframe dragging with snapping support - Add multi-keyframe selection (Shift/Ctrl modifiers) - Support constrained dragging (Shift: vertical, Ctrl: horizontal) - Add keyframe deletion via right-click context menu - Display hover tooltips showing keyframe values - Respect interpolation modes in curve visualization: * Linear: straight lines * Bezier: smooth curves with tangent handles * Step/Hold: horizontal hold then vertical jump * Zero: jump to zero and back Nested animation improvements: - Add bidirectional parent references: * Layer.parentObject → GraphicsObject * AnimationData.parentLayer → Layer * GraphicsObject.parentLayer → Layer - Auto-update segment keyframes when nested animation duration changes - Update both time and value of segment end keyframe - Fix parameter ordering (required before optional) in constructors Bug fixes: - Fix nested object rendering offset (transformCanvas applied twice) - Fix curve visualization ignoring interpolation mode --- src/main.js | 640 ++++++++++++++++--- src/timeline.js | 73 ++- src/widgets.js | 1595 ++++++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 2202 insertions(+), 106 deletions(-) diff --git a/src/main.js b/src/main.js index b83aa45..18f2ef6 100644 --- a/src/main.js +++ b/src/main.js @@ -75,6 +75,7 @@ const { } = window.__TAURI__.dialog; const { documentDir, join, basename, appLocalDataDir } = window.__TAURI__.path; const { Menu, MenuItem, PredefinedMenuItem, Submenu } = window.__TAURI__.menu; +const { PhysicalPosition, LogicalPosition } = window.__TAURI__.dpi; const { getCurrentWindow } = window.__TAURI__.window; const { getVersion } = window.__TAURI__.app; @@ -348,6 +349,7 @@ let context = { dragDirection: undefined, zoomLevel: 1, timelineWidget: null, // Reference to TimelineWindowV2 widget for zoom controls + config: null, // Reference to config object (set after config is initialized) }; let config = { @@ -403,6 +405,7 @@ async function loadConfig() { // const configData = await readTextFile(configPath); const configData = localStorage.getItem("lightningbeamConfig") || "{}"; config = deepMerge({ ...config }, JSON.parse(configData)); + context.config = config; // Make config accessible to widgets via context updateUI(); } catch (error) { console.log("Error loading config, returning default config:", error); @@ -1736,6 +1739,7 @@ let actions = { for (let objectIdx of action.objects) { let object = pointerList[objectIdx]; layer.children.splice(layer.children.indexOf(object), 1); + object.parentLayer = layer; layer.children.push(object); } updateUI(); @@ -1932,6 +1936,27 @@ function uuidv4() { ).toString(16), ); } + +/** + * Generate a consistent pastel color from a UUID string + * Uses hash of UUID to ensure same UUID always produces same color + */ +function uuidToColor(uuid) { + // Simple hash function + let hash = 0; + for (let i = 0; i < uuid.length; i++) { + hash = uuid.charCodeAt(i) + ((hash << 5) - hash); + hash = hash & hash; // Convert to 32-bit integer + } + + // Generate HSL color with fixed saturation and lightness for pastel appearance + const hue = Math.abs(hash % 360); + const saturation = 65; // Medium saturation for pleasant pastels + const lightness = 70; // Light enough to be pastel but readable + + return `hsl(${hue}, ${saturation}%, ${lightness}%)`; +} + function vectorDist(a, b) { return Math.sqrt((a.x - b.x) * (a.x - b.x) + (a.y - b.y) * (a.y - b.y)); } @@ -2326,9 +2351,10 @@ class Keyframe { } class AnimationCurve { - constructor(parameter, uuid = undefined) { + constructor(parameter, uuid = undefined, parentAnimationData = null) { this.parameter = parameter; // e.g., "x", "y", "rotation", "scale_x", "exists" this.keyframes = []; // Always kept sorted by time + this.parentAnimationData = parentAnimationData; // Reference to parent AnimationData for duration updates if (!uuid) { this.idx = uuidv4(); } else { @@ -2337,20 +2363,106 @@ class AnimationCurve { } addKeyframe(keyframe) { - this.keyframes.push(keyframe); - // Keep sorted by time - this.keyframes.sort((a, b) => a.time - b.time); + // Time resolution based on framerate - half a frame's duration + // This can be exposed via UI later + const framerate = context.config?.framerate || 24; + const timeResolution = (1 / framerate) / 2; + + // Check if there's already a keyframe within the time resolution + const existingKeyframe = this.getKeyframeAtTime(keyframe.time, timeResolution); + + if (existingKeyframe) { + // Update the existing keyframe's value instead of adding a new one + existingKeyframe.value = keyframe.value; + existingKeyframe.interpolation = keyframe.interpolation; + if (keyframe.easeIn) existingKeyframe.easeIn = keyframe.easeIn; + if (keyframe.easeOut) existingKeyframe.easeOut = keyframe.easeOut; + } else { + // Add new keyframe + this.keyframes.push(keyframe); + // Keep sorted by time + this.keyframes.sort((a, b) => a.time - b.time); + } + + // Update animation duration after adding keyframe + if (this.parentAnimationData) { + this.parentAnimationData.updateDuration(); + } } removeKeyframe(keyframe) { const index = this.keyframes.indexOf(keyframe); if (index >= 0) { this.keyframes.splice(index, 1); + + // Update animation duration after removing keyframe + if (this.parentAnimationData) { + this.parentAnimationData.updateDuration(); + } } } - getKeyframeAtTime(time) { - return this.keyframes.find(kf => kf.time === time); + getKeyframeAtTime(time, timeResolution = 0) { + if (this.keyframes.length === 0) return null; + + // If no tolerance, use exact match with binary search + if (timeResolution === 0) { + let left = 0; + let right = this.keyframes.length - 1; + + while (left <= right) { + const mid = Math.floor((left + right) / 2); + if (this.keyframes[mid].time === time) { + return this.keyframes[mid]; + } else if (this.keyframes[mid].time < time) { + left = mid + 1; + } else { + right = mid - 1; + } + } + return null; + } + + // With tolerance, find the closest keyframe within timeResolution + let left = 0; + let right = this.keyframes.length - 1; + let closest = null; + let closestDist = Infinity; + + // Binary search to find the insertion point + while (left <= right) { + const mid = Math.floor((left + right) / 2); + const dist = Math.abs(this.keyframes[mid].time - time); + + if (dist < closestDist) { + closestDist = dist; + closest = this.keyframes[mid]; + } + + if (this.keyframes[mid].time < time) { + left = mid + 1; + } else { + right = mid - 1; + } + } + + // Also check adjacent keyframes for closest match + if (left < this.keyframes.length) { + const dist = Math.abs(this.keyframes[left].time - time); + if (dist < closestDist) { + closestDist = dist; + closest = this.keyframes[left]; + } + } + if (right >= 0) { + const dist = Math.abs(this.keyframes[right].time - time); + if (dist < closestDist) { + closestDist = dist; + closest = this.keyframes[right]; + } + } + + return closestDist < timeResolution ? closest : null; } // Find the two keyframes that bracket the given time @@ -2426,6 +2538,10 @@ class AnimationCurve { } return prev.value; + case "zero": + // Return 0 for the entire interval (used for inactive segments) + return 0; + default: return prev.value; } @@ -2440,6 +2556,20 @@ class AnimationCurve { t * t * t; } + // Display color for this curve in timeline (based on parameter type) - Phase 4 + get displayColor() { + // Auto-determined from parameter name + if (this.parameter.endsWith('.x')) return '#7a00b3' // purple + if (this.parameter.endsWith('.y')) return '#ff00ff' // magenta + if (this.parameter.endsWith('.rotation')) return '#5555ff' // blue + if (this.parameter.endsWith('.scale_x')) return '#ffaa00' // orange + if (this.parameter.endsWith('.scale_y')) return '#ffff55' // yellow + if (this.parameter.endsWith('.exists')) return '#55ff55' // green + if (this.parameter.endsWith('.zOrder')) return '#55ffff' // cyan + if (this.parameter.endsWith('.frameNumber')) return '#ff5555' // red + return '#ffffff' // default white + } + static fromJSON(json) { const curve = new AnimationCurve(json.parameter, json.idx); for (let kfJson of json.keyframes || []) { @@ -2458,8 +2588,10 @@ class AnimationCurve { } class AnimationData { - constructor(uuid = undefined) { + constructor(parentLayer = null, uuid = undefined) { this.curves = {}; // parameter name -> AnimationCurve + this.duration = 0; // Duration in seconds (max time of all keyframes) + this.parentLayer = parentLayer; // Reference to parent Layer for updating segment keyframes if (!uuid) { this.idx = uuidv4(); } else { @@ -2473,7 +2605,7 @@ class AnimationData { getOrCreateCurve(parameter) { if (!this.curves[parameter]) { - this.curves[parameter] = new AnimationCurve(parameter); + this.curves[parameter] = new AnimationCurve(parameter, undefined, this); } return this.curves[parameter]; } @@ -2495,7 +2627,11 @@ class AnimationData { } setCurve(parameter, curve) { + // Set parent reference for duration tracking + curve.parentAnimationData = this; this.curves[parameter] = curve; + // Update duration after adding curve with keyframes + this.updateDuration(); } interpolate(parameter, time) { @@ -2513,11 +2649,78 @@ class AnimationData { return values; } - static fromJSON(json) { - const animData = new AnimationData(json.idx); - for (let param in json.curves || {}) { - animData.curves[param] = AnimationCurve.fromJSON(json.curves[param]); + /** + * Update the duration based on all keyframes + * Called automatically when keyframes are added/removed + */ + updateDuration() { + // Calculate max time from all keyframes in all curves + let maxTime = 0; + for (let parameter in this.curves) { + const curve = this.curves[parameter]; + if (curve.keyframes && curve.keyframes.length > 0) { + const lastKeyframe = curve.keyframes[curve.keyframes.length - 1]; + maxTime = Math.max(maxTime, lastKeyframe.time); + } } + + // Update this AnimationData's duration + this.duration = maxTime; + + // If this layer belongs to a nested group, update the segment keyframes in the parent + if (this.parentLayer && this.parentLayer.parentObject) { + this.updateParentSegmentKeyframes(); + } + } + + /** + * Update segment keyframes in parent layer when this layer's duration changes + * This ensures that nested group segments automatically resize when internal animation is added + */ + updateParentSegmentKeyframes() { + const parentObject = this.parentLayer.parentObject; + + // Get the layer that contains this nested object (parentObject.parentLayer) + if (!parentObject.parentLayer || !parentObject.parentLayer.animationData) { + return; + } + + const parentLayer = parentObject.parentLayer; + + // Get the frameNumber curve for this nested object using the correct naming convention + const curveName = `child.${parentObject.idx}.frameNumber`; + const frameNumberCurve = parentLayer.animationData.getCurve(curveName); + + if (!frameNumberCurve || frameNumberCurve.keyframes.length < 2) { + return; + } + + // Update the last keyframe to match the new duration + const lastKeyframe = frameNumberCurve.keyframes[frameNumberCurve.keyframes.length - 1]; + const newFrameValue = Math.ceil(this.duration * config.framerate) + 1; // +1 because frameNumber is 1-indexed + const newTime = this.duration; + + // Only update if the time or value actually changed + if (lastKeyframe.value !== newFrameValue || lastKeyframe.time !== newTime) { + lastKeyframe.value = newFrameValue; + lastKeyframe.time = newTime; + + // Re-sort keyframes in case the time change affects order + frameNumberCurve.keyframes.sort((a, b) => a.time - b.time); + + // Don't recursively call updateDuration to avoid infinite loop + } + } + + static fromJSON(json, parentLayer = null) { + const animData = new AnimationData(parentLayer, json.idx); + for (let param in json.curves || {}) { + const curve = AnimationCurve.fromJSON(json.curves[param]); + curve.parentAnimationData = animData; // Restore parent reference + animData.curves[param] = curve; + } + // Recalculate duration after loading all curves + animData.updateDuration(); return animData; } @@ -2534,7 +2737,7 @@ class AnimationData { } class Layer extends Widget { - constructor(uuid) { + constructor(uuid, parentObject = null) { super(0,0) if (!uuid) { this.idx = uuidv4(); @@ -2544,7 +2747,8 @@ class Layer extends Widget { this.name = "Layer"; // LEGACY: Keep frames array for backwards compatibility during migration this.frames = [new Frame("keyframe", this.idx + "-F1")]; - this.animationData = new AnimationData(); + this.animationData = new AnimationData(this); + this.parentObject = parentObject; // Reference to parent GraphicsObject (for nested objects) // this.frameNum = 0; this.visible = true; this.audible = true; @@ -2552,17 +2756,19 @@ class Layer extends Widget { this.children = [] this.shapes = [] } - static fromJSON(json) { - const layer = new Layer(json.idx); + static fromJSON(json, parentObject = null) { + const layer = new Layer(json.idx, parentObject); for (let i in json.children) { const child = json.children[i]; - layer.children.push(GraphicsObject.fromJSON(child)); + const childObject = GraphicsObject.fromJSON(child); + childObject.parentLayer = layer; + layer.children.push(childObject); } layer.name = json.name; // Load animation data if present (new system) if (json.animationData) { - layer.animationData = AnimationData.fromJSON(json.animationData); + layer.animationData = AnimationData.fromJSON(json.animationData, layer); } // Load shapes if present @@ -3244,9 +3450,7 @@ class Layer extends Widget { } break; case "rectangle": - console.log("Layer.mousemove rectangle - activeShape:", this.activeShape); if (this.activeShape) { - console.log("Updating rectangle shape"); this.activeShape.clear(); this.activeShape.addLine(x, this.activeShape.starty); this.activeShape.addLine(x, y); @@ -3256,7 +3460,6 @@ class Layer extends Widget { this.activeShape.starty, ); this.activeShape.update(); - console.log("Rectangle updated"); } break; case "ellipse": @@ -3712,6 +3915,11 @@ class Shape extends BaseShape { pointerList[this.idx] = this; this.regionIdx = 0; this.inProgress = true; + + // Timeline display settings (Phase 3) + this.showSegment = true // Show segment bar in timeline + this.curvesMode = 'hidden' // 'hidden' | 'minimized' | 'expanded' + this.curvesHeight = 150 // Height in pixels when curves are expanded } static fromJSON(json, parent) { let fillImage = undefined; @@ -3794,6 +4002,9 @@ class Shape extends BaseShape { } return json; } + get segmentColor() { + return uuidToColor(this.idx); + } addCurve(curve) { if (curve.color == undefined) { curve.color = context.strokeStyle; @@ -4153,13 +4364,21 @@ class GraphicsObject extends Widget { this.currentFrameNum = 0; // LEGACY: kept for backwards compatibility this.currentTime = 0; // New: continuous time for AnimationData curves this.currentLayer = 0; - this.children = [new Layer(uuid + "-L1")]; + this.children = [new Layer(uuid + "-L1", this)]; // this.layers = [new Layer(uuid + "-L1")]; this.audioLayers = []; // this.children = [] this.shapes = []; + // Parent reference for nested objects (set when added to a layer) + this.parentLayer = null + + // Timeline display settings (Phase 3) + this.showSegment = true // Show segment bar in timeline + this.curvesMode = 'hidden' // 'hidden' | 'minimized' | 'expanded' + this.curvesHeight = 150 // Height in pixels when curves are expanded + this._globalEvents.add("mousedown") this._globalEvents.add("mousemove") this._globalEvents.add("mouseup") @@ -4179,7 +4398,7 @@ class GraphicsObject extends Widget { graphicsObject.parent = pointerList[json.parent] } for (let layer of json.layers) { - graphicsObject.layers.push(Layer.fromJSON(layer)); + graphicsObject.layers.push(Layer.fromJSON(layer, graphicsObject)); } for (let audioLayer of json.audioLayers) { graphicsObject.audioLayers.push(AudioLayer.fromJSON(audioLayer)); @@ -4223,6 +4442,20 @@ class GraphicsObject extends Widget { get layers() { return this.children } + + /** + * Get the total duration of this GraphicsObject's animation + * Returns the maximum duration across all layers + */ + get duration() { + let maxDuration = 0; + for (let layer of this.layers) { + if (layer.animationData && layer.animationData.duration > maxDuration) { + maxDuration = layer.animationData.duration; + } + } + return maxDuration; + } get allLayers() { return [...this.audioLayers, ...this.layers]; } @@ -4240,6 +4473,9 @@ class GraphicsObject extends Widget { ) + 1 ); } + get segmentColor() { + return uuidToColor(this.idx); + } advanceFrame() { this.setFrameNum(this.currentFrameNum + 1); } @@ -4398,6 +4634,7 @@ class GraphicsObject extends Widget { let childRotation = layer.animationData.interpolate(`child.${idx}.rotation`, currentTime); let childScaleX = layer.animationData.interpolate(`child.${idx}.scale_x`, currentTime); let childScaleY = layer.animationData.interpolate(`child.${idx}.scale_y`, currentTime); + let childFrameNumber = layer.animationData.interpolate(`child.${idx}.frameNumber`, currentTime); if (childX !== null && childY !== null) { child.x = childX; @@ -4405,6 +4642,13 @@ class GraphicsObject extends Widget { child.rotation = childRotation || 0; child.scale_x = childScaleX || 1; child.scale_y = childScaleY || 1; + + // Set child's currentTime based on its frameNumber + // frameNumber 1 = time 0, frameNumber 2 = time 1/framerate, etc. + if (childFrameNumber !== null) { + child.currentTime = (childFrameNumber - 1) / config.framerate; + } + ctx.save(); child.draw(context); ctx.restore(); @@ -4705,6 +4949,7 @@ class GraphicsObject extends Widget { layer.children.push(object) object.parent = this; + object.parentLayer = layer; object.x = x; object.y = y; let idx = object.idx; @@ -4730,6 +4975,29 @@ class GraphicsObject extends Widget { scaleYCurve.addKeyframe(new Keyframe(time, 1, 'linear')); layer.animationData.setCurve(`child.${idx}.scale_y`, scaleYCurve); + // Initialize frameNumber curve with two keyframes defining the segment + // The segment length is based on the object's internal animation duration + let frameNumberCurve = new AnimationCurve(`child.${idx}.frameNumber`); + + // Get the object's animation duration (max time across all its layers) + const objectDuration = object.duration || 0; + const framerate = config.framerate; + + // Calculate the last frame number (frameNumber 1 = time 0, so add 1) + const lastFrameNumber = Math.max(1, Math.ceil(objectDuration * framerate) + 1); + + // Calculate the end time for the segment (minimum 1 frame duration) + const segmentDuration = Math.max(objectDuration, 1 / framerate); + const endTime = time + segmentDuration; + + // Start keyframe: frameNumber 1 at the current time, linear interpolation + frameNumberCurve.addKeyframe(new Keyframe(time, 1, 'linear')); + + // End keyframe: last frame at end time, zero interpolation (inactive after this) + frameNumberCurve.addKeyframe(new Keyframe(endTime, lastFrameNumber, 'zero')); + + layer.animationData.setCurve(`child.${idx}.frameNumber`, frameNumberCurve); + // LEGACY: Also update frame.keys for backwards compatibility let frame = this.currentFrame; if (frame) { @@ -4755,6 +5023,73 @@ class GraphicsObject extends Widget { } // this.children.splice(this.children.indexOf(childObject), 1); } + + /** + * Update this object's frameNumber curve in its parent layer based on child content + * This is called when shapes/children are added/modified within this object + */ + updateFrameNumberCurve() { + // Find parent layer that contains this object + if (!this.parent || !this.parent.animationData) return; + + const parentLayer = this.parent; + const frameNumberKey = `child.${this.idx}.frameNumber`; + + // Collect all keyframe times from this object's content + let allKeyframeTimes = new Set(); + + // Check all layers in this object + for (let layer of this.layers) { + if (!layer.animationData) continue; + + // Get keyframes from all shape curves + for (let shape of layer.shapes) { + const existsKey = `shape.${shape.idx}.exists`; + const existsCurve = layer.animationData.curves[existsKey]; + if (existsCurve && existsCurve.keyframes) { + for (let kf of existsCurve.keyframes) { + allKeyframeTimes.add(kf.time); + } + } + } + + // Get keyframes from all child object curves + for (let child of layer.children) { + const childFrameNumberKey = `child.${child.idx}.frameNumber`; + const childFrameNumberCurve = layer.animationData.curves[childFrameNumberKey]; + if (childFrameNumberCurve && childFrameNumberCurve.keyframes) { + for (let kf of childFrameNumberCurve.keyframes) { + allKeyframeTimes.add(kf.time); + } + } + } + } + + if (allKeyframeTimes.size === 0) return; + + // Sort times + const times = Array.from(allKeyframeTimes).sort((a, b) => a - b); + const firstTime = times[0]; + const lastTime = times[times.length - 1]; + + // Calculate frame numbers (1-based) + const framerate = this.framerate || 24; + const firstFrame = Math.floor(firstTime * framerate) + 1; + const lastFrame = Math.floor(lastTime * framerate) + 1; + + // Update or create frameNumber curve in parent layer + let frameNumberCurve = parentLayer.animationData.curves[frameNumberKey]; + if (!frameNumberCurve) { + frameNumberCurve = new AnimationCurve(frameNumberKey); + parentLayer.animationData.setCurve(frameNumberKey, frameNumberCurve); + } + + // Clear existing keyframes and add new ones + frameNumberCurve.keyframes = []; + frameNumberCurve.addKeyframe(new Keyframe(firstTime, firstFrame, 'hold')); + frameNumberCurve.addKeyframe(new Keyframe(lastTime, lastFrame, 'hold')); + } + addLayer(layer) { this.children.push(layer); } @@ -4859,7 +5194,7 @@ window.addEventListener("contextmenu", async (e) => { // items: [ // ], // }); - // menu.popup({ x: event.clientX, y: event.clientY }); + // menu.popup({ x: e.clientX, y: e.clientY }); }) window.addEventListener("keydown", (e) => { @@ -4946,23 +5281,25 @@ window.addEventListener("keydown", (e) => { function playPause() { playing = !playing; if (playing) { - if (context.activeObject.currentFrameNum >= context.activeObject.maxFrame - 1) { - context.activeObject.setFrameNum(0); + // Reset to start if we're at the end + const duration = context.activeObject.duration; + if (duration > 0 && context.activeObject.currentTime >= duration) { + context.activeObject.currentTime = 0; } + + // Start audio from current time for (let audioLayer of context.activeObject.audioLayers) { if (audioLayer.audible) { for (let i in audioLayer.sounds) { let sound = audioLayer.sounds[i]; - sound.player.start( - 0, - context.activeObject.currentFrameNum / config.framerate, - ); + sound.player.start(0, context.activeObject.currentTime); } } } lastFrameTime = performance.now(); advanceFrame(); } else { + // Stop audio for (let audioLayer of context.activeObject.audioLayers) { for (let i in audioLayer.sounds) { let sound = audioLayer.sounds[i]; @@ -4973,23 +5310,34 @@ function playPause() { } function advanceFrame() { - context.activeObject.advanceFrame(); - updateLayers(); - updateUI(); - if (playing) { - if ( - context.activeObject.currentFrameNum < - context.activeObject.maxFrame - 1 - ) { - const now = performance.now(); - const elapsedTime = now - lastFrameTime; + // Calculate elapsed time since last frame (in seconds) + const now = performance.now(); + const elapsedTime = (now - lastFrameTime) / 1000; + lastFrameTime = now; - // Calculate the time remaining for the next frame - const targetTimePerFrame = 1000 / config.framerate; - const timeToWait = Math.max(0, targetTimePerFrame - elapsedTime); - lastFrameTime = now + timeToWait; - setTimeout(advanceFrame, timeToWait); + // Advance currentTime + context.activeObject.currentTime += elapsedTime; + + // Sync timeline playhead position + if (context.timelineWidget?.timelineState) { + context.timelineWidget.timelineState.currentTime = context.activeObject.currentTime; + } + + // Redraw stage and timeline + updateUI(); + if (context.timelineWidget?.requestRedraw) { + context.timelineWidget.requestRedraw(); + } + + if (playing) { + const duration = context.activeObject.duration; + + // Check if we've reached the end + if (duration > 0 && context.activeObject.currentTime < duration) { + // Continue playing + requestAnimationFrame(advanceFrame); } else { + // Animation finished playing = false; for (let audioLayer of context.activeObject.audioLayers) { for (let i in audioLayer.sounds) { @@ -4998,8 +5346,6 @@ function advanceFrame() { } } } - } else { - updateMenu(); } } @@ -5550,6 +5896,118 @@ function addFrame() { function addKeyframe() { actions.addKeyframe.create(); } + +/** + * Add keyframes to AnimationData curves at the current playhead position + * For new timeline system (Phase 5) + */ +function addKeyframeAtPlayhead() { + // Get the timeline widget and current time + if (!context.timelineWidget) { + console.warn('Timeline widget not available'); + return; + } + + const currentTime = context.timelineWidget.timelineState.currentTime; + + // Determine which object to add keyframes to based on selection + let targetObjects = []; + + // If shapes are selected, add keyframes to those shapes + if (context.shapeselection && context.shapeselection.length > 0) { + targetObjects = context.shapeselection; + } + // If objects are selected, add keyframes to those objects + else if (context.selection && context.selection.length > 0) { + targetObjects = context.selection; + } + // Otherwise, if no selection, don't do anything + else { + console.log('No shapes or objects selected to add keyframes to'); + return; + } + + // For each selected object/shape, add keyframes to all its curves + for (let obj of targetObjects) { + // Determine if this is a shape or an object + const isShape = obj.constructor.name !== 'GraphicsObject'; + + // Find which layer this object/shape belongs to + let animationData = null; + + if (isShape) { + // For shapes, find the layer recursively + const findShapeLayer = (searchObj) => { + for (let layer of searchObj.children) { + if (layer.shapes && layer.shapes.includes(obj)) { + animationData = layer.animationData; + return true; + } + if (layer.children) { + for (let child of layer.children) { + if (findShapeLayer(child)) return true; + } + } + } + return false; + }; + findShapeLayer(context.activeObject); + } else { + // For objects (groups), find the parent layer + for (let layer of context.activeObject.allLayers) { + if (layer.children && layer.children.includes(obj)) { + animationData = layer.animationData; + break; + } + } + } + + if (!animationData) continue; + + // Get all curves for this object/shape by iterating through animationData.curves + const curves = []; + const prefix = isShape ? `shape.${obj.idx}.` : `child.${obj.idx}.`; + + for (let curveName in animationData.curves) { + if (curveName.startsWith(prefix)) { + curves.push(animationData.curves[curveName]); + } + } + + // For each curve, add a keyframe at the current time with the interpolated value + for (let curve of curves) { + // Get the current interpolated value at this time + const currentValue = curve.interpolate(currentTime); + + // Check if there's already a keyframe at this exact time + const existingKeyframe = curve.keyframes.find(kf => Math.abs(kf.time - currentTime) < 0.001); + + if (existingKeyframe) { + // Update the existing keyframe's value + existingKeyframe.value = currentValue; + console.log(`Updated keyframe at time ${currentTime} on ${curve.parameter}`); + } else { + // Create a new keyframe + const newKeyframe = new Keyframe( + currentTime, + currentValue, + 'linear' // Default to linear interpolation + ); + + curve.addKeyframe(newKeyframe); + console.log(`Added keyframe at time ${currentTime} on ${curve.parameter} with value ${currentValue}`); + } + } + } + + // Trigger a redraw of the timeline + if (context.timelineWidget.requestRedraw) { + context.timelineWidget.requestRedraw(); + } + + console.log(`Added keyframes at time ${currentTime} for ${targetObjects.length} object(s)`); +} + function deleteFrame() { let frame = context.activeObject.currentFrame; let layer = context.activeObject.activeLayer; @@ -6684,6 +7142,11 @@ function stage() { // Auto-keyframe: create/update keyframe at current time layer.animationData.addKeyframe(`child.${child.idx}.x`, new Keyframe(currentTime, newX, 'linear')); layer.animationData.addKeyframe(`child.${child.idx}.y`, new Keyframe(currentTime, newY, 'linear')); + + // Trigger timeline redraw + if (context.timelineWidget && context.timelineWidget.requestRedraw) { + context.timelineWidget.requestRedraw(); + } } } } else if (context.selectionRect) { @@ -7365,9 +7828,17 @@ function timelineV2() { const x = e.clientX - rect.left; const y = e.clientY - rect.top; + // Prevent default drag behavior on canvas + e.preventDefault(); + // Capture pointer to ensure we get move/up events even if cursor leaves canvas canvas.setPointerCapture(e.pointerId); + // Store event for modifier key access during clicks (for Shift-click multi-select) + timelineWidget.lastClickEvent = e; + // Also store for drag operations initially + timelineWidget.lastDragEvent = e; + timelineWidget.handleMouseEvent("mousedown", x, y); updateCanvasSize(); // Redraw after interaction }); @@ -7376,6 +7847,10 @@ function timelineV2() { const rect = canvas.getBoundingClientRect(); const x = e.clientX - rect.left; const y = e.clientY - rect.top; + + // Store event for modifier key access during drag (for Shift-drag constraint) + timelineWidget.lastDragEvent = e; + timelineWidget.handleMouseEvent("mousemove", x, y); updateCanvasSize(); // Redraw after interaction }); @@ -7392,6 +7867,23 @@ function timelineV2() { updateCanvasSize(); // Redraw after interaction }); + // Context menu (right-click) for deleting keyframes + canvas.addEventListener("contextmenu", (e) => { + e.preventDefault(); // Prevent default browser context menu + + const rect = canvas.getBoundingClientRect(); + const x = e.clientX - rect.left; + const y = e.clientY - rect.top; + + // Store event for access to clientX/clientY for menu positioning + timelineWidget.lastEvent = e; + // Also store as click event for consistency + timelineWidget.lastClickEvent = e; + + timelineWidget.handleMouseEvent("contextmenu", x, y); + updateCanvasSize(); // Redraw after interaction + }); + // Add wheel event for pinch-zoom support canvas.addEventListener("wheel", (event) => { event.preventDefault(); @@ -7407,8 +7899,11 @@ function timelineV2() { const zoomFactor = event.deltaY > 0 ? 0.95 : 1.05; const oldPixelsPerSecond = timelineWidget.timelineState.pixelsPerSecond; + // Adjust mouse position to account for track header offset + const timelineMouseX = mouseX - timelineWidget.trackHeaderWidth; + // Calculate the time under the mouse BEFORE zooming - const mouseTimeBeforeZoom = timelineWidget.timelineState.pixelToTime(mouseX); + const mouseTimeBeforeZoom = timelineWidget.timelineState.pixelToTime(timelineMouseX); // Apply zoom timelineWidget.timelineState.pixelsPerSecond *= zoomFactor; @@ -7417,31 +7912,30 @@ function timelineV2() { timelineWidget.timelineState.pixelsPerSecond = Math.max(10, Math.min(10000, timelineWidget.timelineState.pixelsPerSecond)); // Adjust viewport so the time under the mouse stays in the same place - // We want: pixelToTime(mouseX) == mouseTimeBeforeZoom - // pixelToTime(mouseX) = (mouseX / pixelsPerSecond) + viewportStartTime - // So: viewportStartTime = mouseTimeBeforeZoom - (mouseX / pixelsPerSecond) - timelineWidget.timelineState.viewportStartTime = mouseTimeBeforeZoom - (mouseX / timelineWidget.timelineState.pixelsPerSecond); + // We want: pixelToTime(timelineMouseX) == mouseTimeBeforeZoom + // pixelToTime(timelineMouseX) = (timelineMouseX / pixelsPerSecond) + viewportStartTime + // So: viewportStartTime = mouseTimeBeforeZoom - (timelineMouseX / pixelsPerSecond) + timelineWidget.timelineState.viewportStartTime = mouseTimeBeforeZoom - (timelineMouseX / timelineWidget.timelineState.pixelsPerSecond); timelineWidget.timelineState.viewportStartTime = Math.max(0, timelineWidget.timelineState.viewportStartTime); updateCanvasSize(); } else { - // 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; + // Regular scroll - handle both horizontal and vertical scrolling everywhere + const deltaX = event.deltaX * config.scrollSpeed; + const deltaY = event.deltaY * config.scrollSpeed; - // 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)); - } + // Horizontal scroll for timeline + timelineWidget.timelineState.viewportStartTime += deltaX / timelineWidget.timelineState.pixelsPerSecond; + timelineWidget.timelineState.viewportStartTime = Math.max(0, timelineWidget.timelineState.viewportStartTime); + + // Vertical scroll for tracks + 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(); } @@ -7937,8 +8431,7 @@ function renderUI() { ctx.fillStyle = "rgba(255,255,255,0.5)"; ctx.fillRect(0, 0, config.fileWidth, config.fileHeight); const transform = ctx.getTransform() - context.activeObject.transformCanvas(ctx) - context.activeObject.draw(context); + context.activeObject.draw(context, true); ctx.setTransform(transform) } if (context.activeShape) { @@ -9003,6 +9496,13 @@ async function renderMenu() { newBlankKeyframeMenuItem, deleteFrameMenuItem, duplicateKeyframeMenuItem, + { + text: "Add Keyframe at Playhead", + enabled: (context.selection && context.selection.length > 0) || + (context.shapeselection && context.shapeselection.length > 0), + action: addKeyframeAtPlayhead, + accelerator: "K", + }, { text: "Add Motion Tween", enabled: activeFrame, diff --git a/src/timeline.js b/src/timeline.js index 6094da4..d695d53 100644 --- a/src/timeline.js +++ b/src/timeline.js @@ -20,6 +20,9 @@ class TimelineState { // Ruler settings this.rulerHeight = 30 // Height of time ruler in pixels + + // Snapping (Phase 5) + this.snapToFrames = false // Whether to snap keyframes to frame boundaries } /** @@ -147,6 +150,17 @@ class TimelineState { // 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 + } } /** @@ -471,23 +485,74 @@ class TrackHierarchy { 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 and shapes can have curves + if (track.type !== 'object' && track.type !== 'shape') { + return baseHeight + } + + const obj = track.object + + // Calculate additional height needed for curves + if (obj.curvesMode === 'minimized') { + // Count curves for this object/shape + // For minimized mode: 15px per curve + // This is a simplified calculation - actual curve count would require AnimationData lookup + // For now, assume 3-5 curves per object (x, y, rotation, etc) + const estimatedCurves = 5 + return baseHeight + (estimatedCurves * 15) + 10 // +10 for padding + } else if (obj.curvesMode === 'expanded') { + // 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() { - return this.tracks.length * this.trackHeight + let totalHeight = 0 + for (let track of this.tracks) { + totalHeight += this.getTrackHeight(track) + } + return totalHeight } /** * Get track at a given Y position */ getTrackAtY(y) { - const trackIndex = Math.floor(y / this.trackHeight) - if (trackIndex >= 0 && trackIndex < this.tracks.length) { - return this.tracks[trackIndex] + 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 } diff --git a/src/widgets.js b/src/widgets.js index 908060e..fd31dc4 100644 --- a/src/widgets.js +++ b/src/widgets.js @@ -47,7 +47,8 @@ class Widget { "mousedown", "mousemove", "mouseup", - "dblclick" + "dblclick", + "contextmenu" ] if (eventTypes.indexOf(eventType)!=-1) { if (typeof(this[eventType]) == "function") { @@ -529,8 +530,11 @@ class TimelineWindowV2 extends Widget { this.width = 800 this.height = 400 - // Create shared timeline state (24 fps default) - this.timelineState = new TimelineState(24) + // Track header column width (fixed on left side) + this.trackHeaderWidth = 150 + + // Create shared timeline state using config framerate + this.timelineState = new TimelineState(context.config?.framerate || 24) // Create time ruler widget this.ruler = new TimeRuler(this.timelineState) @@ -543,6 +547,13 @@ class TimelineWindowV2 extends Widget { // Vertical scroll offset for track hierarchy this.trackScrollOffset = 0 + + // Phase 5: Curve interaction state + this.draggingKeyframe = null // {curve, keyframe, track} + this.selectedKeyframes = new Set() // Set of selected keyframe objects for multi-select + + // Hover state for showing keyframe values + this.hoveredKeyframe = null // {keyframe, x, y} - keyframe being hovered over and its screen position } draw(ctx) { @@ -552,32 +563,69 @@ class TimelineWindowV2 extends Widget { ctx.fillStyle = backgroundColor ctx.fillRect(0, 0, this.width, this.height) - // Draw time ruler at top - this.ruler.draw(ctx, this.width) + // Draw snapping checkbox in ruler header area (Phase 5) + this.drawSnappingCheckbox(ctx) + + // Draw time ruler at top, offset by track header width + ctx.save() + ctx.translate(this.trackHeaderWidth, 0) + this.ruler.draw(ctx, this.width - this.trackHeaderWidth) + ctx.restore() // Phase 2: Build and draw track hierarchy if (this.context.activeObject) { this.trackHierarchy.buildTracks(this.context.activeObject) + this.drawTrackHeaders(ctx) this.drawTracks(ctx) - } - // TODO Phase 3: Draw segments - // TODO Phase 4: Draw minimized curves + // Phase 3: Draw segments + this.drawSegments(ctx) + + // Phase 4: Draw curves + this.drawCurves(ctx) + } ctx.restore() } /** - * Draw track hierarchy (Phase 2) + * Draw snapping checkbox in ruler header area (Phase 5) */ - drawTracks(ctx) { + drawSnappingCheckbox(ctx) { + const checkboxSize = 14 + const checkboxX = 10 + const checkboxY = (this.ruler.height - checkboxSize) / 2 + + // Draw checkbox border + ctx.strokeStyle = foregroundColor + ctx.lineWidth = 1 + ctx.strokeRect(checkboxX, checkboxY, checkboxSize, checkboxSize) + + // Fill if snapping is enabled + if (this.timelineState.snapToFrames) { + ctx.fillStyle = foregroundColor + ctx.fillRect(checkboxX + 2, checkboxY + 2, checkboxSize - 4, checkboxSize - 4) + } + + // Draw label + ctx.fillStyle = labelColor + ctx.font = '11px sans-serif' + ctx.textAlign = 'left' + ctx.textBaseline = 'middle' + ctx.fillText('Snap', checkboxX + checkboxSize + 6, this.ruler.height / 2) + } + + /** + * Draw fixed track headers on the left (names, expand/collapse) + */ + drawTrackHeaders(ctx) { ctx.save() ctx.translate(0, this.ruler.height) // Start below ruler - // Clip to available track area + // Clip to track header area const trackAreaHeight = this.height - this.ruler.height ctx.beginPath() - ctx.rect(0, 0, this.width, trackAreaHeight) + ctx.rect(0, 0, this.trackHeaderWidth, trackAreaHeight) ctx.clip() // Apply vertical scroll offset @@ -587,44 +635,43 @@ class TimelineWindowV2 extends Widget { for (let i = 0; i < this.trackHierarchy.tracks.length; i++) { const track = this.trackHierarchy.tracks[i] - const y = i * this.trackHierarchy.trackHeight + const y = this.trackHierarchy.getTrackY(i) + const trackHeight = this.trackHierarchy.getTrackHeight(track) // Check if this track is selected const isSelected = this.isTrackSelected(track) - // Draw track background (alternating colors or selected highlight) + // Draw track header background if (isSelected) { - ctx.fillStyle = highlight // Highlighted color for selected track + ctx.fillStyle = highlight } else { ctx.fillStyle = i % 2 === 0 ? backgroundColor : shade } - ctx.fillRect(0, y, this.width, this.trackHierarchy.trackHeight) + ctx.fillRect(0, y, this.trackHeaderWidth, trackHeight) - // Draw track border + // Draw 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.moveTo(0, y + trackHeight) + ctx.lineTo(this.trackHeaderWidth, y + trackHeight) ctx.stroke() // Calculate indent const indent = track.indent * indentSize - // Draw expand/collapse indicator for layers and objects with children + // Draw expand/collapse indicator 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 + const triangleY = y + this.trackHierarchy.trackHeight / 2 // Use base height for triangle position 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) @@ -645,17 +692,696 @@ class TimelineWindowV2 extends Widget { 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) + + // Draw toggle buttons for object/shape tracks (Phase 3) + if (track.type === 'object' || track.type === 'shape') { + const buttonSize = 14 + const buttonY = y + (this.trackHierarchy.trackHeight - buttonSize) / 2 // Use base height for button position + let buttonX = this.trackHeaderWidth - 10 // Start from right edge + + // Curves mode button (rightmost) + buttonX -= buttonSize + ctx.strokeStyle = foregroundColor + ctx.lineWidth = 1 + ctx.strokeRect(buttonX, buttonY, buttonSize, buttonSize) + + // Draw symbol based on curves mode + ctx.fillStyle = foregroundColor + ctx.font = '10px sans-serif' + ctx.textAlign = 'center' + ctx.textBaseline = 'middle' + const curveSymbol = track.object.curvesMode === 'expanded' ? '~' : + track.object.curvesMode === 'minimized' ? '≈' : '-' + ctx.fillText(curveSymbol, buttonX + buttonSize / 2, buttonY + buttonSize / 2) + + // Segment visibility button + buttonX -= (buttonSize + 4) + ctx.strokeStyle = foregroundColor + ctx.lineWidth = 1 + ctx.strokeRect(buttonX, buttonY, buttonSize, buttonSize) + + // Fill if segment is visible + if (track.object.showSegment) { + ctx.fillStyle = foregroundColor + ctx.fillRect(buttonX + 2, buttonY + 2, buttonSize - 4, buttonSize - 4) + } + } + } + + // Draw right border of header column + ctx.strokeStyle = shadow + ctx.lineWidth = 2 + ctx.beginPath() + ctx.moveTo(this.trackHeaderWidth, 0) + ctx.lineTo(this.trackHeaderWidth, this.trackHierarchy.getTotalHeight()) + ctx.stroke() + + ctx.restore() + } + + /** + * Draw track backgrounds in timeline area (Phase 2) + */ + drawTracks(ctx) { + ctx.save() + ctx.translate(this.trackHeaderWidth, this.ruler.height) // Start after headers, below ruler + + // Clip to available track area + const trackAreaHeight = this.height - this.ruler.height + const trackAreaWidth = this.width - this.trackHeaderWidth + ctx.beginPath() + ctx.rect(0, 0, trackAreaWidth, trackAreaHeight) + ctx.clip() + + // Apply vertical scroll offset + ctx.translate(0, this.trackScrollOffset) + + for (let i = 0; i < this.trackHierarchy.tracks.length; i++) { + const track = this.trackHierarchy.tracks[i] + const y = this.trackHierarchy.getTrackY(i) + const trackHeight = this.trackHierarchy.getTrackHeight(track) + + // Draw track background (alternating colors only, no selection highlight) + ctx.fillStyle = i % 2 === 0 ? backgroundColor : shade + ctx.fillRect(0, y, trackAreaWidth, trackHeight) + + // Draw track border + ctx.strokeStyle = shadow + ctx.lineWidth = 1 + ctx.beginPath() + ctx.moveTo(0, y + trackHeight) + ctx.lineTo(trackAreaWidth, y + trackHeight) + ctx.stroke() } ctx.restore() } + /** + * Draw segments for shapes (Phase 3) + * Segments show the lifetime of shapes based on their exists curve keyframes + */ + drawSegments(ctx) { + ctx.save() + ctx.translate(this.trackHeaderWidth, this.ruler.height) // Start after headers, below ruler + + // Clip to available track area + const trackAreaHeight = this.height - this.ruler.height + const trackAreaWidth = this.width - this.trackHeaderWidth + ctx.beginPath() + ctx.rect(0, 0, trackAreaWidth, trackAreaHeight) + ctx.clip() + + // Apply vertical scroll offset + ctx.translate(0, this.trackScrollOffset) + + const frameDuration = 1 / this.timelineState.framerate + const minSegmentDuration = frameDuration // Minimum 1 frame + + // Iterate through tracks and draw segments + for (let i = 0; i < this.trackHierarchy.tracks.length; i++) { + const track = this.trackHierarchy.tracks[i] + + if (track.type === 'object') { + // Draw segments for GraphicsObjects (groups) using frameNumber curve + const obj = track.object + + // Skip if segment is hidden (Phase 3) + if (!obj.showSegment) continue + + const y = this.trackHierarchy.getTrackY(i) + const trackHeight = this.trackHierarchy.trackHeight // Use base height for segment + + // Find the parent layer that contains this object + let parentLayer = null + for (let layer of this.context.activeObject.allLayers) { + if (layer.children && layer.children.includes(obj)) { + parentLayer = layer + break + } + } + + if (!parentLayer || !parentLayer.animationData) continue + + // Get the frameNumber curve for this object + const frameNumberKey = `child.${obj.idx}.frameNumber` + const frameNumberCurve = parentLayer.animationData.curves[frameNumberKey] + + if (!frameNumberCurve || !frameNumberCurve.keyframes || frameNumberCurve.keyframes.length === 0) continue + + // Build segments from consecutive keyframes where frameNumber > 0 + let segmentStart = null + for (let j = 0; j < frameNumberCurve.keyframes.length; j++) { + const keyframe = frameNumberCurve.keyframes[j] + + if (keyframe.value > 0) { + // Start of a new segment or continuation + if (segmentStart === null) { + segmentStart = keyframe.time + } + + // Check if this is the last keyframe or if the next one ends the segment + const isLast = (j === frameNumberCurve.keyframes.length - 1) + const nextEndsSegment = !isLast && frameNumberCurve.keyframes[j + 1].value === 0 + + if (isLast || nextEndsSegment) { + // End of segment - draw it + const segmentEnd = nextEndsSegment ? frameNumberCurve.keyframes[j + 1].time : keyframe.time + minSegmentDuration + + const startX = this.timelineState.timeToPixel(segmentStart) + const endX = this.timelineState.timeToPixel(segmentEnd) + const segmentWidth = Math.max(endX - startX, this.timelineState.pixelsPerSecond * minSegmentDuration) + + // Draw segment with object's color + ctx.fillStyle = obj.segmentColor + ctx.fillRect( + startX, + y + 5, + segmentWidth, + trackHeight - 10 + ) + + // Draw border + ctx.strokeStyle = shadow + ctx.lineWidth = 1 + ctx.strokeRect( + startX, + y + 5, + segmentWidth, + trackHeight - 10 + ) + + // Draw object name if there's enough space + const minWidthForLabel = 40 // Minimum pixels to show label + if (segmentWidth >= minWidthForLabel) { + ctx.fillStyle = labelColor + ctx.font = '11px sans-serif' + ctx.textAlign = 'left' + ctx.textBaseline = 'middle' + + // Clip text to segment bounds + ctx.save() + ctx.beginPath() + ctx.rect(startX + 2, y + 5, segmentWidth - 4, trackHeight - 10) + ctx.clip() + + ctx.fillText(obj.name, startX + 4, y + trackHeight / 2) + ctx.restore() + } + + segmentStart = null // Reset for next segment + } + } + } + } else if (track.type === 'shape') { + const shape = track.object + + // Skip if segment is hidden (Phase 3) + if (!shape.showSegment) continue + + const y = this.trackHierarchy.getTrackY(i) + const trackHeight = this.trackHierarchy.trackHeight // Use base height for segment + + // Find the layer this shape belongs to (including nested layers in groups) + let shapeLayer = null + const findShapeLayer = (obj) => { + for (let layer of obj.children) { + if (layer.shapes && layer.shapes.includes(shape)) { + shapeLayer = layer + return true + } + // Recursively search in child objects + if (layer.children) { + for (let child of layer.children) { + if (findShapeLayer(child)) return true + } + } + } + return false + } + findShapeLayer(this.context.activeObject) + + if (!shapeLayer || !shapeLayer.animationData) continue + + // Get the exists curve for this shape + const existsCurveKey = `shape.${shape.idx}.exists` + const existsCurve = shapeLayer.animationData.curves[existsCurveKey] + + if (!existsCurve || !existsCurve.keyframes || existsCurve.keyframes.length === 0) continue + + // Build segments from consecutive keyframes where exists > 0 + let segmentStart = null + for (let j = 0; j < existsCurve.keyframes.length; j++) { + const keyframe = existsCurve.keyframes[j] + + if (keyframe.value > 0) { + // Start of a new segment or continuation + if (segmentStart === null) { + segmentStart = keyframe.time + } + + // Check if this is the last keyframe or if the next one ends the segment + const isLast = (j === existsCurve.keyframes.length - 1) + const nextEndsSegment = !isLast && existsCurve.keyframes[j + 1].value === 0 + + if (isLast || nextEndsSegment) { + // End of segment - draw it + const segmentEnd = nextEndsSegment ? existsCurve.keyframes[j + 1].time : keyframe.time + minSegmentDuration + + const startX = this.timelineState.timeToPixel(segmentStart) + const endX = this.timelineState.timeToPixel(segmentEnd) + const segmentWidth = Math.max(endX - startX, this.timelineState.pixelsPerSecond * minSegmentDuration) + + // Draw segment with shape's color + ctx.fillStyle = shape.segmentColor + ctx.fillRect( + startX, + y + 5, + segmentWidth, + trackHeight - 10 + ) + + // Draw border + ctx.strokeStyle = shadow + ctx.lineWidth = 1 + ctx.strokeRect( + startX, + y + 5, + segmentWidth, + trackHeight - 10 + ) + + // Draw shape name (constructor name) if there's enough space + const minWidthForLabel = 50 // Minimum pixels to show label + if (segmentWidth >= minWidthForLabel) { + const shapeName = shape.constructor.name || 'Shape' + ctx.fillStyle = labelColor + ctx.font = '11px sans-serif' + ctx.textAlign = 'left' + ctx.textBaseline = 'middle' + + // Clip text to segment bounds + ctx.save() + ctx.beginPath() + ctx.rect(startX + 2, y + 5, segmentWidth - 4, trackHeight - 10) + ctx.clip() + + ctx.fillText(shapeName, startX + 4, y + trackHeight / 2) + ctx.restore() + } + + segmentStart = null // Reset for next segment + } + } + } + } + } + + ctx.restore() + } + + /** + * Draw curves for animation parameters (Phase 4) + * Shows keyframe dots in minimized mode, full curves in expanded mode + */ + drawCurves(ctx) { + ctx.save() + ctx.translate(this.trackHeaderWidth, this.ruler.height) // Start after headers, below ruler + + // Clip to available track area + const trackAreaHeight = this.height - this.ruler.height + const trackAreaWidth = this.width - this.trackHeaderWidth + ctx.beginPath() + ctx.rect(0, 0, trackAreaWidth, trackAreaHeight) + ctx.clip() + + // Apply vertical scroll offset + ctx.translate(0, this.trackScrollOffset) + + // Iterate through tracks and draw curves + for (let i = 0; i < this.trackHierarchy.tracks.length; i++) { + const track = this.trackHierarchy.tracks[i] + + // Only draw curves for objects and shapes + if (track.type !== 'object' && track.type !== 'shape') continue + + const obj = track.object + + // Skip if curves are hidden + if (obj.curvesMode === 'hidden') continue + + const y = this.trackHierarchy.getTrackY(i) + + // Find the layer containing this object/shape to get AnimationData + let animationData = null + if (track.type === 'object') { + // For objects, get curves from parent layer + for (let layer of this.context.activeObject.allLayers) { + if (layer.children && layer.children.includes(obj)) { + animationData = layer.animationData + break + } + } + } else if (track.type === 'shape') { + // For shapes, find the layer recursively + const findShapeLayer = (searchObj) => { + for (let layer of searchObj.children) { + if (layer.shapes && layer.shapes.includes(obj)) { + animationData = layer.animationData + return true + } + if (layer.children) { + for (let child of layer.children) { + if (findShapeLayer(child)) return true + } + } + } + return false + } + findShapeLayer(this.context.activeObject) + } + + if (!animationData) continue + + // Get all curves for this object/shape + const curves = [] + for (let curveName in animationData.curves) { + const curve = animationData.curves[curveName] + + // Filter to only curves for this specific object/shape + if (track.type === 'object' && curveName.startsWith(`child.${obj.idx}.`)) { + curves.push(curve) + } else if (track.type === 'shape' && curveName.startsWith(`shape.${obj.idx}.`)) { + curves.push(curve) + } + } + + if (curves.length === 0) continue + + // Draw based on curves mode + if (obj.curvesMode === 'minimized') { + this.drawMinimizedCurves(ctx, curves, y) + } else if (obj.curvesMode === 'expanded') { + this.drawExpandedCurves(ctx, curves, y) + } + } + + ctx.restore() + } + + /** + * Draw minimized curves (keyframe dots only) + */ + drawMinimizedCurves(ctx, curves, trackY) { + const dotRadius = 3 + const rowHeight = 15 // Height per curve in minimized mode + const startY = trackY + 10 // Start below segment area + + for (let i = 0; i < curves.length; i++) { + const curve = curves[i] + const curveY = startY + (i * rowHeight) + + // Draw keyframe dots + for (let keyframe of curve.keyframes) { + const x = this.timelineState.timeToPixel(keyframe.time) + + ctx.fillStyle = curve.displayColor + ctx.beginPath() + ctx.arc(x, curveY, dotRadius, 0, 2 * Math.PI) + ctx.fill() + + // Draw outline for visibility + ctx.strokeStyle = shadow + ctx.lineWidth = 1 + ctx.stroke() + } + } + } + + /** + * Draw expanded curves (full Bezier visualization) + */ + drawExpandedCurves(ctx, curves, trackY) { + const curveHeight = 80 // Height allocated for curve visualization + const startY = trackY + 10 // Start below segment area + const padding = 5 + + // Calculate value range across all curves for auto-scaling + let minValue = Infinity + let maxValue = -Infinity + + for (let curve of curves) { + for (let keyframe of curve.keyframes) { + minValue = Math.min(minValue, keyframe.value) + maxValue = Math.max(maxValue, keyframe.value) + } + } + + // Add padding to the range + const valueRange = maxValue - minValue + const rangePadding = valueRange * 0.1 || 1 // 10% padding, or 1 if range is 0 + minValue -= rangePadding + maxValue += rangePadding + + // Draw background for curve area + ctx.fillStyle = shade + ctx.fillRect(0, startY, this.width - this.trackHeaderWidth, curveHeight) + + // Draw grid lines + ctx.strokeStyle = shadow + ctx.lineWidth = 1 + + // Horizontal grid lines (value axis) + for (let i = 0; i <= 4; i++) { + const y = startY + padding + (i * (curveHeight - 2 * padding) / 4) + ctx.beginPath() + ctx.moveTo(0, y) + ctx.lineTo(this.width - this.trackHeaderWidth, y) + ctx.stroke() + } + + // Helper function to convert value to Y position + const valueToY = (value) => { + const normalizedValue = (value - minValue) / (maxValue - minValue) + return startY + curveHeight - padding - (normalizedValue * (curveHeight - 2 * padding)) + } + + // Draw each curve + for (let curve of curves) { + if (curve.keyframes.length === 0) continue + + ctx.strokeStyle = curve.displayColor + ctx.fillStyle = curve.displayColor + ctx.lineWidth = 2 + + // Draw keyframe dots + for (let keyframe of curve.keyframes) { + const x = this.timelineState.timeToPixel(keyframe.time) + const y = valueToY(keyframe.value) + + // Draw selected keyframes 50% bigger + const isSelected = this.selectedKeyframes.has(keyframe) + const radius = isSelected ? 6 : 4 + + ctx.beginPath() + ctx.arc(x, y, radius, 0, 2 * Math.PI) + ctx.fill() + } + + // Handle single keyframe case - draw horizontal hold line + if (curve.keyframes.length === 1) { + const keyframe = curve.keyframes[0] + const keyframeX = this.timelineState.timeToPixel(keyframe.time) + const keyframeY = valueToY(keyframe.value) + + // Draw horizontal line extending to the right edge of visible area + const rightEdge = this.width - this.trackHeaderWidth + + ctx.beginPath() + ctx.moveTo(keyframeX, keyframeY) + ctx.lineTo(rightEdge, keyframeY) + ctx.stroke() + + // Optionally draw a lighter line extending to the left if keyframe is after t=0 + if (keyframe.time > 0) { + ctx.strokeStyle = curve.displayColor + '40' // More transparent + ctx.beginPath() + ctx.moveTo(0, keyframeY) + ctx.lineTo(keyframeX, keyframeY) + ctx.stroke() + + // Reset stroke style + ctx.strokeStyle = curve.displayColor + } + } + + // Draw curves between keyframes based on interpolation mode + for (let i = 0; i < curve.keyframes.length - 1; i++) { + const kf1 = curve.keyframes[i] + const kf2 = curve.keyframes[i + 1] + + const x1 = this.timelineState.timeToPixel(kf1.time) + const y1 = valueToY(kf1.value) + const x2 = this.timelineState.timeToPixel(kf2.time) + const y2 = valueToY(kf2.value) + + // Draw based on interpolation mode + ctx.beginPath() + ctx.moveTo(x1, y1) + + switch (kf1.interpolation) { + case 'linear': + // Draw straight line + ctx.lineTo(x2, y2) + ctx.stroke() + break + + case 'step': + case 'hold': + // Draw horizontal hold line then vertical jump + ctx.lineTo(x2, y1) + ctx.lineTo(x2, y2) + ctx.stroke() + break + + case 'zero': + // Draw line to zero, hold at zero, then line to next value + const zeroY = valueToY(0) + ctx.lineTo(x1, zeroY) + ctx.lineTo(x2, zeroY) + ctx.lineTo(x2, y2) + ctx.stroke() + break + + case 'bezier': + default: + // Calculate control points for Bezier curve + const cpOffset = (x2 - x1) / 3 // Control points at 1/3 and 2/3 of time range + + const cp1x = x1 + cpOffset + const cp1y = y1 + (kf1.outTangent || 0) * cpOffset + const cp2x = x2 - cpOffset + const cp2y = y2 - (kf2.inTangent || 0) * cpOffset + + ctx.bezierCurveTo(cp1x, cp1y, cp2x, cp2y, x2, y2) + ctx.stroke() + + // Draw tangent handles for bezier mode only + ctx.strokeStyle = curve.displayColor + '80' // Semi-transparent + ctx.lineWidth = 1 + + // Out tangent handle + ctx.beginPath() + ctx.moveTo(x1, y1) + ctx.lineTo(cp1x, cp1y) + ctx.stroke() + + // In tangent handle + ctx.beginPath() + ctx.moveTo(x2, y2) + ctx.lineTo(cp2x, cp2y) + ctx.stroke() + + // Reset for next curve segment + ctx.strokeStyle = curve.displayColor + ctx.lineWidth = 2 + break + } + } + } + + // Draw value labels on the left + ctx.fillStyle = labelColor + ctx.font = '10px sans-serif' + ctx.textAlign = 'right' + ctx.textBaseline = 'middle' + + for (let i = 0; i <= 4; i++) { + const value = minValue + (i * (maxValue - minValue) / 4) + const y = startY + curveHeight - padding - (i * (curveHeight - 2 * padding) / 4) + ctx.fillText(value.toFixed(2), -5, y) + } + + // Draw keyframe value tooltip if hovering (check if hover position is in this track's curve area) + if (this.hoveredKeyframe && this.hoveredKeyframe.trackY === trackY) { + const hoverX = this.hoveredKeyframe.x + const hoverY = this.hoveredKeyframe.y + const hoverValue = this.hoveredKeyframe.keyframe.value + + // Format the value + const valueText = hoverValue.toFixed(2) + + // Measure text to size the tooltip + ctx.font = '11px sans-serif' + const textMetrics = ctx.measureText(valueText) + const textWidth = textMetrics.width + const tooltipPadding = 4 + const tooltipWidth = textWidth + tooltipPadding * 2 + const tooltipHeight = 16 + + // Position tooltip above and to the right of keyframe + let tooltipX = hoverX + 8 + let tooltipY = hoverY - tooltipHeight - 8 + + // Clamp to stay within bounds + const maxX = this.width - this.trackHeaderWidth + if (tooltipX + tooltipWidth > maxX) { + tooltipX = hoverX - tooltipWidth - 8 // Show on left instead + } + if (tooltipY < startY) { + tooltipY = hoverY + 8 // Show below instead + } + + // Draw tooltip background + ctx.fillStyle = backgroundColor + ctx.fillRect(tooltipX, tooltipY, tooltipWidth, tooltipHeight) + + // Draw tooltip border + ctx.strokeStyle = foregroundColor + ctx.lineWidth = 1 + ctx.strokeRect(tooltipX, tooltipY, tooltipWidth, tooltipHeight) + + // Draw value text + ctx.fillStyle = labelColor + ctx.textAlign = 'left' + ctx.textBaseline = 'middle' + ctx.fillText(valueText, tooltipX + tooltipPadding, tooltipY + tooltipHeight / 2) + } + } + mousedown(x, y) { - // 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); + // Check if clicking on snapping checkbox (Phase 5) + if (y <= this.ruler.height && x < this.trackHeaderWidth) { + const checkboxSize = 14 + const checkboxX = 10 + const checkboxY = (this.ruler.height - checkboxSize) / 2 + + if (x >= checkboxX && x <= checkboxX + checkboxSize && + y >= checkboxY && y <= checkboxY + checkboxSize) { + // Toggle snapping + this.timelineState.snapToFrames = !this.timelineState.snapToFrames + console.log('Snapping', this.timelineState.snapToFrames ? 'enabled' : 'disabled') + if (this.requestRedraw) this.requestRedraw() + return true + } + } + + // Check if clicking in ruler area (after track headers) + if (y <= this.ruler.height && x >= this.trackHeaderWidth) { + // Adjust x for ruler (remove track header offset) + const rulerX = x - this.trackHeaderWidth + const hitPlayhead = this.ruler.mousedown(rulerX, y); if (hitPlayhead) { + // Sync activeObject currentTime with the new playhead position + if (this.context.activeObject) { + this.context.activeObject.currentTime = this.timelineState.currentTime + } + + // Trigger stage redraw to show animation at new time + if (this.context.updateUI) { + this.context.updateUI() + } + this.draggingPlayhead = true this._globalEvents.add("mousemove") this._globalEvents.add("mouseup") @@ -663,9 +1389,9 @@ class TimelineWindowV2 extends Widget { } } - // Check if clicking in track area + // Check if clicking in track header area const trackY = y - this.ruler.height - if (trackY >= 0) { + if (trackY >= 0 && x < this.trackHeaderWidth) { // Adjust for vertical scroll offset const adjustedY = trackY - this.trackScrollOffset const track = this.trackHierarchy.getTrackAtY(adjustedY) @@ -688,13 +1414,384 @@ class TimelineWindowV2 extends Widget { return true } - // Clicking elsewhere on track selects it + // Check if clicking on toggle buttons (Phase 3) + if (track.type === 'object' || track.type === 'shape') { + const buttonSize = 14 + const trackIndex = this.trackHierarchy.tracks.indexOf(track) + const trackY = this.trackHierarchy.getTrackY(trackIndex) + const buttonY = trackY + (this.trackHierarchy.trackHeight - buttonSize) / 2 // Use base height for button + + // Calculate button positions (same as in draw) + let buttonX = this.trackHeaderWidth - 10 + + // Curves mode button (rightmost) + const curveButtonX = buttonX - buttonSize + if (x >= curveButtonX && x <= curveButtonX + buttonSize && + adjustedY >= buttonY && adjustedY <= buttonY + buttonSize) { + // Cycle through curves modes: hidden -> minimized -> expanded -> hidden + if (track.object.curvesMode === 'hidden') { + track.object.curvesMode = 'minimized' + } else if (track.object.curvesMode === 'minimized') { + track.object.curvesMode = 'expanded' + } else { + track.object.curvesMode = 'hidden' + } + if (this.requestRedraw) this.requestRedraw() + return true + } + + // Segment visibility button + const segmentButtonX = curveButtonX - (buttonSize + 4) + if (x >= segmentButtonX && x <= segmentButtonX + buttonSize && + adjustedY >= buttonY && adjustedY <= buttonY + buttonSize) { + // Toggle segment visibility + track.object.showSegment = !track.object.showSegment + if (this.requestRedraw) this.requestRedraw() + return true + } + } + + // Clicking elsewhere on track header selects it this.selectTrack(track) if (this.requestRedraw) this.requestRedraw() return true } } + // Check if clicking in timeline area (segments or curves) + if (trackY >= 0 && x >= this.trackHeaderWidth) { + const adjustedY = trackY - this.trackScrollOffset + const adjustedX = x - this.trackHeaderWidth + const track = this.trackHierarchy.getTrackAtY(adjustedY) + + if (track) { + // Phase 5: Check if clicking on expanded curves + if ((track.type === 'object' || track.type === 'shape') && track.object.curvesMode === 'expanded') { + const curveClickResult = this.handleCurveClick(track, adjustedX, adjustedY) + if (curveClickResult) { + return true + } + } + + // Check if clicking on segment + if (this.isPointInSegment(track, adjustedX, adjustedY)) { + this.selectTrack(track) + if (this.requestRedraw) this.requestRedraw() + return true + } + } + } + + return false + } + + /** + * Handle click on curve area in expanded mode (Phase 5) + * Returns true if click was handled + */ + handleCurveClick(track, x, y) { + const trackIndex = this.trackHierarchy.tracks.indexOf(track) + const trackY = this.trackHierarchy.getTrackY(trackIndex) + + const curveHeight = 80 + const startY = trackY + 10 // Start below segment area + const padding = 5 + + // Check if y is within curve area + if (y < startY || y > startY + curveHeight) { + return false + } + + // Get AnimationData and curves for this track + const obj = track.object + let animationData = null + + if (track.type === 'object') { + for (let layer of this.context.activeObject.allLayers) { + if (layer.children && layer.children.includes(obj)) { + animationData = layer.animationData + break + } + } + } else if (track.type === 'shape') { + const findShapeLayer = (searchObj) => { + for (let layer of searchObj.children) { + if (layer.shapes && layer.shapes.includes(obj)) { + animationData = layer.animationData + return true + } + if (layer.children) { + for (let child of layer.children) { + if (findShapeLayer(child)) return true + } + } + } + return false + } + findShapeLayer(this.context.activeObject) + } + + if (!animationData) return false + + // Get all curves for this object/shape + const curves = [] + for (let curveName in animationData.curves) { + const curve = animationData.curves[curveName] + if (track.type === 'object' && curveName.startsWith(`child.${obj.idx}.`)) { + curves.push(curve) + } else if (track.type === 'shape' && curveName.startsWith(`shape.${obj.idx}.`)) { + curves.push(curve) + } + } + + if (curves.length === 0) return false + + // Calculate value range for scaling + let minValue = Infinity + let maxValue = -Infinity + for (let curve of curves) { + for (let keyframe of curve.keyframes) { + minValue = Math.min(minValue, keyframe.value) + maxValue = Math.max(maxValue, keyframe.value) + } + } + const valueRange = maxValue - minValue + const rangePadding = valueRange * 0.1 || 1 + minValue -= rangePadding + maxValue += rangePadding + + // Helper to convert Y position to value + const yToValue = (yPos) => { + const normalizedY = (startY + curveHeight - padding - yPos) / (curveHeight - 2 * padding) + return minValue + (normalizedY * (maxValue - minValue)) + } + + // Convert click position to time and value + let clickTime = this.timelineState.pixelToTime(x) + const clickValue = yToValue(y) + + // Apply snapping to click time + clickTime = this.timelineState.snapTime(clickTime) + + // Check if clicking close to an existing keyframe on ANY curve (within 8px) + // First pass: check all curves for keyframe hits + for (let curve of curves) { + for (let keyframe of curve.keyframes) { + const kfX = this.timelineState.timeToPixel(keyframe.time) + const kfY = startY + curveHeight - padding - ((keyframe.value - minValue) / (maxValue - minValue) * (curveHeight - 2 * padding)) + const distance = Math.sqrt((x - kfX) ** 2 + (y - kfY) ** 2) + + if (distance < 8) { + // Check for multi-select modifier keys from click event + const shiftKey = this.lastClickEvent?.shiftKey || false + const ctrlKey = this.lastClickEvent?.ctrlKey || this.lastClickEvent?.metaKey || false + + if (shiftKey) { + // Shift: Add to selection + this.selectedKeyframes.add(keyframe) + console.log(`Added keyframe to selection, now have ${this.selectedKeyframes.size} selected`) + } else if (ctrlKey) { + // Ctrl/Cmd: Toggle selection + if (this.selectedKeyframes.has(keyframe)) { + this.selectedKeyframes.delete(keyframe) + console.log(`Removed keyframe from selection, now have ${this.selectedKeyframes.size} selected`) + } else { + this.selectedKeyframes.add(keyframe) + console.log(`Added keyframe to selection, now have ${this.selectedKeyframes.size} selected`) + } + } else { + // No modifier: Select only this keyframe + this.selectedKeyframes.clear() + this.selectedKeyframes.add(keyframe) + console.log(`Selected single keyframe`) + } + + // Start dragging this keyframe (and all selected keyframes) + this.draggingKeyframe = { + curve: curve, // Use the actual curve we clicked on + keyframe: keyframe, + track: track, + initialTime: keyframe.time, + initialValue: keyframe.value, + minValue: minValue, + maxValue: maxValue, + curveHeight: curveHeight, + startY: startY, + padding: padding, + yToValue: yToValue // Store the conversion function + } + + // Enable global mouse events for dragging + this._globalEvents.add("mousemove") + this._globalEvents.add("mouseup") + + console.log('Started dragging keyframe at time', keyframe.time, 'on curve', curve.parameter) + if (this.requestRedraw) this.requestRedraw() + return true + } + } + } + + // No keyframe was clicked, so add a new one + // Find the closest curve to the click position + let targetCurve = curves[0] + let minDistance = Infinity + + for (let curve of curves) { + // For each curve, find the value at this time + const curveValue = curve.interpolate(clickTime) + if (curveValue !== null) { + const curveY = startY + curveHeight - padding - ((curveValue - minValue) / (maxValue - minValue) * (curveHeight - 2 * padding)) + const distance = Math.abs(y - curveY) + + if (distance < minDistance) { + minDistance = distance + targetCurve = curve + } + } + } + + console.log('Adding keyframe at time', clickTime, 'with value', clickValue, 'to curve', targetCurve.parameter) + + // Create keyframe directly + const newKeyframe = { + time: clickTime, + value: clickValue, + interpolation: 'linear', + easeIn: { x: 0.42, y: 0 }, + easeOut: { x: 0.58, y: 1 }, + idx: this.generateUUID() + } + + targetCurve.addKeyframe(newKeyframe) + + if (this.requestRedraw) this.requestRedraw() + return true + } + + /** + * Generate UUID (Phase 5) + */ + generateUUID() { + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) { + const r = Math.random() * 16 | 0 + const v = c === 'x' ? r : (r & 0x3 | 0x8) + return v.toString(16) + }) + } + + /** + * Check if a point (in timeline area coordinates) is inside a segment for the given track + */ + isPointInSegment(track, x, y) { + const trackIndex = this.trackHierarchy.tracks.indexOf(track) + if (trackIndex === -1) return false + + const trackY = this.trackHierarchy.getTrackY(trackIndex) + const trackHeight = this.trackHierarchy.trackHeight // Use base height for segment bounds + const segmentTop = trackY + 5 + const segmentBottom = trackY + trackHeight - 5 + + // Check if y is within segment bounds + if (y < segmentTop || y > segmentBottom) return false + + const clickTime = this.timelineState.pixelToTime(x) + const frameDuration = 1 / this.timelineState.framerate + const minSegmentDuration = frameDuration + + if (track.type === 'object') { + // Check frameNumber curve for objects + const obj = track.object + let parentLayer = null + for (let layer of this.context.activeObject.allLayers) { + if (layer.children && layer.children.includes(obj)) { + parentLayer = layer + break + } + } + + if (!parentLayer || !parentLayer.animationData) return false + + const frameNumberKey = `child.${obj.idx}.frameNumber` + const frameNumberCurve = parentLayer.animationData.curves[frameNumberKey] + + if (!frameNumberCurve || !frameNumberCurve.keyframes) return false + + // Check if clickTime is within any segment + let segmentStart = null + for (let j = 0; j < frameNumberCurve.keyframes.length; j++) { + const keyframe = frameNumberCurve.keyframes[j] + + if (keyframe.value > 0) { + if (segmentStart === null) { + segmentStart = keyframe.time + } + + const isLast = (j === frameNumberCurve.keyframes.length - 1) + const nextEndsSegment = !isLast && frameNumberCurve.keyframes[j + 1].value === 0 + + if (isLast || nextEndsSegment) { + const segmentEnd = nextEndsSegment ? frameNumberCurve.keyframes[j + 1].time : keyframe.time + minSegmentDuration + + if (clickTime >= segmentStart && clickTime <= segmentEnd) { + return true + } + segmentStart = null + } + } + } + } else if (track.type === 'shape') { + // Check exists curve for shapes + const shape = track.object + let shapeLayer = null + const findShapeLayer = (obj) => { + for (let layer of obj.children) { + if (layer.shapes && layer.shapes.includes(shape)) { + shapeLayer = layer + return true + } + if (layer.children) { + for (let child of layer.children) { + if (findShapeLayer(child)) return true + } + } + } + return false + } + findShapeLayer(this.context.activeObject) + + if (!shapeLayer || !shapeLayer.animationData) return false + + const existsCurveKey = `shape.${shape.idx}.exists` + const existsCurve = shapeLayer.animationData.curves[existsCurveKey] + + if (!existsCurve || !existsCurve.keyframes) return false + + // Check if clickTime is within any segment + let segmentStart = null + for (let j = 0; j < existsCurve.keyframes.length; j++) { + const keyframe = existsCurve.keyframes[j] + + if (keyframe.value > 0) { + if (segmentStart === null) { + segmentStart = keyframe.time + } + + const isLast = (j === existsCurve.keyframes.length - 1) + const nextEndsSegment = !isLast && existsCurve.keyframes[j + 1].value === 0 + + if (isLast || nextEndsSegment) { + const segmentEnd = nextEndsSegment ? existsCurve.keyframes[j + 1].time : keyframe.time + minSegmentDuration + + if (clickTime >= segmentStart && clickTime <= segmentEnd) { + return true + } + segmentStart = null + } + } + } + } + return false } @@ -763,16 +1860,248 @@ class TimelineWindowV2 extends Widget { } mousemove(x, y) { + // Update hover state for keyframe tooltips (even when not dragging) + // Clear hover if mouse is outside timeline curve areas + let foundHover = false + + if (!this.draggingKeyframe && !this.draggingPlayhead) { + const trackY = y - this.ruler.height + if (trackY >= 0 && x >= this.trackHeaderWidth) { + const adjustedY = trackY - this.trackScrollOffset + const adjustedX = x - this.trackHeaderWidth + const track = this.trackHierarchy.getTrackAtY(adjustedY) + + if (track && (track.type === 'object' || track.type === 'shape') && track.object.curvesMode === 'expanded') { + const trackIndex = this.trackHierarchy.tracks.indexOf(track) + const trackYPos = this.trackHierarchy.getTrackY(trackIndex) + + const curveHeight = 80 + const startY = trackYPos + 10 + const padding = 5 + + // Check if within curve area + if (adjustedY >= startY && adjustedY <= startY + curveHeight) { + // Get AnimationData and curves for this track + const obj = track.object + let animationData = null + + if (track.type === 'object') { + for (let layer of this.context.activeObject.allLayers) { + if (layer.children && layer.children.includes(obj)) { + animationData = layer.animationData + break + } + } + } else if (track.type === 'shape') { + const findShapeLayer = (searchObj) => { + for (let layer of searchObj.children) { + if (layer.shapes && layer.shapes.includes(obj)) { + animationData = layer.animationData + return true + } + if (layer.children) { + for (let child of layer.children) { + if (findShapeLayer(child)) return true + } + } + } + return false + } + findShapeLayer(this.context.activeObject) + } + + if (animationData) { + // Get all curves for this object/shape + const curves = [] + for (let curveName in animationData.curves) { + const curve = animationData.curves[curveName] + if (track.type === 'object' && curveName.startsWith(`child.${obj.idx}.`)) { + curves.push(curve) + } else if (track.type === 'shape' && curveName.startsWith(`shape.${obj.idx}.`)) { + curves.push(curve) + } + } + + if (curves.length > 0) { + // Calculate value range for scaling + let minValue = Infinity + let maxValue = -Infinity + for (let curve of curves) { + for (let keyframe of curve.keyframes) { + minValue = Math.min(minValue, keyframe.value) + maxValue = Math.max(maxValue, keyframe.value) + } + } + const valueRange = maxValue - minValue + const rangePadding = valueRange * 0.1 || 1 + minValue -= rangePadding + maxValue += rangePadding + + // Check if hovering over any keyframe + for (let curve of curves) { + for (let keyframe of curve.keyframes) { + const kfX = this.timelineState.timeToPixel(keyframe.time) + const kfY = startY + curveHeight - padding - ((keyframe.value - minValue) / (maxValue - minValue) * (curveHeight - 2 * padding)) + const distance = Math.sqrt((adjustedX - kfX) ** 2 + (adjustedY - kfY) ** 2) + + if (distance < 8) { + // Found a hover! + this.hoveredKeyframe = { + keyframe: keyframe, + x: kfX, + y: kfY, + trackY: trackYPos // Store track Y for comparison in draw + } + foundHover = true + if (this.requestRedraw) this.requestRedraw() + break + } + } + if (foundHover) break + } + } + } + } + } + } + } + + // Clear hover if not found + if (!foundHover && this.hoveredKeyframe) { + this.hoveredKeyframe = null + if (this.requestRedraw) this.requestRedraw() + } + if (this.draggingPlayhead) { - // Let the ruler handle the mousemove - this.ruler.mousemove(x, y) + // Adjust x for ruler (remove track header offset) + const rulerX = x - this.trackHeaderWidth + this.ruler.mousemove(rulerX, y) // Sync GraphicsObject currentTime with timeline playhead if (this.context.activeObject) { this.context.activeObject.currentTime = this.timelineState.currentTime } + + // Trigger stage redraw to update object positions based on new time + if (this.context.updateUI) { + this.context.updateUI() + } + return true } + + // Phase 5: Handle keyframe dragging + if (this.draggingKeyframe) { + // Adjust coordinates to timeline area + const trackY = y - this.ruler.height + const adjustedX = x - this.trackHeaderWidth + const adjustedY = trackY - this.trackScrollOffset + + // Convert mouse position to time and value + const newTime = this.timelineState.pixelToTime(adjustedX) + const newValue = this.draggingKeyframe.yToValue(adjustedY) + + // Clamp time to not go negative, then apply snapping + let clampedTime = Math.max(0, newTime) + clampedTime = this.timelineState.snapTime(clampedTime) + + // Check for constrained dragging modifiers from drag event + const shiftKey = this.lastDragEvent?.shiftKey || false + const ctrlKey = this.lastDragEvent?.ctrlKey || this.lastDragEvent?.metaKey || false + + // Calculate deltas from the initial position + let timeDelta = clampedTime - this.draggingKeyframe.initialTime + let valueDelta = newValue - this.draggingKeyframe.initialValue + + // Apply constraints based on modifier keys + if (shiftKey && !ctrlKey) { + // Shift: vertical only (constrain time) + timeDelta = 0 + } else if (ctrlKey && !shiftKey) { + // Ctrl/Cmd: horizontal only (constrain value) + valueDelta = 0 + } + + // Update all selected keyframes + for (let selectedKeyframe of this.selectedKeyframes) { + // Get the initial position of this keyframe (stored when dragging started) + if (!selectedKeyframe.initialDragTime) { + selectedKeyframe.initialDragTime = selectedKeyframe.time + selectedKeyframe.initialDragValue = selectedKeyframe.value + } + + // Apply the delta + selectedKeyframe.time = Math.max(0, selectedKeyframe.initialDragTime + timeDelta) + selectedKeyframe.value = selectedKeyframe.initialDragValue + valueDelta + } + + // Resort keyframes in all affected curves + // We need to find all unique curves that contain selected keyframes + const affectedCurves = new Set() + for (let selectedKeyframe of this.selectedKeyframes) { + // Find which curve this keyframe belongs to + // This is a bit inefficient but works + const track = this.draggingKeyframe.track + const obj = track.object + + let animationData = null + if (track.type === 'object') { + for (let layer of this.context.activeObject.allLayers) { + if (layer.children && layer.children.includes(obj)) { + animationData = layer.animationData + break + } + } + } else if (track.type === 'shape') { + const findShapeLayer = (searchObj) => { + for (let layer of searchObj.children) { + if (layer.shapes && layer.shapes.includes(obj)) { + animationData = layer.animationData + return true + } + if (layer.children) { + for (let child of layer.children) { + if (findShapeLayer(child)) return true + } + } + } + return false + } + findShapeLayer(this.context.activeObject) + } + + if (animationData) { + for (let curveName in animationData.curves) { + const curve = animationData.curves[curveName] + if (curve.keyframes.includes(selectedKeyframe)) { + affectedCurves.add(curve) + } + } + } + } + + // Resort all affected curves + for (let curve of affectedCurves) { + curve.keyframes.sort((a, b) => a.time - b.time) + } + + // Sync the activeObject's currentTime with the timeline playhead + // This ensures the stage shows the animation at the correct time + if (this.context.activeObject) { + this.context.activeObject.currentTime = this.timelineState.currentTime + } + + // Trigger stage redraw to update object positions based on new keyframe values + if (this.context.updateUI) { + console.log('[Timeline] Calling updateUI() to redraw stage after keyframe drag, syncing currentTime =', this.timelineState.currentTime) + this.context.updateUI() + } + + // Trigger timeline redraw + if (this.requestRedraw) this.requestRedraw() + return true + } + return false } @@ -786,9 +2115,211 @@ class TimelineWindowV2 extends Widget { this._globalEvents.delete("mouseup") return true } + + // Phase 5: Complete keyframe dragging + if (this.draggingKeyframe) { + console.log(`Finished dragging ${this.selectedKeyframes.size} keyframe(s)`) + + // Clean up initial drag positions from all selected keyframes + for (let selectedKeyframe of this.selectedKeyframes) { + delete selectedKeyframe.initialDragTime + delete selectedKeyframe.initialDragValue + } + + // Clean up dragging state + this.draggingKeyframe = null + this._globalEvents.delete("mousemove") + this._globalEvents.delete("mouseup") + + // Final redraw + if (this.requestRedraw) this.requestRedraw() + return true + } + return false } + /** + * Handle right-click context menu (Phase 5) + * Deletes keyframe if right-clicking on one + */ + contextmenu(x, y) { + // Check if right-clicking in timeline area with curves + const trackY = y - this.ruler.height + if (trackY >= 0 && x >= this.trackHeaderWidth) { + const adjustedY = trackY - this.trackScrollOffset + const adjustedX = x - this.trackHeaderWidth + const track = this.trackHierarchy.getTrackAtY(adjustedY) + + if (track && (track.type === 'object' || track.type === 'shape') && track.object.curvesMode === 'expanded') { + // Use similar logic to handleCurveClick to find if we're clicking on a keyframe + const trackIndex = this.trackHierarchy.tracks.indexOf(track) + const trackYPos = this.trackHierarchy.getTrackY(trackIndex) + + const curveHeight = 80 + const startY = trackYPos + 10 + const padding = 5 + + // Check if y is within curve area + if (adjustedY >= startY && adjustedY <= startY + curveHeight) { + // Get AnimationData and curves for this track + const obj = track.object + let animationData = null + + if (track.type === 'object') { + for (let layer of this.context.activeObject.allLayers) { + if (layer.children && layer.children.includes(obj)) { + animationData = layer.animationData + break + } + } + } else if (track.type === 'shape') { + const findShapeLayer = (searchObj) => { + for (let layer of searchObj.children) { + if (layer.shapes && layer.shapes.includes(obj)) { + animationData = layer.animationData + return true + } + if (layer.children) { + for (let child of layer.children) { + if (findShapeLayer(child)) return true + } + } + } + return false + } + findShapeLayer(this.context.activeObject) + } + + if (!animationData) return false + + // Get all curves for this object/shape + const curves = [] + for (let curveName in animationData.curves) { + const curve = animationData.curves[curveName] + if (track.type === 'object' && curveName.startsWith(`child.${obj.idx}.`)) { + curves.push(curve) + } else if (track.type === 'shape' && curveName.startsWith(`shape.${obj.idx}.`)) { + curves.push(curve) + } + } + + if (curves.length === 0) return false + + // Calculate value range for scaling + let minValue = Infinity + let maxValue = -Infinity + for (let curve of curves) { + for (let keyframe of curve.keyframes) { + minValue = Math.min(minValue, keyframe.value) + maxValue = Math.max(maxValue, keyframe.value) + } + } + const valueRange = maxValue - minValue + const rangePadding = valueRange * 0.1 || 1 + minValue -= rangePadding + maxValue += rangePadding + + // Check if right-clicking on a keyframe (within 8px) + for (let curve of curves) { + for (let i = 0; i < curve.keyframes.length; i++) { + const keyframe = curve.keyframes[i] + const kfX = this.timelineState.timeToPixel(keyframe.time) + const kfY = startY + curveHeight - padding - ((keyframe.value - minValue) / (maxValue - minValue) * (curveHeight - 2 * padding)) + const distance = Math.sqrt((adjustedX - kfX) ** 2 + (adjustedY - kfY) ** 2) + + if (distance < 8) { + // Check if this keyframe is in the current selection + const isInSelection = this.selectedKeyframes.has(keyframe) + + // Determine what to delete + // If there are multiple selected keyframes (regardless of which one we clicked), + // show the confirmation menu + if (this.selectedKeyframes.size > 1) { + // Delete all selected keyframes + const keyframesToDelete = Array.from(this.selectedKeyframes) + this.showDeleteKeyframesMenu(keyframesToDelete, curves) + return true + } + + // Single keyframe deletion - check if it's the last one in its curve + if (curve.keyframes.length <= 1) { + console.log(`Cannot delete last keyframe in curve ${curve.parameter}`) + return true // Still return true to indicate event was handled + } + + // Delete single keyframe + console.log(`Deleting keyframe at time ${keyframe.time} from curve ${curve.parameter}`) + curve.keyframes.splice(i, 1) + + // Remove from selection if it was selected + this.selectedKeyframes.delete(keyframe) + + if (this.requestRedraw) this.requestRedraw() + return true + } + } + } + } + } + } + + return false + } + + /** + * Show Tauri context menu for deleting multiple selected keyframes (Phase 5) + */ + async showDeleteKeyframesMenu(keyframesToDelete, curves) { + const { Menu, MenuItem } = window.__TAURI__.menu + const { PhysicalPosition, LogicalPosition } = window.__TAURI__.dpi + + // Build menu with delete option + const items = [ + await MenuItem.new({ + text: `Delete ${keyframesToDelete.length} keyframe${keyframesToDelete.length > 1 ? 's' : ''}`, + action: async () => { + // Perform deletion + console.log(`Deleting ${keyframesToDelete.length} selected keyframes`) + + // For each keyframe to delete + for (let keyframe of keyframesToDelete) { + // Find which curve(s) contain this keyframe + for (let curve of curves) { + const index = curve.keyframes.indexOf(keyframe) + if (index !== -1) { + // Check if this is the last keyframe in this curve + if (curve.keyframes.length > 1) { + curve.keyframes.splice(index, 1) + console.log(`Deleted keyframe from curve ${curve.parameter}`) + } else { + console.log(`Skipped deleting last keyframe in curve ${curve.parameter}`) + } + break + } + } + } + + // Clear the selection + this.selectedKeyframes.clear() + + // Trigger redraw + if (this.requestRedraw) this.requestRedraw() + } + }) + ] + + const menu = await Menu.new({ items }) + + // Show menu at mouse position (using lastEvent for clientX/clientY) + const clientX = this.lastEvent?.clientX || 0 + const clientY = this.lastEvent?.clientY || 0 + const position = new PhysicalPosition(clientX, clientY) + console.log(position) + // await menu.popup({ at: position }) + await menu.popup(position) + } + // Zoom controls (can be called from keyboard shortcuts) zoomIn() { this.timelineState.zoomIn()