Complete Phase 5: Timeline curve interaction and nested animation support

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
This commit is contained in:
Skyler Lehmkuhl 2025-10-15 19:08:49 -04:00
parent 1936e91327
commit 87d2036f07
3 changed files with 2202 additions and 106 deletions

View File

@ -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) {
// 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
) {
// Calculate elapsed time since last frame (in seconds)
const now = performance.now();
const elapsedTime = now - lastFrameTime;
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,23 +7912,23 @@ 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
// Regular scroll - handle both horizontal and vertical scrolling everywhere
const deltaX = event.deltaX * config.scrollSpeed;
const deltaY = event.deltaY * config.scrollSpeed;
// Horizontal scroll for timeline
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;
// Vertical scroll for tracks
timelineWidget.trackScrollOffset -= deltaY;
// Clamp scroll offset
@ -7441,7 +7936,6 @@ function timelineV2() {
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,

View File

@ -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 }

File diff suppressed because it is too large Load Diff