Add outliner and do work on importing from .beam

This commit is contained in:
Skyler Lehmkuhl 2024-12-28 20:43:54 -05:00
parent d6a1ecb18c
commit 8a647c1d3d
4 changed files with 552 additions and 59 deletions

View File

@ -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;
}

View File

@ -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 ) {

View File

@ -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
}

View File

@ -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
};