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, 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 { getCurrentWindow } = window.__TAURI__.window; const { getVersion } = window.__TAURI__.app; 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[1].match(/([a-zA-Z0-9_-]+\.js:\d+)/); 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); } }, }, }, goToFrame: { type: "number", label: "Go To Frame", enabled: () => context.selection.length == 1, value: { get: () => { if (context.selection.length != 1) return undefined; const selectedObject = context.selection[0]; if (! (selectedObject.idx in context.activeObject.currentFrame.keys)) return undefined; return context.activeObject.currentFrame.keys[selectedObject.idx] .goToFrame; }, set: (val) => { if (context.selection.length != 1) return undefined; const selectedObject = context.selection[0]; context.activeObject.currentFrame.keys[ selectedObject.idx ].goToFrame = val; selectedObject.setFrameNum(val - 1); updateUI(); }, }, }, playFromFrame: { type: "boolean", label: "Play From Frame", enabled: () => context.selection.length == 1, value: { get: () => { if (context.selection.length != 1) return undefined; const selectedObject = context.selection[0]; if (!(selectedObject.idx in context.activeObject.currentFrame.keys)) return; return context.activeObject.currentFrame.keys[selectedObject.idx] .playFromFrame; }, set: (val) => { if (context.selection.length != 1) return undefined; const selectedObject = context.selection[0]; context.activeObject.currentFrame.keys[ selectedObject.idx ].playFromFrame = val; selectedObject.playing = true; updateUI(); }, }, }, }, }, 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, }; let config = { shortcuts: { playAnimation: " ", // undo: "+z" 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, }; 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)); 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) { if (!config.recentFiles.includes(filePath)) { config.recentFiles.unshift(filePath); if (config.recentFiles.length > 10) { config.recentFiles = config.recentFiles.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) => { if (!parent.currentFrame?.exists) 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, 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(), frame: parent.currentFrame.idx, }; undoStack.push({ name: "addShape", action: action }); actions.addShape.execute(action); updateMenu(); }, execute: (action) => { let object = pointerList[action.parent]; let frame = action.frame ? pointerList[action.frame] : object.currentFrame; let curvesList = action.curves; let cxt = { ...context, ...action.context, }; let shape = new Shape(action.startx, action.starty, cxt, 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) { frame.addShape(newShape, cxt.sendToBack, frame); } }, rollback: (action) => { let object = pointerList[action.parent]; let frame = action.frame ? pointerList[action.frame] : object.currentFrame; let shape = pointerList[action.uuid]; frame.removeShape(shape); 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, 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; imageObject.currentFrame.addShape(imageShape); 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, frame: context.activeObject.currentFrame.idx, uuid: uuidv4(), }; undoStack.push({ name: "duplicateObject", action: action }); actions.duplicateObject.execute(action); updateMenu(); }, execute: (action) => { const object = pointerList[action.object]; const frame = pointerList[action.frame]; for (let item of action.items) { if (item.type == "shape") { frame.addShape(Shape.fromJSON(item)); } else if (item.type == "GraphicsObject") { const newObj = GraphicsObject.fromJSON(item); object.addObject(newObj); } } // newObj.idx = action.uuid // context.activeObject.addObject(newObj); updateUI(); }, rollback: (action) => { const object = pointerList[action.object]; const frame = pointerList[action.frame]; for (let item of action.items) { if (item.type == "shape") { frame.removeShape(pointerList[item.idx]); } else if (item.type == "GraphicsObject") { object.removeChild(pointerList[item.idx]); } } updateUI(); }, }, deleteObjects: { create: (objects, shapes) => { redoStack.length = 0; let serializableObjects = []; let serializableShapes = []; for (let object of objects) { serializableObjects.push(object.idx); } for (let shape of shapes) { serializableShapes.push(shape.idx); } let action = { objects: serializableObjects, shapes: serializableShapes, frame: context.activeObject.currentFrame.idx, oldState: structuredClone(context.activeObject.currentFrame.keys), }; undoStack.push({ name: "deleteObjects", action: action }); actions.deleteObjects.execute(action); updateMenu(); }, execute: (action) => { let frame = pointerList[action.frame]; for (let object of action.objects) { delete frame.keys[object]; } for (let shape of action.shapes) { frame.shapes.splice(frame.shapes.indexOf(pointerList[shape]), 1); } updateUI(); }, rollback: (action) => { let frame = pointerList[action.frame]; for (let object of action.objects) { frame.keys[object] = action.oldState[object]; } for (let shape of action.shapes) { frame.shapes.push(pointerList[shape]); } 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(); }, }, editFrame: { initialize: (frame) => { let action = { type: "editFrame", oldState: structuredClone(frame.keys), frame: frame.idx, }; return action; }, finalize: (action, frame) => { action.newState = structuredClone(frame.keys); undoStack.push({ name: "editFrame", action: action }); actions.editFrame.execute(action); context.activeAction = undefined; updateMenu(); }, render: (action, ctx) => {}, create: (frame) => { redoStack.length = 0; // Clear redo stack if (!(frame.idx in startProps)) return; let action = { newState: structuredClone(frame.keys), oldState: startProps[frame.idx], frame: frame.idx, }; undoStack.push({ name: "editFrame", action: action }); actions.editFrame.execute(action); updateMenu(); }, execute: (action) => { let frame = pointerList[action.frame]; if (frame == undefined) return; frame.keys = structuredClone(action.newState); updateUI(); }, rollback: (action) => { let frame = pointerList[action.frame]; frame.keys = structuredClone(action.oldState); updateUI(); }, }, 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(); } // 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]; frameBuffer.push({ frame: frame, frameNum: frameObj.frameNum, layer: frameObj.layer, }); layer.deleteFrame(frame.idx, undefined, frameObj.replacementUuid); } for (let frameObj of frameBuffer) { 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) => { // your code here }, }, 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; for (let shape of context.shapeselection) { serializableShapes.push(shape.idx); if (bbox == undefined) { bbox = shape.bbox(); } else { growBoundingBox(bbox, shape.bbox()); } } for (let object of context.selection) { serializableObjects.push(object.idx); // TODO: rotated bbox if (bbox == undefined) { bbox = object.bbox(); } else { growBoundingBox(bbox, object.bbox()); } } context.shapeselection = []; context.selection = []; let action = { shapes: serializableShapes, objects: serializableObjects, groupUuid: uuidv4(), parent: context.activeObject.idx, frame: context.activeObject.currentFrame.idx, layer: context.activeObject.activeLayer.idx, 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(); }, execute: (action) => { let group = new GraphicsObject(action.groupUuid); let parent = pointerList[action.parent]; let frame = action.frame ? pointerList[action.frame] : parent.currentFrame; let layer; if (action.layer) { layer = pointerList[action.layer] } else { for (let _layer of parent.layers) { for (let _frame of _layer.frames) { if (_frame && (_frame.idx == frame.idx)) { layer = _layer } } } if (layer==undefined) { layer = parent.activeLayer } } for (let shapeIdx of action.shapes) { let shape = pointerList[shapeIdx]; shape.translate(-action.position.x, -action.position.y); group.currentFrame.addShape(shape); frame.removeShape(shape); } for (let objectIdx of action.objects) { let object = pointerList[objectIdx]; group.addObject( object, frame.keys[objectIdx].x - action.position.x, frame.keys[objectIdx].y - action.position.y, ); parent.removeChild(object); } parent.addObject(group, action.position.x, action.position.y, frame); context.selection = [group]; updateUI(); updateInfopanel(); }, rollback: (action) => { let group = pointerList[action.groupUuid]; let parent = pointerList[action.parent]; let frame = action.frame ? pointerList[action.frame] : parent.currentFrame; for (let shapeIdx of action.shapes) { let shape = pointerList[shapeIdx]; frame.addShape(shape); shape.translate(action.position.x, action.position.y); group.getFrame(0).removeShape(shape); } for (let objectIdx of action.objects) { let object = pointerList[objectIdx]; parent.addObject(object, object.x, object.y); group.removeChild(object); } parent.removeChild(group); updateUI(); updateInfopanel(); }, }, sendToBack: { create: () => { redoStack.length = 0; let serializableShapes = []; let serializableObjects = []; let formerIndices = {}; for (let shape of context.shapeselection) { serializableShapes.push(shape.idx); formerIndices[shape.idx] = context.activeObject.currentFrame.shapes.indexOf(shape); } for (let object of context.selection) { serializableObjects.push(object.idx); formerIndices[object.idx] = context.activeObject.activeLayer.children.indexOf(object); } let action = { shapes: serializableShapes, objects: serializableObjects, layer: context.activeObject.activeLayer.idx, frame: context.activeObject.currentFrame.idx, formerIndices: formerIndices, }; undoStack.push({ name: "sendToBack", action: action }); actions.sendToBack.execute(action); updateMenu(); }, execute: (action) => { let frame = pointerList[action.frame]; let layer = pointerList[action.layer]; for (let shapeIdx of action.shapes) { let shape = pointerList[shapeIdx]; frame.shapes.splice(frame.shapes.indexOf(shape), 1); frame.shapes.unshift(shape); } 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 frame = pointerList[action.frame]; let layer = pointerList[action.layer]; for (let shapeIdx of action.shapes) { let shape = pointerList[shapeIdx]; frame.shapes.splice(frame.shapes.indexOf(shape), 1); frame.shapes.splice(action.formerIndices[shapeIdx], 0, shape); } 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; let serializableShapes = []; let serializableObjects = []; let formerIndices = {}; for (let shape of context.shapeselection) { serializableShapes.push(shape.idx); formerIndices[shape.idx] = context.activeObject.currentFrame.shapes.indexOf(shape); } for (let object of context.selection) { serializableObjects.push(object.idx); formerIndices[object.idx] = context.activeObject.activeLayer.children.indexOf(object); } let action = { shapes: serializableShapes, objects: serializableObjects, layer: context.activeObject.activeLayer.idx, frame: context.activeObject.currentFrame.idx, formerIndices: formerIndices, }; undoStack.push({ name: "bringToFront", action: action }); actions.bringToFront.execute(action); updateMenu(); }, execute: (action) => { let frame = pointerList[action.frame]; let layer = pointerList[action.layer]; for (let shapeIdx of action.shapes) { let shape = pointerList[shapeIdx]; frame.shapes.splice(frame.shapes.indexOf(shape), 1); frame.shapes.push(shape); } for (let objectIdx of action.objects) { let object = pointerList[objectIdx]; layer.children.splice(layer.children.indexOf(object), 1); layer.children.push(object); } updateUI(); }, rollback: (action) => { let frame = pointerList[action.frame]; let layer = pointerList[action.layer]; for (let shapeIdx of action.shapes) { let shape = pointerList[shapeIdx]; frame.shapes.splice(frame.shapes.indexOf(shape), 1); frame.shapes.splice(action.formerIndices[shapeIdx], 0, shape); } 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 = []; for (let child of context.activeObject.activeLayer.children) { let idx = child.idx; if (idx in context.activeObject.currentFrame.keys) { selection.push(child.idx); } } for (let shape of context.activeObject.currentFrame.shapes) { 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(); }, }, }; function uuidv4() { return "10000000-1000-4000-8000-100000000000".replace(/[018]/g, (c) => ( +c ^ (crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (+c / 4))) ).toString(16), ); } 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; for (let shape of context.activeObject.currentFrame.shapes) { if (shape instanceof TempShape) continue; 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; for (let shape of context.activeObject.currentFrame.shapes) { if (shape instanceof TempShape) continue; 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(); } } 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(); class Layer extends Widget { constructor(uuid) { super(0,0) if (!uuid) { this.idx = uuidv4(); } else { this.idx = uuid; } this.name = "Layer"; this.frames = [new Frame("keyframe", this.idx + "-F1")]; this.frameNum = 0; this.visible = true; this.audible = true; pointerList[this.idx] = this; } static fromJSON(json) { const layer = new Layer(json.idx); for (let i in json.children) { const child = json.children[i]; layer.children.push(GraphicsObject.fromJSON(child)); } layer.name = json.name; 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) } } // for (let frame in layer.frames) { // if (layer.frames[frame]) { // if (["motion", "shape"].indexOf(layer.frames[frame].frameType) != -1) { // layer.updateFrameNextAndPrev(frame, layer.frames[frame].frameType); // } // } // } 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) { // TODO: we may not need the randomizeUuid parameter anymore let childJson = child.toJSON(randomizeUuid) idMap[child.idx] = childJson.idx json.children.push(childJson); } 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] // delete frameJson.keys[key] } } json.frames.push(frameJson); } else { json.frames.push(undefined) } } json.visible = this.visible; json.audible = this.audible; return json; } 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 }; } draw(ctx) { // super.draw(ctx) if (!this.visible) return; let frameInfo = this.getFrameValue(this.frameNum); let frame = frameInfo.valueAtN !== undefined ? frameInfo.valueAtN : frameInfo.prev; const keyframe = frameInfo.valueAtN ? true : false // let frame = this.getFrame(this.currentFrameNum); let cxt = {...context} cxt.ctx = ctx let t = null; if (frameInfo.prev && frameInfo.next) { t = (this.frameNum - frameInfo.prevIndex) / (frameInfo.nextIndex - frameInfo.prevIndex); } if (frame) { // Update shapes and children for (let shape of frame.shapes) { // If prev.frameType is "shape", look for a matching shape in next if (frameInfo.prev && frameInfo.prev.keyTypes.has("shape")) { const shape2 = frameInfo.next.shapes.find(s => s.shapeId === shape.shapeId); if (shape2) { // If matching shape is found, interpolate and draw shape.lerpShape(shape2, t).draw(cxt); continue; // Skip to next shape } } // Otherwise, just draw the shape as usual cxt.selected = context.shapeselection.includes(shape); shape.draw(cxt); } for (let child of this.children) { if (child.idx in frame.keys) { for (let key in frame.keys[child.idx]) { // If both prev and next exist and prev.frameType is "motion", interpolate child keys if (frameInfo.prev && frameInfo.next && frameInfo.prev.keyTypes.has("motion")) { // Interpolate between prev and next for this key child[key] = lerp(frameInfo.prev.keys[child.idx][key], frameInfo.next.keys[child.idx][key], t); } else { // Otherwise, use the value from the current frame child[key] = frame.keys[child.idx][key]; } } if (!context.objectStack.includes(child)) { if (keyframe) { if (child.goToFrame != undefined) { child.setFrameNum(child.goToFrame - 1) if (child.playFromFrame) { child.playing = true } else { child.playing = false } child.playing = true } } if (child.playing) { let lastFrame = 0; for (let i = this.frameNum; i >= 0; i--) { if ( this.frames[i] && this.frames[i].keys[child.idx] && this.frames[i].keys[child.idx].playFromFrame ) { lastFrame = i; break; } } child.setFrameNum(this.frameNum - lastFrame); } } 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) 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) } } } if (this.activeShape) { this.activeShape.draw(cxt) } if (context.activeCurve) { if (frame.shapes.indexOf(context.activeCurve.shape) != -1) { cxt.selected = true } } } bbox() { let bbox = super.bbox() const frameInfo = this.getFrameValue(this.frameNum); // TODO: if there are multiple layers we should only be counting valid frames const frame = frameInfo.valueAtN ? frameInfo.valueAtN : frameInfo.prev if (!frame) return bbox; if (frame.shapes.length > 0 && bbox == undefined) { bbox = structuredClone(frame.shapes[0].boundingBox); } for (let shape of frame.shapes) { growBoundingBox(bbox, shape.boundingBox); } return bbox } mousedown(x, y) { const mouse = {x: x, y: y} if (this==context.activeLayer) { switch(mode) { case "rectangle": case "ellipse": case "draw": this.clicked = true this.activeShape = new Shape(x, y, context, uuidv4()) this.lastMouse = mouse; 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 // TODO: get this from self let pointShape = getShapeAtPoint( mouse, context.activeObject.currentFrame.shapes, ); 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, ); } 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, ); } 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); 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) { 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) { actions.addShape.create(context.activeObject, this.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 let patternCanvas = document.createElement('canvas'); patternCanvas.width = 2; patternCanvas.height = 2; let patternCtx = 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(patternCanvas, 'repeat'); // repeat the pattern across the canvas // for (let region of this.regions) { // // if (region.filled) continue; // if ((region.fillStyle || region.fillImage) && region.filled) { // // ctx.fillStyle = region.fill // if (region.fillImage) { // let pat = ctx.createPattern(region.fillImage, "no-repeat") // ctx.fillStyle = pat // } else { // ctx.fillStyle = region.fillStyle // } // ctx.beginPath() // for (let curve of region.curves) { // ctx.lineTo(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.fill() // } // } 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, uuid = undefined, shapeId = undefined) { super(startx, starty); 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; } pointerList[this.idx] = this; this.regionIdx = 0; this.inProgress = true; } static fromJSON(json) { 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, }, 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, }); } 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.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; } 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, {}, 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; this.currentLayer = 0; this.children = [new Layer(uuid + "-L1")]; // this.layers = [new Layer(uuid + "-L1")]; this.audioLayers = []; // this.children = [] this.shapes = []; 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 = []; for (let layer of json.layers) { graphicsObject.layers.push(Layer.fromJSON(layer)); } 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 = []; 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 allLayers() { return [...this.audioLayers, ...this.layers]; } get currentFrame() { return this.getFrame(this.currentFrameNum); } get maxFrame() { return ( Math.max( ...this.layers.map((layer) => { return ( layer.frames.findLastIndex((frame) => frame !== undefined) || -1 ); }), ) + 1 ); } advanceFrame() { this.setFrameNum(this.currentFrameNum + 1); } decrementFrame() { this.setFrameNum(this.currentFrameNum - 1); } getFrame(num) { return this.activeLayer.getFrame(num); } setFrameNum(num) { num = Math.max(0, num); for (let layer of this.layers) { this.currentFrameNum = num; layer.frameNum = num let frame = layer.getFrame(num); for (let child of this.children) { let idx = child.idx; if (idx in frame.keys) { child.x = frame.keys[idx].x; child.y = frame.keys[idx].y; child.rotation = frame.keys[idx].rotation; child.scale_x = frame.keys[idx].scale_x; child.scale_y = frame.keys[idx].scale_y; child.playFromFrame = frame.keys[idx].playFromFrame; if ( frame.frameType == "keyframe" && frame.keys[idx].goToFrame != undefined ) { // Frames are 1-indexed child.setFrameNum(frame.keys[idx].goToFrame - 1); if (child.playFromFrame) { child.playing = true; } else { child.playing = false; } } else if (child.playing) { let lastFrame = 0; for (let i = num; i >= 0; i--) { if ( layer.frames[i].frameType == "keyframe" && layer.frames[i].keys[idx].playFromFrame ) { lastFrame = i; break; } } child.setFrameNum(num - lastFrame); } } } } } bbox() { let bbox; // for (let layer of this.layers) { // let frame = layer.getFrame(this.currentFrameNum); // if (frame.shapes.length > 0 && bbox == undefined) { // bbox = structuredClone(frame.shapes[0].boundingBox); // } // for (let shape of frame.shapes) { // growBoundingBox(bbox, shape.boundingBox); // } // } 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; let frame = layer.getFrame(this.currentFrameNum); for (let shape of frame.shapes) { let cxt = {...context} if (context.shapeselection.indexOf(shape) >= 0) { cxt.selected = true } shape.draw(cxt); } for (let child of layer.children) { if (child == context.activeObject) continue; let idx = child.idx; if (idx in frame.keys) { child.x = frame.keys[idx].x; child.y = frame.keys[idx].y; child.rotation = frame.keys[idx].rotation; child.scale_x = frame.keys[idx].scale_x; child.scale_y = frame.keys[idx].scale_y; ctx.save(); child.draw(context); if (true) { } ctx.restore(); } } } if (this == context.activeObject) { 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(); } */ 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, frame = undefined, layer=undefined) { if (frame == undefined) { frame = this.currentFrame; } if (layer==undefined) { layer = this.activeLayer } // this.children.push(object); layer.children.push(object) object.parent = this; object.x = x; object.y = y; let idx = object.idx; frame.keys[idx] = { x: x, y: y, rotation: 0, scale_x: 1, scale_y: 1, goToFrame: 1, }; } 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) { delete frame[idx]; } } // this.children.splice(this.children.indexOf(childObject), 1); } 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.timeline), ); 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: event.clientX, y: event.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) { context.activeObject.currentFrame.saveState(); for (let item of context.selection) { context.activeObject.currentFrame.keys[item.idx].x += 1; } actions.editFrame.create(context.activeObject.currentFrame); updateUI(); } e.preventDefault(); break; case "ArrowLeft": decrementFrame(); break; case "ArrowLeft": if (context.selection) { context.activeObject.currentFrame.saveState(); for (let item of context.selection) { context.activeObject.currentFrame.keys[item.idx].x -= 1; } actions.editFrame.create(context.activeObject.currentFrame); updateUI(); } e.preventDefault(); break; case "ArrowUp": if (context.selection) { context.activeObject.currentFrame.saveState(); for (let item of context.selection) { context.activeObject.currentFrame.keys[item.idx].y -= 1; } actions.editFrame.create(context.activeObject.currentFrame); updateUI(); } e.preventDefault(); break; case "ArrowDown": if (context.selection) { context.activeObject.currentFrame.saveState(); for (let item of context.selection) { context.activeObject.currentFrame.keys[item.idx].y += 1; } actions.editFrame.create(context.activeObject.currentFrame); updateUI(); } e.preventDefault(); break; default: break; } }); function playPause() { playing = !playing; if (playing) { if (context.activeObject.currentFrameNum >= context.activeObject.maxFrame - 1) { context.activeObject.setFrameNum(0); } 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.currentFrameNum / config.framerate, ); } } } lastFrameTime = performance.now(); advanceFrame(); } else { for (let audioLayer of context.activeObject.audioLayers) { for (let i in audioLayer.sounds) { let sound = audioLayer.sounds[i]; sound.player.stop(); } } } } function advanceFrame() { context.activeObject.advanceFrame(); updateLayers(); updateUI(); if (playing) { if ( context.activeObject.currentFrameNum < context.activeObject.maxFrame - 1 ) { const now = performance.now(); const elapsedTime = now - lastFrameTime; // Calculate the time remaining for the next frame const targetTimePerFrame = 1000 / config.framerate; const timeToWait = Math.max(0, targetTimePerFrame - elapsedTime); lastFrameTime = now + timeToWait; setTimeout(advanceFrame, timeToWait); } else { playing = false; for (let audioLayer of context.activeObject.audioLayers) { for (let i in audioLayer.sounds) { let sound = audioLayer.sounds[i]; sound.player.stop(); } } } } else { updateMenu(); } } 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.6", 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) { 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", }); 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.", ); } 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", }); 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" }, ); 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.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" }, ); } } } 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) { _open(path); } } 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 deepAssign(obj) { for (const [key, value] of Object.entries(obj)) { if (typeof value === "object" && value !== null) { // Recurse for nested objects deepAssign(value); } else if (value in existing && key != "name") { // If the value is in the "existing" list, assign a UUID if (!uuidCache[value]) { uuidCache[value] = uuidv4(); // Store the generated UUID for the value } obj[key] = uuidCache[value]; // Assign the UUID to the object property } else if (key in existing) { // If the value is in the "existing" list, assign a UUID if (!uuidCache[key]) { uuidCache[key] = uuidv4(); // Store the generated UUID for the value } obj[key] = uuidCache[key]; // Assign the UUID to the object property } else if (value in uuidCache) { obj[key] = value } } } // Start the recursion with the provided object deepAssign(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() { 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() { // 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(); } 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) 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(); 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) => { let mouse = getMousePos(stage, e); root.handleMouseEvent("mousedown", mouse.x, mouse.y) mouse = context.activeObject.transformMouse(mouse); let selection; if (!context.activeObject.currentFrame?.exists) return; 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": if (context.activeObject.currentFrame.frameType != "keyframe") break; 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; context.activeAction = actions.editFrame.initialize( context.activeObject.currentFrame, ); 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]; if (!(child.idx in context.activeObject.currentFrame.keys)) 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": let line = { p1: mouse, p2: { x: mouse.x + 3000, y: mouse.y } }; // debugCurves = []; // debugPoints = []; // let epsilon = context.fillGaps; // let min_x = Infinity; // let curveB = undefined; // let point = undefined; // let regionPoints; // // First, see if there's an existing shape to change the color of // const startTime = performance.now(); // let pointShape = getShapeAtPoint( // mouse, // context.activeObject.currentFrame.shapes, // ); // const endTime = performance.now(); // console.log( // `getShapeAtPoint took ${endTime - startTime} milliseconds.`, // ); // if (pointShape) { // actions.colorShape.create(pointShape, context.fillStyle); // break; // } // // We didn't find an existing region to paintbucket, see if we can make one // const offset = context.activeObject.transformMouse({x:0, y:0}) // try { // regionPoints = floodFillRegion( // mouse, // epsilon, // offset, // config.fileWidth, // config.fileHeight, // context, // debugPoints, // debugPaintbucket, // ); // } 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, // offset, // config.fileWidth, // config.fileHeight, // context, // debugPoints, // ); // } // 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); // shape.fromPoints(points, 1); // actions.addShape.create(context.activeObject, shape, cxt); break; // Loop labels in JS! // Iterate in reverse so we paintbucket the frontmost shape shapeLoop: for ( let i = context.activeObject.currentFrame.shapes.length - 1; i >= 0; i-- ) { let shape = context.activeObject.currentFrame.shapes[i]; // for (let region of shape.regions) { let intersect_count = 0; for (let curve of shape.curves) { intersect_count += curve.intersects(line).length; } if (intersect_count % 2 == 1) { actions.colorShape.create(shape, context.fillStyle); break shapeLoop; } // } } 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; if (!context.activeObject.currentFrame?.exists) return; 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); } 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; } if (!context.activeObject.currentFrame?.exists) 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 { for (let child of context.selection) { if (!context.activeObject.currentFrame) continue; if (!context.activeObject.currentFrame.keys) continue; if (!child.idx in context.activeObject.currentFrame.keys) continue; context.activeObject.currentFrame.keys[child.idx].x += mouse.x - context.lastMouse.x; context.activeObject.currentFrame.keys[child.idx].y += mouse.y - context.lastMouse.y; } } } 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); } } for (let shape of context.activeObject.currentFrame.shapes) { 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"; 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) { 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; } } } updateLayers(); }); timeline_cvs.addEventListener("pointerup", (e) => { let mouse = getMousePos(timeline_cvs, e); 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); 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 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) { 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(); }); 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; } 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(ctx) if (context.activeObject != root) { ctx.fillStyle = "rgba(255,255,255,0.5)"; ctx.fillRect(0, 0, config.fileWidth, config.fileHeight); const transform = ctx.getTransform() context.activeObject.transformCanvas(ctx) context.activeObject.draw(ctx); 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 = []; for (let shape of context.activeObject.currentFrame.shapes) { 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() { 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: () => { _open(file); }, }); }); 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: "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: !activeKeyframe, accelerator: getShortcut("addKeyframe"), action: addKeyframe, }; 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 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, }, 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) { _open(window.openedFiles[0]) for (let i=1; i