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, invertPixels } from './utils.js'; const { writeTextFile: writeTextFile, readTextFile: readTextFile }= window.__TAURI__.fs; const { open: openFileDialog, save: saveFileDialog, message: messageDialog, confirm: confirmDialog, } = window.__TAURI__.dialog; const { documentDir, join } = window.__TAURI__.path; const { Menu, MenuItem, Submenu } = window.__TAURI__.menu ; const { getCurrentWindow } = window.__TAURI__.window; const macOS = navigator.userAgent.includes('Macintosh') let simplifyPolyline = simplify let greetInputEl; let greetMsgEl; let rootPane; let canvases = []; let mode = "draw" let minSegmentSize = 5; let maxSmoothAngle = 0.6; let undoStack = []; let redoStack = []; let layoutElements = [] let appVersion = "0.6.1-alpha" let minFileVersion = "1.0" let maxFileVersion = "2.0" let filePath = undefined let fileWidth = 1500 let fileHeight = 1000 let fileFps = 12 let playing = false let tools = { select: { icon: "/assets/select.svg", properties: {} }, transform: { icon: "/assets/transform.svg", properties: {} }, draw: { icon: "/assets/draw.svg", properties: { "lineWidth": { type: "number", label: "Line Width" }, "simplifyMode": { type: "enum", options: ["corners", "smooth"], // "auto"], label: "Line Mode" }, "fillShape": { type: "boolean", label: "Fill Shape" } } }, rectangle: { icon: "/assets/rectangle.svg", properties: {} }, ellipse: { icon: "assets/ellipse.svg", properties: {} }, paint_bucket: { icon: "/assets/paint_bucket.svg", properties: {} } } let mouseEvent; let context = { mouseDown: false, swatches: [ "#000000", "#FFFFFF", "#FF0000", "#FFFF00", "#00FF00", "#00FFFF", "#0000FF", "#FF00FF", ], lineWidth: 5, simplifyMode: "smooth", fillShape: true, strokeShape: true, dragging: false, selectionRect: undefined, selection: [], } let config = { shortcuts: { playAnimation: " ", // undo: "+z" undo: "z", redo: "Z", new: "n", save: "s", saveAs: "S", open: "o", quit: "q", } } // 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) => { redoStack.length = 0; // Clear redo stack let serializableCurves = [] for (let curve of shape.curves) { serializableCurves.push({ points: curve.points, color: curve.color }) } let action = { parent: parent.idx, curves: serializableCurves, startx: shape.startx, starty: shape.starty, uuid: uuidv4() } undoStack.push({name: "addShape", action: action}) actions.addShape.execute(action) }, execute: (action) => { let object = pointerList[action.parent] let curvesList = action.curves let shape = new Shape(action.startx, action.starty, context, 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)) } shape.update() object.addShape(shape) }, rollback: (action) => { let object = pointerList[action.parent] let shape = pointerList[action.uuid] object.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() }, 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() } }, colorRegion: { create: (region, color) => { redoStack.length = 0; // Clear redo stack let action = { region: region.idx, oldColor: region.fillStyle, newColor: color } undoStack.push({name: "colorRegion", action: action}) actions.colorRegion.execute(action) }, execute: (action) => { let region = pointerList[action.region] region.fillStyle = action.newColor }, rollback: (action) => { let region = pointerList[action.region] region.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) }, execute: (action) => { let imageObject = new GraphicsObject(action.objectUuid) // let img = pointerList[action.img] let img = new Image(); 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.regions[0].fillImage = img imageShape.regions[0].filled = true imageObject.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.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) } } }, editFrame: { create: (frame) => { redoStack.length = 0; // Clear redo stack let action = { newState: structuredClone(frame.keys), oldState: startProps[frame.idx], frame: frame.idx } undoStack.push({name: "editFrame", action: action}) actions.editFrame.execute(action) }, execute: (action) => { let frame = pointerList[action.frame] console.log(pointerList) console.log(action.frame) frame.keys = structuredClone(action.newState) }, rollback: (action) => { let frame = pointerList[action.frame] frame.keys = structuredClone(action.oldState) } }, 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) }, 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].frameType != "keyframe") { formerType = layer.frames[frameNum].frameType } else { console.log("foolish") 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) }, execute: (action) => { let object = pointerList[action.object] let layer = pointerList[action.layer] let latestFrame = object.getFrame(Math.max(action.frameNum-1, 0)) let newKeyframe = new Frame("keyframe", action.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()) } if (action.frameNum >= layer.frames.length) { for (const [index, idx] of Object.entries(action.addedFrames)) { layer.frames[index] = new Frame("normal", idx) } } // layer.frames.push(newKeyframe) // } else if (layer.frames[action.frameNum].frameType != "keyframe") { layer.frames[action.frameNum] = newKeyframe // } updateLayers() }, 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] layer.frames[action.frameNum].frameType = action.formerType } updateLayers() } }, addMotionTween: { create: () => { redoStack.length = 0 let action = { } undoStack.push({name: 'addMotionTween', action: action}) actions.addMotionTween.execute(action) }, execute: (action) => { // your code here }, rollback: (action) => { // your code here } }, } 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) { var rect = canvas.getBoundingClientRect(); return { x: evt.clientX - rect.left, y: evt.clientY - rect.top }; } 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 (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 (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) { 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 moldCurveMath(curve, mouse) { let interpolated = true let p = curve.project({x: mouse.x, y: mouse.y}) let t1 = p.t; let struts = curve.getStrutPoints(t1); let m = { t: p.t, B: p, e1: struts[7], e2: struts[8] }; m.d1 = { x: m.e1.x - m.B.x, y: m.e1.y - m.B.y}; m.d2 = { x: m.e2.x - m.B.x, y: m.e2.y - m.B.y}; const S = curve.points[0], E = curve.points[curve.order], {B, t, e1, e2} = m, org = curve.getABC(t, B), nB = mouse, d1 = { x: e1.x - B.x, y: e1.y - B.y }, d2 = { x: e2.x - B.x, y: e2.y - B.y }, ne1 = { x: nB.x + d1.x, y: nB.y + d1.y }, ne2 = { x: nB.x + d2.x, y: nB.y + d2.y }, {A, C} = curve.getABC(t, nB), // The cubic case requires us to derive two control points, // which we'll do in a separate function to keep the code // at least somewhat manageable. {v1, v2, C1, C2} = deriveControlPoints(S, A, E, ne1, ne2, t); // if (interpolated) { // For the last example, we need to show what the "ideal" curve // looks like, in addition to the one we actually get when we // rely on the B we picked with the `t` value and e1/e2 points // that point B had... const ideal = getIdealisedCurve(S, nB, E); let idealCurve = new Bezier(ideal.S, ideal.C1, ideal.C2, ideal.E); // } let molded = new Bezier(S,C1,C2,E); let falloff = 100 let d = Bezier.getUtils().dist(ideal.B, p); let t2 = Math.min(falloff, d) / falloff; let iC1 = { x: (1-t2) * molded.points[1].x + t2 * idealCurve.points[1].x, y: (1-t2) * molded.points[1].y + t2 * idealCurve.points[1].y }; let iC2 = { x: (1-t2) * molded.points[2].x + t2 * idealCurve.points[2].x, y: (1-t2) * molded.points[2].y + t2 * idealCurve.points[2].y }; let interpolatedCurve = new Bezier(molded.points[0], iC1, iC2, molded.points[3]); return interpolatedCurve } 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 getIdealisedCurve(p1, p2, p3) { // This "reruns" the curve composition, but with a `t` value // that is unrelated to the actual point B we picked, instead // using whatever the appropriate `t` value would be if we were // trying to fit a circular arc, as per earlier in the section. const utils = Bezier.getUtils() const c = utils.getccenter(p1, p2, p3), d1 = utils.dist(p1, p2), d2 = utils.dist(p3, p2), t = d1 / (d1 + d2), { A, B, C, S, E } = Bezier.getABC(3, p1, p2, p3, t), angle = (Math.atan2(E.y-S.y, E.x-S.x) - Math.atan2(B.y-S.y, B.x-S.x) + utils.TAU) % utils.TAU, bc = (angle < 0 || angle > utils.PI ? -1 : 1) * utils.dist(S, E)/3, de1 = t * bc, de2 = (1-t) * bc, tangent = [ { x: B.x - 10 * (B.y-c.y), y: B.y + 10 * (B.x-c.x) }, { x: B.x + 10 * (B.y-c.y), y: B.y - 10 * (B.x-c.x) } ], tlength = utils.dist(tangent[0], tangent[1]), dx = (tangent[1].x - tangent[0].x)/tlength, dy = (tangent[1].y - tangent[0].y)/tlength, e1 = { x: B.x + de1 * dx, y: B.y + de1 * dy}, e2 = { x: B.x - de2 * dx, y: B.y - de2 * dy }, {v1, v2, C1, C2} = deriveControlPoints(S, A, E, e1, e2, t); return {A,B,C,S,E,e1,e2,v1,v2,C1,C2}; } function growBoundingBox(bboxa, bboxb) { bboxa.x.min = Math.min(bboxa.x.min, bboxb.x.min) bboxa.y.min = Math.min(bboxa.y.min, bboxb.y.min) bboxa.x.max = Math.max(bboxa.x.max, bboxb.x.max) bboxa.y.max = Math.max(bboxa.y.max, bboxb.y.max) } 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 + object.x && candidate.x.max > bbox.x.min + object.x && candidate.y.min < bbox.y.max + object.y && candidate.y.max > bbox.y.min + object.y) { return true; } else { return false; } } else { // We're checking a point if (candidate.x > bbox.x.min + object.x && candidate.x < bbox.x.max + object.x && candidate.y > bbox.y.min + object.y && candidate.y < bbox.y.max + object.y) { return true; } else { return false } } } function undo() { let action = undoStack.pop() if (action) { actions[action.name].rollback(action.action) redoStack.push(action) updateUI() } else { console.log("No actions to undo") } } function redo() { let action = redoStack.pop() if (action) { actions[action.name].execute(action.action) undoStack.push(action) updateUI() } else { console.log("No actions to redo") } } class Frame { constructor(frameType="normal", uuid=undefined) { this.keys = {} this.shapes = [] this.frameType = frameType if (!uuid) { this.idx = uuidv4() } else { this.idx = uuid } pointerList[this.idx] = this } saveState() { startProps[this.idx] = structuredClone(this.keys) } } class Layer { constructor(uuid) { this.frames = [new Frame("keyframe")] this.children = [] if (!uuid) { this.idx = uuidv4() } else { this.idx = uuid } pointerList[this.idx] = this } } class Shape { constructor(startx, starty, context, uuid=undefined) { this.startx = startx; this.starty = starty; this.curves = []; this.vertices = []; this.triangles = []; this.regions = []; 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.boundingBox = { x: {min: startx, max: starty}, y: {min: starty, max: starty} } this.quadtree = new Quadtree({x: {min: 0, max: 500}, y: {min: 0, max: 500}}, 4) if (!uuid) { this.idx = uuidv4() } else { this.idx = uuid } pointerList[this.idx] = this this.regionIdx = 0; } addCurve(curve) { 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.curves.push(curve) } clear() { this.curves = [] } copy() { let newShape = new Shape(this.startx, this.starty, {}) 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; newShape.fillImage = this.fillImage; newShape.strokeStyle = this.strokeStyle; newShape.lineWidth = this.lineWidth newShape.filled = this.filled; newShape.stroked = this.stroked; return newShape } recalculateBoundingBox() { for (let curve of this.curves) { growBoundingBox(this.boundingBox, curve.bbox()) } } simplify(mode="corners") { this.quadtree.clear() // 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.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) } } let epsilon = 0.01 let newCurves = [] let intersectMap = {} for (let i=0; i= 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=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 } } 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 } 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: undefined, filled: false} 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++; } this.vertices.forEach((vertex, i) => { for (let i=0; i 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!`) } } }) } draw(context) { let ctx = context.ctx; ctx.lineWidth = this.lineWidth ctx.lineCap = "round" 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.stroked) { for (let curve of this.curves) { 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() // Debug, show curve endpoints // ctx.beginPath() // ctx.arc(curve.points[3].x,curve.points[3].y, 3, 0, 2*Math.PI) // ctx.fill() } } // Debug, show quadtree // this.quadtree.draw(ctx) } } class GraphicsObject { constructor(uuid) { this.x = 0; this.y = 0; this.rotation = 0; // in radians this.scale = 1; if (!uuid) { this.idx = uuidv4() } else { this.idx = uuid } pointerList[this.idx] = this this.currentFrameNum = 0; this.currentLayer = 0; this.layers = [new Layer(uuid+"-L1")] // this.children = [] this.shapes = [] } get activeLayer() { return this.layers[this.currentLayer] } get children() { return this.activeLayer.children } get currentFrame() { return this.getFrame(this.currentFrameNum) } getFrame(num) { if (this.activeLayer.frames[num]) { if (this.activeLayer.frames[num].frameType == "keyframe") { return this.activeLayer.frames[num] } else if (this.activeLayer.frames[num].frameType == "motion") { let frameKeys = {} const t = (num - this.activeLayer.frames[num].prevIndex) / (this.activeLayer.frames[num].nextIndex - this.activeLayer.frames[num].prevIndex); console.log(this.activeLayer.frames[num].prev) for (let key in this.activeLayer.frames[num].prev.keys) { frameKeys[key] = {} let prevKeyDict = this.activeLayer.frames[num].prev.keys[key] let nextKeyDict = this.activeLayer.frames[num].next.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.activeLayer.frames[num].frameType == "shape") { } else { for (let i=Math.min(num, this.activeLayer.frames.length-1); i>=0; i--) { if (this.activeLayer.frames[i].frameType == "keyframe") { return this.activeLayer.frames[i] } } } } else { for (let i=Math.min(num, this.activeLayer.frames.length-1); i>=0; i--) { if (this.activeLayer.frames[i].frameType == "keyframe") { return this.activeLayer.frames[i] } } } } get maxFrame() { let maxFrames = [] for (let layer of this.layers) { maxFrames.push(layer.frames.length) } return Math.max(maxFrames) } bbox() { let bbox; if (this.currentFrame.shapes.length > 0) { bbox = this.currentFrame.shapes[0].boundingBox for (let shape of this.currentFrame.shapes) { growBoundingBox(bbox, shape.boundingBox) } } if (this.children.length > 0) { if (!bbox) { bbox = this.children[0].bbox() } for (let child of this.children) { growBoundingBox(bbox, child.bbox()) } } return bbox } draw(context) { let ctx = context.ctx; ctx.translate(this.x, this.y) ctx.rotate(this.rotation) // if (this.currentFrameNum>=this.maxFrame) { // this.currentFrameNum = 0; // } for (let shape of this.currentFrame.shapes) { if (false) { invertPixels(ctx, fileWidth, fileHeight) } shape.draw(context) if (false) { invertPixels(ctx, fileWidth, fileHeight) } } for (let child of this.children) { let idx = child.idx if (idx in this.currentFrame.keys) { child.x = this.currentFrame.keys[idx].x; child.y = this.currentFrame.keys[idx].y; child.rotation = this.currentFrame.keys[idx].rotation; child.scale = this.currentFrame.keys[idx].scale; 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 = "black" ctx.beginPath() let vertexSize = 15 ctx.rect(context.activeVertex.current.point.x - vertexSize/2, context.activeVertex.current.point.y - vertexSize/2, vertexSize, vertexSize ) ctx.fill() ctx.restore() } for (let item of context.selection) { ctx.save() ctx.strokeStyle = "#00ffff" ctx.lineWidth = 1; ctx.translate(item.x, item.y) ctx.beginPath() let bbox = item.bbox() ctx.rect(bbox.x.min, bbox.y.min, bbox.x.max, bbox.y.max) 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() } } } addShape(shape) { this.currentFrame.shapes.push(shape) } addObject(object, x=0, y=0) { this.children.push(object) let idx = object.idx this.currentFrame.keys[idx] = { x: x, y: y, rotation: 0, scale: 1, } } removeShape(shape) { for (let layer of this.layers) { for (let frame of layer.frames) { let shapeIndex = frame.shapes.indexOf(shape) if (shapeIndex >= 0) { frame.shapes.splice(shapeIndex, 1) } } } } removeChild(childObject) { let idx = childObject.idx for (let layer of this.layers) { for (let frame of layer.frames) { delete frame[idx] } } this.children.splice(this.children.indexOf(childObject), 1) } saveState() { startProps[this.idx] = { x: this.x, y: this.y, rotation: this.rotation, scale: this.scale } } } let root = new GraphicsObject("root"); context.activeObject = 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("mousemove", (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("keydown", (e) => { // let shortcuts = {} // for (let shortcut of config.shortcuts) { // shortcut = shortcut.split("+") // TODO // } // console.log(e) if (e.key == config.shortcuts.playAnimation) { console.log("Spacebar pressed") playPause() } else if (e.key == config.shortcuts.undo && e.ctrlKey == true) { undo() } else if (e.key == config.shortcuts.redo && e.ctrlKey == true) { redo() } else if (e.key == config.shortcuts.new && e.ctrlKey == true) { newFile() } else if (e.key == config.shortcuts.save && e.ctrlKey == true) { save() } else if (e.key == config.shortcuts.saveAs && e.ctrlKey == true) { saveAs() } else if (e.key == config.shortcuts.open && e.ctrlKey == true) { open() } else if (e.key == config.shortcuts.quit && e.ctrlKey == true) { quit() } else if (e.key == "ArrowRight") { advanceFrame() } else if (e.key == "ArrowLeft") { decrementFrame() } }) function playPause() { playing = !playing updateUI() } function advanceFrame() { context.activeObject.currentFrameNum += 1 updateLayers() updateMenu() updateUI() } function decrementFrame() { if (context.activeObject.currentFrameNum > 0) { context.activeObject.currentFrameNum -= 1 updateLayers() updateMenu() updateUI() } } function _newFile(width, height, fps) { root = new GraphicsObject("root"); context.activeObject = root fileWidth = width fileHeight = height fileFps = fps for (let stage of document.querySelectorAll(".stage")) { stage.width = width stage.height = height stage.style.width = `${width}px` stage.style.height = `${height}px` } updateUI() } async function newFile() { if (await confirmDialog("Create a new file? Unsaved work will be lost.", {title: "New file", kind: "warning"})) { showNewFileDialog() // updateUI() } } async function _save(path) { try { const fileData = { version: "1.1", width: fileWidth, height: fileHeight, fps: fileFps, actions: undoStack } const contents = JSON.stringify(fileData ); await writeTextFile(path, contents) filePath = path 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 path = await saveFileDialog({ filters: [ { name: 'Lightningbeam files (.beam)', extensions: ['beam'], }, ], defaultPath: await join(await documentDir(), "untitled.beam") }); if (path != undefined) _save(path); } async function open() { const path = await openFileDialog({ multiple: false, directory: false, filters: [ { name: 'Lightningbeam files (.beam)', extensions: ['beam'], }, ], defaultPath: await documentDir(), }); if (path) { 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) { _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'}) return } actions[action.name].execute(action.action) undoStack.push(action) } 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.startsWith("failed to read file as text")) { await messageDialog(`Could not parse ${path}, is it actually a Lightningbeam file?`, { title: 'Error', kind: 'error' }) } } } } async function quit() { if (undoStack.length) { if (await confirmDialog("Are you sure you want to quit?", {title: 'Really quit?', kind: "warning"})) { getCurrentWindow().close() } } else { getCurrentWindow().close() } } function addFrame() { if (context.activeObject.currentFrameNum >= context.activeObject.activeLayer.frames.length) { actions.addFrame.create() } } function addKeyframe() { console.log(context.activeObject.currentFrameNum) actions.addKeyframe.create() } function addMotionTween() { let frames = context.activeObject.activeLayer.frames let currentFrame = context.activeObject.currentFrameNum let {lastKeyframeBefore, firstKeyframeAfter} = getKeyframesSurrounding(frames, currentFrame) if ((lastKeyframeBefore != undefined) && (firstKeyframeAfter != undefined)) { for (let i=lastKeyframeBefore + 1; i { e.preventDefault() let mouse = getMousePos(stage, e) const imageTypes = ['image/png', 'image/gif', 'image/avif', 'image/jpeg', 'image/svg+xml', 'image/webp' ]; 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 // console.log(imgsrc) // img.onload = function() { 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); }; } i++; } } } else { } }) stage.addEventListener("dragover", (e) => { e.preventDefault() }) canvases.push(stage) scroller.appendChild(stage) stage.addEventListener("mousedown", (e) => { let mouse = getMousePos(stage, e) switch (mode) { case "rectangle": case "draw": context.mouseDown = true context.activeShape = new Shape(mouse.x, mouse.y, context, true, true) context.lastMouse = mouse break; case "select": let 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} } console.log("gonna move this") } 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} } console.log("gonna move this") } 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.activeObject.currentFrame.saveState() break } } } if (!context.dragging) { // Have to iterate in reverse order to grab the frontmost object when two overlap for (let i=context.activeObject.children.length-1; i>=0; i--) { child = context.activeObject.children[i] // let bbox = child.bbox() if (hitTest(mouse, child)) { if (context.selection.indexOf(child) != -1) { // dragging = true } child.saveState() context.selection = [child] context.dragging = true selected = true context.activeObject.currentFrame.saveState() break } } if (!selected) { context.selection = [] context.selectionRect = {x1: mouse.x, x2: mouse.x, y1: mouse.y, y2:mouse.y} } } } } break; case "paint_bucket": let line = {p1: mouse, p2: {x: mouse.x + 3000, y: mouse.y}} for (let shape of context.activeObject.currentFrame.shapes) { for (let region of shape.regions) { let intersect_count = 0; for (let curve of region.curves) { intersect_count += curve.intersects(line).length } if (intersect_count%2==1) { // region.fillStyle = context.fillStyle actions.colorRegion.create(region, context.fillStyle) } } } break; default: break; } context.lastMouse = mouse updateUI() }) stage.addEventListener("mouseup", (e) => { context.mouseDown = false context.dragging = false context.selectionRect = undefined let mouse = getMousePos(stage, e) 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": actions.addShape.create(context.activeObject, context.activeShape) context.activeShape = undefined break; case "select": 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.editFrame.create(context.activeObject.currentFrame) } break; default: break; } context.lastMouse = mouse context.activeCurve = undefined updateUI() }) stage.addEventListener("mousemove", (e) => { let mouse = getMousePos(stage, e) switch (mode) { case "draw": 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": 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 "select": 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) { 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 = [] for (let child of context.activeObject.children) { if (hitTest(regionToBbox(context.selectionRect), child)) { context.selection.push(child) } } } 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; default: break; } updateUI() }) return scroller } 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 console.log(tool) }) } let tools_break = document.createElement("div") tools_break.className = "horiz_break" tools_scroller.appendChild(tools_break) let fillColor = document.createElement("input") let strokeColor = document.createElement("input") fillColor.className = "color-field" strokeColor.className = "color-field" fillColor.value = "#ffffff" strokeColor.value = "#000000" context.fillStyle = fillColor.value context.strokeStyle = strokeColor.value fillColor.addEventListener('click', e => { Coloris({ el: ".color-field", selectInput: true, focusInput: true, theme: 'default', swatches: context.swatches, defaultColor: '#ffffff', onChange: (color) => { context.fillStyle = color; } }) }) strokeColor.addEventListener('click', e => { Coloris({ el: ".color-field", selectInput: true, focusInput: true, theme: 'default', swatches: context.swatches, defaultColor: '#000000', onChange: (color) => { context.strokeStyle = color; } }) }) // 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 container = document.createElement("div") let layerspanel = document.createElement("div") let framescontainer = document.createElement("div") container.classList.add("horizontal-grid") container.classList.add("layers-container") layerspanel.className = "layers" framescontainer.className = "frames-container" container.appendChild(layerspanel) container.appendChild(framescontainer) layoutElements.push(container) container.setAttribute("lb-percent", 20) return container } function infopanel() { let panel = document.createElement("div") panel.className = "infopanel" let input; let label; let span; // for (let i=0; i<10; i++) { 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.value = getProperty(context, property) break; case "enum": input = document.createElement("select") input.className = "infopanel-input" let optionEl; for (let option of prop.options) { optionEl = document.createElement("option") optionEl.value = option optionEl.innerText = option input.appendChild(optionEl) } input.value = getProperty(context, property) break; case "boolean": input = document.createElement("input") input.className = "infopanel-input" input.type = "checkbox" input.checked = getProperty(context, property) break; } input.addEventListener("input", (e) => { switch (prop.type) { case "number": if (!isNaN(e.target.value) && e.target.value > 0) { setProperty(context, property, e.target.value) } break; case "enum": if (prop.options.indexOf(e.target.value) >= 0) { setProperty(context, property, e.target.value) } break; case "boolean": setProperty(context, property, e.target.checked) } }) label.appendChild(span) label.appendChild(input) panel.appendChild(label) } return panel } createNewFileDialog(_newFile); showNewFileDialog() 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('mousedown', function(event) { // Check if the clicked element is the parent itself and not a child element if (event.target === event.currentTarget) { event.currentTarget.setAttribute("dragging", true) event.currentTarget.style.userSelect = 'none'; rootPane.style.userSelect = "none"; } else { event.currentTarget.setAttribute("dragging", false) } }); div.addEventListener('mousemove', 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() console.log(frac); // Ensure the fraction is between 0 and 1 } }); div.addEventListener('mouseup', (event) => { console.log("mouseup") event.currentTarget.setAttribute("dragging", false) event.currentTarget.style.userSelect = 'auto'; }) Coloris({el: ".color-field"}) 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() { for (let canvas of canvases) { let ctx = canvas.getContext("2d") ctx.reset(); ctx.fillStyle = "white" ctx.fillRect(0,0,canvas.width,canvas.height) context.ctx = ctx; root.draw(context) if (context.activeShape) { context.activeShape.draw(context) } } if (playing) { setTimeout(advanceFrame, 1000/fileFps) } } function updateLayers() { console.log(document.querySelectorAll(".layers-container")) 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 = "" console.log(context.activeObject) for (let layer of context.activeObject.layers) { let layerHeader = document.createElement("div") layerHeader.className = "layer-header" layerspanel.appendChild(layerHeader) let layerTrack = document.createElement("div") layerTrack.className = "layer-track" framescontainer.appendChild(layerTrack) layerTrack.addEventListener("click", (e) => { console.log(layerTrack.getBoundingClientRect()) let mouse = getMousePos(layerTrack, e) let frameNum = parseInt(mouse.x/25) context.activeObject.currentFrameNum = frameNum console.log(context.activeObject ) updateLayers() updateMenu() updateUI() }) 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) } } } } async function updateMenu() { let activeFrame; let activeKeyframe; let newFrameMenuItem; let newKeyframeMenuItem; let deleteFrameMenuItem; activeKeyframe = false if (context.activeObject.activeLayer.frames[context.activeObject.currentFrameNum]) { activeFrame = true if (context.activeObject.activeLayer.frames[context.activeObject.currentFrameNum].frameType=="keyframe") { activeKeyframe = true } } else { activeFrame = false } const fileSubmenu = await Submenu.new({ text: 'File', items: [ { text: 'New file...', enabled: true, action: newFile, }, { text: 'Save', enabled: true, action: save, }, { text: 'Save As...', enabled: true, action: saveAs, }, { text: 'Open File...', enabled: true, action: open, }, { text: 'Quit', enabled: true, action: quit, }, ] }) const editSubmenu = await Submenu.new({ text: "Edit", items: [ { text: "Undo", enabled: true, action: undo }, { text: "Redo", enabled: true, action: redo }, { text: "Cut", enabled: true, action: () => {} }, { text: "Copy", enabled: true, action: () => {} }, { text: "Paste", enabled: true, action: () => {} }, ] }); newFrameMenuItem = { text: "New Frame", enabled: !activeFrame, action: addFrame } newKeyframeMenuItem = { text: "New Keyframe", enabled: !activeKeyframe, action: addKeyframe } deleteFrameMenuItem = { text: "Delete Frame", enabled: activeFrame, action: () => {} } const timelineSubmenu = await Submenu.new({ text: "Timeline", items: [ newFrameMenuItem, newKeyframeMenuItem, deleteFrameMenuItem, { text: "Add Motion Tween", enabled: activeFrame && (!activeKeyframe), action: addMotionTween }, { text: "Return to start", enabled: false, action: () => {} }, { text: "Play", enabled: false, action: () => {} }, ] }); const viewSubmenu = await Submenu.new({ text: "View", items: [ { text: "Zoom In", enabled: false, action: () => {} }, { text: "Zoom Out", enabled: false, action: () => {} }, ] }); const helpSubmenu = await Submenu.new({ text: "Help", items: [ { text: "About...", enabled: true, action: () => { messageDialog(`Lightningbeam version ${appVersion}\nDeveloped by Skyler Lehmkuhl`, {title: 'About', kind: "info"} ) } } ] }); const menu = await Menu.new({ items: [fileSubmenu, editSubmenu, timelineSubmenu, viewSubmenu, helpSubmenu], }) 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 }, }