diff --git a/src/bezier.js b/src/bezier.js index 363e602..637cf3a 100644 --- a/src/bezier.js +++ b/src/bezier.js @@ -1108,6 +1108,16 @@ class Bezier { return new Bezier(S, nc1, nc2, E); } + static fromJSON(json) { + return new Bezier(...json) + } + toJSON() { + return [this.points[0].x, this.points[0].y, + this.points[1].x, this.points[1].y, + this.points[2].x, this.points[2].y, + this.points[3].x, this.points[3].y] + } + static getUtils() { return utils; } diff --git a/src/main.js b/src/main.js index b3dd085..877e31c 100644 --- a/src/main.js +++ b/src/main.js @@ -3,8 +3,8 @@ 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, invertPixels, lerpColor, lerp, camelToWords, generateWaveform, floodFillRegion, getShapeAtPoint, hslToRgb, drawCheckerboardBackground, hexToHsl, hsvToRgb, hexToHsv, rgbToHex, clamp, drawBorderedRect, drawCenteredText, drawHorizontallyCenteredText, deepMerge, getPointNearBox, arraysAreEqual } from './utils.js'; -import { backgroundColor, darkMode, foregroundColor, frameWidth, gutterHeight, highlight, iconSize, labelColor, layerHeight, layerWidth, scrubberColor, shade, shadow } from './styles.js'; +import { titleCase, getMousePositionFraction, getKeyframesSurrounding, invertPixels, lerpColor, lerp, camelToWords, generateWaveform, floodFillRegion, getShapeAtPoint, hslToRgb, drawCheckerboardBackground, hexToHsl, hsvToRgb, hexToHsv, rgbToHex, clamp, drawBorderedRect, drawCenteredText, drawHorizontallyCenteredText, deepMerge, getPointNearBox, arraysAreEqual, drawRegularPolygon, getFileExtension, createModal, deeploop } 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'; const { writeTextFile: writeTextFile, readTextFile: readTextFile, writeFile: writeFile, readFile: readFile }= window.__TAURI__.fs; const { @@ -266,7 +266,8 @@ let config = { fileHeight: 600, framerate: 24, recentFiles: [], - scrollSpeed: 1 + scrollSpeed: 1, + debug: false } function getShortcut(shortcut) { @@ -1419,22 +1420,6 @@ function selectVertex(context, mouse) { } } -// function moldCurve(curve, mouse, oldmouse) { -// let diff = {x: mouse.x - oldmouse.x, y: mouse.y - oldmouse.y} -// let p = curve.project(mouse) -// let min_influence = 0.1 -// const CP1 = { -// x: curve.points[1].x + diff.x*(1-p.t)*2, -// y: curve.points[1].y + diff.y*(1-p.t)*2 -// } -// const CP2 = { -// x: curve.points[2].x + diff.x*(p.t)*2, -// y: curve.points[2].y + diff.y*(p.t)*2 -// } -// return new Bezier(curve.points[0], CP1, CP2, curve.points[3]) -// // return curve -// } - 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); @@ -1462,7 +1447,7 @@ function moldCurve(curve, mouse, oldMouse, epsilon = 0.01) { }; const derivativeP2 = { x: (offset2.x - projection.x) / epsilon, - y: (offset2.y - projection.y) / epsilon + y: (offset2.y - projection.y) / epsilon }; // Step 5: Use the derivatives to move the projected point to the mouse @@ -1595,6 +1580,27 @@ class Frame { } return newFrame } + static fromJSON(json) { + const frame = new Frame(json.frameType, json.idx) + frame.keys = json.keys + for (let i in json.shapes) { + const shape = json.shapes[i] + frame.shapes.push(Shape.fromJSON(shape)) + } + + return frame + } + toJSON() { + const json = {} + json.frameType = this.frameType + json.idx = this.idx + json.keys = this.keys + json.shapes = [] + for (let shape of this.shapes) { + json.shapes.push(shape.toJSON()) + } + return json + } addShape(shape, sendToBack) { if (sendToBack) { this.shapes.unshift(shape) @@ -1648,6 +1654,39 @@ class Layer { 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] + layer.frames.push(Frame.fromJSON(frame)) + } + layer.visible = json.visible + layer.audible = json.audible + + return layer + } + toJSON() { + const json = {} + json.idx = this.idx + json.children = [] + for (let child of this.children) { + json.children.push(child.toJSON()) + } + json.name = this.name + json.frames = [] + for (let frame of this.frames) { + json.frames.push(frame.toJSON()) + } + json.visible = this.visible + json.audible = this.audible + return json + } getFrame(num) { if (this.frames[num]) { if (this.frames[num].frameType == "keyframe") { @@ -1858,10 +1897,10 @@ class Layer { 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) - })) + this.track = new Tone.Part(((time, sound) => { + console.log(this.sounds[sound]) + this.sounds[sound].player.start(time) + })) if (!uuid) { this.idx = uuidv4() } else { @@ -1874,6 +1913,22 @@ class AudioLayer { } this.audible = true } + static fromJSON(json) { + const audioLayer = new AudioLayer(json.idx, json.name) + // TODO: load audiolayer from json + audioLayer.sounds = {} + audioLayer.audible = json.audible + return audioLayer + } + toJSON() { + const json = {} + // TODO: build json from audiolayer + json.sounds = {} + json.audible = this.audible + 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) { @@ -2017,6 +2072,63 @@ class Shape extends BaseShape { this.regionIdx = 0; this.inProgress = true } + static fromJSON(json) { + const shape = new Shape(json.startx, json.starty, { + fillStyle: json.fillStyle, + fillImage: json.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() { + const json = {} + json.startx = this.startx + json.starty = this.starty + json.fillStyle = this.fillStyle + json.fillImage = this.fillImage + json.strokeStyle = this.fillStyle + json.lineWidth = this.lineWidth + json.filled = this.filled + json.stroked = this.stroked + json.idx = this.idx + json.shapeId = this.shapeId + json.curves = [] + for (let curve of this.curves) { + json.curves.push(curve.toJSON()) + } + json.regions = [] + for (let region of this.regions) { + const curves = [] + for (let curve of region.curves) { + curves.push(curve.toJSON()) + } + 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 @@ -2320,6 +2432,46 @@ class GraphicsObject { this.shapes = [] } + 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.layers = [] + 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() { + const json = {} + json.x = this.x + json.y = this.y + json.rotation = this.rotation + json.scale_x = this.scale_x + json.scale_y = this.scale_y + 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()) + } + json.audioLayers = [] + for (let audioLayer of this.audioLayers) { + json.audioLayers.push(audioLayer.toJSON()) + } + return json + } get activeLayer() { return this.layers[this.currentLayer] } @@ -2815,11 +2967,7 @@ function advanceFrame() { // Calculate the time remaining for the next frame const targetTimePerFrame = 1000 / config.framerate; - const timeToWait = Math.max(0, targetTimePerFrame - elapsedTime); // Ensure no negative timeout - // const timeToWait = 1000 / config.framerate - console.log(timeToWait) - - // Update lastFrameTime to the current time + const timeToWait = Math.max(0, targetTimePerFrame - elapsedTime); lastFrameTime = now + timeToWait; setTimeout(advanceFrame, timeToWait) } else { @@ -2843,7 +2991,7 @@ function decrementFrame() { updateUI() } -function _newFile(width, height, fps) { +function _newFile(width, height, fps, context=context) { root = new GraphicsObject("root"); context.objectStack = [root] config.fileWidth = width @@ -2866,15 +3014,28 @@ async function newFile() { async function _save(path) { try { + function replacer(key, value) { + if (key === 'parent') { + return undefined; // Avoid circular references + } + return value; + } const fileData = { - version: "1.4", + version: "1.5", width: config.fileWidth, height: config.fileHeight, fps: config.framerate, - actions: undoStack + 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) } - const contents = JSON.stringify(fileData); - await writeTextFile(path, contents) filePath = path addRecentFile(path) lastSaveIndex = undoStack.length; @@ -2907,7 +3068,7 @@ async function saveAs() { if (path != undefined) _save(path); } -async function _open(path) { +async function _open(path, returnJson=false) { closeDialog() try { const contents = await readTextFile(path) @@ -2918,26 +3079,33 @@ async function _open(path) { } if (file.version >= minFileVersion) { if (file.version < maxFileVersion) { - _newFile(file.width, file.height, file.fps) - if (file.actions == undefined) { - await messageDialog("File has no content!", {title: "Parse error", kind: 'error'}) - return - } - 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'}) + 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 } - console.log(action.name) - await actions[action.name].execute(action.action) - undoStack.push(action) + 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) + await actions[action.name].execute(action.action) + undoStack.push(action) + } + 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() } - 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' }); } @@ -2948,8 +3116,10 @@ async function _open(path) { console.log(e ) if (e instanceof SyntaxError) { await messageDialog(`Could not parse ${path}, ${e.message}`, { title: 'Error', kind: 'error' }) - } else if (e.startsWith("failed to read file as text")) { + } 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) } } } @@ -2991,6 +3161,10 @@ async function importFile() { name: 'Audio files', extensions: ['mp3'], }, + { + name: 'Lightningbeam files', + extensions: ['beam'], + }, ], defaultPath: await documentDir(), title: "Import File" @@ -3020,11 +3194,28 @@ async function importFile() { ]; if (path) { const filename = await basename(path) - const {dataURL, mimeType} = await convertToDataURL(path, imageMimeTypes.concat(audioMimeTypes)); - if (imageMimeTypes.indexOf(mimeType) != -1) { - actions.addImageObject.create(50, 50, dataURL, 0, context.activeObject) + if (getFileExtension(filename)=="beam") { + function reassignIdxs(json) { + deeploop(json, (key, item) => { + if (item.idx in pointerList) { + item.idx = uuidv4() + } + }) + } + + + const json = await _open(path, true) + if (json==undefined) return; + reassignIdxs(json) + createModal(outliner, json) + updateOutliner() } else { - actions.addAudio.create(dataURL, context.activeObject, filename) + 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) + } } } @@ -4361,6 +4552,108 @@ function infopanel() { return panel } +function outliner(object=undefined) { + let outliner = document.createElement("canvas") + outliner.className = "outliner" + if (object==undefined) { + outliner.object = root + } else { + outliner.object = object + } + + let lastResizeTime = 0; + const throttleIntervalMs = 20; + + function updateTimelineCanvasSize() { + const canvasStyles = window.getComputedStyle(outliner); + + outliner.width = parseInt(canvasStyles.width); + outliner.height = parseInt(canvasStyles.height); + updateOutliner() + } + + // 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 + } + 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); @@ -5177,6 +5470,68 @@ function updateInfopanel() { } } +function updateOutliner() { + 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*.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() + } +} + async function updateMenu() { let activeFrame; let activeKeyframe; @@ -5184,6 +5539,9 @@ async function updateMenu() { let newKeyframeMenuItem; let deleteFrameMenuItem; + // Move this + updateOutliner() + let recentFilesList = [] config.recentFiles.forEach((file) => { recentFilesList.push({ @@ -5483,6 +5841,10 @@ const panes = { name: "infopanel", func: infopanel }, + outlineer: { + name: "outliner", + func: outliner + } } function _arrayBufferToBase64( buffer ) { diff --git a/src/styles.js b/src/styles.js index 48db884..30f9245 100644 --- a/src/styles.js +++ b/src/styles.js @@ -12,6 +12,9 @@ const gutterHeight = 15 const scrubberColor = "#cc2222" const labelColor = darkMode ? "white" : "black" + +const triangleSize = 5; + export { darkMode, backgroundColor, @@ -25,5 +28,6 @@ export { frameWidth, gutterHeight, scrubberColor, - labelColor + labelColor, + triangleSize } \ No newline at end of file diff --git a/src/utils.js b/src/utils.js index 48aaf1e..cf95b04 100644 --- a/src/utils.js +++ b/src/utils.js @@ -600,6 +600,23 @@ function drawHorizontallyCenteredText(ctx, text, x, y, height) { ctx.fillText(text, x, centerY); } +function drawRegularPolygon(ctx, x, y, radius, sides, color, rotate = 0) { + ctx.beginPath(); + + // First point, adding rotation to the angle + ctx.moveTo(x + radius * Math.cos(0 + rotate), y + radius * Math.sin(0 + rotate)); + + // Draw the rest of the sides, adding the rotation to each angle + for (let i = 1; i <= sides; i++) { + let angle = (i * 2 * Math.PI) / sides + rotate; // Add rotation to the angle + ctx.lineTo(x + radius * Math.cos(angle), y + radius * Math.sin(angle)); + } + + ctx.closePath(); + ctx.fillStyle = color; + ctx.fill(); +} + function deepMerge(target, source) { // If either target or source is not an object, return source (base case) if (typeof target !== 'object' || target === null) { @@ -666,6 +683,102 @@ function arraysAreEqual(arr1, arr2) { } } +function getFileExtension(filename) { + const dotIndex = filename.lastIndexOf('.'); // Find the last period in the filename + if (dotIndex === -1) return ''; // No extension found (no dot in filename) + return filename.substring(dotIndex + 1); // Extract the extension +} + +function createModal(contentFunction, arg) { + // Create the modal overlay + const modalOverlay = document.createElement('div'); + modalOverlay.style.position = 'fixed'; + modalOverlay.style.top = 0; + modalOverlay.style.left = 0; + modalOverlay.style.width = '100%'; + modalOverlay.style.height = '100%'; + modalOverlay.style.backgroundColor = 'rgba(0, 0, 0, 0.7)'; + modalOverlay.style.zIndex = 1000; + modalOverlay.style.display = 'flex'; + modalOverlay.style.alignItems = 'center'; + modalOverlay.style.justifyContent = 'center'; + + // Create the modal container + const modalContainer = document.createElement('div'); + modalContainer.style.backgroundColor = 'white'; + modalContainer.style.padding = '20px'; + modalContainer.style.borderRadius = '8px'; + modalContainer.style.maxWidth = '80%'; + modalContainer.style.maxHeight = '80%'; + modalContainer.style.overflowY = 'auto'; + + const modalContent = contentFunction(arg); + modalContainer.appendChild(modalContent); + + // Create Ok and Cancel buttons + const buttonContainer = document.createElement('div'); + buttonContainer.style.display = 'flex'; + buttonContainer.style.justifyContent = 'space-between'; + buttonContainer.style.marginTop = '20px'; + + const okButton = document.createElement('button'); + okButton.innerText = 'Ok'; + okButton.style.padding = '10px 20px'; + okButton.style.fontSize = '16px'; + okButton.style.cursor = 'pointer'; + okButton.style.backgroundColor = '#4CAF50'; + okButton.style.color = 'white'; + okButton.style.border = 'none'; + okButton.style.borderRadius = '4px'; + + const cancelButton = document.createElement('button'); + cancelButton.innerText = 'Cancel'; + cancelButton.style.padding = '10px 20px'; + cancelButton.style.fontSize = '16px'; + cancelButton.style.cursor = 'pointer'; + cancelButton.style.backgroundColor = '#f44336'; + cancelButton.style.color = 'white'; + cancelButton.style.border = 'none'; + cancelButton.style.borderRadius = '4px'; + + // Add button events + okButton.addEventListener('click', () => { + modalOverlay.remove(); // Close modal on Ok + console.log(modalContent.active) + // You can add additional action here if needed + }); + + cancelButton.addEventListener('click', () => { + modalOverlay.remove(); // Close modal on Cancel + }); + + // Append buttons to the container + buttonContainer.appendChild(okButton); + buttonContainer.appendChild(cancelButton); + + // Add button container to the modal + modalContainer.appendChild(buttonContainer); + + // Add the modal container to the overlay + modalOverlay.appendChild(modalContainer); + + // Append the modal overlay to the body + document.body.appendChild(modalOverlay); +} + +function deeploop(obj, callback) { + // Loop through all the entries in the object + for (const [key, value] of Object.entries(obj)) { + // Call the callback with the key and value + callback(key, value); + + // If the value is an object, recursively call deeploop on it + if (typeof value === 'object' && value !== null) { + deeploop(value, callback); + } + } +} + export { titleCase, getMousePositionFraction, @@ -687,7 +800,11 @@ export { drawBorderedRect, drawCenteredText, drawHorizontallyCenteredText, + drawRegularPolygon, deepMerge, getPointNearBox, - arraysAreEqual + arraysAreEqual, + getFileExtension, + createModal, + deeploop }; \ No newline at end of file