const { invoke } = window.__TAURI__.core; import * as fitCurve from "/fit-curve.js"; import { Bezier } from "/bezier.js"; import { Quadtree } from "./quadtree.js"; import { createNewFileDialog, showNewFileDialog, closeDialog, } from "./newfile.js"; import { titleCase, getMousePositionFraction, getKeyframesSurrounding, lerpColor, lerp, camelToWords, generateWaveform, floodFillRegion, getShapeAtPoint, hslToRgb, drawCheckerboardBackground, hexToHsl, hsvToRgb, hexToHsv, rgbToHex, clamp, drawBorderedRect, drawCenteredText, drawHorizontallyCenteredText, deepMerge, getPointNearBox, arraysAreEqual, drawRegularPolygon, getFileExtension, createModal, deeploop, signedAngleBetweenVectors, rotateAroundPoint, getRotatedBoundingBox, rotateAroundPointIncremental, rgbToHsv, multiplyMatrices, growBoundingBox, createMissingTexturePattern, } from "./utils.js"; import { backgroundColor, darkMode, foregroundColor, frameWidth, gutterHeight, highlight, iconSize, triangleSize, labelColor, layerHeight, layerWidth, scrubberColor, shade, shadow, } from "./styles.js"; import { Icon } from "./icon.js"; import { AlphaSelectionBar, ColorSelectorWidget, ColorWidget, HueSelectionBar, SaturationValueSelectionGradient, TimelineWindow, TimelineWindowV2, Widget } from "./widgets.js"; const { writeTextFile: writeTextFile, readTextFile: readTextFile, writeFile: writeFile, readFile: readFile, } = window.__TAURI__.fs; const { open: openFileDialog, save: saveFileDialog, message: messageDialog, confirm: confirmDialog, } = window.__TAURI__.dialog; const { documentDir, join, basename, appLocalDataDir } = window.__TAURI__.path; const { Menu, MenuItem, PredefinedMenuItem, Submenu } = window.__TAURI__.menu; const { PhysicalPosition, LogicalPosition } = window.__TAURI__.dpi; const { getCurrentWindow } = window.__TAURI__.window; const { getVersion } = window.__TAURI__.app; import init, { CoreInterface } from './pkg/lightningbeam_core.js'; window.onerror = (message, source, lineno, colno, error) => { invoke("error", { msg: `${message} at ${source}:${lineno}:${colno}\n${error?.stack || ''}` }); }; window.addEventListener('unhandledrejection', (event) => { invoke("error", { msg: `Unhandled Promise Rejection: ${event.reason?.stack || event.reason}` }); }); function forwardConsole(fnName, dest) { const original = console[fnName]; console[fnName] = (...args) => { const error = new Error(); const stackLines = error.stack.split("\n"); let message = args.join(" "); // Join all arguments into a single string const location = stackLines.length>1 ? stackLines[1].match(/([a-zA-Z0-9_-]+\.js:\d+)/) : stackLines.toString(); if (fnName === "error") { // Send the full stack trace for errors invoke(dest, { msg: `${message}\nStack trace:\n${stackLines.slice(1).join("\n")}` }); } else { // For other log levels, just extract the file and line number invoke(dest, { msg: `${location ? location[0] : 'unknown'}: ${message}` }); } original(location ? location[0] : 'unknown', ...args); // Pass all arguments to the original console method }; } forwardConsole('trace', "trace"); forwardConsole('log', "trace"); forwardConsole('debug', "debug"); forwardConsole('info', "info"); forwardConsole('warn', "warn"); forwardConsole('error', "error"); console.log("*** Starting Lightningbeam ***") // Debug flags const debugQuadtree = false; const debugPaintbucket = false; const macOS = navigator.userAgent.includes("Macintosh"); let simplifyPolyline = simplify; let greetInputEl; let greetMsgEl; let rootPane; let canvases = []; let debugCurves = []; let debugPoints = []; let mode = "select"; let minSegmentSize = 5; let maxSmoothAngle = 0.6; let undoStack = []; let redoStack = []; let lastSaveIndex = 0; let layoutElements = []; // Version changes: // 1.4: addShape uses frame as a reference instead of object // 1.6: object coordinates are created relative to their location let minFileVersion = "1.3"; let maxFileVersion = "2.0"; let filePath = undefined; let fileExportPath = undefined; let state = "normal"; let playing = false; let lastFrameTime; let uiDirty = false; let layersDirty = false; let menuDirty = false; let outlinerDirty = false; let infopanelDirty = false; let lastErrorMessage = null; // To keep track of the last error let repeatCount = 0; let clipboard = []; const CONFIG_FILE_PATH = "config.json"; const defaultConfig = {}; let tools = { select: { icon: "/assets/select.svg", properties: { selectedObjects: { type: "text", label: "Selected Object", enabled: () => context.selection.length == 1, value: { get: () => { if (context.selection.length == 1) { return context.selection[0].name; } else if (context.selection.length == 0) { return ""; } else { return ""; } }, set: (val) => { if (context.selection.length == 1) { actions.setName.create(context.selection[0], val); } }, }, }, }, }, transform: { icon: "/assets/transform.svg", properties: {}, }, draw: { icon: "/assets/draw.svg", properties: { lineWidth: { type: "number", label: "Line Width", }, simplifyMode: { type: "enum", options: ["corners", "smooth", "verbatim"], // "auto"], label: "Line Mode", }, fillShape: { type: "boolean", label: "Fill Shape", }, }, }, rectangle: { icon: "/assets/rectangle.svg", properties: { lineWidth: { type: "number", label: "Line Width", }, fillShape: { type: "boolean", label: "Fill Shape", }, }, }, ellipse: { icon: "assets/ellipse.svg", properties: { lineWidth: { type: "number", label: "Line Width", }, fillShape: { type: "boolean", label: "Fill Shape", }, }, }, paint_bucket: { icon: "/assets/paint_bucket.svg", properties: { fillGaps: { type: "number", label: "Fill Gaps", min: 1, }, }, }, eyedropper: { icon: "/assets/eyedropper.svg", properties: { dropperColor: { type: "enum", options: ["Fill color", "Stroke color"], label: "Color" } } } }; let mouseEvent; let context = { mouseDown: false, mousePos: { x: 0, y: 0 }, swatches: [ "#000000", "#FFFFFF", "#FF0000", "#FFFF00", "#00FF00", "#00FFFF", "#0000FF", "#FF00FF", ], lineWidth: 5, simplifyMode: "smooth", fillShape: false, strokeShape: true, fillGaps: 5, dropperColor: "Fill color", dragging: false, selectionRect: undefined, selection: [], shapeselection: [], oldselection: [], oldshapeselection: [], selectedFrames: [], dragDirection: undefined, zoomLevel: 1, timelineWidget: null, // Reference to TimelineWindowV2 widget for zoom controls config: null, // Reference to config object (set after config is initialized) }; let config = { shortcuts: { playAnimation: " ", undo: "z", redo: "Z", new: "n", newWindow: "N", save: "s", saveAs: "S", open: "o", import: "i", export: "e", quit: "q", copy: "c", paste: "v", delete: "Backspace", selectAll: "a", group: "g", addLayer: "l", addKeyframe: "F6", addBlankKeyframe: "F7", zoomIn: "+", zoomOut: "-", resetZoom: "0", }, fileWidth: 800, fileHeight: 600, framerate: 24, recentFiles: [], scrollSpeed: 1, debug: false, reopenLastSession: false }; function getShortcut(shortcut) { if (!(shortcut in config.shortcuts)) return undefined; let shortcutValue = config.shortcuts[shortcut].replace("", "CmdOrCtrl+"); const key = shortcutValue.slice(-1); // If the last character is uppercase, prepend "Shift+" to it return key === key.toUpperCase() && key !== key.toLowerCase() ? shortcutValue.replace(key, `Shift+${key}`) : shortcutValue.replace("++", "+Shift+="); // Hardcode uppercase from = to + } // Load the configuration from the file system async function loadConfig() { try { // const configPath = await join(await appLocalDataDir(), CONFIG_FILE_PATH); // const configData = await readTextFile(configPath); const configData = localStorage.getItem("lightningbeamConfig") || "{}"; config = deepMerge({ ...config }, JSON.parse(configData)); context.config = config; // Make config accessible to widgets via context updateUI(); } catch (error) { console.log("Error loading config, returning default config:", error); } } // Save the configuration to a file async function saveConfig() { try { // const configPath = await join(await appLocalDataDir(), CONFIG_FILE_PATH); // await writeTextFile(configPath, JSON.stringify(config, null, 2)); localStorage.setItem( "lightningbeamConfig", JSON.stringify(config, null, 2), ); } catch (error) { console.error("Error saving config:", error); } } async function addRecentFile(filePath) { config.recentFiles = [filePath, ...config.recentFiles.filter(file => file !== filePath)].slice(0, 10); await saveConfig(config); } // Pointers to all objects let pointerList = {}; // Keeping track of initial values of variables when we edit them continuously let startProps = {}; let 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: (audiosrc, object, audioname) => { redoStack.length = 0; let action = { audiosrc: audiosrc, audioname: audioname, uuid: uuidv4(), layeruuid: uuidv4(), frameNum: object.currentFrameNum, object: object.idx, }; undoStack.push({ name: "addAudio", action: action }); actions.addAudio.execute(action); updateMenu(); }, execute: async (action) => { const player = new Tone.Player().toDestination(); await player.load(action.audiosrc); // player.autostart = true; let newAudioLayer = new AudioLayer(action.layeruuid, action.audioname); let object = pointerList[action.object]; const img = new Image(); img.className = "audioWaveform"; let soundObj = { player: player, start: action.frameNum, img: img, src: action.audiosrc, uuid: action.uuid }; pointerList[action.uuid] = soundObj; newAudioLayer.sounds[action.uuid] = soundObj; // TODO: change start time newAudioLayer.track.add(0, action.uuid); object.audioLayers.push(newAudioLayer); // TODO: compute image height better generateWaveform(img, player.buffer, 50, 25, config.framerate); updateLayers(); }, rollback: (action) => { let object = pointerList[action.object]; let layer = pointerList[action.layeruuid]; object.audioLayers.splice(object.audioLayers.indexOf(layer), 1); updateLayers(); }, }, 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 = {}; for (let obj of objects) { const x = layer.animationData.interpolate(`object.${obj.idx}.x`, time); const y = layer.animationData.interpolate(`object.${obj.idx}.y`, time); oldPositions[obj.idx] = { x, y }; } let action = { type: "moveObjects", objects: objects.map(o => o.idx), layer: layer.idx, time: time, oldPositions: oldPositions, }; 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; let action = { objects: objects.map(o => o.idx), layer: layer.idx, time: time, oldPositions: oldPositions, newPositions: newPositions, }; 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(`object.${objIdx}.x`); const kf = xCurve?.getKeyframeAtTime(time); if (kf) { kf.value = newPos.x; } else { layer.animationData.addKeyframe(`object.${objIdx}.x`, new Keyframe(time, newPos.x, "linear")); } const yCurve = layer.animationData.getCurve(`object.${objIdx}.y`); const kfy = yCurve?.getKeyframeAtTime(time); if (kfy) { kfy.value = newPos.y; } else { layer.animationData.addKeyframe(`object.${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]; // Restore object properties obj.x = oldPos.x; obj.y = oldPos.y; // Restore keyframes in AnimationData const xCurve = layer.animationData.getCurve(`object.${objIdx}.x`); const kf = xCurve?.getKeyframeAtTime(time); if (kf) { kf.value = oldPos.x; } else { layer.animationData.addKeyframe(`object.${objIdx}.x`, new Keyframe(time, oldPos.x, "linear")); } const yCurve = layer.animationData.getCurve(`object.${objIdx}.y`); const kfy = yCurve?.getKeyframeAtTime(time); if (kfy) { kfy.value = oldPos.y; } else { layer.animationData.addKeyframe(`object.${objIdx}.y`, new Keyframe(time, oldPos.y, "linear")); } } 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 { 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(); }, }, }; // Expose context and actions for UI testing window.context = context; window.actions = actions; window.addKeyframeAtPlayhead = addKeyframeAtPlayhead; function uuidv4() { return "10000000-1000-4000-8000-100000000000".replace(/[018]/g, (c) => ( +c ^ (crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (+c / 4))) ).toString(16), ); } /** * Generate a consistent pastel color from a UUID string * Uses hash of UUID to ensure same UUID always produces same color */ function uuidToColor(uuid) { // Simple hash function let hash = 0; for (let i = 0; i < uuid.length; i++) { hash = uuid.charCodeAt(i) + ((hash << 5) - hash); hash = hash & hash; // Convert to 32-bit integer } // Generate HSL color with fixed saturation and lightness for pastel appearance const hue = Math.abs(hash % 360); const saturation = 65; // Medium saturation for pleasant pastels const lightness = 70; // Light enough to be pastel but readable return `hsl(${hue}, ${saturation}%, ${lightness}%)`; } function vectorDist(a, b) { return Math.sqrt((a.x - b.x) * (a.x - b.x) + (a.y - b.y) * (a.y - b.y)); } function getMousePos(canvas, evt, skipOffsets = false, skipZoom = false) { var rect = canvas.getBoundingClientRect(); let offsetX = canvas.offsetX || 0; let offsetY = canvas.offsetY || 0; let zoomLevel = canvas.zoomLevel || 1; if (skipOffsets) { offsetX = 0; offsetY = 0; } return { x: (evt.clientX + offsetX - rect.left) / (skipZoom ? 1 : zoomLevel), y: (evt.clientY + offsetY - rect.top) / (skipZoom ? 1 : zoomLevel), }; } function getProperty(context, path) { let pointer = context; let pathComponents = path.split("."); for (let component of pathComponents) { pointer = pointer[component]; } return pointer; } function setProperty(context, path, value) { let pointer = context; let pathComponents = path.split("."); let finalComponent = pathComponents.pop(); for (let component of pathComponents) { pointer = pointer[component]; } pointer[finalComponent] = value; } function selectCurve(context, mouse) { let mouseTolerance = 15; let closestDist = mouseTolerance; let closestCurve = undefined; let closestShape = undefined; // Get visible shapes from Layer using AnimationData let currentTime = context.activeObject?.currentTime || 0; let layer = context.activeObject?.activeLayer; if (!layer) return undefined; for (let shape of layer.getVisibleShapes(currentTime)) { if ( mouse.x > shape.boundingBox.x.min - mouseTolerance && mouse.x < shape.boundingBox.x.max + mouseTolerance && mouse.y > shape.boundingBox.y.min - mouseTolerance && mouse.y < shape.boundingBox.y.max + mouseTolerance ) { for (let curve of shape.curves) { let dist = vectorDist(mouse, curve.project(mouse)); if (dist <= closestDist) { closestDist = dist; closestCurve = curve; closestShape = shape; } } } } if (closestCurve) { return { curve: closestCurve, shape: closestShape }; } else { return undefined; } } function selectVertex(context, mouse) { let mouseTolerance = 15; let closestDist = mouseTolerance; let closestVertex = undefined; let closestShape = undefined; // Get visible shapes from Layer using AnimationData let currentTime = context.activeObject?.currentTime || 0; let layer = context.activeObject?.activeLayer; if (!layer) return undefined; for (let shape of layer.getVisibleShapes(currentTime)) { if ( mouse.x > shape.boundingBox.x.min - mouseTolerance && mouse.x < shape.boundingBox.x.max + mouseTolerance && mouse.y > shape.boundingBox.y.min - mouseTolerance && mouse.y < shape.boundingBox.y.max + mouseTolerance ) { for (let vertex of shape.vertices) { let dist = vectorDist(mouse, vertex.point); if (dist <= closestDist) { closestDist = dist; closestVertex = vertex; closestShape = shape; } } } } if (closestVertex) { return { vertex: closestVertex, shape: closestShape }; } else { return undefined; } } function moldCurve(curve, mouse, oldMouse, epsilon = 0.01) { // Step 1: Find the closest point on the curve to the old mouse position const projection = curve.project(oldMouse); let t = projection.t; const P1 = curve.points[1]; const P2 = curve.points[2]; // Make copies of the control points to avoid editing the original curve const newP1 = { ...P1 }; const newP2 = { ...P2 }; // Step 2: Create new Bezier curves with the control points slightly offset const offsetP1 = { x: P1.x + epsilon, y: P1.y + epsilon }; const offsetP2 = { x: P2.x + epsilon, y: P2.y + epsilon }; const offsetCurveP1 = new Bezier( curve.points[0], offsetP1, curve.points[2], curve.points[3], ); const offsetCurveP2 = new Bezier( curve.points[0], curve.points[1], offsetP2, curve.points[3], ); // Step 3: See where the same point lands on the offset curves const offset1 = offsetCurveP1.compute(t); const offset2 = offsetCurveP2.compute(t); // Step 4: Calculate derivatives with respect to control points const derivativeP1 = { x: (offset1.x - projection.x) / epsilon, y: (offset1.y - projection.y) / epsilon, }; const derivativeP2 = { x: (offset2.x - projection.x) / epsilon, y: (offset2.y - projection.y) / epsilon, }; // Step 5: Use the derivatives to move the projected point to the mouse const deltaX = mouse.x - projection.x; const deltaY = mouse.y - projection.y; newP1.x = newP1.x + (deltaX / derivativeP1.x) * (1 - t * t); newP1.y = newP1.y + (deltaY / derivativeP1.y) * (1 - t * t); newP2.x = newP2.x + (deltaX / derivativeP2.x) * t * t; newP2.y = newP2.y + (deltaY / derivativeP2.y) * t * t; // Return the updated Bezier curve return new Bezier(curve.points[0], newP1, newP2, curve.points[3]); } function deriveControlPoints(S, A, E, e1, e2, t) { // Deriving the control points is effectively "doing what // we talk about in the section", in code: const v1 = { x: A.x - (A.x - e1.x) / (1 - t), y: A.y - (A.y - e1.y) / (1 - t), }; const v2 = { x: A.x - (A.x - e2.x) / t, y: A.y - (A.y - e2.y) / t, }; const C1 = { x: S.x + (v1.x - S.x) / t, y: S.y + (v1.y - S.y) / t, }; const C2 = { x: E.x + (v2.x - E.x) / (1 - t), y: E.y + (v2.y - E.y) / (1 - t), }; return { v1, v2, C1, C2 }; } function regionToBbox(region) { return { x: { min: Math.min(region.x1, region.x2), max: Math.max(region.x1, region.x2), }, y: { min: Math.min(region.y1, region.y2), max: Math.max(region.y1, region.y2), }, }; } function hitTest(candidate, object) { let bbox = object.bbox(); if (candidate.x.min) { // We're checking a bounding box if ( candidate.x.min < bbox.x.max && candidate.x.max > bbox.x.min && candidate.y.min < bbox.y.max && candidate.y.max > bbox.y.min ) { return true; } else { return false; } } else { // We're checking a point if ( candidate.x > bbox.x.min && candidate.x < bbox.x.max && candidate.y > bbox.y.min && candidate.y < bbox.y.max ) { return true; } else { return false; } } } function undo() { let action = undoStack.pop(); if (action) { actions[action.name].rollback(action.action); redoStack.push(action); updateUI(); updateMenu(); } else { console.log("No actions to undo"); updateMenu(); } } function redo() { let action = redoStack.pop(); if (action) { actions[action.name].execute(action.action); undoStack.push(action); updateUI(); updateMenu(); } else { console.log("No actions to redo"); updateMenu(); } } // LEGACY: Frame class - being phased out in favor of AnimationData // TODO: Remove once all code is migrated to AnimationData system class Frame { constructor(frameType = "normal", uuid = undefined) { this.keys = {}; this.shapes = []; this.frameType = frameType; this.keyTypes = new Set() if (!uuid) { this.idx = uuidv4(); } else { this.idx = uuid; } pointerList[this.idx] = this; } get exists() { return true; } saveState() { startProps[this.idx] = structuredClone(this.keys); } copy(idx) { let newFrame = new Frame( this.frameType, idx.slice(0, 8) + this.idx.slice(8), ); newFrame.keys = structuredClone(this.keys); newFrame.shapes = []; for (let shape of this.shapes) { newFrame.shapes.push(shape.copy(idx)); } return newFrame; } static fromJSON(json) { if (!json) { return undefined } const frame = new Frame(json.frameType, json.idx); frame.keyTypes = new Set(json.keyTypes) frame.keys = json.keys; for (let i in json.shapes) { const shape = json.shapes[i]; frame.shapes.push(Shape.fromJSON(shape)); } return frame; } toJSON(randomizeUuid = false) { const json = {}; json.type = "Frame"; json.frameType = this.frameType; json.keyTypes = Array.from(this.keyTypes) if (randomizeUuid) { json.idx = uuidv4(); } else { json.idx = this.idx; } json.keys = structuredClone(this.keys); json.shapes = []; for (let shape of this.shapes) { json.shapes.push(shape.toJSON(randomizeUuid)); } return json; } addShape(shape, sendToBack) { if (sendToBack) { this.shapes.unshift(shape); } else { this.shapes.push(shape); } } removeShape(shape) { let shapeIndex = this.shapes.indexOf(shape); if (shapeIndex >= 0) { this.shapes.splice(shapeIndex, 1); } } } class TempFrame { constructor() {} get exists() { return false; } get idx() { return "tempFrame"; } get keys() { return {}; } get shapes() { return []; } get frameType() { return "temp"; } copy() { return this; } addShape() {} removeShape() {} } const tempFrame = new TempFrame(); // Animation system classes class Keyframe { constructor(time, value, interpolation = "linear", uuid = undefined) { this.time = time; this.value = value; this.interpolation = interpolation; // 'linear', 'bezier', 'step', 'hold' // For bezier interpolation this.easeIn = { x: 0.42, y: 0 }; // Default ease-in control point this.easeOut = { x: 0.58, y: 1 }; // Default ease-out control point if (!uuid) { this.idx = uuidv4(); } else { this.idx = uuid; } } static fromJSON(json) { const keyframe = new Keyframe(json.time, json.value, json.interpolation, json.idx); if (json.easeIn) keyframe.easeIn = json.easeIn; if (json.easeOut) keyframe.easeOut = json.easeOut; return keyframe; } toJSON() { return { idx: this.idx, time: this.time, value: this.value, interpolation: this.interpolation, easeIn: this.easeIn, easeOut: this.easeOut }; } } class AnimationCurve { constructor(parameter, uuid = undefined, parentAnimationData = null) { this.parameter = parameter; // e.g., "x", "y", "rotation", "scale_x", "exists" this.keyframes = []; // Always kept sorted by time this.parentAnimationData = parentAnimationData; // Reference to parent AnimationData for duration updates if (!uuid) { this.idx = uuidv4(); } else { this.idx = uuid; } } addKeyframe(keyframe) { // Time resolution based on framerate - half a frame's duration // This can be exposed via UI later const framerate = context.config?.framerate || 24; const timeResolution = (1 / framerate) / 2; // Check if there's already a keyframe within the time resolution const existingKeyframe = this.getKeyframeAtTime(keyframe.time, timeResolution); if (existingKeyframe) { // Update the existing keyframe's value instead of adding a new one existingKeyframe.value = keyframe.value; existingKeyframe.interpolation = keyframe.interpolation; if (keyframe.easeIn) existingKeyframe.easeIn = keyframe.easeIn; if (keyframe.easeOut) existingKeyframe.easeOut = keyframe.easeOut; } else { // Add new keyframe this.keyframes.push(keyframe); // Keep sorted by time this.keyframes.sort((a, b) => a.time - b.time); } // Update animation duration after adding keyframe if (this.parentAnimationData) { this.parentAnimationData.updateDuration(); } } removeKeyframe(keyframe) { const index = this.keyframes.indexOf(keyframe); if (index >= 0) { this.keyframes.splice(index, 1); // Update animation duration after removing keyframe if (this.parentAnimationData) { this.parentAnimationData.updateDuration(); } } } getKeyframeAtTime(time, timeResolution = 0) { if (this.keyframes.length === 0) return null; // If no tolerance, use exact match with binary search if (timeResolution === 0) { let left = 0; let right = this.keyframes.length - 1; while (left <= right) { const mid = Math.floor((left + right) / 2); if (this.keyframes[mid].time === time) { return this.keyframes[mid]; } else if (this.keyframes[mid].time < time) { left = mid + 1; } else { right = mid - 1; } } return null; } // With tolerance, find the closest keyframe within timeResolution let left = 0; let right = this.keyframes.length - 1; let closest = null; let closestDist = Infinity; // Binary search to find the insertion point while (left <= right) { const mid = Math.floor((left + right) / 2); const dist = Math.abs(this.keyframes[mid].time - time); if (dist < closestDist) { closestDist = dist; closest = this.keyframes[mid]; } if (this.keyframes[mid].time < time) { left = mid + 1; } else { right = mid - 1; } } // Also check adjacent keyframes for closest match if (left < this.keyframes.length) { const dist = Math.abs(this.keyframes[left].time - time); if (dist < closestDist) { closestDist = dist; closest = this.keyframes[left]; } } if (right >= 0) { const dist = Math.abs(this.keyframes[right].time - time); if (dist < closestDist) { closestDist = dist; closest = this.keyframes[right]; } } return closestDist < timeResolution ? closest : null; } // Find the two keyframes that bracket the given time getBracketingKeyframes(time) { if (this.keyframes.length === 0) return { prev: null, next: null }; if (this.keyframes.length === 1) return { prev: this.keyframes[0], next: this.keyframes[0] }; // Binary search to find the last keyframe at or before time let left = 0; let right = this.keyframes.length - 1; let prevIndex = -1; while (left <= right) { const mid = Math.floor((left + right) / 2); if (this.keyframes[mid].time <= time) { prevIndex = mid; // This could be our answer left = mid + 1; // But check if there's a better one to the right } else { right = mid - 1; // Time is too large, search left } } // If time is before all keyframes if (prevIndex === -1) { return { prev: this.keyframes[0], next: this.keyframes[0], t: 0 }; } // If time is after all keyframes if (prevIndex === this.keyframes.length - 1) { return { prev: this.keyframes[prevIndex], next: this.keyframes[prevIndex], t: 1 }; } const prev = this.keyframes[prevIndex]; const next = this.keyframes[prevIndex + 1]; const t = (time - prev.time) / (next.time - prev.time); return { prev, next, t }; } interpolate(time) { if (this.keyframes.length === 0) { return null; } const { prev, next, t } = this.getBracketingKeyframes(time); if (!prev || !next) { return null; } if (prev === next) { return prev.value; } // Handle different interpolation types switch (prev.interpolation) { case "step": case "hold": return prev.value; case "linear": // Simple linear interpolation if (typeof prev.value === "number" && typeof next.value === "number") { return prev.value + (next.value - prev.value) * t; } return prev.value; case "bezier": // Cubic bezier interpolation using control points if (typeof prev.value === "number" && typeof next.value === "number") { // Use ease-in/ease-out control points const easedT = this.cubicBezierEase(t, prev.easeOut, next.easeIn); return prev.value + (next.value - prev.value) * easedT; } return prev.value; case "zero": // Return 0 for the entire interval (used for inactive segments) return 0; default: return prev.value; } } // Cubic bezier easing function cubicBezierEase(t, easeOut, easeIn) { // Simplified cubic bezier for 0,0 -> easeOut -> easeIn -> 1,1 const u = 1 - t; return 3 * u * u * t * easeOut.y + 3 * u * t * t * easeIn.y + t * t * t; } // Display color for this curve in timeline (based on parameter type) - Phase 4 get displayColor() { // Auto-determined from parameter name if (this.parameter.endsWith('.x')) return '#7a00b3' // purple if (this.parameter.endsWith('.y')) return '#ff00ff' // magenta if (this.parameter.endsWith('.rotation')) return '#5555ff' // blue if (this.parameter.endsWith('.scale_x')) return '#ffaa00' // orange if (this.parameter.endsWith('.scale_y')) return '#ffff55' // yellow if (this.parameter.endsWith('.exists')) return '#55ff55' // green if (this.parameter.endsWith('.zOrder')) return '#55ffff' // cyan if (this.parameter.endsWith('.frameNumber')) return '#ff5555' // red return '#ffffff' // default white } static fromJSON(json) { const curve = new AnimationCurve(json.parameter, json.idx); for (let kfJson of json.keyframes || []) { curve.keyframes.push(Keyframe.fromJSON(kfJson)); } return curve; } toJSON() { return { idx: this.idx, parameter: this.parameter, keyframes: this.keyframes.map(kf => kf.toJSON()) }; } } class AnimationData { constructor(parentLayer = null, uuid = undefined) { this.curves = {}; // parameter name -> AnimationCurve this.duration = 0; // Duration in seconds (max time of all keyframes) this.parentLayer = parentLayer; // Reference to parent Layer for updating segment keyframes if (!uuid) { this.idx = uuidv4(); } else { this.idx = uuid; } } getCurve(parameter) { return this.curves[parameter]; } getOrCreateCurve(parameter) { if (!this.curves[parameter]) { this.curves[parameter] = new AnimationCurve(parameter, undefined, this); } return this.curves[parameter]; } addKeyframe(parameter, keyframe) { const curve = this.getOrCreateCurve(parameter); curve.addKeyframe(keyframe); } removeKeyframe(parameter, keyframe) { const curve = this.curves[parameter]; if (curve) { curve.removeKeyframe(keyframe); } } removeCurve(parameter) { delete this.curves[parameter]; } setCurve(parameter, curve) { // Set parent reference for duration tracking curve.parentAnimationData = this; this.curves[parameter] = curve; // Update duration after adding curve with keyframes this.updateDuration(); } interpolate(parameter, time) { const curve = this.curves[parameter]; if (!curve) return null; return curve.interpolate(time); } // Get all animated values at a given time getValuesAtTime(time) { const values = {}; for (let parameter in this.curves) { values[parameter] = this.curves[parameter].interpolate(time); } return values; } /** * Update the duration based on all keyframes * Called automatically when keyframes are added/removed */ updateDuration() { // Calculate max time from all keyframes in all curves let maxTime = 0; for (let parameter in this.curves) { const curve = this.curves[parameter]; if (curve.keyframes && curve.keyframes.length > 0) { const lastKeyframe = curve.keyframes[curve.keyframes.length - 1]; maxTime = Math.max(maxTime, lastKeyframe.time); } } // Update this AnimationData's duration this.duration = maxTime; // If this layer belongs to a nested group, update the segment keyframes in the parent if (this.parentLayer && this.parentLayer.parentObject) { this.updateParentSegmentKeyframes(); } } /** * Update segment keyframes in parent layer when this layer's duration changes * This ensures that nested group segments automatically resize when internal animation is added */ updateParentSegmentKeyframes() { const parentObject = this.parentLayer.parentObject; // Get the layer that contains this nested object (parentObject.parentLayer) if (!parentObject.parentLayer || !parentObject.parentLayer.animationData) { return; } const parentLayer = parentObject.parentLayer; // Get the frameNumber curve for this nested object using the correct naming convention const curveName = `child.${parentObject.idx}.frameNumber`; const frameNumberCurve = parentLayer.animationData.getCurve(curveName); if (!frameNumberCurve || frameNumberCurve.keyframes.length < 2) { return; } // Update the last keyframe to match the new duration const lastKeyframe = frameNumberCurve.keyframes[frameNumberCurve.keyframes.length - 1]; const newFrameValue = Math.ceil(this.duration * config.framerate) + 1; // +1 because frameNumber is 1-indexed const newTime = this.duration; // Only update if the time or value actually changed if (lastKeyframe.value !== newFrameValue || lastKeyframe.time !== newTime) { lastKeyframe.value = newFrameValue; lastKeyframe.time = newTime; // Re-sort keyframes in case the time change affects order frameNumberCurve.keyframes.sort((a, b) => a.time - b.time); // Don't recursively call updateDuration to avoid infinite loop } } static fromJSON(json, parentLayer = null) { const animData = new AnimationData(parentLayer, json.idx); for (let param in json.curves || {}) { const curve = AnimationCurve.fromJSON(json.curves[param]); curve.parentAnimationData = animData; // Restore parent reference animData.curves[param] = curve; } // Recalculate duration after loading all curves animData.updateDuration(); return animData; } toJSON() { const curves = {}; for (let param in this.curves) { curves[param] = this.curves[param].toJSON(); } return { idx: this.idx, curves: curves }; } } class Layer extends Widget { constructor(uuid, parentObject = null) { super(0,0) if (!uuid) { this.idx = uuidv4(); } else { this.idx = uuid; } this.name = "Layer"; // LEGACY: Keep frames array for backwards compatibility during migration this.frames = [new Frame("keyframe", this.idx + "-F1")]; this.animationData = new AnimationData(this); this.parentObject = parentObject; // Reference to parent GraphicsObject (for nested objects) // this.frameNum = 0; this.visible = true; this.audible = true; pointerList[this.idx] = this; this.children = [] this.shapes = [] } static fromJSON(json, parentObject = null) { const layer = new Layer(json.idx, parentObject); for (let i in json.children) { const child = json.children[i]; const childObject = GraphicsObject.fromJSON(child); childObject.parentLayer = layer; layer.children.push(childObject); } layer.name = json.name; // Load animation data if present (new system) if (json.animationData) { layer.animationData = AnimationData.fromJSON(json.animationData, layer); } // Load shapes if present if (json.shapes) { layer.shapes = json.shapes.map(shape => Shape.fromJSON(shape, layer)); } // Load frames if present (old system - for backwards compatibility) if (json.frames) { layer.frames = []; for (let i in json.frames) { const frame = json.frames[i]; if (!frame) { layer.frames.push(undefined) continue; } if (frame.frameType=="keyframe") { layer.frames.push(Frame.fromJSON(frame)); } else { if (layer.frames[layer.frames.length-1]) { if (frame.frameType == "motion") { layer.frames[layer.frames.length-1].keyTypes.add("motion") } else if (frame.frameType == "shape") { layer.frames[layer.frames.length-1].keyTypes.add("shape") } } layer.frames.push(undefined) } } } layer.visible = json.visible; layer.audible = json.audible; return layer; } toJSON(randomizeUuid = false) { const json = {}; json.type = "Layer"; if (randomizeUuid) { json.idx = uuidv4(); json.name = this.name + " copy"; } else { json.idx = this.idx; json.name = this.name; } json.children = []; let idMap = {} for (let child of this.children) { let childJson = child.toJSON(randomizeUuid) idMap[child.idx] = childJson.idx json.children.push(childJson); } // Serialize animation data (new system) json.animationData = this.animationData.toJSON(); // If randomizing UUIDs, update the curve parameter keys to use new child IDs if (randomizeUuid && json.animationData.curves) { const newCurves = {}; for (let paramKey in json.animationData.curves) { // paramKey format: "childId.property" const parts = paramKey.split('.'); if (parts.length >= 2) { const oldChildId = parts[0]; const property = parts.slice(1).join('.'); if (oldChildId in idMap) { const newParamKey = `${idMap[oldChildId]}.${property}`; newCurves[newParamKey] = json.animationData.curves[paramKey]; newCurves[newParamKey].parameter = newParamKey; } else { newCurves[paramKey] = json.animationData.curves[paramKey]; } } else { newCurves[paramKey] = json.animationData.curves[paramKey]; } } json.animationData.curves = newCurves; } // Serialize shapes json.shapes = this.shapes.map(shape => shape.toJSON(randomizeUuid)); // Serialize frames (old system - for backwards compatibility) if (this.frames) { json.frames = []; for (let frame of this.frames) { if (frame) { let frameJson = frame.toJSON(randomizeUuid) for (let key in frameJson.keys) { if (key in idMap) { frameJson.keys[idMap[key]] = frameJson.keys[key] } } json.frames.push(frameJson); } else { json.frames.push(undefined) } } } json.visible = this.visible; json.audible = this.audible; return json; } // Get all animated property values for all children at a given time getAnimatedState(time) { const state = { shapes: [...this.shapes], // Base shapes from layer childStates: {} // Animated states for each child GraphicsObject }; // For each child, get its animated properties at this time for (let child of this.children) { const childState = {}; // Animatable properties for GraphicsObjects const properties = ['x', 'y', 'rotation', 'scale_x', 'scale_y', 'exists', 'shapeIndex']; for (let prop of properties) { const paramKey = `${child.idx}.${prop}`; const value = this.animationData.interpolate(paramKey, time); if (value !== null) { childState[prop] = value; } } if (Object.keys(childState).length > 0) { state.childStates[child.idx] = childState; } } return state; } // Helper method to add a keyframe for a child's property addKeyframeForChild(childId, property, time, value, interpolation = "linear") { const paramKey = `${childId}.${property}`; const keyframe = new Keyframe(time, value, interpolation); this.animationData.addKeyframe(paramKey, keyframe); return keyframe; } // Helper method to remove a keyframe removeKeyframeForChild(childId, property, keyframe) { const paramKey = `${childId}.${property}`; this.animationData.removeKeyframe(paramKey, keyframe); } // Helper method to get all keyframes for a child's property getKeyframesForChild(childId, property) { const paramKey = `${childId}.${property}`; const curve = this.animationData.getCurve(paramKey); return curve ? curve.keyframes : []; } /** * Add a shape to this layer at the given time * Creates AnimationData keyframes for exists, zOrder, and shapeIndex */ addShape(shape, time, sendToBack = false) { // Add to shapes array this.shapes.push(shape); // Determine zOrder let zOrder; if (sendToBack) { zOrder = 0; // Increment zOrder for all existing shapes at this time for (let existingShape of this.shapes) { if (existingShape !== shape) { let existingZOrderCurve = this.animationData.curves[`shape.${existingShape.shapeId}.zOrder`]; if (existingZOrderCurve) { for (let kf of existingZOrderCurve.keyframes) { if (kf.time === time) { kf.value += 1; } } } } } } else { zOrder = this.shapes.length - 1; } // Add AnimationData keyframes this.animationData.addKeyframe(`shape.${shape.shapeId}.exists`, new Keyframe(time, 1, "hold")); this.animationData.addKeyframe(`shape.${shape.shapeId}.zOrder`, new Keyframe(time, zOrder, "hold")); this.animationData.addKeyframe(`shape.${shape.shapeId}.shapeIndex`, new Keyframe(time, shape.shapeIndex, "linear")); } /** * Remove a specific shape instance from this layer * Leaves a "hole" in shapeIndex values so the shape can be restored later */ removeShape(shape) { const shapeIndex = this.shapes.indexOf(shape); if (shapeIndex < 0) return; const shapeId = shape.shapeId; const removedShapeIndex = shape.shapeIndex; // Remove from array this.shapes.splice(shapeIndex, 1); // Get shapeIndex curve const shapeIndexCurve = this.animationData.getCurve(`shape.${shapeId}.shapeIndex`); if (shapeIndexCurve) { // Remove keyframes that point to this shapeIndex const keyframesToRemove = shapeIndexCurve.keyframes.filter(kf => kf.value === removedShapeIndex); for (let kf of keyframesToRemove) { shapeIndexCurve.removeKeyframe(kf); } // Note: We intentionally leave a "hole" at this shapeIndex value // so the shape can be restored with the same index if undeleted } } getFrame(num) { if (this.frames[num]) { if (this.frames[num].frameType == "keyframe") { return this.frames[num]; } else if (this.frames[num].frameType == "motion") { let frameKeys = {}; let prevFrame = this.frames[num].prev; let nextFrame = this.frames[num].next; const t = (num - this.frames[num].prevIndex) / (this.frames[num].nextIndex - this.frames[num].prevIndex); for (let key in prevFrame?.keys) { frameKeys[key] = {}; let prevKeyDict = prevFrame.keys[key]; let nextKeyDict = nextFrame.keys[key]; for (let prop in prevKeyDict) { frameKeys[key][prop] = (1 - t) * prevKeyDict[prop] + t * nextKeyDict[prop]; } } let frame = new Frame("motion", "temp"); frame.keys = frameKeys; return frame; } else if (this.frames[num].frameType == "shape") { let prevFrame = this.frames[num].prev; let nextFrame = this.frames[num].next; const t = (num - this.frames[num].prevIndex) / (this.frames[num].nextIndex - this.frames[num].prevIndex); let shapes = []; for (let shape1 of prevFrame?.shapes) { if (shape1.curves.length == 0) continue; let shape2 = undefined; for (let i of nextFrame.shapes) { if (shape1.shapeId == i.shapeId) { shape2 = i; } } if (shape2 != undefined) { let path1 = [ { type: "M", x: shape1.curves[0].points[0].x, y: shape1.curves[0].points[0].y, }, ]; for (let curve of shape1.curves) { path1.push({ type: "C", x1: curve.points[1].x, y1: curve.points[1].y, x2: curve.points[2].x, y2: curve.points[2].y, x: curve.points[3].x, y: curve.points[3].y, }); } let path2 = []; if (shape2.curves.length > 0) { path2.push({ type: "M", x: shape2.curves[0].points[0].x, y: shape2.curves[0].points[0].y, }); for (let curve of shape2.curves) { path2.push({ type: "C", x1: curve.points[1].x, y1: curve.points[1].y, x2: curve.points[2].x, y2: curve.points[2].y, x: curve.points[3].x, y: curve.points[3].y, }); } } const interpolator = d3.interpolatePathCommands(path1, path2); let current = interpolator(t); let curves = []; let start = current.shift(); let { x, y } = start; for (let curve of current) { curves.push( new Bezier( x, y, curve.x1, curve.y1, curve.x2, curve.y2, curve.x, curve.y, ), ); x = curve.x; y = curve.y; } let lineWidth = lerp(shape1.lineWidth, shape2.lineWidth, t); let strokeStyle = lerpColor( shape1.strokeStyle, shape2.strokeStyle, t, ); let fillStyle; if (!shape1.fillImage) { fillStyle = lerpColor(shape1.fillStyle, shape2.fillStyle, t); } shapes.push( new TempShape( start.x, start.y, curves, shape1.lineWidth, shape1.stroked, shape1.filled, strokeStyle, fillStyle, ), ); } } let frame = new Frame("shape", "temp"); frame.shapes = shapes; return frame; } else { for (let i = Math.min(num, this.frames.length - 1); i >= 0; i--) { if (this.frames[i]?.frameType == "keyframe") { let tempFrame = this.frames[i].copy("tempFrame"); tempFrame.frameType = "normal"; return tempFrame; } } } } else { for (let i = Math.min(num, this.frames.length - 1); i >= 0; i--) { // if (this.frames[i].frameType == "keyframe") { // let tempFrame = this.frames[i].copy("tempFrame") // tempFrame.frameType = "normal" return tempFrame; // } } } } getLatestFrame(num) { for (let i = num; i >= 0; i--) { if (this.frames[i]?.exists) { return this.getFrame(i); } } } copy(idx) { let newLayer = new Layer(idx.slice(0, 8) + this.idx.slice(8)); let idxMapping = {}; for (let child of this.children) { let newChild = child.copy(idx); idxMapping[child.idx] = newChild.idx; newLayer.children.push(newChild); } newLayer.frames = []; for (let frame of this.frames) { let newFrame = frame.copy(idx); newFrame.keys = {}; for (let key in frame.keys) { newFrame.keys[idxMapping[key]] = structuredClone(frame.keys[key]); } newLayer.frames.push(newFrame); } return newLayer; } addFrame(num, frame, addedFrames) { // let updateDest = undefined; // if (!this.frames[num]) { // for (const [index, idx] of Object.entries(addedFrames)) { // if (!this.frames[index]) { // this.frames[index] = new Frame("normal", idx); // } // } // } else { // if (this.frames[num].frameType == "motion") { // updateDest = "motion"; // } else if (this.frames[num].frameType == "shape") { // updateDest = "shape"; // } // } this.frames[num] = frame; // if (updateDest) { // this.updateFrameNextAndPrev(num - 1, updateDest); // this.updateFrameNextAndPrev(num + 1, updateDest); // } } addOrChangeFrame(num, frameType, uuid, addedFrames) { let latestFrame = this.getLatestFrame(num); let newKeyframe = new Frame(frameType, uuid); for (let key in latestFrame.keys) { newKeyframe.keys[key] = structuredClone(latestFrame.keys[key]); } for (let shape of latestFrame.shapes) { newKeyframe.shapes.push(shape.copy(uuid)); } this.addFrame(num, newKeyframe, addedFrames); } deleteFrame(uuid, destinationType, replacementUuid) { let frame = pointerList[uuid]; let i = this.frames.indexOf(frame); if (i != -1) { if (destinationType == undefined) { // Determine destination type from surrounding frames const prevFrame = this.frames[i - 1]; const nextFrame = this.frames[i + 1]; const prevType = prevFrame ? prevFrame.frameType : null; const nextType = nextFrame ? nextFrame.frameType : null; if (prevType === "motion" || nextType === "motion") { destinationType = "motion"; } else if (prevType === "shape" || nextType === "shape") { destinationType = "shape"; } else if (prevType !== null && nextType !== null) { destinationType = "normal"; } else { destinationType = "none"; } } if (destinationType == "none") { delete this.frames[i]; } else { this.frames[i] = this.frames[i].copy(replacementUuid); this.frames[i].frameType = destinationType; this.updateFrameNextAndPrev(i, destinationType); } } } updateFrameNextAndPrev(num, frameType, lastBefore, firstAfter) { if (!this.frames[num] || this.frames[num].frameType == "keyframe") return; if (lastBefore == undefined || firstAfter == undefined) { let { lastKeyframeBefore, firstKeyframeAfter } = getKeyframesSurrounding( this.frames, num, ); lastBefore = lastKeyframeBefore; firstAfter = firstKeyframeAfter; } for (let i = lastBefore + 1; i < firstAfter; i++) { this.frames[i].frameType = frameType; this.frames[i].prev = this.frames[lastBefore]; this.frames[i].next = this.frames[firstAfter]; this.frames[i].prevIndex = lastBefore; this.frames[i].nextIndex = firstAfter; } } toggleVisibility() { this.visible = !this.visible; updateUI(); updateMenu(); updateLayers(); } getFrameValue(n) { const valueAtN = this.frames[n]; if (valueAtN !== undefined) { return { valueAtN, prev: null, next: null, prevIndex: null, nextIndex: null }; } let prev = n - 1; let next = n + 1; while (prev >= 0 && this.frames[prev] === undefined) { prev--; } while (next < this.frames.length && this.frames[next] === undefined) { next++; } return { valueAtN: undefined, prev: prev >= 0 ? this.frames[prev] : null, next: next < this.frames.length ? this.frames[next] : null, prevIndex: prev >= 0 ? prev : null, nextIndex: next < this.frames.length ? next : null }; } // Get all shapes that exist at the given time getVisibleShapes(time) { const visibleShapes = []; // Calculate tolerance based on framerate (half a frame) const halfFrameDuration = 0.5 / config.framerate; // Group shapes by shapeId const shapesByShapeId = new Map(); for (let shape of this.shapes) { if (shape instanceof TempShape) continue; if (!shapesByShapeId.has(shape.shapeId)) { shapesByShapeId.set(shape.shapeId, []); } shapesByShapeId.get(shape.shapeId).push(shape); } // For each logical shape (shapeId), determine which version to return for EDITING for (let [shapeId, shapes] of shapesByShapeId) { // Check if this logical shape exists at current time let existsValue = this.animationData.interpolate(`shape.${shapeId}.exists`, time); if (existsValue === null || existsValue <= 0) continue; // Get shapeIndex curve const shapeIndexCurve = this.animationData.getCurve(`shape.${shapeId}.shapeIndex`); if (!shapeIndexCurve || !shapeIndexCurve.keyframes || shapeIndexCurve.keyframes.length === 0) { // No shapeIndex curve, return shape with index 0 const shape = shapes.find(s => s.shapeIndex === 0); if (shape) { visibleShapes.push(shape); } continue; } // Find bracketing keyframes const { prev: prevKf, next: nextKf } = shapeIndexCurve.getBracketingKeyframes(time); // Get interpolated shapeIndex value let shapeIndexValue = shapeIndexCurve.interpolate(time); if (shapeIndexValue === null) shapeIndexValue = 0; // Check if we're at a keyframe (within half a frame) const atPrevKeyframe = prevKf && Math.abs(shapeIndexValue - prevKf.value) < halfFrameDuration; const atNextKeyframe = nextKf && Math.abs(shapeIndexValue - nextKf.value) < halfFrameDuration; if (atPrevKeyframe) { // At previous keyframe - return that version for editing const shape = shapes.find(s => s.shapeIndex === prevKf.value); if (shape) visibleShapes.push(shape); } else if (atNextKeyframe) { // At next keyframe - return that version for editing const shape = shapes.find(s => s.shapeIndex === nextKf.value); if (shape) visibleShapes.push(shape); } else if (prevKf && prevKf.interpolation === 'hold') { // Between keyframes but using "hold" interpolation - no morphing // Return the previous keyframe's shape since that's what's shown const shape = shapes.find(s => s.shapeIndex === prevKf.value); if (shape) visibleShapes.push(shape); } // Otherwise: between keyframes with morphing, return nothing (can't edit a morph) } return visibleShapes; } draw(ctx) { // super.draw(ctx) if (!this.visible) return; let cxt = {...context} cxt.ctx = ctx // Draw shapes using AnimationData curves for exists, zOrder, and shape tweening let currentTime = context.activeObject?.currentTime || 0; // Group shapes by shapeId for tweening support const shapesByShapeId = new Map(); for (let shape of this.shapes) { if (shape instanceof TempShape) continue; if (!shapesByShapeId.has(shape.shapeId)) { shapesByShapeId.set(shape.shapeId, []); } shapesByShapeId.get(shape.shapeId).push(shape); } // Process each logical shape (shapeId) let visibleShapes = []; for (let [shapeId, shapes] of shapesByShapeId) { // Check if this logical shape exists at current time let existsValue = this.animationData.interpolate(`shape.${shapeId}.exists`, currentTime); if (existsValue === null || existsValue <= 0) continue; // Get z-order let zOrder = this.animationData.interpolate(`shape.${shapeId}.zOrder`, currentTime); // Get shapeIndex curve and surrounding keyframes const shapeIndexCurve = this.animationData.getCurve(`shape.${shapeId}.shapeIndex`); if (!shapeIndexCurve || !shapeIndexCurve.keyframes || shapeIndexCurve.keyframes.length === 0) { // No shapeIndex curve, just show shape with index 0 const shape = shapes.find(s => s.shapeIndex === 0); if (shape) { visibleShapes.push({ shape, zOrder: zOrder || 0, selected: context.shapeselection.includes(shape) }); } continue; } // Find surrounding keyframes const { prev: prevKf, next: nextKf } = getKeyframesSurrounding(shapeIndexCurve.keyframes, currentTime); // Get interpolated value let shapeIndexValue = shapeIndexCurve.interpolate(currentTime); if (shapeIndexValue === null) shapeIndexValue = 0; // Sort shape versions by shapeIndex shapes.sort((a, b) => a.shapeIndex - b.shapeIndex); // Determine whether to morph based on whether interpolated value equals a keyframe value // Check if we're at either the previous or next keyframe value (no morphing needed) const atPrevKeyframe = prevKf && Math.abs(shapeIndexValue - prevKf.value) < 0.001; const atNextKeyframe = nextKf && Math.abs(shapeIndexValue - nextKf.value) < 0.001; if (atPrevKeyframe || atNextKeyframe) { // No morphing - display the shape at the keyframe value const targetValue = atNextKeyframe ? nextKf.value : prevKf.value; const shape = shapes.find(s => s.shapeIndex === targetValue); if (shape) { visibleShapes.push({ shape, zOrder: zOrder || 0, selected: context.shapeselection.includes(shape) }); } } else if (prevKf && nextKf && prevKf.value !== nextKf.value) { // Morph between shapes specified by surrounding keyframes const shape1 = shapes.find(s => s.shapeIndex === prevKf.value); const shape2 = shapes.find(s => s.shapeIndex === nextKf.value); if (shape1 && shape2) { // Calculate t based on time position between keyframes const t = (currentTime - prevKf.time) / (nextKf.time - prevKf.time); const morphedShape = shape1.lerpShape(shape2, t); visibleShapes.push({ shape: morphedShape, zOrder: zOrder || 0, selected: context.shapeselection.includes(shape1) || context.shapeselection.includes(shape2) }); } else if (shape1) { visibleShapes.push({ shape: shape1, zOrder: zOrder || 0, selected: context.shapeselection.includes(shape1) }); } else if (shape2) { visibleShapes.push({ shape: shape2, zOrder: zOrder || 0, selected: context.shapeselection.includes(shape2) }); } } else if (nextKf) { // Only next keyframe exists, show that shape const shape = shapes.find(s => s.shapeIndex === nextKf.value); if (shape) { visibleShapes.push({ shape, zOrder: zOrder || 0, selected: context.shapeselection.includes(shape) }); } } } // Sort by zOrder (lowest first = back, highest last = front) visibleShapes.sort((a, b) => a.zOrder - b.zOrder); // Draw sorted shapes for (let { shape, selected } of visibleShapes) { cxt.selected = selected; shape.draw(cxt); } // Draw children (GraphicsObjects) using AnimationData curves for (let child of this.children) { // Check if child exists at current time using AnimationData const existsValue = this.animationData.interpolate(`object.${child.idx}.exists`, currentTime); if (existsValue === null || existsValue <= 0) continue; // Get child properties from AnimationData curves const childX = this.animationData.interpolate(`object.${child.idx}.x`, currentTime); const childY = this.animationData.interpolate(`object.${child.idx}.y`, currentTime); const childRotation = this.animationData.interpolate(`object.${child.idx}.rotation`, currentTime); const childScaleX = this.animationData.interpolate(`object.${child.idx}.scale_x`, currentTime); const childScaleY = this.animationData.interpolate(`object.${child.idx}.scale_y`, currentTime); // Apply properties if they exist in AnimationData if (childX !== null) child.x = childX; if (childY !== null) child.y = childY; if (childRotation !== null) child.rotation = childRotation; if (childScaleX !== null) child.scale_x = childScaleX; if (childScaleY !== null) child.scale_y = childScaleY; // Draw the child if not in objectStack if (!context.objectStack.includes(child)) { const transform = ctx.getTransform(); ctx.translate(child.x, child.y); ctx.scale(child.scale_x, child.scale_y); ctx.rotate(child.rotation); child.draw(ctx); // Draw selection outline if selected if (context.selection.includes(child)) { ctx.lineWidth = 1; ctx.strokeStyle = "#00ffff"; ctx.beginPath(); let bbox = child.bbox(); ctx.rect(bbox.x.min - child.x, bbox.y.min - child.y, bbox.x.max - bbox.x.min, bbox.y.max - bbox.y.min); ctx.stroke(); } ctx.setTransform(transform); } } // Draw activeShape regardless of whether frame exists if (this.activeShape) { console.log("Layer.draw: Drawing activeShape", this.activeShape); this.activeShape.draw(cxt) console.log("Layer.draw: Drew activeShape"); } } bbox() { let bbox = super.bbox(); let currentTime = context.activeObject?.currentTime || 0; // Get visible shapes at current time using AnimationData const visibleShapes = this.getVisibleShapes(currentTime); if (visibleShapes.length > 0 && bbox === undefined) { bbox = structuredClone(visibleShapes[0].boundingBox); } for (let shape of visibleShapes) { growBoundingBox(bbox, shape.boundingBox); } return bbox; } mousedown(x, y) { console.log("Layer.mousedown called - this:", this.name, "activeLayer:", context.activeLayer?.name, "mode:", mode); const mouse = {x: x, y: y} if (this==context.activeLayer) { console.log("This IS the active layer"); switch(mode) { case "rectangle": case "ellipse": case "draw": console.log("Creating shape for mode:", mode); this.clicked = true this.activeShape = new Shape(x, y, context, this, uuidv4()) this.lastMouse = mouse; console.log("Shape created:", this.activeShape); break; case "select": case "transform": break; case "paint_bucket": debugCurves = []; debugPoints = []; let epsilon = context.fillGaps; let regionPoints; // First, see if there's an existing shape to change the color of let currentTime = context.activeObject?.currentTime || 0; let visibleShapes = this.getVisibleShapes(currentTime); let pointShape = getShapeAtPoint(mouse, visibleShapes); if (pointShape) { actions.colorShape.create(pointShape, context.fillStyle); break; } // We didn't find an existing region to paintbucket, see if we can make one try { regionPoints = floodFillRegion( mouse, epsilon, config.fileWidth, config.fileHeight, context, debugPoints, debugPaintbucket, visibleShapes, ); } catch (e) { updateUI(); throw e; } if (regionPoints.length > 0 && regionPoints.length < 10) { // probably a very small area, rerun with minimum epsilon regionPoints = floodFillRegion( mouse, 1, config.fileWidth, config.fileHeight, context, debugPoints, false, visibleShapes, ); } let points = []; for (let point of regionPoints) { points.push([point.x, point.y]); } let cxt = { ...context, fillShape: true, strokeShape: false, sendToBack: true, }; let shape = new Shape(regionPoints[0].x, regionPoints[0].y, cxt, this); shape.fromPoints(points, 1); actions.addShape.create(context.activeObject, shape, cxt); break; } } } mousemove(x, y) { const mouse = {x: x, y: y} if (this==context.activeLayer) { switch (mode) { case "draw": if (this.activeShape) { if (vectorDist(mouse, context.lastMouse) > minSegmentSize) { this.activeShape.addLine(x, y); this.lastMouse = mouse; } } break; case "rectangle": if (this.activeShape) { this.activeShape.clear(); this.activeShape.addLine(x, this.activeShape.starty); this.activeShape.addLine(x, y); this.activeShape.addLine(this.activeShape.startx, y); this.activeShape.addLine( this.activeShape.startx, this.activeShape.starty, ); this.activeShape.update(); } break; case "ellipse": if (this.activeShape) { let midX = (mouse.x + this.activeShape.startx) / 2; let midY = (mouse.y + this.activeShape.starty) / 2; let xDiff = (mouse.x - this.activeShape.startx) / 2; let yDiff = (mouse.y - this.activeShape.starty) / 2; let ellipseConst = 0.552284749831; // (4/3)*tan(pi/(2n)) where n=4 this.activeShape.clear(); this.activeShape.addCurve( new Bezier( midX, this.activeShape.starty, midX + ellipseConst * xDiff, this.activeShape.starty, mouse.x, midY - ellipseConst * yDiff, mouse.x, midY, ), ); this.activeShape.addCurve( new Bezier( mouse.x, midY, mouse.x, midY + ellipseConst * yDiff, midX + ellipseConst * xDiff, mouse.y, midX, mouse.y, ), ); this.activeShape.addCurve( new Bezier( midX, mouse.y, midX - ellipseConst * xDiff, mouse.y, this.activeShape.startx, midY + ellipseConst * yDiff, this.activeShape.startx, midY, ), ); this.activeShape.addCurve( new Bezier( this.activeShape.startx, midY, this.activeShape.startx, midY - ellipseConst * yDiff, midX - ellipseConst * xDiff, this.activeShape.starty, midX, this.activeShape.starty, ), ); } break; } } } mouseup(x, y) { console.log("Layer.mouseup called - mode:", mode, "activeShape:", this.activeShape); this.clicked = false if (this==context.activeLayer) { switch (mode) { case "draw": if (this.activeShape) { this.activeShape.addLine(x, y); this.activeShape.simplify(context.simplifyMode); } case "rectangle": case "ellipse": if (this.activeShape) { console.log("Adding shape via actions.addShape.create"); actions.addShape.create(context.activeObject, this.activeShape); console.log("Shape added, clearing activeShape"); this.activeShape = undefined; } break; } } } } class AudioLayer { constructor(uuid, name) { this.sounds = {}; this.track = new Tone.Part((time, sound) => { console.log(this.sounds[sound]); this.sounds[sound].player.start(time); }); if (!uuid) { this.idx = uuidv4(); } else { this.idx = uuid; } if (!name) { this.name = "Audio"; } else { this.name = name; } this.audible = true; } static fromJSON(json) { const audioLayer = new AudioLayer(json.idx, json.name); // TODO: load audiolayer from json audioLayer.sounds = {}; for (let id in json.sounds) { const jsonSound = json.sounds[id] const img = new Image(); img.className = "audioWaveform"; const player = new Tone.Player().toDestination(); player.load(jsonSound.src) .then(() => { generateWaveform(img, player.buffer, 50, 25, config.framerate); }) .catch(error => { // Handle any errors that occur during the load or waveform generation console.error(error); }); let soundObj = { player: player, start: jsonSound.start, img: img, src: jsonSound.src, uuid: jsonSound.uuid }; pointerList[jsonSound.uuid] = soundObj; audioLayer.sounds[jsonSound.uuid] = soundObj; // TODO: change start time audioLayer.track.add(0, jsonSound.uuid); } audioLayer.audible = json.audible; return audioLayer; } toJSON(randomizeUuid = false) { console.log(this.sounds) const json = {}; json.type = "AudioLayer"; // TODO: build json from audiolayer json.sounds = {}; for (let id in this.sounds) { const sound = this.sounds[id] json.sounds[id] = { start: sound.start, src: sound.src, uuid: sound.uuid } } json.audible = this.audible; if (randomizeUuid) { json.idx = uuidv4(); json.name = this.name + " copy"; } else { json.idx = this.idx; json.name = this.name; } return json; } copy(idx) { let newAudioLayer = new AudioLayer( idx.slice(0, 8) + this.idx.slice(8), this.name, ); for (let soundIdx in this.sounds) { let sound = this.sounds[soundIdx]; let newPlayer = new Tone.Player(sound.buffer()).toDestination(); let idx = this.idx.slice(0, 8) + soundIdx.slice(8); let soundObj = { player: newPlayer, start: sound.start, }; pointerList[idx] = soundObj; newAudioLayer.sounds[idx] = soundObj; } } } class BaseShape { constructor(startx, starty) { this.startx = startx; this.starty = starty; this.curves = []; this.regions = []; this.boundingBox = { x: { min: startx, max: starty }, y: { min: starty, max: starty }, }; } recalculateBoundingBox() { this.boundingBox = undefined; for (let curve of this.curves) { if (!this.boundingBox) { this.boundingBox = curve.bbox(); } growBoundingBox(this.boundingBox, curve.bbox()); } } draw(context) { let ctx = context.ctx; ctx.lineWidth = this.lineWidth; ctx.lineCap = "round"; // Create a repeating pattern for indicating selected shapes if (!this.patternCanvas) { this.patternCanvas = document.createElement('canvas'); this.patternCanvas.width = 2; this.patternCanvas.height = 2; let patternCtx = this.patternCanvas.getContext('2d'); // Draw the pattern: // black, transparent, // transparent, white patternCtx.fillStyle = 'black'; patternCtx.fillRect(0, 0, 1, 1); patternCtx.clearRect(1, 0, 1, 1); patternCtx.clearRect(0, 1, 1, 1); patternCtx.fillStyle = 'white'; patternCtx.fillRect(1, 1, 1, 1); } let pattern = ctx.createPattern(this.patternCanvas, 'repeat'); // repeat the pattern across the canvas if (this.filled) { ctx.beginPath(); if (this.fillImage && this.fillImage instanceof Element) { let pat; if (this.fillImage instanceof Element || Object.keys(this.fillImage).length !== 0) { pat = ctx.createPattern(this.fillImage, "no-repeat"); } else { pat = createMissingTexturePattern(ctx) } ctx.fillStyle = pat; } else { ctx.fillStyle = this.fillStyle; } if (context.debugColor) { ctx.fillStyle = context.debugColor; } if (this.curves.length > 0) { ctx.moveTo(this.curves[0].points[0].x, this.curves[0].points[0].y); for (let curve of this.curves) { ctx.bezierCurveTo( curve.points[1].x, curve.points[1].y, curve.points[2].x, curve.points[2].y, curve.points[3].x, curve.points[3].y, ); } } ctx.fill(); if (context.selected) { ctx.fillStyle = pattern ctx.fill() } } function drawCurve(curve, selected) { ctx.strokeStyle = curve.color; ctx.beginPath(); ctx.moveTo(curve.points[0].x, curve.points[0].y); ctx.bezierCurveTo( curve.points[1].x, curve.points[1].y, curve.points[2].x, curve.points[2].y, curve.points[3].x, curve.points[3].y, ); ctx.stroke(); if (selected) { ctx.strokeStyle = pattern ctx.stroke() } } if (this.stroked && !context.debugColor) { for (let curve of this.curves) { drawCurve(curve, context.selected) // // Debug, show curve control points // ctx.beginPath() // ctx.arc(curve.points[1].x,curve.points[1].y, 5, 0, 2*Math.PI) // ctx.arc(curve.points[2].x,curve.points[2].y, 5, 0, 2*Math.PI) // ctx.arc(curve.points[3].x,curve.points[3].y, 5, 0, 2*Math.PI) // ctx.fill() } } if (context.activeCurve && this==context.activeCurve.shape) { drawCurve(context.activeCurve.current, true) } if (context.activeVertex && this==context.activeVertex.shape) { const curves = { ...context.activeVertex.current.startCurves, ...context.activeVertex.current.endCurves } for (let i in curves) { let curve = curves[i] drawCurve(curve, true) } ctx.fillStyle = "#000000aa"; ctx.beginPath(); let vertexSize = 15 / context.zoomLevel; ctx.rect( context.activeVertex.current.point.x - vertexSize / 2, context.activeVertex.current.point.y - vertexSize / 2, vertexSize, vertexSize, ); ctx.fill(); } // Debug, show quadtree if (debugQuadtree && this.quadtree && !context.debugColor) { this.quadtree.draw(ctx); } } lerpShape(shape2, t) { if (this.curves.length == 0) return this; let path1 = [ { type: "M", x: this.curves[0].points[0].x, y: this.curves[0].points[0].y, }, ]; for (let curve of this.curves) { path1.push({ type: "C", x1: curve.points[1].x, y1: curve.points[1].y, x2: curve.points[2].x, y2: curve.points[2].y, x: curve.points[3].x, y: curve.points[3].y, }); } let path2 = []; if (shape2.curves.length > 0) { path2.push({ type: "M", x: shape2.curves[0].points[0].x, y: shape2.curves[0].points[0].y, }); for (let curve of shape2.curves) { path2.push({ type: "C", x1: curve.points[1].x, y1: curve.points[1].y, x2: curve.points[2].x, y2: curve.points[2].y, x: curve.points[3].x, y: curve.points[3].y, }); } } const interpolator = d3.interpolatePathCommands(path1, path2); let current = interpolator(t); let curves = []; let start = current.shift(); let { x, y } = start; let bezier; for (let curve of current) { bezier = new Bezier( x, y, curve.x1, curve.y1, curve.x2, curve.y2, curve.x, curve.y, ) bezier.color = lerpColor(this.strokeStyle, shape2.strokeStyle) curves.push(bezier); x = curve.x; y = curve.y; } let lineWidth = lerp(this.lineWidth, shape2.lineWidth, t); let strokeStyle = lerpColor( this.strokeStyle, shape2.strokeStyle, t, ); let fillStyle; if (!this.fillImage) { fillStyle = lerpColor(this.fillStyle, shape2.fillStyle, t); } return new TempShape( start.x, start.y, curves, lineWidth, this.stroked, this.filled, strokeStyle, fillStyle, ) } } class TempShape extends BaseShape { constructor( startx, starty, curves, lineWidth, stroked, filled, strokeStyle, fillStyle, ) { super(startx, starty); this.curves = curves; this.lineWidth = lineWidth; this.stroked = stroked; this.filled = filled; this.strokeStyle = strokeStyle; this.fillStyle = fillStyle; this.inProgress = false; this.recalculateBoundingBox(); } } class Shape extends BaseShape { constructor(startx, starty, context, parent, uuid = undefined, shapeId = undefined) { super(startx, starty); this.parent = parent; // Reference to parent Layer (required) this.vertices = []; this.triangles = []; this.fillStyle = context.fillStyle; this.fillImage = context.fillImage; this.strokeStyle = context.strokeStyle; this.lineWidth = context.lineWidth; this.filled = context.fillShape; this.stroked = context.strokeShape; this.quadtree = new Quadtree( { x: { min: 0, max: 500 }, y: { min: 0, max: 500 } }, 4, ); if (!uuid) { this.idx = uuidv4(); } else { this.idx = uuid; } if (!shapeId) { this.shapeId = uuidv4(); } else { this.shapeId = shapeId; } this.shapeIndex = 0; // Default shape version index for tweening pointerList[this.idx] = this; this.regionIdx = 0; this.inProgress = true; // Timeline display settings (Phase 3) this.showSegment = true // Show segment bar in timeline this.curvesMode = 'hidden' // 'hidden' | 'minimized' | 'expanded' this.curvesHeight = 150 // Height in pixels when curves are expanded } static fromJSON(json, parent) { let fillImage = undefined; if (json.fillImage && Object.keys(json.fillImage).length !== 0) { let img = new Image(); img.src = json.fillImage.src fillImage = img } else { fillImage = {} } const shape = new Shape( json.startx, json.starty, { fillStyle: json.fillStyle, fillImage: fillImage, strokeStyle: json.strokeStyle, lineWidth: json.lineWidth, fillShape: json.filled, strokeShape: json.stroked, }, parent, json.idx, json.shapeId, ); for (let curve of json.curves) { shape.addCurve(Bezier.fromJSON(curve)); } for (let region of json.regions) { const curves = []; for (let curve of region.curves) { curves.push(Bezier.fromJSON(curve)); } shape.regions.push({ idx: region.idx, curves: curves, fillStyle: region.fillStyle, filled: region.filled, }); } // Load shapeIndex if present (for shape tweening) if (json.shapeIndex !== undefined) { shape.shapeIndex = json.shapeIndex; } return shape; } toJSON(randomizeUuid = false) { const json = {}; json.type = "Shape"; json.startx = this.startx; json.starty = this.starty; json.fillStyle = this.fillStyle; if (this.fillImage instanceof Element) { json.fillImage = { src: this.fillImage.src } } json.strokeStyle = this.fillStyle; json.lineWidth = this.lineWidth; json.filled = this.filled; json.stroked = this.stroked; if (randomizeUuid) { json.idx = uuidv4(); } else { json.idx = this.idx; } json.shapeId = this.shapeId; json.shapeIndex = this.shapeIndex; // For shape tweening json.curves = []; for (let curve of this.curves) { json.curves.push(curve.toJSON(randomizeUuid)); } json.regions = []; for (let region of this.regions) { const curves = []; for (let curve of region.curves) { curves.push(curve.toJSON(randomizeUuid)); } json.regions.push({ idx: region.idx, curves: curves, fillStyle: region.fillStyle, filled: region.filled, }); } return json; } get segmentColor() { return uuidToColor(this.idx); } addCurve(curve) { if (curve.color == undefined) { curve.color = context.strokeStyle; } this.curves.push(curve); this.quadtree.insert(curve, this.curves.length - 1); growBoundingBox(this.boundingBox, curve.bbox()); } addLine(x, y) { let lastpoint; if (this.curves.length) { lastpoint = this.curves[this.curves.length - 1].points[3]; } else { lastpoint = { x: this.startx, y: this.starty }; } let midpoint = { x: (x + lastpoint.x) / 2, y: (y + lastpoint.y) / 2 }; let curve = new Bezier( lastpoint.x, lastpoint.y, midpoint.x, midpoint.y, midpoint.x, midpoint.y, x, y, ); curve.color = context.strokeStyle; this.quadtree.insert(curve, this.curves.length - 1); this.curves.push(curve); } bbox() { return this.boundingBox; } clear() { this.curves = []; this.quadtree.clear(); } copy(idx) { let newShape = new Shape( this.startx, this.starty, {}, this.parent, idx.slice(0, 8) + this.idx.slice(8), this.shapeId, ); newShape.startx = this.startx; newShape.starty = this.starty; for (let curve of this.curves) { let newCurve = 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, ); newCurve.color = curve.color; newShape.addCurve(newCurve); } // TODO // for (let vertex of this.vertices) { // } newShape.updateVertices(); newShape.fillStyle = this.fillStyle; if (this.fillImage instanceof Element) { newShape.fillImage = this.fillImage.cloneNode(true) } else { newShape.fillImage = this.fillImage; } newShape.strokeStyle = this.strokeStyle; newShape.lineWidth = this.lineWidth; newShape.filled = this.filled; newShape.stroked = this.stroked; return newShape; } fromPoints(points, error = 30) { console.log(error); this.curves = []; let curves = fitCurve.fitCurve(points, error); for (let curve of curves) { let bezier = new Bezier( curve[0][0], curve[0][1], curve[1][0], curve[1][1], curve[2][0], curve[2][1], curve[3][0], curve[3][1], ); this.curves.push(bezier); this.quadtree.insert(bezier, this.curves.length - 1); } return this; } simplify(mode = "corners") { this.quadtree.clear(); this.inProgress = false; // Mode can be corners, smooth or auto if (mode == "corners") { let points = [{ x: this.startx, y: this.starty }]; for (let curve of this.curves) { points.push(curve.points[3]); } // points = points.concat(this.curves) let newpoints = simplifyPolyline(points, 10, false); this.curves = []; let lastpoint = newpoints.shift(); let midpoint; for (let point of newpoints) { midpoint = { x: (lastpoint.x + point.x) / 2, y: (lastpoint.y + point.y) / 2, }; let bezier = new Bezier( lastpoint.x, lastpoint.y, midpoint.x, midpoint.y, midpoint.x, midpoint.y, point.x, point.y, ); this.curves.push(bezier); this.quadtree.insert(bezier, this.curves.length - 1); lastpoint = point; } } else if (mode == "smooth") { let error = 30; let points = [[this.startx, this.starty]]; for (let curve of this.curves) { points.push([curve.points[3].x, curve.points[3].y]); } this.fromPoints(points, error); } else if (mode == "verbatim") { // Just keep existing shape } let epsilon = 0.01; let newCurves = []; let intersectMap = {}; for (let i = 0; i < this.curves.length - 1; i++) { // for (let j=i+1; j= j) continue; let intersects = this.curves[i].intersects(this.curves[j]); if (intersects.length) { intersectMap[i] ||= []; intersectMap[j] ||= []; for (let intersect of intersects) { let [t1, t2] = intersect.split("/"); intersectMap[i].push(parseFloat(t1)); intersectMap[j].push(parseFloat(t2)); } } } } for (let lst in intersectMap) { for (let i = 1; i < intersectMap[lst].length; i++) { if ( Math.abs(intersectMap[lst][i] - intersectMap[lst][i - 1]) < epsilon ) { intersectMap[lst].splice(i, 1); i--; } } } for (let i = this.curves.length - 1; i >= 0; i--) { if (i in intersectMap) { intersectMap[i].sort().reverse(); let remainingFraction = 1; let remainingCurve = this.curves[i]; for (let t of intersectMap[i]) { let split = remainingCurve.split(t / remainingFraction); remainingFraction = t; newCurves.push(split.right); remainingCurve = split.left; } newCurves.push(remainingCurve); } else { newCurves.push(this.curves[i]); } } for (let curve of newCurves) { curve.color = context.strokeStyle; } newCurves.reverse(); this.curves = newCurves; } update() { this.recalculateBoundingBox(); this.updateVertices(); if (this.curves.length) { this.startx = this.curves[0].points[0].x; this.starty = this.curves[0].points[0].y; } return [this]; } getClockwiseCurves(point, otherPoints) { // Returns array of {x, y, idx, angle} let points = []; for (let point of otherPoints) { points.push({ ...this.vertices[point].point, idx: point }); } // Add an angle property to each point using tan(angle) = y/x const angles = points.map(({ x, y, idx }) => { return { x, y, idx, angle: (Math.atan2(y - point.y, x - point.x) * 180) / Math.PI, }; }); // Sort your points by angle const pointsSorted = angles.sort((a, b) => a.angle - b.angle); return pointsSorted; } translate(x, y) { this.quadtree.clear() let j=0; for (let curve of this.curves) { for (let i in curve.points) { const point = curve.points[i]; curve.points[i] = { x: point.x + x, y: point.y + y }; } this.quadtree.insert(curve, j) j++; } this.update(); } updateVertices() { this.vertices = []; let utils = Bezier.getUtils(); let epsilon = 1.5; // big epsilon whoa let tooClose; let i = 0; let region = { idx: `${this.idx}-r${this.regionIdx++}`, curves: [], fillStyle: context.fillStyle, filled: context.fillShape, }; pointerList[region.idx] = region; this.regions = [region]; for (let curve of this.curves) { this.regions[0].curves.push(curve); } if (this.regions[0].curves.length) { if ( utils.dist( this.regions[0].curves[0].points[0], this.regions[0].curves[this.regions[0].curves.length - 1].points[3], ) < epsilon ) { this.regions[0].filled = true; } } // Generate vertices for (let curve of this.curves) { for (let index of [0, 3]) { tooClose = false; for (let vertex of this.vertices) { if (utils.dist(curve.points[index], vertex.point) < epsilon) { tooClose = true; vertex[["startCurves", , , "endCurves"][index]][i] = curve; break; } } if (!tooClose) { if (index == 0) { this.vertices.push({ point: curve.points[index], startCurves: { [i]: curve }, endCurves: {}, }); } else { this.vertices.push({ point: curve.points[index], startCurves: {}, endCurves: { [i]: curve }, }); } } } i++; } let shapes = [this]; this.vertices.forEach((vertex, i) => { for (let i = 0; i < Math.min(10, this.regions.length); i++) { let region = this.regions[i]; let regionVertexCurves = []; let vertexCurves = { ...vertex.startCurves, ...vertex.endCurves }; if (Object.keys(vertexCurves).length == 1) { // endpoint continue; } else if (Object.keys(vertexCurves).length == 2) { // path vertex, don't need to do anything continue; } else if (Object.keys(vertexCurves).length == 3) { // T junction. Region doesn't change but might need to update curves? // Skip for now. continue; } else if (Object.keys(vertexCurves).length == 4) { // Intersection, split region in 2 for (let i in vertexCurves) { let curve = vertexCurves[i]; if (region.curves.includes(curve)) { regionVertexCurves.push(curve); } } let start = region.curves.indexOf(regionVertexCurves[1]); let end = region.curves.indexOf(regionVertexCurves[3]); if (end > start) { let newRegion = { idx: `${this.idx}-r${this.regionIdx++}`, // TODO: generate this deterministically so that undo/redo works curves: region.curves.splice(start, end - start), fillStyle: region.fillStyle, filled: true, }; pointerList[newRegion.idx] = newRegion; this.regions.push(newRegion); } } else { // not sure how to handle vertices with more than 4 curves console.log( `Unexpected vertex with ${Object.keys(vertexCurves).length} curves!`, ); } } }); } } class GraphicsObject extends Widget { constructor(uuid) { super(0, 0) this.rotation = 0; // in radians this.scale_x = 1; this.scale_y = 1; if (!uuid) { this.idx = uuidv4(); } else { this.idx = uuid; } pointerList[this.idx] = this; this.name = this.idx; this.currentFrameNum = 0; // LEGACY: kept for backwards compatibility this.currentTime = 0; // New: continuous time for AnimationData curves this.currentLayer = 0; this.children = [new Layer(uuid + "-L1", this)]; // this.layers = [new Layer(uuid + "-L1")]; this.audioLayers = []; // this.children = [] this.shapes = []; // Parent reference for nested objects (set when added to a layer) this.parentLayer = null // Timeline display settings (Phase 3) this.showSegment = true // Show segment bar in timeline this.curvesMode = 'hidden' // 'hidden' | 'minimized' | 'expanded' this.curvesHeight = 150 // Height in pixels when curves are expanded this._globalEvents.add("mousedown") this._globalEvents.add("mousemove") this._globalEvents.add("mouseup") } static fromJSON(json) { const graphicsObject = new GraphicsObject(json.idx); graphicsObject.x = json.x; graphicsObject.y = json.y; graphicsObject.rotation = json.rotation; graphicsObject.scale_x = json.scale_x; graphicsObject.scale_y = json.scale_y; graphicsObject.name = json.name; graphicsObject.currentFrameNum = json.currentFrameNum; graphicsObject.currentLayer = json.currentLayer; graphicsObject.children = []; if (json.parent in pointerList) { graphicsObject.parent = pointerList[json.parent] } for (let layer of json.layers) { graphicsObject.layers.push(Layer.fromJSON(layer, graphicsObject)); } for (let audioLayer of json.audioLayers) { graphicsObject.audioLayers.push(AudioLayer.fromJSON(audioLayer)); } return graphicsObject; } toJSON(randomizeUuid = false) { const json = {}; json.type = "GraphicsObject"; json.x = this.x; json.y = this.y; json.rotation = this.rotation; json.scale_x = this.scale_x; json.scale_y = this.scale_y; if (randomizeUuid) { json.idx = uuidv4(); json.name = this.name + " copy"; } else { json.idx = this.idx; json.name = this.name; } json.currentFrameNum = this.currentFrameNum; json.currentLayer = this.currentLayer; json.layers = []; json.parent = this.parent?.idx for (let layer of this.layers) { json.layers.push(layer.toJSON(randomizeUuid)); } json.audioLayers = []; for (let audioLayer of this.audioLayers) { json.audioLayers.push(audioLayer.toJSON(randomizeUuid)); } return json; } get activeLayer() { return this.layers[this.currentLayer]; } // get children() { // return this.activeLayer.children; // } get layers() { return this.children } /** * Get the total duration of this GraphicsObject's animation * Returns the maximum duration across all layers */ get duration() { let maxDuration = 0; for (let layer of this.layers) { if (layer.animationData && layer.animationData.duration > maxDuration) { maxDuration = layer.animationData.duration; } } return maxDuration; } get allLayers() { return [...this.audioLayers, ...this.layers]; } get maxFrame() { return ( Math.max( ...this.layers.map((layer) => { return ( layer.frames.findLastIndex((frame) => frame !== undefined) || -1 ); }), ) + 1 ); } get segmentColor() { return uuidToColor(this.idx); } /** * Set the current playback time in seconds */ setTime(time) { time = Math.max(0, time); this.currentTime = time; // Update legacy currentFrameNum for any remaining code that needs it this.currentFrameNum = Math.floor(time * config.framerate); // Update layer frameNum for legacy code for (let layer of this.layers) { layer.frameNum = this.currentFrameNum; } } advanceFrame() { const frameDuration = 1 / config.framerate; this.setTime(this.currentTime + frameDuration); } decrementFrame() { const frameDuration = 1 / config.framerate; this.setTime(Math.max(0, this.currentTime - frameDuration)); } bbox() { let bbox; // NEW: Include shapes from AnimationData system let currentTime = this.currentTime || 0; for (let layer of this.layers) { for (let shape of layer.shapes) { // Check if shape exists at current time let existsValue = layer.animationData.interpolate(`shape.${shape.shapeId}.exists`, currentTime); if (existsValue !== null && existsValue > 0) { if (!bbox) { bbox = structuredClone(shape.boundingBox); } else { growBoundingBox(bbox, shape.boundingBox); } } } } // Include children if (this.children.length > 0) { if (!bbox) { bbox = structuredClone(this.children[0].bbox()); } for (let child of this.children) { growBoundingBox(bbox, child.bbox()); } } if (bbox == undefined) { bbox = { x: { min: 0, max: 0 }, y: { min: 0, max: 0 } }; } bbox.x.max *= this.scale_x; bbox.y.max *= this.scale_y; bbox.x.min += this.x; bbox.x.max += this.x; bbox.y.min += this.y; bbox.y.max += this.y; return bbox; } draw(context, calculateTransform=false) { let ctx = context.ctx; ctx.save(); if (calculateTransform) { this.transformCanvas(ctx) } else { ctx.translate(this.x, this.y); ctx.rotate(this.rotation); ctx.scale(this.scale_x, this.scale_y); } // if (this.currentFrameNum>=this.maxFrame) { // this.currentFrameNum = 0; // } if ( context.activeAction && context.activeAction.selection && this.idx in context.activeAction.selection ) return; for (let layer of this.layers) { if (context.activeObject == this && !layer.visible) continue; // 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 with shape tweening/morphing let currentTime = this.currentTime || 0; // Group shapes by shapeId (multiple Shape objects can share a shapeId for tweening) const shapesByShapeId = new Map(); for (let shape of layer.shapes) { if (shape instanceof TempShape) continue; if (!shapesByShapeId.has(shape.shapeId)) { shapesByShapeId.set(shape.shapeId, []); } shapesByShapeId.get(shape.shapeId).push(shape); } // Process each logical shape (shapeId) and determine what to draw let visibleShapes = []; for (let [shapeId, shapes] of shapesByShapeId) { // Check if this logical shape exists at current time const existsCurveKey = `shape.${shapeId}.exists`; let existsValue = layer.animationData.interpolate(existsCurveKey, currentTime); if (existsValue === null || existsValue <= 0) { console.log(`[Widget.draw] Skipping shape ${shapeId} - not visible`); continue; } // Get z-order let zOrder = layer.animationData.interpolate(`shape.${shapeId}.zOrder`, currentTime); // Get shapeIndex curve and surrounding keyframes const shapeIndexCurve = layer.animationData.getCurve(`shape.${shapeId}.shapeIndex`); if (!shapeIndexCurve || !shapeIndexCurve.keyframes || shapeIndexCurve.keyframes.length === 0) { // No shapeIndex curve, just show shape with index 0 const shape = shapes.find(s => s.shapeIndex === 0); if (shape) { visibleShapes.push({ shape, zOrder: zOrder || 0, selected: context.shapeselection.includes(shape) }); } continue; } // Find surrounding keyframes using AnimationCurve's built-in method const { prev: prevKf, next: nextKf, t: interpolationT } = shapeIndexCurve.getBracketingKeyframes(currentTime); // Get interpolated value let shapeIndexValue = shapeIndexCurve.interpolate(currentTime); if (shapeIndexValue === null) shapeIndexValue = 0; // Sort shape versions by shapeIndex shapes.sort((a, b) => a.shapeIndex - b.shapeIndex); // Determine whether to morph based on whether interpolated value equals a keyframe value const atPrevKeyframe = prevKf && Math.abs(shapeIndexValue - prevKf.value) < 0.001; const atNextKeyframe = nextKf && Math.abs(shapeIndexValue - nextKf.value) < 0.001; if (atPrevKeyframe || atNextKeyframe) { // No morphing - display the shape at the keyframe value const targetValue = atNextKeyframe ? nextKf.value : prevKf.value; const shape = shapes.find(s => s.shapeIndex === targetValue); if (shape) { visibleShapes.push({ shape, zOrder: zOrder || 0, selected: context.shapeselection.includes(shape) }); } } else if (prevKf && nextKf && prevKf.value !== nextKf.value) { // Morph between shapes specified by surrounding keyframes const shape1 = shapes.find(s => s.shapeIndex === prevKf.value); const shape2 = shapes.find(s => s.shapeIndex === nextKf.value); if (shape1 && shape2) { // Use the interpolated shapeIndexValue to calculate blend factor // This respects the bezier easing curve const t = (shapeIndexValue - prevKf.value) / (nextKf.value - prevKf.value); console.log(`[Widget.draw] Morphing from shape ${prevKf.value} to ${nextKf.value}, shapeIndexValue=${shapeIndexValue}, t=${t}`); const morphedShape = shape1.lerpShape(shape2, t); visibleShapes.push({ shape: morphedShape, zOrder: zOrder || 0, selected: context.shapeselection.includes(shape1) || context.shapeselection.includes(shape2) }); } else if (shape1) { visibleShapes.push({ shape: shape1, zOrder: zOrder || 0, selected: context.shapeselection.includes(shape1) }); } else if (shape2) { visibleShapes.push({ shape: shape2, zOrder: zOrder || 0, selected: context.shapeselection.includes(shape2) }); } } else if (nextKf) { // Only next keyframe exists, show that shape const shape = shapes.find(s => s.shapeIndex === nextKf.value); if (shape) { visibleShapes.push({ shape, zOrder: zOrder || 0, selected: context.shapeselection.includes(shape) }); } } } // Sort by zOrder visibleShapes.sort((a, b) => a.zOrder - b.zOrder); // Draw sorted shapes for (let { shape, selected } of visibleShapes) { let cxt = {...context} if (selected) { cxt.selected = true } shape.draw(cxt); } // Draw child objects using AnimationData curves for (let child of layer.children) { if (child == context.activeObject) continue; let idx = child.idx; // Use AnimationData to get child's transform let childX = layer.animationData.interpolate(`child.${idx}.x`, currentTime); let childY = layer.animationData.interpolate(`child.${idx}.y`, currentTime); let childRotation = layer.animationData.interpolate(`child.${idx}.rotation`, currentTime); let childScaleX = layer.animationData.interpolate(`child.${idx}.scale_x`, currentTime); let childScaleY = layer.animationData.interpolate(`child.${idx}.scale_y`, currentTime); let childFrameNumber = layer.animationData.interpolate(`child.${idx}.frameNumber`, 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; // Set child's currentTime based on its frameNumber // frameNumber 1 = time 0, frameNumber 2 = time 1/framerate, etc. if (childFrameNumber !== null) { child.currentTime = (childFrameNumber - 1) / config.framerate; } ctx.save(); child.draw(context); ctx.restore(); } } } if (this == context.activeObject) { // Draw selection rectangles for selected items if (mode == "select") { for (let item of context.selection) { if (!item) continue; ctx.save(); ctx.strokeStyle = "#00ffff"; ctx.lineWidth = 1; ctx.beginPath(); let bbox = getRotatedBoundingBox(item); ctx.rect( bbox.x.min, bbox.y.min, bbox.x.max - bbox.x.min, bbox.y.max - bbox.y.min, ); ctx.stroke(); ctx.restore(); } // Draw drag selection rectangle if (context.selectionRect) { ctx.save(); ctx.strokeStyle = "#00ffff"; ctx.lineWidth = 1; ctx.beginPath(); ctx.rect( context.selectionRect.x1, context.selectionRect.y1, context.selectionRect.x2 - context.selectionRect.x1, context.selectionRect.y2 - context.selectionRect.y1, ); ctx.stroke(); ctx.restore(); } } else if (mode == "transform") { let bbox = undefined; for (let item of context.selection) { if (bbox == undefined) { bbox = getRotatedBoundingBox(item); } else { growBoundingBox(bbox, getRotatedBoundingBox(item)); } } if (bbox != undefined) { ctx.save(); ctx.strokeStyle = "#00ffff"; ctx.lineWidth = 1; ctx.beginPath(); let xdiff = bbox.x.max - bbox.x.min; let ydiff = bbox.y.max - bbox.y.min; ctx.rect(bbox.x.min, bbox.y.min, xdiff, ydiff); ctx.stroke(); ctx.fillStyle = "#000000"; let rectRadius = 5; for (let i of [ [0, 0], [0.5, 0], [1, 0], [1, 0.5], [1, 1], [0.5, 1], [0, 1], [0, 0.5], ]) { ctx.beginPath(); ctx.rect( bbox.x.min + xdiff * i[0] - rectRadius, bbox.y.min + ydiff * i[1] - rectRadius, rectRadius * 2, rectRadius * 2, ); ctx.fill(); } ctx.restore(); } } if (context.activeCurve) { ctx.strokeStyle = "magenta"; ctx.beginPath(); ctx.moveTo( context.activeCurve.current.points[0].x, context.activeCurve.current.points[0].y, ); ctx.bezierCurveTo( context.activeCurve.current.points[1].x, context.activeCurve.current.points[1].y, context.activeCurve.current.points[2].x, context.activeCurve.current.points[2].y, context.activeCurve.current.points[3].x, context.activeCurve.current.points[3].y, ); ctx.stroke(); } if (context.activeVertex) { ctx.save(); ctx.strokeStyle = "#00ffff"; let curves = { ...context.activeVertex.current.startCurves, ...context.activeVertex.current.endCurves, }; // I don't understand why I can't use a for...of loop here for (let idx in curves) { let curve = curves[idx]; ctx.beginPath(); ctx.moveTo(curve.points[0].x, curve.points[0].y); ctx.bezierCurveTo( curve.points[1].x, curve.points[1].y, curve.points[2].x, curve.points[2].y, curve.points[3].x, curve.points[3].y, ); ctx.stroke(); } ctx.fillStyle = "#000000aa"; ctx.beginPath(); let vertexSize = 15 / context.zoomLevel; ctx.rect( context.activeVertex.current.point.x - vertexSize / 2, context.activeVertex.current.point.y - vertexSize / 2, vertexSize, vertexSize, ); ctx.fill(); ctx.restore(); } } ctx.restore(); } /* draw(ctx) { super.draw(ctx) if (this==context.activeObject) { if (mode == "select") { for (let item of context.selection) { if (!item) continue; if (item.idx in this.currentFrame.keys) { 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(); } } 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(); } } } } */ transformCanvas(ctx) { if (this.parent) { this.parent.transformCanvas(ctx) } ctx.translate(this.x, this.y); ctx.scale(this.scale_x, this.scale_y); ctx.rotate(this.rotation); } transformMouse(mouse) { // Apply the transformation matrix to the mouse position let matrix = this.generateTransformMatrix(); let { x, y } = mouse; return { x: matrix[0][0] * x + matrix[0][1] * y + matrix[0][2], y: matrix[1][0] * x + matrix[1][1] * y + matrix[1][2] }; } generateTransformMatrix() { // Start with the parent's transform matrix if it exists let parentMatrix = this.parent ? this.parent.generateTransformMatrix() : [[1, 0, 0], [0, 1, 0], [0, 0, 1]]; // Calculate the rotation matrix components const cos = Math.cos(this.rotation); const sin = Math.sin(this.rotation); // Scaling matrix const scaleMatrix = [ [1/this.scale_x, 0, 0], [0, 1/this.scale_y, 0], [0, 0, 1] ]; // Rotation matrix (inverse rotation for transforming back) const rotationMatrix = [ [cos, -sin, 0], [sin, cos, 0], [0, 0, 1] ]; // Translation matrix (inverse translation to adjust for object's position) const translationMatrix = [ [1, 0, -this.x], [0, 1, -this.y], [0, 0, 1] ]; // Multiply translation * rotation * scaling to get the current object's final transformation matrix let tempMatrix = multiplyMatrices(translationMatrix, rotationMatrix); let objectMatrix = multiplyMatrices(tempMatrix, scaleMatrix); // Now combine with the parent's matrix (parent * object) let finalMatrix = multiplyMatrices(parentMatrix, objectMatrix); return finalMatrix; } handleMouseEvent(eventType, x, y) { for (let i in this.layers) { if (i==this.currentLayer) { this.layers[i]._globalEvents.add("mousedown") this.layers[i]._globalEvents.add("mousemove") this.layers[i]._globalEvents.add("mouseup") } else { this.layers[i]._globalEvents.delete("mousedown") this.layers[i]._globalEvents.delete("mousemove") this.layers[i]._globalEvents.delete("mouseup") } } super.handleMouseEvent(eventType, x, y) } addObject(object, x = 0, y = 0, time = undefined, layer=undefined) { if (time == undefined) { time = this.currentTime || 0; } if (layer==undefined) { layer = this.activeLayer } layer.children.push(object) object.parent = this; object.parentLayer = layer; object.x = x; object.y = y; let idx = object.idx; // Add animation curves for the object's position/transform in the layer let xCurve = new AnimationCurve(`child.${idx}.x`); xCurve.addKeyframe(new Keyframe(time, x, 'linear')); layer.animationData.setCurve(`child.${idx}.x`, xCurve); let yCurve = new AnimationCurve(`child.${idx}.y`); yCurve.addKeyframe(new Keyframe(time, y, 'linear')); layer.animationData.setCurve(`child.${idx}.y`, yCurve); let rotationCurve = new AnimationCurve(`child.${idx}.rotation`); rotationCurve.addKeyframe(new Keyframe(time, 0, 'linear')); layer.animationData.setCurve(`child.${idx}.rotation`, rotationCurve); let scaleXCurve = new AnimationCurve(`child.${idx}.scale_x`); scaleXCurve.addKeyframe(new Keyframe(time, 1, 'linear')); layer.animationData.setCurve(`child.${idx}.scale_x`, scaleXCurve); let scaleYCurve = new AnimationCurve(`child.${idx}.scale_y`); scaleYCurve.addKeyframe(new Keyframe(time, 1, 'linear')); layer.animationData.setCurve(`child.${idx}.scale_y`, scaleYCurve); // Initialize frameNumber curve with two keyframes defining the segment // The segment length is based on the object's internal animation duration let frameNumberCurve = new AnimationCurve(`child.${idx}.frameNumber`); // Get the object's animation duration (max time across all its layers) const objectDuration = object.duration || 0; const framerate = config.framerate; // Calculate the last frame number (frameNumber 1 = time 0, so add 1) const lastFrameNumber = Math.max(1, Math.ceil(objectDuration * framerate) + 1); // Calculate the end time for the segment (minimum 1 frame duration) const segmentDuration = Math.max(objectDuration, 1 / framerate); const endTime = time + segmentDuration; // Start keyframe: frameNumber 1 at the current time, linear interpolation frameNumberCurve.addKeyframe(new Keyframe(time, 1, 'linear')); // End keyframe: last frame at end time, zero interpolation (inactive after this) frameNumberCurve.addKeyframe(new Keyframe(endTime, lastFrameNumber, 'zero')); layer.animationData.setCurve(`child.${idx}.frameNumber`, frameNumberCurve); } removeChild(childObject) { let idx = childObject.idx; for (let layer of this.layers) { layer.children = layer.children.filter(child => child.idx !== idx); for (let frame of layer.frames) { if (frame) { delete frame[idx]; } } } // this.children.splice(this.children.indexOf(childObject), 1); } /** * Update this object's frameNumber curve in its parent layer based on child content * This is called when shapes/children are added/modified within this object */ updateFrameNumberCurve() { // Find parent layer that contains this object if (!this.parent || !this.parent.animationData) return; const parentLayer = this.parent; const frameNumberKey = `child.${this.idx}.frameNumber`; // Collect all keyframe times from this object's content let allKeyframeTimes = new Set(); // Check all layers in this object for (let layer of this.layers) { if (!layer.animationData) continue; // Get keyframes from all shape curves for (let shape of layer.shapes) { const existsKey = `shape.${shape.shapeId}.exists`; const existsCurve = layer.animationData.curves[existsKey]; if (existsCurve && existsCurve.keyframes) { for (let kf of existsCurve.keyframes) { allKeyframeTimes.add(kf.time); } } } // Get keyframes from all child object curves for (let child of layer.children) { const childFrameNumberKey = `child.${child.idx}.frameNumber`; const childFrameNumberCurve = layer.animationData.curves[childFrameNumberKey]; if (childFrameNumberCurve && childFrameNumberCurve.keyframes) { for (let kf of childFrameNumberCurve.keyframes) { allKeyframeTimes.add(kf.time); } } } } if (allKeyframeTimes.size === 0) return; // Sort times const times = Array.from(allKeyframeTimes).sort((a, b) => a - b); const firstTime = times[0]; const lastTime = times[times.length - 1]; // Calculate frame numbers (1-based) const framerate = this.framerate || 24; const firstFrame = Math.floor(firstTime * framerate) + 1; const lastFrame = Math.floor(lastTime * framerate) + 1; // Update or create frameNumber curve in parent layer let frameNumberCurve = parentLayer.animationData.curves[frameNumberKey]; if (!frameNumberCurve) { frameNumberCurve = new AnimationCurve(frameNumberKey); parentLayer.animationData.setCurve(frameNumberKey, frameNumberCurve); } // Clear existing keyframes and add new ones frameNumberCurve.keyframes = []; frameNumberCurve.addKeyframe(new Keyframe(firstTime, firstFrame, 'hold')); frameNumberCurve.addKeyframe(new Keyframe(lastTime, lastFrame, 'hold')); } addLayer(layer) { this.children.push(layer); } removeLayer(layer) { this.children.splice(this.children.indexOf(layer), 1); } saveState() { startProps[this.idx] = { x: this.x, y: this.y, rotation: this.rotation, scale_x: this.scale_x, scale_y: this.scale_y, }; } copy(idx) { let newGO = new GraphicsObject(idx.slice(0, 8) + this.idx.slice(8)); newGO.x = this.x; newGO.y = this.y; newGO.rotation = this.rotation; newGO.scale_x = this.scale_x; newGO.scale_y = this.scale_y; newGO.parent = this.parent; pointerList[this.idx] = this; newGO.layers = []; for (let layer of this.layers) { newGO.layers.push(layer.copy(idx)); } for (let audioLayer of this.audioLayers) { newGO.audioLayers.push(audioLayer.copy(idx)); } return newGO; } } let root = new GraphicsObject("root"); Object.defineProperty(context, "activeObject", { get: function () { return this.objectStack.at(-1); }, }); Object.defineProperty(context, "activeLayer", { get: function () { return this.objectStack.at(-1).activeLayer } }) context.objectStack = [root]; async function greet() { // Learn more about Tauri commands at https://tauri.app/develop/calling-rust/ greetMsgEl.textContent = await invoke("greet", { name: greetInputEl.value }); } window.addEventListener("DOMContentLoaded", () => { rootPane = document.querySelector("#root"); rootPane.appendChild(createPane(panes.toolbar)); rootPane.addEventListener("pointermove", (e) => { mouseEvent = e; }); let [_toolbar, panel] = splitPane( rootPane, 10, true, createPane(panes.timelineV2), ); let [stageAndTimeline, _infopanel] = splitPane( panel, 70, false, createPane(panes.infopanel), ); let [_timeline, _stage] = splitPane( stageAndTimeline, 30, false, createPane(panes.stage), ); }); window.addEventListener("resize", () => { updateAll(); }); window.addEventListener("click", function (event) { const popupMenu = document.getElementById("popupMenu"); // If the menu exists and the click is outside the menu and any button with the class 'paneButton', remove the menu if ( popupMenu && !popupMenu.contains(event.target) && !event.target.classList.contains("paneButton") ) { popupMenu.remove(); // Remove the menu from the DOM } }); window.addEventListener("contextmenu", async (e) => { e.preventDefault() // const menu = await Menu.new({ // items: [ // ], // }); // menu.popup({ x: e.clientX, y: e.clientY }); }) window.addEventListener("keydown", (e) => { // let shortcuts = {} // for (let shortcut of config.shortcuts) { // shortcut = shortcut.split("+") // TODO // } if ( e.target.tagName === "INPUT" || e.target.tagName === "TEXTAREA" || e.target.isContentEditable ) { return; // Do nothing if the event target is an input field, textarea, or contenteditable element } // console.log(e); let mod = macOS ? e.metaKey : e.ctrlKey; let key = (mod ? "" : "") + e.key; switch (key) { case config.shortcuts.playAnimation: console.log("Spacebar pressed"); playPause(); break; case config.shortcuts.selectAll: e.preventDefault(); break; // TODO: put these in shortcuts case "ArrowRight": advanceFrame(); e.preventDefault(); break; case "ArrowRight": if (context.selection.length) { const layer = context.activeObject.activeLayer; const time = context.activeObject.currentTime || 0; let oldPositions = {}; let newPositions = {}; for (let item of context.selection) { const oldX = layer.animationData.interpolate(`object.${item.idx}.x`, time) || item.x; const oldY = layer.animationData.interpolate(`object.${item.idx}.y`, time) || item.y; oldPositions[item.idx] = { x: oldX, y: oldY }; item.x = oldX + 1; newPositions[item.idx] = { x: item.x, y: item.y }; } actions.moveObjects.create(context.selection, layer, time, oldPositions, newPositions); } e.preventDefault(); break; case "ArrowLeft": decrementFrame(); break; case "ArrowLeft": if (context.selection.length) { const layer = context.activeObject.activeLayer; const time = context.activeObject.currentTime || 0; let oldPositions = {}; let newPositions = {}; for (let item of context.selection) { const oldX = layer.animationData.interpolate(`object.${item.idx}.x`, time) || item.x; const oldY = layer.animationData.interpolate(`object.${item.idx}.y`, time) || item.y; oldPositions[item.idx] = { x: oldX, y: oldY }; item.x = oldX - 1; newPositions[item.idx] = { x: item.x, y: item.y }; } actions.moveObjects.create(context.selection, layer, time, oldPositions, newPositions); } e.preventDefault(); break; case "ArrowUp": if (context.selection.length) { const layer = context.activeObject.activeLayer; const time = context.activeObject.currentTime || 0; let oldPositions = {}; let newPositions = {}; for (let item of context.selection) { const oldX = layer.animationData.interpolate(`object.${item.idx}.x`, time) || item.x; const oldY = layer.animationData.interpolate(`object.${item.idx}.y`, time) || item.y; oldPositions[item.idx] = { x: oldX, y: oldY }; item.y = oldY - 1; newPositions[item.idx] = { x: item.x, y: item.y }; } actions.moveObjects.create(context.selection, layer, time, oldPositions, newPositions); } e.preventDefault(); break; case "ArrowDown": if (context.selection.length) { const layer = context.activeObject.activeLayer; const time = context.activeObject.currentTime || 0; let oldPositions = {}; let newPositions = {}; for (let item of context.selection) { const oldX = layer.animationData.interpolate(`object.${item.idx}.x`, time) || item.x; const oldY = layer.animationData.interpolate(`object.${item.idx}.y`, time) || item.y; oldPositions[item.idx] = { x: oldX, y: oldY }; item.y = oldY + 1; newPositions[item.idx] = { x: item.x, y: item.y }; } actions.moveObjects.create(context.selection, layer, time, oldPositions, newPositions); } e.preventDefault(); break; default: break; } }); function playPause() { playing = !playing; if (playing) { // Reset to start if we're at the end const duration = context.activeObject.duration; if (duration > 0 && context.activeObject.currentTime >= duration) { context.activeObject.currentTime = 0; } // Start audio from current time for (let audioLayer of context.activeObject.audioLayers) { if (audioLayer.audible) { for (let i in audioLayer.sounds) { let sound = audioLayer.sounds[i]; sound.player.start(0, context.activeObject.currentTime); } } } lastFrameTime = performance.now(); advanceFrame(); } else { // Stop audio for (let audioLayer of context.activeObject.audioLayers) { for (let i in audioLayer.sounds) { let sound = audioLayer.sounds[i]; sound.player.stop(); } } } } function advanceFrame() { // Calculate elapsed time since last frame (in seconds) const now = performance.now(); const elapsedTime = (now - lastFrameTime) / 1000; lastFrameTime = now; // Advance currentTime context.activeObject.currentTime += elapsedTime; // Sync timeline playhead position if (context.timelineWidget?.timelineState) { context.timelineWidget.timelineState.currentTime = context.activeObject.currentTime; } // Redraw stage and timeline updateUI(); if (context.timelineWidget?.requestRedraw) { context.timelineWidget.requestRedraw(); } if (playing) { const duration = context.activeObject.duration; // Check if we've reached the end if (duration > 0 && context.activeObject.currentTime < duration) { // Continue playing requestAnimationFrame(advanceFrame); } else { // Animation finished playing = false; for (let audioLayer of context.activeObject.audioLayers) { for (let i in audioLayer.sounds) { let sound = audioLayer.sounds[i]; sound.player.stop(); } } } } } function decrementFrame() { context.activeObject.decrementFrame(); updateLayers(); updateMenu(); updateUI(); } function newWindow(path) { invoke("create_window", {app: window.__TAURI__.app, path: path}) } function _newFile(width, height, fps) { root = new GraphicsObject("root"); context.objectStack = [root]; context.selection = []; context.shapeselection = []; config.fileWidth = width; config.fileHeight = height; config.framerate = fps; filePath = undefined; saveConfig(); undoStack = []; redoStack = []; updateUI(); updateLayers(); updateMenu(); } async function newFile() { if ( await confirmDialog("Create a new file? Unsaved work will be lost.", { title: "New file", kind: "warning", }) ) { showNewFileDialog(config); } } async function _save(path) { try { function replacer(key, value) { if (key === "parent") { return undefined; // Avoid circular references } return value; } // for (let action of undoStack) { // console.log(action.name); // } const fileData = { version: "1.7.7", width: config.fileWidth, height: config.fileHeight, fps: config.framerate, actions: undoStack, json: root.toJSON(), }; if (config.debug) { // Pretty print file structure when debugging const contents = JSON.stringify(fileData, null, 2); await writeTextFile(path, contents); } else { const contents = JSON.stringify(fileData); await writeTextFile(path, contents); } filePath = path; addRecentFile(path); lastSaveIndex = undoStack.length; updateMenu(); console.log(`${path} saved successfully!`); } catch (error) { console.error("Error saving text file:", error); } } async function save() { if (filePath) { _save(filePath); } else { saveAs(); } } async function saveAs() { const filename = filePath ? await basename(filePath) : "untitled.beam"; const path = await saveFileDialog({ filters: [ { name: "Lightningbeam files (.beam)", extensions: ["beam"], }, ], defaultPath: await join(await documentDir(), filename), }); if (path != undefined) _save(path); } async function _open(path, returnJson = false) { document.body.style.cursor = "wait" closeDialog(); try { const contents = await readTextFile(path); let file = JSON.parse(contents); if (file.version == undefined) { await messageDialog("Could not read file version!", { title: "Load error", kind: "error", }); document.body.style.cursor = "default" return; } if (file.version >= minFileVersion) { if (file.version < maxFileVersion) { if (returnJson) { if (file.json == undefined) { await messageDialog( "Could not import from this file. Re-save it with a current version of Lightningbeam.", ); } document.body.style.cursor = "default" return file.json; } else { _newFile(file.width, file.height, file.fps); if (file.actions == undefined) { await messageDialog("File has no content!", { title: "Parse error", kind: "error", }); document.body.style.cursor = "default" return; } const objectOffsets = {}; const frameIDs = [] if (file.version < "1.7.5") { for (let action of file.actions) { if (!(action.name in actions)) { await messageDialog( `Invalid action ${action.name}. File may be corrupt.`, { title: "Error", kind: "error" }, ); document.body.style.cursor = "default" return; } console.log(action.name); // Data fixes if (file.version <= "1.5") { // Fix coordinates of objects if (action.name == "group") { let bbox; for (let i of action.action.shapes) { const shape = pointerList[i]; if (bbox == undefined) { bbox = shape.bbox(); } else { growBoundingBox(bbox, shape.bbox()); } } for (let i of action.action.objects) { const object = pointerList[i]; // TODO: rotated bbox if (bbox == undefined) { bbox = object.bbox(); } else { growBoundingBox(bbox, object.bbox()); } } const position = { x: (bbox.x.min + bbox.x.max) / 2, y: (bbox.y.min + bbox.y.max) / 2, }; action.action.position = position; objectOffsets[action.action.groupUuid] = position; for (let shape of action.action.shapes) { objectOffsets[shape] = position } } else if (action.name == "editFrame") { for (let key in action.action.newState) { if (key in objectOffsets) { action.action.newState[key].x += objectOffsets[key].x; action.action.newState[key].y += objectOffsets[key].y; } } for (let key in action.action.oldState) { if (key in objectOffsets) { action.action.oldState[key].x += objectOffsets[key].x; action.action.oldState[key].y += objectOffsets[key].y; } } } else if (action.name == "addKeyframe") { for (let id in objectOffsets) { objectOffsets[action.action.uuid.slice(0,8) + id.slice(8)] = objectOffsets[id] } } else if (action.name == "editShape") { if (action.action.shape in objectOffsets) { console.log("editing shape") for (let curve of action.action.newCurves) { for (let point of curve.points) { point.x -= objectOffsets[action.action.shape].x point.y -= objectOffsets[action.action.shape].y } } for (let curve of action.action.oldCurves) { for (let point of curve.points) { point.x -= objectOffsets[action.action.shape].x point.y -= objectOffsets[action.action.shape].y } } } } } if (file.version <= "1.6") { // Fix copy-paste if (action.name == "duplicateObject") { const obj = pointerList[action.action.object]; const objJson = obj.toJSON(true); objJson.idx = action.action.uuid.slice(0, 8) + action.action.object.slice(8); action.action.items = [objJson]; action.action.object = "root"; action.action.frame = root.currentFrame.idx; } } await actions[action.name].execute(action.action); undoStack.push(action); } } else { if (file.version < "1.7.7") { function setParentReferences(obj, parentIdx = null) { if (obj.type === "GraphicsObject") { obj.parent = parentIdx; // Set the parent property } Object.values(obj).forEach(child => { if (typeof child === 'object' && child !== null) setParentReferences(child, obj.type === "GraphicsObject" ? obj.idx : parentIdx); }) } setParentReferences(file.json) console.log(file.json) } if (file.version < "1.7.6") { function restoreLineColors(obj) { // Step 1: Create colorMapping dictionary const colorMapping = (obj.actions || []).reduce((map, action) => { if (action.name === "addShape" && action.action.curves.length > 0) { map[action.action.uuid] = action.action.curves[0].color; } return map; }, {}); // Step 2: Recursive pass to add colors from colorMapping back to curves function recurse(item) { if (item?.curves && item.idx && colorMapping[item.idx]) { item.curves.forEach(curve => { if (Array.isArray(curve)) curve.push(colorMapping[item.idx]); }); } Object.values(item).forEach(value => { if (typeof value === 'object' && value !== null) recurse(value); }); } recurse(obj); } restoreLineColors(file) function restoreAudio(obj) { const audioSrcMapping = (obj.actions || []).reduce((map, action) => { if (action.name === "addAudio") { map[action.action.layeruuid] = action.action; } return map; }, {}); function recurse(item) { if (item.type=="AudioLayer" && audioSrcMapping[item.idx]) { const action = audioSrcMapping[item.idx] item.sounds[action.uuid] = { start: action.frameNum, src: action.audiosrc, uuid: action.uuid } } Object.values(item).forEach(value => { if (typeof value === 'object' && value !== null) recurse(value); }); } recurse(obj); } restoreAudio(file) } // disabled for now // for (let action of file.actions) { // undoStack.push(action) // } root = GraphicsObject.fromJSON(file.json) context.objectStack = [root] } lastSaveIndex = undoStack.length; filePath = path; // Tauri thinks it is setting the title here, but it isn't getting updated await getCurrentWindow().setTitle(await basename(filePath)); addRecentFile(path); updateUI(); } } else { await messageDialog( `File ${path} was created in a newer version of Lightningbeam and cannot be opened in this version.`, { title: "File version mismatch", kind: "error" }, ); } } else { await messageDialog( `File ${path} is too old to be opened in this version of Lightningbeam.`, { title: "File version mismatch", kind: "error" }, ); } } catch (e) { console.log(e); if (e instanceof SyntaxError) { await messageDialog(`Could not parse ${path}, ${e.message}`, { title: "Error", kind: "error", }); } else if ( e instanceof String && e.startsWith("failed to read file as text") ) { await messageDialog( `Could not parse ${path}, is it actually a Lightningbeam file?`, { title: "Error", kind: "error" }, ); } else { console.error(e); await messageDialog( `Error replaying file: ${e}`, { title: "Error", kind: "error" }, ); } } document.body.style.cursor = "default" } async function open() { const path = await openFileDialog({ multiple: false, directory: false, filters: [ { name: "Lightningbeam files (.beam)", extensions: ["beam"], }, ], defaultPath: await documentDir(), }); console.log(path); if (path) { document.body.style.cursor = "wait" setTimeout(()=>_open(path),10); } } function revert() { for (let _ = 0; undoStack.length > lastSaveIndex; _++) { undo(); } } async function importFile() { const path = await openFileDialog({ multiple: false, directory: false, filters: [ { name: "Image files", extensions: ["png", "gif", "avif", "jpg", "jpeg"], }, { name: "Audio files", extensions: ["mp3"], }, { name: "Lightningbeam files", extensions: ["beam"], }, ], defaultPath: await documentDir(), title: "Import File", }); const imageMimeTypes = [ "image/jpeg", // JPEG "image/png", // PNG "image/gif", // GIF "image/webp", // WebP // "image/svg+xml",// SVG "image/bmp", // BMP // "image/tiff", // TIFF // "image/x-icon", // ICO // "image/heif", // HEIF // "image/avif" // AVIF ]; const audioMimeTypes = [ "audio/mpeg", // MP3 // "audio/wav", // WAV // "audio/ogg", // OGG // "audio/webm", // WebM // "audio/aac", // AAC // "audio/flac", // FLAC // "audio/midi", // MIDI // "audio/x-wav", // X-WAV (older WAV files) // "audio/opus" // Opus ]; if (path) { const filename = await basename(path); if (getFileExtension(filename) == "beam") { function reassignIdxs(json) { if (json.idx in pointerList) { json.idx = uuidv4(); } deeploop(json, (key, item) => { if (item.idx in pointerList) { item.idx = uuidv4(); } }); } function assignUUIDs(obj, existing) { const uuidCache = {}; // Cache to store UUIDs for existing values function replaceUuids(obj) { for (const [key, value] of Object.entries(obj)) { if (typeof value === "object" && value !== null) { replaceUuids(value); } else if (value in existing && key != "name") { if (!uuidCache[value]) { uuidCache[value] = uuidv4(); } obj[key] = uuidCache[value]; } } } function replaceReferences(obj) { for (const [key, value] of Object.entries(obj)) { if (key in existing) { obj[uuidCache[key]] = obj[key]; delete obj[key] } if (typeof value === "object" && value !== null) { replaceReferences(value); } else if (value in uuidCache) { obj[key] = value } } } // Start the recursion with the provided object replaceUuids(obj); replaceReferences(obj) return obj; // Return the updated object } const json = await _open(path, true); if (json == undefined) return; assignUUIDs(json, pointerList); createModal(outliner, json, (object) => { actions.importObject.create(object); }); updateOutliner(); } else { const { dataURL, mimeType } = await convertToDataURL( path, imageMimeTypes.concat(audioMimeTypes), ); if (imageMimeTypes.indexOf(mimeType) != -1) { actions.addImageObject.create(50, 50, dataURL, 0, context.activeObject); } else { actions.addAudio.create(dataURL, context.activeObject, filename); } } } } async function quit() { if (undoStack.length > lastSaveIndex) { if ( await confirmDialog("Are you sure you want to quit?", { title: "Really quit?", kind: "warning", }) ) { getCurrentWindow().close(); } } else { getCurrentWindow().close(); } } function copy() { // Phase 6: Check if timeline has selected keyframes first if (context.timelineWidget && context.timelineWidget.copySelectedKeyframes()) { // Keyframes were copied, don't copy objects/shapes return; } // Otherwise, copy objects and shapes as usual clipboard = []; for (let object of context.selection) { clipboard.push(object.toJSON(true)); } for (let shape of context.shapeselection) { clipboard.push(shape.toJSON(true)); } } function paste() { // Phase 6: Check if timeline has keyframes in clipboard first if (context.timelineWidget && context.timelineWidget.pasteKeyframes()) { // Keyframes were pasted return; } // Otherwise, paste objects and shapes as usual // for (let item of clipboard) { // if (item instanceof GraphicsObject) { // console.log(item); // // context.activeObject.addObject(item.copy()) // actions.duplicateObject.create(item); // } // } actions.duplicateObject.create(clipboard); updateUI(); } function delete_action() { if (context.selection.length || context.shapeselection.length) { actions.deleteObjects.create(context.selection, context.shapeselection); context.selection = []; } updateUI(); } function addFrame() { if ( context.activeObject.currentFrameNum >= context.activeObject.activeLayer.frames.length ) { actions.addFrame.create(); } } function addKeyframe() { actions.addKeyframe.create(); } /** * Add keyframes to AnimationData curves at the current playhead position * For new timeline system (Phase 5) */ function addKeyframeAtPlayhead() { console.log('addKeyframeAtPlayhead called'); // Get the timeline widget and current time if (!context.timelineWidget) { console.warn('Timeline widget not available'); return; } const currentTime = context.timelineWidget.timelineState.currentTime; console.log(`Current time: ${currentTime}`); // Determine which object to add keyframes to based on selection let targetObjects = []; // If shapes are selected, add keyframes to those shapes if (context.shapeselection && context.shapeselection.length > 0) { console.log(`Found ${context.shapeselection.length} selected shapes`); targetObjects = context.shapeselection; } // If objects are selected, add keyframes to those objects else if (context.selection && context.selection.length > 0) { console.log(`Found ${context.selection.length} selected objects`); targetObjects = context.selection; } // Otherwise, if no selection, don't do anything else { console.log('No shapes or objects selected to add keyframes to'); console.log('context.shapeselection:', context.shapeselection); console.log('context.selection:', context.selection); return; } // For each selected object/shape, add keyframes to all its curves for (let obj of targetObjects) { // Determine if this is a shape or an object const isShape = obj.constructor.name !== 'GraphicsObject'; // Find which layer this object/shape belongs to let animationData = null; if (isShape) { // For shapes, find the layer recursively const findShapeLayer = (searchObj) => { for (let layer of searchObj.children) { if (layer.shapes && layer.shapes.includes(obj)) { animationData = layer.animationData; return true; } if (layer.children) { for (let child of layer.children) { if (findShapeLayer(child)) return true; } } } return false; }; findShapeLayer(context.activeObject); } else { // For objects (groups), find the parent layer for (let layer of context.activeObject.allLayers) { if (layer.children && layer.children.includes(obj)) { animationData = layer.animationData; break; } } } if (!animationData) continue; // Special handling for shapes: duplicate shape with incremented shapeIndex if (isShape) { // Find the layer that contains this shape let parentLayer = null; const findShapeLayerObj = (searchObj) => { for (let layer of searchObj.children) { if (layer.shapes && layer.shapes.includes(obj)) { parentLayer = layer; return true; } if (layer.children) { for (let child of layer.children) { if (findShapeLayerObj(child)) return true; } } } return false; }; findShapeLayerObj(context.activeObject); if (parentLayer) { // Find the highest shapeIndex for this shapeId const shapesWithSameId = parentLayer.shapes.filter(s => s.shapeId === obj.shapeId); let maxShapeIndex = 0; for (let shape of shapesWithSameId) { maxShapeIndex = Math.max(maxShapeIndex, shape.shapeIndex || 0); } const newShapeIndex = maxShapeIndex + 1; // Duplicate the shape with new shapeIndex const shapeJSON = obj.toJSON(false); // Don't randomize UUIDs shapeJSON.idx = uuidv4(); // But do create a new idx for the duplicate shapeJSON.shapeIndex = newShapeIndex; const newShape = Shape.fromJSON(shapeJSON, parentLayer); parentLayer.shapes.push(newShape); // Add keyframes to all shape curves (exists, zOrder, shapeIndex) // This allows controlling timing, z-order, and morphing const existsCurve = animationData.getOrCreateCurve(`shape.${obj.shapeId}.exists`); const existsValue = existsCurve.interpolate(currentTime); if (existsValue === null) { // No previous keyframe, default to visible existsCurve.addKeyframe(new Keyframe(currentTime, 1, 'hold')); } else { // Add keyframe with current interpolated value existsCurve.addKeyframe(new Keyframe(currentTime, existsValue, 'hold')); } const zOrderCurve = animationData.getOrCreateCurve(`shape.${obj.shapeId}.zOrder`); const zOrderValue = zOrderCurve.interpolate(currentTime); if (zOrderValue === null) { // No previous keyframe, find current z-order from layer const currentZOrder = parentLayer.shapes.indexOf(obj); zOrderCurve.addKeyframe(new Keyframe(currentTime, currentZOrder, 'hold')); } else { // Add keyframe with current interpolated value zOrderCurve.addKeyframe(new Keyframe(currentTime, zOrderValue, 'hold')); } const shapeIndexCurve = animationData.getOrCreateCurve(`shape.${obj.shapeId}.shapeIndex`); // Check if a keyframe already exists at this time to preserve its interpolation type const framerate = context.config?.framerate || 24; const timeResolution = (1 / framerate) / 2; const existingShapeIndexKf = shapeIndexCurve.getKeyframeAtTime(currentTime, timeResolution); const interpolationType = existingShapeIndexKf ? existingShapeIndexKf.interpolation : 'linear'; const shapeIndexKeyframe = new Keyframe(currentTime, newShapeIndex, interpolationType); // Preserve easeIn/easeOut if they exist if (existingShapeIndexKf && existingShapeIndexKf.easeIn) shapeIndexKeyframe.easeIn = existingShapeIndexKf.easeIn; if (existingShapeIndexKf && existingShapeIndexKf.easeOut) shapeIndexKeyframe.easeOut = existingShapeIndexKf.easeOut; shapeIndexCurve.addKeyframe(shapeIndexKeyframe); console.log(`Created new shape version with shapeIndex ${newShapeIndex} at time ${currentTime}`); } } else { // For objects (not shapes), add keyframes to all curves const curves = []; const prefix = `child.${obj.idx}.`; for (let curveName in animationData.curves) { if (curveName.startsWith(prefix)) { curves.push(animationData.curves[curveName]); } } // For each curve, add a keyframe at the current time with the interpolated value for (let curve of curves) { // Get the current interpolated value at this time const currentValue = curve.interpolate(currentTime); // Check if there's already a keyframe at this exact time const existingKeyframe = curve.keyframes.find(kf => Math.abs(kf.time - currentTime) < 0.001); if (existingKeyframe) { // Update the existing keyframe's value existingKeyframe.value = currentValue; console.log(`Updated keyframe at time ${currentTime} on ${curve.parameter}`); } else { // Create a new keyframe const newKeyframe = new Keyframe( currentTime, currentValue, 'linear' // Default to linear interpolation ); curve.addKeyframe(newKeyframe); console.log(`Added keyframe at time ${currentTime} on ${curve.parameter} with value ${currentValue}`); } } } } // Trigger a redraw of the timeline if (context.timelineWidget.requestRedraw) { context.timelineWidget.requestRedraw(); } console.log(`Added keyframes at time ${currentTime} for ${targetObjects.length} object(s)`); } function deleteFrame() { let frame = context.activeObject.currentFrame; let layer = context.activeObject.activeLayer; if (frame) { actions.deleteFrame.create(frame, layer); } } async function about() { messageDialog( `Lightningbeam version ${await getVersion()}\nDeveloped by Skyler Lehmkuhl`, { title: "About", kind: "info" }, ); } // Export stuff that's all crammed in here and needs refactored function createProgressModal() { // Check if the modal already exists const existingModal = document.getElementById('progressModal'); if (existingModal) { existingModal.style.display = 'flex'; return; // If the modal already exists, do nothing } // Create modal container with a unique ID const modal = document.createElement('div'); modal.id = 'progressModal'; // Give the modal a unique ID modal.style.position = 'fixed'; modal.style.top = '0'; modal.style.left = '0'; modal.style.width = '100%'; modal.style.height = '100%'; modal.style.backgroundColor = 'rgba(0, 0, 0, 0.5)'; modal.style.display = 'flex'; modal.style.justifyContent = 'center'; modal.style.alignItems = 'center'; modal.style.zIndex = '9999'; // Create inner modal box const modalContent = document.createElement('div'); modalContent.style.backgroundColor = backgroundColor; modalContent.style.padding = '20px'; modalContent.style.borderRadius = '8px'; modalContent.style.textAlign = 'center'; modalContent.style.minWidth = '300px'; // Create progress bar const progressBar = document.createElement('progress'); progressBar.id = 'progressBar'; progressBar.value = 0; progressBar.max = 100; progressBar.style.width = '100%'; // Create text to show the current frame info const progressText = document.createElement('p'); progressText.id = 'progressText'; progressText.innerText = 'Initializing...'; // Append elements to modalContent modalContent.appendChild(progressBar); modalContent.appendChild(progressText); // Append modalContent to modal modal.appendChild(modalContent); // Append modal to body document.body.appendChild(modal); } async function setupVideoExport(ext, path, canvas, exportContext) { createProgressModal(); await LibAVWebCodecs.load(); console.log("Codecs loaded"); let target; let muxer; let videoEncoder; let videoConfig; let audioEncoder; let audioConfig; const frameTimeMicroseconds = parseInt(1_000_000 / config.framerate) const oldContext = context; context = exportContext; const oldRootFrame = root.currentFrameNum const bitrate = 1e6 // Choose muxer and encoder configuration based on file extension if (ext === "mp4") { target = new Mp4Muxer.ArrayBufferTarget(); // TODO: add video options dialog for width, height, bitrate muxer = new Mp4Muxer.Muxer({ target: target, video: { codec: 'avc', width: config.fileWidth, height: config.fileHeight, frameRate: config.framerate, }, fastStart: 'in-memory', firstTimestampBehavior: 'offset', }); videoConfig = { codec: 'avc1.42001f', width: config.fileWidth, height: config.fileHeight, bitrate: bitrate, }; // Todo: add configuration for mono/stereo audioConfig = { codec: 'mp4a.40.2', // AAC codec sampleRate: 44100, numberOfChannels: 2, // Mono bitrate: 64000, }; } else if (ext === "webm") { target = new WebMMuxer.ArrayBufferTarget(); muxer = new WebMMuxer.Muxer({ target: target, video: { codec: 'V_VP9', width: config.fileWidth, height: config.fileHeight, frameRate: config.framerate, }, firstTimestampBehavior: 'offset', }); videoConfig = { codec: 'vp09.00.10.08', width: config.fileWidth, height: config.fileHeight, bitrate: bitrate, bitrateMode: "constant", }; audioConfig = { codec: 'opus', // Use Opus codec for WebM sampleRate: 48000, numberOfChannels: 2, bitrate: 64000, } } // Initialize the video encoder videoEncoder = new VideoEncoder({ output: (chunk, meta) => muxer.addVideoChunk(chunk, meta, undefined, undefined, frameTimeMicroseconds), error: (e) => console.error(e), }); videoEncoder.configure(videoConfig); // audioEncoder = new AudioEncoder({ // output: (chunk, meta) => muxer.addAudioChunk(chunk, meta), // error: (e) => console.error(e), // }); // audioEncoder.configure(audioConfig) async function finishEncoding() { const progressText = document.getElementById('progressText'); progressText.innerText = 'Finalizing...'; const progressBar = document.getElementById('progressBar'); progressBar.value = 100; await videoEncoder.flush(); muxer.finalize(); await writeFile(path, new Uint8Array(target.buffer)); const modal = document.getElementById('progressModal'); modal.style.display = 'none'; document.querySelector("body").style.cursor = "default"; } const processFrame = async (currentFrame) => { if (currentFrame < root.maxFrame) { // Update progress bar const progressText = document.getElementById('progressText'); progressText.innerText = `Rendering frame ${currentFrame + 1} of ${root.maxFrame}`; const progressBar = document.getElementById('progressBar'); const progress = Math.round(((currentFrame + 1) / root.maxFrame) * 100); progressBar.value = progress; root.setFrameNum(currentFrame); exportContext.ctx.fillStyle = "white"; exportContext.ctx.rect(0, 0, config.fileWidth, config.fileHeight); exportContext.ctx.fill(); root.draw(exportContext.ctx); const frame = new VideoFrame( await LibAVWebCodecs.createImageBitmap(canvas), { timestamp: currentFrame * frameTimeMicroseconds } ); // Encode frame const keyFrame = currentFrame % 60 === 0; // Every 60th frame is a key frame videoEncoder.encode(frame, { keyFrame }); frame.close(); currentFrame++; setTimeout(() => processFrame(currentFrame), 4); } else { // Once all frames are processed, reset context and export context = oldContext; root.setFrameNum(oldRootFrame); finishEncoding(); } }; processFrame(0); } async function render() { document.querySelector("body").style.cursor = "wait"; const path = await saveFileDialog({ filters: [ { name: "WebM files (.webm)", extensions: ["webm"], }, { name: "MP4 files (.mp4)", extensions: ["mp4"], }, { name: "APNG files (.png)", extensions: ["png"], }, { name: "Packed HTML player (.html)", extensions: ["html"], }, ], defaultPath: await join(await documentDir(), "untitled.webm"), }); if (path != undefined) { // SVG balks on images // let ctx = new C2S(fileWidth, fileHeight) // context.ctx = ctx // root.draw(context) // let serializedSVG = ctx.getSerializedSvg() // await writeTextFile(path, serializedSVG) // fileExportPath = path // console.log("wrote SVG") const ext = path.split(".").pop().toLowerCase(); const canvas = document.createElement("canvas"); canvas.width = config.fileWidth; // Set desired width canvas.height = config.fileHeight; // Set desired height let exportContext = { ...context, ctx: canvas.getContext("2d"), selectionRect: undefined, selection: [], shapeselection: [], }; switch (ext) { case "mp4": case "webm": await setupVideoExport(ext, path, canvas, exportContext); break; case "html": fetch("/player.html") .then((response) => { if (!response.ok) { throw new Error("Network response was not ok"); } return response.text(); // Read the response body as a string }) .then((data) => { // TODO: strip out the stuff tauri injects let json = JSON.stringify({ fileWidth: config.fileWidth, fileHeight: config.fileHeight, root: root.toJSON(), }); data = data.replace('"${file}"', json); console.log(data); // The content of the file as a string }) .catch((error) => { // TODO: alert console.error( "There was a problem with the fetch operation:", error, ); }); break; case "png": const frames = []; canvas = document.createElement("canvas"); canvas.width = config.fileWidth; // Set desired width canvas.height = config.fileHeight; // Set desired height for (let i = 0; i < root.maxFrame; i++) { root.currentFrameNum = i; exportContext.ctx.fillStyle = "white"; exportContext.ctx.rect(0, 0, config.fileWidth, config.fileHeight); exportContext.ctx.fill(); root.draw(exportContext); // Convert the canvas content to a PNG image (this is the "frame" we add to the APNG) const imageData = exportContext.ctx.getImageData( 0, 0, canvas.width, canvas.height, ); // Step 2: Create a frame buffer (Uint8Array) from the image data const frameBuffer = new Uint8Array(imageData.data.buffer); frames.push(frameBuffer); // Add the frame buffer to the frames array } // Step 3: Use UPNG.js to create the animated PNG const apng = UPNG.encode( frames, canvas.width, canvas.height, 0, parseInt(100 / config.framerate), ); // Step 4: Save the APNG file (in Tauri, use writeFile or in the browser, download it) const apngBlob = new Blob([apng], { type: "image/png" }); // If you're using Tauri: await writeFile( path, // The destination file path for saving new Uint8Array(await apngBlob.arrayBuffer()), ); break; } } document.querySelector("body").style.cursor = "default"; } function updateScrollPosition(zoomFactor) { if (context.mousePos) { for (let canvas of canvases) { canvas.offsetX = (canvas.offsetX + context.mousePos.x) * zoomFactor - context.mousePos.x; canvas.offsetY = (canvas.offsetY + context.mousePos.y) * zoomFactor - context.mousePos.y; canvas.zoomLevel = context.zoomLevel } } } function zoomIn() { let zoomFactor = 2; if (context.zoomLevel < 8) { context.zoomLevel *= zoomFactor; updateScrollPosition(zoomFactor); updateUI(); updateMenu(); } } function zoomOut() { let zoomFactor = 0.5; if (context.zoomLevel > 1 / 8) { context.zoomLevel *= zoomFactor; updateScrollPosition(zoomFactor); updateUI(); updateMenu(); } } function resetZoom() { context.zoomLevel = 1; recenter() } function recenter() { for (let canvas of canvases) { canvas.offsetX = canvas.offsetY = 0; } updateUI(); updateMenu(); } function stage() { let stage = document.createElement("canvas"); // let scroller = document.createElement("div") // let stageWrapper = document.createElement("div") stage.className = "stage"; // stage.width = config.fileWidth // stage.height = config.fileHeight stage.offsetX = 0; stage.offsetY = 0; stage.zoomLevel = context.zoomLevel let lastResizeTime = 0; const throttleIntervalMs = 20; function updateStageCanvasSize() { const canvasStyles = window.getComputedStyle(stage); stage.width = parseInt(canvasStyles.width); stage.height = parseInt(canvasStyles.height); updateUI(); renderAll(); } const resizeObserver = new ResizeObserver(() => { const currentTime = Date.now(); if (currentTime - lastResizeTime > throttleIntervalMs) { lastResizeTime = currentTime; updateStageCanvasSize(); } }); resizeObserver.observe(stage); updateStageCanvasSize(); stage.addEventListener("wheel", (event) => { event.preventDefault(); // Check if this is a pinch-zoom gesture (ctrlKey is set on trackpad pinch) if (event.ctrlKey) { // Pinch zoom - zoom in/out based on deltaY const zoomFactor = event.deltaY > 0 ? 0.95 : 1.05; const oldZoom = context.zoomLevel; context.zoomLevel = Math.max(1/8, Math.min(8, context.zoomLevel * zoomFactor)); // Update scroll position to zoom towards mouse if (context.mousePos) { const actualZoomFactor = context.zoomLevel / oldZoom; stage.offsetX = (stage.offsetX + context.mousePos.x) * actualZoomFactor - context.mousePos.x; stage.offsetY = (stage.offsetY + context.mousePos.y) * actualZoomFactor - context.mousePos.y; } updateUI(); updateMenu(); } else { // Regular scroll const deltaX = event.deltaX * config.scrollSpeed; const deltaY = event.deltaY * config.scrollSpeed; stage.offsetX += deltaX; stage.offsetY += deltaY; const currentTime = Date.now(); if (currentTime - lastResizeTime > throttleIntervalMs) { lastResizeTime = currentTime; updateUI(); } } }); // scroller.className = "scroll" // stageWrapper.className = "stageWrapper" // let selectionRect = document.createElement("div") // selectionRect.className = "selectionRect" // for (let i of ["nw", "ne", "se", "sw"]) { // let cornerRotateRect = document.createElement("div") // cornerRotateRect.classList.add("cornerRotateRect") // cornerRotateRect.classList.add(i) // cornerRotateRect.addEventListener('mouseup', (e) => { // const newEvent = new MouseEvent(e.type, e); // stage.dispatchEvent(newEvent) // }) // cornerRotateRect.addEventListener('mousemove', (e) => { // const newEvent = new MouseEvent(e.type, e); // stage.dispatchEvent(newEvent) // }) // selectionRect.appendChild(cornerRotateRect) // } // for (let i of ["nw", "n", "ne", "e", "se", "s", "sw", "w"]) { // let cornerRect = document.createElement("div") // cornerRect.classList.add("cornerRect") // cornerRect.classList.add(i) // cornerRect.addEventListener('mousedown', (e) => { // let bbox = undefined; // let selection = {} // for (let item of context.selection) { // if (bbox==undefined) { // bbox = structuredClone(item.bbox()) // } else { // growBoundingBox(bbox, item.bbox()) // } // selection[item.idx] = {x: item.x, y: item.y, scale_x: item.scale_x, scale_y: item.scale_y} // } // if (bbox != undefined) { // context.dragDirection = i // context.activeTransform = { // initial: { // x: {min: bbox.x.min, max: bbox.x.max}, // y: {min: bbox.y.min, max: bbox.y.max}, // selection: selection // }, // current: { // x: {min: bbox.x.min, max: bbox.x.max}, // y: {min: bbox.y.min, max: bbox.y.max}, // selection: structuredClone(selection) // } // } // context.activeObject.currentFrame.saveState() // } // }) // cornerRect.addEventListener('mouseup', (e) => { // const newEvent = new MouseEvent(e.type, e); // stage.dispatchEvent(newEvent) // }) // cornerRect.addEventListener('mousemove', (e) => { // const newEvent = new MouseEvent(e.type, e); // stage.dispatchEvent(newEvent) // }) // selectionRect.appendChild(cornerRect) // } stage.addEventListener("drop", (e) => { e.preventDefault(); let mouse = getMousePos(stage, e); const imageTypes = [ "image/png", "image/gif", "image/avif", "image/jpeg", "image/webp", //'image/svg+xml' // Disabling SVG until we can export them nicely ]; const audioTypes = ["audio/mpeg"]; // TODO: figure out what other audio formats Tone.js accepts if (e.dataTransfer.items) { let i = 0; for (let item of e.dataTransfer.items) { if (item.kind == "file") { let file = item.getAsFile(); if (imageTypes.includes(file.type)) { let img = new Image(); let reader = new FileReader(); // Read the file as a data URL reader.readAsDataURL(file); reader.ix = i; reader.onload = function (event) { let imgsrc = event.target.result; // This is the data URL actions.addImageObject.create( mouse.x, mouse.y, imgsrc, reader.ix, context.activeObject, ); }; reader.onerror = function (error) { console.error("Error reading file as data URL", error); }; } else if (audioTypes.includes(file.type)) { let reader = new FileReader(); // Read the file as a data URL reader.readAsDataURL(file); reader.onload = function (event) { let audiosrc = event.target.result; actions.addAudio.create( audiosrc, context.activeObject, file.name, ); }; } i++; } } } else { } }); stage.addEventListener("dragover", (e) => { e.preventDefault(); }); canvases.push(stage); // stageWrapper.appendChild(stage) // stageWrapper.appendChild(selectionRect) // scroller.appendChild(stageWrapper) stage.addEventListener("pointerdown", (e) => { console.log("POINTERDOWN EVENT - mode:", mode); let mouse = getMousePos(stage, e); console.log("Mouse position:", mouse); root.handleMouseEvent("mousedown", mouse.x, mouse.y) mouse = context.activeObject.transformMouse(mouse); let selection; switch (mode) { case "rectangle": case "ellipse": case "draw": // context.mouseDown = true; // context.activeShape = new Shape(mouse.x, mouse.y, context, uuidv4()); // context.lastMouse = mouse; break; case "select": // No longer need keyframe check with AnimationData system selection = selectVertex(context, mouse); if (selection) { context.dragging = true; context.activeCurve = undefined; context.activeVertex = { current: { point: { x: selection.vertex.point.x, y: selection.vertex.point.y, }, startCurves: structuredClone(selection.vertex.startCurves), endCurves: structuredClone(selection.vertex.endCurves), }, initial: selection.vertex, shape: selection.shape, startmouse: { x: mouse.x, y: mouse.y }, }; } else { selection = selectCurve(context, mouse); if (selection) { context.dragging = true; context.activeVertex = undefined; context.activeCurve = { initial: selection.curve, current: new Bezier(selection.curve.points).setColor( selection.curve.color, ), shape: selection.shape, startmouse: { x: mouse.x, y: mouse.y }, }; } else { let selected = false; let child; if (context.selection.length) { for (child of context.selection) { if (hitTest(mouse, child)) { context.dragging = true; context.lastMouse = mouse; const layer = context.activeObject.activeLayer; const time = context.activeObject.currentTime || 0; context.activeAction = actions.moveObjects.initialize( context.selection, layer, time, ); break; } } } if (!context.dragging) { // Have to iterate in reverse order to grab the frontmost object when two overlap for ( let i = context.activeObject.activeLayer.children.length - 1; i >= 0; i-- ) { child = context.activeObject.activeLayer.children[i]; // Check if child exists using AnimationData curves let currentTime = context.activeObject.currentTime || 0; let childX = context.activeObject.activeLayer.animationData.interpolate(`child.${child.idx}.x`, currentTime); let childY = context.activeObject.activeLayer.animationData.interpolate(`child.${child.idx}.y`, currentTime); // Skip if child doesn't have position data at current time if (childX === null || childY === null) continue; // let bbox = child.bbox() if (hitTest(mouse, child)) { if (context.selection.indexOf(child) != -1) { // dragging = true } child.saveState(); if (e.shiftKey) { context.selection.push(child); } else { context.selection = [child]; } context.dragging = true; selected = true; context.activeAction = actions.editFrame.initialize( context.activeObject.currentFrame, ); break; } } if (!selected) { context.oldselection = context.selection; context.oldshapeselection = context.shapeselection; context.selection = []; context.shapeselection = []; if ( context.oldselection.length || context.oldshapeselection.length ) { actions.select.create(); } context.oldselection = context.selection; context.oldshapeselection = context.selection; context.selectionRect = { x1: mouse.x, x2: mouse.x, y1: mouse.y, y2: mouse.y, }; } } } } break; case "transform": let bbox = undefined; selection = {}; for (let item of context.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 transformPoint = getPointNearBox(bbox, mouse, 10); if (transformPoint) { context.dragDirection = transformPoint; context.activeTransform = { initial: { x: { min: bbox.x.min, max: bbox.x.max }, y: { min: bbox.y.min, max: bbox.y.max }, rotation: 0, selection: selection, }, current: { x: { min: bbox.x.min, max: bbox.x.max }, y: { min: bbox.y.min, max: bbox.y.max }, rotation: 0, selection: structuredClone(selection), }, }; context.activeAction = actions.transformObjects.initialize( context.activeObject.currentFrame, context.selection, transformPoint, mouse, ); } else { transformPoint = getPointNearBox(bbox, mouse, 30, false); if (transformPoint) { stage.style.cursor = `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' fill='currentColor' class='bi bi-arrow-counterclockwise' viewBox='0 0 16 16'%3E%3Cpath fill-rule='evenodd' d='M8 3a5 5 0 1 1-4.546 2.914.5.5 0 0 0-.908-.417A6 6 0 1 0 8 2z'/%3E%3Cpath d='M8 4.466V.534a.25.25 0 0 0-.41-.192L5.23 2.308a.25.25 0 0 0 0 .384l2.36 1.966A.25.25 0 0 0 8 4.466'/%3E%3C/svg%3E") 12 12, auto`; context.dragDirection = "r"; context.activeTransform = { 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 }, rotation: 0, mouse: { x: mouse.x, y: mouse.y }, selection: structuredClone(selection), }, }; context.activeAction = actions.transformObjects.initialize( context.activeObject.currentFrame, context.selection, "r", mouse, ); } else { stage.style.cursor = "default"; } } break; case "paint_bucket": // Paint bucket is now handled in Layer.mousedown (line ~3458) break; case "eyedropper": const ctx = stage.getContext("2d") const imageData = ctx.getImageData(mouse.x, mouse.y, 1, 1); // Get pixel at (x, y) const data = imageData.data; // The pixel data is in the `data` array const hsv = rgbToHsv(...data) if (context.dropperColor == "Fill color") { for (let el of document.querySelectorAll(".color-field.fill")) { el.setColor(hsv, 'ff') } } else { for (let el of document.querySelectorAll(".color-field.stroke")) { el.setColor(hsv, 'ff') } } break; default: break; } context.lastMouse = mouse; updateUI(); updateInfopanel(); }); stage.mouseup = (e) => { context.mouseDown = false; context.dragging = false; context.dragDirection = undefined; context.selectionRect = undefined; let mouse = getMousePos(stage, e); root.handleMouseEvent("mouseup", mouse.x, mouse.y) mouse = context.activeObject.transformMouse(mouse); switch (mode) { case "draw": // if (context.activeShape) { // context.activeShape.addLine(mouse.x, mouse.y); // context.activeShape.simplify(context.simplifyMode); // actions.addShape.create(context.activeObject, context.activeShape); // context.activeShape = undefined; // } break; case "rectangle": case "ellipse": // actions.addShape.create(context.activeObject, context.activeShape); // context.activeShape = undefined; break; case "select": if (context.activeAction) { actions[context.activeAction.type].finalize( context.activeAction, context.activeObject.currentFrame, ); } else if (context.activeVertex) { let newCurves = []; for (let i in context.activeVertex.shape.curves) { if (i in context.activeVertex.current.startCurves) { newCurves.push(context.activeVertex.current.startCurves[i]); } else if (i in context.activeVertex.current.endCurves) { newCurves.push(context.activeVertex.current.endCurves[i]); } else { newCurves.push(context.activeVertex.shape.curves[i]); } } actions.editShape.create(context.activeVertex.shape, newCurves); } else if (context.activeCurve) { let newCurves = []; for (let curve of context.activeCurve.shape.curves) { if (curve == context.activeCurve.initial) { newCurves.push(context.activeCurve.current); } else { newCurves.push(curve); } } actions.editShape.create(context.activeCurve.shape, newCurves); // Add the shape to selection after editing if (e.shiftKey) { if (!context.shapeselection.includes(context.activeCurve.shape)) { context.shapeselection.push(context.activeCurve.shape); } } else { context.shapeselection = [context.activeCurve.shape]; } actions.select.create(); } else if (context.selection.length) { actions.select.create(); // actions.editFrame.create(context.activeObject.currentFrame) } else if (context.shapeselection.length) { actions.select.create(); } break; case "transform": if (context.activeAction) { actions[context.activeAction.type].finalize( context.activeAction, context.activeObject.currentFrame, ); } // actions.editFrame.create(context.activeObject.currentFrame) break; default: break; } context.lastMouse = mouse; context.activeCurve = undefined; updateUI(); updateMenu(); updateInfopanel(); }; stage.addEventListener("pointerup", stage.mouseup); stage.addEventListener("pointermove", (e) => { let mouse = getMousePos(stage, e); root.handleMouseEvent("mousemove", mouse.x, mouse.y) mouse = context.activeObject.transformMouse(mouse); context.mousePos = mouse; // if mouse is released, even if it happened outside the stage if ( e.buttons == 0 && (context.mouseDown || context.dragging || context.dragDirection || context.selectionRect) ) { stage.mouseup(e); return; } switch (mode) { case "draw": stage.style.cursor = "default"; context.activeCurve = undefined; if (context.activeShape) { if (vectorDist(mouse, context.lastMouse) > minSegmentSize) { context.activeShape.addLine(mouse.x, mouse.y); context.lastMouse = mouse; } } break; case "rectangle": stage.style.cursor = "default"; context.activeCurve = undefined; // if (context.activeShape) { // context.activeShape.clear(); // context.activeShape.addLine(mouse.x, context.activeShape.starty); // context.activeShape.addLine(mouse.x, mouse.y); // context.activeShape.addLine(context.activeShape.startx, mouse.y); // context.activeShape.addLine( // context.activeShape.startx, // context.activeShape.starty, // ); // context.activeShape.update(); // } // break; case "ellipse": stage.style.cursor = "default"; context.activeCurve = undefined; // if (context.activeShape) { // let midX = (mouse.x + context.activeShape.startx) / 2; // let midY = (mouse.y + context.activeShape.starty) / 2; // let xDiff = (mouse.x - context.activeShape.startx) / 2; // let yDiff = (mouse.y - context.activeShape.starty) / 2; // let ellipseConst = 0.552284749831; // (4/3)*tan(pi/(2n)) where n=4 // context.activeShape.clear(); // context.activeShape.addCurve( // new Bezier( // midX, // context.activeShape.starty, // midX + ellipseConst * xDiff, // context.activeShape.starty, // mouse.x, // midY - ellipseConst * yDiff, // mouse.x, // midY, // ), // ); // context.activeShape.addCurve( // new Bezier( // mouse.x, // midY, // mouse.x, // midY + ellipseConst * yDiff, // midX + ellipseConst * xDiff, // mouse.y, // midX, // mouse.y, // ), // ); // context.activeShape.addCurve( // new Bezier( // midX, // mouse.y, // midX - ellipseConst * xDiff, // mouse.y, // context.activeShape.startx, // midY + ellipseConst * yDiff, // context.activeShape.startx, // midY, // ), // ); // context.activeShape.addCurve( // new Bezier( // context.activeShape.startx, // midY, // context.activeShape.startx, // midY - ellipseConst * yDiff, // midX - ellipseConst * xDiff, // context.activeShape.starty, // midX, // context.activeShape.starty, // ), // ); // } // break; case "select": stage.style.cursor = "default"; if (context.dragging) { if (context.activeVertex) { let vert = context.activeVertex; let mouseDelta = { x: mouse.x - vert.startmouse.x, y: mouse.y - vert.startmouse.y, }; vert.current.point.x = vert.initial.point.x + mouseDelta.x; vert.current.point.y = vert.initial.point.y + mouseDelta.y; for (let i in vert.current.startCurves) { let curve = vert.current.startCurves[i]; let oldCurve = vert.initial.startCurves[i]; curve.points[0] = vert.current.point; curve.points[1] = { x: oldCurve.points[1].x + mouseDelta.x, y: oldCurve.points[1].y + mouseDelta.y, }; } for (let i in vert.current.endCurves) { let curve = vert.current.endCurves[i]; let oldCurve = vert.initial.endCurves[i]; curve.points[3] = { x: vert.current.point.x, y: vert.current.point.y, }; curve.points[2] = { x: oldCurve.points[2].x + mouseDelta.x, y: oldCurve.points[2].y + mouseDelta.y, }; } } else if (context.activeCurve) { context.activeCurve.current.points = moldCurve( context.activeCurve.initial, mouse, context.activeCurve.startmouse, ).points; } else { // TODO: Add user preference for keyframing behavior: // - Auto-keyframe (current): create/update keyframe at current time // - Edit previous (Flash-style): update most recent keyframe before current time // - Ephemeral (Blender-style): changes don't persist without manual keyframe // Could also add modifier key (e.g. Shift) to toggle between modes // Move selected children (groups) using AnimationData with auto-keyframing for (let child of context.selection) { let currentTime = context.activeObject.currentTime || 0; let layer = context.activeObject.activeLayer; // Get current position from AnimationData let childX = layer.animationData.interpolate(`child.${child.idx}.x`, currentTime); let childY = layer.animationData.interpolate(`child.${child.idx}.y`, currentTime); // Skip if child doesn't have position data if (childX === null || childY === null) continue; // Update position let newX = childX + (mouse.x - context.lastMouse.x); let newY = childY + (mouse.y - context.lastMouse.y); // Auto-keyframe: create/update keyframe at current time layer.animationData.addKeyframe(`child.${child.idx}.x`, new Keyframe(currentTime, newX, 'linear')); layer.animationData.addKeyframe(`child.${child.idx}.y`, new Keyframe(currentTime, newY, 'linear')); // Trigger timeline redraw if (context.timelineWidget && context.timelineWidget.requestRedraw) { context.timelineWidget.requestRedraw(); } } } } else if (context.selectionRect) { context.selectionRect.x2 = mouse.x; context.selectionRect.y2 = mouse.y; context.selection = []; context.shapeselection = []; for (let child of context.activeObject.activeLayer.children) { if (hitTest(regionToBbox(context.selectionRect), child)) { context.selection.push(child); } } // Use getVisibleShapes instead of currentFrame.shapes let currentTime = context.activeObject?.currentTime || 0; let layer = context.activeObject?.activeLayer; if (layer) { for (let shape of layer.getVisibleShapes(currentTime)) { if (hitTest(regionToBbox(context.selectionRect), shape)) { context.shapeselection.push(shape); } } } } else { let selection = selectVertex(context, mouse); if (selection) { context.activeCurve = undefined; context.activeVertex = { current: selection.vertex, initial: { point: { x: selection.vertex.point.x, y: selection.vertex.point.y, }, startCurves: structuredClone(selection.vertex.startCurves), endCurves: structuredClone(selection.vertex.endCurves), }, shape: selection.shape, startmouse: { x: mouse.x, y: mouse.y }, }; } else { context.activeVertex = undefined; selection = selectCurve(context, mouse); if (selection) { context.activeCurve = { current: selection.curve, initial: new Bezier(selection.curve.points).setColor( selection.curve.color, ), shape: selection.shape, startmouse: mouse, }; } else { context.activeCurve = undefined; } } } context.lastMouse = mouse; break; case "transform": // stage.style.cursor = "nw-resize" let bbox = undefined; for (let item of context.selection) { if (bbox == undefined) { bbox = getRotatedBoundingBox(item); } else { growBoundingBox(bbox, getRotatedBoundingBox(item)); } } if (bbox == undefined) break; let point = getPointNearBox(bbox, mouse, 10); if (point) { stage.style.cursor = `${point}-resize`; } else { point = getPointNearBox(bbox, mouse, 30, false); if (point) { stage.style.cursor = `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' fill='currentColor' class='bi bi-arrow-counterclockwise' viewBox='0 0 16 16'%3E%3Cpath fill-rule='evenodd' d='M8 3a5 5 0 1 1-4.546 2.914.5.5 0 0 0-.908-.417A6 6 0 1 0 8 2z'/%3E%3Cpath d='M8 4.466V.534a.25.25 0 0 0-.41-.192L5.23 2.308a.25.25 0 0 0 0 .384l2.36 1.966A.25.25 0 0 0 8 4.466'/%3E%3C/svg%3E") 12 12, auto`; } else { stage.style.cursor = "default"; } } // if (context.dragDirection) { // let initial = context.activeTransform.initial // let current = context.activeTransform.current // let initialSelection = context.activeTransform.initial.selection // if (context.dragDirection.indexOf('n') != -1) { // current.y.min = mouse.y // } else if (context.dragDirection.indexOf('s') != -1) { // current.y.max = mouse.y // } // if (context.dragDirection.indexOf('w') != -1) { // current.x.min = mouse.x // } else if (context.dragDirection.indexOf('e') != -1) { // current.x.max = mouse.x // } // // Calculate the translation difference between current and initial values // let delta_x = current.x.min - initial.x.min; // let delta_y = current.y.min - initial.y.min; // if (context.dragDirection == 'r') { // let 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) // // delta_x -= dx // // delta_y -= dy // // console.log(dx, dy) // } // // This is probably unnecessary since initial rotation is 0 // const delta_rot = current.rotation - initial.rotation // // Calculate the scaling factor based on the difference between current and initial values // const scale_x_ratio = (current.x.max - current.x.min) / (initial.x.max - initial.x.min); // const scale_y_ratio = (current.y.max - current.y.min) / (initial.y.max - initial.y.min); // for (let idx in initialSelection) { // let item = context.activeObject.currentFrame.keys[idx] // let xoffset = initialSelection[idx].x - initial.x.min // let yoffset = initialSelection[idx].y - initial.y.min // item.x = initial.x.min + delta_x + xoffset * scale_x_ratio // item.y = initial.y.min + delta_y + yoffset * scale_y_ratio // item.scale_x = initialSelection[idx].scale_x * scale_x_ratio // item.scale_y = initialSelection[idx].scale_y * scale_y_ratio // item.rotation = initialSelection[idx].rotation + delta_rot // } // } if (context.activeAction) { actions[context.activeAction.type].update( context.activeAction, mouse, ); } break; default: break; } updateUI(); }); stage.addEventListener("dblclick", (e) => { context.mouseDown = false; context.dragging = false; context.dragDirection = undefined; context.selectionRect = undefined; let mouse = getMousePos(stage, e); mouse = context.activeObject.transformMouse(mouse); modeswitcher: switch (mode) { case "select": for (let i = context.activeObject.activeLayer.children.length - 1; i >= 0; i--) { let child = context.activeObject.activeLayer.children[i]; if (!(child.idx in context.activeObject.currentFrame.keys)) continue; if (hitTest(mouse, child)) { context.objectStack.push(child); context.selection = []; context.shapeselection = []; updateUI(); updateLayers(); updateMenu(); updateInfopanel(); break modeswitcher; } } // we didn't click on a child, go up a level if (context.activeObject.parent) { context.selection = [context.activeObject]; context.activeObject.setFrameNum(0); context.shapeselection = []; context.objectStack.pop(); updateUI(); updateLayers(); updateMenu(); updateInfopanel(); } break; default: break; } }); return stage; } function toolbar() { let tools_scroller = document.createElement("div"); tools_scroller.className = "toolbar"; for (let tool in tools) { let toolbtn = document.createElement("button"); toolbtn.className = "toolbtn"; toolbtn.setAttribute("data-tool", tool); // For UI testing let icon = document.createElement("img"); icon.className = "icon"; icon.src = tools[tool].icon; toolbtn.appendChild(icon); tools_scroller.appendChild(toolbtn); toolbtn.addEventListener("click", () => { mode = tool; updateInfopanel(); updateUI(); console.log(`Switched tool to ${tool}`); }); } let tools_break = document.createElement("div"); tools_break.className = "horiz_break"; tools_scroller.appendChild(tools_break); let fillColor = document.createElement("div"); let strokeColor = document.createElement("div"); fillColor.className = "color-field"; strokeColor.className = "color-field"; fillColor.classList.add("fill") strokeColor.classList.add("stroke") fillColor.setColor = (hsv, alpha) => { const rgb = hsvToRgb(...hsv) const color = rgbToHex(rgb.r, rgb.g, rgb.b) + alpha fillColor.style.setProperty("--color", color); fillColor.color = color; fillColor.hsv = hsv fillColor.alpha = alpha context.fillStyle = color; }; strokeColor.setColor = (hsv, alpha) => { const rgb = hsvToRgb(...hsv) const color = rgbToHex(rgb.r, rgb.g, rgb.b) + alpha strokeColor.style.setProperty("--color", color); strokeColor.color = color; strokeColor.hsv = hsv strokeColor.alpha = alpha context.strokeStyle = color; }; fillColor.setColor([0, 1, 1], 'ff'); strokeColor.setColor([0,0,0], 'ff'); fillColor.style.setProperty("--label-text", `"Fill color:"`); strokeColor.style.setProperty("--label-text", `"Stroke color:"`); fillColor.type = "color"; fillColor.value = "#ff0000"; strokeColor.value = "#000000"; let evtListener; let padding = 10; let gradwidth = 25; let ccwidth = 300; let mainSize = ccwidth - (3 * padding + gradwidth); let colorClickHandler = (e) => { let colorCvs = document.querySelector("#color-cvs"); if (colorCvs == null) { console.log("creating new one"); colorCvs = document.createElement("canvas"); colorCvs.id = "color-cvs"; document.body.appendChild(colorCvs); colorCvs.width = ccwidth; colorCvs.height = 500; colorCvs.style.width = "300px"; colorCvs.style.height = "500px"; colorCvs.style.position = "absolute"; colorCvs.style.left = "500px"; colorCvs.style.top = "500px"; colorCvs.style.boxShadow = "0 2px 2px rgba(0,0,0,0.2)"; colorCvs.style.cursor = "crosshair"; colorCvs.currentColor = "#00ffba88"; colorCvs.currentHSV = [0,0,0] colorCvs.currentAlpha = 1 colorCvs.colorSelectorWidget = new ColorSelectorWidget(0, 0, colorCvs) colorCvs.draw = function () { let ctx = colorCvs.getContext("2d"); colorCvs.colorSelectorWidget.draw(ctx) }; colorCvs.addEventListener("pointerdown", (e) => { colorCvs.clickedMainGradient = false; colorCvs.clickedHueGradient = false; colorCvs.clickedAlphaGradient = false; let mouse = getMousePos(colorCvs, e); colorCvs.colorSelectorWidget.handleMouseEvent("mousedown", mouse.x, mouse.y) colorCvs.colorEl.setColor(colorCvs.currentHSV, colorCvs.currentAlpha); colorCvs.draw(); }); window.addEventListener("pointerup", (e) => { let mouse = getMousePos(colorCvs, e); colorCvs.clickedMainGradient = false; colorCvs.clickedHueGradient = false; colorCvs.clickedAlphaGradient = false; colorCvs.colorSelectorWidget.handleMouseEvent("mouseup", mouse.x, mouse.y) if (e.target != colorCvs) { colorCvs.style.display = "none"; window.removeEventListener("pointermove", evtListener); } }); } else { colorCvs.style.display = "block"; } evtListener = window.addEventListener("pointermove", (e) => { let mouse = getMousePos(colorCvs, e); colorCvs.colorSelectorWidget.handleMouseEvent("mousemove", mouse.x, mouse.y) colorCvs.draw() colorCvs.colorEl.setColor(colorCvs.currentHSV, colorCvs.currentAlpha); }); // Get mouse coordinates relative to the viewport const mouseX = e.clientX + window.scrollX; const mouseY = e.clientY + window.scrollY; const divWidth = colorCvs.offsetWidth; const divHeight = colorCvs.offsetHeight; const windowWidth = window.innerWidth; const windowHeight = window.innerHeight; // Default position to the mouse cursor let left = mouseX; let top = mouseY; // If the window is narrower than twice the width, center horizontally if (windowWidth < divWidth * 2) { left = (windowWidth - divWidth) / 2; } else { // If it would overflow on the right side, position it to the left of the cursor if (left + divWidth > windowWidth) { left = mouseX - divWidth; } } // If the window is shorter than twice the height, center vertically if (windowHeight < divHeight * 2) { top = (windowHeight - divHeight) / 2; } else { // If it would overflow at the bottom, position it above the cursor if (top + divHeight > windowHeight) { top = mouseY - divHeight; } } colorCvs.style.left = `${left}px`; colorCvs.style.top = `${top}px`; colorCvs.colorEl = e.target; colorCvs.currentColor = e.target.color; colorCvs.currentHSV = e.target.hsv; colorCvs.currentAlpha = e.target.alpha colorCvs.draw(); e.preventDefault(); }; fillColor.addEventListener("click", colorClickHandler); strokeColor.addEventListener("click", colorClickHandler); // Fill and stroke colors use the same set of swatches fillColor.addEventListener("change", (e) => { context.swatches.unshift(fillColor.value); if (context.swatches.length > 12) context.swatches.pop(); }); strokeColor.addEventListener("change", (e) => { context.swatches.unshift(strokeColor.value); if (context.swatches.length > 12) context.swatches.pop(); }); tools_scroller.appendChild(fillColor); tools_scroller.appendChild(strokeColor); return tools_scroller; } function timeline() { let timeline_cvs = document.createElement("canvas"); timeline_cvs.className = "timeline"; // Start building widget hierarchy timeline_cvs.timelinewindow = new TimelineWindow(0, 0, context) // Load icons for show/hide layer timeline_cvs.icons = {}; timeline_cvs.icons.volume_up_fill = new Icon("assets/volume-up-fill.svg"); timeline_cvs.icons.volume_mute = new Icon("assets/volume-mute.svg"); timeline_cvs.icons.eye_fill = new Icon("assets/eye-fill.svg"); timeline_cvs.icons.eye_slash = new Icon("assets/eye-slash.svg"); // Variable to store the last time updateTimelineCanvasSize was called let lastResizeTime = 0; const throttleIntervalMs = 20; function updateTimelineCanvasSize() { const canvasStyles = window.getComputedStyle(timeline_cvs); timeline_cvs.width = parseInt(canvasStyles.width); timeline_cvs.height = parseInt(canvasStyles.height); updateLayers(); renderAll(); } // Set up ResizeObserver to watch for changes in the canvas size const resizeObserver = new ResizeObserver(() => { const currentTime = Date.now(); // Only call updateTimelineCanvasSize if enough time has passed since the last call // This prevents error messages about a ResizeObserver loop if (currentTime - lastResizeTime > throttleIntervalMs) { lastResizeTime = currentTime; updateTimelineCanvasSize(); } }); resizeObserver.observe(timeline_cvs); timeline_cvs.frameDragOffset = { frames: 0, layers: 0, }; timeline_cvs.addEventListener("dragstart", (event) => { event.preventDefault(); }); timeline_cvs.addEventListener("wheel", (event) => { event.preventDefault(); const deltaX = event.deltaX * config.scrollSpeed; const deltaY = event.deltaY * config.scrollSpeed; let maxScroll = context.activeObject.layers.length * layerHeight + context.activeObject.audioLayers.length * layerHeight + gutterHeight - timeline_cvs.height; timeline_cvs.offsetX = Math.max(0, timeline_cvs.offsetX + deltaX); timeline_cvs.offsetY = Math.max( 0, Math.min(maxScroll, timeline_cvs.offsetY + deltaY), ); timeline_cvs.timelinewindow.offsetX = -timeline_cvs.offsetX timeline_cvs.timelinewindow.offsetY = -timeline_cvs.offsetY const currentTime = Date.now(); if (currentTime - lastResizeTime > throttleIntervalMs) { lastResizeTime = currentTime; updateLayers(); } }); timeline_cvs.addEventListener("pointerdown", (e) => { let mouse = getMousePos(timeline_cvs, e, true, true); mouse.y += timeline_cvs.offsetY; if (mouse.x > layerWidth) { mouse.x -= layerWidth; mouse.x += timeline_cvs.offsetX; mouse.y -= gutterHeight; timeline_cvs.clicked_frame = Math.floor(mouse.x / frameWidth); context.activeObject.setFrameNum(timeline_cvs.clicked_frame); const layerIdx = Math.floor(mouse.y / layerHeight); if (layerIdx < context.activeObject.layers.length && layerIdx >= 0) { const layer = context.activeObject.layers[ context.activeObject.layers.length - layerIdx - 1 ]; const frame = layer.getFrame(timeline_cvs.clicked_frame); if (frame.exists) { console.log(frame.keys) if (!e.shiftKey) { // Check if the clicked frame is already in the selection const existingIndex = context.selectedFrames.findIndex( (selected) => selected.frameNum === timeline_cvs.clicked_frame && selected.layer === layerIdx, ); if (existingIndex !== -1) { if (!e.ctrlKey) { // Do nothing } else { // Remove the clicked frame from the selection context.selectedFrames.splice(existingIndex, 1); } } else { if (!e.ctrlKey) { context.selectedFrames = []; // Reset selection } // Add the clicked frame to the selection context.selectedFrames.push({ layer: layerIdx, frameNum: timeline_cvs.clicked_frame, }); } } else { const currentSelection = context.selectedFrames[context.selectedFrames.length - 1]; const startFrame = Math.min( currentSelection.frameNum, timeline_cvs.clicked_frame, ); const endFrame = Math.max( currentSelection.frameNum, timeline_cvs.clicked_frame, ); const startLayer = Math.min(currentSelection.layer, layerIdx); const endLayer = Math.max(currentSelection.layer, layerIdx); for (let l = startLayer; l <= endLayer; l++) { const layerToAdd = context.activeObject.layers[ context.activeObject.layers.length - l - 1 ]; for (let f = startFrame; f <= endFrame; f++) { const frameToAdd = layerToAdd.getFrame(f); if ( frameToAdd.exists && !context.selectedFrames.some( (selected) => selected.frameNum === f && selected.layer === l, ) ) { context.selectedFrames.push({ layer: l, frameNum: f, }); } } } } timeline_cvs.draggingFrames = true; timeline_cvs.dragFrameStart = { frame: timeline_cvs.clicked_frame, layer: layerIdx, }; timeline_cvs.frameDragOffset = { frames: 0, layers: 0, }; } else { context.selectedFrames = []; } } else { context.selectedFrames = []; } updateUI(); } else { mouse.y -= gutterHeight; let l = Math.floor(mouse.y / layerHeight); if (l < context.activeObject.allLayers.length) { let i = context.activeObject.allLayers.length - (l + 1); mouse.y -= l * layerHeight; if ( mouse.x > layerWidth - iconSize - 5 && mouse.x < layerWidth - 5 && mouse.y > 0.5 * (layerHeight - iconSize) && mouse.y < 0.5 * (layerHeight + iconSize) ) { context.activeObject.allLayers[i].visible = !context.activeObject.allLayers[i].visible; updateUI(); updateMenu(); } else if ( mouse.x > layerWidth - iconSize * 2 - 10 && mouse.x < layerWidth - iconSize - 5 && mouse.y > 0.5 * (layerHeight - iconSize) && mouse.y < 0.5 * (layerHeight + iconSize) ) { context.activeObject.allLayers[i].audible = !context.activeObject.allLayers[i].audible; updateUI(); updateMenu(); } else { context.activeObject.currentLayer = i - context.activeObject.audioLayers.length; } } } updateLayers(); }); timeline_cvs.addEventListener("pointerup", (e) => { let mouse = getMousePos(timeline_cvs, e, true, true); mouse.y += timeline_cvs.offsetY; if (mouse.x > layerWidth || timeline_cvs.draggingFrames) { mouse.x += timeline_cvs.offsetX - layerWidth; if (timeline_cvs.draggingFrames) { if ( timeline_cvs.frameDragOffset.frames != 0 || timeline_cvs.frameDragOffset.layers != 0 ) { actions.moveFrames.create(timeline_cvs.frameDragOffset); context.selectedFrames = []; } } timeline_cvs.draggingFrames = false; updateLayers(); updateMenu(); } }); timeline_cvs.addEventListener("pointermove", (e) => { let mouse = getMousePos(timeline_cvs, e, true, true); mouse.y += timeline_cvs.offsetY; if (mouse.x > layerWidth || timeline_cvs.draggingFrames) { mouse.x += timeline_cvs.offsetX - layerWidth; if (timeline_cvs.draggingFrames) { const minFrameNum = -Math.min( ...context.selectedFrames.map((selection) => selection.frameNum), ); const minLayer = -Math.min( ...context.selectedFrames.map((selection) => selection.layer), ); const maxLayer = context.activeObject.layers.length - 1 - Math.max( ...context.selectedFrames.map((selection) => selection.layer), ); timeline_cvs.frameDragOffset = { frames: Math.max( Math.floor(mouse.x / frameWidth) - timeline_cvs.dragFrameStart.frame, minFrameNum, ), layers: Math.min( Math.max( Math.floor(mouse.y / layerHeight) - timeline_cvs.dragFrameStart.layer, minLayer, ), maxLayer, ), }; updateLayers(); } } }); timeline_cvs.offsetX = 0; timeline_cvs.offsetY = 0; updateTimelineCanvasSize(); 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; // Prevent default drag behavior on canvas e.preventDefault(); // Capture pointer to ensure we get move/up events even if cursor leaves canvas canvas.setPointerCapture(e.pointerId); // Store event for modifier key access during clicks (for Shift-click multi-select) timelineWidget.lastClickEvent = e; // Also store for drag operations initially timelineWidget.lastDragEvent = e; timelineWidget.handleMouseEvent("mousedown", x, y); updateCanvasSize(); // Redraw after interaction }); canvas.addEventListener("pointermove", (e) => { const rect = canvas.getBoundingClientRect(); const x = e.clientX - rect.left; const y = e.clientY - rect.top; // Store event for modifier key access during drag (for Shift-drag constraint) timelineWidget.lastDragEvent = e; timelineWidget.handleMouseEvent("mousemove", x, y); updateCanvasSize(); // Redraw after interaction }); 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 }); // Context menu (right-click) for deleting keyframes canvas.addEventListener("contextmenu", (e) => { e.preventDefault(); // Prevent default browser context menu const rect = canvas.getBoundingClientRect(); const x = e.clientX - rect.left; const y = e.clientY - rect.top; // Store event for access to clientX/clientY for menu positioning timelineWidget.lastEvent = e; // Also store as click event for consistency timelineWidget.lastClickEvent = e; timelineWidget.handleMouseEvent("contextmenu", x, y); updateCanvasSize(); // Redraw after interaction }); // Add wheel event for pinch-zoom support canvas.addEventListener("wheel", (event) => { event.preventDefault(); // Get mouse position const rect = canvas.getBoundingClientRect(); const mouseX = event.clientX - rect.left; const mouseY = event.clientY - rect.top; // 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; // Adjust mouse position to account for track header offset const timelineMouseX = mouseX - timelineWidget.trackHeaderWidth; // Calculate the time under the mouse BEFORE zooming const mouseTimeBeforeZoom = timelineWidget.timelineState.pixelToTime(timelineMouseX); // 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(timelineMouseX) == mouseTimeBeforeZoom // pixelToTime(timelineMouseX) = (timelineMouseX / pixelsPerSecond) + viewportStartTime // So: viewportStartTime = mouseTimeBeforeZoom - (timelineMouseX / pixelsPerSecond) timelineWidget.timelineState.viewportStartTime = mouseTimeBeforeZoom - (timelineMouseX / timelineWidget.timelineState.pixelsPerSecond); timelineWidget.timelineState.viewportStartTime = Math.max(0, timelineWidget.timelineState.viewportStartTime); updateCanvasSize(); } else { // Regular scroll - handle both horizontal and vertical scrolling everywhere const deltaX = event.deltaX * config.scrollSpeed; const deltaY = event.deltaY * config.scrollSpeed; // Horizontal scroll for timeline timelineWidget.timelineState.viewportStartTime += deltaX / timelineWidget.timelineState.pixelsPerSecond; timelineWidget.timelineState.viewportStartTime = Math.max(0, timelineWidget.timelineState.viewportStartTime); // Vertical scroll for tracks timelineWidget.trackScrollOffset -= deltaY; // Clamp scroll offset const trackAreaHeight = canvas.height - timelineWidget.ruler.height; const totalTracksHeight = timelineWidget.trackHierarchy.getTotalHeight(); const maxScroll = Math.min(0, trackAreaHeight - totalTracksHeight); timelineWidget.trackScrollOffset = Math.max(maxScroll, Math.min(0, timelineWidget.trackScrollOffset)); updateCanvasSize(); } }); updateCanvasSize(); return canvas; } function infopanel() { let panel = document.createElement("div"); panel.className = "infopanel"; updateInfopanel(); return panel; } function outliner(object = undefined) { let outliner = document.createElement("canvas"); outliner.className = "outliner"; if (object == undefined) { outliner.object = root; } else { outliner.object = object; } outliner.style.cursor = "pointer"; let lastResizeTime = 0; const throttleIntervalMs = 20; function updateTimelineCanvasSize() { const canvasStyles = window.getComputedStyle(outliner); outliner.width = parseInt(canvasStyles.width); outliner.height = parseInt(canvasStyles.height); updateOutliner(); renderAll(); } // Set up ResizeObserver to watch for changes in the canvas size const resizeObserver = new ResizeObserver(() => { const currentTime = Date.now(); // Only call updateTimelineCanvasSize if enough time has passed since the last call // This prevents error messages about a ResizeObserver loop if (currentTime - lastResizeTime > throttleIntervalMs) { lastResizeTime = currentTime; updateTimelineCanvasSize(); } }); resizeObserver.observe(outliner); outliner.collapsed = {}; outliner.offsetX = 0; outliner.offsetY = 0; outliner.addEventListener("click", function (e) { const mouse = getMousePos(outliner, e); const mouseY = mouse.y; // Get the Y position of the click const mouseX = mouse.x; // Get the X position (not used here, but can be used to check clicked area) // Iterate again to check which object was clicked let currentY = 20; // Starting y position const stack = [{ object: outliner.object, indent: 0 }]; while (stack.length > 0) { const { object, indent } = stack.pop(); // Check if the click was on this object if (mouseY >= currentY - 20 && mouseY <= currentY) { if (mouseX >= 0 && mouseX <= indent + 2 * triangleSize) { // Toggle the collapsed state of the object outliner.collapsed[object.idx] = !outliner.collapsed[object.idx]; } else { outliner.active = object; // Only do selection when this is pointing at the actual file if (outliner.object==root) { context.objectStack = [] let parent = object; while (true) { if (parent.parent) { parent = parent.parent context.objectStack.unshift(parent) } else { break } } if (context.objectStack.length==0) { context.objectStack.push(root) } context.oldselection = context.selection context.oldshapeselection = context.shapeselection context.selection = [object] context.shapeselection = [] actions.select.create() } } updateOutliner(); // Re-render the outliner return; } // Update the Y position for the next object currentY += 20; // If the object is collapsed, skip it if (outliner.collapsed[object.idx]) { continue; } // If the object has layers, add them to the stack if (object.layers) { for (let i = object.layers.length - 1; i >= 0; i--) { const layer = object.layers[i]; stack.push({ object: layer, indent: indent + 20 }); } } else if (object.children) { for (let i = object.children.length - 1; i >= 0; i--) { const child = object.children[i]; stack.push({ object: child, indent: indent + 40 }); } } } }); outliner.addEventListener("wheel", (event) => { event.preventDefault(); const deltaY = event.deltaY * config.scrollSpeed; outliner.offsetY = Math.max(0, outliner.offsetY + deltaY); const currentTime = Date.now(); if (currentTime - lastResizeTime > throttleIntervalMs) { lastResizeTime = currentTime; updateOutliner(); } }); return outliner; } async function startup() { await loadConfig(); createNewFileDialog(_newFile, _open, config); if (!window.openedFiles?.length) { if (config.reopenLastSession && config.recentFiles?.length) { document.body.style.cursor = "wait" setTimeout(()=>_open(config.recentFiles[0]), 10) } else { showNewFileDialog(config); } } } startup(); function createPaneMenu(div) { const menuItems = ["Item 1", "Item 2", "Item 3"]; // The items for the menu // Get the menu container (create a new div for the menu) const popupMenu = document.createElement("div"); popupMenu.id = "popupMenu"; // Set the ID to ensure we can target it later // Create a
    element to hold the list items const ul = document.createElement("ul"); // Loop through the menuItems array and create a
  • for each item for (let pane in panes) { const li = document.createElement("li"); // Create the element for the icon const img = document.createElement("img"); img.src = `assets/${panes[pane].name}.svg`; // Use the appropriate SVG as the source // img.style.width = "20px"; // Set the icon size // img.style.height = "20px"; // Set the icon size // img.style.marginRight = "10px"; // Add space between the icon and text // Append the image to the
  • element li.appendChild(img); // Set the text of the item li.appendChild(document.createTextNode(titleCase(panes[pane].name))); li.addEventListener("click", () => { createPane(panes[pane], div); updateUI(); updateLayers(); updateAll(); popupMenu.remove(); }); ul.appendChild(li); // Append the
  • to the
      } popupMenu.appendChild(ul); // Append the
        to the popupMenu div document.body.appendChild(popupMenu); // Append the menu to the body return popupMenu; // Return the created menu element } function createPane(paneType = undefined, div = undefined) { if (!div) { div = document.createElement("div"); } else { div.textContent = ""; } let header = document.createElement("div"); if (!paneType) { paneType = panes.stage; // TODO: change based on type } let content = paneType.func(); header.className = "header"; let button = document.createElement("button"); header.appendChild(button); let icon = document.createElement("img"); icon.className = "icon"; icon.src = `/assets/${paneType.name}.svg`; button.appendChild(icon); button.addEventListener("click", () => { let popupMenu = document.getElementById("popupMenu"); // If the menu is already in the DOM, remove it if (popupMenu) { popupMenu.remove(); // Remove the menu from the DOM } else { // Create and append the new menu to the DOM popupMenu = createPaneMenu(div); // Position the menu below the button const buttonRect = event.target.getBoundingClientRect(); popupMenu.style.left = `${buttonRect.left}px`; popupMenu.style.top = `${buttonRect.bottom + window.scrollY}px`; } // Prevent the click event from propagating to the window click listener event.stopPropagation(); }); // Add custom header controls if the content element provides them if (content.headerControls && typeof content.headerControls === 'function') { const controls = content.headerControls(); for (const control of controls) { header.appendChild(control); } } div.className = "vertical-grid"; header.style.height = "calc( 2 * var(--lineheight))"; content.style.height = "calc( 100% - 2 * var(--lineheight) )"; div.appendChild(header); div.appendChild(content); return div; } function splitPane(div, percent, horiz, newPane = undefined) { let content = div.firstElementChild; let div1 = document.createElement("div"); let div2 = document.createElement("div"); div1.className = "panecontainer"; div2.className = "panecontainer"; div1.appendChild(content); if (newPane) { div2.appendChild(newPane); } else { div2.appendChild(createPane()); } div.appendChild(div1); div.appendChild(div2); if (horiz) { div.className = "horizontal-grid"; } else { div.className = "vertical-grid"; } div.setAttribute("lb-percent", percent); // TODO: better attribute name div.addEventListener("pointerdown", function (event) { // Check if the clicked element is the parent itself and not a child element if (event.target === event.currentTarget) { if (event.button === 0) { // Left click event.currentTarget.setAttribute("dragging", true); event.currentTarget.style.userSelect = "none"; rootPane.style.userSelect = "none"; } } else { event.currentTarget.setAttribute("dragging", false); } }); div.addEventListener("contextmenu", async function (event) { if (event.target === event.currentTarget) { event.preventDefault(); // Prevent the default context menu from appearing event.stopPropagation(); function createSplit(direction) { let splitIndicator = document.createElement("div"); splitIndicator.className = "splitIndicator"; splitIndicator.style.flexDirection = direction == "vertical" ? "column" : "row"; document.body.appendChild(splitIndicator); splitIndicator.addEventListener("pointermove", (e) => { const { clientX: mouseX, clientY: mouseY } = e; const rect = splitIndicator.getBoundingClientRect(); // Create child elements and divider if not already present let firstHalf = splitIndicator.querySelector(".first-half"); let secondHalf = splitIndicator.querySelector(".second-half"); let divider = splitIndicator.querySelector(".divider"); if (!firstHalf || !secondHalf || !divider) { firstHalf = document.createElement("div"); secondHalf = document.createElement("div"); divider = document.createElement("div"); firstHalf.classList.add("first-half"); secondHalf.classList.add("second-half"); divider.classList.add("divider"); splitIndicator.innerHTML = ""; // Clear previous children splitIndicator.append(firstHalf, divider, secondHalf); } const isVertical = direction === "vertical"; // Calculate dimensions for halves const [first, second] = isVertical ? [mouseY - rect.top, rect.bottom - mouseY] : [mouseX - rect.left, rect.right - mouseX]; const firstSize = `${first}px`; const secondSize = `${second}px`; splitIndicator.percent = isVertical ? ((mouseY - rect.top) / (rect.bottom - rect.top)) * 100 : ((mouseX - rect.left) / (rect.right - rect.left)) * 100; // Apply styles for first and second halves firstHalf.style[isVertical ? "height" : "width"] = firstSize; secondHalf.style[isVertical ? "height" : "width"] = secondSize; firstHalf.style[isVertical ? "width" : "height"] = "100%"; secondHalf.style[isVertical ? "width" : "height"] = "100%"; // Apply divider styles divider.style.backgroundColor = "#000"; if (isVertical) { divider.style.height = "2px"; divider.style.width = "100%"; divider.style.left = `${mouseX - rect.left}px`; } else { divider.style.width = "2px"; divider.style.height = "100%"; divider.style.top = `${mouseY - rect.top}px`; } }); splitIndicator.addEventListener("click", (e) => { if (splitIndicator.percent) { splitPane( splitIndicator.targetElement, splitIndicator.percent, direction == "horizontal", createPane(panes.timeline), ); document.body.removeChild(splitIndicator); document.removeEventListener("pointermove", splitListener); setTimeout(updateUI, 20); } }); const splitListener = document.addEventListener("pointermove", (e) => { const mouseX = e.clientX; const mouseY = e.clientY; // Get all elements under the mouse pointer const elementsUnderMouse = document.querySelectorAll(":hover"); let targetElement = null; for (let element of elementsUnderMouse) { if ( element.matches( ".horizontal-grid > .panecontainer, .vertical-grid > .panecontainer", ) ) { targetElement = element; } } if (targetElement) { const rect = targetElement.getBoundingClientRect(); splitIndicator.style.left = `${rect.left}px`; splitIndicator.style.top = `${rect.top}px`; splitIndicator.style.width = `${rect.width}px`; splitIndicator.style.height = `${rect.height}px`; splitIndicator.targetElement = targetElement; } }); } // TODO: use icon menu items // See https://github.com/tauri-apps/tauri/blob/dev/packages/api/src/menu/iconMenuItem.ts const menu = await Menu.new({ items: [ { id: "ctx_option0", text: "Area options", enabled: false }, { id: "ctx_option1", text: "Vertical Split", action: () => createSplit("vertical"), }, { id: "ctx_option2", text: "Horizontal Split", action: () => createSplit("horizontal"), }, new PredefinedMenuItem("Separator"), { id: "ctx_option3", text: horiz ? "Join Left" : "Join Up" }, { id: "ctx_option4", text: horiz ? "Join Right" : "Join Down" }, ], }); menu.popup({ x: event.clientX, y: event.clientY }); } console.log("Right-click on the element"); // Your custom logic here }); div.addEventListener("pointermove", function (event) { // Check if the clicked element is the parent itself and not a child element if (event.currentTarget.getAttribute("dragging") == "true") { const frac = getMousePositionFraction(event, event.currentTarget); div.setAttribute("lb-percent", frac * 100); updateAll(); } }); div.addEventListener("pointerup", (event) => { event.currentTarget.setAttribute("dragging", false); // event.currentTarget.style.userSelect = 'auto'; }); updateAll(); updateUI(); updateLayers(); return [div1, div2]; } function updateAll() { updateLayout(rootPane); for (let element of layoutElements) { updateLayout(element); } } function updateLayout(element) { let rect = element.getBoundingClientRect(); let percent = element.getAttribute("lb-percent"); percent ||= 50; let children = element.children; if (children.length != 2) return; if (element.classList.contains("horizontal-grid")) { children[0].style.width = `${(rect.width * percent) / 100}px`; children[1].style.width = `${(rect.width * (100 - percent)) / 100}px`; children[0].style.height = `${rect.height}px`; children[1].style.height = `${rect.height}px`; } else if (element.classList.contains("vertical-grid")) { children[0].style.height = `${(rect.height * percent) / 100}px`; children[1].style.height = `${(rect.height * (100 - percent)) / 100}px`; children[0].style.width = `${rect.width}px`; children[1].style.width = `${rect.width}px`; } if (children[0].getAttribute("lb-percent")) { updateLayout(children[0]); } if (children[1].getAttribute("lb-percent")) { updateLayout(children[1]); } } function updateUI() { uiDirty = true; } // Add updateUI and updateMenu to context so widgets can call them context.updateUI = updateUI; context.updateMenu = updateMenu; function renderUI() { for (let canvas of canvases) { let ctx = canvas.getContext("2d"); ctx.resetTransform(); ctx.beginPath(); ctx.fillStyle = backgroundColor; ctx.fillRect(0, 0, canvas.width, canvas.height); ctx.translate(-canvas.offsetX, -canvas.offsetY); ctx.scale(context.zoomLevel, context.zoomLevel); ctx.fillStyle = "white"; ctx.fillRect(0, 0, config.fileWidth, config.fileHeight); context.ctx = ctx; // root.draw(context); root.draw(context) if (context.activeObject != root) { ctx.fillStyle = "rgba(255,255,255,0.5)"; ctx.fillRect(0, 0, config.fileWidth, config.fileHeight); const transform = ctx.getTransform() context.activeObject.draw(context, true); ctx.setTransform(transform) } if (context.activeShape) { context.activeShape.draw(context); } ctx.save() context.activeObject.transformCanvas(ctx) // Debug rendering if (debugQuadtree) { ctx.fillStyle = "rgba(255,255,255,0.5)"; ctx.fillRect(0, 0, config.fileWidth, config.fileHeight); const ep = 2.5; const bbox = { x: { min: context.mousePos.x - ep, max: context.mousePos.x + ep }, y: { min: context.mousePos.y - ep, max: context.mousePos.y + ep }, }; debugCurves = []; const currentTime = context.activeObject.currentTime || 0; const visibleShapes = context.activeObject.activeLayer.getVisibleShapes(currentTime); for (let shape of visibleShapes) { for (let i of shape.quadtree.query(bbox)) { debugCurves.push(shape.curves[i]); } } } // let i=4; for (let curve of debugCurves) { ctx.beginPath(); // ctx.strokeStyle = `#ff${i}${i}${i}${i}` // i = (i+3)%10 ctx.strokeStyle = "#" + ((Math.random() * 0xffffff) << 0).toString(16); ctx.moveTo(curve.points[0].x, curve.points[0].y); ctx.bezierCurveTo( curve.points[1].x, curve.points[1].y, curve.points[2].x, curve.points[2].y, curve.points[3].x, curve.points[3].y, ); ctx.stroke(); ctx.beginPath(); let bbox = curve.bbox(); ctx.rect( bbox.x.min, bbox.y.min, bbox.x.max - bbox.x.min, bbox.y.max - bbox.y.min, ); ctx.stroke(); } let i = 0; for (let point of debugPoints) { ctx.beginPath(); let j = i.toString(16).padStart(2, "0"); ctx.fillStyle = `#${j}ff${j}`; i += 1; i %= 255; ctx.arc(point.x, point.y, 3, 0, 2 * Math.PI); ctx.fill(); } ctx.restore() if (context.activeAction) { actions[context.activeAction.type].render(context.activeAction, ctx); } } for (let selectionRect of document.querySelectorAll(".selectionRect")) { selectionRect.style.display = "none"; } if (mode == "transform") { if (context.selection.length > 0) { for (let selectionRect of document.querySelectorAll(".selectionRect")) { let bbox = undefined; for (let item of context.selection) { if (bbox == undefined) { bbox = structuredClone(item.bbox()); } else { growBoundingBox(bbox, item.bbox()); } } if (bbox != undefined) { selectionRect.style.display = "block"; selectionRect.style.left = `${bbox.x.min}px`; selectionRect.style.top = `${bbox.y.min}px`; selectionRect.style.width = `${bbox.x.max - bbox.x.min}px`; selectionRect.style.height = `${bbox.y.max - bbox.y.min}px`; } } } } } function updateLayers() { layersDirty = true; } function renderLayers() { // Also trigger TimelineV2 redraw if it exists if (context.timelineWidget?.requestRedraw) { context.timelineWidget.requestRedraw(); } for (let canvas of document.querySelectorAll(".timeline")) { const width = canvas.width; const height = canvas.height; const ctx = canvas.getContext("2d"); const offsetX = canvas.offsetX; const offsetY = canvas.offsetY; const frameCount = (width + offsetX - layerWidth) / frameWidth; ctx.fillStyle = backgroundColor; ctx.fillRect(0, 0, width, height); ctx.lineWidth = 1; ctx.save() ctx.translate(layerWidth, gutterHeight) canvas.timelinewindow.width = width - layerWidth canvas.timelinewindow.height = height - gutterHeight canvas.timelinewindow.draw(ctx) ctx.restore() // Draw timeline top ctx.save(); ctx.save(); ctx.beginPath(); ctx.rect(layerWidth, 0, width - layerWidth, height); ctx.clip(); ctx.translate(layerWidth - offsetX, 0); ctx.fillStyle = labelColor; for ( let j = Math.floor(offsetX / (5 * frameWidth)) * 5; j < frameCount + 1; j += 5 ) { drawCenteredText( ctx, j.toString(), (j - 0.5) * frameWidth, gutterHeight / 2, gutterHeight, ); } ctx.restore(); ctx.translate(0, gutterHeight); ctx.strokeStyle = shadow; ctx.beginPath(); ctx.moveTo(layerWidth, 0); ctx.lineTo(layerWidth, height); ctx.stroke(); ctx.save(); ctx.rect(0, 0, width, height); ctx.clip(); ctx.translate(0, -offsetY); // Draw layer headers let i = 0; for (let k = context.activeObject.allLayers.length - 1; k >= 0; k--) { let layer = context.activeObject.allLayers[k]; if (context.activeObject.activeLayer == layer) { ctx.fillStyle = darkMode ? "#444" : "#ccc"; } else { ctx.fillStyle = darkMode ? "#222" : "#aaa"; } drawBorderedRect( ctx, 0, i * layerHeight, layerWidth, layerHeight, highlight, shadow, ); ctx.fillStyle = darkMode ? "white" : "black"; drawHorizontallyCenteredText( ctx, layer.name, 5, (i + 0.5) * layerHeight, layerHeight * 0.4, ); ctx.save(); const visibilityIcon = layer.visible ? canvas.icons.eye_fill : canvas.icons.eye_slash; visibilityIcon.render( ctx, layerWidth - iconSize - 5, (i + 0.5) * layerHeight - iconSize * 0.5, iconSize, iconSize, labelColor, ); const audibilityIcon = layer.audible ? canvas.icons.volume_up_fill : canvas.icons.volume_mute; audibilityIcon.render( ctx, layerWidth - iconSize * 2 - 10, (i + 0.5) * layerHeight - iconSize * 0.5, iconSize, iconSize, labelColor, ); ctx.restore(); // ctx.save(); // ctx.beginPath(); // ctx.rect(layerWidth, i * layerHeight, width, layerHeight); // ctx.clip(); // ctx.translate(layerWidth - offsetX, i * layerHeight); // // Draw empty frames // for (let j = Math.floor(offsetX / frameWidth); j < frameCount; j++) { // ctx.fillStyle = (j + 1) % 5 == 0 ? shade : backgroundColor; // drawBorderedRect( // ctx, // j * frameWidth, // 0, // frameWidth, // layerHeight, // shadow, // highlight, // shadow, // shadow, // ); // } // // Draw existing frames // if (layer instanceof Layer) { // for (let j=0; j { // // if (!frame) return; // // switch (frame.frameType) { // // case "keyframe": // // ctx.fillStyle = foregroundColor; // // drawBorderedRect( // // ctx, // // j * frameWidth, // // 0, // // frameWidth, // // layerHeight, // // highlight, // // shadow, // // shadow, // // shadow, // // ); // // ctx.fillStyle = "#111"; // // ctx.beginPath(); // // ctx.arc( // // (j + 0.5) * frameWidth, // // layerHeight * 0.75, // // frameWidth * 0.25, // // 0, // // 2 * Math.PI, // // ); // // ctx.fill(); // // break; // // case "normal": // // ctx.fillStyle = foregroundColor; // // drawBorderedRect( // // ctx, // // j * frameWidth, // // 0, // // frameWidth, // // layerHeight, // // highlight, // // shadow, // // backgroundColor, // // backgroundColor, // // ); // // break; // // case "motion": // // ctx.fillStyle = "#7a00b3"; // // ctx.fillRect(j * frameWidth, 0, frameWidth, layerHeight); // // break; // // case "shape": // // ctx.fillStyle = "#9bff9b"; // // ctx.fillRect(j * frameWidth, 0, frameWidth, layerHeight); // // break; // // } // // }); // } else if (layer instanceof AudioLayer) { // // TODO: split waveform into chunks // for (let i in layer.sounds) { // let sound = layer.sounds[i]; // // layerTrack.appendChild(sound.img) // ctx.drawImage(sound.img, 0, 0); // } // } // // if (context.activeObject.currentFrameNum) // ctx.restore(); i++; } ctx.restore(); // Draw highlighted frame ctx.save(); ctx.translate(layerWidth - offsetX, -offsetY); ctx.translate( canvas.frameDragOffset.frames * frameWidth, canvas.frameDragOffset.layers * layerHeight, ); ctx.globalCompositeOperation = "difference"; for (let frame of context.selectedFrames) { ctx.fillStyle = "grey"; ctx.fillRect( frame.frameNum * frameWidth, frame.layer * layerHeight, frameWidth, layerHeight, ); } ctx.globalCompositeOperation = "source-over"; ctx.restore(); // Draw scrubber bar ctx.save(); ctx.beginPath(); ctx.rect(layerWidth, -gutterHeight, width, height); ctx.clip(); ctx.translate(layerWidth - offsetX, 0); let frameNum = context.activeObject.currentFrameNum; ctx.strokeStyle = scrubberColor; ctx.beginPath(); ctx.moveTo((frameNum + 0.5) * frameWidth, 0); ctx.lineTo((frameNum + 0.5) * frameWidth, height); ctx.stroke(); ctx.beginPath(); ctx.fillStyle = scrubberColor; ctx.fillRect( frameNum * frameWidth, -gutterHeight, frameWidth, gutterHeight, ); ctx.fillStyle = "white"; drawCenteredText( ctx, (frameNum + 1).toString(), (frameNum + 0.5) * frameWidth, -gutterHeight / 2, gutterHeight, ); ctx.restore(); ctx.restore(); } return; for (let container of document.querySelectorAll(".layers-container")) { let layerspanel = container.querySelectorAll(".layers")[0]; let framescontainer = container.querySelectorAll(".frames-container")[0]; layerspanel.textContent = ""; framescontainer.textContent = ""; for (let layer of context.activeObject.layers) { let layerHeader = document.createElement("div"); layerHeader.className = "layer-header"; if (context.activeObject.activeLayer == layer) { layerHeader.classList.add("active"); } layerspanel.appendChild(layerHeader); let layerName = document.createElement("div"); layerName.className = "layer-name"; layerName.contentEditable = "plaintext-only"; layerName.addEventListener("click", (e) => { e.stopPropagation(); }); layerName.addEventListener("blur", (e) => { actions.changeLayerName.create(layer, layerName.innerText); }); layerName.innerText = layer.name; layerHeader.appendChild(layerName); // Visibility icon element let visibilityIcon = document.createElement("img"); visibilityIcon.className = "visibility-icon"; visibilityIcon.src = layer.visible ? "assets/eye-fill.svg" : "assets/eye-slash.svg"; // Toggle visibility on click visibilityIcon.addEventListener("click", (e) => { e.stopPropagation(); // Prevent click from bubbling to the layerHeader click listener layer.visible = !layer.visible; // visibilityIcon.src = layer.visible ? "assets/eye-fill.svg" : "assets/eye-slash.svg" updateUI(); updateMenu(); updateLayers(); }); layerHeader.appendChild(visibilityIcon); layerHeader.addEventListener("click", (e) => { context.activeObject.currentLayer = context.activeObject.layers.indexOf(layer); updateLayers(); updateUI(); }); let layerTrack = document.createElement("div"); layerTrack.className = "layer-track"; if (!layer.visible) { layerTrack.classList.add("invisible"); } framescontainer.appendChild(layerTrack); layerTrack.addEventListener("click", (e) => { let mouse = getMousePos(layerTrack, e); let frameNum = parseInt(mouse.x / 25); context.activeObject.setFrameNum(frameNum); updateLayers(); updateMenu(); updateUI(); updateInfopanel(); }); let highlightedFrame = false; layer.frames.forEach((frame, i) => { let frameEl = document.createElement("div"); frameEl.className = "frame"; frameEl.setAttribute("frameNum", i); if (i == context.activeObject.currentFrameNum) { frameEl.classList.add("active"); highlightedFrame = true; } frameEl.classList.add(frame.frameType); layerTrack.appendChild(frameEl); }); if (!highlightedFrame) { let highlightObj = document.createElement("div"); let frameCount = layer.frames.length; highlightObj.className = "frame-highlight"; highlightObj.style.left = `${(context.activeObject.currentFrameNum - frameCount) * 25}px`; layerTrack.appendChild(highlightObj); } } for (let audioLayer of context.activeObject.audioLayers) { let layerHeader = document.createElement("div"); layerHeader.className = "layer-header"; layerHeader.classList.add("audio"); layerspanel.appendChild(layerHeader); let layerTrack = document.createElement("div"); layerTrack.className = "layer-track"; layerTrack.classList.add("audio"); framescontainer.appendChild(layerTrack); for (let i in audioLayer.sounds) { let sound = audioLayer.sounds[i]; layerTrack.appendChild(sound.img); } let layerName = document.createElement("div"); layerName.className = "layer-name"; layerName.contentEditable = "plaintext-only"; layerName.addEventListener("click", (e) => { e.stopPropagation(); }); layerName.addEventListener("blur", (e) => { actions.changeLayerName.create(audioLayer, layerName.innerText); }); layerName.innerText = audioLayer.name; layerHeader.appendChild(layerName); } } } function updateInfopanel() { infopanelDirty = true; } function renderInfopanel() { for (let panel of document.querySelectorAll(".infopanel")) { panel.innerText = ""; let input; let label; let span; let breadcrumbs = document.createElement("div"); const bctitle = document.createElement("span"); bctitle.style.cursor = "default"; bctitle.textContent = "Context: "; breadcrumbs.appendChild(bctitle); let crumbs = []; for (let object of context.objectStack) { crumbs.push({ name: object.name, object: object }); } crumbs.forEach((crumb, index) => { const crumbText = document.createElement("span"); crumbText.textContent = crumb.name; breadcrumbs.appendChild(crumbText); if (index < crumbs.length - 1) { const separator = document.createElement("span"); separator.textContent = " > "; separator.style.cursor = "default"; crumbText.style.cursor = "pointer"; breadcrumbs.appendChild(separator); } else { crumbText.style.cursor = "default"; } }); breadcrumbs.addEventListener("click", function (event) { const span = event.target; // Only handle clicks on the breadcrumb text segments (not the separators) if (span.tagName === "SPAN" && span.textContent !== " > ") { const clickedText = span.textContent; // Find the crumb associated with the clicked text const crumb = crumbs.find((c) => c.name === clickedText); if (crumb) { const index = context.objectStack.indexOf(crumb.object); if (index !== -1) { // Keep only the objects up to the clicked one and add the clicked one as the last item context.objectStack = context.objectStack.slice(0, index + 1); updateUI(); updateLayers(); updateMenu(); updateInfopanel(); } } } }); panel.appendChild(breadcrumbs); for (let property in tools[mode].properties) { let prop = tools[mode].properties[property]; label = document.createElement("label"); label.className = "infopanel-field"; span = document.createElement("span"); span.className = "infopanel-label"; span.innerText = prop.label; switch (prop.type) { case "number": input = document.createElement("input"); input.className = "infopanel-input"; input.type = "number"; input.disabled = prop.enabled == undefined ? false : !prop.enabled(); if (prop.value) { input.value = prop.value.get(); } else { input.value = getProperty(context, property); } if (prop.min) { input.min = prop.min; } if (prop.max) { input.max = prop.max; } break; case "enum": input = document.createElement("select"); input.className = "infopanel-input"; input.disabled = prop.enabled == undefined ? false : !prop.enabled(); let optionEl; for (let option of prop.options) { optionEl = document.createElement("option"); optionEl.value = option; optionEl.innerText = option; input.appendChild(optionEl); } if (prop.value) { input.value = prop.value.get(); } else { input.value = getProperty(context, property); } break; case "boolean": input = document.createElement("input"); input.className = "infopanel-input"; input.type = "checkbox"; input.disabled = prop.enabled == undefined ? false : !prop.enabled(); if (prop.value) { input.checked = prop.value.get(); } else { input.checked = getProperty(context, property); } break; case "text": input = document.createElement("input"); input.className = "infopanel-input"; input.disabled = prop.enabled == undefined ? false : !prop.enabled(); if (prop.value) { input.value = prop.value.get(); } else { input.value = getProperty(context, property); } break; } input.addEventListener("input", (e) => { switch (prop.type) { case "number": if (!isNaN(e.target.value) && e.target.value > 0) { if (prop.value) { prop.value.set(parseInt(e.target.value)); } else { setProperty(context, property, parseInt(e.target.value)); } } break; case "enum": if (prop.options.indexOf(e.target.value) >= 0) { setProperty(context, property, e.target.value); } break; case "boolean": if (prop.value) { prop.value.set(e.target.value); } else { setProperty(context, property, e.target.checked); } break; case "text": // Do nothing because this event fires for every character typed break; } }); input.addEventListener("blur", (e) => { switch (prop.type) { case "text": if (prop.value) { prop.value.set(e.target.value); } else { setProperty(context, property, parseInt(e.target.value)); } break; } }); input.addEventListener("keydown", (e) => { if (e.key === "Enter") { e.target.blur(); } }); label.appendChild(span); label.appendChild(input); panel.appendChild(label); } } } function updateOutliner() { outlinerDirty = true; } function renderOutliner() { const padding = 20; // pixels for (let outliner of document.querySelectorAll(".outliner")) { const x = 0; let y = padding; const ctx = outliner.getContext("2d"); ctx.fillStyle = "white"; ctx.fillRect(0, 0, outliner.width, outliner.height); const stack = [{ object: outliner.object, indent: 0 }]; ctx.save(); ctx.translate(0, -outliner.offsetY); // Iterate as long as there are items in the stack while (stack.length > 0) { const { object, indent } = stack.pop(); // Determine if the object is collapsed and draw the corresponding triangle const triangleX = x + indent + triangleSize; // X position for the triangle const triangleY = y - padding / 2; // Y position for the triangle (centered vertically) if (outliner.active === object) { ctx.fillStyle = "red"; ctx.fillRect(0, y - padding, outliner.width, padding); } if (outliner.collapsed[object.idx]) { drawRegularPolygon(ctx, triangleX, triangleY, triangleSize, 3, "black"); } else { drawRegularPolygon( ctx, triangleX, triangleY, triangleSize, 3, "black", Math.PI / 2, ); } // Draw the current object (GraphicsObject or Layer) const label = `(${object.constructor.name}) ${object.name}`; ctx.fillStyle = "black"; // ctx.fillText(label, x + indent + 2*triangleSize, y); drawHorizontallyCenteredText( ctx, label, x + indent + 2 * triangleSize, y - padding / 2, padding * 0.75, ); // Update the Y position for the next line y += padding; // Space between lines (adjust as necessary) if (outliner.collapsed[object.idx]) { continue; } // If the object has layers, add them to the stack if (object.layers) { for (let i = object.layers.length - 1; i >= 0; i--) { const layer = object.layers[i]; stack.push({ object: layer, indent: indent + padding }); } } else if (object.children) { for (let i = object.children.length - 1; i >= 0; i--) { const child = object.children[i]; stack.push({ object: child, indent: indent + 2 * padding }); } } } ctx.restore(); } } function updateMenu() { menuDirty = true; } async function renderMenu() { let activeFrame; let activeKeyframe; let newFrameMenuItem; let newKeyframeMenuItem; let newBlankKeyframeMenuItem; let duplicateKeyframeMenuItem; let deleteFrameMenuItem; // Move this updateOutliner(); let recentFilesList = []; config.recentFiles.forEach((file) => { recentFilesList.push({ text: file, enabled: true, action: () => { document.body.style.cursor = "wait" setTimeout(()=>_open(file),10); }, }); }); const frameInfo = context.activeObject.activeLayer.getFrameValue( context.activeObject.currentFrameNum ) if (frameInfo.valueAtN) { activeFrame = true; activeKeyframe = true; } else if (frameInfo.prev && frameInfo.next) { activeFrame = true; activeKeyframe = false; } else { activeFrame = false; activeKeyframe = false; } const appSubmenu = await Submenu.new({ text: "Lightningbeam", items: [ { text: "About Lightningbeam", enabled: true, action: about, }, { text: "Settings", enabled: false, action: () => {}, }, { text: "Close Window", enabled: true, action: quit, }, { text: "Quit Lightningbeam", enabled: true, action: quit, }, ], }); const fileSubmenu = await Submenu.new({ text: "File", items: [ { text: "New file...", enabled: true, action: newFile, accelerator: getShortcut("new"), }, { text: "New Window", enabled: true, action: newWindow, accelerator: getShortcut("newWindow"), }, { text: "Save", enabled: true, action: save, accelerator: getShortcut("save"), }, { text: "Save As...", enabled: true, action: saveAs, accelerator: getShortcut("saveAs"), }, await Submenu.new({ text: "Open Recent", items: recentFilesList, }), { text: "Open File...", enabled: true, action: open, accelerator: getShortcut("open"), }, { text: "Revert", enabled: undoStack.length > lastSaveIndex, action: revert, }, { text: "Import...", enabled: true, action: importFile, accelerator: getShortcut("import"), }, { text: "Export...", enabled: true, action: render, accelerator: getShortcut("export"), }, { text: "Quit", enabled: true, action: quit, accelerator: getShortcut("quit"), }, ], }); const editSubmenu = await Submenu.new({ text: "Edit", items: [ { text: "Undo " + (undoStack.length > 0 ? camelToWords(undoStack[undoStack.length - 1].name) : ""), enabled: undoStack.length > 0, action: undo, accelerator: getShortcut("undo"), }, { text: "Redo " + (redoStack.length > 0 ? camelToWords(redoStack[redoStack.length - 1].name) : ""), enabled: redoStack.length > 0, action: redo, accelerator: getShortcut("redo"), }, { text: "Cut", enabled: false, action: () => {}, }, { text: "Copy", enabled: context.selection.length > 0 || context.shapeselection.length > 0, action: copy, accelerator: getShortcut("copy"), }, { text: "Paste", enabled: true, action: paste, accelerator: getShortcut("paste"), }, { text: "Delete", enabled: context.selection.length > 0 || context.shapeselection.length > 0, action: delete_action, accelerator: getShortcut("delete"), }, { text: "Select All", enabled: true, action: actions.selectAll.create, accelerator: getShortcut("selectAll"), }, { text: "Select None", enabled: true, action: actions.selectNone.create, accelerator: getShortcut("selectNone"), }, ], }); const modifySubmenu = await Submenu.new({ text: "Modify", items: [ { text: "Group", enabled: context.selection.length != 0 || context.shapeselection.length != 0, action: actions.group.create, accelerator: getShortcut("group"), }, { text: "Send to back", enabled: context.selection.length != 0 || context.shapeselection.length != 0, action: actions.sendToBack.create, }, { text: "Bring to front", enabled: context.selection.length != 0 || context.shapeselection.length != 0, action: actions.bringToFront.create, }, ], }); const layerSubmenu = await Submenu.new({ text: "Layer", items: [ { text: "Add Layer", enabled: true, action: actions.addLayer.create, accelerator: getShortcut("addLayer"), }, { text: "Delete Layer", enabled: context.activeObject.layers.length > 1, action: actions.deleteLayer.create, }, { text: context.activeObject.activeLayer.visible ? "Hide Layer" : "Show Layer", enabled: true, action: () => { context.activeObject.activeLayer.toggleVisibility(); }, }, ], }); newFrameMenuItem = { text: "New Frame", enabled: !activeFrame, action: addFrame, }; newKeyframeMenuItem = { text: "New Keyframe", enabled: (context.selection && context.selection.length > 0) || (context.shapeselection && context.shapeselection.length > 0), accelerator: getShortcut("addKeyframe"), action: addKeyframeAtPlayhead, }; newBlankKeyframeMenuItem = { text: "New Blank Keyframe", // enabled: !activeKeyframe, enabled: false, accelerator: getShortcut("addBlankKeyframe"), action: addKeyframe, }; duplicateKeyframeMenuItem = { text: "Duplicate Keyframe", enabled: activeKeyframe, action: () => { context.activeObject.setFrameNum(context.activeObject.currentFrameNum+1) addKeyframe() }, }; deleteFrameMenuItem = { text: "Delete Frame", enabled: activeFrame, action: deleteFrame, }; const timelineSubmenu = await Submenu.new({ text: "Timeline", items: [ // newFrameMenuItem, newKeyframeMenuItem, newBlankKeyframeMenuItem, deleteFrameMenuItem, duplicateKeyframeMenuItem, { text: "Add Keyframe at Playhead", enabled: (context.selection && context.selection.length > 0) || (context.shapeselection && context.shapeselection.length > 0), action: addKeyframeAtPlayhead, accelerator: "K", }, { text: "Add Motion Tween", enabled: activeFrame, action: actions.addMotionTween.create, }, { text: "Add Shape Tween", enabled: activeFrame, action: actions.addShapeTween.create, }, { text: "Return to start", enabled: false, action: () => {}, }, { text: "Play", enabled: !playing, action: playPause, accelerator: getShortcut("playAnimation"), }, ], }); const viewSubmenu = await Submenu.new({ text: "View", items: [ { text: "Zoom In", enabled: true, action: zoomIn, accelerator: getShortcut("zoomIn"), }, { text: "Zoom Out", enabled: true, action: zoomOut, accelerator: getShortcut("zoomOut"), }, { text: "Actual Size", enabled: context.zoomLevel != 1, action: resetZoom, accelerator: getShortcut("resetZoom"), }, { text: "Recenter View", enabled: true, action: recenter, // accelerator: getShortcut("recenter"), }, ], }); const helpSubmenu = await Submenu.new({ text: "Help", items: [ { text: "About...", enabled: true, action: about, }, ], }); let items = [ fileSubmenu, editSubmenu, modifySubmenu, layerSubmenu, timelineSubmenu, viewSubmenu, helpSubmenu, ]; if (macOS) { items.unshift(appSubmenu); } const menu = await Menu.new({ items: items, }); await (macOS ? menu.setAsAppMenu() : menu.setAsWindowMenu()); } updateMenu(); const panes = { stage: { name: "stage", func: stage, }, toolbar: { name: "toolbar", func: toolbar, }, timeline: { name: "timeline", func: timeline, }, timelineV2: { name: "timeline-v2", func: timelineV2, }, infopanel: { name: "infopanel", func: infopanel, }, outlineer: { name: "outliner", func: outliner, }, }; function _arrayBufferToBase64(buffer) { var binary = ""; var bytes = new Uint8Array(buffer); var len = bytes.byteLength; for (var i = 0; i < len; i++) { binary += String.fromCharCode(bytes[i]); } return window.btoa(binary); } async function convertToDataURL(filePath, allowedMimeTypes) { try { // Read the image file as a binary file (buffer) const binaryData = await readFile(filePath); const mimeType = getMimeType(filePath); if (!mimeType) { throw new Error("Unsupported file type"); } if (allowedMimeTypes.indexOf(mimeType) == -1) { throw new Error(`Unsupported MIME type ${mimeType}`); } const base64Data = _arrayBufferToBase64(binaryData); const dataURL = `data:${mimeType};base64,${base64Data}`; return { dataURL, mimeType }; } catch (error) { console.log(error); console.error("Error reading the file:", error); return null; } } // Determine the MIME type based on the file extension function getMimeType(filePath) { const ext = filePath.split(".").pop().toLowerCase(); switch (ext) { case "jpg": case "jpeg": return "image/jpeg"; case "png": return "image/png"; case "gif": return "image/gif"; case "bmp": return "image/bmp"; case "webp": return "image/webp"; case "mp3": return "audio/mpeg"; default: return null; // Unsupported file type } } function startToneOnUserInteraction() { // Function to handle the first interaction (click or key press) const startTone = () => { Tone.start() .then(() => { console.log("Tone.js started!"); }) .catch((err) => { console.error("Error starting Tone.js:", err); }); // Remove the event listeners to prevent them from firing again document.removeEventListener("click", startTone); document.removeEventListener("keydown", startTone); }; // Add event listeners for mouse click and key press document.addEventListener("click", startTone); document.addEventListener("keydown", startTone); } startToneOnUserInteraction(); function renderAll() { try { if (uiDirty) { renderUI(); uiDirty = false; } if (layersDirty) { renderLayers(); layersDirty = false; } if (outlinerDirty) { renderOutliner(); outlinerDirty = false; } if (menuDirty) { renderMenu(); menuDirty = false; } if (infopanelDirty) { renderInfopanel(); infopanelDirty = false; } } catch (error) { const errorMessage = error.message || error.toString(); // Use error message or string representation of the error if (errorMessage !== lastErrorMessage) { // A new error, log it and reset repeat count console.error(error); lastErrorMessage = errorMessage; repeatCount = 1; } else if (repeatCount === 1) { // The error repeats for the second time, log "[Repeats]" console.warn("[Repeats]"); repeatCount = 2; } } finally { requestAnimationFrame(renderAll); } } renderAll(); if (window.openedFiles?.length>0) { document.body.style.cursor = "wait" setTimeout(()=>_open(window.openedFiles[0]),10) for (let i=1; i { if (!audioStarted) { try { coreInterface.resume_audio(); audioStarted = true; console.log("Started CoreInterface Audio!") } catch (err) { console.error("Audio resume failed:", err); } } // Remove the event listeners to prevent them from firing again document.removeEventListener("click", startCoreInterfaceAudio); document.removeEventListener("keydown", startCoreInterfaceAudio); }; // Add event listeners for mouse click and key press document.addEventListener("click", startCoreInterfaceAudio); document.addEventListener("keydown", startCoreInterfaceAudio); } testAudio()