├── .gitattributes ├── .gitignore ├── LICENSE ├── README.md ├── build.js ├── package.json └── src ├── area ├── functions.js └── select.js ├── batch ├── README.md ├── filter │ ├── blend.js │ ├── invert.js │ ├── onion.js │ ├── replace.js │ ├── shading.js │ └── smoothing.js ├── index.js ├── matrix.js └── tile.js ├── bounds └── index.js ├── camera └── functions.js ├── cfg.js ├── color.js ├── container └── index.js ├── env ├── fill.js ├── functions.js └── insert.js ├── event ├── emitter.js └── listener.js ├── extend.js ├── index.js ├── layer ├── README.md ├── index.js └── matrix.js ├── math.js ├── render ├── buffer.js ├── build.js ├── draw.js ├── generate.js ├── main.js ├── render.js ├── resize.js └── shaders.js ├── setup.js ├── stack ├── cmd.js ├── kind.js ├── redo.js ├── state.js └── undo.js ├── storage ├── read.js └── write.js ├── transform ├── flip.js └── rotate.js ├── ui └── index.js └── utils.js /.gitattributes: -------------------------------------------------------------------------------- 1 | # Line endings: enforce LF in GitHub, convert to native on checkout. 2 | 3 | * text=auto 4 | *.js text 5 | 6 | # Make GitHub ignore vendor libraries while computing language stats. 7 | # See https://github.com/github/linguist#overrides. 8 | 9 | *.proto linguist-vendored=true 10 | *.sh linguist-vendored=true 11 | *.bat linguist-vendored=true 12 | *.css linguist-vendored=true 13 | *.html linguist-vendored=true 14 | 15 | # Explicitly specify language for non-standard extensions used under 16 | # ide/web/lib/templates to make GitHub correctly count their language stats. 17 | # 18 | *.js_ linguist-language=JavaScript -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | logs 2 | *.log 3 | npm-debug.log* 4 | node_modules 5 | 6 | benchmark -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 2-Clause License 2 | 3 | Copyright (c) 2017, Felix Maier 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | * Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | * Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 17 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 18 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 19 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 20 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 21 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 22 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 23 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 24 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 25 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 26 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 |

3 | Poxi is a modern, flat pixel art editor for the browser with focus on elegance, simplicity and productivity. 4 |

5 |
6 | 7 |
8 | Demo 9 |

