Work on moving things to animation curves
This commit is contained in:
parent
7bade4517c
commit
6c79914ffb
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
569
src/main.js
569
src/main.js
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ body {
|
|||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
touch-action: none; /* Prevent default touch gestures including pinch-zoom */
|
||||
}
|
||||
|
||||
* {
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
107
src/widgets.js
107
src/widgets.js
|
|
@ -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
|
||||
};
|
||||
Loading…
Reference in New Issue