' + ("" +
+ settings.a11y.open + "") + ("" +
+ settings.a11y.swatch + "");
+
+ // Append the color picker to the DOM
+ document.body.appendChild(picker);
+
+ // Reference the UI elements
+ colorArea = getEl('clr-color-area');
+ colorMarker = getEl('clr-color-marker');
+ clearButton = getEl('clr-clear');
+ closeButton = getEl('clr-close');
+ colorPreview = getEl('clr-color-preview');
+ colorValue = getEl('clr-color-value');
+ hueSlider = getEl('clr-hue-slider');
+ hueMarker = getEl('clr-hue-marker');
+ alphaSlider = getEl('clr-alpha-slider');
+ alphaMarker = getEl('clr-alpha-marker');
+
+ // Bind the picker to the default selector
+ bindFields(settings.el);
+ wrapFields(settings.el);
+
+ addListener(picker, 'mousedown', function (event) {
+ picker.classList.remove('clr-keyboard-nav');
+ event.stopPropagation();
+ });
+
+ addListener(colorArea, 'mousedown', function (event) {
+ addListener(document, 'mousemove', moveMarker);
+ });
+
+ addListener(colorArea, 'contextmenu', function (event) {
+ event.preventDefault();
+ });
+
+ addListener(colorArea, 'touchstart', function (event) {
+ document.addEventListener('touchmove', moveMarker, { passive: false });
+ });
+
+ addListener(colorMarker, 'mousedown', function (event) {
+ addListener(document, 'mousemove', moveMarker);
+ });
+
+ addListener(colorMarker, 'touchstart', function (event) {
+ document.addEventListener('touchmove', moveMarker, { passive: false });
+ });
+
+ addListener(colorValue, 'change', function (event) {
+ var value = colorValue.value;
+
+ if (currentEl || settings.inline) {
+ var color = value === '' ? value : setColorFromStr(value);
+ pickColor(color);
+ }
+ });
+
+ addListener(clearButton, 'click', function (event) {
+ pickColor('');
+ closePicker();
+ });
+
+ addListener(closeButton, 'click', function (event) {
+ pickColor();
+ closePicker();
+ });
+
+ addListener(getEl('clr-format'), 'click', '.clr-format input', function (event) {
+ currentFormat = event.target.value;
+ updateColor();
+ pickColor();
+ });
+
+ addListener(picker, 'click', '.clr-swatches button', function (event) {
+ setColorFromStr(event.target.textContent);
+ pickColor();
+
+ if (settings.swatchesOnly) {
+ closePicker();
+ }
+ });
+
+ addListener(document, 'mouseup', function (event) {
+ document.removeEventListener('mousemove', moveMarker);
+ });
+
+ addListener(document, 'touchend', function (event) {
+ document.removeEventListener('touchmove', moveMarker);
+ });
+
+ addListener(document, 'mousedown', function (event) {
+ keyboardNav = false;
+ picker.classList.remove('clr-keyboard-nav');
+ closePicker();
+ });
+
+ addListener(document, 'keydown', function (event) {
+ var key = event.key;
+ var target = event.target;
+ var shiftKey = event.shiftKey;
+ var navKeys = ['Tab', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'];
+
+ if (key === 'Escape') {
+ closePicker(true);
+
+ // Display focus rings when using the keyboard
+ } else if (navKeys.includes(key)) {
+ keyboardNav = true;
+ picker.classList.add('clr-keyboard-nav');
+ }
+
+ // Trap the focus within the color picker while it's open
+ if (key === 'Tab' && target.matches('.clr-picker *')) {
+ var focusables = getFocusableElements();
+ var firstFocusable = focusables.shift();
+ var lastFocusable = focusables.pop();
+
+ if (shiftKey && target === firstFocusable) {
+ lastFocusable.focus();
+ event.preventDefault();
+ } else if (!shiftKey && target === lastFocusable) {
+ firstFocusable.focus();
+ event.preventDefault();
+ }
+ }
+ });
+
+ addListener(document, 'click', '.clr-field button', function (event) {
+ // Reset any previously set per-instance options
+ if (hasInstance) {
+ resetVirtualInstance();
+ }
+
+ // Open the color picker
+ event.target.nextElementSibling.dispatchEvent(new Event('click', { bubbles: true }));
+ });
+
+ addListener(colorMarker, 'keydown', function (event) {
+ var movements = {
+ ArrowUp: [0, -1],
+ ArrowDown: [0, 1],
+ ArrowLeft: [-1, 0],
+ ArrowRight: [1, 0] };
+
+
+ if (Object.keys(movements).includes(event.key)) {
+ moveMarkerOnKeydown.apply(void 0, movements[event.key]);
+ event.preventDefault();
+ }
+ });
+
+ addListener(colorArea, 'click', moveMarker);
+ addListener(hueSlider, 'input', setHue);
+ addListener(alphaSlider, 'input', setAlpha);
+ }
+
+ /**
+ * Return a list of focusable elements within the color picker.
+ * @return {array} The list of focusable DOM elemnts.
+ */
+ function getFocusableElements() {
+ var controls = Array.from(picker.querySelectorAll('input, button'));
+ var focusables = controls.filter(function (node) {return !!node.offsetWidth;});
+
+ return focusables;
+ }
+
+ /**
+ * Shortcut for getElementById to optimize the minified JS.
+ * @param {string} id The element id.
+ * @return {object} The DOM element with the provided id.
+ */
+ function getEl(id) {
+ return document.getElementById(id);
+ }
+
+ /**
+ * Shortcut for addEventListener to optimize the minified JS.
+ * @param {object} context The context to which the listener is attached.
+ * @param {string} type Event type.
+ * @param {(string|function)} selector Event target if delegation is used, event handler if not.
+ * @param {function} [fn] Event handler if delegation is used.
+ */
+ function addListener(context, type, selector, fn) {
+ var matches = Element.prototype.matches || Element.prototype.msMatchesSelector;
+
+ // Delegate event to the target of the selector
+ if (typeof selector === 'string') {
+ context.addEventListener(type, function (event) {
+ if (matches.call(event.target, selector)) {
+ fn.call(event.target, event);
+ }
+ });
+
+ // If the selector is not a string then it's a function
+ // in which case we need a regular event listener
+ } else {
+ fn = selector;
+ context.addEventListener(type, fn);
+ }
+ }
+
+ /**
+ * Call a function only when the DOM is ready.
+ * @param {function} fn The function to call.
+ * @param {array} [args] Arguments to pass to the function.
+ */
+ function DOMReady(fn, args) {
+ args = args !== undefined ? args : [];
+
+ if (document.readyState !== 'loading') {
+ fn.apply(void 0, args);
+ } else {
+ document.addEventListener('DOMContentLoaded', function () {
+ fn.apply(void 0, args);
+ });
+ }
+ }
+
+ // Polyfill for Nodelist.forEach
+ if (NodeList !== undefined && NodeList.prototype && !NodeList.prototype.forEach) {
+ NodeList.prototype.forEach = Array.prototype.forEach;
+ }
+
+ // Expose the color picker to the global scope
+ window.Coloris = function () {
+ var methods = {
+ set: configure,
+ wrap: wrapFields,
+ close: closePicker,
+ setInstance: setVirtualInstance,
+ removeInstance: removeVirtualInstance,
+ updatePosition: updatePickerPosition,
+ ready: DOMReady };
+
+
+ function Coloris(options) {
+ DOMReady(function () {
+ if (options) {
+ if (typeof options === 'string') {
+ bindFields(options);
+ } else {
+ configure(options);
+ }
+ }
+ });
+ }var _loop2 = function _loop2(
+
+ key) {
+ Coloris[key] = function () {for (var _len = arguments.length, args = new Array(_len), _key2 = 0; _key2 < _len; _key2++) {args[_key2] = arguments[_key2];}
+ DOMReady(methods[key], args);
+ };};for (var key in methods) {_loop2(key);
+ }
+
+ return Coloris;
+ }();
+
+ // Init the color picker when the DOM is ready
+ DOMReady(init);
+
+})(window, document, Math);
\ No newline at end of file
diff --git a/src/d3-interpolate-path.js b/src/d3-interpolate-path.js
new file mode 100644
index 0000000..7d31297
--- /dev/null
+++ b/src/d3-interpolate-path.js
@@ -0,0 +1,782 @@
+(function (global, factory) {
+typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports) :
+typeof define === 'function' && define.amd ? define(['exports'], factory) :
+(global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global.d3 = global.d3 || {}));
+}(this, (function (exports) { 'use strict';
+
+function ownKeys(object, enumerableOnly) {
+ var keys = Object.keys(object);
+
+ if (Object.getOwnPropertySymbols) {
+ var symbols = Object.getOwnPropertySymbols(object);
+
+ if (enumerableOnly) {
+ symbols = symbols.filter(function (sym) {
+ return Object.getOwnPropertyDescriptor(object, sym).enumerable;
+ });
+ }
+
+ keys.push.apply(keys, symbols);
+ }
+
+ return keys;
+}
+
+function _objectSpread2(target) {
+ for (var i = 1; i < arguments.length; i++) {
+ var source = arguments[i] != null ? arguments[i] : {};
+
+ if (i % 2) {
+ ownKeys(Object(source), true).forEach(function (key) {
+ _defineProperty(target, key, source[key]);
+ });
+ } else if (Object.getOwnPropertyDescriptors) {
+ Object.defineProperties(target, Object.getOwnPropertyDescriptors(source));
+ } else {
+ ownKeys(Object(source)).forEach(function (key) {
+ Object.defineProperty(target, key, Object.getOwnPropertyDescriptor(source, key));
+ });
+ }
+ }
+
+ return target;
+}
+
+function _typeof(obj) {
+ "@babel/helpers - typeof";
+
+ if (typeof Symbol === "function" && typeof Symbol.iterator === "symbol") {
+ _typeof = function (obj) {
+ return typeof obj;
+ };
+ } else {
+ _typeof = function (obj) {
+ return obj && typeof Symbol === "function" && obj.constructor === Symbol && obj !== Symbol.prototype ? "symbol" : typeof obj;
+ };
+ }
+
+ return _typeof(obj);
+}
+
+function _defineProperty(obj, key, value) {
+ if (key in obj) {
+ Object.defineProperty(obj, key, {
+ value: value,
+ enumerable: true,
+ configurable: true,
+ writable: true
+ });
+ } else {
+ obj[key] = value;
+ }
+
+ return obj;
+}
+
+function _extends() {
+ _extends = Object.assign || function (target) {
+ for (var i = 1; i < arguments.length; i++) {
+ var source = arguments[i];
+
+ for (var key in source) {
+ if (Object.prototype.hasOwnProperty.call(source, key)) {
+ target[key] = source[key];
+ }
+ }
+ }
+
+ return target;
+ };
+
+ return _extends.apply(this, arguments);
+}
+
+function _unsupportedIterableToArray(o, minLen) {
+ if (!o) return;
+ if (typeof o === "string") return _arrayLikeToArray(o, minLen);
+ var n = Object.prototype.toString.call(o).slice(8, -1);
+ if (n === "Object" && o.constructor) n = o.constructor.name;
+ if (n === "Map" || n === "Set") return Array.from(o);
+ if (n === "Arguments" || /^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n)) return _arrayLikeToArray(o, minLen);
+}
+
+function _arrayLikeToArray(arr, len) {
+ if (len == null || len > arr.length) len = arr.length;
+
+ for (var i = 0, arr2 = new Array(len); i < len; i++) arr2[i] = arr[i];
+
+ return arr2;
+}
+
+function _createForOfIteratorHelper(o, allowArrayLike) {
+ var it = typeof Symbol !== "undefined" && o[Symbol.iterator] || o["@@iterator"];
+
+ if (!it) {
+ if (Array.isArray(o) || (it = _unsupportedIterableToArray(o)) || allowArrayLike && o && typeof o.length === "number") {
+ if (it) o = it;
+ var i = 0;
+
+ var F = function () {};
+
+ return {
+ s: F,
+ n: function () {
+ if (i >= o.length) return {
+ done: true
+ };
+ return {
+ done: false,
+ value: o[i++]
+ };
+ },
+ e: function (e) {
+ throw e;
+ },
+ f: F
+ };
+ }
+
+ throw new TypeError("Invalid attempt to iterate non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.");
+ }
+
+ var normalCompletion = true,
+ didErr = false,
+ err;
+ return {
+ s: function () {
+ it = it.call(o);
+ },
+ n: function () {
+ var step = it.next();
+ normalCompletion = step.done;
+ return step;
+ },
+ e: function (e) {
+ didErr = true;
+ err = e;
+ },
+ f: function () {
+ try {
+ if (!normalCompletion && it.return != null) it.return();
+ } finally {
+ if (didErr) throw err;
+ }
+ }
+ };
+}
+
+/**
+ * de Casteljau's algorithm for drawing and splitting bezier curves.
+ * Inspired by https://pomax.github.io/bezierinfo/
+ *
+ * @param {Number[][]} points Array of [x,y] points: [start, control1, control2, ..., end]
+ * The original segment to split.
+ * @param {Number} t Where to split the curve (value between [0, 1])
+ * @return {Object} An object { left, right } where left is the segment from 0..t and
+ * right is the segment from t..1.
+ */
+function decasteljau(points, t) {
+ var left = [];
+ var right = [];
+
+ function decasteljauRecurse(points, t) {
+ if (points.length === 1) {
+ left.push(points[0]);
+ right.push(points[0]);
+ } else {
+ var newPoints = Array(points.length - 1);
+
+ for (var i = 0; i < newPoints.length; i++) {
+ if (i === 0) {
+ left.push(points[0]);
+ }
+
+ if (i === newPoints.length - 1) {
+ right.push(points[i + 1]);
+ }
+
+ newPoints[i] = [(1 - t) * points[i][0] + t * points[i + 1][0], (1 - t) * points[i][1] + t * points[i + 1][1]];
+ }
+
+ decasteljauRecurse(newPoints, t);
+ }
+ }
+
+ if (points.length) {
+ decasteljauRecurse(points, t);
+ }
+
+ return {
+ left: left,
+ right: right.reverse()
+ };
+}
+/**
+ * Convert segments represented as points back into a command object
+ *
+ * @param {Number[][]} points Array of [x,y] points: [start, control1, control2, ..., end]
+ * Represents a segment
+ * @return {Object} A command object representing the segment.
+ */
+
+
+function pointsToCommand(points) {
+ var command = {};
+
+ if (points.length === 4) {
+ command.x2 = points[2][0];
+ command.y2 = points[2][1];
+ }
+
+ if (points.length >= 3) {
+ command.x1 = points[1][0];
+ command.y1 = points[1][1];
+ }
+
+ command.x = points[points.length - 1][0];
+ command.y = points[points.length - 1][1];
+
+ if (points.length === 4) {
+ // start, control1, control2, end
+ command.type = 'C';
+ } else if (points.length === 3) {
+ // start, control, end
+ command.type = 'Q';
+ } else {
+ // start, end
+ command.type = 'L';
+ }
+
+ return command;
+}
+/**
+ * Runs de Casteljau's algorithm enough times to produce the desired number of segments.
+ *
+ * @param {Number[][]} points Array of [x,y] points for de Casteljau (the initial segment to split)
+ * @param {Number} segmentCount Number of segments to split the original into
+ * @return {Number[][][]} Array of segments
+ */
+
+
+function splitCurveAsPoints(points, segmentCount) {
+ segmentCount = segmentCount || 2;
+ var segments = [];
+ var remainingCurve = points;
+ var tIncrement = 1 / segmentCount; // x-----x-----x-----x
+ // t= 0.33 0.66 1
+ // x-----o-----------x
+ // r= 0.33
+ // x-----o-----x
+ // r= 0.5 (0.33 / (1 - 0.33)) === tIncrement / (1 - (tIncrement * (i - 1))
+ // x-----x-----x-----x----x
+ // t= 0.25 0.5 0.75 1
+ // x-----o----------------x
+ // r= 0.25
+ // x-----o----------x
+ // r= 0.33 (0.25 / (1 - 0.25))
+ // x-----o----x
+ // r= 0.5 (0.25 / (1 - 0.5))
+
+ for (var i = 0; i < segmentCount - 1; i++) {
+ var tRelative = tIncrement / (1 - tIncrement * i);
+ var split = decasteljau(remainingCurve, tRelative);
+ segments.push(split.left);
+ remainingCurve = split.right;
+ } // last segment is just to the end from the last point
+
+
+ segments.push(remainingCurve);
+ return segments;
+}
+/**
+ * Convert command objects to arrays of points, run de Casteljau's algorithm on it
+ * to split into to the desired number of segments.
+ *
+ * @param {Object} commandStart The start command object
+ * @param {Object} commandEnd The end command object
+ * @param {Number} segmentCount The number of segments to create
+ * @return {Object[]} An array of commands representing the segments in sequence
+ */
+
+
+function splitCurve(commandStart, commandEnd, segmentCount) {
+ var points = [[commandStart.x, commandStart.y]];
+
+ if (commandEnd.x1 != null) {
+ points.push([commandEnd.x1, commandEnd.y1]);
+ }
+
+ if (commandEnd.x2 != null) {
+ points.push([commandEnd.x2, commandEnd.y2]);
+ }
+
+ points.push([commandEnd.x, commandEnd.y]);
+ return splitCurveAsPoints(points, segmentCount).map(pointsToCommand);
+}
+
+var commandTokenRegex = /[MLCSTQAHVZmlcstqahv]|-?[\d.e+-]+/g;
+/**
+ * List of params for each command type in a path `d` attribute
+ */
+
+var typeMap = {
+ M: ['x', 'y'],
+ L: ['x', 'y'],
+ H: ['x'],
+ V: ['y'],
+ C: ['x1', 'y1', 'x2', 'y2', 'x', 'y'],
+ S: ['x2', 'y2', 'x', 'y'],
+ Q: ['x1', 'y1', 'x', 'y'],
+ T: ['x', 'y'],
+ A: ['rx', 'ry', 'xAxisRotation', 'largeArcFlag', 'sweepFlag', 'x', 'y'],
+ Z: []
+}; // Add lower case entries too matching uppercase (e.g. 'm' == 'M')
+
+Object.keys(typeMap).forEach(function (key) {
+ typeMap[key.toLowerCase()] = typeMap[key];
+});
+
+function arrayOfLength(length, value) {
+ var array = Array(length);
+
+ for (var i = 0; i < length; i++) {
+ array[i] = value;
+ }
+
+ return array;
+}
+/**
+ * Converts a command object to a string to be used in a `d` attribute
+ * @param {Object} command A command object
+ * @return {String} The string for the `d` attribute
+ */
+
+
+function commandToString(command) {
+ return "".concat(command.type).concat(typeMap[command.type].map(function (p) {
+ return command[p];
+ }).join(','));
+}
+/**
+ * Converts command A to have the same type as command B.
+ *
+ * e.g., L0,5 -> C0,5,0,5,0,5
+ *
+ * Uses these rules:
+ * x1 <- x
+ * x2 <- x
+ * y1 <- y
+ * y2 <- y
+ * rx <- 0
+ * ry <- 0
+ * xAxisRotation <- read from B
+ * largeArcFlag <- read from B
+ * sweepflag <- read from B
+ *
+ * @param {Object} aCommand Command object from path `d` attribute
+ * @param {Object} bCommand Command object from path `d` attribute to match against
+ * @return {Object} aCommand converted to type of bCommand
+ */
+
+
+function convertToSameType(aCommand, bCommand) {
+ var conversionMap = {
+ x1: 'x',
+ y1: 'y',
+ x2: 'x',
+ y2: 'y'
+ };
+ var readFromBKeys = ['xAxisRotation', 'largeArcFlag', 'sweepFlag']; // convert (but ignore M types)
+
+ if (aCommand.type !== bCommand.type && bCommand.type.toUpperCase() !== 'M') {
+ var aConverted = {};
+ Object.keys(bCommand).forEach(function (bKey) {
+ var bValue = bCommand[bKey]; // first read from the A command
+
+ var aValue = aCommand[bKey]; // if it is one of these values, read from B no matter what
+
+ if (aValue === undefined) {
+ if (readFromBKeys.includes(bKey)) {
+ aValue = bValue;
+ } else {
+ // if it wasn't in the A command, see if an equivalent was
+ if (aValue === undefined && conversionMap[bKey]) {
+ aValue = aCommand[conversionMap[bKey]];
+ } // if it doesn't have a converted value, use 0
+
+
+ if (aValue === undefined) {
+ aValue = 0;
+ }
+ }
+ }
+
+ aConverted[bKey] = aValue;
+ }); // update the type to match B
+
+ aConverted.type = bCommand.type;
+ aCommand = aConverted;
+ }
+
+ return aCommand;
+}
+/**
+ * Interpolate between command objects commandStart and commandEnd segmentCount times.
+ * If the types are L, Q, or C then the curves are split as per de Casteljau's algorithm.
+ * Otherwise we just copy commandStart segmentCount - 1 times, finally ending with commandEnd.
+ *
+ * @param {Object} commandStart Command object at the beginning of the segment
+ * @param {Object} commandEnd Command object at the end of the segment
+ * @param {Number} segmentCount The number of segments to split this into. If only 1
+ * Then [commandEnd] is returned.
+ * @return {Object[]} Array of ~segmentCount command objects between commandStart and
+ * commandEnd. (Can be segmentCount+1 objects if commandStart is type M).
+ */
+
+
+function splitSegment(commandStart, commandEnd, segmentCount) {
+ var segments = []; // line, quadratic bezier, or cubic bezier
+
+ if (commandEnd.type === 'L' || commandEnd.type === 'Q' || commandEnd.type === 'C') {
+ segments = segments.concat(splitCurve(commandStart, commandEnd, segmentCount)); // general case - just copy the same point
+ } else {
+ var copyCommand = _extends({}, commandStart); // convert M to L
+
+
+ if (copyCommand.type === 'M') {
+ copyCommand.type = 'L';
+ }
+
+ segments = segments.concat(arrayOfLength(segmentCount - 1).map(function () {
+ return copyCommand;
+ }));
+ segments.push(commandEnd);
+ }
+
+ return segments;
+}
+/**
+ * Extends an array of commandsToExtend to the length of the referenceCommands by
+ * splitting segments until the number of commands match. Ensures all the actual
+ * points of commandsToExtend are in the extended array.
+ *
+ * @param {Object[]} commandsToExtend The command object array to extend
+ * @param {Object[]} referenceCommands The command object array to match in length
+ * @param {Function} excludeSegment a function that takes a start command object and
+ * end command object and returns true if the segment should be excluded from splitting.
+ * @return {Object[]} The extended commandsToExtend array
+ */
+
+
+function extend(commandsToExtend, referenceCommands, excludeSegment) {
+ // compute insertion points:
+ // number of segments in the path to extend
+ var numSegmentsToExtend = commandsToExtend.length - 1; // number of segments in the reference path.
+
+ var numReferenceSegments = referenceCommands.length - 1; // this value is always between [0, 1].
+
+ var segmentRatio = numSegmentsToExtend / numReferenceSegments; // create a map, mapping segments in referenceCommands to how many points
+ // should be added in that segment (should always be >= 1 since we need each
+ // point itself).
+ // 0 = segment 0-1, 1 = segment 1-2, n-1 = last vertex
+
+ var countPointsPerSegment = arrayOfLength(numReferenceSegments).reduce(function (accum, d, i) {
+ var insertIndex = Math.floor(segmentRatio * i); // handle excluding segments
+
+ if (excludeSegment && insertIndex < commandsToExtend.length - 1 && excludeSegment(commandsToExtend[insertIndex], commandsToExtend[insertIndex + 1])) {
+ // set the insertIndex to the segment that this point should be added to:
+ // round the insertIndex essentially so we split half and half on
+ // neighbouring segments. hence the segmentRatio * i < 0.5
+ var addToPriorSegment = segmentRatio * i % 1 < 0.5; // only skip segment if we already have 1 point in it (can't entirely remove a segment)
+
+ if (accum[insertIndex]) {
+ // TODO - Note this is a naive algorithm that should work for most d3-area use cases
+ // but if two adjacent segments are supposed to be skipped, this will not perform as
+ // expected. Could be updated to search for nearest segment to place the point in, but
+ // will only do that if necessary.
+ // add to the prior segment
+ if (addToPriorSegment) {
+ if (insertIndex > 0) {
+ insertIndex -= 1; // not possible to add to previous so adding to next
+ } else if (insertIndex < commandsToExtend.length - 1) {
+ insertIndex += 1;
+ } // add to next segment
+
+ } else if (insertIndex < commandsToExtend.length - 1) {
+ insertIndex += 1; // not possible to add to next so adding to previous
+ } else if (insertIndex > 0) {
+ insertIndex -= 1;
+ }
+ }
+ }
+
+ accum[insertIndex] = (accum[insertIndex] || 0) + 1;
+ return accum;
+ }, []); // extend each segment to have the correct number of points for a smooth interpolation
+
+ var extended = countPointsPerSegment.reduce(function (extended, segmentCount, i) {
+ // if last command, just add `segmentCount` number of times
+ if (i === commandsToExtend.length - 1) {
+ var lastCommandCopies = arrayOfLength(segmentCount, _extends({}, commandsToExtend[commandsToExtend.length - 1])); // convert M to L
+
+ if (lastCommandCopies[0].type === 'M') {
+ lastCommandCopies.forEach(function (d) {
+ d.type = 'L';
+ });
+ }
+
+ return extended.concat(lastCommandCopies);
+ } // otherwise, split the segment segmentCount times.
+
+
+ return extended.concat(splitSegment(commandsToExtend[i], commandsToExtend[i + 1], segmentCount));
+ }, []); // add in the very first point since splitSegment only adds in the ones after it
+
+ extended.unshift(commandsToExtend[0]);
+ return extended;
+}
+/**
+ * Takes a path `d` string and converts it into an array of command
+ * objects. Drops the `Z` character.
+ *
+ * @param {String|null} d A path `d` string
+ */
+
+
+function pathCommandsFromString(d) {
+ // split into valid tokens
+ var tokens = (d || '').match(commandTokenRegex) || [];
+ var commands = [];
+ var commandArgs;
+ var command; // iterate over each token, checking if we are at a new command
+ // by presence in the typeMap
+
+ for (var i = 0; i < tokens.length; ++i) {
+ commandArgs = typeMap[tokens[i]]; // new command found:
+
+ if (commandArgs) {
+ command = {
+ type: tokens[i]
+ }; // add each of the expected args for this command:
+
+ for (var a = 0; a < commandArgs.length; ++a) {
+ command[commandArgs[a]] = +tokens[i + a + 1];
+ } // need to increment our token index appropriately since
+ // we consumed token args
+
+
+ i += commandArgs.length;
+ commands.push(command);
+ }
+ }
+
+ return commands;
+}
+/**
+ * Interpolate from A to B by extending A and B during interpolation to have
+ * the same number of points. This allows for a smooth transition when they
+ * have a different number of points.
+ *
+ * Ignores the `Z` command in paths unless both A and B end with it.
+ *
+ * This function works directly with arrays of command objects instead of with
+ * path `d` strings (see interpolatePath for working with `d` strings).
+ *
+ * @param {Object[]} aCommandsInput Array of path commands
+ * @param {Object[]} bCommandsInput Array of path commands
+ * @param {(Function|Object)} interpolateOptions
+ * @param {Function} interpolateOptions.excludeSegment a function that takes a start command object and
+ * end command object and returns true if the segment should be excluded from splitting.
+ * @param {Boolean} interpolateOptions.snapEndsToInput a boolean indicating whether end of input should
+ * be sourced from input argument or computed.
+ * @returns {Function} Interpolation function that maps t ([0, 1]) to an array of path commands.
+ */
+
+function interpolatePathCommands(aCommandsInput, bCommandsInput, interpolateOptions) {
+ // make a copy so we don't mess with the input arrays
+ var aCommands = aCommandsInput == null ? [] : aCommandsInput.slice();
+ var bCommands = bCommandsInput == null ? [] : bCommandsInput.slice();
+
+ var _ref = _typeof(interpolateOptions) === 'object' ? interpolateOptions : {
+ excludeSegment: interpolateOptions,
+ snapEndsToInput: true
+ },
+ excludeSegment = _ref.excludeSegment,
+ snapEndsToInput = _ref.snapEndsToInput; // both input sets are empty, so we don't interpolate
+
+
+ if (!aCommands.length && !bCommands.length) {
+ return function nullInterpolator() {
+ return [];
+ };
+ } // do we add Z during interpolation? yes if both have it. (we'd expect both to have it or not)
+
+
+ var addZ = (aCommands.length === 0 || aCommands[aCommands.length - 1].type === 'Z') && (bCommands.length === 0 || bCommands[bCommands.length - 1].type === 'Z'); // we temporarily remove Z
+
+ if (aCommands.length > 0 && aCommands[aCommands.length - 1].type === 'Z') {
+ aCommands.pop();
+ }
+
+ if (bCommands.length > 0 && bCommands[bCommands.length - 1].type === 'Z') {
+ bCommands.pop();
+ } // if A is empty, treat it as if it used to contain just the first point
+ // of B. This makes it so the line extends out of from that first point.
+
+
+ if (!aCommands.length) {
+ aCommands.push(bCommands[0]); // otherwise if B is empty, treat it as if it contains the first point
+ // of A. This makes it so the line retracts into the first point.
+ } else if (!bCommands.length) {
+ bCommands.push(aCommands[0]);
+ } // extend to match equal size
+
+
+ var numPointsToExtend = Math.abs(bCommands.length - aCommands.length);
+
+ if (numPointsToExtend !== 0) {
+ // B has more points than A, so add points to A before interpolating
+ if (bCommands.length > aCommands.length) {
+ aCommands = extend(aCommands, bCommands, excludeSegment); // else if A has more points than B, add more points to B
+ } else if (bCommands.length < aCommands.length) {
+ bCommands = extend(bCommands, aCommands, excludeSegment);
+ }
+ } // commands have same length now.
+ // convert commands in A to the same type as those in B
+
+
+ aCommands = aCommands.map(function (aCommand, i) {
+ return convertToSameType(aCommand, bCommands[i]);
+ }); // create mutable interpolated command objects
+
+ var interpolatedCommands = aCommands.map(function (aCommand) {
+ return _objectSpread2({}, aCommand);
+ });
+
+ if (addZ) {
+ interpolatedCommands.push({
+ type: 'Z'
+ });
+ aCommands.push({
+ type: 'Z'
+ }); // required for when returning at t == 0
+ }
+
+ return function pathCommandInterpolator(t) {
+ // at 1 return the final value without the extensions used during interpolation
+ if (t === 1 && snapEndsToInput) {
+ return bCommandsInput == null ? [] : bCommandsInput;
+ } // work with aCommands directly since interpolatedCommands are mutated
+
+
+ if (t === 0) {
+ return aCommands;
+ } // interpolate the commands using the mutable interpolated command objs
+
+
+ for (var i = 0; i < interpolatedCommands.length; ++i) {
+ // if (interpolatedCommands[i].type === 'Z') continue;
+ var aCommand = aCommands[i];
+ var bCommand = bCommands[i];
+ var interpolatedCommand = interpolatedCommands[i];
+
+ var _iterator = _createForOfIteratorHelper(typeMap[interpolatedCommand.type]),
+ _step;
+
+ try {
+ for (_iterator.s(); !(_step = _iterator.n()).done;) {
+ var arg = _step.value;
+ interpolatedCommand[arg] = (1 - t) * aCommand[arg] + t * bCommand[arg]; // do not use floats for flags (#27), round to integer
+
+ if (arg === 'largeArcFlag' || arg === 'sweepFlag') {
+ interpolatedCommand[arg] = Math.round(interpolatedCommand[arg]);
+ }
+ }
+ } catch (err) {
+ _iterator.e(err);
+ } finally {
+ _iterator.f();
+ }
+ }
+
+ return interpolatedCommands;
+ };
+}
+/** @typedef InterpolateOptions */
+
+/**
+ * Interpolate from A to B by extending A and B during interpolation to have
+ * the same number of points. This allows for a smooth transition when they
+ * have a different number of points.
+ *
+ * Ignores the `Z` character in paths unless both A and B end with it.
+ *
+ * @param {String} a The `d` attribute for a path
+ * @param {String} b The `d` attribute for a path
+ * @param {((command1, command2) => boolean|{
+ * excludeSegment?: (command1, command2) => boolean;
+ * snapEndsToInput?: boolean
+ * })} interpolateOptions The excludeSegment function or an options object
+ * - interpolateOptions.excludeSegment a function that takes a start command object and
+ * end command object and returns true if the segment should be excluded from splitting.
+ * - interpolateOptions.snapEndsToInput a boolean indicating whether end of input should
+ * be sourced from input argument or computed.
+ * @returns {Function} Interpolation function that maps t ([0, 1]) to a path `d` string.
+ */
+
+function interpolatePath(a, b, interpolateOptions) {
+ var aCommands = pathCommandsFromString(a);
+ var bCommands = pathCommandsFromString(b);
+
+ var _ref2 = _typeof(interpolateOptions) === 'object' ? interpolateOptions : {
+ excludeSegment: interpolateOptions,
+ snapEndsToInput: true
+ },
+ excludeSegment = _ref2.excludeSegment,
+ snapEndsToInput = _ref2.snapEndsToInput;
+
+ if (!aCommands.length && !bCommands.length) {
+ return function nullInterpolator() {
+ return '';
+ };
+ }
+
+ var commandInterpolator = interpolatePathCommands(aCommands, bCommands, {
+ excludeSegment: excludeSegment,
+ snapEndsToInput: snapEndsToInput
+ });
+ return function pathStringInterpolator(t) {
+ // at 1 return the final value without the extensions used during interpolation
+ if (t === 1 && snapEndsToInput) {
+ return b == null ? '' : b;
+ }
+
+ var interpolatedCommands = commandInterpolator(t); // convert to a string (fastest concat: https://jsperf.com/join-concat/150)
+
+ var interpolatedString = '';
+
+ var _iterator2 = _createForOfIteratorHelper(interpolatedCommands),
+ _step2;
+
+ try {
+ for (_iterator2.s(); !(_step2 = _iterator2.n()).done;) {
+ var interpolatedCommand = _step2.value;
+ interpolatedString += commandToString(interpolatedCommand);
+ }
+ } catch (err) {
+ _iterator2.e(err);
+ } finally {
+ _iterator2.f();
+ }
+
+ return interpolatedString;
+ };
+}
+
+exports.interpolatePath = interpolatePath;
+exports.interpolatePathCommands = interpolatePathCommands;
+exports.pathCommandsFromString = pathCommandsFromString;
+
+Object.defineProperty(exports, '__esModule', { value: true });
+
+})));
diff --git a/src/fit-curve.js b/src/fit-curve.js
new file mode 100644
index 0000000..d0aa229
--- /dev/null
+++ b/src/fit-curve.js
@@ -0,0 +1,606 @@
+
+
+/**
+ * @preserve JavaScript implementation of
+ * Algorithm for Automatically Fitting Digitized Curves
+ * by Philip J. Schneider
+ * "Graphics Gems", Academic Press, 1990
+ *
+ * The MIT License (MIT)
+ *
+ * https://github.com/soswow/fit-curves
+ */
+
+/**
+ * Fit one or more Bezier curves to a set of points.
+ *
+ * @param {Array>} points - Array of digitized points, e.g. [[5,5],[5,50],[110,140],[210,160],[320,110]]
+ * @param {Number} maxError - Tolerance, squared error between points and fitted curve
+ * @returns {Array>>} Array of Bezier curves, where each element is [first-point, control-point-1, control-point-2, second-point] and points are [x, y]
+ */
+export function fitCurve(points, maxError, progressCallback) {
+ if (!Array.isArray(points)) {
+ throw new TypeError("First argument should be an array");
+ }
+ points.forEach(function (point) {
+ if (!Array.isArray(point) || point.some(function (item) {
+ return typeof item !== 'number';
+ }) || point.length !== points[0].length) {
+ throw Error("Each point should be an array of numbers. Each point should have the same amount of numbers.");
+ }
+ });
+
+ // Remove duplicate points
+ points = points.filter(function (point, i) {
+ return i === 0 || !point.every(function (val, j) {
+ return val === points[i - 1][j];
+ });
+ });
+
+ if (points.length < 2) {
+ return [];
+ }
+
+ var len = points.length;
+ var leftTangent = createTangent(points[1], points[0]);
+ var rightTangent = createTangent(points[len - 2], points[len - 1]);
+
+ return fitCubic(points, leftTangent, rightTangent, maxError, progressCallback);
+}
+
+/**
+ * Fit a Bezier curve to a (sub)set of digitized points.
+ * Your code should not call this function directly. Use {@link fitCurve} instead.
+ *
+ * @param {Array>} points - Array of digitized points, e.g. [[5,5],[5,50],[110,140],[210,160],[320,110]]
+ * @param {Array} leftTangent - Unit tangent vector at start point
+ * @param {Array} rightTangent - Unit tangent vector at end point
+ * @param {Number} error - Tolerance, squared error between points and fitted curve
+ * @returns {Array>>} Array of Bezier curves, where each element is [first-point, control-point-1, control-point-2, second-point] and points are [x, y]
+ */
+function fitCubic(points, leftTangent, rightTangent, error, progressCallback) {
+ var MaxIterations = 20; //Max times to try iterating (to find an acceptable curve)
+
+ var bezCurve, //Control points of fitted Bezier curve
+ u, //Parameter values for point
+ uPrime, //Improved parameter values
+ maxError, prevErr, //Maximum fitting error
+ splitPoint, prevSplit, //Point to split point set at if we need more than one curve
+ centerVector, toCenterTangent, fromCenterTangent, //Unit tangent vector(s) at splitPoint
+ beziers, //Array of fitted Bezier curves if we need more than one curve
+ dist, i;
+
+ //console.log('fitCubic, ', points.length);
+
+ //Use heuristic if region only has two points in it
+ if (points.length === 2) {
+ dist = maths.vectorLen(maths.subtract(points[0], points[1])) / 3.0;
+ bezCurve = [points[0], maths.addArrays(points[0], maths.mulItems(leftTangent, dist)), maths.addArrays(points[1], maths.mulItems(rightTangent, dist)), points[1]];
+ return [bezCurve];
+ }
+
+ //Parameterize points, and attempt to fit curve
+ u = chordLengthParameterize(points);
+
+ var _generateAndReport = generateAndReport(points, u, u, leftTangent, rightTangent, progressCallback);
+
+ bezCurve = _generateAndReport[0];
+ maxError = _generateAndReport[1];
+ splitPoint = _generateAndReport[2];
+
+
+ if (maxError === 0 || maxError < error) {
+ return [bezCurve];
+ }
+ //If error not too large, try some reparameterization and iteration
+ if (maxError < error * error) {
+
+ uPrime = u;
+ prevErr = maxError;
+ prevSplit = splitPoint;
+
+ for (i = 0; i < MaxIterations; i++) {
+
+ uPrime = reparameterize(bezCurve, points, uPrime);
+
+ var _generateAndReport2 = generateAndReport(points, u, uPrime, leftTangent, rightTangent, progressCallback);
+
+ bezCurve = _generateAndReport2[0];
+ maxError = _generateAndReport2[1];
+ splitPoint = _generateAndReport2[2];
+
+
+ if (maxError < error) {
+ return [bezCurve];
+ }
+ //If the development of the fitted curve grinds to a halt,
+ //we abort this attempt (and try a shorter curve):
+ else if (splitPoint === prevSplit) {
+ var errChange = maxError / prevErr;
+ if (errChange > .9999 && errChange < 1.0001) {
+ break;
+ }
+ }
+
+ prevErr = maxError;
+ prevSplit = splitPoint;
+ }
+ }
+
+ //Fitting failed -- split at max error point and fit recursively
+ beziers = [];
+
+ //To create a smooth transition from one curve segment to the next, we
+ //calculate the line between the points directly before and after the
+ //center, and use that as the tangent both to and from the center point.
+ centerVector = maths.subtract(points[splitPoint - 1], points[splitPoint + 1]);
+ //However, this won't work if they're the same point, because the line we
+ //want to use as a tangent would be 0. Instead, we calculate the line from
+ //that "double-point" to the center point, and use its tangent.
+ if (centerVector.every(function (val) {
+ return val === 0;
+ })) {
+ //[x,y] -> [-y,x]: http://stackoverflow.com/a/4780141/1869660
+ centerVector = maths.subtract(points[splitPoint - 1], points[splitPoint]);
+ var _ref = [-centerVector[1], centerVector[0]];
+ centerVector[0] = _ref[0];
+ centerVector[1] = _ref[1];
+ }
+ toCenterTangent = maths.normalize(centerVector);
+ //To and from need to point in opposite directions:
+ fromCenterTangent = maths.mulItems(toCenterTangent, -1);
+
+ /*
+ Note: An alternative to this "divide and conquer" recursion could be to always
+ let new curve segments start by trying to go all the way to the end,
+ instead of only to the end of the current subdivided polyline.
+ That might let many segments fit a few points more, reducing the number of total segments.
+ However, a few tests have shown that the segment reduction is insignificant
+ (240 pts, 100 err: 25 curves vs 27 curves. 140 pts, 100 err: 17 curves on both),
+ and the results take twice as many steps and milliseconds to finish,
+ without looking any better than what we already have.
+ */
+ beziers = beziers.concat(fitCubic(points.slice(0, splitPoint + 1), leftTangent, toCenterTangent, error, progressCallback));
+ beziers = beziers.concat(fitCubic(points.slice(splitPoint), fromCenterTangent, rightTangent, error, progressCallback));
+ return beziers;
+};
+
+function generateAndReport(points, paramsOrig, paramsPrime, leftTangent, rightTangent, progressCallback) {
+ var bezCurve, maxError, splitPoint;
+
+ bezCurve = generateBezier(points, paramsPrime, leftTangent, rightTangent, progressCallback);
+ //Find max deviation of points to fitted curve.
+ //Here we always use the original parameters (from chordLengthParameterize()),
+ //because we need to compare the current curve to the actual source polyline,
+ //and not the currently iterated parameters which reparameterize() & generateBezier() use,
+ //as those have probably drifted far away and may no longer be in ascending order.
+
+ var _computeMaxError = computeMaxError(points, bezCurve, paramsOrig);
+
+ maxError = _computeMaxError[0];
+ splitPoint = _computeMaxError[1];
+
+
+ if (progressCallback) {
+ progressCallback({
+ bez: bezCurve,
+ points: points,
+ params: paramsOrig,
+ maxErr: maxError,
+ maxPoint: splitPoint
+ });
+ }
+
+ return [bezCurve, maxError, splitPoint];
+}
+
+/**
+ * Use least-squares method to find Bezier control points for region.
+ *
+ * @param {Array>} points - Array of digitized points
+ * @param {Array} parameters - Parameter values for region
+ * @param {Array} leftTangent - Unit tangent vector at start point
+ * @param {Array} rightTangent - Unit tangent vector at end point
+ * @returns {Array>} Approximated Bezier curve: [first-point, control-point-1, control-point-2, second-point] where points are [x, y]
+ */
+function generateBezier(points, parameters, leftTangent, rightTangent) {
+ var bezCurve,
+ //Bezier curve ctl pts
+ A,
+ a,
+ //Precomputed rhs for eqn
+ C,
+ X,
+ //Matrices C & X
+ det_C0_C1,
+ det_C0_X,
+ det_X_C1,
+ //Determinants of matrices
+ alpha_l,
+ alpha_r,
+ //Alpha values, left and right
+
+ epsilon,
+ segLength,
+ i,
+ len,
+ tmp,
+ u,
+ ux,
+ firstPoint = points[0],
+ lastPoint = points[points.length - 1];
+
+ bezCurve = [firstPoint, null, null, lastPoint];
+ //console.log('gb', parameters.length);
+
+ //Compute the A's
+ A = maths.zeros_Xx2x2(parameters.length);
+ for (i = 0, len = parameters.length; i < len; i++) {
+ u = parameters[i];
+ ux = 1 - u;
+ a = A[i];
+
+ a[0] = maths.mulItems(leftTangent, 3 * u * (ux * ux));
+ a[1] = maths.mulItems(rightTangent, 3 * ux * (u * u));
+ }
+
+ //Create the C and X matrices
+ C = [[0, 0], [0, 0]];
+ X = [0, 0];
+ for (i = 0, len = points.length; i < len; i++) {
+ u = parameters[i];
+ a = A[i];
+
+ C[0][0] += maths.dot(a[0], a[0]);
+ C[0][1] += maths.dot(a[0], a[1]);
+ C[1][0] += maths.dot(a[0], a[1]);
+ C[1][1] += maths.dot(a[1], a[1]);
+
+ tmp = maths.subtract(points[i], bezier.q([firstPoint, firstPoint, lastPoint, lastPoint], u));
+
+ X[0] += maths.dot(a[0], tmp);
+ X[1] += maths.dot(a[1], tmp);
+ }
+
+ //Compute the determinants of C and X
+ det_C0_C1 = C[0][0] * C[1][1] - C[1][0] * C[0][1];
+ det_C0_X = C[0][0] * X[1] - C[1][0] * X[0];
+ det_X_C1 = X[0] * C[1][1] - X[1] * C[0][1];
+
+ //Finally, derive alpha values
+ alpha_l = det_C0_C1 === 0 ? 0 : det_X_C1 / det_C0_C1;
+ alpha_r = det_C0_C1 === 0 ? 0 : det_C0_X / det_C0_C1;
+
+ //If alpha negative, use the Wu/Barsky heuristic (see text).
+ //If alpha is 0, you get coincident control points that lead to
+ //divide by zero in any subsequent NewtonRaphsonRootFind() call.
+ segLength = maths.vectorLen(maths.subtract(firstPoint, lastPoint));
+ epsilon = 1.0e-6 * segLength;
+ if (alpha_l < epsilon || alpha_r < epsilon) {
+ //Fall back on standard (probably inaccurate) formula, and subdivide further if needed.
+ bezCurve[1] = maths.addArrays(firstPoint, maths.mulItems(leftTangent, segLength / 3.0));
+ bezCurve[2] = maths.addArrays(lastPoint, maths.mulItems(rightTangent, segLength / 3.0));
+ } else {
+ //First and last control points of the Bezier curve are
+ //positioned exactly at the first and last data points
+ //Control points 1 and 2 are positioned an alpha distance out
+ //on the tangent vectors, left and right, respectively
+ bezCurve[1] = maths.addArrays(firstPoint, maths.mulItems(leftTangent, alpha_l));
+ bezCurve[2] = maths.addArrays(lastPoint, maths.mulItems(rightTangent, alpha_r));
+ }
+
+ return bezCurve;
+};
+
+/**
+ * Given set of points and their parameterization, try to find a better parameterization.
+ *
+ * @param {Array>} bezier - Current fitted curve
+ * @param {Array>} points - Array of digitized points
+ * @param {Array} parameters - Current parameter values
+ * @returns {Array} New parameter values
+ */
+function reparameterize(bezier, points, parameters) {
+ /*
+ var j, len, point, results, u;
+ results = [];
+ for (j = 0, len = points.length; j < len; j++) {
+ point = points[j], u = parameters[j];
+ results.push(newtonRaphsonRootFind(bezier, point, u));
+ }
+ return results;
+ //*/
+ return parameters.map(function (p, i) {
+ return newtonRaphsonRootFind(bezier, points[i], p);
+ });
+};
+
+/**
+ * Use Newton-Raphson iteration to find better root.
+ *
+ * @param {Array>} bez - Current fitted curve
+ * @param {Array} point - Digitized point
+ * @param {Number} u - Parameter value for "P"
+ * @returns {Number} New u
+ */
+function newtonRaphsonRootFind(bez, point, u) {
+ /*
+ Newton's root finding algorithm calculates f(x)=0 by reiterating
+ x_n+1 = x_n - f(x_n)/f'(x_n)
+ We are trying to find curve parameter u for some point p that minimizes
+ the distance from that point to the curve. Distance point to curve is d=q(u)-p.
+ At minimum distance the point is perpendicular to the curve.
+ We are solving
+ f = q(u)-p * q'(u) = 0
+ with
+ f' = q'(u) * q'(u) + q(u)-p * q''(u)
+ gives
+ u_n+1 = u_n - |q(u_n)-p * q'(u_n)| / |q'(u_n)**2 + q(u_n)-p * q''(u_n)|
+ */
+
+ var d = maths.subtract(bezier.q(bez, u), point),
+ qprime = bezier.qprime(bez, u),
+ numerator = maths.mulMatrix(d, qprime),
+ denominator = maths.sum(maths.squareItems(qprime)) + 2 * maths.mulMatrix(d, bezier.qprimeprime(bez, u));
+
+ if (denominator === 0) {
+ return u;
+ } else {
+ return u - numerator / denominator;
+ }
+};
+
+/**
+ * Assign parameter values to digitized points using relative distances between points.
+ *
+ * @param {Array>} points - Array of digitized points
+ * @returns {Array} Parameter values
+ */
+function chordLengthParameterize(points) {
+ var u = [],
+ currU,
+ prevU,
+ prevP;
+
+ points.forEach(function (p, i) {
+ currU = i ? prevU + maths.vectorLen(maths.subtract(p, prevP)) : 0;
+ u.push(currU);
+
+ prevU = currU;
+ prevP = p;
+ });
+ u = u.map(function (x) {
+ return x / prevU;
+ });
+
+ return u;
+};
+
+/**
+ * Find the maximum squared distance of digitized points to fitted curve.
+ *
+ * @param {Array>} points - Array of digitized points
+ * @param {Array>} bez - Fitted curve
+ * @param {Array} parameters - Parameterization of points
+ * @returns {Array} Maximum error (squared) and point of max error
+ */
+function computeMaxError(points, bez, parameters) {
+ var dist, //Current error
+ maxDist, //Maximum error
+ splitPoint, //Point of maximum error
+ v, //Vector from point to curve
+ i, count, point, t;
+
+ maxDist = 0;
+ splitPoint = Math.floor(points.length / 2);
+
+ var t_distMap = mapTtoRelativeDistances(bez, 10);
+
+ for (i = 0, count = points.length; i < count; i++) {
+ point = points[i];
+ //Find 't' for a point on the bez curve that's as close to 'point' as possible:
+ t = find_t(bez, parameters[i], t_distMap, 10);
+
+ v = maths.subtract(bezier.q(bez, t), point);
+ dist = v[0] * v[0] + v[1] * v[1];
+
+ if (dist > maxDist) {
+ maxDist = dist;
+ splitPoint = i;
+ }
+ }
+
+ return [maxDist, splitPoint];
+};
+
+//Sample 't's and map them to relative distances along the curve:
+var mapTtoRelativeDistances = function mapTtoRelativeDistances(bez, B_parts) {
+ var B_t_curr;
+ var B_t_dist = [0];
+ var B_t_prev = bez[0];
+ var sumLen = 0;
+
+ for (var i = 1; i <= B_parts; i++) {
+ B_t_curr = bezier.q(bez, i / B_parts);
+
+ sumLen += maths.vectorLen(maths.subtract(B_t_curr, B_t_prev));
+
+ B_t_dist.push(sumLen);
+ B_t_prev = B_t_curr;
+ }
+
+ //Normalize B_length to the same interval as the parameter distances; 0 to 1:
+ B_t_dist = B_t_dist.map(function (x) {
+ return x / sumLen;
+ });
+ return B_t_dist;
+};
+
+function find_t(bez, param, t_distMap, B_parts) {
+ if (param < 0) {
+ return 0;
+ }
+ if (param > 1) {
+ return 1;
+ }
+
+ /*
+ 'param' is a value between 0 and 1 telling us the relative position
+ of a point on the source polyline (linearly from the start (0) to the end (1)).
+ To see if a given curve - 'bez' - is a close approximation of the polyline,
+ we compare such a poly-point to the point on the curve that's the same
+ relative distance along the curve's length.
+ But finding that curve-point takes a little work:
+ There is a function "B(t)" to find points along a curve from the parametric parameter 't'
+ (also relative from 0 to 1: http://stackoverflow.com/a/32841764/1869660
+ http://pomax.github.io/bezierinfo/#explanation),
+ but 't' isn't linear by length (http://gamedev.stackexchange.com/questions/105230).
+ So, we sample some points along the curve using a handful of values for 't'.
+ Then, we calculate the length between those samples via plain euclidean distance;
+ B(t) concentrates the points around sharp turns, so this should give us a good-enough outline of the curve.
+ Thus, for a given relative distance ('param'), we can now find an upper and lower value
+ for the corresponding 't' by searching through those sampled distances.
+ Finally, we just use linear interpolation to find a better value for the exact 't'.
+ More info:
+ http://gamedev.stackexchange.com/questions/105230/points-evenly-spaced-along-a-bezier-curve
+ http://stackoverflow.com/questions/29438398/cheap-way-of-calculating-cubic-bezier-length
+ http://steve.hollasch.net/cgindex/curves/cbezarclen.html
+ https://github.com/retuxx/tinyspline
+ */
+ var lenMax, lenMin, tMax, tMin, t;
+
+ //Find the two t-s that the current param distance lies between,
+ //and then interpolate a somewhat accurate value for the exact t:
+ for (var i = 1; i <= B_parts; i++) {
+
+ if (param <= t_distMap[i]) {
+ tMin = (i - 1) / B_parts;
+ tMax = i / B_parts;
+ lenMin = t_distMap[i - 1];
+ lenMax = t_distMap[i];
+
+ t = (param - lenMin) / (lenMax - lenMin) * (tMax - tMin) + tMin;
+ break;
+ }
+ }
+ return t;
+}
+
+/**
+ * Creates a vector of length 1 which shows the direction from B to A
+ */
+function createTangent(pointA, pointB) {
+ return maths.normalize(maths.subtract(pointA, pointB));
+}
+
+/*
+ Simplified versions of what we need from math.js
+ Optimized for our input, which is only numbers and 1x2 arrays (i.e. [x, y] coordinates).
+*/
+
+var maths = function () {
+ function maths() {
+ _classCallCheck(this, maths);
+ }
+
+ maths.zeros_Xx2x2 = function zeros_Xx2x2(x) {
+ var zs = [];
+ while (x--) {
+ zs.push([0, 0]);
+ }
+ return zs;
+ };
+
+ maths.mulItems = function mulItems(items, multiplier) {
+ return items.map(function (x) {
+ return x * multiplier;
+ });
+ };
+
+ maths.mulMatrix = function mulMatrix(m1, m2) {
+ //https://en.wikipedia.org/wiki/Matrix_multiplication#Matrix_product_.28two_matrices.29
+ //Simplified to only handle 1-dimensional matrices (i.e. arrays) of equal length:
+ return m1.reduce(function (sum, x1, i) {
+ return sum + x1 * m2[i];
+ }, 0);
+ };
+
+ maths.subtract = function subtract(arr1, arr2) {
+ return arr1.map(function (x1, i) {
+ return x1 - arr2[i];
+ });
+ };
+
+ maths.addArrays = function addArrays(arr1, arr2) {
+ return arr1.map(function (x1, i) {
+ return x1 + arr2[i];
+ });
+ };
+
+ maths.addItems = function addItems(items, addition) {
+ return items.map(function (x) {
+ return x + addition;
+ });
+ };
+
+ maths.sum = function sum(items) {
+ return items.reduce(function (sum, x) {
+ return sum + x;
+ });
+ };
+
+ maths.dot = function dot(m1, m2) {
+ return maths.mulMatrix(m1, m2);
+ };
+
+ maths.vectorLen = function vectorLen(v) {
+ return Math.hypot.apply(Math, v);
+ };
+
+ maths.divItems = function divItems(items, divisor) {
+ return items.map(function (x) {
+ return x / divisor;
+ });
+ };
+
+ maths.squareItems = function squareItems(items) {
+ return items.map(function (x) {
+ return x * x;
+ });
+ };
+
+ maths.normalize = function normalize(v) {
+ return this.divItems(v, this.vectorLen(v));
+ };
+
+ return maths;
+}();
+
+var bezier = function () {
+ function bezier() {
+ _classCallCheck(this, bezier);
+ }
+
+ bezier.q = function q(ctrlPoly, t) {
+ var tx = 1.0 - t;
+ var pA = maths.mulItems(ctrlPoly[0], tx * tx * tx),
+ pB = maths.mulItems(ctrlPoly[1], 3 * tx * tx * t),
+ pC = maths.mulItems(ctrlPoly[2], 3 * tx * t * t),
+ pD = maths.mulItems(ctrlPoly[3], t * t * t);
+ return maths.addArrays(maths.addArrays(pA, pB), maths.addArrays(pC, pD));
+ };
+
+ bezier.qprime = function qprime(ctrlPoly, t) {
+ var tx = 1.0 - t;
+ var pA = maths.mulItems(maths.subtract(ctrlPoly[1], ctrlPoly[0]), 3 * tx * tx),
+ pB = maths.mulItems(maths.subtract(ctrlPoly[2], ctrlPoly[1]), 6 * tx * t),
+ pC = maths.mulItems(maths.subtract(ctrlPoly[3], ctrlPoly[2]), 3 * t * t);
+ return maths.addArrays(maths.addArrays(pA, pB), pC);
+ };
+
+ bezier.qprimeprime = function qprimeprime(ctrlPoly, t) {
+ return maths.addArrays(maths.mulItems(maths.addArrays(maths.subtract(ctrlPoly[2], maths.mulItems(ctrlPoly[1], 2)), ctrlPoly[0]), 6 * (1.0 - t)), maths.mulItems(maths.addArrays(maths.subtract(ctrlPoly[3], maths.mulItems(ctrlPoly[2], 2)), ctrlPoly[1]), 6 * t));
+ };
+
+ return bezier;
+}();
diff --git a/src/index.html b/src/index.html
new file mode 100644
index 0000000..c2f3171
--- /dev/null
+++ b/src/index.html
@@ -0,0 +1,48 @@
+
+
+
+
+
+
+
+
+ Tauri App
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/main.js b/src/main.js
new file mode 100644
index 0000000..705847b
--- /dev/null
+++ b/src/main.js
@@ -0,0 +1,2426 @@
+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: [],
+ shapeselection: [],
+}
+
+let config = {
+ shortcuts: {
+ playAnimation: " ",
+ // undo: "+z"
+ undo: "z",
+ redo: "Z",
+ new: "n",
+ save: "s",
+ saveAs: "S",
+ open: "o",
+ quit: "q",
+ group: "g",
+ }
+}
+
+// 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 frameNum = context.activeObject.currentFrameNum
+ let layer = context.activeObject.activeLayer
+ let frames = layer.frames
+ let {lastKeyframeBefore, firstKeyframeAfter} = getKeyframesSurrounding(frames, frameNum)
+
+ let action = {
+ frameNum: frameNum,
+ layer: layer.idx,
+ lastBefore: lastKeyframeBefore,
+ firstAfter: firstKeyframeAfter,
+ }
+ undoStack.push({name: 'addMotionTween', action: action})
+ actions.addMotionTween.execute(action)
+ },
+ execute: (action) => {
+ let layer = pointerList[action.layer]
+ let frames = layer.frames
+ if ((action.lastBefore != undefined) && (action.firstAfter != undefined)) {
+ for (let i=action.lastBefore + 1; i {
+ let layer = pointerList[action.layer]
+ let frames = layer.frames
+ for (let i=action.lastBefore + 1; i {
+ redoStack.length = 0
+ let serializableShapes = []
+ let serializableObjects = []
+ for (let shape of context.shapeselection) {
+ serializableShapes.push(shape.idx)
+ }
+ for (let object of context.selection) {
+ serializableObjects.push(object.idx)
+ }
+ context.shapeselection = []
+ context.selection = []
+ let action = {
+ shapes: serializableShapes,
+ objects: serializableObjects,
+ groupUuid: uuidv4(),
+ parent: context.activeObject.idx
+ }
+ undoStack.push({name: 'group', action: action})
+ actions.group.execute(action)
+ },
+ execute: (action) => {
+ // your code here
+ let group = new GraphicsObject(action.groupUuid)
+ let parent = pointerList[action.parent]
+ for (let shapeIdx of action.shapes) {
+ let shape = pointerList[shapeIdx]
+ group.addShape(shape)
+ parent.removeShape(shape)
+ }
+ for (let objectIdx of action.objects) {
+ let object = pointerList[objectIdx]
+ group.addObject(object, object.x, object.y)
+ parent.removeChild(object)
+ }
+ parent.addObject(group)
+ if (context.activeObject==parent && context.selection.length==0 && context.shapeselection.length==0) {
+ context.selection.push(group)
+ }
+ updateUI()
+ },
+ rollback: (action) => {
+ let group = pointerList[action.groupUuid]
+ let parent = pointerList[action.parent]
+ for (let shapeIdx of action.shapes) {
+ let shape = pointerList[shapeIdx]
+ parent.addShape(shape)
+ group.removeShape(shape)
+ }
+ for (let objectIdx of action.objects) {
+ let object = pointerList[objectIdx]
+ parent.addObject(object, object.x, object.y)
+ group.removeChild(object)
+ }
+ parent.removeChild(group)
+ updateUI()
+ }
+ },
+}
+
+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 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 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 && candidate.x.max > bbox.x.min &&
+ candidate.y.min < bbox.y.max && candidate.y.max > bbox.y.min) {
+ return true;
+ } else {
+ return false;
+ }
+ } else {
+ // We're checking a point
+ if (candidate.x > bbox.x.min &&
+ candidate.x < bbox.x.max &&
+ candidate.y > bbox.y.min &&
+ candidate.y < bbox.y.max) {
+ return true;
+ } else {
+ return false
+ }
+ }
+
+}
+
+function undo() {
+ let action = undoStack.pop()
+ if (action) {
+ actions[action.name].rollback(action.action)
+ redoStack.push(action)
+ updateUI()
+ } 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)
+ }
+ bbox() {
+ return this.boundingBox
+ }
+ 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 = structuredClone(this.currentFrame.shapes[0].boundingBox)
+ for (let shape of this.currentFrame.shapes) {
+ growBoundingBox(bbox, shape.boundingBox)
+ }
+ }
+ if (this.children.length > 0) {
+ if (!bbox) {
+ bbox = structuredClone(this.children[0].bbox())
+ }
+ for (let child of this.children) {
+ growBoundingBox(bbox, child.bbox())
+ }
+ }
+ bbox.x.min += this.x
+ bbox.x.max += this.x
+ bbox.y.min += this.y
+ bbox.y.max += this.y
+ console.log(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 (context.shapeselection.indexOf(shape) >= 0) {
+ invertPixels(ctx, fileWidth, fileHeight)
+ }
+ shape.draw(context)
+ if (context.shapeselection.indexOf(shape) >= 0) {
+ 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.beginPath()
+ let bbox = item.bbox()
+ ctx.rect(bbox.x.min, bbox.y.min, bbox.x.max - bbox.x.min, bbox.y.max - bbox.y.min)
+ ctx.stroke()
+ ctx.restore()
+ }
+ if (context.selectionRect) {
+ ctx.save()
+ ctx.strokeStyle = "#00ffff"
+ ctx.lineWidth = 1;
+ ctx.beginPath()
+ ctx.rect(
+ context.selectionRect.x1, context.selectionRect.y1,
+ context.selectionRect.x2 - context.selectionRect.x1,
+ context.selectionRect.y2 - context.selectionRect.y1
+ )
+ ctx.stroke()
+ ctx.restore()
+ }
+ }
+ }
+ 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.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 == config.shortcuts.undo && e.ctrlKey == true) {
+ undo()
+ } else if (e.key == config.shortcuts.redo && e.ctrlKey == true) {
+ redo()
+ } else if (e.key == config.shortcuts.group && e.ctrlKey == true) {
+ actions.group.create()
+ }
+ 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() {
+ actions.addMotionTween.create()
+}
+
+function stage() {
+ let stage = document.createElement("canvas")
+ let scroller = document.createElement("div")
+ stage.className = "stage"
+ stage.width = 1500
+ stage.height = 1000
+ scroller.className = "scroll"
+ stage.addEventListener("drop", (e) => {
+ 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 = []
+ context.shapeselection = []
+ for (let child of context.activeObject.children) {
+ if (hitTest(regionToBbox(context.selectionRect), child)) {
+ context.selection.push(child)
+ }
+ }
+ for (let shape of context.activeObject.currentFrame.shapes) {
+ if (hitTest(regionToBbox(context.selectionRect), shape)) {
+ context.shapeselection.push(shape)
+ }
+ }
+ } else {
+ let selection = selectVertex(context, mouse)
+ if (selection) {
+ context.activeCurve = undefined
+ context.activeVertex = {
+ current: selection.vertex,
+ initial: {
+ point: {x: selection.vertex.point.x, y: selection.vertex.point.y},
+ startCurves: structuredClone(selection.vertex.startCurves),
+ endCurves: structuredClone(selection.vertex.endCurves),
+ },
+ shape: selection.shape,
+ startmouse: {x: mouse.x, y: mouse.y}
+ }
+ } else {
+ context.activeVertex = undefined
+ selection = selectCurve(context, mouse)
+ if (selection) {
+ context.activeCurve = {
+ current: selection.curve,
+ initial: new Bezier(selection.curve.points).setColor(selection.curve.color),
+ shape: selection.shape,
+ startmouse: mouse
+ }
+ } else {
+ context.activeCurve = undefined
+ }
+ }
+ }
+ context.lastMouse = mouse
+ break;
+ 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 = "#ff0000"
+ 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: '#ff0000',
+ 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: () => {}
+ },
+ {
+ text: "Group",
+ enabled: true,
+ action: actions.group.create
+ },
+ ]
+ });
+
+ 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
+ },
+}
\ No newline at end of file
diff --git a/src/newfile.js b/src/newfile.js
new file mode 100644
index 0000000..0ced18f
--- /dev/null
+++ b/src/newfile.js
@@ -0,0 +1,97 @@
+let overlay;
+let newFileDialog;
+
+function createNewFileDialog(callback) {
+ overlay = document.createElement('div');
+ overlay.id = 'overlay';
+ document.body.appendChild(overlay);
+
+ newFileDialog = document.createElement('div');
+ newFileDialog.id = 'newFileDialog';
+ newFileDialog.classList.add('hidden');
+ document.body.appendChild(newFileDialog);
+
+ // Create dialog content dynamically
+ const title = document.createElement('h3');
+ title.textContent = 'Create New File';
+ newFileDialog.appendChild(title);
+
+ // Create Width input
+ const widthLabel = document.createElement('label');
+ widthLabel.setAttribute('for', 'width');
+ widthLabel.classList.add('dialog-label');
+ widthLabel.textContent = 'Width:';
+ newFileDialog.appendChild(widthLabel);
+
+ const widthInput = document.createElement('input');
+ widthInput.type = 'number';
+ widthInput.id = 'width';
+ widthInput.classList.add('dialog-input');
+ widthInput.value = '1500'; // Default value
+ newFileDialog.appendChild(widthInput);
+
+ // Create Height input
+ const heightLabel = document.createElement('label');
+ heightLabel.setAttribute('for', 'height');
+ heightLabel.classList.add('dialog-label');
+ heightLabel.textContent = 'Height:';
+ newFileDialog.appendChild(heightLabel);
+
+ const heightInput = document.createElement('input');
+ heightInput.type = 'number';
+ heightInput.id = 'height';
+ heightInput.classList.add('dialog-input');
+ heightInput.value = '1000'; // Default value
+ newFileDialog.appendChild(heightInput);
+
+ // Create FPS input
+ const fpsLabel = document.createElement('label');
+ fpsLabel.setAttribute('for', 'fps');
+ fpsLabel.classList.add('dialog-label');
+ fpsLabel.textContent = 'Frames per Second:';
+ newFileDialog.appendChild(fpsLabel);
+
+ const fpsInput = document.createElement('input');
+ fpsInput.type = 'number';
+ fpsInput.id = 'fps';
+ fpsInput.classList.add('dialog-input');
+ fpsInput.value = '12'; // Default value
+ newFileDialog.appendChild(fpsInput);
+
+ // Create Create button
+ const createButton = document.createElement('button');
+ createButton.textContent = 'Create';
+ createButton.classList.add('dialog-button');
+ createButton.onclick = createNewFile;
+ newFileDialog.appendChild(createButton);
+
+
+ // Create the new file (simulation)
+ function createNewFile() {
+ const width = document.getElementById('width').value;
+ const height = document.getElementById('height').value;
+ const fps = document.getElementById('fps').value;
+ console.log(`New file created with width: ${width} and height: ${height}`);
+ callback(width, height, fps)
+
+ // Add any further logic to handle the new file creation here
+
+ closeDialog(); // Close the dialog after file creation
+ }
+
+ // Close the dialog if the overlay is clicked
+ overlay.onclick = closeDialog;
+}
+
+// Show the dialog
+function showNewFileDialog() {
+ overlay.style.display = 'block';
+ newFileDialog.style.display = 'block';
+}
+
+// Close the dialog
+function closeDialog() {
+ overlay.style.display = 'none';
+ newFileDialog.style.display = 'none';
+}
+export { createNewFileDialog, showNewFileDialog, closeDialog };
\ No newline at end of file
diff --git a/src/quadtree.js b/src/quadtree.js
new file mode 100644
index 0000000..68df8e0
--- /dev/null
+++ b/src/quadtree.js
@@ -0,0 +1,176 @@
+class Quadtree {
+ constructor(boundary, capacity) {
+ // Boundary is the bounding box of the area this quadtree node covers
+ // Capacity is the maximum number of curves a node can hold before subdividing
+ this.boundary = boundary; // {x: {min: , max: }, y: {min: , max: }}
+ this.capacity = capacity;
+ this.curveIndexes = [];
+ this.curves = [];
+ this.divided = false;
+
+ this.nw = null; // Northwest quadrant
+ this.ne = null; // Northeast quadrant
+ this.sw = null; // Southwest quadrant
+ this.se = null; // Southeast quadrant
+ }
+
+
+ // Check if a bounding box intersects with the boundary of this quadtree node
+ intersects(bbox) {
+ return !(bbox.x.max < this.boundary.x.min || bbox.x.min > this.boundary.x.max ||
+ bbox.y.max < this.boundary.y.min || bbox.y.min > this.boundary.y.max);
+ }
+
+ // Subdivide this quadtree node into 4 quadrants
+ subdivide() {
+ const xMid = (this.boundary.x.min + this.boundary.x.max) / 2;
+ const yMid = (this.boundary.y.min + this.boundary.y.max) / 2;
+
+ const nwBoundary = { x: { min: this.boundary.x.min, max: xMid }, y: { min: this.boundary.y.min, max: yMid }};
+ const neBoundary = { x: { min: xMid, max: this.boundary.x.max }, y: { min: this.boundary.y.min, max: yMid }};
+ const swBoundary = { x: { min: this.boundary.x.min, max: xMid }, y: { min: yMid, max: this.boundary.y.max }};
+ const seBoundary = { x: { min: xMid, max: this.boundary.x.max }, y: { min: yMid, max: this.boundary.y.max }};
+
+ this.nw = new Quadtree(nwBoundary, this.capacity);
+ this.ne = new Quadtree(neBoundary, this.capacity);
+ this.sw = new Quadtree(swBoundary, this.capacity);
+ this.se = new Quadtree(seBoundary, this.capacity);
+
+ this.divided = true;
+ }
+
+ insert (curve, curveIdx) {
+ const bbox = curve.bbox()
+ if (!this.intersects(curve.bbox())) {
+ let newNode = new Quadtree(this.boundary, this.capacity)
+ newNode.curveIndexes = this.curveIndexes;
+ newNode.curves = this.curves;
+ newNode.divided = this.divided;
+
+ newNode.nw = this.nw;
+ newNode.ne = this.ne;
+ newNode.sw = this.sw;
+ newNode.se = this.se;
+
+ this.curveIndexes = [];
+ this.curves = [];
+ this.subdivide()
+ if (bbox.x.max < this.boundary.x.max) {
+ if (bbox.y.max < this.boundary.y.max) {
+ this.boundary.x.min -= this.boundary.x.max - this.boundary.x.min
+ this.boundary.y.min -= this.boundary.y.max - this.boundary.y.min
+ this.nw = newNode
+ } else {
+ this.boundary.x.min -= this.boundary.x.max - this.boundary.x.min
+ this.boundary.y.max += this.boundary.y.max - this.boundary.y.min
+ this.sw = newNode
+ }
+ } else {
+ if (bbox.y.max < this.boundary.y.max) {
+ this.boundary.x.max += this.boundary.x.max - this.boundary.x.min
+ this.boundary.y.min -= this.boundary.y.max - this.boundary.y.min
+ this.ne = newNode
+ } else {
+ this.boundary.x.max += this.boundary.x.max - this.boundary.x.min
+ this.boundary.y.max += this.boundary.y.max - this.boundary.y.min
+ this.se = newNode
+ }
+ }
+ return this.insert(curve, curveIdx)
+ } else {
+ return this._insert(curve, curveIdx)
+ }
+ }
+
+ // Insert a curve into the quadtree, subdividing if necessary
+ _insert(curve, curveIdx) {
+ // If the curve's bounding box doesn't intersect this node's boundary, do nothing
+ if (!this.intersects(curve.bbox())) {
+ return false;
+ }
+
+ // If the node has space, insert the curve here
+ if (this.curves.length < this.capacity) {
+ this.curves.push(curve);
+ this.curveIndexes.push(curveIdx)
+ return true;
+ }
+
+ // Otherwise, subdivide and insert the curve into the appropriate quadrant
+ if (!this.divided) {
+ this.subdivide();
+ }
+
+ return (
+ this.nw._insert(curve, curveIdx) ||
+ this.ne._insert(curve, curveIdx) ||
+ this.sw._insert(curve, curveIdx) ||
+ this.se._insert(curve, curveIdx)
+ );
+ }
+
+ // Query all curves that intersect with a given bounding box
+ query(range, found = []) {
+ // If the range doesn't intersect with this node's boundary, return
+ if (!this.intersects(range)) {
+ return found;
+ }
+
+ // Check the curves in this node
+ for (let i = 0; i < this.curves.length; i++) {
+ if (this.bboxIntersect(this.curves[i].bbox(), range)) {
+ found.push(this.curveIndexes[i]); // Return the curve index instead of the curve
+ }
+ }
+
+ // If the node is subdivided, check the child quadrants
+ if (this.divided) {
+ this.nw.query(range, found);
+ this.ne.query(range, found);
+ this.sw.query(range, found);
+ this.se.query(range, found);
+ }
+
+ return found;
+ }
+
+ // Helper method to check if two bounding boxes intersect
+ bboxIntersect(bbox1, bbox2) {
+ return !(bbox1.x.max < bbox2.x.min || bbox1.x.min > bbox2.x.max ||
+ bbox1.y.max < bbox2.y.min || bbox1.y.min > bbox2.y.max);
+ }
+
+ clear() {
+ this.curveIndexes = [];
+ this.curves = [];
+ this.divided = false;
+
+ this.nw = null; // Northwest quadrant
+ this.ne = null; // Northeast quadrant
+ this.sw = null; // Southwest quadrant
+ this.se = null; // Southeast quadrant
+ }
+ draw(ctx) {
+ // Debug visualization
+ ctx.save()
+ ctx.strokeStyle = "red"
+ ctx.lineWidth = 1
+ ctx.beginPath()
+ ctx.rect(
+ this.boundary.x.min,
+ this.boundary.y.min,
+ this.boundary.x.max-this.boundary.x.min,
+ this.boundary.y.max-this.boundary.y.min
+ )
+ ctx.stroke()
+ if (this.divided) {
+ this.nw.draw(ctx)
+ this.ne.draw(ctx)
+ this.sw.draw(ctx)
+ this.se.draw(ctx)
+ }
+ ctx.restore()
+ }
+ }
+
+ export { Quadtree };
\ No newline at end of file
diff --git a/src/simplify.js b/src/simplify.js
new file mode 100644
index 0000000..339c84f
--- /dev/null
+++ b/src/simplify.js
@@ -0,0 +1,123 @@
+/*
+ (c) 2017, Vladimir Agafonkin
+ Simplify.js, a high-performance JS polyline simplification library
+ mourner.github.io/simplify-js
+*/
+
+(function () { 'use strict';
+
+// to suit your point format, run search/replace for '.x' and '.y';
+// for 3D version, see 3d branch (configurability would draw significant performance overhead)
+
+// square distance between 2 points
+function getSqDist(p1, p2) {
+
+ var dx = p1.x - p2.x,
+ dy = p1.y - p2.y;
+
+ return dx * dx + dy * dy;
+}
+
+// square distance from a point to a segment
+function getSqSegDist(p, p1, p2) {
+
+ var x = p1.x,
+ y = p1.y,
+ dx = p2.x - x,
+ dy = p2.y - y;
+
+ if (dx !== 0 || dy !== 0) {
+
+ var t = ((p.x - x) * dx + (p.y - y) * dy) / (dx * dx + dy * dy);
+
+ if (t > 1) {
+ x = p2.x;
+ y = p2.y;
+
+ } else if (t > 0) {
+ x += dx * t;
+ y += dy * t;
+ }
+ }
+
+ dx = p.x - x;
+ dy = p.y - y;
+
+ return dx * dx + dy * dy;
+}
+// rest of the code doesn't care about point format
+
+// basic distance-based simplification
+function simplifyRadialDist(points, sqTolerance) {
+
+ var prevPoint = points[0],
+ newPoints = [prevPoint],
+ point;
+
+ for (var i = 1, len = points.length; i < len; i++) {
+ point = points[i];
+
+ if (getSqDist(point, prevPoint) > sqTolerance) {
+ newPoints.push(point);
+ prevPoint = point;
+ }
+ }
+
+ if (prevPoint !== point) newPoints.push(point);
+
+ return newPoints;
+}
+
+function simplifyDPStep(points, first, last, sqTolerance, simplified) {
+ var maxSqDist = sqTolerance,
+ index;
+
+ for (var i = first + 1; i < last; i++) {
+ var sqDist = getSqSegDist(points[i], points[first], points[last]);
+
+ if (sqDist > maxSqDist) {
+ index = i;
+ maxSqDist = sqDist;
+ }
+ }
+
+ if (maxSqDist > sqTolerance) {
+ if (index - first > 1) simplifyDPStep(points, first, index, sqTolerance, simplified);
+ simplified.push(points[index]);
+ if (last - index > 1) simplifyDPStep(points, index, last, sqTolerance, simplified);
+ }
+}
+
+// simplification using Ramer-Douglas-Peucker algorithm
+function simplifyDouglasPeucker(points, sqTolerance) {
+ var last = points.length - 1;
+
+ var simplified = [points[0]];
+ simplifyDPStep(points, 0, last, sqTolerance, simplified);
+ simplified.push(points[last]);
+
+ return simplified;
+}
+
+// both algorithms combined for awesome performance
+function simplify(points, tolerance, highestQuality) {
+
+ if (points.length <= 2) return points;
+
+ var sqTolerance = tolerance !== undefined ? tolerance * tolerance : 1;
+
+ points = highestQuality ? points : simplifyRadialDist(points, sqTolerance);
+ points = simplifyDouglasPeucker(points, sqTolerance);
+
+ return points;
+}
+
+// export as AMD module / Node module / browser or worker variable
+if (typeof define === 'function' && define.amd) define(function() { return simplify; });
+else if (typeof module !== 'undefined') {
+ module.exports = simplify;
+ module.exports.default = simplify;
+} else if (typeof self !== 'undefined') self.simplify = simplify;
+else window.simplify = simplify;
+
+})();
diff --git a/src/styles.css b/src/styles.css
new file mode 100644
index 0000000..51781e1
--- /dev/null
+++ b/src/styles.css
@@ -0,0 +1,415 @@
+body {
+ width: 100%;
+ height: 100%;
+ overflow: hidden;
+}
+
+.logo.vanilla:hover {
+ filter: drop-shadow(0 0 2em #ffe21c);
+}
+:root {
+ --lineheight: 24px;
+ font-family: Inter, Avenir, Helvetica, Arial, sans-serif;
+ font-size: 16px;
+ line-height: var(--lineheight);
+ font-weight: 400;
+
+ color: #0f0f0f;
+ background-color: #f6f6f6;
+
+ font-synthesis: none;
+ text-rendering: optimizeLegibility;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+ -webkit-text-size-adjust: 100%;
+ height: 100%;
+}
+
+.container {
+ margin: 0;
+ padding-top: 10vh;
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ text-align: center;
+}
+
+.logo {
+ height: 6em;
+ padding: 1.5em;
+ will-change: filter;
+ transition: 0.75s;
+}
+
+.logo.tauri:hover {
+ filter: drop-shadow(0 0 2em #24c8db);
+}
+
+.row {
+ display: flex;
+ justify-content: center;
+}
+
+a {
+ font-weight: 500;
+ color: #646cff;
+ text-decoration: inherit;
+}
+
+a:hover {
+ color: #535bf2;
+}
+
+h1 {
+ text-align: center;
+}
+
+input,
+button {
+ border-radius: 8px;
+ border: 1px solid transparent;
+ padding: 0.6em 1.2em;
+ font-size: 1em;
+ font-weight: 500;
+ font-family: inherit;
+ color: #0f0f0f;
+ background-color: #ffffff;
+ transition: border-color 0.25s;
+ box-shadow: 0 2px 2px rgba(0, 0, 0, 0.2);
+}
+
+div {
+ /* this should be on everything by default, really */
+ box-sizing: border-box;
+}
+
+button {
+ cursor: pointer;
+}
+
+button:hover {
+ border-color: #396cd8;
+}
+button:active {
+ border-color: #396cd8;
+ background-color: #e8e8e8;
+}
+
+input,
+button {
+ outline: none;
+}
+
+#greet-input {
+ margin-right: 5px;
+}
+
+@media (prefers-color-scheme: dark) {
+ :root {
+ color: #f6f6f6;
+ background-color: #2f2f2f;
+ }
+
+ a:hover {
+ color: #24c8db;
+ }
+
+ input,
+ button {
+ color: #ffffff;
+ background-color: #0f0f0f98;
+ }
+ button:active {
+ background-color: #0f0f0f69;
+ }
+}
+
+.header {
+ height: 60px;
+ min-width: 100%;
+ background-color: #3f3f3f;
+ text-align: left;
+ z-index: 1;
+}
+
+.icon {
+ width: var(--lineheight);
+ height: var(--lineheight);
+}
+
+.panecontainer {
+ width: 100%;
+ height: 100%;
+}
+
+.horizontal-grid, .vertical-grid {
+ display: flex;
+ gap: 5px;
+ background-color: #0f0f0f;
+ width: 100%;
+ height: 100%;
+ contain: strict;
+}
+.horizontal-grid {
+ flex-direction: row;
+}
+.vertical-grid {
+ flex-direction: column;
+}
+/* I don't fully understand this selector but it works for now */
+.horizontal-grid:hover:not(:has(*:hover)) {
+ background: #666;
+ cursor: ew-resize;
+}
+.vertical-grid:hover:not(:has(*:hover)) {
+ background: #666;
+ cursor: ns-resize
+}
+.scroll {
+ overflow: scroll;
+ width: 100%;
+ height: 100%;
+ background-color: #555;
+}
+.stage {
+ width: 1500px;
+ height: 1000px;
+ overflow: scroll;
+}
+.toolbar {
+ display: flex;
+ flex-direction: row;
+ gap: 10px;
+ padding: 5px;
+ flex-wrap: wrap;
+ align-content: flex-start;
+ justify-content: space-around;
+}
+.toolbtn {
+ width: calc( 3 * var(--lineheight) );
+ height: calc( 3 * var(--lineheight) );
+ background-color: #2f2f2f;
+}
+
+.horiz_break {
+ width: 100%;
+ height: 5px;
+
+ background-color: #2f2f2f;
+}
+.clr-field {
+ width: 100%;
+}
+.clr-field button {
+ width: 50% !important;
+ /* height: 100% !important; */
+ /* margin: 100px; */
+ border-radius: 5px;
+}
+.clr-field input {
+ width: 50%;
+}
+.infopanel {
+ width: 100%;
+ height: 100%;
+ background-color: #3f3f3f;
+ display: flex;
+ box-sizing: border-box;
+ gap: calc( var(--lineheight) / 2 );
+ padding: calc( var(--lineheight) / 2 );
+ flex-direction: column;
+ flex-wrap: wrap;
+ align-content: flex-start;
+}
+.infopanel-field {
+ width: 300px;
+ height: var(--lineheight);
+ display: flex;
+ flex-direction: row;
+}
+.infopanel-label {
+ flex: 1 1 50%;
+}
+.infopanel-input {
+ flex: 1 1 50%;
+ width: 50%;
+}
+.layers-container {
+ overflow-y: scroll;
+}
+.layers {
+ background-color: #222222;
+ display: flex;
+ flex-direction: column;
+ flex-wrap: nowrap;
+ min-height: 100%;
+}
+.frames-container {
+ background-color: #222222;
+ display: flex;
+ flex-direction: column;
+ flex-wrap: nowrap;
+ /* overflow-x: scroll; */
+ /* overflow-y:inherit; */
+ min-height: 100%;
+}
+.layer-header {
+ width: 100%;
+ height: calc( 2 * var(--lineheight));
+ background-color: #3f3f3f;
+ border-top: 1px solid #4f4f4f;
+ border-bottom: 1px solid #222222;
+ flex-shrink: 0;
+}
+.layer-track {
+ min-width: 100%;
+ height: calc( 2 * var(--lineheight));
+ background: repeating-linear-gradient(to right, transparent, transparent 24px, #3f3f3f 24px, #3f3f3f 25px),
+ repeating-linear-gradient(to right, #222222, #222222 100px, #151515 100px, #151515 125px);
+
+ display: flex;
+ flex-direction: row;
+ border-top: 1px solid #222222;
+ border-bottom: 1px solid #3f3f3f;
+ flex-shrink: 0;
+}
+.frame {
+ width: 25px;
+ height: 100%;
+
+ background-color: #4f4f4f;
+ flex-grow: 0;
+ flex-shrink: 0;
+ border-right: 1px solid #3f3f3f;
+ border-left: 1px solid #555555;
+}
+.frame:hover {
+ background-color: #555555;
+}
+.frame.active {
+ background-color: #666666;
+}
+.frame.keyframe {
+ position: relative;
+}
+.frame.keyframe::before {
+ content: ''; /* Creates a pseudo-element */
+ position: absolute;
+ bottom: 0; /* Position the circle at the bottom of the div */
+ left: 50%; /* Center the circle horizontally */
+ transform: translateX(-50%); /* Adjust for perfect centering */
+ width: 50%; /* Set the width of the circle to half of the div's width */
+ height: 0; /* Initially set to 0 */
+ padding-bottom: 50%; /* Set padding-bottom to 50% of the div's width to create a circle */
+ border-radius: 50%; /* Make the shape a circle */
+ background-color: #222; /* Set the color of the circle (black in this case) */
+ margin-bottom: 5px;
+}
+.frame.motion {
+ background-color: #7a00b3;
+ border: none;
+}
+.frame.motion:hover, .frame.motion.active {
+ background-color: #530379;
+ border: none;
+}
+/* :nth-child(1 of .frame.motion) {
+ background-color: blue;
+}
+:nth-last-child(1 of .frame.motion) {
+ background-color: red;
+} */
+
+.frame-highlight {
+ background-color: red;
+ width: 25px;
+ height: calc( 2 * var(--lineheight) - 2px);
+ position: relative;
+}
+
+.hidden {
+ display: none;
+}
+
+#overlay {
+ display: none; /* Hidden by default */
+ position: fixed;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ background: rgba(0, 0, 0, 0.5);
+ z-index: 999; /* Under the dialog */
+}
+
+/* Scoped styles for the dialog */
+#newFileDialog {
+ display: none; /* Hidden by default */
+ position: fixed;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+ background-color: #444;
+ border: 1px solid #333;
+ border-radius: 5px;
+ box-shadow: 0 0 10px rgba(0, 0, 0, 0.5);
+ padding: 20px;
+ width: 300px;
+ z-index: 1000; /* Make sure it's in front of other elements */
+}
+
+#newFileDialog .dialog-label {
+ display: block;
+ margin: 10px 0 5px;
+}
+
+#newFileDialog .dialog-input {
+ width: 100%;
+ padding: 8px;
+ margin: 5px 0;
+ border: 1px solid #333;
+}
+
+#newFileDialog .dialog-button {
+ width: 100%;
+ padding: 10px;
+ background-color: #007bff;
+ color: white;
+ border: none;
+ cursor: pointer;
+}
+
+#newFileDialog .dialog-button:hover {
+ background-color: #0056b3;
+}
+
+#popupMenu {
+ background-color: #222;
+ box-shadow: 0 4px 8px rgba(0,0,0,0.5);
+ padding: 20px;
+ border-radius: 5px;
+ position: absolute;
+}
+#popupMenu ul {
+ padding: 0px;
+ margin: 0px;
+}
+#popupMenu li {
+ color: #ccc;
+ list-style-type: none;
+ display: flex;
+ align-items: center; /* Vertically center the image and text */
+ padding: 5px 0; /* Add padding for better spacing */
+}
+#popupMenu li:hover {
+ background-color: #444;
+ cursor:pointer;
+}
+#popupMenu li:not(:last-child) {
+ border-bottom: 1px solid #444; /* Horizontal line for all li elements except the last */
+}
+#popupMenu li img {
+ margin-right: 10px; /* Space between the icon and text */
+ width: 20px; /* Adjust the width of the icon */
+ height: 20px; /* Adjust the height of the icon */
+}
\ No newline at end of file
diff --git a/src/utils.js b/src/utils.js
new file mode 100644
index 0000000..974df6d
--- /dev/null
+++ b/src/utils.js
@@ -0,0 +1,94 @@
+function titleCase(str) {
+ return str.charAt(0).toUpperCase() + str.slice(1).toLowerCase();
+}
+
+function getMousePositionFraction(event, element) {
+ const rect = element.getBoundingClientRect(); // Get the element's position and size
+
+ if (element.classList.contains('horizontal-grid')) {
+ // If the element has the "horizontal-grid" class, calculate the horizontal position (X)
+ const xPos = event.clientX - rect.left; // Mouse X position relative to the element
+ const fraction = xPos / rect.width; // Fraction of the width
+ return Math.min(Math.max(fraction, 0), 1); // Ensure the fraction is between 0 and 1
+ } else if (element.classList.contains('vertical-grid')) {
+ // If the element has the "vertical-grid" class, calculate the vertical position (Y)
+ const yPos = event.clientY - rect.top; // Mouse Y position relative to the element
+ const fraction = yPos / rect.height; // Fraction of the height
+ return Math.min(Math.max(fraction, 0), 1); // Ensure the fraction is between 0 and 1
+ }
+ return 0; // If neither class is present, return 0 (or handle as needed)
+ }
+
+function getKeyframesSurrounding(frames, index) {
+ let lastKeyframeBefore = undefined;
+ let firstKeyframeAfter = undefined;
+
+ // Find the last keyframe before the given index
+ for (let i = index - 1; i >= 0; i--) {
+ if (frames[i].frameType === "keyframe") {
+ lastKeyframeBefore = i;
+ break;
+ }
+ }
+
+ // Find the first keyframe after the given index
+ for (let i = index + 1; i < frames.length; i++) {
+ if (frames[i].frameType === "keyframe") {
+ firstKeyframeAfter = i;
+ break;
+ }
+ }
+ return { lastKeyframeBefore, firstKeyframeAfter };
+}
+
+function invertPixels(ctx, width, height) {
+ // Create an off-screen canvas for the pattern
+ const patternCanvas = document.createElement('canvas');
+ const patternContext = patternCanvas.getContext('2d');
+
+ // Define the size of the repeating pattern (2x2 pixels)
+ const patternSize = 2;
+ patternCanvas.width = patternSize;
+ patternCanvas.height = patternSize;
+
+ // Create the alternating pattern (regular and inverted pixels)
+ function createInvertedPattern() {
+ const patternData = patternContext.createImageData(patternSize, patternSize);
+ const data = patternData.data;
+
+ // Fill the pattern with alternating colors (inverted every other pixel)
+ for (let i = 0; i < patternSize; i++) {
+ for (let j = 0; j < patternSize; j++) {
+ const index = (i * patternSize + j) * 4;
+ // Determine if we should invert the color
+ if ((i + j) % 2 === 0) {
+ data[index] = 255; // Red
+ data[index + 1] = 0; // Green
+ data[index + 2] = 0; // Blue
+ data[index + 3] = 255; // Alpha
+ } else {
+ data[index] = 0; // Red (inverted)
+ data[index + 1] = 255; // Green (inverted)
+ data[index + 2] = 255; // Blue (inverted)
+ data[index + 3] = 255; // Alpha
+ }
+ }
+ }
+
+ // Set the pattern on the off-screen canvas
+ patternContext.putImageData(patternData, 0, 0);
+ return patternCanvas;
+ }
+
+ // Create the pattern using the function
+ const pattern = ctx.createPattern(createInvertedPattern(), 'repeat');
+
+ // Draw a rectangle with the pattern
+ ctx.globalCompositeOperation = "difference"
+ ctx.fillStyle = pattern;
+ ctx.fillRect(0, 0, width, height);
+
+ ctx.globalCompositeOperation = "source-over"
+}
+
+export { titleCase, getMousePositionFraction, getKeyframesSurrounding, invertPixels };
\ No newline at end of file
diff --git a/src/vector.js b/src/vector.js
new file mode 100644
index 0000000..704a41f
--- /dev/null
+++ b/src/vector.js
@@ -0,0 +1,74 @@
+class Vector {
+ constructor(x, y, z) {
+ if (arguments.length === 1) {
+ z = x.z;
+ y = x.y;
+ x = x.x;
+ }
+ this.x = x;
+ this.y = y;
+ if (z !== undefined) {
+ this.z = z;
+ }
+ }
+ dist(other, y, z = 0) {
+ if (y !== undefined) other = { x: other, y, z };
+ let sum = 0;
+ sum += (this.x - other.x) ** 2;
+ sum += (this.y - other.y) ** 2;
+ let z1 = this.z ? this.z : 0;
+ let z2 = other.z ? other.z : 0;
+ sum += (z1 - z2) ** 2;
+ return sum ** 0.5;
+ }
+ normalize(f) {
+ let mag = this.dist(0, 0, 0);
+ return new Vector((f * this.x) / mag, (f * this.y) / mag, (f * this.z) / mag);
+ }
+ getAngle() {
+ return -Math.atan2(this.y, this.x);
+ }
+ reflect(other) {
+ let p = new Vector(other.x - this.x, other.y - this.y);
+ if (other.z !== undefined) {
+ p.z = other.z;
+ if (this.z !== undefined) {
+ p.z -= this.z;
+ }
+ }
+ return this.subtract(p);
+ }
+ add(other) {
+ let p = new Vector(this.x + other.x, this.y + other.y);
+ if (this.z !== undefined) {
+ p.z = this.z;
+ if (other.z !== undefined) {
+ p.z += other.z;
+ }
+ }
+ return p;
+ }
+ subtract(other) {
+ let p = new Vector(this.x - other.x, this.y - other.y);
+ if (this.z !== undefined) {
+ p.z = this.z;
+ if (other.z !== undefined) {
+ p.z -= other.z;
+ }
+ }
+ return p;
+ }
+ scale(f = 1) {
+ if (f === 0) {
+ return new Vector(0, 0, this.z === undefined ? undefined : 0);
+ }
+ let p = new Vector(this.x * f, this.y * f);
+ if (this.z !== undefined) {
+ p.z = this.z * f;
+ }
+ return p;
+ }
+ }
+
+ export { Vector };
+
\ No newline at end of file