10 | 11 | BSD-2 12 | 13 | 14 | No dependencies 15 | 16 | 17 | No dependencies 18 | 19 | 20 | Stability 21 | 22 | 23 | Woot Woot! 24 | 25 |
26 | 27 | ### Engine features 28 | - Smart batching 29 | - WebGL-based renderer 30 | - Low-level matrices 31 | - Undo/Redo for all operations 32 | - Infinite grid 33 | - Copy by reference 34 | 35 | I've created this pixel editor because of the lack of smooth pixel editors inside the browser. All current implementations lack of speed and just feel clunky and slow. I've created a whole low-level pixel matrix framework from scratch for this, offering incredible speed, a undo/redo state machine and various basic transformation methods. This allows you to work on images even larger than 8000px á 8000px with very low memory consummation. 36 | 37 | ### Coming soon 38 | - Animations 39 | - Selections 40 | - Faster bucket filling 41 | 42 | ### Contributing 43 | 44 | Code related pull requests are very welcome, but please make sure they match the existing code style. 45 | 46 | ### License 47 | [BSD-2](https://github.com/maierfelix/poxi/blob/master/LICENSE) 48 | -------------------------------------------------------------------------------- /build.js: -------------------------------------------------------------------------------- 1 | const fs = require("fs"); 2 | const url = require("url"); 3 | const path = require("path"); 4 | const buble = require("rollup-plugin-buble"); 5 | const rollup = require("rollup"); 6 | const electron = require("electron"); 7 | 8 | const bundleSource = () => { 9 | return new Promise((resolve, reject) => { 10 | rollup.rollup({ 11 | entry: __dirname + "/src/index.js", 12 | plugins: [ buble() ], 13 | }).then((bundle) => { 14 | const result = bundle.generate({ 15 | format: "cjs" 16 | }); 17 | const code = "(function() { " + result.code + "})();"; 18 | fs.writeFileSync("static/bundle.js", code); 19 | resolve(); 20 | }).catch((e) => { 21 | reject(e); 22 | }); 23 | }); 24 | }; 25 | 26 | // now open up electron with our fresh rolluped bundle 27 | const initElectron = () => { 28 | return new Promise((resolve) => { 29 | 30 | const app = electron.app; 31 | const BrowserWindow = electron.BrowserWindow; 32 | 33 | let win = null; 34 | const createWindow = () => { 35 | win = new BrowserWindow({ 36 | width: 980, 37 | height: 680, 38 | titleBarStyle: "hidden", 39 | icon: path.join(__dirname, "/static/assets/img/tree.png") 40 | }); 41 | 42 | win.loadURL(url.format({ 43 | pathname: path.join(__dirname, "/static/index.html"), 44 | protocol: "file:", 45 | slashes: true 46 | })); 47 | win.setMenu(null); 48 | 49 | //win.setFullScreen(true); 50 | win.webContents.openDevTools(); 51 | win.on("closed", () => { 52 | win = null; 53 | }); 54 | resolve({ win, app }); 55 | }; 56 | 57 | app.on("ready", createWindow); 58 | app.on("window-all-closed", () => { 59 | if (process.platform !== "darwin") app.quit(); 60 | }); 61 | app.on("activate", () => { 62 | if (win === null) createWindow(); 63 | }); 64 | 65 | }); 66 | }; 67 | 68 | const initializeStage = () => { 69 | return new Promise((resolve) => { 70 | bundleSource().then(() => { 71 | initElectron().then(resolve); 72 | }).catch((e) => { 73 | throw new Error(e); 74 | }); 75 | }); 76 | }; 77 | 78 | // simple live reload system 79 | initializeStage().then((old) => { 80 | const onRefresh = (e, file) => { 81 | // prevent circular tracking 82 | if (file === "bundle.js") return; 83 | bundleSource().then(() => { 84 | old.win.reload(); 85 | old.win.webContents.reloadIgnoringCache(); 86 | old.win.webContents.openDevTools(); 87 | console.log("Refreshed!", "#" + Date.now()); 88 | }); 89 | }; 90 | fs.watch("./src/", {recursive: true}, onRefresh); 91 | fs.watch("./static/", {recursive: true}, onRefresh); 92 | }); 93 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "poxi", 3 | "version": "0.5.3", 4 | "description": "A modern flat pixel editor", 5 | "license": "BSD-2-Clause", 6 | "repository": { 7 | "type": "git", 8 | "url": "git+ssh://git@github.com/maierfelix/poxi.git" 9 | }, 10 | "main": "build.js", 11 | "homepage": "https://github.com/maierfelix/poxi#readme", 12 | "author": "Felix Maier ", 13 | "contributors": [ 14 | "Felix Maier (https://github.com/maierfelix)" 15 | ], 16 | "bugs": { 17 | "url": "https://github.com/maierfelix/poxi/issues" 18 | }, 19 | "keywords": [], 20 | "scripts": { 21 | "start": "electron ." 22 | }, 23 | "engines": { 24 | "node": ">= 6.x" 25 | }, 26 | "devDependencies": { 27 | "ws": "^2.3.1", 28 | "electron": "^1.6.6", 29 | "rollup": "^0.41.6", 30 | "rollup-plugin-buble": "^0.15.0" 31 | }, 32 | "dependencies": {} 33 | } 34 | -------------------------------------------------------------------------------- /src/area/functions.js: -------------------------------------------------------------------------------- 1 | import { SELECTION_COLOR } from "../cfg"; 2 | 3 | import { 4 | createCanvasBuffer 5 | } from "../utils"; 6 | 7 | import { 8 | bytesToRgba, 9 | colorToRgbaString 10 | } from "../color"; 11 | 12 | import CommandKind from "../stack/kind"; 13 | 14 | /** 15 | * @param {Object} selection 16 | */ 17 | export function copy(selection) { 18 | this.clipboard.copy = null; 19 | // shape based selection 20 | if (selection.shape !== null) { 21 | this.copyByShape(selection); 22 | } else { 23 | this.copyBySelection(selection); 24 | } 25 | }; 26 | 27 | /** 28 | * Shape-based copying 29 | * @param {Object} selection 30 | */ 31 | export function copyByShape(selection) { 32 | const shape = selection.shape; 33 | const data = shape.data; 34 | const bx = shape.bounds.x; const by = shape.bounds.y; 35 | const bw = shape.bounds.w; const bh = shape.bounds.h; 36 | let pixels = []; 37 | for (let ii = 0; ii < data.length; ii += 4) { 38 | const idx = ii / 4; 39 | const xx = idx % bw; 40 | const yy = (idx / bw) | 0; 41 | const px = (yy * bw + xx) * 4; 42 | const alpha = data[px + 3]; 43 | // ignore shape pixels that aren't used 44 | if (alpha <= 0) continue; 45 | const pixel = this.getAbsolutePixelAt(bx + xx, by + yy); 46 | if (pixel === null) continue; 47 | pixels.push({ 48 | x: xx, y: yy, color: pixel 49 | }); 50 | }; 51 | this.clipboard.copy = { 52 | pixels: pixels, 53 | selection: selection 54 | }; 55 | }; 56 | 57 | /** 58 | * Rectangle-based copying 59 | * @param {Object} selection 60 | */ 61 | export function copyBySelection(selection) { 62 | const x = selection.x; const y = selection.y; 63 | const w = selection.w; const h = selection.h; 64 | let pixels = []; 65 | for (let ii = 0; ii < w * h; ++ii) { 66 | const xx = ii % w; 67 | const yy = (ii / w) | 0; 68 | const pixel = this.getAbsolutePixelAt(x + xx, y + yy); 69 | if (pixel === null) continue; 70 | pixels.push({ 71 | x: xx, y: yy, color: pixel 72 | }); 73 | }; 74 | this.clipboard.copy = { 75 | pixels: pixels, 76 | selection: selection 77 | }; 78 | }; 79 | 80 | /** 81 | * @param {Number} x 82 | * @param {Number} y 83 | * @param {Object} sel 84 | * @return {Void} 85 | */ 86 | export function pasteAt(x, y, sel) { 87 | const pixels = sel.pixels; 88 | if (pixels === null || !pixels.length) return; 89 | const layer = this.getCurrentLayer(); 90 | const batch = layer.createBatchAt(x, y); 91 | batch.resizeRectangular( 92 | x, y, 93 | sel.w - 1, sel.h - 1 94 | ); 95 | for (let ii = 0; ii < pixels.length; ++ii) { 96 | const pixel = pixels[ii]; 97 | const color = pixel.color; 98 | batch.drawPixelFast(x + pixel.x, y + pixel.y, color); 99 | }; 100 | batch.refreshTexture(false); 101 | this.enqueue(CommandKind.PASTE, batch); 102 | return; 103 | }; 104 | 105 | /** 106 | * @param {Object} selection 107 | * @return {Void} 108 | */ 109 | export function cut(selection) { 110 | this.copy(selection); 111 | const pixels = this.clipboard.copy.pixels; 112 | if (pixels === null || !pixels.length) return; 113 | this.clearSelection(selection); 114 | return; 115 | }; 116 | 117 | /** 118 | * Shape-based clearing 119 | * @param {Object} selection 120 | * @return {Void} 121 | */ 122 | export function clearSelection(selection) { 123 | const shape = selection.shape; 124 | const bounds = shape.bounds; 125 | const data = shape.data; 126 | const x = selection.x; const y = selection.y; 127 | const w = selection.w; const h = selection.h; 128 | const layer = this.getCurrentLayer(); 129 | layer.createBatchAt(x, y); 130 | batch.isEraser = true; 131 | const bw = bounds.w; const bh = bounds.h; 132 | batch.resizeRectangular( 133 | x, y, 134 | w - 1, h - 1 135 | ); 136 | let count = 0; 137 | for (let ii = 0; ii < data.length; ii += 4) { 138 | const idx = (ii / 4) | 0; 139 | const xx = (idx % bw) | 0; 140 | const yy = (idx / bw) | 0; 141 | const px = (yy * bw + xx) * 4; 142 | if (data[px + 3] <= 0) continue; 143 | const pixel = this.getAbsolutePixelAt(x + xx, y + yy); 144 | // only erase if we have sth to erase 145 | if (pixel === null) continue; 146 | batch.erasePixelFast(x + xx, y + yy, pixel); 147 | count++; 148 | }; 149 | // nothing to change 150 | if (count <= 0) { 151 | batch.kill(); 152 | return; 153 | } 154 | batch.refreshTexture(false); 155 | this.enqueue(CommandKind.CLEAR, batch); 156 | return; 157 | }; 158 | 159 | /** 160 | * @param {Number} x 161 | * @param {Number} y 162 | * @return {Batch} 163 | */ 164 | export function getShapeAt(x, y) { 165 | const color = this.getAbsolutePixelAt(x, y); 166 | if (color === null) return (null); 167 | const shape = this.getBinaryShape(x, y, color); 168 | if (shape === null) return (null); 169 | const batch = this.createDynamicBatch(x, y); 170 | const bounds = this.bounds; 171 | const bx = bounds.x; 172 | const by = bounds.y; 173 | const bw = bounds.w; 174 | const bh = bounds.h; 175 | // create buffer to draw a fake shape into 176 | const buffer = createCanvasBuffer(bw, bh); 177 | const rgba = bytesToRgba(SELECTION_COLOR); 178 | rgba[3] = 0.45; 179 | buffer.fillStyle = colorToRgbaString(rgba); 180 | for (let ii = 0; ii < shape.length; ++ii) { 181 | const xx = (ii % bw); 182 | const yy = (ii / bw) | 0; 183 | if (shape[yy * bw + xx] !== 2) continue; 184 | buffer.fillRect( 185 | xx, yy, 186 | 1, 1 187 | ); 188 | }; 189 | batch.buffer = buffer; 190 | batch.data = new Uint8Array(buffer.getImageData(0, 0, bw, bh).data); 191 | batch.bounds.update(bx, by, bw, bh); 192 | batch.resizeByMatrixData(); 193 | batch.refreshTexture(true); 194 | return (batch); 195 | }; 196 | -------------------------------------------------------------------------------- /src/area/select.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @return {Object} 3 | */ 4 | export function getSelection() { 5 | // active shape selection 6 | if (this.shape !== null) { 7 | const bounds = this.shape.bounds; 8 | return ({ 9 | shape: this.shape, 10 | x: bounds.x, y: bounds.y, 11 | w: bounds.w, h: bounds.h 12 | }); 13 | } 14 | let x = this.sx; let y = this.sy; 15 | let w = this.sw; let h = this.sh; 16 | if (w < 0) x += w; 17 | if (h < 0) y += h; 18 | w = w < 0 ? -w : w; 19 | h = h < 0 ? -h : h; 20 | return ({ 21 | shape: null, 22 | x: x, y: y, 23 | w: w, h: h 24 | }); 25 | }; 26 | 27 | /** 28 | * @param {Number} x 29 | * @param {Number} y 30 | */ 31 | export function selectFrom(x, y) { 32 | x = x | 0; 33 | y = y | 0; 34 | const relative = this.getRelativeTileOffset(x, y); 35 | this.sx = relative.x; 36 | this.sy = relative.y; 37 | this.sw = this.sh = 0; 38 | this.redraw = true; 39 | }; 40 | 41 | /** 42 | * @param {Number} x 43 | * @param {Number} y 44 | */ 45 | export function selectTo(x, y) { 46 | x = x | 0; 47 | y = y | 0; 48 | const relative = this.getRelativeTileOffset(x, y); 49 | let w = relative.x - this.sx; 50 | let h = relative.y - this.sy; 51 | w = w + (w >= 0 ? 1 : 0); 52 | h = h + (h >= 0 ? 1 : 0); 53 | this.sw = w; 54 | this.sh = h; 55 | this.redraw = true; 56 | }; 57 | 58 | export function resetSelection() { 59 | this.sx = this.sy = 0; 60 | this.sw = this.sh = -0; 61 | if (this.shape !== null) { 62 | this.destroyTexture(this.shape.texture); 63 | this.shape = null; 64 | } 65 | this.redraw = true; 66 | }; 67 | -------------------------------------------------------------------------------- /src/batch/README.md: -------------------------------------------------------------------------------- 1 | # Batches 2 | 3 | Poxi batches your drawings to allow undo/redo as well as to draw them in an efficient way. 4 | 5 | There are two kinds of batches: 6 | 1. Pixel batches, which contain an Uint8Array storing the drawn pixel data 7 | 2. Erase batches, which contain an Uint8Array storing erased pixel the same way as found in pixel batches, but get handled different in the undo/redo process (inject, deject pixels). 8 | 9 | Batches store a bounding property, which contains their dynamic position on the grid and their absolute width and height. Batch boundings get automatically updated as soon as their content changes. Batches also have a method to resize down their pixel data by the minimum boundings, e.g. auto cropping out unused parts of their content. 10 | 11 | Batches also contain a WebGLTexture property, which is the reference to the active texture on the GPU. As soon as a batch's pixel data get's changed, it's texture gets automatically updated and/or destroyed if it got resized as well. When a batch gets destroyed (e.g. by undoing or exceeding the undo stack size limit), the texture also gets automatically freed from the GPU's memory. 12 | -------------------------------------------------------------------------------- /src/batch/filter/blend.js: -------------------------------------------------------------------------------- 1 | import { SETTINGS } from "../../cfg"; 2 | 3 | import CommandKind from "../../stack/kind"; 4 | 5 | /** 6 | * Shade or tint 7 | * @param {Number} x 8 | * @param {Number} y 9 | * @param {Number} factor 10 | */ 11 | export function applyColorLightness(x, y, factor) { 12 | const instance = this.instance; 13 | const bounds = this.bounds; 14 | const layer = this.layer; 15 | const w = SETTINGS.LIGHT_SIZE; 16 | const h = SETTINGS.LIGHT_SIZE; 17 | for (let ii = 0; ii < w * h; ++ii) { 18 | const xx = x + (ii % w) | 0; 19 | const yy = y + (ii / w) | 0; 20 | const pixel = layer.getLivePixelAt(xx, yy); 21 | if (pixel === null) continue; 22 | const t = factor < 0 ? 0 : 255; 23 | const p = factor < 0 ? -factor : factor; 24 | const r = (Math.round((t - pixel[0]) * p) + pixel[0]); 25 | const g = (Math.round((t - pixel[1]) * p) + pixel[1]); 26 | const b = (Math.round((t - pixel[2]) * p) + pixel[2]); 27 | const a = pixel[3]; 28 | this.drawPixel(xx, yy, [r, g, b, a]); 29 | }; 30 | }; 31 | -------------------------------------------------------------------------------- /src/batch/filter/invert.js: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maierfelix/poxi/08e58c0216e7fda7c975321c1ca75407929f4063/src/batch/filter/invert.js -------------------------------------------------------------------------------- /src/batch/filter/onion.js: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maierfelix/poxi/08e58c0216e7fda7c975321c1ca75407929f4063/src/batch/filter/onion.js -------------------------------------------------------------------------------- /src/batch/filter/replace.js: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maierfelix/poxi/08e58c0216e7fda7c975321c1ca75407929f4063/src/batch/filter/replace.js -------------------------------------------------------------------------------- /src/batch/filter/shading.js: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maierfelix/poxi/08e58c0216e7fda7c975321c1ca75407929f4063/src/batch/filter/shading.js -------------------------------------------------------------------------------- /src/batch/filter/smoothing.js: -------------------------------------------------------------------------------- 1 | 2 | /** 3 | * Remove L shaped corners 4 | * http://deepnight.net/pixel-perfect-drawing/ 5 | * @param {Batch} batch 6 | */ 7 | export function applyPixelSmoothing(batch) { 8 | const bw = batch.bounds.w; 9 | const bh = batch.bounds.h; 10 | const tiles = batch.data; 11 | for (let ii = 0; ii < tiles.length; ii += 4) { 12 | if (!(ii > 0 && ii + 1 < tiles.length)) continue; 13 | const x = (ii % bw); 14 | const y = (ii / bw) | 0; 15 | const px = (yy * bw + xx) * 4; 16 | if ( 17 | (w.x === o.x || w.y === o.y) && 18 | (e.x === o.x || e.y === o.y) && 19 | (w.x !== e.x) && (w.y !== e.y) 20 | ) { 21 | tiles[ii + 0] = 0; 22 | tiles[ii + 1] = 0; 23 | tiles[ii + 2] = 0; 24 | tiles[ii + 3] = 0; 25 | } 26 | }; 27 | }; 28 | -------------------------------------------------------------------------------- /src/batch/index.js: -------------------------------------------------------------------------------- 1 | import { MAX_SAFE_INTEGER } from "../cfg"; 2 | 3 | import { 4 | uid, 5 | createCanvasBuffer 6 | } from "../utils"; 7 | 8 | import { colorToRgbaString } from "../color"; 9 | 10 | import extend from "../extend"; 11 | 12 | import Boundings from "../bounds/index"; 13 | 14 | import * as _tile from "./tile"; 15 | import * as _matrix from "./matrix"; 16 | 17 | import * as _blend from "./filter/blend"; 18 | import * as _invert from "./filter/invert"; 19 | import * as _onion from "./filter/onion"; 20 | import * as _replace from "./filter/replace"; 21 | import * as _shading from "./filter/shading"; 22 | import * as _smoothing from "./filter/smoothing"; 23 | 24 | /** 25 | * @class Batch 26 | */ 27 | class Batch { 28 | /** 29 | * @param {Poxi} instance 30 | * @constructor 31 | */ 32 | constructor(instance) { 33 | this.id = uid(); 34 | // save reference to layer 35 | this.layer = null; 36 | // reference to stage 37 | this.instance = instance; 38 | // data related 39 | this.data = null; 40 | this.reverse = null; 41 | // buffer related 42 | // used for canvas batches 43 | this.buffer = null; 44 | // wgl texture 45 | // TODO: textures can possibly get from gpu as soon as mouseUp event passed 46 | this.texture = null; 47 | // relative boundings 48 | this.bounds = new Boundings(); 49 | // we use this batch for erasing 50 | this.isEraser = false; 51 | // we use this batch for moving 52 | this.isMover = false; 53 | this.position = { x: 0, y: 0, mx: 0, my: 0 }; 54 | // indicates if we should force to render this batch 55 | // even when it is not registered inside our stack yet 56 | this.forceRendering = false; 57 | } 58 | }; 59 | 60 | /** 61 | * @return {Batch} 62 | */ 63 | Batch.prototype.clone = function() { 64 | const batch = new Batch(this.instance); 65 | batch.prepareMatrix(this.bounds.x, this.bounds.y); 66 | // clone props 67 | batch.layer = this.layer; 68 | batch.buffer = this.buffer; 69 | batch.bounds = this.bounds.clone(); 70 | batch.isEraser = this.isEraser; 71 | batch.isMover = this.isMover; 72 | batch.position = Object.assign(this.position); 73 | batch.forceRendering = this.forceRendering; 74 | // clone matrices 75 | batch.data = new Uint8Array(this.data); 76 | batch.reverse = new Uint8Array(this.reverse); 77 | batch.refreshTexture(true); 78 | return (batch); 79 | }; 80 | 81 | /** 82 | * @param {Number} x 83 | * @param {Number} y 84 | */ 85 | Batch.prototype.move = function(x, y) { 86 | const xx = x - this.position.mx; 87 | const yy = y - this.position.my; 88 | this.position.x += xx; 89 | this.position.y += yy; 90 | this.layer.x += xx; 91 | this.layer.y += yy; 92 | this.position.mx = x; 93 | this.position.my = y; 94 | }; 95 | 96 | /** 97 | * @return {Number} 98 | */ 99 | Batch.prototype.getStackIndex = function() { 100 | const id = this.id; 101 | const commands = this.instance.stack; 102 | for (let ii = 0; ii < commands.length; ++ii) { 103 | const cmd = commands[ii]; 104 | if (cmd.batch.id === id) return (ii); 105 | }; 106 | return (-1); 107 | }; 108 | 109 | Batch.prototype.kill = function() { 110 | const id = this.id; 111 | const instance = this.instance; 112 | const layers = instance.layers; 113 | // free batch from memory 114 | this.bounds = null; 115 | this.buffer = null; 116 | this.data = null; 117 | this.reverse = null; 118 | this.destroyTexture(); 119 | // finally remove batch from layer 120 | let count = 0; 121 | for (let ii = 0; ii < layers.length; ++ii) { 122 | const batches = layers[ii].batches; 123 | for (let jj = 0; jj < batches.length; ++jj) { 124 | const batch = batches[jj]; 125 | if (batch.id === id) { 126 | batches.splice(jj, 1); 127 | layers[ii].updateBoundings(); 128 | count++; 129 | } 130 | }; 131 | }; 132 | /*if (count <= 0 && !this.isMover) { 133 | throw new Error(`Failed to kill batch:${this.id}`); 134 | }*/ 135 | }; 136 | 137 | /** 138 | * Frees the batch texture from the gpu 139 | */ 140 | Batch.prototype.destroyTexture = function() { 141 | const texture = this.texture; 142 | if (texture !== null) { 143 | this.instance.destroyTexture(texture); 144 | this.texture = null; 145 | } 146 | }; 147 | 148 | /** 149 | * @param {Boolean} state 150 | */ 151 | Batch.prototype.refreshTexture = function(resized) { 152 | const bounds = this.bounds; 153 | const bw = bounds.w; const bh = bounds.h; 154 | const instance = this.instance; 155 | if (resized) { 156 | // free old texture from memory 157 | if (this.texture !== null) { 158 | instance.destroyTexture(this.texture); 159 | } 160 | this.texture = instance.bufferTexture(this.id, this.data, bw, bh); 161 | } else { 162 | instance.updateTexture(this.texture, this.data, bw, bh); 163 | } 164 | // trigger our stage to get redrawn 165 | this.instance.redraw = true; 166 | }; 167 | 168 | extend(Batch, _tile); 169 | extend(Batch, _matrix); 170 | 171 | extend(Batch, _blend); 172 | extend(Batch, _invert); 173 | extend(Batch, _onion); 174 | extend(Batch, _replace); 175 | extend(Batch, _shading); 176 | extend(Batch, _smoothing); 177 | 178 | export default Batch; 179 | -------------------------------------------------------------------------------- /src/batch/matrix.js: -------------------------------------------------------------------------------- 1 | import { 2 | MAX_SAFE_INTEGER, 3 | BATCH_JUMP_RESIZE 4 | } from "../cfg"; 5 | 6 | import { 7 | alphaByteToRgbAlpha, 8 | additiveAlphaColorBlending 9 | } from "../color"; 10 | 11 | /** 12 | * @param {Number} x 13 | * @param {Number} y 14 | * @param {Number} w 15 | * @param {Number} h 16 | */ 17 | export function resizeRectangular(x, y, w, h) { 18 | this.resizeByOffset(x, y); 19 | this.resizeByOffset(x + w, y + h); 20 | }; 21 | 22 | /** 23 | * @param {Number} x 24 | * @param {Number} y 25 | */ 26 | export function resizeByOffset(x, y) { 27 | const bounds = this.bounds; 28 | let bx = bounds.x; let by = bounds.y; 29 | const dx = x - this.layer.x; 30 | const dy = y - this.layer.y; 31 | const w = (Math.abs(bx - dx) | 0) + 1; 32 | const h = (Math.abs(by - dy) | 0) + 1; 33 | const ox = bx; const oy = by; 34 | const ow = bounds.w; const oh = bounds.h; 35 | const xx = -(bx - dx) | 0; 36 | const yy = -(by - dy) | 0; 37 | // resize bound rect to left, top 38 | if (xx < 0) { 39 | bx += xx - BATCH_JUMP_RESIZE; 40 | bounds.w += Math.abs(xx) + BATCH_JUMP_RESIZE; 41 | } 42 | if (yy < 0) { 43 | by += yy - BATCH_JUMP_RESIZE; 44 | bounds.h += Math.abs(yy) + BATCH_JUMP_RESIZE; 45 | } 46 | // resize bound to right, bottom 47 | if (w > bounds.w) bounds.w = w + BATCH_JUMP_RESIZE; 48 | if (h > bounds.h) bounds.h = h + BATCH_JUMP_RESIZE; 49 | bounds.x = bx; bounds.y = by; 50 | // make sure we only resize if necessary 51 | if (ow !== bounds.w || oh !== bounds.h) { 52 | this.resizeMatrix( 53 | ox - bx, oy - by, 54 | bounds.w - ow, bounds.h - oh 55 | ); 56 | } 57 | }; 58 | 59 | /** 60 | * Resizes matrix by calculating min/max of x,y,w,h 61 | * @return {Void} 62 | */ 63 | export function resizeByMatrixData() { 64 | const data = this.data; 65 | const bounds = this.bounds; 66 | const bx = bounds.x; const by = bounds.y; 67 | const bw = bounds.w; const bh = bounds.h; 68 | const ox = bounds.x; const oy = bounds.y; 69 | const ow = bounds.w; const oh = bounds.h; 70 | let x = MAX_SAFE_INTEGER; let y = MAX_SAFE_INTEGER; 71 | let w = -MAX_SAFE_INTEGER; let h = -MAX_SAFE_INTEGER; 72 | let count = 0; 73 | for (let ii = 0; ii < data.length; ii += 4) { 74 | const idx = (ii / 4) | 0; 75 | const xx = (idx % bw) | 0; 76 | const yy = (idx / bw) | 0; 77 | const px = 4 * (yy * bw + xx); 78 | const r = data[px + 0]; 79 | const g = data[px + 1]; 80 | const b = data[px + 2]; 81 | const a = data[px + 3]; 82 | // ignore empty tiles 83 | if (a <= 0) continue; 84 | // x, y 85 | if (xx >= 0 && xx <= x) x = xx; 86 | if (yy >= 0 && yy <= y) y = yy; 87 | // width, height 88 | if (xx >= 0 && xx >= w) w = xx; 89 | if (yy >= 0 && yy >= h) h = yy; 90 | count++; 91 | }; 92 | const nx = (w - (-x + w)); 93 | const ny = (h - (-y + h)); 94 | const nbx = bounds.x + nx; const nby = bounds.y + ny; 95 | const nbw = (-x + w) + 1; const nbh = (-y + h) + 1; 96 | // abort if nothing has changed 97 | if (count <= 0) return; 98 | if (ox === nbx && oy === nby && ow === nbw && oh === nbh) return; 99 | bounds.x = nbx; bounds.y = nby; 100 | bounds.w = nbw; bounds.h = nbh; 101 | this.resizeMatrix( 102 | ox - nbx, oy - nby, 103 | nbw - ow, nbh - oh 104 | ); 105 | return; 106 | }; 107 | 108 | /** 109 | * Resize internal array buffers 110 | * and join old matrix with new one 111 | * @param {Number} x - Resize left 112 | * @param {Number} y - Resize top 113 | * @param {Number} w - Resize right 114 | * @param {Number} h - Resize bottom 115 | * @return {Void} 116 | */ 117 | export function resizeMatrix(x, y, w, h) { 118 | const data = this.data; 119 | const rdata = this.reverse; 120 | const nw = this.bounds.w; const nh = this.bounds.h; 121 | const ow = nw - w; const oh = nh - h; 122 | const size = 4 * (nw * nh); 123 | const buffer = new Uint8Array(size); 124 | const reverse = new Uint8Array(size); 125 | for (let ii = 0; ii < data.length; ii += 4) { 126 | const idx = (ii / 4) | 0; 127 | const xx = (idx % ow) | 0; 128 | const yy = (idx / ow) | 0; 129 | const opx = 4 * (yy * ow + xx); 130 | // black magic 🦄 131 | const npx = opx + (yy * (nw - ow) * 4) + (x * 4) + ((y * 4) * nw); 132 | if (data[opx + 3] <= 0) continue; 133 | // refill data 134 | buffer[npx + 0] = data[opx + 0]; 135 | buffer[npx + 1] = data[opx + 1]; 136 | buffer[npx + 2] = data[opx + 2]; 137 | buffer[npx + 3] = data[opx + 3]; 138 | if (rdata[opx + 3] <= 0) continue; 139 | // refill reverse data 140 | reverse[npx + 0] = rdata[opx + 0]; 141 | reverse[npx + 1] = rdata[opx + 1]; 142 | reverse[npx + 2] = rdata[opx + 2]; 143 | reverse[npx + 3] = rdata[opx + 3]; 144 | }; 145 | this.data = buffer; 146 | this.reverse = reverse; 147 | this.refreshTexture(true); 148 | return; 149 | }; 150 | 151 | /** 152 | * Merges given matrix 153 | * @param {Batch} batch 154 | * @param {Number} px - X-offset to merge at 155 | * @param {Number} py - Y-offset to merge at 156 | * @param {Boolean} state - Add or reverse 157 | */ 158 | export function injectMatrix(batch, state) { 159 | const buffer = this.data; 160 | const isEraser = batch.isEraser; 161 | const data = state ? batch.data : batch.reverse; 162 | const bw = batch.bounds.w | 0; 163 | const bx = (this.bounds.x) | 0; 164 | const by = (this.bounds.y) | 0; 165 | const dx = (batch.bounds.x - bx) | 0; 166 | const dy = (batch.bounds.y - by) | 0; 167 | const w = this.bounds.w | 0; const h = this.bounds.h | 0; 168 | const x = bx | 0; const y = by | 0; 169 | // loop given batch data and merge it with our main matrix 170 | for (let ii = 0; ii < data.length; ii += 4) { 171 | const idx = (ii / 4) | 0; 172 | const xx = (idx % bw) | 0; 173 | const yy = (idx / bw) | 0; 174 | const opx = ((yy * bw + xx) * 4) | 0; 175 | const npx = (opx + (yy * (w - bw) * 4) + (dx * 4) + ((dy * 4) * w)) | 0; 176 | const alpha = data[opx + 3] | 0; 177 | // erase pixel 178 | if (isEraser === true) { 179 | if (state === true && alpha > 0) { 180 | buffer[npx + 0] = buffer[npx + 1] = buffer[npx + 2] = buffer[npx + 3] = 0; 181 | continue; 182 | } 183 | } 184 | // ignore empty data pixels 185 | if (alpha <= 0 && state === true) continue; 186 | // only overwrite the reverse batch's used pixels 187 | if (state === false && alpha <= 0 && batch.data[opx + 3] <= 0) continue; 188 | // manual color blending 189 | if (buffer[npx + 3] > 0 && alpha < 255 && alpha > 0) { 190 | const src = buffer.subarray(npx, npx + 4); 191 | const dst = data.subarray(opx, opx + 4); 192 | // manual color blending 193 | if (state) { 194 | const src = buffer.subarray(npx, npx + 4); 195 | const dst = data.subarray(opx, opx + 4); 196 | const color = additiveAlphaColorBlending(src, dst); 197 | buffer[npx + 0] = color[0]; 198 | buffer[npx + 1] = color[1]; 199 | buffer[npx + 2] = color[2]; 200 | buffer[npx + 3] = color[3]; 201 | continue; 202 | // simply fill with reverse data 203 | } else { 204 | buffer[npx + 0] = data[opx + 0]; 205 | buffer[npx + 1] = data[opx + 1]; 206 | buffer[npx + 2] = data[opx + 2]; 207 | buffer[npx + 3] = data[opx + 3]; 208 | continue; 209 | } 210 | } 211 | // just fill colors with given batch data kind 212 | buffer[npx + 0] = data[opx + 0]; 213 | buffer[npx + 1] = data[opx + 1]; 214 | buffer[npx + 2] = data[opx + 2]; 215 | buffer[npx + 3] = data[opx + 3]; 216 | }; 217 | }; 218 | 219 | /** 220 | * @param {Number} x 221 | * @param {Number} y 222 | * @return {Array} 223 | */ 224 | export function getRawPixelAt(x, y) { 225 | // normalize coordinates 226 | const xx = x - (this.bounds.x); 227 | const yy = y - (this.bounds.y); 228 | // now extract the data 229 | const data = this.data; 230 | // imagedata array is 1d 231 | const idx = 4 * (yy * this.bounds.w + xx); 232 | // pixel index out of bounds 233 | if (idx < 0 || idx >= data.length) return (null); 234 | // get each color value 235 | const r = data[idx + 0]; 236 | const g = data[idx + 1]; 237 | const b = data[idx + 2]; 238 | const a = data[idx + 3]; 239 | // dont return anything if we got no valid color 240 | if (a <= 0) return (null); 241 | const color = [r, g, b, alphaByteToRgbAlpha(a)]; 242 | // finally return the color array 243 | return (color); 244 | }; 245 | 246 | /** 247 | * Clears all pixels non-reversible 248 | */ 249 | export function clear() { 250 | const data = this.data; 251 | for (let ii = 0; ii < data.length; ++ii) { 252 | data[ii] = 0; 253 | }; 254 | }; 255 | 256 | /** 257 | * @param {Number} x 258 | * @param {Number} y 259 | */ 260 | export function prepareMatrix(x, y) { 261 | const bounds = this.bounds; 262 | // we don't have a buffer to store data at yet 263 | if (this.data === null) { 264 | bounds.x = x; 265 | bounds.y = y; 266 | bounds.w = 1; 267 | bounds.h = 1; 268 | const size = 4 * (bounds.w * bounds.h); 269 | this.data = new Uint8Array(size); 270 | this.reverse = new Uint8Array(size); 271 | this.texture = this.instance.bufferTexture(this.id, this.data, bounds.w, bounds.h); 272 | } 273 | }; 274 | 275 | /** 276 | * Returns the very first found pixel color 277 | * *We expect that the batch is single colored* 278 | * @ignore 279 | * @return {Uint8Array} 280 | */ 281 | export function getBatchColor() { 282 | const data = this.data; 283 | const bounds = this.bounds; 284 | const bw = bounds.w; const bh = bounds.h; 285 | // calculate batch color 286 | for (let ii = 0; ii < bw * bh; ++ii) { 287 | const xx = (ii % bw) | 0; 288 | const yy = (ii / bw) | 0; 289 | const px = 4 * (yy * bw + xx); 290 | if (data[px + 3] <= 0) continue; 291 | return (data.subarray(px, px + 4)); 292 | }; 293 | return (null); 294 | }; 295 | 296 | /** 297 | * @return {Boolean} 298 | */ 299 | export function isEmpty() { 300 | const data = this.data; 301 | const bw = this.bounds.w; 302 | let count = 0; 303 | for (let ii = 0; ii < data.length; ii += 4) { 304 | const idx = (ii / 4) | 0; 305 | const xx = (idx % bw) | 0; 306 | const yy = (idx / bw) | 0; 307 | const px = (yy * bw + xx) * 4; 308 | const a = data[px + 3]; 309 | // ignore empty tiles 310 | if (a <= 0) continue; 311 | count++; 312 | }; 313 | return (count <= 0); 314 | }; 315 | -------------------------------------------------------------------------------- /src/batch/tile.js: -------------------------------------------------------------------------------- 1 | import { isPowerOfTwo } from "../math"; 2 | 3 | import { 4 | colorToRgbaString, 5 | rgbAlphaToAlphaByte 6 | } from "../color"; 7 | 8 | /** 9 | * @param {Number} x 10 | * @param {Number} y 11 | * @param {Number} size 12 | * @param {Array} color 13 | */ 14 | export function drawAt(x, y, size, color) { 15 | const xpad = Math.floor(size / 2); 16 | const ypad = Math.floor(size / 2); 17 | this.fillRect( 18 | x - xpad, y - ypad, 19 | size + xpad, size + ypad, 20 | color 21 | ); 22 | }; 23 | 24 | /** 25 | * @param {Number} x 26 | * @param {Number} y 27 | * @param {Number} w 28 | * @param {Number} h 29 | * @param {Array} color 30 | */ 31 | export function fillRect(x, y, w, h, color) { 32 | for (let ii = 0; ii < w * h; ++ii) { 33 | const xx = (ii % w) + x; 34 | const yy = ((ii / w) | 0) + y; 35 | this.drawPixel(xx, yy, color); 36 | }; 37 | }; 38 | 39 | /** 40 | * @param {Number} x 41 | * @param {Number} y 42 | * @param {Array} color 43 | */ 44 | export function drawPixel(x, y, color) { 45 | this.resizeByOffset(x, y); 46 | this.drawPixelFast(x, y, color); 47 | }; 48 | 49 | /** 50 | * @param {Number} x 51 | * @param {Number} y 52 | * @param {Array} color 53 | */ 54 | export function drawPixelFast(x, y, color) { 55 | const data = this.data; 56 | const rdata = this.reverse; 57 | const dx = x - this.layer.x; 58 | const dy = y - this.layer.y; 59 | const xx = dx - this.bounds.x; 60 | const yy = dy - this.bounds.y; 61 | const idx = 4 * (yy * this.bounds.w + xx); 62 | const pixel = this.layer.getPixelAt(x, y); 63 | // save earlier pixel state into reverse matrix 64 | if (rdata[idx + 3] <= 0 && pixel !== null) { 65 | rdata[idx + 0] = pixel[0]; 66 | rdata[idx + 1] = pixel[1]; 67 | rdata[idx + 2] = pixel[2]; 68 | rdata[idx + 3] = rgbAlphaToAlphaByte(pixel[3]); 69 | } 70 | // overwrite pixel 71 | data[idx + 0] = color[0]; 72 | data[idx + 1] = color[1]; 73 | data[idx + 2] = color[2]; 74 | data[idx + 3] = rgbAlphaToAlphaByte(color[3]); 75 | }; 76 | 77 | /** 78 | * @param {Number} x 79 | * @param {Number} y 80 | * @param {Number} size 81 | */ 82 | export function clearAt(x, y, size) { 83 | const xpad = Math.floor(size / 2); 84 | const ypad = Math.floor(size / 2); 85 | this.clearRect( 86 | x - xpad, y - ypad, 87 | size + xpad, size + ypad, 88 | color 89 | ); 90 | }; 91 | 92 | /** 93 | * @param {Number} x 94 | * @param {Number} y 95 | * @param {Number} w 96 | * @param {Number} h 97 | */ 98 | export function clearRect(x, y, w, h) { 99 | for (let ii = 0; ii < w * h; ++ii) { 100 | const xx = (ii % w) + x; 101 | const yy = ((ii / w) | 0) + y; 102 | this.erasePixel(xx, yy); 103 | }; 104 | }; 105 | 106 | /** 107 | * @param {Number} x 108 | * @param {Number} y 109 | * @return {Void} 110 | */ 111 | export function erasePixel(x, y) { 112 | const pixel = this.layer.getPixelAt(x, y); 113 | // nothing to erase 114 | if (pixel === null) return; 115 | this.resizeByOffset(x, y); 116 | this.erasePixelFast(x, y, pixel); 117 | return; 118 | }; 119 | 120 | /** 121 | * @param {Number} x 122 | * @param {Number} y 123 | * @param {Array} pixel - Earlier pixel 124 | */ 125 | export function erasePixelFast(x, y, pixel) { 126 | const data = this.data; 127 | const rdata = this.reverse; 128 | const dx = x - this.layer.x; 129 | const dy = y - this.layer.y; 130 | const xx = dx - this.bounds.x; 131 | const yy = dy - this.bounds.y; 132 | const idx = 4 * (yy * this.bounds.w + xx); 133 | // save old pixel into reverse matrix if not set yet 134 | if (rdata[idx + 3] <= 0) { 135 | rdata[idx + 0] = pixel[0]; 136 | rdata[idx + 1] = pixel[1]; 137 | rdata[idx + 2] = pixel[2]; 138 | rdata[idx + 3] = rgbAlphaToAlphaByte(pixel[3]); 139 | } 140 | // reset pixel data 141 | data[idx + 0] = 255; 142 | data[idx + 1] = 255; 143 | data[idx + 2] = 255; 144 | data[idx + 3] = 255; 145 | }; 146 | -------------------------------------------------------------------------------- /src/bounds/index.js: -------------------------------------------------------------------------------- 1 | import { intersectRectangles } from "../math"; 2 | 3 | /** 4 | * @class Boundings 5 | */ 6 | class Boundings { 7 | /** 8 | * @param {Number} x 9 | * @param {Number} y 10 | * @param {Number} w 11 | * @param {Number} h 12 | */ 13 | constructor(x = 0, y = 0, w = 0, h = 0) { 14 | this.x = 0; 15 | this.y = 0; 16 | this.w = 0; 17 | this.h = 0; 18 | this.update(x, y, w, h); 19 | }; 20 | }; 21 | 22 | /** 23 | * @return {Boundings} 24 | */ 25 | Boundings.prototype.clone = function() { 26 | const bounds = new Boundings( 27 | this.x, this.y, this.w, this.h 28 | ); 29 | return (bounds); 30 | }; 31 | 32 | /** 33 | * @param {Number} x 34 | * @param {Number} y 35 | * @param {Number} w 36 | * @param {Number} h 37 | */ 38 | Boundings.prototype.update = function(x, y, w, h) { 39 | x = x | 0; 40 | y = y | 0; 41 | w = w | 0; 42 | h = h | 0; 43 | this.x = x; 44 | this.y = y; 45 | this.w = w; 46 | this.h = h; 47 | }; 48 | 49 | /** 50 | * @param {Boundings} bounds 51 | */ 52 | Boundings.prototype.updateByBoundings = function(bounds) { 53 | this.x = bounds.x | 0; 54 | this.y = bounds.y | 0; 55 | this.w = bounds.w | 0; 56 | this.h = bounds.h | 0; 57 | }; 58 | 59 | /** 60 | * @param {Number} x 61 | * @param {Number} y 62 | * @return {Boolean} 63 | */ 64 | Boundings.prototype.isPointInside = function(x, y) { 65 | x = x | 0; 66 | y = y | 0; 67 | const state = intersectRectangles( 68 | this.x, this.y, this.w - 1, this.h - 1, 69 | x, y, 0, 0 70 | ); 71 | return (state); 72 | }; 73 | 74 | export default Boundings; 75 | -------------------------------------------------------------------------------- /src/camera/functions.js: -------------------------------------------------------------------------------- 1 | import { 2 | TILE_SIZE, 3 | MIN_SCALE, 4 | MAX_SCALE, 5 | ZOOM_SPEED, 6 | MAGIC_SCALE 7 | } from "../cfg"; 8 | 9 | import { 10 | roundTo, 11 | zoomScale, 12 | alignToGrid 13 | } from "../math"; 14 | 15 | /** 16 | * @param {Number} dir 17 | */ 18 | export function scale(dir) { 19 | const x = (dir * (ZOOM_SPEED / 1e2)) * zoomScale(this.cs); 20 | const oscale = this.cs; 21 | if (this.cs + x <= MIN_SCALE) this.cs = MIN_SCALE; 22 | else if (this.cs + x >= MAX_SCALE) this.cs = MAX_SCALE; 23 | else this.cs += x; 24 | this.cs = roundTo(this.cs, MAGIC_SCALE); 25 | if (this.cs >= (MAX_SCALE - 1) + .25) this.cs = (MAX_SCALE - 1) + .25; 26 | this.cx -= (this.lx) * (zoomScale(this.cs) - zoomScale(oscale)); 27 | this.cy -= (this.ly) * (zoomScale(this.cs) - zoomScale(oscale)); 28 | this.cr = roundTo(this.cs, MAGIC_SCALE); 29 | this.updateGrid(); 30 | }; 31 | 32 | /** 33 | * @param {Number} x 34 | * @param {Number} y 35 | */ 36 | export function hover(x, y) { 37 | x = x | 0; 38 | y = y | 0; 39 | this.mx = x; 40 | this.my = y; 41 | }; 42 | 43 | /** 44 | * @param {Number} x 45 | * @param {Number} y 46 | */ 47 | export function click(x, y) { 48 | x = x | 0; 49 | y = y | 0; 50 | const position = this.getRelativeOffset(x, y); 51 | this.dx = x; 52 | this.dy = y; 53 | this.lx = position.x; 54 | this.ly = position.y; 55 | }; 56 | 57 | /** 58 | * @param {Number} x 59 | * @param {Number} y 60 | */ 61 | export function drag(x, y) { 62 | x = x | 0; 63 | y = y | 0; 64 | this.cx += x - this.dx; 65 | this.cy += y - this.dy; 66 | this.dx = x; 67 | this.dy = y; 68 | this.updateGrid(); 69 | }; 70 | 71 | /** 72 | * @param {Number} x 73 | * @param {Number} y 74 | * @return {Object} 75 | */ 76 | export function getRelativeOffset(x, y) { 77 | x = x | 0; 78 | y = y | 0; 79 | const xx = (x - this.cx) / this.cs; 80 | const yy = (y - this.cy) / this.cs; 81 | return ({ 82 | x: xx, 83 | y: yy 84 | }); 85 | }; 86 | 87 | /** 88 | * @param {Number} x 89 | * @param {Number} y 90 | * @return {Object} 91 | */ 92 | export function getRelativeTileOffset(x, y) { 93 | x = x | 0; 94 | y = y | 0; 95 | const rel = this.getRelativeOffset(x, y); 96 | return ( 97 | this.getTileOffsetAt(rel.x, rel.y) 98 | ); 99 | }; 100 | 101 | /** 102 | * @param {Number} x 103 | * @param {Number} y 104 | * @return {Object} 105 | */ 106 | export function getTileOffsetAt(x, y) { 107 | x = x | 0; 108 | y = y | 0; 109 | const half = TILE_SIZE / 2; 110 | const xx = alignToGrid(x - half); 111 | const yy = alignToGrid(y - half); 112 | return ({ 113 | x: (xx / TILE_SIZE) | 0, 114 | y: (yy / TILE_SIZE) | 0 115 | }); 116 | }; 117 | 118 | /** 119 | * @param {Boundings} bounds 120 | */ 121 | export function boundsInsideView(bounds) { 122 | const cs = this.cs; 123 | const ww = (bounds.w * TILE_SIZE) * cs; 124 | const hh = (bounds.h * TILE_SIZE) * cs; 125 | const xx = ((bounds.x * TILE_SIZE) * cs) + this.cx; 126 | const yy = ((bounds.y * TILE_SIZE) * cs) + this.cy; 127 | return ( 128 | (xx + ww >= 0 && xx <= this.cw) && 129 | (yy + hh >= 0 && yy <= this.ch) 130 | ); 131 | }; 132 | -------------------------------------------------------------------------------- /src/cfg.js: -------------------------------------------------------------------------------- 1 | // default view size 2 | export const DEFAULT_WIDTH = 480; 3 | export const DEFAULT_HEIGHT = 320; 4 | // default grid hidden or not 5 | export const DEFAULT_GRID_HIDDEN = false; 6 | 7 | export const TILE_SIZE = 8; 8 | export const MIN_SCALE = 0.1; 9 | export const MAX_SCALE = 32; 10 | export const BASE_SCALE = 1; 11 | export const MAGIC_SCALE = .125; 12 | // trace ghost tiles by alpha=^2 13 | export const UNSET_TILE_COLOR = 2; 14 | export const ERASE_TILE_COLOR = [0, 1, 0, 0.1]; 15 | export const BASE_TILE_COLOR = [0, 0, 0, 0]; 16 | export const SELECTION_COLOR = [1, 1, 1, 0.1]; 17 | export const SELECTION_COLOR_ACTIVE = [1, 1, 1, 0.2]; 18 | export const TILE_HOVER_COLOR = [1, 1, 1, 0.2]; 19 | 20 | // 32-bit ints are allowed at maximum 21 | export const MAX_SAFE_INTEGER = (2 ** 31) - 1; 22 | 23 | // alpha byte to rgb-alpha conversion 24 | export const MAGIC_RGB_A_BYTE = 0.00392; 25 | 26 | // factor when to hide the grid 27 | export const HIDE_GRID = 1.0; 28 | export const GRID_LINE_WIDTH = 0.25; 29 | 30 | // how fast we can scale with our mouse wheel 31 | export const ZOOM_SPEED = 15; 32 | 33 | // base step size we jump when resizing batches 34 | export const BATCH_JUMP_RESIZE = 64; 35 | 36 | // Maximum allowed items inside stack 37 | export const STACK_LIMIT = 128; 38 | 39 | // WebGL texture limit 40 | export const WGL_TEXTURE_LIMIT = STACK_LIMIT * 2; 41 | 42 | // WebGL supported or not 43 | export const WGL_SUPPORTED = ( 44 | typeof WebGLRenderingContext !== "undefined" 45 | ); 46 | 47 | // WebAssembly supported or not 48 | export const WASM_SUPPORTED = ( 49 | typeof WebAssembly !== "undefined" 50 | ); 51 | 52 | // dev mode state 53 | export let MODES = { 54 | DEV: false 55 | }; 56 | 57 | // different settings 58 | export let SETTINGS = { 59 | LIGHT_SIZE: 1, 60 | PENCIL_SIZE: 1, 61 | ERASER_SIZE: 1, 62 | LIGHTING_MODE: 0.05 63 | }; 64 | 65 | export const STORAGE_KEY = "poxi"; 66 | export const STORAGE_OBJECT = window.localStorage; 67 | 68 | // asset path 69 | export const ASSET_PATH = "./assets/"; 70 | 71 | // light bulb icon res 72 | export const LIGHT_DARKEN_IMG_PATH = ASSET_PATH + "img/light_off.png"; 73 | export const LIGHT_LIGHTEN_IMG_PATH = ASSET_PATH + "img/light_on.png"; 74 | -------------------------------------------------------------------------------- /src/color.js: -------------------------------------------------------------------------------- 1 | import { MAGIC_RGB_A_BYTE } from "./cfg"; 2 | 3 | /** 4 | * 0-255 => 0-1 with precision 1 5 | * @param {Number} a 6 | * @return {Number} 7 | */ 8 | export function alphaByteToRgbAlpha(a) { 9 | return ((a * MAGIC_RGB_A_BYTE) * 10) / 10; 10 | }; 11 | 12 | /** 13 | * 0-1 => 0-255 14 | * Derivative of alphaByteToRgbAlpha 15 | * @param {Number} a 16 | * @return {Number} 17 | */ 18 | export function rgbAlphaToAlphaByte(a) { 19 | return ((a / MAGIC_RGB_A_BYTE) * 10) / 10; 20 | }; 21 | 22 | /** 23 | * Convert rgba to rgba byte color 24 | * @param {Array} rgba 25 | * @return {Array} 26 | */ 27 | export function rgbaToBytes(rgba) { 28 | const r = rgba[0] / 255; 29 | const g = rgba[1] / 255; 30 | const b = rgba[2] / 255; 31 | const a = rgba[3]; 32 | return ([r, g, b, a]); 33 | }; 34 | 35 | /** 36 | * Convert bytes to rgba color 37 | * @param {Array} bytes 38 | * @return {Array} 39 | */ 40 | export function bytesToRgba(bytes) { 41 | const r = bytes[0] * 255; 42 | const g = bytes[1] * 255; 43 | const b = bytes[2] * 255; 44 | const a = bytes[3]; 45 | return ([r, g, b, a]); 46 | }; 47 | 48 | /** 49 | * @param {Uint8Array} aa 50 | * @param {Uint8Array} bb 51 | * @param {Number} dir 52 | * @return {Uint8Array} 53 | */ 54 | export function rgbaDifference(aa, bb, dir) { 55 | const rgba = new Uint8Array(4); 56 | rgba[0] = aa[0] + bb[0] * dir; 57 | rgba[1] = aa[1] + bb[1] * dir; 58 | rgba[2] = aa[2] + bb[2] * dir; 59 | rgba[3] = aa[3] + bb[3] * dir; 60 | return (rgba); 61 | }; 62 | 63 | /** 64 | * Additive color blending with alpha support 65 | * @param {Uint8Array} src 66 | * @param {Uint8Array} dst 67 | * @return {Uint8Array} 68 | */ 69 | export function additiveAlphaColorBlending(src, dst) { 70 | const a1 = ((src[3] * MAGIC_RGB_A_BYTE) * 10) / 10; 71 | const a2 = ((dst[3] * MAGIC_RGB_A_BYTE) * 10) / 10; 72 | const a = 1 - (1 - a2) * (1 - a1); 73 | src[0] = ((dst[0] * a2 / a) + (src[0] * a1 * (1 - a2) / a)) | 0; 74 | src[1] = ((dst[1] * a2 / a) + (src[1] * a1 * (1 - a2) / a)) | 0; 75 | src[2] = ((dst[2] * a2 / a) + (src[2] * a1 * (1 - a2) / a)) | 0; 76 | src[3] = (((a / MAGIC_RGB_A_BYTE) * 10) / 10) | 0; 77 | return (src); 78 | }; 79 | 80 | /** 81 | * @param {Uint8Array} rgba 82 | * @return {Uint8Array} 83 | */ 84 | export function invertColors(rgba) { 85 | const data = new Uint8Array(rgba.length); 86 | for (let ii = 0; ii < rgba.length; ii += 4) { 87 | const alpha = rgba[ii + 3]; 88 | data[ii + 0] = alpha - rgba[ii + 0]; 89 | data[ii + 1] = alpha - rgba[ii + 1]; 90 | data[ii + 2] = alpha - rgba[ii + 2]; 91 | data[ii + 3] = alpha; 92 | }; 93 | return (data); 94 | }; 95 | 96 | /** 97 | * @param {Uint8Array} rgba 98 | * @return {Uint8Array} 99 | */ 100 | export function grayscaleColors(rgba) { 101 | const data = new Uint8Array(rgba.length); 102 | for (let ii = 0; ii < rgba.length; ii += 4) { 103 | const gr = ( 104 | (rgba[ii + 0] * .3) + (rgba[ii + 1] * .59) + (rgba[ii + 2] * .11) 105 | ) | 0; 106 | data[ii + 0] = gr; 107 | data[ii + 1] = gr; 108 | data[ii + 2] = gr; 109 | data[ii + 3] = rgba[ii + 3]; 110 | }; 111 | return (data); 112 | }; 113 | 114 | /** 115 | * @return {Array} 116 | */ 117 | export function randomRgbaColor() { 118 | const r = (Math.random() * 256) | 0; 119 | const g = (Math.random() * 256) | 0; 120 | const b = (Math.random() * 256) | 0; 121 | return ([r, g, b, 1]); 122 | }; 123 | 124 | let velo = 64; 125 | let rr = 127; let rrr = velo; 126 | let rg = 12; let rrg = velo; 127 | let rb = 108; let rrb = velo; 128 | /** 129 | * @return {Array} 130 | */ 131 | export function getRainbowColor() { 132 | rr += rrr; 133 | if (rr >= 255) rrr = -velo; 134 | else if (rr <= 0) rrr = velo; 135 | rg += rrg; 136 | if (rg >= 255) rrg = -velo; 137 | else if (rg <= 0) rrg = velo; 138 | rb += rrb; 139 | if (rb >= 255) rrb = -velo; 140 | else if (rb <= 0) rrb = velo; 141 | return ([rr, rg, rb, velo]); 142 | }; 143 | 144 | /** 145 | * @param {Array} color 146 | * @return {String} 147 | */ 148 | export function colorToRgbaString(color) { 149 | const r = color[0]; 150 | const g = color[1]; 151 | const b = color[2]; 152 | const a = color[3]; 153 | return (`rgba(${r},${g},${b},${a})`); 154 | }; 155 | 156 | /** 157 | * @param {String} hex 158 | * @return {Array} 159 | */ 160 | export function hexToRgba(hex) { 161 | const r = parseInt(hex.substring(1,3), 16); 162 | const g = parseInt(hex.substring(3,5), 16); 163 | const b = parseInt(hex.substring(5,7), 16); 164 | return ([r, g, b, 1]); 165 | }; 166 | 167 | /** 168 | * @param {Array} rgba 169 | * @return {String} 170 | */ 171 | export function rgbaToHex(rgba) { 172 | const r = rgba[0]; 173 | const g = rgba[1]; 174 | const b = rgba[2]; 175 | const a = rgba[3]; 176 | return ( 177 | "#" + 178 | ("0" + parseInt(r, 10).toString(16)).slice(-2) + 179 | ("0" + parseInt(g, 10).toString(16)).slice(-2) + 180 | ("0" + parseInt(b, 10).toString(16)).slice(-2) 181 | ); 182 | }; 183 | 184 | /** 185 | * Do rgba color arrays match 186 | * @param {Array} a 187 | * @param {Array} a 188 | * @return {Boolean} 189 | */ 190 | export function colorsMatch(a, b) { 191 | return ( 192 | a[0] === b[0] && 193 | a[1] === b[1] && 194 | a[2] === b[2] && 195 | a[3] === b[3] 196 | ); 197 | }; 198 | 199 | /** 200 | * Checks if a color array is fully transparent 201 | * @param {Array} color 202 | * @return {Boolean} 203 | */ 204 | const transparent = [0, 0, 0, 0]; 205 | export function isGhostColor(color) { 206 | return (colorsMatch(color, transparent)); 207 | }; 208 | -------------------------------------------------------------------------------- /src/container/index.js: -------------------------------------------------------------------------------- 1 | import { TILE_SIZE } from "../cfg"; 2 | import { 3 | uid, 4 | createCanvasBuffer 5 | } from "../utils"; 6 | 7 | import { intersectRectangles } from "../math"; 8 | 9 | import { 10 | colorToRgbaString, 11 | rgbAlphaToAlphaByte 12 | } from "../color"; 13 | 14 | import extend from "../extend"; 15 | import Layer from "../layer/index"; 16 | import Boundings from "../bounds/index"; 17 | 18 | /** 19 | * @class Container 20 | * @extends Layer 21 | */ 22 | class Container extends Layer { 23 | /** 24 | * @param {Poxi} instance 25 | * @constructor 26 | */ 27 | constructor(instance) { 28 | super(instance); 29 | this.animation = { 30 | w: 32, h: 32, 31 | frame: 0, speed: 0 32 | }; 33 | // tile amount x 34 | this.bounds.w = 3; 35 | // tile amount y 36 | this.bounds.h = 4; 37 | } 38 | }; 39 | 40 | /** 41 | * @param {Number} index 42 | * @return {CanvasRenderingContext2D} 43 | */ 44 | Container.prototype.getFrameData = function(index) { 45 | const instance = this.instance; 46 | const animation = this.animation; 47 | const fw = animation.w; const fh = animation.h; 48 | const fx = this.bounds.x + (fw * ((index % this.bounds.w) | 0)); 49 | const fy = this.bounds.y + (fh * ((index / this.bounds.w) | 0)); 50 | const layers = this.instance.layers; 51 | const size = fw * fh; 52 | const buffer = createCanvasBuffer(fw, fh); 53 | for (let ii = 0; ii < size; ++ii) { 54 | const xx = (ii % fw) | 0; 55 | const yy = (ii / fw) | 0; 56 | const pixel = instance.getAbsolutePixelAt(fx + xx, fy + yy); 57 | if (pixel === null) continue; 58 | buffer.fillStyle = colorToRgbaString(pixel); 59 | buffer.fillRect(xx, yy, 1, 1); 60 | }; 61 | return (buffer); 62 | }; 63 | 64 | /** 65 | * @return {CanvasRenderingContext2D} 66 | */ 67 | Container.prototype.getAnimationTemplate = function() { 68 | const instance = this.instance; 69 | const animation = this.animation; 70 | const fw = animation.w; const fh = animation.h; 71 | const fx = this.bounds.x - fw; 72 | const fy = this.bounds.y; 73 | const layers = this.instance.layers; 74 | const size = fw * fh; 75 | const buffer = createCanvasBuffer(fw, fh); 76 | for (let ii = 0; ii < size; ++ii) { 77 | const xx = (ii % fw) | 0; 78 | const yy = (ii / fw) | 0; 79 | const pixel = instance.getAbsolutePixelAt(fx + xx, fy + yy); 80 | if (pixel === null) continue; 81 | buffer.fillStyle = colorToRgbaString(pixel); 82 | buffer.fillRect(xx, yy, 1, 1); 83 | }; 84 | return (buffer); 85 | }; 86 | 87 | Container.prototype.renderAnimationPreview = function() { 88 | const instance = this.instance; 89 | const cx = instance.cx | 0; 90 | const cy = instance.cy | 0; 91 | const cr = instance.cr; 92 | const buffer = instance.cache.fg; 93 | const animation = this.animation; 94 | const bw = this.bounds.w; 95 | const bh = this.bounds.h; 96 | const tilew = ((animation.w * TILE_SIZE) * cr) | 0; 97 | const tileh = ((animation.h * TILE_SIZE) * cr) | 0; 98 | const ww = (((bw * animation.w) * TILE_SIZE) * cr) | 0; 99 | const hh = (((bh * animation.h) * TILE_SIZE) * cr) | 0; 100 | const xx = (cx + (this.bounds.x * TILE_SIZE) * cr) | 0; 101 | const yy = (cy + (this.bounds.y * TILE_SIZE) * cr) | 0; 102 | const ctx = this.getFrameData(animation.frame); 103 | const canvas = ctx.canvas; 104 | // draw preview border 105 | const preview = this.getAnimationTemplate(); 106 | buffer.globalAlpha = 0.25; 107 | const size = ((bw * bh) * animation.w); 108 | for (let ii = 0; ii < size; ii += animation.w) { 109 | const px = (ii % bw) | 0; 110 | const py = ((ii / bw) / animation.w) | 0; 111 | buffer.drawImage( 112 | preview.canvas, 113 | 0, 0, 114 | animation.w, animation.h, 115 | xx + (px * tilew), yy + (py * tileh), 116 | tilew, tileh 117 | ); 118 | }; 119 | buffer.globalAlpha = 1.0; 120 | }; 121 | 122 | Container.prototype.renderFrame = function() { 123 | const instance = this.instance; 124 | const cx = instance.cx | 0; 125 | const cy = instance.cy | 0; 126 | const cr = instance.cr; 127 | const buffer = instance.cache.fg; 128 | const animation = this.animation; 129 | const bw = this.bounds.w; 130 | const bh = this.bounds.h; 131 | const tilew = ((animation.w * TILE_SIZE) * cr) | 0; 132 | const tileh = ((animation.h * TILE_SIZE) * cr) | 0; 133 | const ww = (((bw * animation.w) * TILE_SIZE) * cr) | 0; 134 | const hh = (((bh * animation.h) * TILE_SIZE) * cr) | 0; 135 | const xx = (cx + (this.bounds.x * TILE_SIZE) * cr) | 0; 136 | const yy = (cy + (this.bounds.y * TILE_SIZE) * cr) | 0; 137 | const ax = xx - tilew | 0; 138 | const ay = yy | 0; 139 | const ctx = this.getFrameData(animation.frame); 140 | const canvas = ctx.canvas; 141 | buffer.drawImage( 142 | canvas, 143 | 0, 0, 144 | canvas.width | 0, canvas.height | 0, 145 | ax | 0, ay | 0, 146 | tilew | 0, tileh | 0 147 | ); 148 | }; 149 | 150 | Container.prototype.renderBoundings = function() { 151 | const instance = this.instance; 152 | const cx = instance.cx | 0; 153 | const cy = instance.cy | 0; 154 | const cr = instance.cr; 155 | const buffer = instance.cache.fg; 156 | const lw = Math.max(0.55, 0.55 * cr); 157 | const animation = this.animation; 158 | const bw = this.bounds.w; 159 | const bh = this.bounds.h; 160 | const tilew = ((animation.w * TILE_SIZE) * cr) | 0; 161 | const tileh = ((animation.h * TILE_SIZE) * cr) | 0; 162 | const ww = (((bw * animation.w) * TILE_SIZE) * cr) | 0; 163 | const hh = (((bh * animation.h) * TILE_SIZE) * cr) | 0; 164 | const xx = (cx + (this.bounds.x * TILE_SIZE) * cr) | 0; 165 | const yy = (cy + (this.bounds.y * TILE_SIZE) * cr) | 0; 166 | const ax = xx - tilew | 0; 167 | const ay = yy | 0; 168 | // draw grid 169 | buffer.strokeStyle = "rgba(255,255,255,0.375)"; 170 | buffer.lineWidth = lw; 171 | buffer.beginPath(); 172 | for (let ii = tilew; ii < ww; ii += tilew) { 173 | buffer.moveTo(xx + ii, yy); 174 | buffer.lineTo(xx + ii, yy + hh); 175 | }; 176 | for (let ii = tileh; ii < hh; ii += tileh) { 177 | buffer.moveTo(xx, yy + ii); 178 | buffer.lineTo(xx + ww, yy + ii); 179 | }; 180 | buffer.stroke(); 181 | buffer.closePath(); 182 | // draw border 183 | instance.drawStrokedRect(xx, yy, ww, hh, "rgba(255,255,255,0.375)"); 184 | instance.drawRectangle(ax, ay, tilew - lw, tileh, [1,1,1,0.1]); 185 | instance.drawStrokedRect(ax, ay, tilew - lw, tileh, "rgba(255,255,255,0.375)"); 186 | }; 187 | 188 | export default Container; 189 | -------------------------------------------------------------------------------- /src/env/fill.js: -------------------------------------------------------------------------------- 1 | import { BASE_TILE_COLOR } from "../cfg"; 2 | 3 | import { 4 | createCanvasBuffer 5 | } from "../utils"; 6 | 7 | import { 8 | colorsMatch, 9 | rgbAlphaToAlphaByte 10 | } from "../color"; 11 | 12 | import CommandKind from "../stack/kind"; 13 | 14 | /** 15 | * Fill enclosed tile area 16 | * @param {Number} x 17 | * @param {Number} y 18 | * @param {Array} color 19 | */ 20 | export function fillBucket(x, y, color) { 21 | color = color || [255, 255, 255, 1]; 22 | if (color[3] > 1) throw new Error("Invalid alpha color!"); 23 | // differentiate between empty and colored tiles 24 | const layer = this.getCurrentLayer(); 25 | const bounds = layer.bounds; 26 | const base = layer.getPixelAt(x, y) || BASE_TILE_COLOR; 27 | // clicked tile color and fill colors matches, abort 28 | if (colorsMatch(base, color)) return; 29 | // save the current stack index 30 | const batch = layer.createBatchAt(x, y); 31 | // flood fill 32 | let shape = this.getBinaryShape(x, y, base); 33 | // ups, we filled infinite 34 | if (shape === null) return; 35 | // now fill a buffer by our grid data 36 | const bx = layer.x + bounds.x; const by = layer.y + bounds.y; 37 | const bw = bounds.w; const bh = bounds.h; 38 | const bcolor = [color[0], color[1], color[2], color[3]]; 39 | batch.resizeRectangular( 40 | bx, by, 41 | bw, bh 42 | ); 43 | let count = 0; 44 | // flood fill pixels 45 | for (let ii = 0; ii < bw * bh; ++ii) { 46 | const xx = (ii % bw) | 0; 47 | const yy = (ii / bw) | 0; 48 | const px = (yy * bw + xx) | 0; 49 | // only fill active grid pixels 50 | if (shape[px] !== 2) continue; 51 | batch.drawPixelFast(bx + xx, by + yy, bcolor); 52 | count++; 53 | }; 54 | // nothing changed 55 | if (count <= 0) { 56 | batch.kill(); 57 | return; 58 | } 59 | // auto resize batch's size by the used pixel data 60 | batch.resizeByMatrixData(); 61 | this.enqueue(CommandKind.FILL, batch); 62 | // free grid from memory 63 | shape = null; 64 | return; 65 | }; 66 | 67 | /** 68 | * @param {Number} x 69 | * @param {Number} y 70 | * @return {Void} 71 | */ 72 | export function floodPaint(x, y) { 73 | const color = this.fillStyle; 74 | const layer = this.getCurrentLayer(); 75 | const bounds = layer.bounds; 76 | const base = layer.getPixelAt(x, y); 77 | // empty base tile or colors to fill are the same 78 | if (base === null || colorsMatch(base, color)) return; 79 | const bx = layer.x + bounds.x; const by = layer.y + bounds.y; 80 | const bw = bounds.w; const bh = bounds.h; 81 | const batch = layer.createBatchAt(bx, by); 82 | batch.resizeRectangular( 83 | bx, by, 84 | bw, bh 85 | ); 86 | let count = 0; 87 | // flood paint 88 | for (let ii = 0; ii < bw * bh; ++ii) { 89 | const xx = (ii % bw); 90 | const yy = (ii / bw) | 0; 91 | const pixel = layer.getPixelAt(bx + xx, by + yy); 92 | if (pixel === null) continue; 93 | if (!colorsMatch(base, pixel)) continue; 94 | batch.drawPixelFast(bx + xx, by + yy, color); 95 | count++; 96 | }; 97 | // nothing changed 98 | if (count <= 0) { 99 | batch.kill(); 100 | return; 101 | } 102 | batch.resizeByMatrixData(); 103 | this.enqueue(CommandKind.FLOOD_FILL, batch); 104 | return; 105 | }; 106 | -------------------------------------------------------------------------------- /src/env/functions.js: -------------------------------------------------------------------------------- 1 | import { 2 | SETTINGS, 3 | MAX_SAFE_INTEGER 4 | } from "../cfg"; 5 | 6 | import { 7 | colorToRgbaString, 8 | alphaByteToRgbAlpha 9 | } from "../color"; 10 | 11 | import Layer from "../layer/index"; 12 | import Batch from "../batch/index"; 13 | import CommandKind from "../stack/kind"; 14 | 15 | /** 16 | * @return {Boolean} 17 | */ 18 | export function isInActiveState() { 19 | const states = this.states; 20 | for (let key in states) { 21 | // ignore dragging state 22 | if (key === "dragging") continue; 23 | if (states[key]) return (true); 24 | }; 25 | return (false); 26 | }; 27 | 28 | /** 29 | * @param {Number} id 30 | * @return {Batch} 31 | */ 32 | export function getBatchById(id) { 33 | let result = null; 34 | const layers = this.layers; 35 | for (let ii = 0; ii < layers.length; ++ii) { 36 | const idx = layers.length - 1 - ii; 37 | const layer = layers[idx]; 38 | let batch = layer.getBatchById(id); 39 | if (batch !== null) { 40 | result = batch; 41 | break; 42 | } 43 | }; 44 | return (result); 45 | }; 46 | 47 | /** 48 | * @return {Layer} 49 | */ 50 | export function addLayer() { 51 | const layer = new Layer(this); 52 | layer.addUiReference(); 53 | this.layers.push(layer); 54 | return (layer); 55 | }; 56 | 57 | /** 58 | * @return {Layer} 59 | */ 60 | export function getCurrentLayer() { 61 | return (this.activeLayer || null); 62 | }; 63 | 64 | /** 65 | * @param {HTMLElement} node 66 | * @return {Layer} 67 | */ 68 | export function getLayerByNode(node) { 69 | for (let ii = 0; ii < this.layers.length; ++ii) { 70 | const layer = this.layers[ii]; 71 | if (layer.node === node) return (layer); 72 | }; 73 | return (null); 74 | }; 75 | 76 | /** 77 | * @param {Number} index 78 | * @return {Layer} 79 | */ 80 | export function getLayerByIndex(index) { 81 | return (this.layers[index] || null); 82 | }; 83 | 84 | /** 85 | * @param {Layer} layer 86 | */ 87 | export function setActiveLayer(layer) { 88 | const old = this.getCurrentLayer(); 89 | if (old && old.node) { 90 | old.node.classList.remove("selected"); 91 | } 92 | if (layer) layer.node.classList.add("selected"); 93 | this.activeLayer = layer; 94 | this.redraw = true; 95 | }; 96 | 97 | export function refreshUiLayers() { 98 | const layers = this.layers; 99 | for (let ii = 0; ii < layers.length; ++ii) { 100 | const layer = layers[ii]; 101 | layer.removeUiReference(); 102 | layer.addUiReference(); 103 | }; 104 | }; 105 | 106 | /** 107 | * @param {Number} x 108 | * @param {Number} y 109 | * @return {Layer} 110 | */ 111 | export function getLayerByPoint(x, y) { 112 | const layers = this.layers; 113 | // search by active pixel 114 | for (let ii = 0; ii < layers.length; ++ii) { 115 | const layer = layers[ii]; 116 | const xx = x - layer.x; 117 | const yy = y - layer.y; 118 | if (layer.locked) continue; 119 | if (layer.bounds.isPointInside(xx, yy)) { 120 | if (layer.getPixelAt(x, y)) { 121 | return (layer); 122 | } 123 | } 124 | }; 125 | // active pixel search failed 126 | // so now search by point inside 127 | for (let ii = 0; ii < layers.length; ++ii) { 128 | const idx = layers.length - 1 - ii; 129 | const layer = layers[ii]; 130 | const xx = x - layer.x; 131 | const yy = y - layer.y; 132 | if (layer.locked) continue; 133 | if (layer.bounds.isPointInside(xx, yy)) { 134 | return (layer); 135 | } 136 | }; 137 | return (null); 138 | }; 139 | 140 | /** 141 | * Get batch to insert at by current active state 142 | * @return {Batch} 143 | */ 144 | export function getCurrentDrawingBatch() { 145 | for (let key in this.states) { 146 | const state = this.states[key]; 147 | if (state === true && this.buffers[key]) { 148 | return (this.buffers[key]); 149 | } 150 | }; 151 | return (null); 152 | }; 153 | 154 | /** 155 | * @param {Number} x 156 | * @param {Number} y 157 | * @return {Batch} 158 | */ 159 | export function createDynamicBatch(x, y) { 160 | const batch = new Batch(this); 161 | batch.prepareMatrix(x, y); 162 | return (batch); 163 | }; 164 | 165 | /** 166 | * Get absolute pixel 167 | * @param {Number} x 168 | * @param {Number} y 169 | * @return {Array} 170 | */ 171 | export function getAbsolutePixelAt(x, y) { 172 | // normalize coordinates 173 | const bw = this.bounds.w; 174 | const bh = this.bounds.h; 175 | const xx = x - this.bounds.x; 176 | const yy = y - this.bounds.y; 177 | // check if point inside boundings 178 | if ( 179 | (xx < 0 || yy < 0) || 180 | (bw <= 0 || bh <= 0) || 181 | (xx >= bw || yy >= bh) 182 | ) return (null); 183 | // go through each layer reversed 184 | // and search for the given pixel 185 | const layers = this.layers; 186 | for (let ii = 0; ii < layers.length; ++ii) { 187 | const layer = layers[ii]; 188 | if (!layer.visible) continue; 189 | const pixel = layer.getPixelAt(x, y); 190 | if (pixel !== null) return (pixel); 191 | }; 192 | return (null); 193 | }; 194 | 195 | /** 196 | * Get layer relative pixel 197 | * @param {Number} x 198 | * @param {Number} y 199 | * @return {Array} 200 | */ 201 | export function getRelativePixelAt(x, y) { 202 | // normalize coordinates 203 | const bw = this.bounds.w; 204 | const bh = this.bounds.h; 205 | const xx = x - this.bounds.x; 206 | const yy = y - this.bounds.y; 207 | // check if point inside boundings 208 | if ( 209 | (xx < 0 || yy < 0) || 210 | (bw <= 0 || bh <= 0) || 211 | (xx >= bw || yy >= bh) 212 | ) return (null); 213 | // search for the pixel at given layer 214 | const layer = this.getCurrentLayer(); 215 | if (layer !== null) { 216 | return (layer.getPixelAt(x, y)); 217 | } 218 | return (null); 219 | }; 220 | /** 221 | * Get absolute live pixel 222 | * @param {Number} x 223 | * @param {Number} y 224 | * @return {Array} 225 | */ 226 | export function getLivePixelAt(x, y) { 227 | // normalize coordinates 228 | const bw = this.bounds.w; 229 | const bh = this.bounds.h; 230 | const xx = x - this.bounds.x; 231 | const yy = y - this.bounds.y; 232 | // check if point inside boundings 233 | if ( 234 | (xx < 0 || yy < 0) || 235 | (bw <= 0 || bh <= 0) || 236 | (xx >= bw || yy >= bh) 237 | ) return (null); 238 | // go through each layer reversed 239 | // and search for the given pixel 240 | const layers = this.layers; 241 | for (let ii = 0; ii < layers.length; ++ii) { 242 | const pixel = layers[ii].getLivePixelAt(x, y); 243 | if (pixel !== null) return (pixel); 244 | }; 245 | return (null); 246 | }; 247 | 248 | export function updateGlobalBoundings() { 249 | const layers = this.layers; 250 | const bounds = this.bounds; 251 | let x = MAX_SAFE_INTEGER; let y = MAX_SAFE_INTEGER; 252 | let w = -MAX_SAFE_INTEGER; let h = -MAX_SAFE_INTEGER; 253 | let count = 0; 254 | for (let ii = 0; ii < layers.length; ++ii) { 255 | const layer = layers[ii]; 256 | layer.updateBoundings(); 257 | const bounds = layer.bounds; 258 | const bx = layer.x + bounds.x; const by = layer.y + bounds.y; 259 | const bw = bx + bounds.w; const bh = by + bounds.h; 260 | // ignore empty layers 261 | if (bounds.w === 0 && bounds.h === 0) continue; 262 | // calculate x 263 | if (x < 0 && bx < x) x = bx; 264 | else if (x >= 0 && (bx < 0 || bx < x)) x = bx; 265 | // calculate y 266 | if (y < 0 && by < y) y = by; 267 | else if (y >= 0 && (by < 0 || by < y)) y = by; 268 | // calculate width 269 | if (bw > w) w = bw; 270 | // calculate height 271 | if (bh > h) h = bh; 272 | count++; 273 | }; 274 | // update our boundings 275 | if (count > 0) { 276 | //this.updateSelectionMatrix(); 277 | this.bounds.update(x, y, -x + w, -y + h); 278 | } 279 | }; 280 | 281 | /** 282 | * Uses preallocated binary grid with the size of the absolute boundings 283 | * of our working area. In the next step we trace "alive cells" in the grid, 284 | * then we take the boundings of the used area of our grid and crop out 285 | * the relevant part. Next we can process each tile=^2 traced as inside shape 286 | * @param {Number} x 287 | * @param {Number} y 288 | * @param {Array} base 289 | * @return {Uint8Array} 290 | */ 291 | export function getBinaryShape(x, y, base) { 292 | const layer = this.getCurrentLayer(); 293 | const bounds = layer.bounds; 294 | const bx = layer.x + bounds.x; const by = layer.y + bounds.y; 295 | const bw = bounds.w; const bh = bounds.h; 296 | const isEmpty = base[3] === 0; 297 | const gridl = bw * bh; 298 | // allocate and do a basic fill onto the grid 299 | let grid = new Uint8Array(bw * bh); 300 | for (let ii = 0; ii < gridl; ++ii) { 301 | const xx = ii % bw; 302 | const yy = (ii / bw) | 0; 303 | const color = layer.getPixelAt(bx + xx, by + yy); 304 | // empty tile based 305 | if (isEmpty) { if (color !== null) continue; } 306 | // color based 307 | else { 308 | if (color === null) continue; 309 | if (!( 310 | base[0] === color[0] && 311 | base[1] === color[1] && 312 | base[2] === color[2] && 313 | base[3] === color[3] 314 | )) continue; 315 | } 316 | // fill tiles with 1's if we got a color match 317 | grid[yy * bw + xx] = 1; 318 | }; 319 | // trace connected tiles by [x,y]=2 320 | let queue = [{x: x - bx, y: y - by}]; 321 | while (queue.length > 0) { 322 | const point = queue.pop(); 323 | const x = point.x; const y = point.y; 324 | const idx = y * bw + x; 325 | // set this grid tile to 2, if it got traced earlier as a color match 326 | if (grid[idx] === 1) grid[idx] = 2; 327 | const nn = (y-1) * bw + x; 328 | const ee = y * bw + (x+1); 329 | const ss = (y+1) * bw + x; 330 | const ww = y * bw + (x-1); 331 | if (grid[nn] === 1) queue.push({x, y:y-1}); 332 | if (grid[ee] === 1) queue.push({x:x+1, y}); 333 | if (grid[ss] === 1) queue.push({x, y:y+1}); 334 | if (grid[ww] === 1) queue.push({x:x-1, y}); 335 | }; 336 | return (grid); 337 | }; 338 | 339 | /** 340 | * @return {Number} 341 | */ 342 | export function getCursorSize() { 343 | for (let key in this.modes) { 344 | if (!this.modes[key]) continue; 345 | switch (key) { 346 | case "arc": 347 | case "draw": 348 | case "rect": 349 | case "stroke": 350 | return (SETTINGS.PENCIL_SIZE); 351 | break; 352 | case "erase": 353 | return (SETTINGS.ERASER_SIZE); 354 | break; 355 | case "light": 356 | return (SETTINGS.LIGHT_SIZE); 357 | break; 358 | case "move": 359 | case "fill": 360 | case "shape": 361 | case "flood": 362 | case "select": 363 | return (1); 364 | break; 365 | case "pipette": 366 | return (1); 367 | break; 368 | }; 369 | }; 370 | return (1); 371 | }; 372 | -------------------------------------------------------------------------------- /src/env/insert.js: -------------------------------------------------------------------------------- 1 | import { SETTINGS } from "../cfg"; 2 | import { alignToGrid } from "../math"; 3 | 4 | import { 5 | getRainbowColor, 6 | alphaByteToRgbAlpha 7 | } from "../color"; 8 | 9 | import CommandKind from "../stack/kind"; 10 | 11 | /** 12 | * @param {CanvasRenderingContext2D} ctx 13 | * @param {Number} x 14 | * @param {Number} y 15 | * @return {Void} 16 | */ 17 | export function insertImage(ctx, x, y) { 18 | const layer = this.getCurrentLayer(); 19 | if (layer === null) return; 20 | const batch = layer.createBatchAt(x, y); 21 | const view = ctx.canvas; 22 | const width = view.width; const height = view.height; 23 | const data = ctx.getImageData(0, 0, width, height).data; 24 | const ww = width - 1; const hh = height - 1; 25 | batch.resizeRectangular( 26 | x, y, 27 | width - 1, height - 1 28 | ); 29 | let count = 0; 30 | for (let ii = 0; ii < data.length; ii += 4) { 31 | const idx = (ii / 4) | 0; 32 | const xx = (idx % width) | 0; 33 | const yy = (idx / width) | 0; 34 | const px = (yy * width + xx) * 4; 35 | const r = data[px + 0]; 36 | const g = data[px + 1]; 37 | const b = data[px + 2]; 38 | const a = data[px + 3]; 39 | if (a <= 0) continue; 40 | batch.drawPixelFast(x + xx, y + yy, [r, g, b, alphaByteToRgbAlpha(a)]); 41 | count++; 42 | }; 43 | // nothing changed 44 | if (count <= 0) { 45 | batch.kill(); 46 | return; 47 | } 48 | batch.refreshTexture(true); 49 | batch.resizeByMatrixData(); 50 | this.enqueue(CommandKind.INSERT_IMAGE, batch); 51 | return; 52 | }; 53 | 54 | /** 55 | * @param {Number} x0 56 | * @param {Number} y0 57 | * @param {Number} x1 58 | * @param {Number} y1 59 | */ 60 | export function insertLine(x0, y0, x1, y1) { 61 | const base = 8 * this.cr; 62 | const batch = this.getCurrentDrawingBatch(); 63 | const dx = Math.abs(x1 - x0); const dy = Math.abs(y1 - y0); 64 | const sx = (x0 < x1) ? 1 : -1; const sy = (y0 < y1) ? 1 : -1; 65 | let err = (dx - dy); 66 | while (true) { 67 | const relative = this.getRelativeTileOffset(x0, y0); 68 | // TODO: limit repeation rate on brush size (take modulo) 69 | if (this.states.drawing) { 70 | batch.drawAt(relative.x, relative.y, SETTINGS.PENCIL_SIZE, this.fillStyle); 71 | } 72 | else if (this.states.erasing) { 73 | batch.clearAt(relative.x, relative.y, SETTINGS.ERASER_SIZE); 74 | } 75 | else if (this.states.lighting) { 76 | batch.applyColorLightness(relative.x, relative.y, SETTINGS.LIGHTING_MODE); 77 | } 78 | else if (this.states.stroke) { 79 | batch.drawAt(x0, y0, SETTINGS.PENCIL_SIZE, this.fillStyle); 80 | } 81 | if (x0 === x1 && y0 === y1) break; 82 | const e2 = 2 * err; 83 | if (e2 > -dy) { err -= dy; x0 += sx; } 84 | if (e2 < dx) { err += dx; y0 += sy; } 85 | }; 86 | }; 87 | 88 | /** 89 | * Inserts filled arc at given position 90 | * @param {Batch} batch 91 | * @param {Number} x 92 | * @param {Number} y 93 | * @param {Number} radius 94 | * @param {Array} color 95 | */ 96 | export function fillArc(batch, x, y, radius, color) { 97 | radius = (radius || 1.0) | 0; 98 | if (!color) color = [255, 255, 255, 1]; 99 | this.insertStrokedArc(batch, x, y, radius, color); 100 | // TODO: now fill the stroked circle (with fill?) 101 | }; 102 | 103 | /** 104 | * Inserts stroked arc at given position 105 | * @param {Batch} batch 106 | * @param {Number} x 107 | * @param {Number} y 108 | * @param {Number} radius 109 | * @param {Array} color 110 | */ 111 | export function strokeArc(batch, x, y, radius, color) { 112 | radius = (radius || 1.0) | 0; 113 | if (!color) color = [255, 255, 255, 1]; 114 | this.insertStrokedArc(batch, x, y, radius, color); 115 | }; 116 | 117 | /** 118 | * Inserts filled arc at given position 119 | * @param {Batch} batch 120 | * @param {Number} x1 121 | * @param {Number} y1 122 | * @param {Number} radius 123 | * @param {Array} color 124 | */ 125 | export function insertStrokedArc(batch, x1, y1, radius, color) { 126 | let x2 = radius; 127 | let y2 = 0; 128 | let err = 0; 129 | const size = SETTINGS.PENCIL_SIZE; 130 | for (;x2 >= y2;) { 131 | batch.drawAt(x2 + x1, y2 + y1, size, color); 132 | batch.drawAt(y2 + x1, x2 + y1, size, color); 133 | batch.drawAt(-x2 + x1, y2 + y1, size, color); 134 | batch.drawAt(-y2 + x1, x2 + y1, size, color); 135 | batch.drawAt(-x2 + x1, -y2 + y1, size, color); 136 | batch.drawAt(-y2 + x1, -x2 + y1, size, color); 137 | batch.drawAt(x2 + x1, -y2 + y1, size, color); 138 | batch.drawAt(y2 + x1, -x2 + y1, size, color); 139 | if (err <= 0) { 140 | y2 += 1; 141 | err += 2 * y2 + 1; 142 | } 143 | if (err > 0) { 144 | x2 -= 1; 145 | err -= 2 * x2 + 1; 146 | } 147 | }; 148 | }; 149 | 150 | /** 151 | * Inserts filled rectangle at given position 152 | * @param {Batch} batch 153 | * @param {Number} x 154 | * @param {Number} y 155 | * @param {Number} width 156 | * @param {Number} height 157 | * @param {Array} color 158 | */ 159 | export function fillRect(batch, x, y, width, height, color) { 160 | if (!color) color = [255, 255, 255, 1]; 161 | this.insertRectangleAt( 162 | batch, 163 | x | 0, y | 0, 164 | width | 0, height | 0, 165 | color, true 166 | ); 167 | }; 168 | 169 | /** 170 | * Inserts stroked rectangle at given position 171 | * @param {Batch} batch 172 | * @param {Number} x 173 | * @param {Number} y 174 | * @param {Number} width 175 | * @param {Number} height 176 | * @param {Array} color 177 | */ 178 | export function strokeRect(batch, x, y, width, height, color) { 179 | if (!color) color = [255, 255, 255, 1]; 180 | this.insertRectangleAt( 181 | batch, 182 | x | 0, y | 0, 183 | width | 0, height | 0, 184 | color, false 185 | ); 186 | }; 187 | 188 | /** 189 | * Inserts rectangle at given position 190 | * @param {Batch} batch 191 | * @param {Number} x1 192 | * @param {Number} y1 193 | * @param {Number} x2 194 | * @param {Number} y2 195 | * @param {Array} color 196 | * @param {Boolean} filled 197 | */ 198 | export function insertRectangleAt(batch, x1, y1, x2, y2, color, filled) { 199 | const width = Math.abs(x2); 200 | const height = Math.abs(y2); 201 | const dx = (x2 < 0 ? -1 : 1); 202 | const dy = (y2 < 0 ? -1 : 1); 203 | const size = SETTINGS.PENCIL_SIZE; 204 | // stroke rectangle 205 | if (!filled) { 206 | for (let ii = 0; ii < width * height; ++ii) { 207 | const xx = (ii % width) | 0; 208 | const yy = (ii / width) | 0; 209 | if (!( 210 | (xx === 0 || xx >= width-1) || 211 | (yy === 0 || yy >= height-1)) 212 | ) continue; 213 | batch.drawAt(x1 + xx * dx, y1 + yy * dy, size, color); 214 | }; 215 | // filled rectangle 216 | } else { 217 | for (let ii = 0; ii < width * height; ++ii) { 218 | const xx = (ii % width) | 0; 219 | const yy = (ii / width) | 0; 220 | batch.drawAt(x1 + xx * dx, y1 + yy * dy, size, color); 221 | }; 222 | } 223 | }; 224 | -------------------------------------------------------------------------------- /src/event/emitter.js: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maierfelix/poxi/08e58c0216e7fda7c975321c1ca75407929f4063/src/event/emitter.js -------------------------------------------------------------------------------- /src/event/listener.js: -------------------------------------------------------------------------------- 1 | import { 2 | hexToRgba, 3 | rgbaToHex, 4 | rgbaToBytes, 5 | getRainbowColor 6 | } from "../color"; 7 | 8 | import { 9 | MODES, 10 | SETTINGS, 11 | TILE_SIZE, 12 | LIGHT_DARKEN_IMG_PATH, 13 | LIGHT_LIGHTEN_IMG_PATH 14 | } from "../cfg"; 15 | 16 | import { pointDistance } from "../math"; 17 | 18 | import CommandKind from "../stack/kind"; 19 | 20 | export function initListeners() { 21 | 22 | window.addEventListener("resize", (e) => this.onResize(e)); 23 | 24 | window.addEventListener("mousedown", (e) => this.onMouseDown(e)); 25 | window.addEventListener("mouseup", (e) => this.onMouseUp(e)); 26 | 27 | window.addEventListener("mousemove", (e) => this.onMouseMove(e)); 28 | 29 | window.addEventListener("keydown", (e) => this.onKeyDown(e)); 30 | window.addEventListener("keyup", (e) => this.onKeyUp(e)); 31 | 32 | window.addEventListener("contextmenu", (e) => this.onContextmenu(e)); 33 | 34 | window.addEventListener("wheel", (e) => this.onMouseWheel(e)); 35 | window.addEventListener("mousewheel", (e) => this.onMouseWheel(e)); 36 | 37 | this.view.addEventListener("mouseout", (e) => this.onMouseOut(e)); 38 | this.view.addEventListener("mouseleave", (e) => this.onMouseLeave(e)); 39 | 40 | menu.addEventListener("click", (e) => this.onColorMenuClick(e)); 41 | 42 | }; 43 | 44 | /** 45 | * @param {Event} e 46 | */ 47 | export function onResize(e) { 48 | this.resize( 49 | window.innerWidth, window.innerHeight 50 | ); 51 | }; 52 | 53 | /** 54 | * @param {Event} e 55 | */ 56 | export function onMouseOut(e) { 57 | e.preventDefault(); 58 | //this.onMouseUp(e); 59 | }; 60 | 61 | /** 62 | * @param {Event} e 63 | */ 64 | export function onMouseLeave(e) { 65 | e.preventDefault(); 66 | //this.onMouseUp(e); 67 | }; 68 | 69 | export function resetListElementsActiveState(el) { 70 | for (let ii = 0; ii < el.children.length; ii++) { 71 | el.children[ii].classList.remove("active"); 72 | }; 73 | }; 74 | 75 | /** 76 | * @param {HTMLElement} el 77 | * @return {Void} 78 | */ 79 | export function onColorMenuClick(el) { 80 | const element = el.target; 81 | if (element.id) return; 82 | const value = element.getAttribute("color"); 83 | const rgba = JSON.parse(value); 84 | this.setUiColorByRgba(rgba); 85 | return; 86 | }; 87 | 88 | /** 89 | * @param {HTMLElement} el 90 | */ 91 | export function processUIClick(el) { 92 | const parent = el.parentNode; 93 | if (!parent) return; 94 | const id = parent.id; 95 | if (id === "pencil-size") { 96 | const value = el.innerHTML; 97 | SETTINGS.PENCIL_SIZE = parseInt(value); 98 | this.resetModes(); 99 | this.modes.draw = true; 100 | tiled.style.opacity = 1.0; 101 | } 102 | else if (id === "eraser-size") { 103 | const value = el.innerHTML; 104 | SETTINGS.ERASER_SIZE = parseInt(value); 105 | this.resetModes(); 106 | this.modes.erase = true; 107 | erase.style.opacity = 1.0; 108 | } 109 | else if (id === "light-size") { 110 | const value = el.innerHTML; 111 | SETTINGS.LIGHT_SIZE = parseInt(value); 112 | this.resetModes(); 113 | this.modes.light = true; 114 | lighting.style.opacity = 1.0; 115 | } 116 | else return; 117 | this.resetListElementsActiveState(el.parentNode); 118 | el.classList.add("active"); 119 | }; 120 | 121 | /** 122 | * @param {Event} e 123 | */ 124 | export function onMouseDown(e) { 125 | // only allow clicking on canvas 126 | if (!(e.target instanceof HTMLCanvasElement)) { 127 | this.processUIClick(e.target); 128 | return; 129 | } 130 | // finalize earlier operations and abort 131 | if (this.isInActiveState() && e.which === 1) { 132 | this.onMouseUp(e); 133 | return; 134 | } 135 | const x = e.clientX; 136 | const y = e.clientY; 137 | const relative = this.getRelativeTileOffset(x, y); 138 | const rx = relative.x; const ry = relative.y; 139 | if (e.which === 1) { 140 | const layer = this.getCurrentLayer(); 141 | if (layer === null) return; 142 | this.resetSelection(); 143 | if (this.modes.move) { 144 | const layer = this.getLayerByPoint(rx, ry); 145 | if (layer !== null) { 146 | this.buffers.mLayer = this.getCurrentLayer(); 147 | this.setActiveLayer(layer); 148 | this.states.moving = true; 149 | const batch = this.createDynamicBatch(rx, ry); 150 | batch.position.mx = rx; 151 | batch.position.my = ry; 152 | batch.layer = layer; 153 | batch.isMover = true; 154 | this.buffers.move = batch; 155 | } 156 | } 157 | else if (this.modes.select) { 158 | this.states.selecting = true; 159 | this.selectFrom(x, y); 160 | this.selectTo(x, y); 161 | } 162 | else if (this.modes.arc) { 163 | this.states.arc = true; 164 | this.buffers.arc = layer.createBatchAt(rx, ry); 165 | const batch = this.buffers.arc; 166 | batch.forceRendering = true; 167 | batch.refreshTexture(false); 168 | } 169 | else if (this.modes.rect) { 170 | this.states.rect = true; 171 | this.buffers.rect = layer.createBatchAt(rx, ry); 172 | const batch = this.buffers.rect; 173 | batch.forceRendering = true; 174 | batch.refreshTexture(false); 175 | } 176 | else if (this.modes.draw) { 177 | this.states.drawing = true; 178 | this.buffers.drawing = layer.createBatchAt(rx, ry); 179 | const batch = this.buffers.drawing; 180 | batch.forceRendering = true; 181 | batch.drawAt(rx, ry, SETTINGS.PENCIL_SIZE, this.fillStyle); 182 | batch.refreshTexture(false); 183 | } 184 | else if (this.modes.erase) { 185 | this.states.erasing = true; 186 | this.buffers.erasing = layer.createBatchAt(rx, ry); 187 | const batch = this.buffers.erasing; 188 | batch.forceRendering = true; 189 | batch.clearRect(rx, ry, SETTINGS.ERASER_SIZE, SETTINGS.ERASER_SIZE); 190 | batch.refreshTexture(false); 191 | batch.isEraser = true; 192 | } 193 | else if (this.modes.light) { 194 | this.states.lighting = true; 195 | this.buffers.lighting = layer.createBatchAt(rx, ry); 196 | const batch = this.buffers.lighting; 197 | batch.forceRendering = true; 198 | batch.applyColorLightness(rx, ry, SETTINGS.LIGHTING_MODE); 199 | batch.refreshTexture(false); 200 | } 201 | else if (this.modes.stroke) { 202 | this.states.stroke = true; 203 | this.buffers.stroke = layer.createBatchAt(rx, ry); 204 | const batch = this.buffers.stroke; 205 | batch.forceRendering = true; 206 | batch.refreshTexture(false); 207 | } 208 | else if (this.modes.flood) { 209 | this.floodPaint(rx, ry); 210 | } 211 | else if (this.modes.fill) { 212 | this.fillBucket(rx, ry, this.fillStyle); 213 | } 214 | else if (this.modes.shape) { 215 | const batch = this.getShapeAt(rx, ry); 216 | this.shape = batch; 217 | } 218 | else if (this.modes.pipette) { 219 | this.states.pipette = true; 220 | const color = this.getAbsolutePixelAt(rx, ry); 221 | if (color !== null) { 222 | this.fillStyle = color; 223 | color_view.style.background = color.value = rgbaToHex(color); 224 | } 225 | } 226 | } 227 | else if (e.which === 2 || e.which === 3) { 228 | e.preventDefault(); 229 | this.states.dragging = true; 230 | this.click(x, y); 231 | } 232 | if (e.which === 1) { 233 | this.last.mdx = x; this.last.mdy = y; 234 | const start = this.getRelativeTileOffset(this.last.mdx, this.last.mdy); 235 | this.last.mdrx = start.x; this.last.mdry = start.y; 236 | } 237 | }; 238 | 239 | let lastx = -0; 240 | let lasty = -0; 241 | /** 242 | * @param {Event} e 243 | */ 244 | export function onMouseMove(e) { 245 | e.preventDefault(); 246 | const x = e.clientX; const y = e.clientY; 247 | if (!(e.target instanceof HTMLCanvasElement)) return; 248 | const last = this.last; 249 | const layer = this.getCurrentLayer(); 250 | const relative = this.getRelativeTileOffset(x, y); 251 | const rx = relative.x; const ry = relative.y; 252 | // mouse polling rate isn't 'per-pixel' 253 | // so we try to interpolate missed offsets 254 | this.updateCursorPosition(x, y); 255 | if (this.modes.move) { 256 | this.redraw = true; 257 | } 258 | if (this.states.dragging) { 259 | this.drag(x, y); 260 | this.hover(x, y); 261 | lastx = x; lasty = y; 262 | last.mx = rx; last.my = ry; 263 | return; 264 | } 265 | if (last.mx === rx && last.my === ry) return; 266 | this.hover(x, y); 267 | this.redraw = true; 268 | if (this.states.moving) { 269 | const batch = this.buffers.move; 270 | batch.move(rx, ry); 271 | batch.refreshTexture(false); 272 | } 273 | else if (this.states.arc) { 274 | const batch = this.buffers.arc; 275 | batch.clear(); 276 | const sx = this.last.mdrx; 277 | const sy = this.last.mdry; 278 | const radius = pointDistance(sx, sy, rx, ry); 279 | this.strokeArc(batch, sx, sy, radius, this.fillStyle); 280 | batch.refreshTexture(false); 281 | } 282 | else if (this.states.rect) { 283 | const batch = this.buffers.rect; 284 | batch.clear(); 285 | const sx = this.last.mdrx; 286 | const sy = this.last.mdry; 287 | const ww = rx - sx; 288 | const hh = ry - sy; 289 | this.strokeRect(batch, sx, sy, ww, hh, this.fillStyle); 290 | batch.refreshTexture(false); 291 | } 292 | else if (this.states.stroke) { 293 | const batch = this.buffers.stroke; 294 | batch.clear(); 295 | this.insertLine(this.last.mdrx, this.last.mdry, rx, ry); 296 | batch.refreshTexture(false); 297 | } 298 | else if (this.states.drawing) { 299 | const batch = this.buffers.drawing; 300 | this.insertLine(x, y, lastx, lasty); 301 | batch.refreshTexture(false); 302 | } 303 | else if (this.states.erasing) { 304 | const batch = this.buffers.erasing; 305 | this.insertLine(x, y, lastx, lasty); 306 | batch.refreshTexture(false); 307 | } 308 | else if (this.states.lighting) { 309 | const batch = this.buffers.lighting; 310 | this.insertLine(x, y, lastx, lasty); 311 | batch.refreshTexture(false); 312 | } 313 | else if (this.states.dragging) { 314 | this.drag(x, y); 315 | } 316 | else if (this.states.selecting) { 317 | this.selectTo(x, y); 318 | } 319 | else if (this.states.pipette) { 320 | const color = this.getAbsolutePixelAt(rx, ry); 321 | if (color !== null) { 322 | this.fillStyle = color; 323 | color_view.style.background = color.value = rgbaToHex(color); 324 | } 325 | } 326 | lastx = x; lasty = y; 327 | last.mx = rx; last.my = ry; 328 | }; 329 | 330 | /** 331 | * @param {Event} e 332 | */ 333 | export function onMouseUp(e) { 334 | e.preventDefault(); 335 | if (!(e.target instanceof HTMLCanvasElement)) return; 336 | if (e.which === 1) { 337 | if (this.getCurrentLayer() === null) return; 338 | if (this.modes.move && this.buffers.move) { 339 | const batch = this.buffers.move; 340 | const layer = batch.layer; 341 | this.states.move = false; 342 | this.states.moving = false; 343 | // only enqueue if batch not empty 344 | if (batch.position.x !== 0 || batch.position.y !== 0) { 345 | layer.x -= batch.position.x; 346 | layer.y -= batch.position.y; 347 | this.enqueue(CommandKind.LAYER_MOVE, batch); 348 | } else { 349 | batch.kill(); 350 | } 351 | this.setActiveLayer(this.buffers.mLayer); 352 | this.buffers.move = null; 353 | } 354 | else if (this.states.arc) { 355 | const batch = this.buffers.arc; 356 | batch.forceRendering = false; 357 | this.states.arc = false; 358 | batch.resizeByMatrixData(); 359 | batch.refreshTexture(false); 360 | if (batch.isEmpty()) batch.kill(); 361 | else this.enqueue(CommandKind.ARC_FILL, batch); 362 | this.buffers.arc = null; 363 | } 364 | else if (this.states.rect) { 365 | const batch = this.buffers.rect; 366 | batch.forceRendering = false; 367 | this.states.rect = false; 368 | batch.resizeByMatrixData(); 369 | batch.refreshTexture(false); 370 | if (batch.isEmpty()) batch.kill(); 371 | else this.enqueue(CommandKind.RECT_FILL, batch); 372 | this.buffers.rect = null; 373 | } 374 | else if (this.states.stroke) { 375 | const batch = this.buffers.stroke; 376 | batch.forceRendering = false; 377 | this.states.stroke = false; 378 | batch.resizeByMatrixData(); 379 | batch.refreshTexture(false); 380 | if (batch.isEmpty()) batch.kill(); 381 | else this.enqueue(CommandKind.STROKE, batch); 382 | this.buffers.stroke = null; 383 | } 384 | else if (this.states.select) { 385 | this.states.selecting = false; 386 | this.redraw = true; 387 | } 388 | else if (this.states.drawing) { 389 | const batch = this.buffers.drawing; 390 | batch.forceRendering = false; 391 | batch.resizeByMatrixData(); 392 | this.states.drawing = false; 393 | this.enqueue(CommandKind.DRAW, batch); 394 | this.buffers.drawing = null; 395 | } 396 | else if (this.states.erasing) { 397 | const batch = this.buffers.erasing; 398 | batch.forceRendering = false; 399 | batch.resizeByMatrixData(); 400 | this.states.erasing = false; 401 | if (batch.isEmpty()) batch.kill(); 402 | else this.enqueue(CommandKind.ERASE, batch); 403 | this.buffers.erasing = null; 404 | } 405 | else if (this.states.lighting) { 406 | const batch = this.buffers.lighting; 407 | batch.forceRendering = false; 408 | batch.resizeByMatrixData(); 409 | this.states.lighting = false; 410 | if (batch.isEmpty()) batch.kill(); 411 | else this.enqueue(CommandKind.LIGHTING, batch); 412 | this.buffers.lighting = null; 413 | } 414 | else if (this.states.pipette) { 415 | this.states.pipette = false; 416 | } 417 | } 418 | if (e.which === 2 || e.which === 3) { 419 | this.states.dragging = false; 420 | } 421 | }; 422 | 423 | /** 424 | * @param {Event} e 425 | */ 426 | export function onKeyDown(e) { 427 | const code = e.keyCode; 428 | const target = e.target; 429 | if (target !== document.body) { 430 | return; 431 | } 432 | e.preventDefault(); 433 | this.keys[code] = 1; 434 | switch (code) { 435 | // ctrl 436 | case 17: 437 | if (this.modes.light) { 438 | // lighting mode is darken 439 | SETTINGS.LIGHTING_MODE = -(Math.abs(SETTINGS.LIGHTING_MODE)); 440 | lighting.src = LIGHT_DARKEN_IMG_PATH; 441 | } 442 | break; 443 | // del 444 | case 46: 445 | this.clearSelection(this.getSelection()); 446 | this.resetSelection(); 447 | break; 448 | // c | ctrl+c 449 | case 67: 450 | if (this.keys[17]) { 451 | this.copy(this.getSelection()); 452 | } 453 | break; 454 | // x | ctrl+x 455 | case 88: 456 | if (this.keys[17]) { 457 | this.cut(this.getSelection()); 458 | this.resetSelection(); 459 | } 460 | break; 461 | // v + ctrl+v 462 | case 86: 463 | if (this.keys[17]) { 464 | this.pasteAt(this.last.mx, this.last.my, this.getSelection()); 465 | this.resetSelection(); 466 | } 467 | break; 468 | // z | ctr+z 469 | case 90: 470 | if (this.keys[17]) { 471 | this.undo(); 472 | } 473 | break; 474 | // y | ctrl+y 475 | case 89: 476 | if (this.keys[17]) { 477 | this.redo(); 478 | } 479 | break; 480 | // f2 481 | case 113: 482 | MODES.DEV = !MODES.DEV; 483 | this.redraw = true; 484 | break; 485 | // f5 486 | case 116: 487 | location.reload(); 488 | break; 489 | // space 490 | case 32: 491 | // already open, close so 492 | if (this.states.fastColorMenu) { 493 | this.closeFastColorPickerMenu(); 494 | return; 495 | } 496 | const width = menu.clientWidth; 497 | const height = menu.clientHeight; 498 | const btmWidth = document.querySelector(".bottom-menu").clientHeight; 499 | let yy = lasty; 500 | let xx = (lastx - (width / 2) | 0); 501 | // invert menu position since we are out of window view 502 | if (yy + height > stage.ch - btmWidth) { 503 | yy = yy - height; 504 | } 505 | menu.style.top = yy + "px"; 506 | menu.style.left = xx + "px"; 507 | this.openFastColorPickerMenu(); 508 | break; 509 | default: 510 | return; 511 | break; 512 | }; 513 | }; 514 | 515 | /** 516 | * @param {Event} e 517 | */ 518 | export function onKeyUp(e) { 519 | e.preventDefault(); 520 | const code = e.keyCode; 521 | this.keys[code] = 0; 522 | switch (code) { 523 | // ctrl 524 | case 17: 525 | // lighting mode is lighten 526 | if (this.modes.light) { 527 | SETTINGS.LIGHTING_MODE = Math.abs(SETTINGS.LIGHTING_MODE); 528 | lighting.src = LIGHT_LIGHTEN_IMG_PATH; 529 | } 530 | break; 531 | }; 532 | }; 533 | 534 | /** 535 | * @param {Event} e 536 | */ 537 | export function onContextmenu(e) { 538 | e.preventDefault(); 539 | }; 540 | 541 | /** 542 | * @param {Event} e 543 | */ 544 | export function onMouseWheel(e) { 545 | if (!(e.target instanceof HTMLCanvasElement)) return; 546 | e.preventDefault(); 547 | const x = e.clientX; 548 | const y = e.clientY; 549 | const value = e.deltaY > 0 ? -1 : 1; 550 | this.click(x, y); 551 | this.scale(value); 552 | this.hover(x, y); 553 | }; 554 | -------------------------------------------------------------------------------- /src/extend.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @param {Class} cls 3 | * @param {Array} prot 4 | */ 5 | export default function(cls, prot) { 6 | for (let key in prot) { 7 | if (prot[key] instanceof Function) { 8 | if (cls.prototype[key] instanceof Function) { 9 | console.log(`Warning: Overwriting ${cls.name}.prototype.${key}`); 10 | } 11 | cls.prototype[key] = prot[key]; 12 | } 13 | }; 14 | }; 15 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import extend from "./extend"; 2 | 3 | import * as _select from "./area/select"; 4 | import * as _area_functions from "./area/functions"; 5 | 6 | import * as _camera from "./camera/functions"; 7 | 8 | import * as _emitter from "./event/emitter"; 9 | import * as _listener from "./event/listener"; 10 | 11 | import * as _env from "./env/functions"; 12 | import * as _fill from "./env/fill"; 13 | import * as _insert from "./env/insert"; 14 | 15 | import * as _buffer from "./render/buffer"; 16 | import * as _build from "./render/build"; 17 | import * as _draw from "./render/draw"; 18 | import * as _generate from "./render/generate"; 19 | import * as _main from "./render/main"; 20 | import * as _render from "./render/render"; 21 | import * as _resize from "./render/resize"; 22 | import * as _shaders from "./render/shaders"; 23 | 24 | import * as _redo from "./stack/redo"; 25 | import * as _state from "./stack/state"; 26 | import * as _undo from "./stack/undo"; 27 | 28 | import * as _read from "./storage/read"; 29 | import * as _write from "./storage/write"; 30 | 31 | import * as _transflip from "./transform/flip"; 32 | import * as _transrotate from "./transform/rotate"; 33 | 34 | import * as _ui from "./ui/index"; 35 | 36 | import * as _setup from "./setup"; 37 | 38 | import { BASE_SCALE } from "./cfg"; 39 | 40 | import Layer from "./Layer/index"; 41 | import Boundings from "./bounds/index"; 42 | 43 | /** 44 | * @class {Poxi} 45 | */ 46 | class Poxi { 47 | /** 48 | * @constructor 49 | */ 50 | constructor() { 51 | // # webgl related 52 | // wgl context 53 | this.gl = null; 54 | // canvas reference 55 | this.view = null; 56 | // webgl program 57 | this.program = null; 58 | // global boundings 59 | this.bounds = new Boundings(); 60 | // # camera related 61 | this.cx = 0; 62 | this.cy = 0; 63 | this.cw = 0; 64 | this.ch = 0; 65 | // camera render scale 66 | this.cr = BASE_SCALE; 67 | this.cs = BASE_SCALE; 68 | // camera drag related 69 | this.dx = 0; 70 | this.dy = 0; 71 | // camera zoom related 72 | this.lx = 0; 73 | this.ly = 0; 74 | // selection related 75 | this.sx = 0; 76 | this.sy = 0; 77 | this.sw = -0; 78 | this.sh = -0; 79 | this.shape = null; 80 | // mouse offset 81 | this.mx = 0; 82 | this.my = 0; 83 | // stack related 84 | this.stack = []; 85 | this.sindex = -1; 86 | // layer related 87 | this.layers = []; 88 | // container related 89 | this.containers = []; 90 | // general cache 91 | this.cache = { 92 | bg: null, 93 | fg: null, 94 | // unused base layer 95 | layer: null, 96 | fgTexture: null, 97 | grid: null, 98 | gridTexture: null, 99 | // wgl cache 100 | gl: { 101 | // empty texture 102 | empty: null, 103 | // general buffers 104 | buffers: {}, 105 | // we use buffered uv coords 106 | vertices: {}, 107 | // texture pool 108 | textures: {} 109 | } 110 | }; 111 | // last things 112 | this.last = { 113 | cx: 1, cy: 1, 114 | // mouse move coordinates 115 | mx: 0, my: 0, 116 | // mouse down coordinates 117 | mdx: 0, mdy: 0, 118 | // mouse down relative coordinates 119 | mdrx: 0, mdry: 0 120 | }; 121 | // shared buffer related 122 | this.buffers = { 123 | arc: null, 124 | rect: null, 125 | move: null, 126 | stroke: null, 127 | erasing: null, 128 | drawing: null 129 | }; 130 | // keyboard related 131 | this.keys = {}; 132 | // clipboard related 133 | this.clipboard = { 134 | copy: null 135 | }; 136 | // stage stages 137 | this.states = { 138 | arc: false, 139 | rect: false, 140 | stroke: false, 141 | moving: false, 142 | drawing: false, 143 | pipette: false, 144 | lighting: false, 145 | dragging: false, 146 | select: false, 147 | selecting: false, 148 | fastColorMenu: false 149 | }; 150 | // mode related 151 | this.modes = { 152 | arc: false, 153 | move: false, 154 | fill: false, 155 | rect: false, 156 | draw: false, 157 | shape: false, 158 | light: false, 159 | erase: false, 160 | flood: false, 161 | select: false, 162 | stroke: false, 163 | pipette: false 164 | }; 165 | // how many frames we have drawn 166 | this.frames = 0; 167 | // indicates if we have to redraw our stage 168 | this.redraw = false; 169 | // global fill style 170 | this.fillStyle = [0, 0, 0, 0]; 171 | // favorite used colors 172 | this.favoriteColors = []; 173 | // selected active layer 174 | this.activeLayer = null; 175 | this.setup(); 176 | } 177 | }; 178 | 179 | extend(Poxi, _select); 180 | extend(Poxi, _area_functions); 181 | 182 | extend(Poxi, _camera); 183 | 184 | extend(Poxi, _emitter); 185 | extend(Poxi, _listener); 186 | 187 | extend(Poxi, _env); 188 | extend(Poxi, _fill); 189 | extend(Poxi, _insert); 190 | 191 | extend(Poxi, _buffer); 192 | extend(Poxi, _build); 193 | extend(Poxi, _draw); 194 | extend(Poxi, _generate); 195 | extend(Poxi, _main); 196 | extend(Poxi, _render); 197 | extend(Poxi, _resize); 198 | extend(Poxi, _shaders); 199 | 200 | extend(Poxi, _redo); 201 | extend(Poxi, _state); 202 | extend(Poxi, _undo); 203 | 204 | extend(Poxi, _read); 205 | extend(Poxi, _write); 206 | 207 | extend(Poxi, _transflip); 208 | extend(Poxi, _transrotate); 209 | 210 | extend(Poxi, _ui); 211 | 212 | extend(Poxi, _setup); 213 | 214 | if (typeof window !== "undefined") { 215 | window.Poxi = Poxi; 216 | window.stage = new Poxi(); 217 | } else { 218 | throw new Error("Poxi only runs inside the browser"); 219 | } 220 | -------------------------------------------------------------------------------- /src/layer/README.md: -------------------------------------------------------------------------------- 1 | # Layers 2 | 3 | Layers contain batches and update their boundings as soon as a batch gets added or resized. Layers also store a xy position and opacity value. Layers can also be hidden, locked and re-named. 4 | -------------------------------------------------------------------------------- /src/layer/index.js: -------------------------------------------------------------------------------- 1 | import extend from "../extend"; 2 | import { 3 | uid, 4 | createCanvasBuffer 5 | } from "../utils"; 6 | import { getRainbowColor } from "../color"; 7 | 8 | import Batch from "../batch/index"; 9 | import Boundings from "../bounds/index"; 10 | 11 | import * as _matrix from "./matrix"; 12 | 13 | /** 14 | * @class {Layer} 15 | */ 16 | class Layer { 17 | /** 18 | * @param {Poxi} instance 19 | * @constructor 20 | */ 21 | constructor(instance) { 22 | this.id = uid(); 23 | this.instance = instance; 24 | // position 25 | this.x = 0; 26 | this.y = 0; 27 | // references layers inference colors 28 | this.color = { value: null }; 29 | // last boundings 30 | this.last = { x: 0, y: 0, w: 0, h: 0 }; 31 | // we can name layers 32 | this.index = this.generateLayerNameIndex(); 33 | this._name = "Layer " + this.index; 34 | // reference to ui node 35 | this.node = null; 36 | // reference (clone) to master layer 37 | this.reference = null; 38 | // opacity applied over local batches 39 | this._opacity = 1.0; 40 | // layer batch matrix 41 | this.batch = null; 42 | // batches we hold here 43 | this.batches = []; 44 | // relative boundings 45 | this.bounds = new Boundings(); 46 | // layer states get/set base 47 | this._visible = true; 48 | this._locked = false; 49 | this.allocateLayerMatrix(); 50 | } 51 | /** 52 | * @return {String} 53 | */ 54 | get name() { 55 | return (this._name); 56 | } 57 | /** 58 | * @param {String} 59 | */ 60 | set name(value) { 61 | this._name = value; 62 | const node = this.node.querySelector(".layer-text"); 63 | node.value = value; 64 | } 65 | /** 66 | * @return {Number} 67 | */ 68 | get opacity() { 69 | return (this._opacity); 70 | } 71 | /** 72 | * @param {Number} 73 | */ 74 | set opacity(value) { 75 | this._opacity = value; 76 | this.instance.redraw = true; 77 | } 78 | /** 79 | * @return {Boolean} 80 | */ 81 | get visible() { 82 | return (this._visible); 83 | } 84 | /** 85 | * @param {Boolean} state 86 | */ 87 | set visible(state) { 88 | this._visible = state; 89 | this.instance.redraw = true; 90 | const node = this.node.querySelector(".layer-item-visible"); 91 | node.src = state ? "assets/img/visible.png" : "assets/img/invisible.png"; 92 | } 93 | /** 94 | * @return {Boolean} 95 | */ 96 | get locked() { 97 | return (this._locked); 98 | } 99 | /** 100 | * @param {Boolean} state 101 | */ 102 | set locked(state) { 103 | this._locked = state; 104 | this.instance.redraw = true; 105 | const node = this.node.querySelector(".layer-item-locked"); 106 | node.src = state ? "assets/img/locked.png" : "assets/img/unlocked.png"; 107 | } 108 | /** 109 | * Returns if layer is active 110 | * @return {Boolean} 111 | */ 112 | get isActive() { 113 | const current = this.instance.getCurrentLayer(); 114 | return (current === this); 115 | } 116 | /** 117 | * Indicates if layer is a reference (absolute or referenced) 118 | * @return {Boolean} 119 | */ 120 | get isReference() { 121 | return ( 122 | (this.getReferencedLayers().length > 0 || this.reference !== null) 123 | ); 124 | } 125 | }; 126 | 127 | /** 128 | * @return {Layer} 129 | */ 130 | Layer.prototype.clone = function() { 131 | const layer = new Layer(this.instance); 132 | const batch = this.batch.clone(); 133 | layer.last = Object.assign(layer.last); 134 | layer.opacity = this.opacity; 135 | layer.bounds = this.bounds.clone(); 136 | layer.x = this.x; layer.y = this.y; 137 | layer.batch = batch; 138 | layer.batches.push(batch); 139 | layer._visible = this.visible; 140 | layer._locked = this.locked; 141 | return (layer); 142 | }; 143 | 144 | Layer.prototype.cloneByReference = function() { 145 | if (this.color.value === null) { 146 | this.color.value = getRainbowColor(); 147 | this.removeUiReference(); 148 | this.addUiReference(); 149 | } 150 | const layer = new Layer(this.instance); 151 | layer.last = Object.assign(layer.last); 152 | layer.opacity = this.opacity; 153 | layer.bounds = this.bounds; 154 | layer.x = this.x; layer.y = this.y; 155 | layer.batch = this.batch; 156 | layer.batches = this.batches; 157 | //layer.reference = this.reference || this; 158 | layer.reference = this; 159 | layer.color = this.color; 160 | layer._visible = this.visible; 161 | layer._locked = this.locked; 162 | return (layer); 163 | }; 164 | 165 | /** 166 | * Walks through referenced layers until 167 | * the absolute layer reference is found 168 | * @return {Layer} 169 | */ 170 | Layer.prototype.getAbsoluteReference = function() { 171 | let layer = this.reference; 172 | while (true) { 173 | if (layer.reference === null) { 174 | return (layer); 175 | } 176 | layer = layer.reference; 177 | }; 178 | return (null); 179 | }; 180 | 181 | /** 182 | * @return {Array} 183 | */ 184 | Layer.prototype.getReferencedLayers = function() { 185 | const layers = this.instance.layers; 186 | const references = []; 187 | for (let ii = 0; ii < layers.length; ++ii) { 188 | const layer = layers[ii]; 189 | if (layer === this) continue; 190 | if (layer.reference !== null) { 191 | if (layer.reference === this) references.push(layer); 192 | } 193 | }; 194 | return (references); 195 | }; 196 | 197 | Layer.prototype.allocateLayerMatrix = function() { 198 | const instance = this.instance; 199 | this.batch = instance.createDynamicBatch(0, 0); 200 | // add reference to unused layer so we can use 201 | // the batch matrix logic for our layers too 202 | // but without including layer x,y in calculations 203 | this.batch.layer = instance.cache.layer; 204 | }; 205 | 206 | /** 207 | * @return {Number} 208 | */ 209 | Layer.prototype.getIndex = function() { 210 | const layers = this.instance.layers; 211 | for (let ii = 0; ii < layers.length; ++ii) { 212 | const layer = layers[ii]; 213 | if (this.id === layer.id) return (ii); 214 | }; 215 | return (-1); 216 | }; 217 | 218 | /** 219 | * @return {Boolean} 220 | */ 221 | Layer.prototype.isEmpty = function() { 222 | return (this.batch.isEmpty()); 223 | }; 224 | 225 | Layer.prototype.removeFromLayers = function() { 226 | const layers = this.instance.layers; 227 | for (let ii = 0; ii < layers.length; ++ii) { 228 | const layer = layers[ii]; 229 | if (this.id === layer.id) layers.splice(ii, 1); 230 | }; 231 | }; 232 | 233 | /** 234 | * @param {Number} x 235 | * @param {Number} y 236 | * @return {Batch} 237 | */ 238 | Layer.prototype.createBatchAt = function(x, y) { 239 | const dx = (x - this.x) | 0; 240 | const dy = (y - this.y) | 0; 241 | const batch = new Batch(this.instance); 242 | batch.prepareMatrix(dx, dy); 243 | this.addBatch(batch); 244 | return (batch); 245 | }; 246 | 247 | /** 248 | * Push batch and auto update layer boundings 249 | * @param {Batch} batch 250 | */ 251 | Layer.prototype.addBatch = function(batch) { 252 | batch.layer = this; 253 | this.batches.push(batch); 254 | }; 255 | 256 | /** 257 | * @param {Number} id 258 | * @return {Number} 259 | */ 260 | Layer.prototype.getBatchById = function(id) { 261 | let result = null; 262 | const batches = this.batches; 263 | for (let ii = 0; ii < batches.length; ++ii) { 264 | const batch = batches[ii]; 265 | if (batch.id === id) { 266 | result = batch; 267 | break; 268 | } 269 | }; 270 | return (result); 271 | }; 272 | 273 | /** 274 | * Returns the layer position 275 | * relative to our stage 276 | * @return {Object} 277 | */ 278 | Layer.prototype.getRelativePosition = function() { 279 | const sx = this.instance.bounds.x; 280 | const sy = this.instance.bounds.y; 281 | const x = (this.x + this.bounds.x) - sx; 282 | const y = (this.y + this.bounds.y) - sy; 283 | return ({ x, y }); 284 | }; 285 | 286 | /** 287 | * Fill imagedata with pixels then 288 | * put it into a canvas and return it 289 | * @return {CanvasRenderingContext2D} 290 | */ 291 | Layer.prototype.toCanvasBuffer = function() { 292 | // TODO: also draw batch[n].forceRendering for live previews 293 | const data = this.batch.data; 294 | const lw = this.bounds.w | 0; 295 | const lh = this.bounds.h | 0; 296 | const buffer = createCanvasBuffer(lw, lh); 297 | // prevent imagedata construction from failing 298 | if (lw <= 0 || lh <= 0) return (buffer); 299 | const img = new ImageData(lw, lh); 300 | const idata = img.data; 301 | for (let ii = 0; ii < data.length; ii += 4) { 302 | const alpha = data[ii + 3] | 0; 303 | if (alpha <= 0) continue; 304 | idata[ii + 0] = data[ii + 0] | 0; 305 | idata[ii + 1] = data[ii + 1] | 0; 306 | idata[ii + 2] = data[ii + 2] | 0; 307 | idata[ii + 3] = alpha; 308 | }; 309 | buffer.putImageData(img, 0, 0); 310 | return (buffer); 311 | }; 312 | 313 | /** 314 | * Auto generates a layer index name 315 | * and filles missing layer indices 316 | * @return {Number} 317 | */ 318 | Layer.prototype.generateLayerNameIndex = function() { 319 | const layers = this.instance.layers; 320 | // clone, take numeric index, sort ascending, es6 left its muck here 321 | const sorted = layers.concat().map((item) => item.index).sort((a, b) => a - b); 322 | for (let ii = 0; ii < sorted.length; ++ii) { 323 | if (sorted.indexOf(ii) < 0) return (ii); 324 | }; 325 | return (layers.length); 326 | }; 327 | 328 | /** 329 | * Returns the string version of the dom node 330 | * @return {String} 331 | */ 332 | Layer.prototype.generateUiNode = function() { 333 | const html = ` 334 |
335 | 336 | 337 | 338 |
339 | `; 340 | return (html); 341 | }; 342 | 343 | /** 344 | * Returns color of the layer node 345 | * @return {String} 346 | */ 347 | Layer.prototype.getUiNodeColor = function() { 348 | // only attach color to layer if 349 | // layer is a absolute reference or is a reference 350 | const count = this.isReference; 351 | const cc = this.color.value; 352 | const color = ( 353 | cc && count ? `rgba(${cc[0]},${cc[1]},${cc[2]},0.1)` : "" 354 | ); 355 | return (color); 356 | }; 357 | 358 | /** 359 | * yuck in here, yuck in here 360 | */ 361 | Layer.prototype.addUiReference = function() { 362 | const tmpl = this.generateUiNode(); 363 | const parser = new DOMParser(); 364 | const html = parser.parseFromString(tmpl, "text/html").querySelector(".layer-item"); 365 | const index = this.getIndex(); 366 | const ctx = window.layers; 367 | if (index >= ctx.children.length) { 368 | ctx.appendChild(html); 369 | } else { 370 | ctx.insertBefore(html, ctx.children[index]); 371 | } 372 | // save reference to inserted layer node 373 | this.node = html; 374 | html.style.backgroundColor = this.getUiNodeColor(); 375 | if (this.isActive) { 376 | this.instance.setActiveLayer(this); 377 | } 378 | this.locked = this.locked; 379 | this.visible = this.visible; 380 | }; 381 | 382 | Layer.prototype.removeUiReference = function() { 383 | this.node.parentNode.removeChild(this.node); 384 | this.node = null; 385 | }; 386 | 387 | extend(Layer, _matrix); 388 | 389 | export default Layer; 390 | -------------------------------------------------------------------------------- /src/layer/matrix.js: -------------------------------------------------------------------------------- 1 | import { MAX_SAFE_INTEGER } from "../cfg"; 2 | import { alphaByteToRgbAlpha } from "../color"; 3 | 4 | /** 5 | * @return {Boolean} 6 | */ 7 | export function hasResized() { 8 | const ox = this.bounds.x; const oy = this.bounds.y; 9 | const ow = this.bounds.w; const oh = this.bounds.h; 10 | const nx = this.last.x; const ny = this.last.y; 11 | const nw = this.last.w; const nh = this.last.h; 12 | return ( 13 | ox !== nx || oy !== ny || 14 | ow !== nw || oh !== nh 15 | ); 16 | }; 17 | 18 | /** 19 | * Updates the layer's boundings by calculating 20 | * min/max of x,y,w,h of all stored batches 21 | */ 22 | export function updateBoundings() { 23 | let x = MAX_SAFE_INTEGER; let y = MAX_SAFE_INTEGER; 24 | let w = -MAX_SAFE_INTEGER; let h = -MAX_SAFE_INTEGER; 25 | const batches = this.batches; 26 | let count = 0; 27 | for (let ii = 0; ii < batches.length; ++ii) { 28 | const batch = batches[ii]; 29 | const bounds = batch.bounds; 30 | const bx = bounds.x; const by = bounds.y; 31 | const bw = bx + bounds.w; const bh = by + bounds.h; 32 | // ignore empty batches 33 | if (bounds.w === 0 && bounds.h === 0) continue; 34 | // calculate x 35 | if (x < 0 && bx < x) x = bx; 36 | else if (x >= 0 && (bx < 0 || bx < x)) x = bx; 37 | // calculate y 38 | if (y < 0 && by < y) y = by; 39 | else if (y >= 0 && (by < 0 || by < y)) y = by; 40 | // calculate width 41 | if (bw > w) w = bw; 42 | // calculate height 43 | if (bh > h) h = bh; 44 | count++; 45 | }; 46 | // update our boundings 47 | if (count > 0) { 48 | const bounds = this.bounds; 49 | this.last.x = bounds.x; this.last.y = bounds.y; 50 | this.last.w = bounds.w; this.last.h = bounds.h; 51 | bounds.update( 52 | x, y, 53 | -x + w, -y + h 54 | ); 55 | } 56 | if (this.hasResized()) { 57 | const main = this.batch; 58 | main.bounds.update( 59 | this.bounds.x, this.bounds.y, 60 | this.bounds.w, this.bounds.h 61 | ); 62 | const xx = this.last.x; const yy = this.last.y; 63 | const ww = this.last.w; const hh = this.last.h; 64 | main.resizeMatrix( 65 | xx - this.bounds.x, yy - this.bounds.y, 66 | this.bounds.w - ww, this.bounds.h - hh 67 | ); 68 | } 69 | }; 70 | 71 | /** 72 | * Access raw pixel 73 | * @param {Number} x 74 | * @param {Number} y 75 | * @return {Array} 76 | */ 77 | export function getPixelAt(x, y) { 78 | const bw = this.bounds.w; 79 | const bh = this.bounds.h; 80 | // normalize coordinates 81 | const dx = (x - this.x) | 0; 82 | const dy = (y - this.y) | 0; 83 | const xx = dx - this.bounds.x; 84 | const yy = dy - this.bounds.y; 85 | // check if point inside boundings 86 | if ( 87 | (xx < 0 || yy < 0) || 88 | (bw <= 0 || bh <= 0) || 89 | (xx >= bw || yy >= bh) 90 | ) return (null); 91 | // now get the pixel from the layer matrix 92 | return (this.batch.getRawPixelAt(dx, dy)); 93 | }; 94 | 95 | /** 96 | * Access live pixel 97 | * @param {Number} x 98 | * @param {Number} y 99 | * @return {Array} 100 | */ 101 | export function getLivePixelAt(x, y) { 102 | const bw = this.bounds.w; 103 | const bh = this.bounds.h; 104 | // normalize coordinates 105 | const dx = (x - this.x) | 0; 106 | const dy = (y - this.y) | 0; 107 | const xx = dx - this.bounds.x; 108 | const yy = dy - this.bounds.y; 109 | // check if point inside boundings 110 | if ( 111 | (xx < 0 || yy < 0) || 112 | (bw <= 0 || bh <= 0) || 113 | (xx >= bw || yy >= bh) 114 | ) return (null); 115 | for (let ii = 0; ii < this.batches.length; ++ii) { 116 | const idx = (this.batches.length - 1) - ii; 117 | const batch = this.batches[idx]; 118 | const pixel = batch.getRawPixelAt(dx, dy); 119 | if (pixel !== null) return (pixel); 120 | }; 121 | return (null); 122 | }; 123 | 124 | /** 125 | * Merges two layers 126 | * Resize this by layer<->this bounding diff 127 | * Inject this matrix into layer matrix at layer bound pos 128 | * @param {Layer} olayer 129 | * @return {Batch} 130 | */ 131 | export function mergeWithLayer(olayer) { 132 | const layer = olayer.clone(); 133 | // TODO: fix merging referenced layers 134 | const main = this.batch; 135 | const opacity = this.opacity; 136 | const ldata = layer.batch.data; 137 | const lw = layer.bounds.w; 138 | const lh = layer.bounds.h; 139 | const lx = layer.x + layer.bounds.x; 140 | const ly = layer.y + layer.bounds.y; 141 | const sx = this.x + this.bounds.x; 142 | const sy = this.y + this.bounds.y; 143 | const dx = sx - lx; const dy = sy - ly; 144 | const bx = sx - dx; const by = sy - dy; 145 | const batch = this.createBatchAt(bx, by); 146 | // pre-resize batch 147 | batch.bounds.w = lw; 148 | batch.bounds.h = lh; 149 | // allocate pixel memory 150 | batch.data = new Uint8Array(ldata.length); 151 | batch.reverse = new Uint8Array(ldata.length); 152 | // draw batch matrix into layer matrix 153 | // and save earlier state 154 | for (let ii = 0; ii < ldata.length; ii += 4) { 155 | const idx = (ii / 4) | 0; 156 | const xx = (idx % lw) | 0; 157 | const yy = (idx / lw) | 0; 158 | const pixel = layer.getPixelAt(lx + xx, ly + yy); 159 | if (pixel === null) continue; 160 | //console.log(pixel[3], layer.oapcity); 161 | pixel[3] = pixel[3] * (opacity * layer.opacity); 162 | batch.drawPixelFast(lx + xx, ly + yy, pixel); 163 | }; 164 | batch.refreshTexture(true); 165 | return (batch); 166 | }; 167 | -------------------------------------------------------------------------------- /src/math.js: -------------------------------------------------------------------------------- 1 | import { TILE_SIZE } from "./cfg"; 2 | 3 | /** 4 | * @param {Number} x 5 | * @return {Number} 6 | */ 7 | export function zoomScale(x) { 8 | return ( 9 | x >= 0 ? x + 1 : 10 | x < 0 ? x + 1 : 11 | x + 1 12 | ); 13 | }; 14 | 15 | /** 16 | * @param {Number} x 17 | * @return {Boolean} 18 | */ 19 | export function isPowerOfTwo(x) { 20 | return ((x & (x - 1)) === 0); 21 | }; 22 | 23 | /** 24 | * @param {Number} x 25 | * @param {Number} t 26 | * @return {Number} 27 | */ 28 | export function roundTo(x, t) { 29 | const i = 1 / t; 30 | return (Math.round(x * i) / i); 31 | }; 32 | 33 | /** 34 | * @param {Number} value 35 | * @return {Number} 36 | */ 37 | export function alignToGrid(value) { 38 | return (roundTo(value, TILE_SIZE)); 39 | }; 40 | 41 | /** 42 | * @param {Number} x1 43 | * @param {Number} y1 44 | * @param {Number} x2 45 | * @param {Number} y2 46 | * @return {Number} 47 | */ 48 | export function pointDistance(x1, y1, x2, y2) { 49 | const xx = Math.pow(x2 - x1, 2); 50 | const yy = Math.pow(y2 - y1, 2); 51 | return (Math.sqrt(xx + yy)); 52 | }; 53 | 54 | /** 55 | * @param {Number} x1 56 | * @param {Number} y1 57 | * @param {Number} w1 58 | * @param {Number} h1 59 | * @param {Number} x2 60 | * @param {Number} y2 61 | * @param {Number} w2 62 | * @param {Number} h2 63 | * @return {Boolean} 64 | */ 65 | export function intersectRectangles(x1, y1, w1, h1, x2, y2, w2, h2) { 66 | x1 = x1 | 0; y1 = y1 | 0; 67 | w1 = w1 | 0; h1 = h1 | 0; 68 | x2 = x2 | 0; y2 = y2 | 0; 69 | w2 = w2 | 0; h2 = h2 | 0; 70 | const xx = Math.max(x1, x2); 71 | const ww = Math.min(x1 + w1, x2 + w2); 72 | const yy = Math.max(y1, y2); 73 | const hh = Math.min(y1 + h1, y2 + h2); 74 | return (ww >= xx && hh >= yy); 75 | }; 76 | -------------------------------------------------------------------------------- /src/render/buffer.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Create texture buffer from canvas 3 | * @param {String} name 4 | * @param {Uint8Array} data 5 | * @param {Number} width 6 | * @param {Number} height 7 | * @return {WebGLTexture} 8 | */ 9 | export function bufferTexture(name, data, width, height) { 10 | const gl = this.gl; 11 | const texture = gl.createTexture(); 12 | gl.bindTexture(gl.TEXTURE_2D, texture); 13 | gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, width, height, 0, gl.RGBA, gl.UNSIGNED_BYTE, data); 14 | gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST); 15 | gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST); 16 | gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); 17 | gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); 18 | if (this.cache.gl.textures[name] === void 0) { 19 | this.cache.gl.textures[name] = texture; 20 | } 21 | gl.bindTexture(gl.TEXTURE_2D, null); 22 | return (this.cache.gl.textures[name]); 23 | }; 24 | 25 | /** 26 | * Lookup for the texture inside our texture pool and free it from memory 27 | * @param {WebGLTexture} texture 28 | */ 29 | export function destroyTexture(texture) { 30 | const gl = this.gl; 31 | const textures = this.cache.gl.textures; 32 | for (let key in textures) { 33 | let txt = textures[key]; 34 | if (txt !== texture) continue; 35 | gl.deleteTexture(txt); 36 | delete textures[key]; 37 | txt = null; 38 | break; 39 | }; 40 | }; 41 | 42 | /** 43 | * @param {WebGLTexture} texture 44 | * @param {Uint8Array} data 45 | * @param {Number} width 46 | * @param {Number} height 47 | */ 48 | export function updateTexture(texture, data, width, height) { 49 | const gl = this.gl; 50 | gl.bindTexture(gl.TEXTURE_2D, texture); 51 | gl.texSubImage2D(gl.TEXTURE_2D, 0, 0, 0, width, height, gl.RGBA, gl.UNSIGNED_BYTE, data); 52 | gl.bindTexture(gl.TEXTURE_2D, null); 53 | }; 54 | 55 | /** 56 | * Create texture buffer from canvas 57 | * @param {String} name 58 | * @param {HTMLCanvasElement} canvas 59 | * @return {WebGLTexture} 60 | */ 61 | export function bufferTextureByCanvas(name, canvas) { 62 | const gl = this.gl; 63 | const texture = gl.createTexture(); 64 | gl.bindTexture(gl.TEXTURE_2D, texture); 65 | gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, canvas); 66 | gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST); 67 | gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST); 68 | gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); 69 | gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); 70 | if (this.cache.gl.textures[name] === void 0) { 71 | this.cache.gl.textures[name] = texture; 72 | } 73 | gl.bindTexture(gl.TEXTURE_2D, null); 74 | return (this.cache.gl.textures[name]); 75 | }; 76 | 77 | /** 78 | * @param {WebGLTexture} texture 79 | * @param {HTMLCanvasElement} canvas 80 | */ 81 | export function updateTextureByCanvas(texture, canvas) { 82 | const gl = this.gl; 83 | gl.bindTexture(gl.TEXTURE_2D, texture); 84 | gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, canvas); 85 | gl.bindTexture(gl.TEXTURE_2D, null); 86 | }; 87 | -------------------------------------------------------------------------------- /src/render/build.js: -------------------------------------------------------------------------------- 1 | import { getWGLContext } from "../utils"; 2 | import { WGL_TEXTURE_LIMIT } from "../cfg"; 3 | 4 | import { 5 | SPRITE_VERTEX, 6 | SPRITE_FRAGMENT 7 | } from "./shaders"; 8 | 9 | /** 10 | * @param {HTMLCanvasElement} view 11 | */ 12 | export function setupRenderer(view) { 13 | this.view = view; 14 | this.gl = getWGLContext(view); 15 | this.program = this.createSpriteProgram(); 16 | this.gl.useProgram(this.program); 17 | this.cache.gl.empty = this.createEmptyTexture(); 18 | }; 19 | 20 | /** 21 | * @return {WebGLTexture} 22 | */ 23 | export function createEmptyTexture() { 24 | const gl = this.gl; 25 | const texture = gl.createTexture(); 26 | gl.bindTexture(gl.TEXTURE_2D, texture); 27 | gl.texImage2D( 28 | gl.TEXTURE_2D, 0, gl.RGBA, 1, 1, 0, gl.RGBA, gl.UNSIGNED_BYTE, 29 | new Uint8Array([0, 0, 0, 0]) 30 | ); 31 | gl.bindTexture(gl.TEXTURE_2D, null); 32 | return (texture); 33 | }; 34 | 35 | /** 36 | * @return {WebGLProgram} 37 | */ 38 | export function createSpriteProgram() { 39 | const gl = this.gl; 40 | const size = WGL_TEXTURE_LIMIT; 41 | const program = gl.createProgram(); 42 | const vshader = gl.createShader(gl.VERTEX_SHADER); 43 | const fshader = gl.createShader(gl.FRAGMENT_SHADER); 44 | 45 | this.compileShader(vshader, SPRITE_VERTEX); 46 | this.compileShader(fshader, SPRITE_FRAGMENT); 47 | 48 | gl.attachShader(program, vshader); 49 | gl.attachShader(program, fshader); 50 | gl.linkProgram(program); 51 | 52 | const cache = this.cache.gl; 53 | const buffers = cache.buffers; 54 | const vertices = cache.vertices; 55 | const idxs = vertices.idx = new Float32Array(size * 6); 56 | vertices.position = new Float32Array(size * 12); 57 | 58 | buffers.idx = gl.createBuffer(); 59 | buffers.position = gl.createBuffer(); 60 | for (let ii = 0; ii < size; ii++) { 61 | idxs[6 * ii + 0] = 0; 62 | idxs[6 * ii + 1] = 1; 63 | idxs[6 * ii + 2] = 2; 64 | idxs[6 * ii + 3] = 1; 65 | idxs[6 * ii + 4] = 2; 66 | idxs[6 * ii + 5] = 3; 67 | }; 68 | 69 | this.setGlAttribute(program, buffers.idx, "aIdx", 1, idxs); 70 | return (program); 71 | }; 72 | 73 | export function compileShader(shader, shader_src) { 74 | const gl = this.gl; 75 | gl.shaderSource(shader, shader_src); 76 | gl.compileShader(shader); 77 | }; 78 | 79 | export function setGlAttribute(program, buffer, name, size, values) { 80 | const gl = this.gl; 81 | const attribute = gl.getAttribLocation(program, name); 82 | gl.enableVertexAttribArray(attribute); 83 | gl.bindBuffer(gl.ARRAY_BUFFER, buffer); 84 | if (values.length > 0) { 85 | gl.bufferData(gl.ARRAY_BUFFER, values, gl.DYNAMIC_DRAW); 86 | } 87 | gl.vertexAttribPointer(attribute, size, gl.FLOAT, false, 0, 0); 88 | }; 89 | -------------------------------------------------------------------------------- /src/render/draw.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Clears the context 3 | */ 4 | export function clear() { 5 | const gl = this.gl; 6 | gl.clearColor(0, 0, 0, 1); 7 | gl.clear(gl.COLOR_BUFFER_BIT); 8 | }; 9 | 10 | /** 11 | * Draw a texture 12 | * @param {Texture} tex 13 | * @param {Number} dx 14 | * @param {Number} dy 15 | * @param {Number} dw 16 | * @param {Number} dh 17 | */ 18 | export function drawImage(tex, dx, dy, dw, dh) { 19 | dx = dx | 0; 20 | dy = dy | 0; 21 | dw = dw | 0; 22 | dh = dh | 0; 23 | 24 | const gl = this.gl; 25 | const program = this.program; 26 | 27 | gl.uniform2f( 28 | gl.getUniformLocation(program, "uObjScale"), 29 | dw, dh 30 | ); 31 | 32 | const pos = this.cache.gl.vertices.position; 33 | for (let ii = 0; ii < 6; ++ii) { 34 | pos[2 * ii + 0] = dx + (dw / 2); 35 | pos[2 * ii + 1] = dy + (dh / 2); 36 | }; 37 | 38 | gl.activeTexture(gl.TEXTURE0); 39 | gl.bindTexture(gl.TEXTURE_2D, tex); 40 | this.setGlAttribute(program, this.cache.gl.buffers.position, "aObjCen", 2, pos); 41 | gl.drawArrays(gl.TRIANGLES, 0, 6); 42 | 43 | }; 44 | 45 | /** 46 | * Draw a rectangle 47 | * @param {Number} dx 48 | * @param {Number} dy 49 | * @param {Number} dw 50 | * @param {Number} dh 51 | * @param {Array} color 52 | */ 53 | export function drawRectangle(dx, dy, dw, dh, color) { 54 | dx = dx | 0; 55 | dy = dy | 0; 56 | dw = dw | 0; 57 | dh = dh | 0; 58 | 59 | const gl = this.gl; 60 | const program = this.program; 61 | 62 | gl.uniform2f( 63 | gl.getUniformLocation(program, "uObjScale"), 64 | dw, dh 65 | ); 66 | gl.uniform1i(gl.getUniformLocation(program, "isRect"), 1); 67 | 68 | const pos = this.cache.gl.vertices.position; 69 | for (let ii = 0; ii < 6; ++ii) { 70 | pos[2 * ii + 0] = dx + (dw / 2); 71 | pos[2 * ii + 1] = dy + (dh / 2); 72 | }; 73 | 74 | gl.activeTexture(gl.TEXTURE0); 75 | gl.bindTexture(gl.TEXTURE_2D, this.cache.gl.empty); 76 | gl.uniform4f( 77 | gl.getUniformLocation(program, "vColor"), 78 | color[0], color[1], color[2], color[3] 79 | ); 80 | this.setGlAttribute(program, this.cache.gl.buffers.position, "aObjCen", 2, pos); 81 | gl.drawArrays(gl.TRIANGLES, 0, 6); 82 | gl.uniform1i(gl.getUniformLocation(program, "isRect"), 0); 83 | 84 | }; 85 | -------------------------------------------------------------------------------- /src/render/generate.js: -------------------------------------------------------------------------------- 1 | import { 2 | TILE_SIZE, 3 | HIDE_GRID, 4 | MAGIC_SCALE, 5 | GRID_LINE_WIDTH 6 | } from "../cfg"; 7 | 8 | import { 9 | createCanvasBuffer, 10 | applyImageSmoothing 11 | } from "../utils"; 12 | 13 | /** 14 | * @return {CanvasRenderingContext2D} 15 | */ 16 | export function createGridBuffer() { 17 | const cw = this.cw; 18 | const ch = this.ch; 19 | const buffer = createCanvasBuffer(cw, ch); 20 | if (this.cache.grid !== null) { 21 | this.cache.grid = null; 22 | this.destroyTexture(this.cache.gridTexture); 23 | } 24 | this.cache.grid = buffer; 25 | this.cache.gridTexture = this.bufferTextureByCanvas("grid", buffer.canvas); 26 | this.redrawGridBuffer(); 27 | return (buffer); 28 | }; 29 | 30 | /** 31 | * @return {Void} 32 | */ 33 | export function redrawGridBuffer() { 34 | if (this.cr <= HIDE_GRID) return; 35 | const buffer = this.cache.grid; 36 | const texture = this.cache.gridTexture; 37 | const cr = this.cr; 38 | const size = (TILE_SIZE * cr) | 0; 39 | const cx = this.cx; 40 | const cy = this.cy; 41 | const cw = this.cw; 42 | const ch = this.ch; 43 | buffer.clearRect(0, 0, cw, ch); 44 | buffer.lineWidth = GRID_LINE_WIDTH; 45 | buffer.strokeStyle = "rgba(51,51,51,0.5)"; 46 | buffer.beginPath(); 47 | for (let xx = (cx % size) | 0; xx < cw; xx += size) { 48 | buffer.moveTo(xx, 0); 49 | buffer.lineTo(xx, ch); 50 | }; 51 | for (let yy = (cy % size) | 0; yy < ch; yy += size) { 52 | buffer.moveTo(0, yy); 53 | buffer.lineTo(cw, yy); 54 | }; 55 | buffer.stroke(); 56 | buffer.stroke(); 57 | buffer.closePath(); 58 | this.updateTextureByCanvas(texture, buffer.canvas); 59 | this.last.cx = this.cx; 60 | this.last.cy = this.cy; 61 | return; 62 | }; 63 | 64 | /** 65 | * @return {WebGLTexture} 66 | */ 67 | export function createBackgroundBuffer() { 68 | if (this.cache.bg instanceof WebGLTexture) { 69 | this.destroyTexture(this.cache.bg); 70 | } 71 | const size = TILE_SIZE; 72 | const cw = this.cw; 73 | const ch = this.ch; 74 | const canvas = document.createElement("canvas"); 75 | const buffer = canvas.getContext("2d"); 76 | canvas.width = cw; 77 | canvas.height = ch; 78 | // dark rectangles 79 | buffer.fillStyle = "#1f1f1f"; 80 | buffer.fillRect(0, 0, cw, ch); 81 | // bright rectangles 82 | buffer.fillStyle = "#212121"; 83 | for (let yy = 0; yy < ch; yy += size*2) { 84 | for (let xx = 0; xx < cw; xx += size*2) { 85 | // applied 2 times to increase saturation 86 | buffer.fillRect(xx, yy, size, size); 87 | buffer.fillRect(xx, yy, size, size); 88 | }; 89 | }; 90 | for (let yy = size; yy < ch; yy += size*2) { 91 | for (let xx = size; xx < cw; xx += size*2) { 92 | buffer.fillRect(xx, yy, size, size); 93 | }; 94 | }; 95 | const texture = this.bufferTextureByCanvas("background", canvas); 96 | return (texture); 97 | }; 98 | 99 | /** 100 | * @return {CanvasRenderingContext2D} 101 | */ 102 | export function createForegroundBuffer() { 103 | const cw = this.cw; 104 | const ch = this.ch; 105 | const buffer = createCanvasBuffer(cw, ch); 106 | applyImageSmoothing(buffer, false); 107 | if (this.cache.fg !== null) { 108 | this.cache.fg = null; 109 | this.destroyTexture(this.cache.fgTexture); 110 | } 111 | this.cache.fg = buffer; 112 | this.cache.fgTexture = this.bufferTextureByCanvas("foreground", buffer.canvas); 113 | return (buffer); 114 | }; 115 | -------------------------------------------------------------------------------- /src/render/main.js: -------------------------------------------------------------------------------- 1 | import { createCanvasBuffer } from "../utils"; 2 | 3 | /** 4 | * @return {String} 5 | */ 6 | export function exportAsDataUrl() { 7 | const layers = this.layers; 8 | const ww = this.bounds.w; const hh = this.bounds.h; 9 | const sx = this.bounds.x; const sy = this.bounds.y; 10 | const buffer = createCanvasBuffer(ww, hh); 11 | const view = buffer.canvas; 12 | for (let ii = 0; ii < layers.length; ++ii) { 13 | const idx = layers.length - 1 - ii; 14 | const layer = layers[idx]; 15 | const pos = layer.getRelativePosition(); 16 | const canvas = layer.toCanvas(); 17 | buffer.drawImage( 18 | canvas, 19 | 0, 0, 20 | canvas.width, canvas.height, 21 | pos.x, pos.y, 22 | canvas.width, canvas.height 23 | ); 24 | }; 25 | return (view.toDataURL("image/png")); 26 | }; 27 | -------------------------------------------------------------------------------- /src/render/render.js: -------------------------------------------------------------------------------- 1 | import { 2 | MODES, 3 | TILE_SIZE, 4 | HIDE_GRID, 5 | MAGIC_SCALE, 6 | GRID_LINE_WIDTH, 7 | SELECTION_COLOR, 8 | ERASE_TILE_COLOR, 9 | TILE_HOVER_COLOR, 10 | SELECTION_COLOR_ACTIVE 11 | } from "../cfg"; 12 | 13 | import { createCanvasBuffer } from "../utils"; 14 | import { colorToRgbaString } from "../color"; 15 | import { intersectRectangles } from "../math"; 16 | 17 | export function update() { 18 | const containers = this.containers; 19 | for (let ii = 0; ii < containers.length; ++ii) { 20 | const container = containers[ii]; 21 | const ww = container.bounds.w; 22 | const hh = container.bounds.h; 23 | if (this.frames % 32 === 0) { 24 | const frame = container.animation.frame; 25 | container.animation.frame = (frame >= (ww * hh) - 1) ? 0 : frame + 1; 26 | } 27 | this.redraw = true; 28 | }; 29 | }; 30 | 31 | export function updateGrid() { 32 | // only redraw texture if it's absolutely necessary 33 | if (this.last.cx !== this.cx || this.last.cy !== this.cy) { 34 | this.redrawGridBuffer(); 35 | this.redraw = true; 36 | } 37 | }; 38 | 39 | /** Main render method */ 40 | export function render() { 41 | this.frames++; 42 | const selection = this.sw !== -0 && this.sh !== -0; 43 | const cr = this.cr; 44 | const gl = this.gl; 45 | const glOpacity = gl.getUniformLocation(this.program, "vOpacity"); 46 | gl.uniform1f( 47 | glOpacity, 1.0 48 | ); 49 | // clear foreground buffer 50 | this.cache.fg.clearRect(0, 0, this.cw, this.ch); 51 | this.renderBackground(); 52 | //if (this.cr > HIDE_GRID) this.renderGrid(); 53 | // render cached version of our working area 54 | const cx = this.cx | 0; 55 | const cy = this.cy | 0; 56 | // draw global boundings 57 | if (MODES.DEV) { 58 | const bounds = this.bounds; 59 | const x = (cx + ((bounds.x * TILE_SIZE) * cr)) | 0; 60 | const y = (cy + ((bounds.y * TILE_SIZE) * cr)) | 0; 61 | const w = (bounds.w * TILE_SIZE) * cr; 62 | const h = (bounds.h * TILE_SIZE) * cr; 63 | this.drawRectangle( 64 | x, y, 65 | w, h, 66 | [0, 1, 0, 0.1] 67 | ); 68 | } 69 | // render containers 70 | const containers = this.containers; 71 | for (let ii = 0; ii < containers.length; ++ii) { 72 | const container = containers[ii]; 73 | if (!container.visible) continue; 74 | this.renderContainer(container); 75 | }; 76 | // render layers 77 | const layers = this.layers; 78 | for (let ii = 0; ii < layers.length; ++ii) { 79 | const idx = layers.length - 1 - ii; 80 | const layer = layers[idx]; 81 | if (!layer.visible) continue; 82 | gl.uniform1f( 83 | glOpacity, layer.opacity 84 | ); 85 | const bounds = layer.bounds; 86 | const ww = (bounds.w * TILE_SIZE) * cr; 87 | const hh = (bounds.h * TILE_SIZE) * cr; 88 | const xx = cx + ((layer.x + bounds.x) * TILE_SIZE) * cr; 89 | const yy = cy + ((layer.y + bounds.y) * TILE_SIZE) * cr; 90 | this.drawImage( 91 | layer.batch.texture, 92 | xx, yy, 93 | ww, hh 94 | ); 95 | // don't forget to render live batches 96 | this.renderLayer(layer); 97 | }; 98 | gl.uniform1f( 99 | glOpacity, 1.0 100 | ); 101 | if (!this.states.drawing && (!this.states.select || !selection)) { 102 | this.renderHoveredTile(); 103 | } 104 | if (this.shape !== null) this.renderShapeSelection(); 105 | else if (selection) this.renderSelection(); 106 | if (MODES.DEV) this.renderStats(); 107 | // render foreground buffer 108 | this.renderForeground(); 109 | this.redraw = false; 110 | }; 111 | 112 | /** 113 | * @param {Layer} layer 114 | */ 115 | export function renderLayer(layer) { 116 | const cx = this.cx | 0; 117 | const cy = this.cy | 0; 118 | const cr = this.cr; 119 | const gl = this.gl; 120 | const glOpacity = gl.getUniformLocation(this.program, "vOpacity"); 121 | const batches = layer.batches; 122 | const sindex = this.sindex; 123 | for (let ii = 0; ii < batches.length; ++ii) { 124 | const batch = batches[ii]; 125 | const bounds = batch.bounds; 126 | if (!batch.forceRendering) continue; 127 | // batch index is higher than stack index, so ignore this batch 128 | if (sindex - batch.getStackIndex() < 0) { 129 | if (!batch.forceRendering) continue; 130 | } 131 | gl.uniform1f( 132 | glOpacity, layer.opacity 133 | ); 134 | //if (!this.boundsInsideView(bounds)) continue; 135 | const x = (cx + (((layer.x + bounds.x) * TILE_SIZE) * cr)) | 0; 136 | const y = (cy + (((layer.y + bounds.y) * TILE_SIZE) * cr)) | 0; 137 | const w = (bounds.w * TILE_SIZE) * cr; 138 | const h = (bounds.h * TILE_SIZE) * cr; 139 | // draw batch boundings 140 | if (MODES.DEV) { 141 | this.drawRectangle( 142 | x, y, 143 | w, h, 144 | [1, 0, 0, 0.1] 145 | ); 146 | } 147 | // erase by alpha blending 148 | if (batch.isEraser) { 149 | const gl = this.gl; 150 | gl.blendFuncSeparate(gl.ONE_MINUS_SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA, gl.ZERO, gl.ZERO); 151 | } 152 | this.drawImage( 153 | batch.texture, 154 | x, y, 155 | w, h 156 | ); 157 | // reset blending state 158 | if (batch.isEraser) { 159 | const gl = this.gl; 160 | gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA); 161 | } 162 | gl.uniform1f( 163 | glOpacity, 1.0 164 | ); 165 | }; 166 | // draw a thin rectangle around active layers 167 | if (MODES.DEV && layer.isActive) { 168 | const bounds = layer.bounds; 169 | const x = (cx + (((layer.x + bounds.x) * TILE_SIZE) * cr)); 170 | const y = (cy + (((layer.y + bounds.y) * TILE_SIZE) * cr)); 171 | const w = (bounds.w * TILE_SIZE) * cr; 172 | const h = (bounds.h * TILE_SIZE) * cr; 173 | this.drawStrokedRect(x, y, w, h, "rgba(255,255,255,0.2)"); 174 | } 175 | }; 176 | 177 | /** 178 | * @param {Container} container 179 | */ 180 | export function renderContainer(container) { 181 | container.renderBoundings(); 182 | const ww = container.bounds.w | 0; 183 | const hh = container.bounds.h | 0; 184 | for (let ii = 0; ii < ww * hh; ++ii) { 185 | container.renderFrame(ii); 186 | }; 187 | container.renderAnimationPreview(); 188 | }; 189 | 190 | export function renderForeground() { 191 | const texture = this.cache.fgTexture; 192 | const fg = this.cache.fg; 193 | const view = fg.canvas; 194 | this.updateTextureByCanvas(texture, view); 195 | this.drawImage( 196 | texture, 197 | 0, 0, view.width, view.height 198 | ); 199 | }; 200 | 201 | export function renderBackground() { 202 | this.drawImage( 203 | this.cache.bg, 204 | 0, 0, 205 | this.cw, this.ch 206 | ); 207 | }; 208 | 209 | export function renderGrid() { 210 | this.drawImage( 211 | this.cache.gridTexture, 212 | 0, 0, 213 | this.cw, this.ch 214 | ); 215 | }; 216 | 217 | export function renderHoveredTile() { 218 | const cx = this.cx | 0; 219 | const cy = this.cy | 0; 220 | const cr = this.cr; 221 | // apply empty tile hover color 222 | const mx = this.mx; 223 | const my = this.my; 224 | const relative = this.getRelativeTileOffset(mx, my); 225 | const rx = relative.x * TILE_SIZE; 226 | const ry = relative.y * TILE_SIZE; 227 | const x = ((cx + GRID_LINE_WIDTH/2) + (rx * cr)) | 0; 228 | const y = ((cy + GRID_LINE_WIDTH/2) + (ry * cr)) | 0; 229 | const ww = (TILE_SIZE * cr) | 0; 230 | const hh = (TILE_SIZE * cr) | 0; 231 | this.drawRectangle( 232 | x, y, 233 | ww, hh, 234 | TILE_HOVER_COLOR 235 | ); 236 | }; 237 | 238 | export function renderSelection() { 239 | const cx = this.cx | 0; 240 | const cy = this.cy | 0; 241 | const cr = this.cr; 242 | const xx = (cx + (this.sx * TILE_SIZE) * cr) | 0; 243 | const yy = (cy + (this.sy * TILE_SIZE) * cr) | 0; 244 | const ww = ((this.sw * TILE_SIZE) * cr) | 0; 245 | const hh = ((this.sh * TILE_SIZE) * cr) | 0; 246 | const color = ( 247 | this.states.selecting ? 248 | SELECTION_COLOR_ACTIVE : 249 | SELECTION_COLOR 250 | ); 251 | this.drawRectangle( 252 | xx, yy, 253 | ww, hh, 254 | color 255 | ); 256 | }; 257 | 258 | export function renderShapeSelection() { 259 | const cx = this.cx | 0; 260 | const cy = this.cy | 0; 261 | const cr = this.cr; 262 | const batch = this.shape; 263 | const bounds = batch.bounds; 264 | const xx = (cx + ((bounds.x * TILE_SIZE) * cr)) | 0; 265 | const yy = (cy + ((bounds.y * TILE_SIZE) * cr)) | 0; 266 | const ww = (bounds.w * TILE_SIZE) * cr; 267 | const hh = (bounds.h * TILE_SIZE) * cr; 268 | this.drawImage( 269 | batch.texture, 270 | xx, yy, ww, hh 271 | ); 272 | }; 273 | 274 | export function renderStats() { 275 | const buffer = this.cache.fg; 276 | const bounds = this.bounds; 277 | const mx = this.last.mx; 278 | const my = this.last.my; 279 | // font style 280 | buffer.font = "10px Verdana"; 281 | buffer.fillStyle = "#fff"; 282 | // stats 283 | buffer.fillText(`Mouse: x: ${mx}, y: ${my}`, 8, 16); 284 | buffer.fillText(`GPU textures: ${Object.keys(this.cache.gl.textures).length}`, 8, 28); 285 | buffer.fillText(`Boundings: x: ${bounds.x}, y: ${bounds.y}, w: ${bounds.w}, h: ${bounds.h}`, 8, 40); 286 | buffer.fillText(`Camera scale: ${this.cr}`, 8, 52); 287 | buffer.fillText(`Stack: ${this.sindex + 1}:${this.stack.length}`, 8, 64); 288 | // mouse color 289 | let color = this.getAbsolutePixelAt(mx, my); 290 | if (color !== null) { 291 | buffer.fillStyle = colorToRgbaString(color); 292 | buffer.fillRect(8, 70, 8, 8); 293 | buffer.fillStyle = "#fff"; 294 | buffer.fillText(`${color[0]}, ${color[1]}, ${color[2]}, ${color[3]}`, 22, 77); 295 | } 296 | /*if (MODES.DEV) { 297 | const bounds = this.bounds; 298 | const cr = this.cr; 299 | const xx = ((this.cx | 0) + ((bounds.x * TILE_SIZE) * cr)) | 0; 300 | const yy = ((this.cy | 0) + ((bounds.y * TILE_SIZE) * cr)) | 0; 301 | const ww = (bounds.w * TILE_SIZE) * cr; 302 | const hh = (bounds.h * TILE_SIZE) * cr; 303 | this.drawResizeRectangle(xx, yy, ww, hh, "#313131"); 304 | }*/ 305 | if (this.sw !== 0 && this.sh !== 0) { 306 | this.drawSelectionShape(); 307 | } 308 | }; 309 | 310 | export function drawSelectionShape() { 311 | const cr = this.cr; 312 | const s = this.getSelection(); 313 | const xx = ((this.cx | 0) + ((s.x * TILE_SIZE) * cr)) | 0; 314 | const yy = ((this.cy | 0) + ((s.y * TILE_SIZE) * cr)) | 0; 315 | const ww = (s.w * TILE_SIZE) * cr; 316 | const hh = (s.h * TILE_SIZE) * cr; 317 | const size = TILE_SIZE * cr; 318 | const buffer = this.cache.fg; 319 | buffer.strokeStyle = "rgba(255,255,255,0.7)"; 320 | buffer.lineWidth = 0.45 * cr; 321 | buffer.setLineDash([size, size]); 322 | buffer.strokeRect( 323 | xx, yy, 324 | ww, hh 325 | ); 326 | buffer.setLineDash([0, 0]); 327 | }; 328 | 329 | /** 330 | * Draws a stroked rectangle 331 | * @param {Number} x 332 | * @param {Number} y 333 | * @param {Number} w 334 | * @param {Number} h 335 | * @param {String} color 336 | */ 337 | export function drawStrokedRect(x, y, w, h, color) { 338 | const cr = this.cr; 339 | const lw = Math.max(0.55, 0.55 * cr); 340 | const buffer = this.cache.fg; 341 | buffer.strokeStyle = color; 342 | buffer.lineWidth = lw; 343 | // main rectangle 344 | buffer.strokeRect( 345 | x - (lw / 2), y - (lw / 2), 346 | w + lw, h + lw 347 | ); 348 | }; 349 | 350 | /** 351 | * Draw resizable rectangle around given rectangle corners 352 | * @param {Number} x 353 | * @param {Number} y 354 | * @param {Number} w 355 | * @param {Number} h 356 | * @param {String} color 357 | */ 358 | export function drawResizeRectangle(x, y, w, h, color) { 359 | const cr = this.cr; 360 | const ww = 4 * cr; 361 | const hh = 4 * cr; 362 | const buffer = this.cache.fg; 363 | buffer.strokeStyle = color; 364 | buffer.lineWidth = Math.max(0.4, 0.45 * cr); 365 | // main rectangle 366 | buffer.strokeRect( 367 | x, y, 368 | w, h 369 | ); 370 | buffer.lineWidth = Math.max(0.4, 0.3 * cr); 371 | // left rectangle 372 | buffer.strokeRect( 373 | x - ww, (y + (h / 2) - hh / 2), 374 | ww, hh 375 | ); 376 | // right rectangle 377 | buffer.strokeRect( 378 | x + w, (y + (h / 2) - hh / 2), 379 | ww, hh 380 | ); 381 | // top rectangle 382 | buffer.strokeRect( 383 | (x + (w / 2) - ww / 2), y - hh, 384 | ww, hh 385 | ); 386 | // bottom rectangle 387 | buffer.strokeRect( 388 | (x + (w / 2) - ww / 2), (y + h), 389 | ww, hh 390 | ); 391 | }; 392 | -------------------------------------------------------------------------------- /src/render/resize.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Resize 3 | * @param {Number} width 4 | * @param {Number} height 5 | */ 6 | export function resize(width, height) { 7 | width = width | 0; 8 | height = height | 0; 9 | const gl = this.gl; 10 | const view = this.view; 11 | // first update camera size 12 | this.cw = width; 13 | this.ch = height; 14 | // update view 15 | view.width = width; 16 | view.height = height; 17 | // update viewport 18 | gl.viewport(0, 0, width, height); 19 | // update shader scales 20 | gl.uniform2f( 21 | gl.getUniformLocation(this.program, "uScale"), 22 | width, height 23 | ); 24 | gl.enable(gl.BLEND); 25 | gl.disable(gl.CULL_FACE); 26 | gl.disable(gl.DEPTH_TEST); 27 | gl.disable(gl.STENCIL_TEST); 28 | gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA); 29 | // re-generate our bg and fg 30 | this.cache.bg = this.createBackgroundBuffer(); 31 | this.cache.fg = this.createForegroundBuffer(); 32 | // re-generate our grid 33 | this.cache.grid = this.createGridBuffer(); 34 | this.redrawGridBuffer(); 35 | this.redraw = true; 36 | }; 37 | -------------------------------------------------------------------------------- /src/render/shaders.js: -------------------------------------------------------------------------------- 1 | export const SPRITE_VERTEX = ` 2 | precision lowp float; 3 | uniform vec2 uScale; 4 | uniform vec2 uObjScale; 5 | attribute vec2 aObjCen; 6 | attribute float aIdx; 7 | varying vec2 uv; 8 | void main(void) { 9 | if (aIdx == 0.0) { 10 | uv = vec2(0.0,0.0); 11 | } else if (aIdx == 1.0) { 12 | uv = vec2(1.0,0.0); 13 | } else if (aIdx == 2.0) { 14 | uv = vec2(0.0,1.0); 15 | } else { 16 | uv = vec2(1.0,1.0); 17 | } 18 | gl_Position = vec4( 19 | -1.0 + 2.0 * (aObjCen.x + uObjScale.x * (-0.5 + uv.x)) / uScale.x, 20 | 1.0 - 2.0 * (aObjCen.y + uObjScale.y * (-0.5 + uv.y)) / uScale.y, 21 | 0.0, 1.0 22 | ); 23 | } 24 | `; 25 | 26 | export const SPRITE_FRAGMENT = ` 27 | precision lowp float; 28 | uniform sampler2D uSampler; 29 | varying vec2 uv; 30 | uniform int isRect; 31 | uniform float vOpacity; 32 | uniform vec4 vColor; 33 | void main(void) { 34 | if (isRect == 0) { 35 | gl_FragColor = texture2D(uSampler, uv); 36 | } else { 37 | gl_FragColor = vColor + texture2D(uSampler, uv); 38 | } 39 | gl_FragColor.a *= vOpacity; 40 | if (gl_FragColor.a < 0.01) discard; 41 | } 42 | `; 43 | -------------------------------------------------------------------------------- /src/setup.js: -------------------------------------------------------------------------------- 1 | import { rgbaToHex } from "./color"; 2 | import { getWGLContext } from "./utils"; 3 | 4 | import Batch from "./batch/index"; 5 | import Layer from "./layer/index"; 6 | 7 | export function setup() { 8 | const view = document.createElement("canvas"); 9 | const width = window.innerWidth; 10 | const height = window.innerHeight; 11 | view.width = width; 12 | view.height = height; 13 | // sync storage colors with stage colors 14 | /*const colors = this.readStorage("favorite_colors"); 15 | if (colors && colors.length > 2) { 16 | this.favoriteColors = JSON.parse(colors); 17 | this.updateFastColorPickMenu(); 18 | this.setUiColorByRgba(this.favoriteColors[0].color); 19 | } else { 20 | this.setUiColorByHex([255, 0, 0, 1]); 21 | }*/ 22 | this.setUiColorByRgba([255, 0, 0, 1]); 23 | this.setupRenderer(view); 24 | this.initListeners(); 25 | this.resize(width, height); 26 | this.scale(0); 27 | const draw = () => { 28 | requestAnimationFrame(() => draw()); 29 | this.update(); 30 | if (this.redraw) { 31 | this.clear(); 32 | this.render(); 33 | } 34 | }; 35 | // add some things manually 36 | (() => { 37 | this.cache.layer = new Layer(this); 38 | const layer = this.addLayer(); 39 | this.setActiveLayer(layer); 40 | })(); 41 | requestAnimationFrame(() => draw()); 42 | this.setupUi(); 43 | document.body.appendChild(view); 44 | }; 45 | -------------------------------------------------------------------------------- /src/stack/cmd.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @class Command 3 | */ 4 | class Command { 5 | /** 6 | * @param {Number} kind 7 | * @param {Batch} batch 8 | * @constructor 9 | */ 10 | constructor(kind, batch) { 11 | this.kind = kind; 12 | this.batch = batch; 13 | } 14 | }; 15 | 16 | export default Command; 17 | -------------------------------------------------------------------------------- /src/stack/kind.js: -------------------------------------------------------------------------------- 1 | const CommandKind = { 2 | UNKNOWN: 0, 3 | LAYER_OPERATION: 1, 4 | BATCH_OPERATION: 2, 5 | CONTAINER_OPERATION: 3, 6 | DRAW: 4, 7 | ERASE: 5, 8 | FILL: 6, 9 | BACKGROUND: 7, 10 | PASTE: 8, 11 | CUT: 9, 12 | INSERT_IMAGE: 10, 13 | STROKE: 11, 14 | RECT_FILL: 12, 15 | RECT_STROKE: 13, 16 | ARC_FILL: 14, 17 | ARC_STROKE: 15, 18 | FLOOD_FILL: 16, 19 | LIGHTING: 17, 20 | LAYER_ADD: 18, 21 | LAYER_REMOVE: 19, 22 | LAYER_LOCK: 20, 23 | LAYER_FLIP_VERTICAL: 21, 24 | LAYER_FLIP_HORIZONTAL: 22, 25 | LAYER_MOVE: 23, 26 | LAYER_ORDER: 24, 27 | LAYER_RENAME: 25, 28 | LAYER_ROTATE_LEFT: 26, 29 | LAYER_ROTATE_RIGHT: 27, 30 | LAYER_VISIBILITY: 28, 31 | LAYER_OPACITY: 29, 32 | LAYER_CLONE: 30, 33 | LAYER_CLONE_REF: 31, 34 | LAYER_MERGE: 32, 35 | CONTAINER_ADD: 33, 36 | CONTAINER_REMOVE: 34 37 | }; 38 | 39 | export default CommandKind; 40 | -------------------------------------------------------------------------------- /src/stack/redo.js: -------------------------------------------------------------------------------- 1 | import Command from "./cmd"; 2 | import CommandKind from "./kind"; 3 | 4 | /** 5 | * @return {Void} 6 | */ 7 | export function redo() { 8 | // prevent undo/redo when in e.g drawing state 9 | if (this.isInActiveState()) return; 10 | if (this.sindex < this.stack.length - 1) { 11 | this.sindex++; 12 | const cmd = this.currentStackOperation(); 13 | this.fire(cmd, true); 14 | } 15 | this.refreshUiLayers(); 16 | this.updateGlobalBoundings(); 17 | this.redraw = true; 18 | return; 19 | }; 20 | 21 | /** 22 | * @param {Number} kind 23 | * @param {Batch} batch 24 | * @return {Void} 25 | */ 26 | export function enqueue(kind, batch) { 27 | // our stack index is out of position 28 | // => clean up all more recent batches 29 | this.refreshStack(); 30 | const cmd = new Command(kind, batch); 31 | const type = this.getCommandKind(cmd); 32 | // free the texture from gpu, we dont need it anymore 33 | if (type === CommandKind.BATCH_OPERATION) { 34 | batch.destroyTexture(); 35 | } 36 | const last = this.currentStackOperation(); 37 | this.stack.push(cmd); 38 | this.redo(); 39 | // delete last operation if it's mergable 40 | if (last !== null && last.kind === cmd.kind) { 41 | if (this.isMergeableOperation(cmd.kind)) { 42 | // delete last 2 commands 43 | this.stack.splice(this.sindex - 1, this.stack.length); 44 | this.sindex = this.stack.length - 1; 45 | } 46 | } 47 | }; 48 | -------------------------------------------------------------------------------- /src/stack/state.js: -------------------------------------------------------------------------------- 1 | import { MODES } from "../cfg"; 2 | import { additiveAlphaColorBlending } from "../color"; 3 | 4 | import CommandKind from "./kind"; 5 | 6 | /** 7 | * Manually refresh the stack, 8 | * clear future operations etc. 9 | */ 10 | export function refreshStack() { 11 | if (this.sindex < this.stack.length - 1) { 12 | this.dequeue(this.sindex, this.stack.length - 1); 13 | } else { 14 | this.stack.splice(this.sindex + 1, this.stack.length); 15 | } 16 | //this.updateGlobalBoundings(); 17 | }; 18 | 19 | /** 20 | * Returns the latest stack operation 21 | * @return {Command} 22 | */ 23 | export function currentStackOperation() { 24 | return (this.stack[this.sindex] || null); 25 | }; 26 | 27 | /** 28 | * Indicates if an operation is mergeable 29 | * with an older same kind but opposite operation 30 | * @param {Number} kind 31 | * @return {Boolean} 32 | */ 33 | export function isMergeableOperation(kind) { 34 | return ( 35 | kind === CommandKind.LAYER_LOCK || 36 | kind === CommandKind.LAYER_FLIP_VERTICAL || 37 | kind === CommandKind.LAYER_FLIP_HORIZONTAL || 38 | kind === CommandKind.LAYER_VISIBILITY 39 | ); 40 | }; 41 | 42 | /** 43 | * @param {Command} cmd 44 | * @param {Boolean} state 45 | */ 46 | export function fire(cmd, state) { 47 | const kind = this.getCommandKind(cmd); 48 | switch (kind) { 49 | case CommandKind.LAYER_OPERATION: 50 | this.fireLayerOperation(cmd, state); 51 | break; 52 | case CommandKind.BATCH_OPERATION: 53 | this.fireBatchOperation(cmd, state); 54 | break; 55 | case CommandKind.CONTAINER_OPERATION: 56 | this.fireContainerOperation(cmd, state); 57 | break; 58 | }; 59 | }; 60 | 61 | /** 62 | * @param {Command} cmd 63 | * @param {Boolean} state 64 | */ 65 | export function fireContainerOperation(cmd, state) { 66 | const batch = cmd.batch; 67 | const container = batch.container; 68 | if (state) { 69 | this.containers.push(container); 70 | } else { 71 | for (let ii = 0; ii < this.containers.length; ++ii) { 72 | if (this.containers[ii].id === container.id) { 73 | this.containers.splice(ii, 1); 74 | break; 75 | } 76 | }; 77 | } 78 | }; 79 | 80 | /** 81 | * @param {Command} cmd 82 | * @param {Boolean} state 83 | */ 84 | export function fireLayerOperation(cmd, state) { 85 | const kind = cmd.kind; 86 | const batch = cmd.batch; 87 | const layer = batch.layer; 88 | const main = layer.batch; 89 | switch (kind) { 90 | case CommandKind.LAYER_MERGE: 91 | layer.updateBoundings(); 92 | const data = cmd.batch.data; 93 | if (state) { 94 | layer.removeUiReference(); 95 | this.layers.splice(batch.index, 1); 96 | let index = batch.index < 0 ? 0 : batch.index; 97 | index = index === this.layers.length ? index - 1 : index; 98 | const merge = this.getLayerByIndex(index); 99 | this.setActiveLayer(merge); 100 | merge.updateBoundings(); 101 | merge.batch.injectMatrix(data, true); 102 | merge.batch.refreshTexture(true); 103 | } else { 104 | const merge = this.getLayerByIndex(batch.index); 105 | this.layers.splice(batch.index, 0, layer); 106 | layer.addUiReference(); 107 | this.setActiveLayer(layer); 108 | merge.batch.injectMatrix(data, false); 109 | merge.batch.refreshTexture(true); 110 | } 111 | break; 112 | case CommandKind.LAYER_CLONE: 113 | case CommandKind.LAYER_CLONE_REF: 114 | case CommandKind.LAYER_ADD: 115 | if (state) { 116 | this.layers.splice(batch.index, 0, layer); 117 | layer.addUiReference(); 118 | this.setActiveLayer(layer); 119 | } else { 120 | layer.removeUiReference(); 121 | this.layers.splice(batch.index, 1); 122 | const index = batch.index < 0 ? 0 : batch.index; 123 | this.setActiveLayer(this.getLayerByIndex(index)); 124 | } 125 | break; 126 | case CommandKind.LAYER_REMOVE: 127 | if (!state) { 128 | this.layers.splice(batch.index, 0, layer); 129 | layer.addUiReference(); 130 | this.setActiveLayer(layer); 131 | } else { 132 | layer.removeUiReference(); 133 | this.layers.splice(batch.index, 1); 134 | let index = batch.index < 0 ? 0 : batch.index; 135 | index = index === this.layers.length ? index - 1 : index; 136 | this.setActiveLayer(this.getLayerByIndex(index)); 137 | } 138 | break; 139 | case CommandKind.LAYER_RENAME: 140 | layer.name = batch[state ? "name": "oname"]; 141 | break; 142 | case CommandKind.LAYER_LOCK: 143 | layer.locked = !layer.locked; 144 | break; 145 | case CommandKind.LAYER_VISIBILITY: 146 | layer.visible = !layer.visible; 147 | break; 148 | case CommandKind.LAYER_OPACITY: 149 | layer.opacity = batch[state ? "opacity" : "oopacity"]; 150 | break; 151 | case CommandKind.LAYER_ORDER: 152 | if (state) { 153 | const tmp = this.layers[batch.oindex]; 154 | this.layers[batch.oindex] = this.layers[batch.index]; 155 | this.layers[batch.index] = tmp; 156 | tmp.removeUiReference(); tmp.addUiReference(); 157 | this.setActiveLayer(tmp); 158 | } else { 159 | const tmp = this.layers[batch.index]; 160 | this.layers[batch.index] = this.layers[batch.oindex]; 161 | this.layers[batch.oindex] = tmp; 162 | tmp.removeUiReference(); tmp.addUiReference(); 163 | this.setActiveLayer(tmp); 164 | } 165 | break; 166 | case CommandKind.LAYER_MOVE: 167 | layer.updateBoundings(); 168 | const dir = state ? 1 : -1; 169 | layer.x += (batch.position.x * dir); 170 | layer.y += (batch.position.y * dir); 171 | break; 172 | case CommandKind.LAYER_FLIP_VERTICAL: 173 | this.flipVertically(layer); 174 | break; 175 | case CommandKind.LAYER_FLIP_HORIZONTAL: 176 | this.flipHorizontally(layer); 177 | break; 178 | case CommandKind.LAYER_ROTATE_LEFT: 179 | if (state) this.rotateLeft(layer); 180 | else this.rotateRight(layer); 181 | break; 182 | case CommandKind.LAYER_ROTATE_RIGHT: 183 | if (state) this.rotateRight(layer); 184 | else this.rotateLeft(layer); 185 | break; 186 | }; 187 | }; 188 | 189 | /** 190 | * @param {Command} cmd 191 | * @param {Boolean} state 192 | */ 193 | export function fireBatchOperation(cmd, state) { 194 | const batch = cmd.batch; 195 | const layer = batch.layer; 196 | const main = layer.batch; 197 | const kind = cmd.kind; 198 | layer.updateBoundings(); 199 | main.injectMatrix(batch, state); 200 | main.refreshTexture(true); 201 | }; 202 | 203 | /** 204 | * @param {Command} cmd 205 | * @return {Number} 206 | */ 207 | export function getCommandKind(cmd) { 208 | const kind = cmd.kind; 209 | switch (kind) { 210 | case CommandKind.LAYER_LOCK: 211 | case CommandKind.LAYER_MOVE: 212 | case CommandKind.LAYER_ORDER: 213 | case CommandKind.LAYER_RENAME: 214 | case CommandKind.LAYER_ROTATE_LEFT: 215 | case CommandKind.LAYER_ROTATE_RIGHT: 216 | case CommandKind.LAYER_VISIBILITY: 217 | case CommandKind.LAYER_OPACITY: 218 | case CommandKind.LAYER_ADD: 219 | case CommandKind.LAYER_REMOVE: 220 | case CommandKind.LAYER_MERGE: 221 | case CommandKind.LAYER_CLONE: 222 | case CommandKind.LAYER_CLONE_REF: 223 | case CommandKind.LAYER_FLIP_VERTICAL: 224 | case CommandKind.LAYER_FLIP_HORIZONTAL: 225 | return (CommandKind.LAYER_OPERATION); 226 | break; 227 | case CommandKind.DRAW: 228 | case CommandKind.ERASE: 229 | case CommandKind.FILL: 230 | case CommandKind.BACKGROUND: 231 | case CommandKind.PASTE: 232 | case CommandKind.CUT: 233 | case CommandKind.INSERT_IMAGE: 234 | case CommandKind.STROKE: 235 | case CommandKind.RECT_FILL: 236 | case CommandKind.RECT_STROKE: 237 | case CommandKind.ARC_FILL: 238 | case CommandKind.ARC_STROKE: 239 | case CommandKind.FLOOD_FILL: 240 | case CommandKind.LIGHTING: 241 | return (CommandKind.BATCH_OPERATION); 242 | break; 243 | case CommandKind.CONTAINER_ADD: 244 | case CommandKind.CONTAINER_REMOVE: 245 | return (CommandKind.CONTAINER_OPERATION); 246 | break; 247 | }; 248 | return (CommandKind.UNKNOWN); 249 | }; 250 | -------------------------------------------------------------------------------- /src/stack/undo.js: -------------------------------------------------------------------------------- 1 | import CommandKind from "./kind"; 2 | 3 | /** 4 | * @return {Void} 5 | */ 6 | export function undo() { 7 | // prevent undo/redo when in e.g drawing state 8 | if (this.isInActiveState()) return; 9 | if (this.sindex >= 0) { 10 | const cmd = this.currentStackOperation(); 11 | this.fire(cmd, false); 12 | this.sindex--; 13 | } 14 | this.refreshUiLayers(); 15 | this.updateGlobalBoundings(); 16 | this.redraw = true; 17 | return; 18 | }; 19 | 20 | /** 21 | * Dequeue items from stack 22 | * @param {Number} from 23 | * @param {Number} to 24 | */ 25 | export function dequeue(from, to) { 26 | from = from + 1; 27 | const count = (to - (from - 1)); 28 | for (let ii = count; ii > 0; --ii) { 29 | const idx = from + ii - 1; 30 | const cmd = this.stack[idx]; 31 | const kind = this.getCommandKind(cmd); 32 | switch (kind) { 33 | case CommandKind.BATCH_OPERATION: 34 | cmd.batch.kill(); 35 | break; 36 | case CommandKind.LAYER_OPERATION: 37 | if (cmd.kind === CommandKind.LAYER_MERGE) { 38 | cmd.batch.data.kill(); 39 | } 40 | break; 41 | }; 42 | this.stack.splice(idx, 1); 43 | }; 44 | }; 45 | -------------------------------------------------------------------------------- /src/storage/read.js: -------------------------------------------------------------------------------- 1 | import { 2 | STORAGE_KEY, 3 | STORAGE_OBJECT 4 | } from "../cfg"; 5 | 6 | /** 7 | * @param {String} key 8 | * @return {String} 9 | */ 10 | export function readStorage(key) { 11 | const access = `${STORAGE_KEY}::${key}`; 12 | const value = STORAGE_OBJECT.getItem(access); 13 | return (value || ""); 14 | }; 15 | -------------------------------------------------------------------------------- /src/storage/write.js: -------------------------------------------------------------------------------- 1 | import { 2 | STORAGE_KEY, 3 | STORAGE_OBJECT 4 | } from "../cfg"; 5 | 6 | /** 7 | * @param {String} key 8 | * @param {String} value 9 | */ 10 | export function writeStorage(key, value) { 11 | const access = `${STORAGE_KEY}::${key}`; 12 | STORAGE_OBJECT.setItem(access, value); 13 | }; 14 | 15 | /** 16 | * @param {String} key 17 | * @param {String} value 18 | */ 19 | export function appendStorage(key, value) { 20 | const access = `${STORAGE_KEY}::${key}`; 21 | const base = this.readStorage(key); 22 | this.writeStorage(key, base + value); 23 | }; 24 | -------------------------------------------------------------------------------- /src/transform/flip.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @param {Layer} layer 3 | */ 4 | export function flipHorizontally(layer) { 5 | const batch = layer.batch; 6 | const data = batch.data; 7 | const ww = batch.bounds.w; 8 | const hh = batch.bounds.h; 9 | const pixels = new Uint8Array(data.length); 10 | for (let ii = 0; ii < data.length; ii += 4) { 11 | const idx = (ii / 4) | 0; 12 | const xx = (idx % ww) | 0; 13 | const yy = (idx / ww) | 0; 14 | const opx = 4 * (yy * ww + xx); 15 | const npx = 4 * ((ww - xx) + (yy * ww) - 1); 16 | pixels[opx + 0] = data[npx + 0]; 17 | pixels[opx + 1] = data[npx + 1]; 18 | pixels[opx + 2] = data[npx + 2]; 19 | pixels[opx + 3] = data[npx + 3]; 20 | }; 21 | batch.data = pixels; 22 | batch.refreshTexture(true); 23 | }; 24 | 25 | /** 26 | * @param {Layer} layer 27 | */ 28 | export function flipVertically(layer) { 29 | const batch = layer.batch; 30 | const data = batch.data; 31 | const ww = batch.bounds.w; 32 | const hh = batch.bounds.h; 33 | const pixels = new Uint8Array(data.length); 34 | for (let ii = 0; ii < data.length; ii += 4) { 35 | const idx = (ii / 4) | 0; 36 | const xx = (idx % ww) | 0; 37 | const yy = (idx / ww) | 0; 38 | const opx = 4 * (yy * ww + xx); 39 | const npx = 4 * (((hh - yy - 1) * ww) + xx); 40 | pixels[opx + 0] = data[npx + 0]; 41 | pixels[opx + 1] = data[npx + 1]; 42 | pixels[opx + 2] = data[npx + 2]; 43 | pixels[opx + 3] = data[npx + 3]; 44 | }; 45 | batch.data = pixels; 46 | batch.refreshTexture(true); 47 | }; 48 | -------------------------------------------------------------------------------- /src/transform/rotate.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @param {Layer} layer 3 | */ 4 | export function rotateRight(layer) { 5 | const main = layer.batch; 6 | const batch = layer.createBatchAt( 7 | main.bounds.x, main.bounds.y 8 | ); 9 | const data = main.data; 10 | const ww = main.bounds.w; 11 | const hh = main.bounds.h; 12 | const pixels = new Uint8Array(data.length); 13 | for (let ii = 0; ii < data.length; ii += 4) { 14 | const idx = (ii / 4) | 0; 15 | const xx = (idx % ww) | 0; 16 | const yy = (idx / ww) | 0; 17 | const opx = 4 * (yy * ww + xx); 18 | const npx = 4 * ((xx * ww) + (hh - yy - 1)); 19 | pixels[opx + 0] = data[npx + 0]; 20 | pixels[opx + 1] = data[npx + 1]; 21 | pixels[opx + 2] = data[npx + 2]; 22 | pixels[opx + 3] = data[npx + 3]; 23 | }; 24 | batch.data = pixels; 25 | batch.bounds.w = hh; 26 | batch.bounds.h = ww; 27 | batch.refreshTexture(true); 28 | main.refreshTexture(true); 29 | layer.updateBoundings(); 30 | layer.bounds.w = hh; 31 | layer.bounds.h = ww; 32 | main.bounds.w = hh; 33 | main.bounds.h = ww; 34 | main.data = pixels; 35 | this.updateGlobalBoundings(); 36 | }; 37 | 38 | export function rotateLeft() { 39 | 40 | }; 41 | -------------------------------------------------------------------------------- /src/ui/index.js: -------------------------------------------------------------------------------- 1 | import { 2 | hexToRgba, 3 | rgbaToHex, 4 | rgbaToBytes 5 | } from "../color"; 6 | 7 | import Layer from "../layer/index"; 8 | import Container from "../container/index"; 9 | import CommandKind from "../stack/kind"; 10 | 11 | export function resetModes() { 12 | for (let key in this.modes) { 13 | this.resetSelection(); 14 | if (this.modes[key] === true) { 15 | this.resetActiveCursor(key); 16 | } 17 | this.modes[key] = false; 18 | }; 19 | this.resetActiveUiButtons(); 20 | }; 21 | 22 | /** 23 | * @param {String} mode 24 | */ 25 | export function resetActiveCursor(mode) { 26 | const el = this.getCursorNodeByMode(mode); 27 | if (el !== null) { 28 | el.style.left = "0px"; 29 | el.style.top = "0px"; 30 | el.style.display = "none"; 31 | } 32 | }; 33 | 34 | /** 35 | * Returns the current active mode as a string 36 | * @return {String} 37 | */ 38 | export function getActiveMode() { 39 | for (let key in this.modes) { 40 | if (this.modes[key] === true) return (key); 41 | }; 42 | return (null); 43 | }; 44 | 45 | /** 46 | * @param {String} mode 47 | * @return {HTMLElement} 48 | */ 49 | export function getCursorNodeByMode(mode) { 50 | const value = "#c" + mode; 51 | const el = document.querySelector(value); 52 | return (el); 53 | }; 54 | 55 | /** 56 | * @param {Number} x 57 | * @param {Number} y 58 | * @return {Void} 59 | */ 60 | export function updateCursorPosition(x, y) { 61 | const mode = this.getActiveMode(); 62 | if (mode === null) return; 63 | const el = this.getCursorNodeByMode(mode); 64 | if (el === null) return; 65 | el.style.left = x + "px"; 66 | el.style.top = (y - 16) + "px"; 67 | el.style.display = "block"; 68 | return; 69 | }; 70 | 71 | export function resetActiveUiButtons() { 72 | arc.style.removeProperty("opacity"); 73 | move.style.removeProperty("opacity"); 74 | shape.style.removeProperty("opacity"); 75 | tiled.style.removeProperty("opacity"); 76 | erase.style.removeProperty("opacity"); 77 | bucket.style.removeProperty("opacity"); 78 | select.style.removeProperty("opacity"); 79 | stroke.style.removeProperty("opacity"); 80 | pipette.style.removeProperty("opacity"); 81 | lighting.style.removeProperty("opacity"); 82 | rectangle.style.removeProperty("opacity"); 83 | paint_all.style.removeProperty("opacity"); 84 | }; 85 | 86 | /** 87 | * @param {String} value 88 | * @return {Void} 89 | */ 90 | export function setUiColorByHex(value) { 91 | // close fast color picker menu 92 | if (this.states.fastColorMenu) { 93 | this.closeFastColorPickerMenu(); 94 | } 95 | color_hex.innerHTML = String(value).toUpperCase(); 96 | color_view.style.background = value; 97 | const rgba = hexToRgba(value); 98 | // prevent changing color if it didnt changed 99 | if ( 100 | this.fillStyle[0] === rgba[0] && 101 | this.fillStyle[1] === rgba[1] && 102 | this.fillStyle[2] === rgba[2] && 103 | this.fillStyle[3] === rgba[3] 104 | ) return; 105 | this.fillStyle = rgba; 106 | this.addCustomColor(rgba); 107 | return; 108 | }; 109 | 110 | /** 111 | * @param {Array} rgba 112 | * @return {Void} 113 | */ 114 | export function setUiColorByRgba(rgba) { 115 | const r = rgba[0]; 116 | const g = rgba[1]; 117 | const b = rgba[2]; 118 | const a = rgba[3]; 119 | color_view.style.background = `rgba(${r},${g},${b},${a})`; 120 | const hex = rgbaToHex(rgba); 121 | // prevent changing color if it didnt changed 122 | if ( 123 | this.fillStyle[0] === r && 124 | this.fillStyle[1] === g && 125 | this.fillStyle[2] === b && 126 | this.fillStyle[3] === a 127 | ) return; 128 | this.fillStyle = rgba; 129 | return; 130 | }; 131 | 132 | /** 133 | * @param {Array} rgba 134 | */ 135 | export function addCustomColor(rgba) { 136 | const colors = this.favoriteColors; 137 | let count = 0; 138 | for (let ii = 0; ii < colors.length; ++ii) { 139 | const color = colors[ii].color; 140 | const index = colors[ii].index; 141 | // color already saved, increase it's importance 142 | if ( 143 | color[0] === rgba[0] && 144 | color[1] === rgba[1] && 145 | color[2] === rgba[2] && 146 | color[3] === rgba[3] 147 | ) { 148 | colors[ii].index += 1; 149 | count++; 150 | //console.log("Found!", color, rgba); 151 | } 152 | }; 153 | // color isn't saved yet 154 | if (count <= 0) { 155 | // color limit exceeded, replace less used color with this one 156 | if (colors.length >= 16) { 157 | colors[colors.length - 1].color = color; 158 | colors[colors.length - 1].index += 1; 159 | } else { 160 | // we have to replace the less used color 161 | colors.push({ 162 | color: rgba, 163 | index: 0 164 | }); 165 | } 166 | } 167 | // resort descending by most used color 168 | colors.sort((a, b) => { return (b.index - a.index); }); 169 | // sync with storage 170 | this.writeStorage("favorite_colors", JSON.stringify(colors)); 171 | // sync color menu with favorite colors 172 | this.updateFastColorPickMenu(); 173 | }; 174 | 175 | export function closeFastColorPickerMenu() { 176 | menu.style.visibility = "hidden"; 177 | this.states.fastColorMenu = false; 178 | }; 179 | 180 | export function openFastColorPickerMenu() { 181 | menu.style.visibility = "visible"; 182 | this.states.fastColorMenu = true; 183 | }; 184 | 185 | export function updateFastColorPickMenu() { 186 | // first remove all color nodes 187 | for (let ii = 0; ii < 16; ++ii) { 188 | const node = colors.children[0]; 189 | if (!node) continue; 190 | node.parentNode.removeChild(node); 191 | }; 192 | // now re-insert the updated ones 193 | for (let ii = 0; ii < 16; ++ii) { 194 | const color = this.favoriteColors[ii]; 195 | const node = document.createElement("div"); 196 | if (color) { 197 | node.setAttribute("color", "[" + color.color + "]"); 198 | node.style.background = rgbaToHex(color.color); 199 | } else { 200 | node.setAttribute("color", "[0,0,0,1]"); 201 | node.style.background = "#000000"; 202 | } 203 | colors.appendChild(node); 204 | }; 205 | }; 206 | 207 | export function hoverLayer(e) { 208 | const el = e.target; 209 | const kind = el.classList.value; 210 | const parent = ( 211 | kind !== "layer-item" ? el.parentNode : el 212 | ); 213 | const layer = this.getLayerByNode(parent); 214 | if (layer === null) return; 215 | //const canvas = layer.toCanvas(); 216 | }; 217 | 218 | /** 219 | * @param {HTMLElement} e 220 | * @param {Boolean} dbl 221 | */ 222 | export function clickedLayer(e, dbl) { 223 | const el = e.target; 224 | const kind = el.classList.value; 225 | const parent = ( 226 | kind !== "layer-item" ? el.parentNode : el 227 | ); 228 | const layer = this.getLayerByNode(parent); 229 | if (layer === null) return; 230 | switch (kind) { 231 | // clicked on layer, set it active 232 | case "layer-text": 233 | if (!dbl) { 234 | this.setActiveLayer(layer); 235 | } 236 | else { 237 | el.removeAttribute("readonly"); 238 | el.focus(); 239 | el.onkeypress = (e) => { 240 | const code = (e.keyCode ? e.keyCode : e.which); 241 | if (code === 13) el.blur(); 242 | }; 243 | el.onblur = () => { 244 | const oname = layer.name; 245 | if (!el.value) { 246 | layer.name = oname; 247 | } else { 248 | if (String(oname) !== el.value) { 249 | this.enqueue(CommandKind.LAYER_RENAME, { 250 | oname, name: el.value, layer: layer 251 | }); 252 | } 253 | } 254 | el.setAttribute("readonly", "readonly"); 255 | }; 256 | } 257 | break; 258 | // clicked a layer icon 259 | case "layer-item-visible": 260 | this.enqueue(CommandKind.LAYER_VISIBILITY, { 261 | layer: layer 262 | }); 263 | break; 264 | // clicked lock icon 265 | case "layer-item-locked": 266 | this.enqueue(CommandKind.LAYER_LOCK, { 267 | layer: layer 268 | }); 269 | break; 270 | }; 271 | }; 272 | 273 | export function setupUi() { 274 | 275 | // ui 276 | tiled.onclick = (e) => { 277 | this.resetModes(); 278 | this.modes.draw = true; 279 | tiled.style.opacity = 1.0; 280 | }; 281 | erase.onclick = (e) => { 282 | this.resetModes(); 283 | this.modes.erase = true; 284 | erase.style.opacity = 1.0; 285 | }; 286 | bucket.onclick = (e) => { 287 | this.resetModes(); 288 | this.modes.fill = true; 289 | bucket.style.opacity = 1.0; 290 | }; 291 | pipette.onclick = (e) => { 292 | this.resetModes(); 293 | this.modes.pipette = true; 294 | pipette.style.opacity = 1.0; 295 | }; 296 | select.onclick = (e) => { 297 | this.resetModes(); 298 | this.modes.select = true; 299 | select.style.opacity = 1.0; 300 | }; 301 | stroke.onclick = (e) => { 302 | this.resetModes(); 303 | this.modes.stroke = true; 304 | stroke.style.opacity = 1.0; 305 | }; 306 | arc.onclick = (e) => { 307 | this.resetModes(); 308 | this.modes.arc = true; 309 | arc.style.opacity = 1.0; 310 | }; 311 | rectangle.onclick = (e) => { 312 | this.resetModes(); 313 | this.modes.rect = true; 314 | rectangle.style.opacity = 1.0; 315 | }; 316 | paint_all.onclick = (e) => { 317 | this.resetModes(); 318 | this.modes.flood = true; 319 | paint_all.style.opacity = 1.0; 320 | }; 321 | shape.onclick = (e) => { 322 | this.resetModes(); 323 | this.modes.shape = true; 324 | shape.style.opacity = 1.0; 325 | }; 326 | lighting.onclick = (e) => { 327 | this.resetModes(); 328 | this.modes.light = true; 329 | lighting.style.opacity = 1.0; 330 | }; 331 | move.onclick = (e) => { 332 | this.resetModes(); 333 | this.modes.move = true; 334 | move.style.opacity = 1.0; 335 | }; 336 | color.onchange = (e) => { 337 | this.setUiColorByHex(color.value); 338 | }; 339 | 340 | undo.onclick = (e) => { 341 | this.undo(); 342 | }; 343 | redo.onclick = (e) => { 344 | this.redo(); 345 | }; 346 | 347 | download.onclick = (e) => { 348 | const link = document.createElement("a"); 349 | const data = this.exportAsDataUrl(); 350 | link.href = data; 351 | link.download = 655321 + ".png"; 352 | link.click(); 353 | }; 354 | 355 | // ## drag&drop images 356 | file.onclick = (e) => { e.preventDefault(); }; 357 | file.onchange = (e) => { 358 | file.style.display = "none"; 359 | let reader = new FileReader(); 360 | reader.onload = (e) => { 361 | if (e.target.result.slice(11, 14) !== "png") { 362 | throw new Error("Invalid image type!"); 363 | } 364 | let img = new Image(); 365 | let canvas = document.createElement("canvas"); 366 | let ctx = canvas.getContext("2d"); 367 | img.onload = () => { 368 | canvas.width = img.width; 369 | canvas.height = img.height; 370 | ctx.drawImage( 371 | img, 372 | 0, 0, 373 | img.width, img.height, 374 | 0, 0, 375 | img.width, img.height 376 | ); 377 | this.insertImage(ctx, this.last.mx, this.last.my); 378 | file.value = ""; // reassign to allow second files 379 | }; 380 | img.src = e.target.result; 381 | }; 382 | reader.readAsDataURL(e.target.files[0]); 383 | }; 384 | // hidy things for drag & drop input 385 | this.view.addEventListener("dragenter", (e) => { 386 | file.style.display = "block"; 387 | }); 388 | file.addEventListener("dragleave", (e) => { 389 | file.style.display = "none"; 390 | }); 391 | 392 | layers.addEventListener("mouseover", (e) => this.hoverLayer(e)); 393 | layers.addEventListener("click", (e) => this.clickedLayer(e, false)); 394 | layers.addEventListener("dblclick", (e) => this.clickedLayer(e, true)); 395 | 396 | add_layer.onclick = (e) => { 397 | const layer = this.getCurrentLayer(); 398 | let index = layer ? layer.getIndex() : 0; 399 | index = index < 0 ? 0 : index; 400 | this.enqueue(CommandKind.LAYER_ADD, { 401 | layer: new Layer(this), index 402 | }); 403 | }; 404 | remove_layer.onclick = (e) => { 405 | const layer = this.getCurrentLayer(); 406 | let index = layer ? layer.getIndex() : 0; 407 | index = index < 0 ? 0 : index; 408 | if (layer !== null) this.enqueue(CommandKind.LAYER_REMOVE, { 409 | layer, index 410 | }); 411 | this.redraw = true; 412 | }; 413 | 414 | move_layer_up.onclick = (e) => { 415 | const layer = this.getCurrentLayer(); 416 | if (layer !== null && layer.getIndex() > 0) { 417 | this.enqueue(CommandKind.LAYER_ORDER, { 418 | layer, index: layer.getIndex() - 1, oindex: layer.getIndex() 419 | }); 420 | } 421 | this.redraw = true; 422 | }; 423 | move_layer_down.onclick = (e) => { 424 | const layer = this.getCurrentLayer(); 425 | if (layer !== null && layer.getIndex() < this.layers.length - 1) { 426 | this.enqueue(CommandKind.LAYER_ORDER, { 427 | layer, index: layer.getIndex() + 1, oindex: layer.getIndex() 428 | }); 429 | } 430 | this.redraw = true; 431 | }; 432 | 433 | clone.onclick = (e) => { 434 | const layer = this.getCurrentLayer(); 435 | if (layer !== null) { 436 | let index = layer ? layer.getIndex() : 0; 437 | index = index < 0 ? 0 : index; 438 | this.enqueue(CommandKind.LAYER_CLONE, { 439 | layer: layer.clone(), index 440 | }); 441 | } 442 | }; 443 | 444 | clone_by_ref.onclick = (e) => { 445 | const layer = this.getCurrentLayer(); 446 | if (layer !== null) { 447 | let index = layer ? layer.getIndex() : 0; 448 | index = index < 0 ? 0 : index; 449 | this.enqueue(CommandKind.LAYER_CLONE_REF, { 450 | layer: layer.cloneByReference(), index 451 | }); 452 | } 453 | }; 454 | 455 | flip_horizontal.onclick = (e) => { 456 | const layer = this.getCurrentLayer(); 457 | if (layer !== null && !layer.isEmpty()) { 458 | this.enqueue(CommandKind.LAYER_FLIP_HORIZONTAL, { layer }); 459 | } 460 | }; 461 | flip_vertical.onclick = (e) => { 462 | const layer = this.getCurrentLayer(); 463 | if (layer !== null && !layer.isEmpty()) { 464 | this.enqueue(CommandKind.LAYER_FLIP_VERTICAL, { layer }); 465 | } 466 | }; 467 | 468 | rotate_right.onclick = (e) => { 469 | const layer = this.getCurrentLayer(); 470 | if (layer !== null && !layer.isEmpty()) { 471 | this.enqueue(CommandKind.LAYER_ROTATE_RIGHT, { layer }); 472 | } 473 | }; 474 | rotate_left.onclick = (e) => { 475 | const layer = this.getCurrentLayer(); 476 | if (layer !== null && !layer.isEmpty()) { 477 | this.enqueue(CommandKind.LAYER_ROTATE_LEFT, { layer }); 478 | } 479 | }; 480 | 481 | merge.onclick = (e) => { 482 | const layer = this.getCurrentLayer(); 483 | if (layer !== null && this.layers.length > 1) { 484 | if (layer.getIndex() < this.layers.length - 1) { 485 | const merge = this.getLayerByIndex(layer.getIndex() + 1); 486 | const data = merge.mergeWithLayer(layer); 487 | this.enqueue(CommandKind.LAYER_MERGE, { data, layer, index: layer.getIndex() }); 488 | } 489 | } 490 | }; 491 | 492 | add_animation.onclick = (e) => { 493 | const container = new Container(this); 494 | const relative = this.getRelativeTileOffset(this.mx, this.my); 495 | container.bounds.x = relative.x; container.bounds.y = relative.y; 496 | this.enqueue(CommandKind.CONTAINER_ADD, { 497 | container: container 498 | }); 499 | }; 500 | 501 | const layer_opacity = (e) => { 502 | const opacity = 0.5; 503 | const oopacity = layer.opacity; 504 | if (oopacity !== opacity) { 505 | this.enqueue(CommandKind.LAYER_OPACITY, { 506 | oopacity, opacity, layer: layer 507 | }); 508 | } 509 | }; 510 | 511 | this.modes.draw = true; 512 | tiled.style.opacity = 1.0; 513 | 514 | // setup ui list button states 515 | /*this.processUIClick(document.querySelector("#light-size").children[0]); 516 | this.processUIClick(document.querySelector("#eraser-size").children[0]); 517 | this.processUIClick(document.querySelector("#pencil-size").children[0]);*/ 518 | 519 | }; 520 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | import { 2 | WGL_SUPPORTED, 3 | MAGIC_RGB_A_BYTE 4 | } from "./cfg"; 5 | 6 | /** 7 | * Returns unique integer 8 | * @return {Number} 9 | */ 10 | let uidx = 0; 11 | export function uid() { 12 | return (++uidx); 13 | }; 14 | 15 | /** 16 | * @param {Number} width 17 | * @param {Number} height 18 | * @return {CanvasRenderingContext2D} 19 | */ 20 | export function createCanvasBuffer(width, height) { 21 | const canvas = document.createElement("canvas"); 22 | canvas.width = width; 23 | canvas.height = height; 24 | const ctx = canvas.getContext("2d"); 25 | applyImageSmoothing(ctx, false); 26 | return (ctx); 27 | }; 28 | 29 | /** 30 | * @param {CanvasRenderingContext2D} ctx 31 | * @param {Boolean} state 32 | */ 33 | export function applyImageSmoothing(ctx, state) { 34 | ctx.imageSmoothingEnabled = state; 35 | ctx.oImageSmoothingEnabled = state; 36 | ctx.msImageSmoothingEnabled = state; 37 | ctx.webkitImageSmoothingEnabled = state; 38 | }; 39 | 40 | /** 41 | * @param {String} path 42 | * @param {Function} resolve 43 | */ 44 | export function loadImage(path, resolve) { 45 | const img = new Image(); 46 | img.addEventListener("load", () => { 47 | resolve(img); 48 | }); 49 | img.addEventListener("error", () => { 50 | throw new Error("Failed to load image ressource " + path); 51 | }); 52 | img.src = path; 53 | }; 54 | 55 | /** 56 | * Creates and returns an webgl context 57 | * @param {HTMLCanvasElement} canvas 58 | * @return {WebGLRenderingContext} 59 | */ 60 | export function getWGLContext(canvas) { 61 | if (!WGL_SUPPORTED) { 62 | throw new Error("Your browser doesn't support WebGL."); 63 | } 64 | const opts = { 65 | alpha: false, 66 | antialias: false, 67 | premultipliedAlpha: false, 68 | stencil: false, 69 | preserveDrawingBuffer: false 70 | }; 71 | return ( 72 | canvas.getContext("webgl", opts) || 73 | canvas.getContext("experimental-webgl", opts) 74 | ); 75 | }; 76 | --------------------------------------------------------------------------------