Work on moving things to animation curves

This commit is contained in:
Skyler Lehmkuhl 2025-10-15 00:41:51 -04:00
parent 7bade4517c
commit 6c79914ffb
5 changed files with 1115 additions and 108 deletions

View File

@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<link rel="stylesheet" href="styles.css" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<title>Lightningbeam</title>
<script src="Tone.js"></script>

View File

@ -60,7 +60,7 @@ import {
shadow,
} from "./styles.js";
import { Icon } from "./icon.js";
import { AlphaSelectionBar, ColorSelectorWidget, ColorWidget, HueSelectionBar, SaturationValueSelectionGradient, TimelineWindow, Widget } from "./widgets.js";
import { AlphaSelectionBar, ColorSelectorWidget, ColorWidget, HueSelectionBar, SaturationValueSelectionGradient, TimelineWindow, TimelineWindowV2, Widget } from "./widgets.js";
const {
writeTextFile: writeTextFile,
readTextFile: readTextFile,
@ -347,6 +347,7 @@ let context = {
selectedFrames: [],
dragDirection: undefined,
zoomLevel: 1,
timelineWidget: null, // Reference to TimelineWindowV2 widget for zoom controls
};
let config = {
@ -661,7 +662,7 @@ let actions = {
fillImage: img,
strokeShape: false,
};
let imageShape = new Shape(0, 0, ct, action.shapeUuid);
let imageShape = new Shape(0, 0, ct, imageObject.activeLayer, action.shapeUuid);
imageShape.addLine(img.width, 0);
imageShape.addLine(img.width, img.height);
imageShape.addLine(0, img.height);
@ -1507,7 +1508,10 @@ let actions = {
let serializableShapes = [];
let serializableObjects = [];
let bbox;
const frame = context.activeObject.currentFrame
const currentTime = context.activeObject?.currentTime || 0;
const layer = context.activeObject.activeLayer;
// For shapes - use AnimationData system
for (let shape of context.shapeselection) {
serializableShapes.push(shape.idx);
if (bbox == undefined) {
@ -1516,8 +1520,11 @@ let actions = {
growBoundingBox(bbox, shape.bbox());
}
}
// For objects - check legacy frame system if available
const frame = context.activeObject.currentFrame;
for (let object of context.selection) {
if (object.idx in frame.keys) {
if (frame && object.idx in frame.keys) {
serializableObjects.push(object.idx);
// TODO: rotated bbox
if (bbox == undefined) {
@ -1527,6 +1534,7 @@ let actions = {
}
}
}
context.shapeselection = [];
context.selection = [];
let action = {
@ -1534,8 +1542,9 @@ let actions = {
objects: serializableObjects,
groupUuid: uuidv4(),
parent: context.activeObject.idx,
frame: frame.idx,
layer: context.activeObject.activeLayer.idx,
frame: frame?.idx,
layer: layer.idx,
currentTime: currentTime,
position: {
x: (bbox.x.min + bbox.x.max) / 2,
y: (bbox.y.min + bbox.y.max) / 2,
@ -1548,41 +1557,62 @@ let actions = {
execute: (action) => {
let group = new GraphicsObject(action.groupUuid);
let parent = pointerList[action.parent];
let frame = action.frame
? pointerList[action.frame]
: parent.currentFrame;
let layer;
if (action.layer) {
layer = pointerList[action.layer]
} else {
for (let _layer of parent.layers) {
for (let _frame of _layer.frames) {
if (_frame && (_frame.idx == frame.idx)) {
layer = _layer
}
}
}
if (layer==undefined) {
layer = parent.activeLayer
}
}
let layer = pointerList[action.layer] || parent.activeLayer;
const currentTime = action.currentTime || 0;
// Move shapes from parent layer to group's first layer
for (let shapeIdx of action.shapes) {
let shape = pointerList[shapeIdx];
shape.translate(-action.position.x, -action.position.y);
group.currentFrame.addShape(shape);
frame.removeShape(shape);
// Remove shape from parent layer's shapes array
let shapeIndex = layer.shapes.indexOf(shape);
if (shapeIndex !== -1) {
layer.shapes.splice(shapeIndex, 1);
}
// Remove animation curves for this shape from parent layer
layer.animationData.removeCurve(`shape.${shape.idx}.exists`);
layer.animationData.removeCurve(`shape.${shape.idx}.zOrder`);
// Add shape to group's first layer
let groupLayer = group.activeLayer;
shape.parent = groupLayer;
groupLayer.shapes.push(shape);
// Add animation curves for this shape in group's layer
let existsCurve = new AnimationCurve(`shape.${shape.idx}.exists`);
existsCurve.addKeyframe(new Keyframe(0, 1, 'linear'));
groupLayer.animationData.setCurve(`shape.${shape.idx}.exists`, existsCurve);
let zOrderCurve = new AnimationCurve(`shape.${shape.idx}.zOrder`);
zOrderCurve.addKeyframe(new Keyframe(0, groupLayer.shapes.length - 1, 'linear'));
groupLayer.animationData.setCurve(`shape.${shape.idx}.zOrder`, zOrderCurve);
}
// Move objects (children) to the group
// For legacy frame-based children, use frame.keys if available
let frame = action.frame ? pointerList[action.frame] : parent.currentFrame;
for (let objectIdx of action.objects) {
let object = pointerList[objectIdx];
if (frame && frame.keys && frame.keys[objectIdx]) {
group.addObject(
object,
frame.keys[objectIdx].x - action.position.x,
frame.keys[objectIdx].y - action.position.y,
currentTime
);
} else {
group.addObject(object, 0, 0, currentTime);
}
parent.removeChild(object);
}
parent.addObject(group, action.position.x, action.position.y, frame);
// Add group to parent using time-based API
parent.addObject(group, action.position.x, action.position.y, currentTime);
context.selection = [group];
context.activeCurve = undefined;
context.activeVertex = undefined;
updateUI();
updateInfopanel();
},
@ -1758,9 +1788,14 @@ let actions = {
selection.push(child.idx);
}
}
for (let shape of context.activeObject.currentFrame.shapes) {
// Use getVisibleShapes instead of currentFrame.shapes
let currentTime = context.activeObject?.currentTime || 0;
let layer = context.activeObject?.activeLayer;
if (layer) {
for (let shape of layer.getVisibleShapes(currentTime)) {
shapeselection.push(shape.idx);
}
}
let action = {
selection: selection,
shapeselection: shapeselection,
@ -1944,13 +1979,7 @@ function selectCurve(context, mouse) {
let layer = context.activeObject?.activeLayer;
if (!layer) return undefined;
for (let shape of layer.shapes) {
if (shape instanceof TempShape) continue;
// Check if shape exists at current time
let existsValue = layer.animationData.interpolate(`shape.${shape.idx}.exists`, currentTime);
if (!existsValue || existsValue <= 0) continue;
for (let shape of layer.getVisibleShapes(currentTime)) {
if (
mouse.x > shape.boundingBox.x.min - mouseTolerance &&
mouse.x < shape.boundingBox.x.max + mouseTolerance &&
@ -1978,8 +2007,13 @@ function selectVertex(context, mouse) {
let closestDist = mouseTolerance;
let closestVertex = undefined;
let closestShape = undefined;
for (let shape of context.activeObject.currentFrame.shapes) {
if (shape instanceof TempShape) continue;
// Get visible shapes from Layer using AnimationData
let currentTime = context.activeObject?.currentTime || 0;
let layer = context.activeObject?.activeLayer;
if (!layer) return undefined;
for (let shape of layer.getVisibleShapes(currentTime)) {
if (
mouse.x > shape.boundingBox.x.min - mouseTolerance &&
mouse.x < shape.boundingBox.x.max + mouseTolerance &&
@ -2355,12 +2389,18 @@ class AnimationCurve {
}
interpolate(time) {
if (this.keyframes.length === 0) return null;
if (this.keyframes.length === 0) {
return null;
}
const { prev, next, t } = this.getBracketingKeyframes(time);
if (!prev || !next) return null;
if (prev === next) return prev.value;
if (!prev || !next) {
return null;
}
if (prev === next) {
return prev.value;
}
// Handle different interpolation types
switch (prev.interpolation) {
@ -2452,6 +2492,10 @@ class AnimationData {
delete this.curves[parameter];
}
setCurve(parameter, curve) {
this.curves[parameter] = curve;
}
interpolate(parameter, time) {
const curve = this.curves[parameter];
if (!curve) return null;
@ -2521,7 +2565,7 @@ class Layer extends Widget {
// Load shapes if present
if (json.shapes) {
layer.shapes = json.shapes.map(shape => Shape.fromJSON(shape));
layer.shapes = json.shapes.map(shape => Shape.fromJSON(shape, layer));
}
// Load frames if present (old system - for backwards compatibility)
@ -2951,6 +2995,21 @@ class Layer extends Widget {
};
}
// Get all shapes that exist at the given time
getVisibleShapes(time) {
const visibleShapes = [];
for (let shape of this.shapes) {
if (shape instanceof TempShape) continue;
// Check if shape exists at current time
let existsValue = this.animationData.interpolate(`shape.${shape.idx}.exists`, time);
if (existsValue && existsValue > 0) {
visibleShapes.push(shape);
}
}
return visibleShapes;
}
draw(ctx) {
// super.draw(ctx)
if (!this.visible) return;
@ -3163,7 +3222,7 @@ class Layer extends Widget {
strokeShape: false,
sendToBack: true,
};
let shape = new Shape(regionPoints[0].x, regionPoints[0].y, cxt);
let shape = new Shape(regionPoints[0].x, regionPoints[0].y, cxt, this);
shape.fromPoints(points, 1);
actions.addShape.create(context.activeObject, shape, cxt);
break;
@ -3652,7 +3711,7 @@ class Shape extends BaseShape {
this.regionIdx = 0;
this.inProgress = true;
}
static fromJSON(json) {
static fromJSON(json, parent) {
let fillImage = undefined;
if (json.fillImage && Object.keys(json.fillImage).length !== 0) {
let img = new Image();
@ -3672,6 +3731,7 @@ class Shape extends BaseShape {
fillShape: json.filled,
strokeShape: json.stroked,
},
parent,
json.idx,
json.shapeId,
);
@ -3774,6 +3834,7 @@ class Shape extends BaseShape {
this.startx,
this.starty,
{},
this.parent,
idx.slice(0, 8) + this.idx.slice(8),
this.shapeId,
);
@ -4231,15 +4292,24 @@ class GraphicsObject extends Widget {
}
bbox() {
let bbox;
// for (let layer of this.layers) {
// let frame = layer.getFrame(this.currentFrameNum);
// if (frame.shapes.length > 0 && bbox == undefined) {
// bbox = structuredClone(frame.shapes[0].boundingBox);
// }
// for (let shape of frame.shapes) {
// growBoundingBox(bbox, shape.boundingBox);
// }
// }
// NEW: Include shapes from AnimationData system
let currentTime = this.currentTime || 0;
for (let layer of this.layers) {
for (let shape of layer.shapes) {
// Check if shape exists at current time
let existsValue = layer.animationData.interpolate(`shape.${shape.idx}.exists`, currentTime);
if (existsValue !== null && existsValue > 0) {
if (!bbox) {
bbox = structuredClone(shape.boundingBox);
} else {
growBoundingBox(bbox, shape.boundingBox);
}
}
}
}
// Include children
if (this.children.length > 0) {
if (!bbox) {
bbox = structuredClone(this.children[0].bbox());
@ -4248,6 +4318,7 @@ class GraphicsObject extends Widget {
growBoundingBox(bbox, child.bbox());
}
}
if (bbox == undefined) {
bbox = { x: { min: 0, max: 0 }, y: { min: 0, max: 0 } };
}
@ -4259,7 +4330,7 @@ class GraphicsObject extends Widget {
bbox.y.max += this.y;
return bbox;
}
/*
draw(context, calculateTransform=false) {
let ctx = context.ctx;
ctx.save();
@ -4279,34 +4350,142 @@ class GraphicsObject extends Widget {
this.idx in context.activeAction.selection
)
return;
for (let layer of this.layers) {
if (context.activeObject == this && !layer.visible) continue;
let frame = layer.getFrame(this.currentFrameNum);
for (let shape of frame.shapes) {
// Draw activeShape (shape being drawn in progress) for active layer only
if (layer === context.activeLayer && layer.activeShape) {
let cxt = {...context};
layer.activeShape.draw(cxt);
}
// NEW: Use AnimationData system to draw shapes
let currentTime = this.currentTime || 0;
let visibleShapes = [];
for (let shape of layer.shapes) {
if (shape instanceof TempShape) continue;
let existsValue = layer.animationData.interpolate(`shape.${shape.idx}.exists`, currentTime);
if (existsValue !== null && existsValue > 0) {
let zOrder = layer.animationData.interpolate(`shape.${shape.idx}.zOrder`, currentTime);
visibleShapes.push({ shape, zOrder: zOrder || 0 });
}
}
// Sort by zOrder
visibleShapes.sort((a, b) => a.zOrder - b.zOrder);
// Draw sorted shapes
for (let { shape } of visibleShapes) {
let cxt = {...context}
if (context.shapeselection.indexOf(shape) >= 0) {
cxt.selected = true
}
shape.draw(cxt);
}
// Draw child objects using AnimationData curves
for (let child of layer.children) {
if (child == context.activeObject) continue;
let idx = child.idx;
if (idx in frame.keys) {
child.x = frame.keys[idx].x;
child.y = frame.keys[idx].y;
child.rotation = frame.keys[idx].rotation;
child.scale_x = frame.keys[idx].scale_x;
child.scale_y = frame.keys[idx].scale_y;
// Use AnimationData to get child's transform
let childX = layer.animationData.interpolate(`child.${idx}.x`, currentTime);
let childY = layer.animationData.interpolate(`child.${idx}.y`, currentTime);
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);
if (childX !== null && childY !== null) {
child.x = childX;
child.y = childY;
child.rotation = childRotation || 0;
child.scale_x = childScaleX || 1;
child.scale_y = childScaleY || 1;
ctx.save();
child.draw(context);
if (true) {
}
ctx.restore();
}
}
}
if (this == context.activeObject) {
// Draw selection rectangles for selected items
if (mode == "select") {
for (let item of context.selection) {
if (!item) continue;
ctx.save();
ctx.strokeStyle = "#00ffff";
ctx.lineWidth = 1;
ctx.beginPath();
let bbox = getRotatedBoundingBox(item);
ctx.rect(
bbox.x.min,
bbox.y.min,
bbox.x.max - bbox.x.min,
bbox.y.max - bbox.y.min,
);
ctx.stroke();
ctx.restore();
}
// Draw drag selection rectangle
if (context.selectionRect) {
ctx.save();
ctx.strokeStyle = "#00ffff";
ctx.lineWidth = 1;
ctx.beginPath();
ctx.rect(
context.selectionRect.x1,
context.selectionRect.y1,
context.selectionRect.x2 - context.selectionRect.x1,
context.selectionRect.y2 - context.selectionRect.y1,
);
ctx.stroke();
ctx.restore();
}
} else if (mode == "transform") {
let bbox = undefined;
for (let item of context.selection) {
if (bbox == undefined) {
bbox = getRotatedBoundingBox(item);
} else {
growBoundingBox(bbox, getRotatedBoundingBox(item));
}
}
if (bbox != undefined) {
ctx.save();
ctx.strokeStyle = "#00ffff";
ctx.lineWidth = 1;
ctx.beginPath();
let xdiff = bbox.x.max - bbox.x.min;
let ydiff = bbox.y.max - bbox.y.min;
ctx.rect(bbox.x.min, bbox.y.min, xdiff, ydiff);
ctx.stroke();
ctx.fillStyle = "#000000";
let rectRadius = 5;
for (let i of [
[0, 0],
[0.5, 0],
[1, 0],
[1, 0.5],
[1, 1],
[0.5, 1],
[0, 1],
[0, 0.5],
]) {
ctx.beginPath();
ctx.rect(
bbox.x.min + xdiff * i[0] - rectRadius,
bbox.y.min + ydiff * i[1] - rectRadius,
rectRadius * 2,
rectRadius * 2,
);
ctx.fill();
}
ctx.restore();
}
}
if (context.activeCurve) {
ctx.strokeStyle = "magenta";
ctx.beginPath();
@ -4358,7 +4537,10 @@ class GraphicsObject extends Widget {
ctx.fill();
ctx.restore();
}
*/
}
ctx.restore();
}
/*
draw(ctx) {
super.draw(ctx)
if (this==context.activeObject) {
@ -4440,6 +4622,7 @@ class GraphicsObject extends Widget {
}
}
}
*/
transformCanvas(ctx) {
if (this.parent) {
this.parent.transformCanvas(ctx)
@ -4510,19 +4693,44 @@ class GraphicsObject extends Widget {
}
super.handleMouseEvent(eventType, x, y)
}
addObject(object, x = 0, y = 0, frame = undefined, layer=undefined) {
if (frame == undefined) {
frame = this.currentFrame;
addObject(object, x = 0, y = 0, time = undefined, layer=undefined) {
if (time == undefined) {
time = this.currentTime || 0;
}
if (layer==undefined) {
layer = this.activeLayer
}
// this.children.push(object);
layer.children.push(object)
object.parent = this;
object.x = x;
object.y = y;
let idx = object.idx;
// Add animation curves for the object's position/transform in the layer
let xCurve = new AnimationCurve(`child.${idx}.x`);
xCurve.addKeyframe(new Keyframe(time, x, 'linear'));
layer.animationData.setCurve(`child.${idx}.x`, xCurve);
let yCurve = new AnimationCurve(`child.${idx}.y`);
yCurve.addKeyframe(new Keyframe(time, y, 'linear'));
layer.animationData.setCurve(`child.${idx}.y`, yCurve);
let rotationCurve = new AnimationCurve(`child.${idx}.rotation`);
rotationCurve.addKeyframe(new Keyframe(time, 0, 'linear'));
layer.animationData.setCurve(`child.${idx}.rotation`, rotationCurve);
let scaleXCurve = new AnimationCurve(`child.${idx}.scale_x`);
scaleXCurve.addKeyframe(new Keyframe(time, 1, 'linear'));
layer.animationData.setCurve(`child.${idx}.scale_x`, scaleXCurve);
let scaleYCurve = new AnimationCurve(`child.${idx}.scale_y`);
scaleYCurve.addKeyframe(new Keyframe(time, 1, 'linear'));
layer.animationData.setCurve(`child.${idx}.scale_y`, scaleYCurve);
// LEGACY: Also update frame.keys for backwards compatibility
let frame = this.currentFrame;
if (frame) {
frame.keys[idx] = {
x: x,
y: y,
@ -4532,6 +4740,7 @@ class GraphicsObject extends Widget {
goToFrame: 1,
};
}
}
removeChild(childObject) {
let idx = childObject.idx;
for (let layer of this.layers) {
@ -5759,6 +5968,25 @@ function stage() {
stage.addEventListener("wheel", (event) => {
event.preventDefault();
// Check if this is a pinch-zoom gesture (ctrlKey is set on trackpad pinch)
if (event.ctrlKey) {
// Pinch zoom - zoom in/out based on deltaY
const zoomFactor = event.deltaY > 0 ? 0.95 : 1.05;
const oldZoom = context.zoomLevel;
context.zoomLevel = Math.max(1/8, Math.min(8, context.zoomLevel * zoomFactor));
// Update scroll position to zoom towards mouse
if (context.mousePos) {
const actualZoomFactor = context.zoomLevel / oldZoom;
stage.offsetX = (stage.offsetX + context.mousePos.x) * actualZoomFactor - context.mousePos.x;
stage.offsetY = (stage.offsetY + context.mousePos.y) * actualZoomFactor - context.mousePos.y;
}
updateUI();
updateMenu();
} else {
// Regular scroll
const deltaX = event.deltaX * config.scrollSpeed;
const deltaY = event.deltaY * config.scrollSpeed;
@ -5769,6 +5997,7 @@ function stage() {
lastResizeTime = currentTime;
updateUI();
}
}
});
// scroller.className = "scroll"
// stageWrapper.className = "stageWrapper"
@ -5912,7 +6141,7 @@ function stage() {
// context.lastMouse = mouse;
break;
case "select":
if (context.activeObject.currentFrame.frameType != "keyframe") break;
// No longer need keyframe check with AnimationData system
selection = selectVertex(context, mouse);
if (selection) {
context.dragging = true;
@ -5966,8 +6195,15 @@ function stage() {
i--
) {
child = context.activeObject.activeLayer.children[i];
if (!(child.idx in context.activeObject.currentFrame.keys))
continue;
// Check if child exists using AnimationData curves
let currentTime = context.activeObject.currentTime || 0;
let childX = context.activeObject.activeLayer.animationData.interpolate(`child.${child.idx}.x`, currentTime);
let childY = context.activeObject.activeLayer.animationData.interpolate(`child.${child.idx}.y`, currentTime);
// Skip if child doesn't have position data at current time
if (childX === null || childY === null) continue;
// let bbox = child.bbox()
if (hitTest(mouse, child)) {
if (context.selection.indexOf(child) != -1) {
@ -6245,6 +6481,15 @@ function stage() {
}
}
actions.editShape.create(context.activeCurve.shape, newCurves);
// Add the shape to selection after editing
if (e.shiftKey) {
if (!context.shapeselection.includes(context.activeCurve.shape)) {
context.shapeselection.push(context.activeCurve.shape);
}
} else {
context.shapeselection = [context.activeCurve.shape];
}
actions.select.create();
} else if (context.selection.length) {
actions.select.create();
// actions.editFrame.create(context.activeObject.currentFrame)
@ -6412,14 +6657,31 @@ function stage() {
context.activeCurve.startmouse,
).points;
} else {
// TODO: Add user preference for keyframing behavior:
// - Auto-keyframe (current): create/update keyframe at current time
// - Edit previous (Flash-style): update most recent keyframe before current time
// - Ephemeral (Blender-style): changes don't persist without manual keyframe
// Could also add modifier key (e.g. Shift) to toggle between modes
// Move selected children (groups) using AnimationData with auto-keyframing
for (let child of context.selection) {
if (!context.activeObject.currentFrame) continue;
if (!context.activeObject.currentFrame.keys) continue;
if (!(child.idx in context.activeObject.currentFrame.keys)) continue;
context.activeObject.currentFrame.keys[child.idx].x +=
mouse.x - context.lastMouse.x;
context.activeObject.currentFrame.keys[child.idx].y +=
mouse.y - context.lastMouse.y;
let currentTime = context.activeObject.currentTime || 0;
let layer = context.activeObject.activeLayer;
// Get current position from AnimationData
let childX = layer.animationData.interpolate(`child.${child.idx}.x`, currentTime);
let childY = layer.animationData.interpolate(`child.${child.idx}.y`, currentTime);
// Skip if child doesn't have position data
if (childX === null || childY === null) continue;
// Update position
let newX = childX + (mouse.x - context.lastMouse.x);
let newY = childY + (mouse.y - context.lastMouse.y);
// 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'));
}
}
} else if (context.selectionRect) {
@ -6432,11 +6694,16 @@ function stage() {
context.selection.push(child);
}
}
for (let shape of context.activeObject.currentFrame.shapes) {
// Use getVisibleShapes instead of currentFrame.shapes
let currentTime = context.activeObject?.currentTime || 0;
let layer = context.activeObject?.activeLayer;
if (layer) {
for (let shape of layer.getVisibleShapes(currentTime)) {
if (hitTest(regionToBbox(context.selectionRect), shape)) {
context.shapeselection.push(shape);
}
}
}
} else {
let selection = selectVertex(context, mouse);
if (selection) {
@ -6603,6 +6870,7 @@ function toolbar() {
for (let tool in tools) {
let toolbtn = document.createElement("button");
toolbtn.className = "toolbtn";
toolbtn.setAttribute("data-tool", tool); // For UI testing
let icon = document.createElement("img");
icon.className = "icon";
icon.src = tools[tool].icon;
@ -7040,6 +7308,133 @@ function timeline() {
return timeline_cvs;
}
function timelineV2() {
let canvas = document.createElement("canvas");
canvas.className = "timeline-v2";
// Create TimelineWindowV2 widget
const timelineWidget = new TimelineWindowV2(0, 0, context);
// Store reference in context for zoom controls
context.timelineWidget = timelineWidget;
// Update canvas size based on container
function updateCanvasSize() {
const canvasStyles = window.getComputedStyle(canvas);
canvas.width = parseInt(canvasStyles.width);
canvas.height = parseInt(canvasStyles.height);
// Update widget dimensions
timelineWidget.width = canvas.width;
timelineWidget.height = canvas.height;
// Render
const ctx = canvas.getContext("2d");
ctx.clearRect(0, 0, canvas.width, canvas.height);
timelineWidget.draw(ctx);
}
// Store updateCanvasSize on the widget so zoom controls can trigger redraw
timelineWidget.requestRedraw = updateCanvasSize;
// Add custom property to store the time format toggle button
// so createPane can add it to the header
canvas.headerControls = () => {
const toggleButton = document.createElement("button");
toggleButton.textContent = timelineWidget.timelineState.timeFormat === 'frames' ? 'Frames' : 'Seconds';
toggleButton.style.marginLeft = '10px';
toggleButton.addEventListener("click", () => {
timelineWidget.toggleTimeFormat();
toggleButton.textContent = timelineWidget.timelineState.timeFormat === 'frames' ? 'Frames' : 'Seconds';
updateCanvasSize(); // Redraw after format change
});
return [toggleButton];
};
// Set up ResizeObserver
const resizeObserver = new ResizeObserver(() => {
updateCanvasSize();
});
resizeObserver.observe(canvas);
// Mouse event handlers
canvas.addEventListener("pointerdown", (e) => {
const rect = canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
// Capture pointer to ensure we get move/up events even if cursor leaves canvas
canvas.setPointerCapture(e.pointerId);
timelineWidget.handleMouseEvent("mousedown", x, y);
updateCanvasSize(); // Redraw after interaction
});
canvas.addEventListener("pointermove", (e) => {
const rect = canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
timelineWidget.handleMouseEvent("mousemove", x, y);
updateCanvasSize(); // Redraw after interaction
});
canvas.addEventListener("pointerup", (e) => {
const rect = canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
// Release pointer capture
canvas.releasePointerCapture(e.pointerId);
timelineWidget.handleMouseEvent("mouseup", x, y);
updateCanvasSize(); // Redraw after interaction
});
// Add wheel event for pinch-zoom support
canvas.addEventListener("wheel", (event) => {
event.preventDefault();
// Check if this is a pinch-zoom gesture (ctrlKey is set on trackpad pinch)
if (event.ctrlKey) {
// Pinch zoom - zoom in/out based on deltaY
const zoomFactor = event.deltaY > 0 ? 0.95 : 1.05;
const oldPixelsPerSecond = timelineWidget.timelineState.pixelsPerSecond;
// Calculate the time under the mouse BEFORE zooming
const rect = canvas.getBoundingClientRect();
const mouseX = event.clientX - rect.left;
const mouseTimeBeforeZoom = timelineWidget.timelineState.pixelToTime(mouseX);
// Apply zoom
timelineWidget.timelineState.pixelsPerSecond *= zoomFactor;
// Clamp to reasonable range
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);
timelineWidget.timelineState.viewportStartTime = Math.max(0, timelineWidget.timelineState.viewportStartTime);
updateCanvasSize();
} else {
// Regular scroll - horizontal scroll for timeline
const deltaX = event.deltaX * config.scrollSpeed;
// Update viewport horizontal scroll
timelineWidget.timelineState.viewportStartTime += deltaX / timelineWidget.timelineState.pixelsPerSecond;
timelineWidget.timelineState.viewportStartTime = Math.max(0, timelineWidget.timelineState.viewportStartTime);
updateCanvasSize();
}
});
updateCanvasSize();
return canvas;
}
function infopanel() {
let panel = document.createElement("div");
panel.className = "infopanel";
@ -7264,6 +7659,14 @@ function createPane(paneType = undefined, div = undefined) {
event.stopPropagation();
});
// Add custom header controls if the content element provides them
if (content.headerControls && typeof content.headerControls === 'function') {
const controls = content.headerControls();
for (const control of controls) {
header.appendChild(control);
}
}
div.className = "vertical-grid";
header.style.height = "calc( 2 * var(--lineheight))";
content.style.height = "calc( 100% - 2 * var(--lineheight) )";
@ -7509,13 +7912,13 @@ function renderUI() {
context.ctx = ctx;
// root.draw(context);
root.draw(ctx)
root.draw(context)
if (context.activeObject != root) {
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(ctx);
context.activeObject.draw(context);
ctx.setTransform(transform)
}
if (context.activeShape) {
@ -8670,6 +9073,10 @@ const panes = {
name: "timeline",
func: timeline,
},
timelineV2: {
name: "timeline-v2",
func: timelineV2,
},
infopanel: {
name: "infopanel",
func: infopanel,

View File

@ -2,6 +2,7 @@ body {
width: 100%;
height: 100%;
overflow: hidden;
touch-action: none; /* Prevent default touch gestures including pinch-zoom */
}
* {

494
src/timeline.js Normal file
View File

@ -0,0 +1,494 @@
// Timeline V2 - New timeline implementation for AnimationData curve-based system
/**
* TimelineState - Global state for timeline display and interaction
*/
class TimelineState {
constructor(framerate = 24) {
// Time format settings
this.timeFormat = 'frames' // 'frames' | 'seconds' | 'measures'
this.framerate = framerate
// Zoom and viewport
this.pixelsPerSecond = 100 // Zoom level - how many pixels per second of animation
this.viewportStartTime = 0 // Horizontal scroll position (in seconds)
// Playhead
this.currentTime = 0 // Current time (in seconds)
// Ruler settings
this.rulerHeight = 30 // Height of time ruler in pixels
}
/**
* Convert time (seconds) to pixel position
*/
timeToPixel(time) {
return (time - this.viewportStartTime) * this.pixelsPerSecond
}
/**
* Convert pixel position to time (seconds)
*/
pixelToTime(pixel) {
return (pixel / this.pixelsPerSecond) + this.viewportStartTime
}
/**
* Convert time (seconds) to frame number
*/
timeToFrame(time) {
return Math.floor(time * this.framerate)
}
/**
* Convert frame number to time (seconds)
*/
frameToTime(frame) {
return frame / this.framerate
}
/**
* Calculate appropriate ruler interval based on zoom level
* Returns interval in seconds that gives ~50-100px spacing
*/
getRulerInterval() {
const targetPixelSpacing = 75 // Target pixels between major ticks
const timeSpacing = targetPixelSpacing / this.pixelsPerSecond // In seconds
// Standard interval options (in seconds)
const intervals = [
0.01, 0.02, 0.05, // 10ms, 20ms, 50ms
0.1, 0.2, 0.5, // 100ms, 200ms, 500ms
1, 2, 5, // 1s, 2s, 5s
10, 20, 30, 60, // 10s, 20s, 30s, 1min
120, 300, 600 // 2min, 5min, 10min
]
// Find closest interval
let bestInterval = intervals[0]
let bestDiff = Math.abs(timeSpacing - bestInterval)
for (let interval of intervals) {
const diff = Math.abs(timeSpacing - interval)
if (diff < bestDiff) {
bestDiff = diff
bestInterval = interval
}
}
return bestInterval
}
/**
* Calculate appropriate ruler interval for frame mode
* Returns interval in frames that gives ~50-100px spacing
*/
getRulerIntervalFrames() {
const targetPixelSpacing = 75
const pixelsPerFrame = this.pixelsPerSecond / this.framerate
const frameSpacing = targetPixelSpacing / pixelsPerFrame
// Standard frame intervals
const intervals = [1, 2, 5, 10, 20, 50, 100, 200, 500, 1000]
// Find closest interval
let bestInterval = intervals[0]
let bestDiff = Math.abs(frameSpacing - bestInterval)
for (let interval of intervals) {
const diff = Math.abs(frameSpacing - interval)
if (diff < bestDiff) {
bestDiff = diff
bestInterval = interval
}
}
return bestInterval
}
/**
* Format time for display based on current format setting
*/
formatTime(time) {
if (this.timeFormat === 'frames') {
return `${this.timeToFrame(time)}`
} else if (this.timeFormat === 'seconds') {
const minutes = Math.floor(time / 60)
const seconds = Math.floor(time % 60)
const ms = Math.floor((time % 1) * 10)
if (minutes > 0) {
return `${minutes}:${seconds.toString().padStart(2, '0')}`
} else {
return `${seconds}.${ms}s`
}
}
// measures format - TODO when DAW features added
return `${time.toFixed(2)}`
}
/**
* Zoom in (increase pixelsPerSecond)
*/
zoomIn(factor = 1.5) {
this.pixelsPerSecond *= factor
// Clamp to reasonable range
this.pixelsPerSecond = Math.min(this.pixelsPerSecond, 10000) // Max zoom
}
/**
* Zoom out (decrease pixelsPerSecond)
*/
zoomOut(factor = 1.5) {
this.pixelsPerSecond /= factor
// Clamp to reasonable range
this.pixelsPerSecond = Math.max(this.pixelsPerSecond, 10) // Min zoom
}
}
/**
* TimeRuler - Widget for displaying time ruler with adaptive intervals
*/
class TimeRuler {
constructor(timelineState) {
this.state = timelineState
this.height = timelineState.rulerHeight
}
/**
* Draw the time ruler
*/
draw(ctx, width) {
ctx.save()
// Background
ctx.fillStyle = '#2a2a2a'
ctx.fillRect(0, 0, width, this.height)
// Determine interval based on current zoom and format
let interval, isFrameMode
if (this.state.timeFormat === 'frames') {
interval = this.state.getRulerIntervalFrames() // In frames
isFrameMode = true
} else {
interval = this.state.getRulerInterval() // In seconds
isFrameMode = false
}
// Calculate visible time range
const startTime = this.state.viewportStartTime
const endTime = this.state.pixelToTime(width)
// Draw tick marks and labels
if (isFrameMode) {
this.drawFrameTicks(ctx, width, interval, startTime, endTime)
} else {
this.drawSecondTicks(ctx, width, interval, startTime, endTime)
}
// Draw playhead (current time indicator)
this.drawPlayhead(ctx, width)
ctx.restore()
}
/**
* Draw tick marks for frame mode
*/
drawFrameTicks(ctx, width, interval, startTime, endTime) {
const startFrame = Math.floor(this.state.timeToFrame(startTime) / interval) * interval
const endFrame = Math.ceil(this.state.timeToFrame(endTime) / interval) * interval
ctx.fillStyle = '#cccccc'
ctx.font = '11px sans-serif'
ctx.textAlign = 'center'
ctx.textBaseline = 'top'
for (let frame = startFrame; frame <= endFrame; frame += interval) {
const time = this.state.frameToTime(frame)
const x = this.state.timeToPixel(time)
if (x < 0 || x > width) continue
// Major tick
ctx.strokeStyle = '#888888'
ctx.lineWidth = 1
ctx.beginPath()
ctx.moveTo(x, this.height - 10)
ctx.lineTo(x, this.height)
ctx.stroke()
// Label
ctx.fillText(frame.toString(), x, 2)
// Minor ticks (subdivisions)
const minorInterval = interval / 5
if (minorInterval >= 1) {
for (let i = 1; i < 5; i++) {
const minorFrame = frame + (minorInterval * i)
const minorTime = this.state.frameToTime(minorFrame)
const minorX = this.state.timeToPixel(minorTime)
if (minorX < 0 || minorX > width) continue
ctx.strokeStyle = '#555555'
ctx.beginPath()
ctx.moveTo(minorX, this.height - 5)
ctx.lineTo(minorX, this.height)
ctx.stroke()
}
}
}
}
/**
* Draw tick marks for second mode
*/
drawSecondTicks(ctx, width, interval, startTime, endTime) {
const startTick = Math.floor(startTime / interval) * interval
const endTick = Math.ceil(endTime / interval) * interval
ctx.fillStyle = '#cccccc'
ctx.font = '11px sans-serif'
ctx.textAlign = 'center'
ctx.textBaseline = 'top'
for (let time = startTick; time <= endTick; time += interval) {
const x = this.state.timeToPixel(time)
if (x < 0 || x > width) continue
// Major tick
ctx.strokeStyle = '#888888'
ctx.lineWidth = 1
ctx.beginPath()
ctx.moveTo(x, this.height - 10)
ctx.lineTo(x, this.height)
ctx.stroke()
// Label
ctx.fillText(this.state.formatTime(time), x, 2)
// Minor ticks (subdivisions)
const minorInterval = interval / 5
for (let i = 1; i < 5; i++) {
const minorTime = time + (minorInterval * i)
const minorX = this.state.timeToPixel(minorTime)
if (minorX < 0 || minorX > width) continue
ctx.strokeStyle = '#555555'
ctx.beginPath()
ctx.moveTo(minorX, this.height - 5)
ctx.lineTo(minorX, this.height)
ctx.stroke()
}
}
}
/**
* Draw playhead (current time indicator)
*/
drawPlayhead(ctx, width) {
const x = this.state.timeToPixel(this.state.currentTime)
// Only draw if playhead is visible
if (x < 0 || x > width) return
ctx.strokeStyle = '#ff0000'
ctx.lineWidth = 2
ctx.beginPath()
ctx.moveTo(x, 0)
ctx.lineTo(x, this.height)
ctx.stroke()
// Playhead handle (triangle at top)
ctx.fillStyle = '#ff0000'
ctx.beginPath()
ctx.moveTo(x, 0)
ctx.lineTo(x - 6, 8)
ctx.lineTo(x + 6, 8)
ctx.closePath()
ctx.fill()
}
/**
* Hit test for playhead dragging (no longer used, kept for potential future use)
*/
hitTestPlayhead(x, y) {
const playheadX = this.state.timeToPixel(this.state.currentTime)
const distance = Math.abs(x - playheadX)
// 10px tolerance for hitting playhead
return distance < 10 && y >= 0 && y <= this.height
}
/**
* Handle mouse down - start dragging playhead
*/
mousedown(x, y) {
// Clicking anywhere in the ruler moves the playhead there
this.state.currentTime = this.state.pixelToTime(x)
this.state.currentTime = Math.max(0, this.state.currentTime)
this.draggingPlayhead = true
console.log("TimeRuler: Set draggingPlayhead = true, currentTime =", this.state.currentTime);
return true
}
/**
* Handle mouse move - drag playhead
*/
mousemove(x, y) {
console.log("TimeRuler.mousemove called, draggingPlayhead =", this.draggingPlayhead);
if (this.draggingPlayhead) {
const newTime = this.state.pixelToTime(x);
this.state.currentTime = Math.max(0, newTime);
console.log("TimeRuler: Updated currentTime to", this.state.currentTime);
return true
}
return false
}
/**
* Handle mouse up - stop dragging
*/
mouseup(x, y) {
if (this.draggingPlayhead) {
this.draggingPlayhead = false
return true
}
return false
}
}
/**
* TrackHierarchy - Builds and manages hierarchical track structure from GraphicsObject
* Phase 2: Track hierarchy display
*/
class TrackHierarchy {
constructor() {
this.tracks = [] // Flat list of tracks for rendering
this.trackHeight = 30 // Default track height in pixels
}
/**
* Build track list from GraphicsObject layers
* Creates a flattened list of tracks for rendering, maintaining hierarchy info
*/
buildTracks(graphicsObject) {
this.tracks = []
if (!graphicsObject || !graphicsObject.children) {
return
}
// Iterate through layers (GraphicsObject.children are Layers)
for (let layer of graphicsObject.children) {
// Add layer track
const layerTrack = {
type: 'layer',
object: layer,
name: layer.name || 'Layer',
indent: 0,
collapsed: layer.collapsed || false,
visible: layer.visible !== false
}
this.tracks.push(layerTrack)
// If layer is not collapsed, add its children
if (!layerTrack.collapsed) {
// Add child GraphicsObjects (nested groups)
if (layer.children) {
for (let child of layer.children) {
this.addObjectTrack(child, 1)
}
}
// Add shapes
if (layer.shapes) {
for (let shape of layer.shapes) {
this.addShapeTrack(shape, 1)
}
}
}
}
}
/**
* Recursively add object track and its children
*/
addObjectTrack(obj, indent) {
const track = {
type: 'object',
object: obj,
name: obj.name || obj.idx,
indent: indent,
collapsed: obj.trackCollapsed || false
}
this.tracks.push(track)
// If object is not collapsed, add its children
if (!track.collapsed && obj.children) {
for (let layer of obj.children) {
// Nested object's layers
const nestedLayerTrack = {
type: 'layer',
object: layer,
name: layer.name || 'Layer',
indent: indent + 1,
collapsed: layer.collapsed || false,
visible: layer.visible !== false
}
this.tracks.push(nestedLayerTrack)
if (!nestedLayerTrack.collapsed) {
// Add nested layer's children
if (layer.children) {
for (let child of layer.children) {
this.addObjectTrack(child, indent + 2)
}
}
if (layer.shapes) {
for (let shape of layer.shapes) {
this.addShapeTrack(shape, indent + 2)
}
}
}
}
}
}
/**
* Add shape track
*/
addShapeTrack(shape, indent) {
const track = {
type: 'shape',
object: shape,
name: shape.constructor.name || 'Shape',
indent: indent
}
this.tracks.push(track)
}
/**
* Calculate total height needed for all tracks
*/
getTotalHeight() {
return this.tracks.length * this.trackHeight
}
/**
* 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]
}
return null
}
}
export { TimelineState, TimeRuler, TrackHierarchy }

View File

@ -1,5 +1,6 @@
import { backgroundColor, foregroundColor, frameWidth, highlight, layerHeight, shade, shadow } from "./styles.js";
import { clamp, drawBorderedRect, drawCheckerboardBackground, hslToRgb, hsvToRgb, rgbToHex } from "./utils.js"
import { TimelineState, TimeRuler } from "./timeline.js"
function growBoundingBox(bboxa, bboxb) {
bboxa.x.min = Math.min(bboxa.x.min, bboxb.x.min);
@ -516,6 +517,109 @@ class TimelineWindow extends ScrollableWindow {
}
}
/**
* TimelineWindowV2 - New timeline widget using AnimationData curve-based system
* Phase 1: Time ruler with zoom-adaptive intervals and playhead
*/
class TimelineWindowV2 extends Widget {
constructor(x, y, context) {
super(x, y)
this.context = context
this.width = 800
this.height = 400
// Create shared timeline state (24 fps default)
this.timelineState = new TimelineState(24)
// Create time ruler widget
this.ruler = new TimeRuler(this.timelineState)
// Track if we're dragging playhead
this.draggingPlayhead = false
}
draw(ctx) {
ctx.save()
// Draw background
ctx.fillStyle = '#1e1e1e'
ctx.fillRect(0, 0, this.width, this.height)
// Draw time ruler at top
this.ruler.draw(ctx, this.width)
// TODO Phase 2: Draw track hierarchy below ruler
// TODO Phase 3: Draw segments
// TODO Phase 4: Draw minimized curves
ctx.restore()
}
mousedown(x, y) {
console.log("TimelineV2 mousedown:", x, y, "ruler height:", this.ruler.height);
// Check if clicking in ruler area
if (y <= this.ruler.height) {
// Let the ruler handle the mousedown (for playhead dragging)
const hitPlayhead = this.ruler.mousedown(x, y);
console.log("Ruler mousedown returned:", hitPlayhead);
if (hitPlayhead) {
this.draggingPlayhead = true
this._globalEvents.add("mousemove")
this._globalEvents.add("mouseup")
console.log("Started dragging playhead");
return true
}
}
return false
}
mousemove(x, y) {
if (this.draggingPlayhead) {
console.log("TimelineV2 mousemove while dragging:", x, y);
// Let the ruler handle the mousemove
this.ruler.mousemove(x, y)
// Sync GraphicsObject currentTime with timeline playhead
if (this.context.activeObject) {
this.context.activeObject.currentTime = this.timelineState.currentTime
}
return true
}
return false
}
mouseup(x, y) {
if (this.draggingPlayhead) {
// Let the ruler handle the mouseup
this.ruler.mouseup(x, y)
this.draggingPlayhead = false
this._globalEvents.delete("mousemove")
this._globalEvents.delete("mouseup")
return true
}
return false
}
// Zoom controls (can be called from keyboard shortcuts)
zoomIn() {
this.timelineState.zoomIn()
}
zoomOut() {
this.timelineState.zoomOut()
}
// Toggle time format
toggleTimeFormat() {
if (this.timelineState.timeFormat === 'frames') {
this.timelineState.timeFormat = 'seconds'
} else {
this.timelineState.timeFormat = 'frames'
}
}
}
export {
SCROLL,
Widget,
@ -527,5 +631,6 @@ export {
HBox, VBox,
ScrollableWindow,
ScrollableWindowHeaders,
TimelineWindow
TimelineWindow,
TimelineWindowV2
};