Work on moving things to animation curves
This commit is contained in:
parent
7bade4517c
commit
6c79914ffb
|
|
@ -3,7 +3,7 @@
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="stylesheet" href="styles.css" />
|
<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>
|
<title>Lightningbeam</title>
|
||||||
<script src="Tone.js"></script>
|
<script src="Tone.js"></script>
|
||||||
|
|
||||||
|
|
|
||||||
569
src/main.js
569
src/main.js
|
|
@ -60,7 +60,7 @@ import {
|
||||||
shadow,
|
shadow,
|
||||||
} from "./styles.js";
|
} from "./styles.js";
|
||||||
import { Icon } from "./icon.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 {
|
const {
|
||||||
writeTextFile: writeTextFile,
|
writeTextFile: writeTextFile,
|
||||||
readTextFile: readTextFile,
|
readTextFile: readTextFile,
|
||||||
|
|
@ -347,6 +347,7 @@ let context = {
|
||||||
selectedFrames: [],
|
selectedFrames: [],
|
||||||
dragDirection: undefined,
|
dragDirection: undefined,
|
||||||
zoomLevel: 1,
|
zoomLevel: 1,
|
||||||
|
timelineWidget: null, // Reference to TimelineWindowV2 widget for zoom controls
|
||||||
};
|
};
|
||||||
|
|
||||||
let config = {
|
let config = {
|
||||||
|
|
@ -661,7 +662,7 @@ let actions = {
|
||||||
fillImage: img,
|
fillImage: img,
|
||||||
strokeShape: false,
|
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, 0);
|
||||||
imageShape.addLine(img.width, img.height);
|
imageShape.addLine(img.width, img.height);
|
||||||
imageShape.addLine(0, img.height);
|
imageShape.addLine(0, img.height);
|
||||||
|
|
@ -1507,7 +1508,10 @@ let actions = {
|
||||||
let serializableShapes = [];
|
let serializableShapes = [];
|
||||||
let serializableObjects = [];
|
let serializableObjects = [];
|
||||||
let bbox;
|
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) {
|
for (let shape of context.shapeselection) {
|
||||||
serializableShapes.push(shape.idx);
|
serializableShapes.push(shape.idx);
|
||||||
if (bbox == undefined) {
|
if (bbox == undefined) {
|
||||||
|
|
@ -1516,8 +1520,11 @@ let actions = {
|
||||||
growBoundingBox(bbox, shape.bbox());
|
growBoundingBox(bbox, shape.bbox());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// For objects - check legacy frame system if available
|
||||||
|
const frame = context.activeObject.currentFrame;
|
||||||
for (let object of context.selection) {
|
for (let object of context.selection) {
|
||||||
if (object.idx in frame.keys) {
|
if (frame && object.idx in frame.keys) {
|
||||||
serializableObjects.push(object.idx);
|
serializableObjects.push(object.idx);
|
||||||
// TODO: rotated bbox
|
// TODO: rotated bbox
|
||||||
if (bbox == undefined) {
|
if (bbox == undefined) {
|
||||||
|
|
@ -1527,6 +1534,7 @@ let actions = {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
context.shapeselection = [];
|
context.shapeselection = [];
|
||||||
context.selection = [];
|
context.selection = [];
|
||||||
let action = {
|
let action = {
|
||||||
|
|
@ -1534,8 +1542,9 @@ let actions = {
|
||||||
objects: serializableObjects,
|
objects: serializableObjects,
|
||||||
groupUuid: uuidv4(),
|
groupUuid: uuidv4(),
|
||||||
parent: context.activeObject.idx,
|
parent: context.activeObject.idx,
|
||||||
frame: frame.idx,
|
frame: frame?.idx,
|
||||||
layer: context.activeObject.activeLayer.idx,
|
layer: layer.idx,
|
||||||
|
currentTime: currentTime,
|
||||||
position: {
|
position: {
|
||||||
x: (bbox.x.min + bbox.x.max) / 2,
|
x: (bbox.x.min + bbox.x.max) / 2,
|
||||||
y: (bbox.y.min + bbox.y.max) / 2,
|
y: (bbox.y.min + bbox.y.max) / 2,
|
||||||
|
|
@ -1548,41 +1557,62 @@ let actions = {
|
||||||
execute: (action) => {
|
execute: (action) => {
|
||||||
let group = new GraphicsObject(action.groupUuid);
|
let group = new GraphicsObject(action.groupUuid);
|
||||||
let parent = pointerList[action.parent];
|
let parent = pointerList[action.parent];
|
||||||
let frame = action.frame
|
let layer = pointerList[action.layer] || parent.activeLayer;
|
||||||
? pointerList[action.frame]
|
const currentTime = action.currentTime || 0;
|
||||||
: parent.currentFrame;
|
|
||||||
let layer;
|
// Move shapes from parent layer to group's first 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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (let shapeIdx of action.shapes) {
|
for (let shapeIdx of action.shapes) {
|
||||||
let shape = pointerList[shapeIdx];
|
let shape = pointerList[shapeIdx];
|
||||||
shape.translate(-action.position.x, -action.position.y);
|
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) {
|
for (let objectIdx of action.objects) {
|
||||||
let object = pointerList[objectIdx];
|
let object = pointerList[objectIdx];
|
||||||
|
if (frame && frame.keys && frame.keys[objectIdx]) {
|
||||||
group.addObject(
|
group.addObject(
|
||||||
object,
|
object,
|
||||||
frame.keys[objectIdx].x - action.position.x,
|
frame.keys[objectIdx].x - action.position.x,
|
||||||
frame.keys[objectIdx].y - action.position.y,
|
frame.keys[objectIdx].y - action.position.y,
|
||||||
|
currentTime
|
||||||
);
|
);
|
||||||
|
} else {
|
||||||
|
group.addObject(object, 0, 0, currentTime);
|
||||||
|
}
|
||||||
parent.removeChild(object);
|
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.selection = [group];
|
||||||
|
context.activeCurve = undefined;
|
||||||
|
context.activeVertex = undefined;
|
||||||
updateUI();
|
updateUI();
|
||||||
updateInfopanel();
|
updateInfopanel();
|
||||||
},
|
},
|
||||||
|
|
@ -1758,9 +1788,14 @@ let actions = {
|
||||||
selection.push(child.idx);
|
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);
|
shapeselection.push(shape.idx);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
let action = {
|
let action = {
|
||||||
selection: selection,
|
selection: selection,
|
||||||
shapeselection: shapeselection,
|
shapeselection: shapeselection,
|
||||||
|
|
@ -1944,13 +1979,7 @@ function selectCurve(context, mouse) {
|
||||||
let layer = context.activeObject?.activeLayer;
|
let layer = context.activeObject?.activeLayer;
|
||||||
if (!layer) return undefined;
|
if (!layer) return undefined;
|
||||||
|
|
||||||
for (let shape of layer.shapes) {
|
for (let shape of layer.getVisibleShapes(currentTime)) {
|
||||||
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;
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
mouse.x > shape.boundingBox.x.min - mouseTolerance &&
|
mouse.x > shape.boundingBox.x.min - mouseTolerance &&
|
||||||
mouse.x < shape.boundingBox.x.max + mouseTolerance &&
|
mouse.x < shape.boundingBox.x.max + mouseTolerance &&
|
||||||
|
|
@ -1978,8 +2007,13 @@ function selectVertex(context, mouse) {
|
||||||
let closestDist = mouseTolerance;
|
let closestDist = mouseTolerance;
|
||||||
let closestVertex = undefined;
|
let closestVertex = undefined;
|
||||||
let closestShape = 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 (
|
if (
|
||||||
mouse.x > shape.boundingBox.x.min - mouseTolerance &&
|
mouse.x > shape.boundingBox.x.min - mouseTolerance &&
|
||||||
mouse.x < shape.boundingBox.x.max + mouseTolerance &&
|
mouse.x < shape.boundingBox.x.max + mouseTolerance &&
|
||||||
|
|
@ -2355,12 +2389,18 @@ class AnimationCurve {
|
||||||
}
|
}
|
||||||
|
|
||||||
interpolate(time) {
|
interpolate(time) {
|
||||||
if (this.keyframes.length === 0) return null;
|
if (this.keyframes.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const { prev, next, t } = this.getBracketingKeyframes(time);
|
const { prev, next, t } = this.getBracketingKeyframes(time);
|
||||||
|
|
||||||
if (!prev || !next) return null;
|
if (!prev || !next) {
|
||||||
if (prev === next) return prev.value;
|
return null;
|
||||||
|
}
|
||||||
|
if (prev === next) {
|
||||||
|
return prev.value;
|
||||||
|
}
|
||||||
|
|
||||||
// Handle different interpolation types
|
// Handle different interpolation types
|
||||||
switch (prev.interpolation) {
|
switch (prev.interpolation) {
|
||||||
|
|
@ -2452,6 +2492,10 @@ class AnimationData {
|
||||||
delete this.curves[parameter];
|
delete this.curves[parameter];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setCurve(parameter, curve) {
|
||||||
|
this.curves[parameter] = curve;
|
||||||
|
}
|
||||||
|
|
||||||
interpolate(parameter, time) {
|
interpolate(parameter, time) {
|
||||||
const curve = this.curves[parameter];
|
const curve = this.curves[parameter];
|
||||||
if (!curve) return null;
|
if (!curve) return null;
|
||||||
|
|
@ -2521,7 +2565,7 @@ class Layer extends Widget {
|
||||||
|
|
||||||
// Load shapes if present
|
// Load shapes if present
|
||||||
if (json.shapes) {
|
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)
|
// 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) {
|
draw(ctx) {
|
||||||
// super.draw(ctx)
|
// super.draw(ctx)
|
||||||
if (!this.visible) return;
|
if (!this.visible) return;
|
||||||
|
|
@ -3163,7 +3222,7 @@ class Layer extends Widget {
|
||||||
strokeShape: false,
|
strokeShape: false,
|
||||||
sendToBack: true,
|
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);
|
shape.fromPoints(points, 1);
|
||||||
actions.addShape.create(context.activeObject, shape, cxt);
|
actions.addShape.create(context.activeObject, shape, cxt);
|
||||||
break;
|
break;
|
||||||
|
|
@ -3652,7 +3711,7 @@ class Shape extends BaseShape {
|
||||||
this.regionIdx = 0;
|
this.regionIdx = 0;
|
||||||
this.inProgress = true;
|
this.inProgress = true;
|
||||||
}
|
}
|
||||||
static fromJSON(json) {
|
static fromJSON(json, parent) {
|
||||||
let fillImage = undefined;
|
let fillImage = undefined;
|
||||||
if (json.fillImage && Object.keys(json.fillImage).length !== 0) {
|
if (json.fillImage && Object.keys(json.fillImage).length !== 0) {
|
||||||
let img = new Image();
|
let img = new Image();
|
||||||
|
|
@ -3672,6 +3731,7 @@ class Shape extends BaseShape {
|
||||||
fillShape: json.filled,
|
fillShape: json.filled,
|
||||||
strokeShape: json.stroked,
|
strokeShape: json.stroked,
|
||||||
},
|
},
|
||||||
|
parent,
|
||||||
json.idx,
|
json.idx,
|
||||||
json.shapeId,
|
json.shapeId,
|
||||||
);
|
);
|
||||||
|
|
@ -3774,6 +3834,7 @@ class Shape extends BaseShape {
|
||||||
this.startx,
|
this.startx,
|
||||||
this.starty,
|
this.starty,
|
||||||
{},
|
{},
|
||||||
|
this.parent,
|
||||||
idx.slice(0, 8) + this.idx.slice(8),
|
idx.slice(0, 8) + this.idx.slice(8),
|
||||||
this.shapeId,
|
this.shapeId,
|
||||||
);
|
);
|
||||||
|
|
@ -4231,15 +4292,24 @@ class GraphicsObject extends Widget {
|
||||||
}
|
}
|
||||||
bbox() {
|
bbox() {
|
||||||
let bbox;
|
let bbox;
|
||||||
// for (let layer of this.layers) {
|
|
||||||
// let frame = layer.getFrame(this.currentFrameNum);
|
// NEW: Include shapes from AnimationData system
|
||||||
// if (frame.shapes.length > 0 && bbox == undefined) {
|
let currentTime = this.currentTime || 0;
|
||||||
// bbox = structuredClone(frame.shapes[0].boundingBox);
|
for (let layer of this.layers) {
|
||||||
// }
|
for (let shape of layer.shapes) {
|
||||||
// for (let shape of frame.shapes) {
|
// Check if shape exists at current time
|
||||||
// growBoundingBox(bbox, shape.boundingBox);
|
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 (this.children.length > 0) {
|
||||||
if (!bbox) {
|
if (!bbox) {
|
||||||
bbox = structuredClone(this.children[0].bbox());
|
bbox = structuredClone(this.children[0].bbox());
|
||||||
|
|
@ -4248,6 +4318,7 @@ class GraphicsObject extends Widget {
|
||||||
growBoundingBox(bbox, child.bbox());
|
growBoundingBox(bbox, child.bbox());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (bbox == undefined) {
|
if (bbox == undefined) {
|
||||||
bbox = { x: { min: 0, max: 0 }, y: { min: 0, max: 0 } };
|
bbox = { x: { min: 0, max: 0 }, y: { min: 0, max: 0 } };
|
||||||
}
|
}
|
||||||
|
|
@ -4259,7 +4330,7 @@ class GraphicsObject extends Widget {
|
||||||
bbox.y.max += this.y;
|
bbox.y.max += this.y;
|
||||||
return bbox;
|
return bbox;
|
||||||
}
|
}
|
||||||
/*
|
|
||||||
draw(context, calculateTransform=false) {
|
draw(context, calculateTransform=false) {
|
||||||
let ctx = context.ctx;
|
let ctx = context.ctx;
|
||||||
ctx.save();
|
ctx.save();
|
||||||
|
|
@ -4279,34 +4350,142 @@ class GraphicsObject extends Widget {
|
||||||
this.idx in context.activeAction.selection
|
this.idx in context.activeAction.selection
|
||||||
)
|
)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
for (let layer of this.layers) {
|
for (let layer of this.layers) {
|
||||||
if (context.activeObject == this && !layer.visible) continue;
|
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}
|
let cxt = {...context}
|
||||||
if (context.shapeselection.indexOf(shape) >= 0) {
|
if (context.shapeselection.indexOf(shape) >= 0) {
|
||||||
cxt.selected = true
|
cxt.selected = true
|
||||||
}
|
}
|
||||||
shape.draw(cxt);
|
shape.draw(cxt);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Draw child objects using AnimationData curves
|
||||||
for (let child of layer.children) {
|
for (let child of layer.children) {
|
||||||
if (child == context.activeObject) continue;
|
if (child == context.activeObject) continue;
|
||||||
let idx = child.idx;
|
let idx = child.idx;
|
||||||
if (idx in frame.keys) {
|
|
||||||
child.x = frame.keys[idx].x;
|
// Use AnimationData to get child's transform
|
||||||
child.y = frame.keys[idx].y;
|
let childX = layer.animationData.interpolate(`child.${idx}.x`, currentTime);
|
||||||
child.rotation = frame.keys[idx].rotation;
|
let childY = layer.animationData.interpolate(`child.${idx}.y`, currentTime);
|
||||||
child.scale_x = frame.keys[idx].scale_x;
|
let childRotation = layer.animationData.interpolate(`child.${idx}.rotation`, currentTime);
|
||||||
child.scale_y = frame.keys[idx].scale_y;
|
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();
|
ctx.save();
|
||||||
child.draw(context);
|
child.draw(context);
|
||||||
if (true) {
|
|
||||||
}
|
|
||||||
ctx.restore();
|
ctx.restore();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (this == context.activeObject) {
|
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) {
|
if (context.activeCurve) {
|
||||||
ctx.strokeStyle = "magenta";
|
ctx.strokeStyle = "magenta";
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
|
|
@ -4358,7 +4537,10 @@ class GraphicsObject extends Widget {
|
||||||
ctx.fill();
|
ctx.fill();
|
||||||
ctx.restore();
|
ctx.restore();
|
||||||
}
|
}
|
||||||
*/
|
}
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
/*
|
||||||
draw(ctx) {
|
draw(ctx) {
|
||||||
super.draw(ctx)
|
super.draw(ctx)
|
||||||
if (this==context.activeObject) {
|
if (this==context.activeObject) {
|
||||||
|
|
@ -4440,6 +4622,7 @@ class GraphicsObject extends Widget {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
*/
|
||||||
transformCanvas(ctx) {
|
transformCanvas(ctx) {
|
||||||
if (this.parent) {
|
if (this.parent) {
|
||||||
this.parent.transformCanvas(ctx)
|
this.parent.transformCanvas(ctx)
|
||||||
|
|
@ -4510,19 +4693,44 @@ class GraphicsObject extends Widget {
|
||||||
}
|
}
|
||||||
super.handleMouseEvent(eventType, x, y)
|
super.handleMouseEvent(eventType, x, y)
|
||||||
}
|
}
|
||||||
addObject(object, x = 0, y = 0, frame = undefined, layer=undefined) {
|
addObject(object, x = 0, y = 0, time = undefined, layer=undefined) {
|
||||||
if (frame == undefined) {
|
if (time == undefined) {
|
||||||
frame = this.currentFrame;
|
time = this.currentTime || 0;
|
||||||
}
|
}
|
||||||
if (layer==undefined) {
|
if (layer==undefined) {
|
||||||
layer = this.activeLayer
|
layer = this.activeLayer
|
||||||
}
|
}
|
||||||
// this.children.push(object);
|
|
||||||
layer.children.push(object)
|
layer.children.push(object)
|
||||||
object.parent = this;
|
object.parent = this;
|
||||||
object.x = x;
|
object.x = x;
|
||||||
object.y = y;
|
object.y = y;
|
||||||
let idx = object.idx;
|
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] = {
|
frame.keys[idx] = {
|
||||||
x: x,
|
x: x,
|
||||||
y: y,
|
y: y,
|
||||||
|
|
@ -4532,6 +4740,7 @@ class GraphicsObject extends Widget {
|
||||||
goToFrame: 1,
|
goToFrame: 1,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
}
|
||||||
removeChild(childObject) {
|
removeChild(childObject) {
|
||||||
let idx = childObject.idx;
|
let idx = childObject.idx;
|
||||||
for (let layer of this.layers) {
|
for (let layer of this.layers) {
|
||||||
|
|
@ -5759,6 +5968,25 @@ function stage() {
|
||||||
|
|
||||||
stage.addEventListener("wheel", (event) => {
|
stage.addEventListener("wheel", (event) => {
|
||||||
event.preventDefault();
|
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 deltaX = event.deltaX * config.scrollSpeed;
|
||||||
const deltaY = event.deltaY * config.scrollSpeed;
|
const deltaY = event.deltaY * config.scrollSpeed;
|
||||||
|
|
||||||
|
|
@ -5769,6 +5997,7 @@ function stage() {
|
||||||
lastResizeTime = currentTime;
|
lastResizeTime = currentTime;
|
||||||
updateUI();
|
updateUI();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
// scroller.className = "scroll"
|
// scroller.className = "scroll"
|
||||||
// stageWrapper.className = "stageWrapper"
|
// stageWrapper.className = "stageWrapper"
|
||||||
|
|
@ -5912,7 +6141,7 @@ function stage() {
|
||||||
// context.lastMouse = mouse;
|
// context.lastMouse = mouse;
|
||||||
break;
|
break;
|
||||||
case "select":
|
case "select":
|
||||||
if (context.activeObject.currentFrame.frameType != "keyframe") break;
|
// No longer need keyframe check with AnimationData system
|
||||||
selection = selectVertex(context, mouse);
|
selection = selectVertex(context, mouse);
|
||||||
if (selection) {
|
if (selection) {
|
||||||
context.dragging = true;
|
context.dragging = true;
|
||||||
|
|
@ -5966,8 +6195,15 @@ function stage() {
|
||||||
i--
|
i--
|
||||||
) {
|
) {
|
||||||
child = context.activeObject.activeLayer.children[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()
|
// let bbox = child.bbox()
|
||||||
if (hitTest(mouse, child)) {
|
if (hitTest(mouse, child)) {
|
||||||
if (context.selection.indexOf(child) != -1) {
|
if (context.selection.indexOf(child) != -1) {
|
||||||
|
|
@ -6245,6 +6481,15 @@ function stage() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
actions.editShape.create(context.activeCurve.shape, newCurves);
|
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) {
|
} else if (context.selection.length) {
|
||||||
actions.select.create();
|
actions.select.create();
|
||||||
// actions.editFrame.create(context.activeObject.currentFrame)
|
// actions.editFrame.create(context.activeObject.currentFrame)
|
||||||
|
|
@ -6412,14 +6657,31 @@ function stage() {
|
||||||
context.activeCurve.startmouse,
|
context.activeCurve.startmouse,
|
||||||
).points;
|
).points;
|
||||||
} else {
|
} 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) {
|
for (let child of context.selection) {
|
||||||
if (!context.activeObject.currentFrame) continue;
|
let currentTime = context.activeObject.currentTime || 0;
|
||||||
if (!context.activeObject.currentFrame.keys) continue;
|
let layer = context.activeObject.activeLayer;
|
||||||
if (!(child.idx in context.activeObject.currentFrame.keys)) continue;
|
|
||||||
context.activeObject.currentFrame.keys[child.idx].x +=
|
// Get current position from AnimationData
|
||||||
mouse.x - context.lastMouse.x;
|
let childX = layer.animationData.interpolate(`child.${child.idx}.x`, currentTime);
|
||||||
context.activeObject.currentFrame.keys[child.idx].y +=
|
let childY = layer.animationData.interpolate(`child.${child.idx}.y`, currentTime);
|
||||||
mouse.y - context.lastMouse.y;
|
|
||||||
|
// 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) {
|
} else if (context.selectionRect) {
|
||||||
|
|
@ -6432,11 +6694,16 @@ function stage() {
|
||||||
context.selection.push(child);
|
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)) {
|
if (hitTest(regionToBbox(context.selectionRect), shape)) {
|
||||||
context.shapeselection.push(shape);
|
context.shapeselection.push(shape);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
let selection = selectVertex(context, mouse);
|
let selection = selectVertex(context, mouse);
|
||||||
if (selection) {
|
if (selection) {
|
||||||
|
|
@ -6603,6 +6870,7 @@ function toolbar() {
|
||||||
for (let tool in tools) {
|
for (let tool in tools) {
|
||||||
let toolbtn = document.createElement("button");
|
let toolbtn = document.createElement("button");
|
||||||
toolbtn.className = "toolbtn";
|
toolbtn.className = "toolbtn";
|
||||||
|
toolbtn.setAttribute("data-tool", tool); // For UI testing
|
||||||
let icon = document.createElement("img");
|
let icon = document.createElement("img");
|
||||||
icon.className = "icon";
|
icon.className = "icon";
|
||||||
icon.src = tools[tool].icon;
|
icon.src = tools[tool].icon;
|
||||||
|
|
@ -7040,6 +7308,133 @@ function timeline() {
|
||||||
return timeline_cvs;
|
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() {
|
function infopanel() {
|
||||||
let panel = document.createElement("div");
|
let panel = document.createElement("div");
|
||||||
panel.className = "infopanel";
|
panel.className = "infopanel";
|
||||||
|
|
@ -7264,6 +7659,14 @@ function createPane(paneType = undefined, div = undefined) {
|
||||||
event.stopPropagation();
|
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";
|
div.className = "vertical-grid";
|
||||||
header.style.height = "calc( 2 * var(--lineheight))";
|
header.style.height = "calc( 2 * var(--lineheight))";
|
||||||
content.style.height = "calc( 100% - 2 * var(--lineheight) )";
|
content.style.height = "calc( 100% - 2 * var(--lineheight) )";
|
||||||
|
|
@ -7509,13 +7912,13 @@ function renderUI() {
|
||||||
|
|
||||||
context.ctx = ctx;
|
context.ctx = ctx;
|
||||||
// root.draw(context);
|
// root.draw(context);
|
||||||
root.draw(ctx)
|
root.draw(context)
|
||||||
if (context.activeObject != root) {
|
if (context.activeObject != root) {
|
||||||
ctx.fillStyle = "rgba(255,255,255,0.5)";
|
ctx.fillStyle = "rgba(255,255,255,0.5)";
|
||||||
ctx.fillRect(0, 0, config.fileWidth, config.fileHeight);
|
ctx.fillRect(0, 0, config.fileWidth, config.fileHeight);
|
||||||
const transform = ctx.getTransform()
|
const transform = ctx.getTransform()
|
||||||
context.activeObject.transformCanvas(ctx)
|
context.activeObject.transformCanvas(ctx)
|
||||||
context.activeObject.draw(ctx);
|
context.activeObject.draw(context);
|
||||||
ctx.setTransform(transform)
|
ctx.setTransform(transform)
|
||||||
}
|
}
|
||||||
if (context.activeShape) {
|
if (context.activeShape) {
|
||||||
|
|
@ -8670,6 +9073,10 @@ const panes = {
|
||||||
name: "timeline",
|
name: "timeline",
|
||||||
func: timeline,
|
func: timeline,
|
||||||
},
|
},
|
||||||
|
timelineV2: {
|
||||||
|
name: "timeline-v2",
|
||||||
|
func: timelineV2,
|
||||||
|
},
|
||||||
infopanel: {
|
infopanel: {
|
||||||
name: "infopanel",
|
name: "infopanel",
|
||||||
func: infopanel,
|
func: infopanel,
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ body {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
overflow: hidden;
|
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 { backgroundColor, foregroundColor, frameWidth, highlight, layerHeight, shade, shadow } from "./styles.js";
|
||||||
import { clamp, drawBorderedRect, drawCheckerboardBackground, hslToRgb, hsvToRgb, rgbToHex } from "./utils.js"
|
import { clamp, drawBorderedRect, drawCheckerboardBackground, hslToRgb, hsvToRgb, rgbToHex } from "./utils.js"
|
||||||
|
import { TimelineState, TimeRuler } from "./timeline.js"
|
||||||
|
|
||||||
function growBoundingBox(bboxa, bboxb) {
|
function growBoundingBox(bboxa, bboxb) {
|
||||||
bboxa.x.min = Math.min(bboxa.x.min, bboxb.x.min);
|
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 {
|
export {
|
||||||
SCROLL,
|
SCROLL,
|
||||||
Widget,
|
Widget,
|
||||||
|
|
@ -527,5 +631,6 @@ export {
|
||||||
HBox, VBox,
|
HBox, VBox,
|
||||||
ScrollableWindow,
|
ScrollableWindow,
|
||||||
ScrollableWindowHeaders,
|
ScrollableWindowHeaders,
|
||||||
TimelineWindow
|
TimelineWindow,
|
||||||
|
TimelineWindowV2
|
||||||
};
|
};
|
||||||
Loading…
Reference in New Issue