Lightningbeam/src/actions/index.js

2308 lines
73 KiB
JavaScript

// Actions module - extracted from main.js
// This module contains all the undo/redo-able actions for the application
// Imports for dependencies
import { context, pointerList } from '../state.js';
import { Shape } from '../models/shapes.js';
import { Bezier } from '../bezier.js';
import {
Keyframe,
AnimationCurve,
AnimationData,
Frame
} from '../models/animation.js';
import { GraphicsObject } from '../models/graphics-object.js';
import { Layer, AudioTrack } from '../models/layer.js';
import {
arraysAreEqual,
lerp,
lerpColor,
generateWaveform,
signedAngleBetweenVectors,
rotateAroundPointIncremental,
getRotatedBoundingBox,
growBoundingBox
} from '../utils.js';
// UUID generation function (keeping local version for now)
function uuidv4() {
return "10000000-1000-4000-8000-100000000000".replace(/[018]/g, (c) =>
(
+c ^
(crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (+c / 4)))
).toString(16),
);
}
// Dependencies that will be injected
let undoStack = null;
let redoStack = null;
let updateMenu = null;
let updateLayers = null;
let updateUI = null;
let updateInfopanel = null;
let invoke = null;
let config = null;
/**
* Initialize the actions module with required dependencies
* @param {Object} deps - Dependencies object
* @param {Array} deps.undoStack - Reference to the undo stack
* @param {Array} deps.redoStack - Reference to the redo stack
* @param {Function} deps.updateMenu - Function to update the menu
* @param {Function} deps.updateLayers - Function to update layers UI
* @param {Function} deps.updateUI - Function to update main UI
* @param {Function} deps.updateInfopanel - Function to update info panel
* @param {Function} deps.invoke - Tauri invoke function
* @param {Object} deps.config - Application config object
*/
export function initializeActions(deps) {
undoStack = deps.undoStack;
redoStack = deps.redoStack;
updateMenu = deps.updateMenu;
updateLayers = deps.updateLayers;
updateUI = deps.updateUI;
updateInfopanel = deps.updateInfopanel;
invoke = deps.invoke;
config = deps.config;
}
export const actions = {
addShape: {
create: (parent, shape, ctx) => {
// parent should be a GraphicsObject
if (!parent.activeLayer) return;
if (shape.curves.length == 0) return;
redoStack.length = 0; // Clear redo stack
let serializableCurves = [];
for (let curve of shape.curves) {
serializableCurves.push({ points: curve.points, color: curve.color });
}
let c = {
...context,
...ctx,
};
let action = {
parent: parent.idx,
layer: parent.activeLayer.idx,
curves: serializableCurves,
startx: shape.startx,
starty: shape.starty,
context: {
fillShape: c.fillShape,
strokeShape: c.strokeShape,
fillStyle: c.fillStyle,
sendToBack: c.sendToBack,
lineWidth: c.lineWidth,
},
uuid: uuidv4(),
time: parent.currentTime, // Use currentTime instead of currentFrame
};
undoStack.push({ name: "addShape", action: action });
actions.addShape.execute(action);
updateMenu();
updateLayers();
},
execute: (action) => {
let layer = pointerList[action.layer];
let curvesList = action.curves;
let cxt = {
...context,
...action.context,
};
let shape = new Shape(action.startx, action.starty, cxt, layer, action.uuid);
for (let curve of curvesList) {
shape.addCurve(
new Bezier(
curve.points[0].x,
curve.points[0].y,
curve.points[1].x,
curve.points[1].y,
curve.points[2].x,
curve.points[2].y,
curve.points[3].x,
curve.points[3].y,
).setColor(curve.color),
);
}
let shapes = shape.update();
for (let newShape of shapes) {
// Add shape to layer's shapes array
layer.shapes.push(newShape);
// Determine zOrder based on sendToBack
let zOrder;
if (cxt.sendToBack) {
// Insert at back (zOrder 0), shift all other shapes up
zOrder = 0;
// Increment zOrder for all existing shapes
for (let existingShape of layer.shapes) {
if (existingShape !== newShape) {
let existingZOrderCurve = layer.animationData.curves[`shape.${existingShape.shapeId}.zOrder`];
if (existingZOrderCurve) {
// Find keyframe at this time and increment it
for (let kf of existingZOrderCurve.keyframes) {
if (kf.time === action.time) {
kf.value += 1;
}
}
}
}
}
} else {
// Insert at front (max zOrder + 1)
zOrder = layer.shapes.length - 1;
}
// Add keyframes to AnimationData for this shape
// Use shapeId (not idx) so that multiple versions share curves
let existsKeyframe = new Keyframe(action.time, 1, "hold");
layer.animationData.addKeyframe(`shape.${newShape.shapeId}.exists`, existsKeyframe);
let zOrderKeyframe = new Keyframe(action.time, zOrder, "hold");
layer.animationData.addKeyframe(`shape.${newShape.shapeId}.zOrder`, zOrderKeyframe);
let shapeIndexKeyframe = new Keyframe(action.time, 0, "linear");
layer.animationData.addKeyframe(`shape.${newShape.shapeId}.shapeIndex`, shapeIndexKeyframe);
}
},
rollback: (action) => {
let layer = pointerList[action.layer];
let shape = pointerList[action.uuid];
// Remove shape from layer's shapes array
let shapeIndex = layer.shapes.indexOf(shape);
if (shapeIndex !== -1) {
layer.shapes.splice(shapeIndex, 1);
}
// Remove keyframes from AnimationData (use shapeId not idx)
delete layer.animationData.curves[`shape.${shape.shapeId}.exists`];
delete layer.animationData.curves[`shape.${shape.shapeId}.zOrder`];
delete layer.animationData.curves[`shape.${shape.shapeId}.shapeIndex`];
delete pointerList[action.uuid];
},
},
editShape: {
create: (shape, newCurves) => {
redoStack.length = 0; // Clear redo stack
let serializableNewCurves = [];
for (let curve of newCurves) {
serializableNewCurves.push({
points: curve.points,
color: curve.color,
});
}
let serializableOldCurves = [];
for (let curve of shape.curves) {
serializableOldCurves.push({ points: curve.points });
}
let action = {
shape: shape.idx,
oldCurves: serializableOldCurves,
newCurves: serializableNewCurves,
};
undoStack.push({ name: "editShape", action: action });
actions.editShape.execute(action);
},
execute: (action) => {
let shape = pointerList[action.shape];
let curvesList = action.newCurves;
shape.curves = [];
for (let curve of curvesList) {
shape.addCurve(
new Bezier(
curve.points[0].x,
curve.points[0].y,
curve.points[1].x,
curve.points[1].y,
curve.points[2].x,
curve.points[2].y,
curve.points[3].x,
curve.points[3].y,
).setColor(curve.color),
);
}
shape.update();
updateUI();
},
rollback: (action) => {
let shape = pointerList[action.shape];
let curvesList = action.oldCurves;
shape.curves = [];
for (let curve of curvesList) {
shape.addCurve(
new Bezier(
curve.points[0].x,
curve.points[0].y,
curve.points[1].x,
curve.points[1].y,
curve.points[2].x,
curve.points[2].y,
curve.points[3].x,
curve.points[3].y,
).setColor(curve.color),
);
}
shape.update();
},
},
colorShape: {
create: (shape, color) => {
redoStack.length = 0; // Clear redo stack
let action = {
shape: shape.idx,
oldColor: shape.fillStyle,
newColor: color,
};
undoStack.push({ name: "colorShape", action: action });
actions.colorShape.execute(action);
updateMenu();
},
execute: (action) => {
let shape = pointerList[action.shape];
shape.fillStyle = action.newColor;
},
rollback: (action) => {
let shape = pointerList[action.shape];
shape.fillStyle = action.oldColor;
},
},
addImageObject: {
create: (x, y, imgsrc, ix, parent) => {
redoStack.length = 0; // Clear redo stack
let action = {
shapeUuid: uuidv4(),
objectUuid: uuidv4(),
x: x,
y: y,
src: imgsrc,
ix: ix,
parent: parent.idx,
};
undoStack.push({ name: "addImageObject", action: action });
actions.addImageObject.execute(action);
updateMenu();
},
execute: async (action) => {
let imageObject = new GraphicsObject(action.objectUuid);
function loadImage(src) {
return new Promise((resolve, reject) => {
let img = new Image();
img.onload = () => resolve(img); // Resolve the promise with the image once loaded
img.onerror = (err) => reject(err); // Reject the promise if there's an error loading the image
img.src = src; // Start loading the image
});
}
let img = await loadImage(action.src);
// img.onload = function() {
let ct = {
...context,
fillImage: img,
strokeShape: false,
};
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);
imageShape.addLine(0, 0);
imageShape.update();
imageShape.fillImage = img;
imageShape.filled = true;
// Add shape to layer using new AnimationData-aware method
const time = imageObject.currentTime || 0;
imageObject.activeLayer.addShape(imageShape, time);
let parent = pointerList[action.parent];
parent.addObject(
imageObject,
action.x - img.width / 2 + 20 * action.ix,
action.y - img.height / 2 + 20 * action.ix,
);
updateUI();
// }
// img.src = action.src
},
rollback: (action) => {
let shape = pointerList[action.shapeUuid];
let object = pointerList[action.objectUuid];
let parent = pointerList[action.parent];
object.getFrame(0).removeShape(shape);
delete pointerList[action.shapeUuid];
parent.removeChild(object);
delete pointerList[action.objectUuid];
let selectIndex = context.selection.indexOf(object);
if (selectIndex >= 0) {
context.selection.splice(selectIndex, 1);
}
},
},
addAudio: {
create: (filePath, object, audioname) => {
redoStack.length = 0;
let action = {
filePath: filePath,
audioname: audioname,
trackuuid: uuidv4(),
object: object.idx,
};
undoStack.push({ name: "addAudio", action: action });
actions.addAudio.execute(action);
updateMenu();
},
execute: async (action) => {
// Create new AudioTrack with DAW backend
let newAudioTrack = new AudioTrack(action.trackuuid, action.audioname);
let object = pointerList[action.object];
// Add placeholder clip immediately so user sees feedback
newAudioTrack.clips.push({
clipId: 0,
poolIndex: 0,
name: 'Loading...',
startTime: 0,
duration: 10,
offset: 0,
loading: true
});
// Add track to object immediately
object.audioTracks.push(newAudioTrack);
// Update UI to show placeholder
updateLayers();
if (context.timelineWidget) {
context.timelineWidget.requestRedraw();
}
// Load audio asynchronously and update clip
try {
// Initialize track in backend and load audio file
await newAudioTrack.initializeTrack();
const metadata = await newAudioTrack.loadAudioFile(action.filePath);
// Use actual duration from the audio file metadata
const duration = metadata.duration;
// Replace placeholder clip with real clip
newAudioTrack.clips[0] = {
clipId: 0,
poolIndex: metadata.pool_index,
name: action.audioname,
startTime: 0,
duration: duration,
offset: 0,
loading: false,
waveform: metadata.waveform // Store waveform data for rendering
};
// Add clip to backend (call backend directly to avoid duplicate push)
const { invoke } = window.__TAURI__.core
await invoke('audio_add_clip', {
trackId: newAudioTrack.audioTrackId,
poolIndex: metadata.pool_index,
startTime: 0,
duration: duration,
offset: 0
});
// Update UI with real clip data
updateLayers();
if (context.timelineWidget) {
context.timelineWidget.requestRedraw();
}
} catch (error) {
console.error('Failed to load audio:', error);
// Update clip to show error
newAudioTrack.clips[0].name = 'Error loading';
newAudioTrack.clips[0].loading = false;
if (context.timelineWidget) {
context.timelineWidget.requestRedraw();
}
}
},
rollback: (action) => {
let object = pointerList[action.object];
let track = pointerList[action.trackuuid];
object.audioTracks.splice(object.audioTracks.indexOf(track), 1);
updateLayers();
if (context.timelineWidget) {
context.timelineWidget.requestRedraw();
}
},
},
addMIDI: {
create: (filePath, object, midiname) => {
redoStack.length = 0;
let action = {
filePath: filePath,
midiname: midiname,
trackuuid: uuidv4(),
object: object.idx,
};
undoStack.push({ name: "addMIDI", action: action });
actions.addMIDI.execute(action);
updateMenu();
},
execute: async (action) => {
// Create new AudioTrack with type='midi' for MIDI files
let newMIDITrack = new AudioTrack(action.trackuuid, action.midiname, 'midi');
let object = pointerList[action.object];
// Note: MIDI tracks now use node-based instruments via instrument_graph
const { invoke } = window.__TAURI__.core;
// Add placeholder clip immediately so user sees feedback
newMIDITrack.clips.push({
clipId: 0,
name: 'Loading...',
startTime: 0,
duration: 10,
loading: true
});
// Add track to object immediately
object.audioTracks.push(newMIDITrack);
// Update UI to show placeholder
updateLayers();
if (context.timelineWidget) {
context.timelineWidget.requestRedraw();
}
// Load MIDI file asynchronously and update clip
try {
// Initialize track in backend
await newMIDITrack.initializeTrack();
// Load MIDI file into the track
const metadata = await invoke('audio_load_midi_file', {
trackId: newMIDITrack.audioTrackId,
path: action.filePath,
startTime: 0
});
// Replace placeholder clip with real clip including note data
newMIDITrack.clips[0] = {
clipId: 0,
name: action.midiname,
startTime: 0,
duration: metadata.duration,
notes: metadata.notes, // Store MIDI notes for visualization
loading: false
};
// Update UI with real clip data
updateLayers();
if (context.timelineWidget) {
context.timelineWidget.requestRedraw();
}
} catch (error) {
console.error('Failed to load MIDI file:', error);
// Update clip to show error
newMIDITrack.clips[0].name = 'Error loading';
newMIDITrack.clips[0].loading = false;
if (context.timelineWidget) {
context.timelineWidget.requestRedraw();
}
}
},
rollback: (action) => {
let object = pointerList[action.object];
let track = pointerList[action.trackuuid];
object.audioTracks.splice(object.audioTracks.indexOf(track), 1);
updateLayers();
if (context.timelineWidget) {
context.timelineWidget.requestRedraw();
}
},
},
duplicateObject: {
create: (items) => {
redoStack.length = 0;
function deepCopyWithIdxMapping(obj, dictionary = {}) {
if (Array.isArray(obj)) {
return obj.map(item => deepCopyWithIdxMapping(item, dictionary));
}
if (obj === null || typeof obj !== 'object') {
return obj;
}
const newObj = {};
for (const key in obj) {
let value = obj[key];
if (key === 'idx' && !(value in dictionary)) {
dictionary[value] = uuidv4();
}
newObj[key] = value in dictionary ? dictionary[value] : value;
if (typeof newObj[key] === 'object' && newObj[key] !== null) {
newObj[key] = deepCopyWithIdxMapping(newObj[key], dictionary);
}
}
return newObj;
}
let action = {
items: deepCopyWithIdxMapping(items),
object: context.activeObject.idx,
layer: context.activeObject.activeLayer.idx,
time: context.activeObject.currentTime || 0,
uuid: uuidv4(),
};
undoStack.push({ name: "duplicateObject", action: action });
actions.duplicateObject.execute(action);
updateMenu();
},
execute: (action) => {
const object = pointerList[action.object];
const layer = pointerList[action.layer];
const time = action.time;
for (let item of action.items) {
if (item.type == "shape") {
const shape = Shape.fromJSON(item);
layer.addShape(shape, time);
} else if (item.type == "GraphicsObject") {
const newObj = GraphicsObject.fromJSON(item);
object.addObject(newObj);
}
}
updateUI();
},
rollback: (action) => {
const object = pointerList[action.object];
const layer = pointerList[action.layer];
for (let item of action.items) {
if (item.type == "shape") {
layer.removeShape(pointerList[item.idx]);
} else if (item.type == "GraphicsObject") {
object.removeChild(pointerList[item.idx]);
}
}
updateUI();
},
},
deleteObjects: {
create: (objects, shapes) => {
redoStack.length = 0;
const layer = context.activeObject.activeLayer;
const time = context.activeObject.currentTime || 0;
let serializableObjects = [];
let oldObjectExists = {};
for (let object of objects) {
serializableObjects.push(object.idx);
// Store old exists value for rollback
const existsValue = layer.animationData.interpolate(`object.${object.idx}.exists`, time);
oldObjectExists[object.idx] = existsValue !== null ? existsValue : 1;
}
let serializableShapes = [];
for (let shape of shapes) {
serializableShapes.push(shape.idx);
}
let action = {
objects: serializableObjects,
shapes: serializableShapes,
layer: layer.idx,
time: time,
oldObjectExists: oldObjectExists,
};
undoStack.push({ name: "deleteObjects", action: action });
actions.deleteObjects.execute(action);
updateMenu();
},
execute: (action) => {
const layer = pointerList[action.layer];
const time = action.time;
// For objects: set exists to 0 at this time
for (let objectIdx of action.objects) {
const existsCurve = layer.animationData.getCurve(`object.${objectIdx}.exists`);
const kf = existsCurve?.getKeyframeAtTime(time);
if (kf) {
kf.value = 0;
} else {
layer.animationData.addKeyframe(`object.${objectIdx}.exists`, new Keyframe(time, 0, "hold"));
}
}
// For shapes: remove them (leaves holes that can be filled on undo)
for (let shapeIdx of action.shapes) {
layer.removeShape(pointerList[shapeIdx]);
}
updateUI();
},
rollback: (action) => {
const layer = pointerList[action.layer];
const time = action.time;
// Restore old exists values for objects
for (let objectIdx of action.objects) {
const oldExists = action.oldObjectExists[objectIdx];
const existsCurve = layer.animationData.getCurve(`object.${objectIdx}.exists`);
const kf = existsCurve?.getKeyframeAtTime(time);
if (kf) {
kf.value = oldExists;
} else {
layer.animationData.addKeyframe(`object.${objectIdx}.exists`, new Keyframe(time, oldExists, "hold"));
}
}
// For shapes: restore them with their original shapeIndex (fills the holes)
for (let shapeIdx of action.shapes) {
const shape = pointerList[shapeIdx];
if (shape) {
layer.addShape(shape, time);
}
}
updateUI();
},
},
addLayer: {
create: () => {
redoStack.length = 0;
let action = {
object: context.activeObject.idx,
uuid: uuidv4(),
};
undoStack.push({ name: "addLayer", action: action });
actions.addLayer.execute(action);
updateMenu();
},
execute: (action) => {
let object = pointerList[action.object];
let layer = new Layer(action.uuid);
layer.name = `Layer ${object.layers.length + 1}`;
object.layers.push(layer);
object.currentLayer = object.layers.indexOf(layer);
updateLayers();
},
rollback: (action) => {
let object = pointerList[action.object];
let layer = pointerList[action.uuid];
object.layers.splice(object.layers.indexOf(layer), 1);
object.currentLayer = Math.min(
object.currentLayer,
object.layers.length - 1,
);
updateLayers();
},
},
deleteLayer: {
create: (layer) => {
redoStack.length = 0;
// Don't allow deleting the only layer
if (context.activeObject.layers.length == 1) return;
if (!(layer instanceof Layer)) {
layer = context.activeObject.activeLayer;
}
let action = {
object: context.activeObject.idx,
layer: layer.idx,
index: context.activeObject.layers.indexOf(layer),
};
undoStack.push({ name: "deleteLayer", action: action });
actions.deleteLayer.execute(action);
updateMenu();
},
execute: (action) => {
let object = pointerList[action.object];
let layer = pointerList[action.layer];
let changelayer = false;
if (object.activeLayer == layer) {
changelayer = true;
}
object.layers.splice(object.layers.indexOf(layer), 1);
if (changelayer) {
object.currentLayer = 0;
}
updateUI();
updateLayers();
},
rollback: (action) => {
let object = pointerList[action.object];
let layer = pointerList[action.layer];
object.layers.splice(action.index, 0, layer);
updateUI();
updateLayers();
},
},
changeLayerName: {
create: (layer, newName) => {
redoStack.length = 0;
let action = {
layer: layer.idx,
newName: newName,
oldName: layer.name,
};
undoStack.push({ name: "changeLayerName", action: action });
actions.changeLayerName.execute(action);
updateMenu();
},
execute: (action) => {
let layer = pointerList[action.layer];
layer.name = action.newName;
updateLayers();
},
rollback: (action) => {
let layer = pointerList[action.layer];
layer.name = action.oldName;
updateLayers();
},
},
importObject: {
create: (object) => {
redoStack.length = 0;
let action = {
object: object,
activeObject: context.activeObject.idx,
};
undoStack.push({ name: "importObject", action: action });
actions.importObject.execute(action);
updateMenu();
},
execute: (action) => {
const activeObject = pointerList[action.activeObject];
switch (action.object.type) {
case "GraphicsObject":
let object = GraphicsObject.fromJSON(action.object);
activeObject.addObject(object);
break;
case "Layer":
let layer = Layer.fromJSON(action.object);
activeObject.addLayer(layer);
}
updateUI();
updateLayers();
},
rollback: (action) => {
const activeObject = pointerList[action.activeObject];
switch (action.object.type) {
case "GraphicsObject":
let object = pointerList[action.object.idx];
activeObject.removeChild(object);
break;
case "Layer":
let layer = pointerList[action.object.idx];
activeObject.removeLayer(layer);
}
updateUI();
updateLayers();
},
},
transformObjects: {
initialize: (
frame,
_selection,
direction,
mouse,
transform = undefined,
) => {
let bbox = undefined;
const selection = {};
for (let item of _selection) {
if (bbox == undefined) {
bbox = getRotatedBoundingBox(item);
} else {
growBoundingBox(bbox, getRotatedBoundingBox(item));
}
selection[item.idx] = {
x: item.x,
y: item.y,
scale_x: item.scale_x,
scale_y: item.scale_y,
rotation: item.rotation,
};
}
let action = {
type: "transformObjects",
oldState: structuredClone(frame.keys),
frame: frame.idx,
transform: {
initial: {
x: { min: bbox.x.min, max: bbox.x.max },
y: { min: bbox.y.min, max: bbox.y.max },
rotation: 0,
mouse: { x: mouse.x, y: mouse.y },
selection: selection,
},
current: {
x: { min: bbox.x.min, max: bbox.x.max },
y: { min: bbox.y.min, max: bbox.y.max },
scale_x: 1,
scale_y: 1,
rotation: 0,
mouse: { x: mouse.x, y: mouse.y },
selection: structuredClone(selection),
},
},
selection: selection,
direction: direction,
};
if (transform) {
action.transform = transform;
}
return action;
},
update: (action, mouse) => {
const initial = action.transform.initial;
const current = action.transform.current;
if (action.direction.indexOf("n") != -1) {
current.y.min = mouse.y;
} else if (action.direction.indexOf("s") != -1) {
current.y.max = mouse.y;
}
if (action.direction.indexOf("w") != -1) {
current.x.min = mouse.x;
} else if (action.direction.indexOf("e") != -1) {
current.x.max = mouse.x;
}
if (context.dragDirection == "r") {
const pivot = {
x: (initial.x.min + initial.x.max) / 2,
y: (initial.y.min + initial.y.max) / 2,
};
current.rotation = signedAngleBetweenVectors(
pivot,
initial.mouse,
mouse,
);
const { dx, dy } = rotateAroundPointIncremental(
current.x.min,
current.y.min,
pivot,
current.rotation,
);
}
// Calculate the scaling factor based on the difference between current and initial values
action.transform.current.scale_x =
(current.x.max - current.x.min) / (initial.x.max - initial.x.min);
action.transform.current.scale_y =
(current.y.max - current.y.min) / (initial.y.max - initial.y.min);
return action;
},
render: (action, ctx) => {
const initial = action.transform.initial;
const current = action.transform.current;
ctx.save();
ctx.translate(
(current.x.max + current.x.min) / 2,
(current.y.max - current.y.min) / 2,
);
ctx.rotate(current.rotation);
ctx.translate(
-(current.x.max + current.x.min) / 2,
-(current.y.max - current.y.min) / 2,
);
const cxt = {
ctx: ctx,
selection: [],
shapeselection: [],
};
for (let obj in action.selection) {
const object = pointerList[obj];
const transform = ctx.getTransform()
ctx.translate(object.x, object.y)
ctx.scale(object.scale_x, object.scale_y)
ctx.rotate(object.rotation)
object.draw(ctx)
ctx.setTransform(transform)
}
ctx.strokeStyle = "#00ffff";
ctx.lineWidth = 1;
ctx.beginPath();
ctx.rect(
current.x.min,
current.y.min,
current.x.max - current.x.min,
current.y.max - current.y.min,
);
ctx.stroke();
ctx.fillStyle = "#000000";
const rectRadius = 5;
const xdiff = current.x.max - current.x.min;
const ydiff = current.y.max - current.y.min;
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(
current.x.min + xdiff * i[0] - rectRadius,
current.y.min + ydiff * i[1] - rectRadius,
rectRadius * 2,
rectRadius * 2,
);
ctx.fill();
}
ctx.restore();
},
finalize: (action) => {
undoStack.push({ name: "transformObjects", action: action });
actions.transformObjects.execute(action);
context.activeAction = undefined;
updateMenu();
},
execute: (action) => {
const frame = pointerList[action.frame];
const initial = action.transform.initial;
const current = action.transform.current;
const delta_x = current.x.min - initial.x.min;
const delta_y = current.y.min - initial.y.min;
const delta_rot = current.rotation - initial.rotation;
// frame.keys = structuredClone(action.newState)
for (let idx in action.selection) {
const item = frame.keys[idx];
const xoffset = action.selection[idx].x - initial.x.min;
const yoffset = action.selection[idx].y - initial.y.min;
item.x = initial.x.min + delta_x + xoffset * current.scale_x;
item.y = initial.y.min + delta_y + yoffset * current.scale_y;
item.scale_x = action.selection[idx].scale_x * current.scale_x;
item.scale_y = action.selection[idx].scale_y * current.scale_y;
item.rotation = action.selection[idx].rotation + delta_rot;
}
updateUI();
},
rollback: (action) => {
let frame = pointerList[action.frame];
frame.keys = structuredClone(action.oldState);
updateUI();
},
},
moveObjects: {
initialize: (objects, layer, time) => {
let oldPositions = {};
let hadKeyframes = {};
for (let obj of objects) {
const xCurve = layer.animationData.getCurve(`child.${obj.idx}.x`);
const yCurve = layer.animationData.getCurve(`child.${obj.idx}.y`);
const xKf = xCurve?.getKeyframeAtTime(time);
const yKf = yCurve?.getKeyframeAtTime(time);
const x = layer.animationData.interpolate(`child.${obj.idx}.x`, time);
const y = layer.animationData.interpolate(`child.${obj.idx}.y`, time);
oldPositions[obj.idx] = { x, y };
hadKeyframes[obj.idx] = { x: !!xKf, y: !!yKf };
}
let action = {
type: "moveObjects",
objects: objects.map(o => o.idx),
layer: layer.idx,
time: time,
oldPositions: oldPositions,
hadKeyframes: hadKeyframes,
};
return action;
},
finalize: (action) => {
const layer = pointerList[action.layer];
let newPositions = {};
for (let objIdx of action.objects) {
const obj = pointerList[objIdx];
newPositions[objIdx] = { x: obj.x, y: obj.y };
}
action.newPositions = newPositions;
undoStack.push({ name: "moveObjects", action: action });
actions.moveObjects.execute(action);
context.activeAction = undefined;
updateMenu();
},
render: (action, ctx) => {},
create: (objects, layer, time, oldPositions, newPositions) => {
redoStack.length = 0;
// Track which keyframes existed before the move
let hadKeyframes = {};
for (let obj of objects) {
const xCurve = layer.animationData.getCurve(`child.${obj.idx}.x`);
const yCurve = layer.animationData.getCurve(`child.${obj.idx}.y`);
const xKf = xCurve?.getKeyframeAtTime(time);
const yKf = yCurve?.getKeyframeAtTime(time);
hadKeyframes[obj.idx] = { x: !!xKf, y: !!yKf };
}
let action = {
objects: objects.map(o => o.idx),
layer: layer.idx,
time: time,
oldPositions: oldPositions,
newPositions: newPositions,
hadKeyframes: hadKeyframes,
};
undoStack.push({ name: "moveObjects", action: action });
actions.moveObjects.execute(action);
updateMenu();
},
execute: (action) => {
const layer = pointerList[action.layer];
const time = action.time;
for (let objIdx of action.objects) {
const obj = pointerList[objIdx];
const newPos = action.newPositions[objIdx];
// Update object properties
obj.x = newPos.x;
obj.y = newPos.y;
// Add/update keyframes in AnimationData
const xCurve = layer.animationData.getCurve(`child.${objIdx}.x`);
const kf = xCurve?.getKeyframeAtTime(time);
if (kf) {
kf.value = newPos.x;
} else {
layer.animationData.addKeyframe(`child.${objIdx}.x`, new Keyframe(time, newPos.x, "linear"));
}
const yCurve = layer.animationData.getCurve(`child.${objIdx}.y`);
const kfy = yCurve?.getKeyframeAtTime(time);
if (kfy) {
kfy.value = newPos.y;
} else {
layer.animationData.addKeyframe(`child.${objIdx}.y`, new Keyframe(time, newPos.y, "linear"));
}
}
updateUI();
},
rollback: (action) => {
const layer = pointerList[action.layer];
const time = action.time;
for (let objIdx of action.objects) {
const obj = pointerList[objIdx];
const oldPos = action.oldPositions[objIdx];
const hadKfs = action.hadKeyframes?.[objIdx] || { x: false, y: false };
// Restore object properties
obj.x = oldPos.x;
obj.y = oldPos.y;
// Restore or remove keyframes in AnimationData
const xCurve = layer.animationData.getCurve(`child.${objIdx}.x`);
if (hadKfs.x) {
// Had a keyframe before - restore its value
const kf = xCurve?.getKeyframeAtTime(time);
if (kf) {
kf.value = oldPos.x;
} else {
layer.animationData.addKeyframe(`child.${objIdx}.x`, new Keyframe(time, oldPos.x, "linear"));
}
} else {
// No keyframe before - remove the entire curve
if (xCurve) {
layer.animationData.removeCurve(`child.${objIdx}.x`);
}
}
const yCurve = layer.animationData.getCurve(`child.${objIdx}.y`);
if (hadKfs.y) {
// Had a keyframe before - restore its value
const kfy = yCurve?.getKeyframeAtTime(time);
if (kfy) {
kfy.value = oldPos.y;
} else {
layer.animationData.addKeyframe(`child.${objIdx}.y`, new Keyframe(time, oldPos.y, "linear"));
}
} else {
// No keyframe before - remove the entire curve
if (yCurve) {
layer.animationData.removeCurve(`child.${objIdx}.y`);
}
}
}
updateUI();
},
},
editFrame: {
// DEPRECATED: Kept for backwards compatibility
initialize: (frame) => {
console.warn("editFrame is deprecated, use moveObjects instead");
return null;
},
finalize: (action, frame) => {},
render: (action, ctx) => {},
create: (frame) => {},
execute: (action) => {},
rollback: (action) => {},
},
addFrame: {
create: () => {
redoStack.length = 0;
let frames = [];
for (
let i = context.activeObject.activeLayer.frames.length;
i <= context.activeObject.currentFrameNum;
i++
) {
frames.push(uuidv4());
}
let action = {
frames: frames,
layer: context.activeObject.activeLayer.idx,
};
undoStack.push({ name: "addFrame", action: action });
actions.addFrame.execute(action);
updateMenu();
},
execute: (action) => {
let layer = pointerList[action.layer];
for (let frame of action.frames) {
layer.frames.push(new Frame("normal", frame));
}
updateLayers();
},
rollback: (action) => {
let layer = pointerList[action.layer];
for (let _frame of action.frames) {
layer.frames.pop();
}
updateLayers();
},
},
addKeyframe: {
create: () => {
let frameNum = context.activeObject.currentFrameNum;
let layer = context.activeObject.activeLayer;
let formerType;
let addedFrames = {};
if (frameNum >= layer.frames.length) {
formerType = "none";
// for (let i = layer.frames.length; i <= frameNum; i++) {
// addedFrames[i] = uuidv4();
// }
} else if (!layer.frames[frameNum]) {
formerType = undefined
} else if (layer.frames[frameNum].frameType != "keyframe") {
formerType = layer.frames[frameNum].frameType;
} else {
return; // Already a keyframe, nothing to do
}
redoStack.length = 0;
let action = {
frameNum: frameNum,
object: context.activeObject.idx,
layer: layer.idx,
formerType: formerType,
addedFrames: addedFrames,
uuid: uuidv4(),
};
undoStack.push({ name: "addKeyframe", action: action });
actions.addKeyframe.execute(action);
updateMenu();
},
execute: (action) => {
let object = pointerList[action.object];
let layer = pointerList[action.layer];
layer.addOrChangeFrame(
action.frameNum,
"keyframe",
action.uuid,
action.addedFrames,
);
updateLayers();
updateUI();
},
rollback: (action) => {
let layer = pointerList[action.layer];
if (action.formerType == "none") {
for (let i in action.addedFrames) {
layer.frames.pop();
}
} else {
let layer = pointerList[action.layer];
if (action.formerType) {
layer.frames[action.frameNum].frameType = action.formerType;
} else {
layer.frames[action.frameNum = undefined]
}
}
updateLayers();
updateUI();
},
},
deleteFrame: {
create: (frame, layer) => {
redoStack.length = 0;
let action = {
frame: frame.idx,
layer: layer.idx,
replacementUuid: uuidv4(),
};
undoStack.push({ name: "deleteFrame", action: action });
actions.deleteFrame.execute(action);
updateMenu();
},
execute: (action) => {
let layer = pointerList[action.layer];
layer.deleteFrame(
action.frame,
undefined,
action.replacementUuid ? action.replacementUuid : uuidv4(),
);
updateLayers();
updateUI();
},
rollback: (action) => {
let layer = pointerList[action.layer];
let frame = pointerList[action.frame];
layer.addFrame(action.frameNum, frame, {});
updateLayers();
updateUI();
},
},
moveFrames: {
create: (offset) => {
redoStack.length = 0;
const selectedFrames = structuredClone(context.selectedFrames);
for (let frame of selectedFrames) {
frame.replacementUuid = uuidv4();
frame.layer = context.activeObject.layers.length - frame.layer - 1;
}
// const fillFrames = []
// for (let i=0; i<context.activeObject.layers.length;i++) {
// const fillLayer = []
// for (let j=0; j<Math.abs(offset.frames); j++) {
// fillLayer.push(uuidv4())
// }
// fillFrames.push(fillLayer)
// }
let action = {
selectedFrames: selectedFrames,
offset: offset,
object: context.activeObject.idx,
// fillFrames: fillFrames
};
undoStack.push({ name: "moveFrames", action: action });
actions.moveFrames.execute(action);
updateMenu();
},
execute: (action) => {
const object = pointerList[action.object];
const frameBuffer = [];
for (let frameObj of action.selectedFrames) {
let layer = object.layers[frameObj.layer];
let frame = layer.frames[frameObj.frameNum];
if (frameObj) {
frameBuffer.push({
frame: frame,
frameNum: frameObj.frameNum,
layer: frameObj.layer,
});
layer.deleteFrame(frame.idx, undefined, frameObj.replacementUuid);
}
}
for (let frameObj of frameBuffer) {
// TODO: figure out object tracking when moving frames between layers
const layer_idx = frameObj.layer// + action.offset.layers;
let layer = object.layers[layer_idx];
let frame = frameObj.frame;
layer.addFrame(frameObj.frameNum + action.offset.frames, frame, []); //fillFrames[layer_idx])
}
updateLayers();
updateUI();
},
rollback: (action) => {
const object = pointerList[action.object];
const frameBuffer = [];
for (let frameObj of action.selectedFrames) {
let layer = object.layers[frameObj.layer];
let frame = layer.frames[frameObj.frameNum + action.offset.frames];
if (frameObj) {
frameBuffer.push({
frame: frame,
frameNum: frameObj.frameNum,
layer: frameObj.layer,
});
layer.deleteFrame(frame.idx, "none")
}
}
for (let frameObj of frameBuffer) {
let layer = object.layers[frameObj.layer];
let frame = frameObj.frame;
if (frameObj) {
layer.addFrame(frameObj.frameNum, frame, [])
}
}
},
},
addMotionTween: {
create: () => {
redoStack.length = 0;
let frameNum = context.activeObject.currentFrameNum;
let layer = context.activeObject.activeLayer;
const frameInfo = layer.getFrameValue(frameNum)
let lastKeyframeBefore, firstKeyframeAfter
if (frameInfo.valueAtN) {
lastKeyframeBefore = frameNum
} else if (frameInfo.prev) {
lastKeyframeBefore = frameInfo.prevIndex
} else {
return
}
firstKeyframeAfter = frameInfo.nextIndex
let action = {
frameNum: frameNum,
layer: layer.idx,
lastBefore: lastKeyframeBefore,
firstAfter: firstKeyframeAfter,
};
undoStack.push({ name: "addMotionTween", action: action });
actions.addMotionTween.execute(action);
updateMenu();
},
execute: (action) => {
let layer = pointerList[action.layer];
let frames = layer.frames;
if (action.lastBefore != undefined) {
console.log("adding motion")
frames[action.lastBefore].keyTypes.add("motion")
}
updateLayers();
updateUI();
},
rollback: (action) => {
let layer = pointerList[action.layer];
let frames = layer.frames;
if (action.lastBefore != undefined) {
frames[action.lastBefore].keyTypes.delete("motion")
}
updateLayers();
updateUI();
},
},
addShapeTween: {
create: () => {
redoStack.length = 0;
let frameNum = context.activeObject.currentFrameNum;
let layer = context.activeObject.activeLayer;
const frameInfo = layer.getFrameValue(frameNum)
let lastKeyframeBefore, firstKeyframeAfter
if (frameInfo.valueAtN) {
lastKeyframeBefore = frameNum
} else if (frameInfo.prev) {
lastKeyframeBefore = frameInfo.prevIndex
} else {
return
}
firstKeyframeAfter = frameInfo.nextIndex
let action = {
frameNum: frameNum,
layer: layer.idx,
lastBefore: lastKeyframeBefore,
firstAfter: firstKeyframeAfter,
};
console.log(action)
undoStack.push({ name: "addShapeTween", action: action });
actions.addShapeTween.execute(action);
updateMenu();
},
execute: (action) => {
let layer = pointerList[action.layer];
let frames = layer.frames;
if (action.lastBefore != undefined) {
frames[action.lastBefore].keyTypes.add("shape")
}
updateLayers();
updateUI();
},
rollback: (action) => {
let layer = pointerList[action.layer];
let frames = layer.frames;
if (action.lastBefore != undefined) {
frames[action.lastBefore].keyTypes.delete("shape")
}
updateLayers();
updateUI();
},
},
group: {
create: () => {
redoStack.length = 0;
let serializableShapes = [];
let serializableObjects = [];
let bbox;
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) {
bbox = shape.bbox();
} else {
growBoundingBox(bbox, shape.bbox());
}
}
// For objects - check if they exist at current time
for (let object of context.selection) {
const existsValue = layer.animationData.interpolate(`object.${object.idx}.exists`, currentTime);
if (existsValue > 0) {
serializableObjects.push(object.idx);
// TODO: rotated bbox
if (bbox == undefined) {
bbox = object.bbox();
} else {
growBoundingBox(bbox, object.bbox());
}
}
}
// If nothing was selected, don't create a group
if (!bbox) {
return;
}
context.shapeselection = [];
context.selection = [];
let action = {
shapes: serializableShapes,
objects: serializableObjects,
groupUuid: uuidv4(),
parent: context.activeObject.idx,
layer: layer.idx,
currentTime: currentTime,
position: {
x: (bbox.x.min + bbox.x.max) / 2,
y: (bbox.y.min + bbox.y.max) / 2,
},
};
undoStack.push({ name: "group", action: action });
actions.group.execute(action);
updateMenu();
updateLayers();
},
execute: (action) => {
let group = new GraphicsObject(action.groupUuid);
let parent = pointerList[action.parent];
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);
// 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.shapeId}.exists`);
layer.animationData.removeCurve(`shape.${shape.shapeId}.zOrder`);
layer.animationData.removeCurve(`shape.${shape.shapeId}.shapeIndex`);
// 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.shapeId}.exists`);
existsCurve.addKeyframe(new Keyframe(0, 1, 'linear'));
groupLayer.animationData.setCurve(`shape.${shape.shapeId}.exists`, existsCurve);
let zOrderCurve = new AnimationCurve(`shape.${shape.shapeId}.zOrder`);
zOrderCurve.addKeyframe(new Keyframe(0, groupLayer.shapes.length - 1, 'linear'));
groupLayer.animationData.setCurve(`shape.${shape.shapeId}.zOrder`, zOrderCurve);
let shapeIndexCurve = new AnimationCurve(`shape.${shape.shapeId}.shapeIndex`);
shapeIndexCurve.addKeyframe(new Keyframe(0, 0, 'linear'));
groupLayer.animationData.setCurve(`shape.${shape.shapeId}.shapeIndex`, shapeIndexCurve);
}
// Move objects (children) to the group
for (let objectIdx of action.objects) {
let object = pointerList[objectIdx];
// Get object position from AnimationData if available
const objX = layer.animationData.interpolate(`object.${objectIdx}.x`, currentTime);
const objY = layer.animationData.interpolate(`object.${objectIdx}.y`, currentTime);
if (objX !== null && objY !== null) {
group.addObject(
object,
objX - action.position.x,
objY - action.position.y,
currentTime
);
} else {
group.addObject(object, 0, 0, currentTime);
}
parent.removeChild(object);
}
// 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();
},
rollback: (action) => {
let group = pointerList[action.groupUuid];
let parent = pointerList[action.parent];
const layer = pointerList[action.layer] || parent.activeLayer;
const currentTime = action.currentTime || 0;
for (let shapeIdx of action.shapes) {
let shape = pointerList[shapeIdx];
shape.translate(action.position.x, action.position.y);
layer.addShape(shape, currentTime);
group.activeLayer.removeShape(shape);
}
for (let objectIdx of action.objects) {
let object = pointerList[objectIdx];
parent.addObject(object, object.x, object.y, currentTime);
group.removeChild(object);
}
parent.removeChild(group);
updateUI();
updateInfopanel();
},
},
sendToBack: {
create: () => {
redoStack.length = 0;
const currentTime = context.activeObject.currentTime || 0;
const layer = context.activeObject.activeLayer;
let serializableShapes = [];
let oldZOrders = {};
// Store current zOrder for each shape
for (let shape of context.shapeselection) {
serializableShapes.push(shape.idx);
const zOrder = layer.animationData.interpolate(`shape.${shape.shapeId}.zOrder`, currentTime);
oldZOrders[shape.idx] = zOrder !== null ? zOrder : 0;
}
let serializableObjects = [];
let formerIndices = {};
for (let object of context.selection) {
serializableObjects.push(object.idx);
formerIndices[object.idx] = layer.children.indexOf(object);
}
let action = {
shapes: serializableShapes,
objects: serializableObjects,
layer: layer.idx,
time: currentTime,
oldZOrders: oldZOrders,
formerIndices: formerIndices,
};
undoStack.push({ name: "sendToBack", action: action });
actions.sendToBack.execute(action);
updateMenu();
},
execute: (action) => {
let layer = pointerList[action.layer];
const time = action.time;
// For shapes: set zOrder to 0, increment all others
for (let shapeIdx of action.shapes) {
let shape = pointerList[shapeIdx];
// Increment zOrder for all other shapes at this time
for (let otherShape of layer.shapes) {
if (otherShape.shapeId !== shape.shapeId) {
const zOrderCurve = layer.animationData.getCurve(`shape.${otherShape.shapeId}.zOrder`);
if (zOrderCurve) {
const kf = zOrderCurve.getKeyframeAtTime(time);
if (kf) {
kf.value += 1;
} else {
// Add keyframe at current time with incremented value
const currentZOrder = layer.animationData.interpolate(`shape.${otherShape.shapeId}.zOrder`, time) || 0;
layer.animationData.addKeyframe(`shape.${otherShape.shapeId}.zOrder`, new Keyframe(time, currentZOrder + 1, "hold"));
}
}
}
}
// Set this shape's zOrder to 0
const zOrderCurve = layer.animationData.getCurve(`shape.${shape.shapeId}.zOrder`);
const kf = zOrderCurve?.getKeyframeAtTime(time);
if (kf) {
kf.value = 0;
} else {
layer.animationData.addKeyframe(`shape.${shape.shapeId}.zOrder`, new Keyframe(time, 0, "hold"));
}
}
// For objects: move to front of children array
for (let objectIdx of action.objects) {
let object = pointerList[objectIdx];
layer.children.splice(layer.children.indexOf(object), 1);
layer.children.unshift(object);
}
updateUI();
},
rollback: (action) => {
let layer = pointerList[action.layer];
const time = action.time;
// Restore old zOrder values for shapes
for (let shapeIdx of action.shapes) {
let shape = pointerList[shapeIdx];
const oldZOrder = action.oldZOrders[shapeIdx];
const zOrderCurve = layer.animationData.getCurve(`shape.${shape.shapeId}.zOrder`);
const kf = zOrderCurve?.getKeyframeAtTime(time);
if (kf) {
kf.value = oldZOrder;
} else {
layer.animationData.addKeyframe(`shape.${shape.shapeId}.zOrder`, new Keyframe(time, oldZOrder, "hold"));
}
}
// Restore old positions for objects
for (let objectIdx of action.objects) {
let object = pointerList[objectIdx];
layer.children.splice(layer.children.indexOf(object), 1);
layer.children.splice(action.formerIndices[objectIdx], 0, object);
}
updateUI();
},
},
bringToFront: {
create: () => {
redoStack.length = 0;
const currentTime = context.activeObject.currentTime || 0;
const layer = context.activeObject.activeLayer;
let serializableShapes = [];
let oldZOrders = {};
// Store current zOrder for each shape
for (let shape of context.shapeselection) {
serializableShapes.push(shape.idx);
const zOrder = layer.animationData.interpolate(`shape.${shape.shapeId}.zOrder`, currentTime);
oldZOrders[shape.idx] = zOrder !== null ? zOrder : 0;
}
let serializableObjects = [];
let formerIndices = {};
for (let object of context.selection) {
serializableObjects.push(object.idx);
formerIndices[object.idx] = layer.children.indexOf(object);
}
let action = {
shapes: serializableShapes,
objects: serializableObjects,
layer: layer.idx,
time: currentTime,
oldZOrders: oldZOrders,
formerIndices: formerIndices,
};
undoStack.push({ name: "bringToFront", action: action });
actions.bringToFront.execute(action);
updateMenu();
},
execute: (action) => {
let layer = pointerList[action.layer];
const time = action.time;
// Find max zOrder at this time
let maxZOrder = -1;
for (let shape of layer.shapes) {
const zOrder = layer.animationData.interpolate(`shape.${shape.shapeId}.zOrder`, time);
if (zOrder !== null && zOrder > maxZOrder) {
maxZOrder = zOrder;
}
}
// For shapes: set zOrder to max+1, max+2, etc.
let newZOrder = maxZOrder + 1;
for (let shapeIdx of action.shapes) {
let shape = pointerList[shapeIdx];
const zOrderCurve = layer.animationData.getCurve(`shape.${shape.shapeId}.zOrder`);
const kf = zOrderCurve?.getKeyframeAtTime(time);
if (kf) {
kf.value = newZOrder;
} else {
layer.animationData.addKeyframe(`shape.${shape.shapeId}.zOrder`, new Keyframe(time, newZOrder, "hold"));
}
newZOrder++;
}
// For objects: move to end of children array
for (let objectIdx of action.objects) {
let object = pointerList[objectIdx];
layer.children.splice(layer.children.indexOf(object), 1);
object.parentLayer = layer;
layer.children.push(object);
}
updateUI();
},
rollback: (action) => {
let layer = pointerList[action.layer];
const time = action.time;
// Restore old zOrder values for shapes
for (let shapeIdx of action.shapes) {
let shape = pointerList[shapeIdx];
const oldZOrder = action.oldZOrders[shapeIdx];
const zOrderCurve = layer.animationData.getCurve(`shape.${shape.shapeId}.zOrder`);
const kf = zOrderCurve?.getKeyframeAtTime(time);
if (kf) {
kf.value = oldZOrder;
} else {
layer.animationData.addKeyframe(`shape.${shape.shapeId}.zOrder`, new Keyframe(time, oldZOrder, "hold"));
}
}
// Restore old positions for objects
for (let objectIdx of action.objects) {
let object = pointerList[objectIdx];
layer.children.splice(layer.children.indexOf(object), 1);
layer.children.splice(action.formerIndices[objectIdx], 0, object);
}
updateUI();
},
},
setName: {
create: (object, name) => {
redoStack.length = 0;
let action = {
object: object.idx,
newName: name,
oldName: object.name,
};
undoStack.push({ name: "setName", action: action });
actions.setName.execute(action);
updateMenu();
},
execute: (action) => {
let object = pointerList[action.object];
object.name = action.newName;
updateInfopanel();
},
rollback: (action) => {
let object = pointerList[action.object];
object.name = action.oldName;
updateInfopanel();
},
},
selectAll: {
create: () => {
redoStack.length = 0;
let selection = [];
let shapeselection = [];
const currentTime = context.activeObject.currentTime || 0;
const layer = context.activeObject.activeLayer;
for (let child of layer.children) {
let idx = child.idx;
const existsValue = layer.animationData.interpolate(`object.${idx}.exists`, currentTime);
if (existsValue > 0) {
selection.push(child.idx);
}
}
// Use getVisibleShapes instead of currentFrame.shapes
if (layer) {
for (let shape of layer.getVisibleShapes(currentTime)) {
shapeselection.push(shape.idx);
}
}
let action = {
selection: selection,
shapeselection: shapeselection,
};
undoStack.push({ name: "selectAll", action: action });
actions.selectAll.execute(action);
updateMenu();
},
execute: (action) => {
context.selection = [];
context.shapeselection = [];
for (let item of action.selection) {
context.selection.push(pointerList[item]);
}
for (let shape of action.shapeselection) {
context.shapeselection.push(pointerList[shape]);
}
updateUI();
updateMenu();
},
rollback: (action) => {
context.selection = [];
context.shapeselection = [];
updateUI();
updateMenu();
},
},
selectNone: {
create: () => {
redoStack.length = 0;
let selection = [];
let shapeselection = [];
for (let item of context.selection) {
selection.push(item.idx);
}
for (let shape of context.shapeselection) {
shapeselection.push(shape.idx);
}
let action = {
selection: selection,
shapeselection: shapeselection,
};
undoStack.push({ name: "selectNone", action: action });
actions.selectNone.execute(action);
updateMenu();
},
execute: (action) => {
context.selection = [];
context.shapeselection = [];
updateUI();
updateMenu();
},
rollback: (action) => {
context.selection = [];
context.shapeselection = [];
for (let item of action.selection) {
context.selection.push(pointerList[item]);
}
for (let shape of action.shapeselection) {
context.shapeselection.push(pointerList[shape]);
}
updateUI();
updateMenu();
},
},
select: {
create: () => {
redoStack.length = 0;
if (
arraysAreEqual(context.oldselection, context.selection) &&
arraysAreEqual(context.oldshapeselection, context.shapeselection)
)
return;
let oldselection = [];
let oldshapeselection = [];
for (let item of context.oldselection) {
oldselection.push(item.idx);
}
for (let shape of context.oldshapeselection) {
oldshapeselection.push(shape.idx);
}
let selection = [];
let shapeselection = [];
for (let item of context.selection) {
selection.push(item.idx);
}
for (let shape of context.shapeselection) {
shapeselection.push(shape.idx);
}
let action = {
selection: selection,
shapeselection: shapeselection,
oldselection: oldselection,
oldshapeselection: oldshapeselection,
};
undoStack.push({ name: "select", action: action });
actions.select.execute(action);
updateMenu();
},
execute: (action) => {
context.selection = [];
context.shapeselection = [];
for (let item of action.selection) {
context.selection.push(pointerList[item]);
}
for (let shape of action.shapeselection) {
context.shapeselection.push(pointerList[shape]);
}
updateUI();
updateMenu();
},
rollback: (action) => {
context.selection = [];
context.shapeselection = [];
for (let item of action.oldselection) {
context.selection.push(pointerList[item]);
}
for (let shape of action.oldshapeselection) {
context.shapeselection.push(pointerList[shape]);
}
updateUI();
updateMenu();
},
},
// Node graph actions
graphAddNode: {
create: (trackId, nodeType, position, nodeId, backendId) => {
redoStack.length = 0;
let action = {
trackId: trackId,
nodeType: nodeType,
position: position,
nodeId: nodeId, // Frontend node ID from Drawflow
backendId: backendId
};
undoStack.push({ name: "graphAddNode", action: action });
actions.graphAddNode.execute(action);
updateMenu();
},
execute: async (action) => {
// Re-add node via Tauri and reload frontend
const result = await invoke('graph_add_node', {
trackId: action.trackId,
nodeType: action.nodeType,
posX: action.position.x,
posY: action.position.y
});
// Reload the entire graph to show the restored node
if (context.reloadNodeEditor) {
await context.reloadNodeEditor();
}
},
rollback: async (action) => {
// Remove node from backend
await invoke('graph_remove_node', {
trackId: action.trackId,
nodeId: action.backendId
});
// Remove from frontend
if (context.nodeEditor) {
context.nodeEditor.removeNodeId(`node-${action.nodeId}`);
}
},
},
graphRemoveNode: {
create: (trackId, nodeId, backendId, nodeData) => {
redoStack.length = 0;
let action = {
trackId: trackId,
nodeId: nodeId,
backendId: backendId,
nodeData: nodeData, // Store full node data for restoration
};
undoStack.push({ name: "graphRemoveNode", action: action });
actions.graphRemoveNode.execute(action);
updateMenu();
},
execute: async (action) => {
await invoke('graph_remove_node', {
trackId: action.trackId,
nodeId: action.backendId
});
if (context.nodeEditor) {
context.nodeEditor.removeNodeId(`node-${action.nodeId}`);
}
},
rollback: async (action) => {
// Re-add node to backend
const result = await invoke('graph_add_node', {
trackId: action.trackId,
nodeType: action.nodeData.nodeType,
posX: action.nodeData.position.x,
posY: action.nodeData.position.y
});
// Store new backend ID
const newBackendId = result.node_id || result;
// Re-add to frontend via reloadGraph
if (context.reloadNodeEditor) {
await context.reloadNodeEditor();
}
},
},
graphAddConnection: {
create: (trackId, fromNode, fromPort, toNode, toPort, frontendFromId, frontendToId, fromPortClass, toPortClass) => {
redoStack.length = 0;
let action = {
trackId: trackId,
fromNode: fromNode,
fromPort: fromPort,
toNode: toNode,
toPort: toPort,
frontendFromId: frontendFromId,
frontendToId: frontendToId,
fromPortClass: fromPortClass,
toPortClass: toPortClass
};
undoStack.push({ name: "graphAddConnection", action: action });
actions.graphAddConnection.execute(action);
updateMenu();
},
execute: async (action) => {
// Suppress action recording during undo/redo
if (context.nodeEditorState) {
context.nodeEditorState.suppressActionRecording = true;
}
try {
await invoke('graph_connect', {
trackId: action.trackId,
fromNode: action.fromNode,
fromPort: action.fromPort,
toNode: action.toNode,
toPort: action.toPort
});
// Add connection in frontend only if it doesn't exist
if (context.nodeEditor) {
const inputNode = context.nodeEditor.getNodeFromId(action.frontendToId);
const inputConnections = inputNode?.inputs[action.toPortClass]?.connections;
const alreadyConnected = inputConnections?.some(conn =>
conn.node === action.frontendFromId && conn.input === action.fromPortClass
);
if (!alreadyConnected) {
context.nodeEditor.addConnection(
action.frontendFromId,
action.frontendToId,
action.fromPortClass,
action.toPortClass
);
}
}
} finally {
if (context.nodeEditorState) {
context.nodeEditorState.suppressActionRecording = false;
}
}
},
rollback: async (action) => {
// Suppress action recording during undo/redo
if (context.nodeEditorState) {
context.nodeEditorState.suppressActionRecording = true;
}
try {
await invoke('graph_disconnect', {
trackId: action.trackId,
fromNode: action.fromNode,
fromPort: action.fromPort,
toNode: action.toNode,
toPort: action.toPort
});
// Remove from frontend
if (context.nodeEditor) {
context.nodeEditor.removeSingleConnection(
action.frontendFromId,
action.frontendToId,
action.fromPortClass,
action.toPortClass
);
}
} finally {
if (context.nodeEditorState) {
context.nodeEditorState.suppressActionRecording = false;
}
}
},
},
graphRemoveConnection: {
create: (trackId, fromNode, fromPort, toNode, toPort, frontendFromId, frontendToId, fromPortClass, toPortClass) => {
redoStack.length = 0;
let action = {
trackId: trackId,
fromNode: fromNode,
fromPort: fromPort,
toNode: toNode,
toPort: toPort,
frontendFromId: frontendFromId,
frontendToId: frontendToId,
fromPortClass: fromPortClass,
toPortClass: toPortClass
};
undoStack.push({ name: "graphRemoveConnection", action: action });
actions.graphRemoveConnection.execute(action);
updateMenu();
},
execute: async (action) => {
// Suppress action recording during undo/redo
if (context.nodeEditorState) {
context.nodeEditorState.suppressActionRecording = true;
}
try {
await invoke('graph_disconnect', {
trackId: action.trackId,
fromNode: action.fromNode,
fromPort: action.fromPort,
toNode: action.toNode,
toPort: action.toPort
});
if (context.nodeEditor) {
context.nodeEditor.removeSingleConnection(
action.frontendFromId,
action.frontendToId,
action.fromPortClass,
action.toPortClass
);
}
} finally {
if (context.nodeEditorState) {
context.nodeEditorState.suppressActionRecording = false;
}
}
},
rollback: async (action) => {
// Suppress action recording during undo/redo
if (context.nodeEditorState) {
context.nodeEditorState.suppressActionRecording = true;
}
try {
await invoke('graph_connect', {
trackId: action.trackId,
fromNode: action.fromNode,
fromPort: action.fromPort,
toNode: action.toNode,
toPort: action.toPort
});
// Re-add connection in frontend
if (context.nodeEditor) {
context.nodeEditor.addConnection(
action.frontendFromId,
action.frontendToId,
action.fromPortClass,
action.toPortClass
);
}
} finally {
if (context.nodeEditorState) {
context.nodeEditorState.suppressActionRecording = false;
}
}
},
},
graphSetParameter: {
initialize: (trackId, nodeId, paramId, frontendNodeId, currentValue) => {
return {
trackId: trackId,
nodeId: nodeId,
paramId: paramId,
frontendNodeId: frontendNodeId,
oldValue: currentValue,
};
},
finalize: (action, newValue) => {
action.newValue = newValue;
// Only record if value actually changed
if (action.oldValue !== action.newValue) {
undoStack.push({ name: "graphSetParameter", action: action });
updateMenu();
}
},
create: (trackId, nodeId, paramId, frontendNodeId, newValue, oldValue) => {
redoStack.length = 0;
let action = {
trackId: trackId,
nodeId: nodeId,
paramId: paramId,
frontendNodeId: frontendNodeId,
newValue: newValue,
oldValue: oldValue,
};
undoStack.push({ name: "graphSetParameter", action: action });
actions.graphSetParameter.execute(action);
updateMenu();
},
execute: async (action) => {
await invoke('graph_set_parameter', {
trackId: action.trackId,
nodeId: action.nodeId,
paramId: action.paramId,
value: action.newValue
});
// Update frontend slider if it exists
const slider = document.querySelector(`#node-${action.frontendNodeId} input[data-param="${action.paramId}"]`);
if (slider) {
slider.value = action.newValue;
// Trigger display update
slider.dispatchEvent(new Event('input'));
}
},
rollback: async (action) => {
await invoke('graph_set_parameter', {
trackId: action.trackId,
nodeId: action.nodeId,
paramId: action.paramId,
value: action.oldValue
});
// Update frontend slider
const slider = document.querySelector(`#node-${action.frontendNodeId} input[data-param="${action.paramId}"]`);
if (slider) {
slider.value = action.oldValue;
slider.dispatchEvent(new Event('input'));
}
},
},
graphMoveNode: {
create: (trackId, nodeId, oldPosition, newPosition) => {
redoStack.length = 0;
let action = {
trackId: trackId,
nodeId: nodeId,
oldPosition: oldPosition,
newPosition: newPosition,
};
undoStack.push({ name: "graphMoveNode", action: action });
// Don't call execute - movement already happened in UI
updateMenu();
},
execute: (action) => {
// Move node in frontend
if (context.nodeEditor) {
const node = context.nodeEditor.getNodeFromId(action.nodeId);
if (node) {
context.nodeEditor.drawflow.drawflow[context.nodeEditor.module].data[action.nodeId].pos_x = action.newPosition.x;
context.nodeEditor.drawflow.drawflow[context.nodeEditor.module].data[action.nodeId].pos_y = action.newPosition.y;
// Update visual position
const nodeElement = document.getElementById(`node-${action.nodeId}`);
if (nodeElement) {
nodeElement.style.left = action.newPosition.x + 'px';
nodeElement.style.top = action.newPosition.y + 'px';
}
context.nodeEditor.updateConnectionNodes(`node-${action.nodeId}`);
}
}
},
rollback: (action) => {
// Move node back to old position
if (context.nodeEditor) {
const node = context.nodeEditor.getNodeFromId(action.nodeId);
if (node) {
context.nodeEditor.drawflow.drawflow[context.nodeEditor.module].data[action.nodeId].pos_x = action.oldPosition.x;
context.nodeEditor.drawflow.drawflow[context.nodeEditor.module].data[action.nodeId].pos_y = action.oldPosition.y;
const nodeElement = document.getElementById(`node-${action.nodeId}`);
if (nodeElement) {
nodeElement.style.left = action.oldPosition.x + 'px';
nodeElement.style.top = action.oldPosition.y + 'px';
}
context.nodeEditor.updateConnectionNodes(`node-${action.nodeId}`);
}
}
},
},
};